Browse Source

Merge branch 'next' into custom-payment-process

Michael Bromley 5 years ago
parent
commit
b56a4daa06
100 changed files with 2987 additions and 558 deletions
  1. 1 0
      .github/workflows/build_and_test.yml
  2. 23 0
      CHANGELOG.md
  3. 204 18
      docs/content/docs/developer-guide/customizing-models.md
  4. 1 1
      docs/content/docs/developer-guide/importing-product-data.md
  5. 5 1
      e2e-common/e2e-initial-data.ts
  6. 1 1
      lerna.json
  7. 3 3
      packages/admin-ui-plugin/package.json
  8. 28 28
      packages/admin-ui/i18n-coverage.json
  9. 2 2
      packages/admin-ui/package.json
  10. 12 11
      packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.ts
  11. 7 8
      packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.html
  12. 32 18
      packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.ts
  13. 2 2
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts
  14. 1 1
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts
  15. 2 2
      packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.html
  16. 2 2
      packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.html
  17. 5 10
      packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.ts
  18. 31 36
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html
  19. 68 81
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts
  20. 7 2
      packages/admin-ui/src/lib/core/src/common/base-list.component.ts
  21. 459 81
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  22. 5 1
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  23. 20 20
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  24. 2 0
      packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.ts
  25. 18 10
      packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.spec.ts
  26. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  27. 2 2
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html
  28. 45 8
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts
  29. 126 0
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  30. 52 7
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  31. 2 0
      packages/admin-ui/src/lib/core/src/data/definitions/shared-definitions.ts
  32. 8 3
      packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts
  33. 2 6
      packages/admin-ui/src/lib/core/src/data/providers/facet-data.service.ts
  34. 54 0
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  35. 18 0
      packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts
  36. 16 1
      packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts
  37. 9 5
      packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts
  38. 91 0
      packages/admin-ui/src/lib/core/src/data/utils/transform-relation-custom-field-inputs.spec.ts
  39. 47 0
      packages/admin-ui/src/lib/core/src/data/utils/transform-relation-custom-field-inputs.ts
  40. 8 2
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts
  41. 11 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  42. 2 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.html
  43. 18 14
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts
  44. 9 9
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.html
  45. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.scss
  46. 31 32
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts
  47. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.html
  48. 28 4
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.ts
  49. 20 2
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html
  50. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.scss
  51. 20 2
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts
  52. 35 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.html
  53. 26 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.scss
  54. 92 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.ts
  55. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.html
  56. 5 1
      packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.ts
  57. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.ts
  58. 9 5
      packages/admin-ui/src/lib/core/src/shared/components/currency-input/currency-input.component.ts
  59. 26 0
      packages/admin-ui/src/lib/core/src/shared/components/manage-tags-dialog/manage-tags-dialog.component.html
  60. 19 0
      packages/admin-ui/src/lib/core/src/shared/components/manage-tags-dialog/manage-tags-dialog.component.scss
  61. 60 0
      packages/admin-ui/src/lib/core/src/shared/components/manage-tags-dialog/manage-tags-dialog.component.ts
  62. 17 0
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.html
  63. 11 0
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.scss
  64. 61 0
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.ts
  65. 4 5
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  66. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts
  67. 23 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.html
  68. 14 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.scss
  69. 71 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts
  70. 30 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.html
  71. 0 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.scss
  72. 74 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.ts
  73. 37 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.html
  74. 3 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.scss
  75. 95 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.ts
  76. 28 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.html
  77. 3 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.scss
  78. 95 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.ts
  79. 31 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.html
  80. 23 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.scss
  81. 39 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.ts
  82. 29 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.html
  83. 5 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.scss
  84. 19 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.ts
  85. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.html
  86. 0 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.scss
  87. 15 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.ts
  88. 2 2
      packages/admin-ui/src/lib/core/src/shared/pipes/asset-preview.pipe.ts
  89. 31 1
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  90. 5 2
      packages/admin-ui/src/lib/order/src/components/fulfill-order-dialog/fulfill-order-dialog.component.ts
  91. 19 16
      packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.html
  92. 36 29
      packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.ts
  93. 11 0
      packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.html
  94. 24 1
      packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.ts
  95. 25 5
      packages/admin-ui/src/lib/settings/src/components/channel-detail/channel-detail.component.html
  96. 26 2
      packages/admin-ui/src/lib/settings/src/components/channel-detail/channel-detail.component.ts
  97. 80 25
      packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.html
  98. 143 23
      packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.ts
  99. 4 0
      packages/admin-ui/src/lib/settings/src/components/payment-method-list/payment-method-list.component.html
  100. 11 0
      packages/admin-ui/src/lib/settings/src/components/profile/profile.component.html

+ 1 - 0
.github/workflows/build_and_test.yml

@@ -7,6 +7,7 @@ on:
   pull_request:
     branches:
       - master
+      - next
 env:
   CI: true
   node: 12.x

+ 23 - 0
CHANGELOG.md

@@ -1,3 +1,26 @@
+## <small>0.18.3 (2021-01-29)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix filtering products by term in Channel ([d880f8e](https://github.com/vendure-ecommerce/vendure/commit/d880f8e))
+* **admin-ui** Fix role editor Channel value display  ([c258975](https://github.com/vendure-ecommerce/vendure/commit/c258975))
+* **admin-ui** Fix various issues with product variant management view ([d34f935](https://github.com/vendure-ecommerce/vendure/commit/d34f935)), closes [#602](https://github.com/vendure-ecommerce/vendure/issues/602)
+* **admin-ui** Translate missing Brazilian (PT-br) i18n json ([808d1fe](https://github.com/vendure-ecommerce/vendure/commit/808d1fe))
+* **core** Do not allow updating products not in active channel ([4b2fac7](https://github.com/vendure-ecommerce/vendure/commit/4b2fac7))
+* **core** Prevent multiple ProductVariantPrice creation ([c853033](https://github.com/vendure-ecommerce/vendure/commit/c853033)), closes [#652](https://github.com/vendure-ecommerce/vendure/issues/652)
+* **core** Re-calculate OrderItem price on all OrderLine changes ([0d8c485](https://github.com/vendure-ecommerce/vendure/commit/0d8c485)), closes [#660](https://github.com/vendure-ecommerce/vendure/issues/660)
+* **core** Update search index for all channels on updates ([85de520](https://github.com/vendure-ecommerce/vendure/commit/85de520)), closes [#629](https://github.com/vendure-ecommerce/vendure/issues/629)
+* **elasticsearch-plugin** Update search index for all channels on updates ([2be29c2](https://github.com/vendure-ecommerce/vendure/commit/2be29c2)), closes [#629](https://github.com/vendure-ecommerce/vendure/issues/629)
+
+#### Features
+
+* **admin-ui** Nav menu requirePermissions accepts predicate fn ([c74765d](https://github.com/vendure-ecommerce/vendure/commit/c74765d)), closes [#651](https://github.com/vendure-ecommerce/vendure/issues/651)
+* **admin-ui** Support "required" & "defaultValue" in ConfigArgs ([6e5e482](https://github.com/vendure-ecommerce/vendure/commit/6e5e482)), closes [#643](https://github.com/vendure-ecommerce/vendure/issues/643)
+* **core** Support "defaultValue" field in ConfigArgs ([92ae819](https://github.com/vendure-ecommerce/vendure/commit/92ae819)), closes [#643](https://github.com/vendure-ecommerce/vendure/issues/643)
+* **core** Support "required" field in ConfigArgs ([9940385](https://github.com/vendure-ecommerce/vendure/commit/9940385)), closes [#643](https://github.com/vendure-ecommerce/vendure/issues/643)
+* **elasticsearch-plugin** LanguageCode support in CustomMappings ([b114428](https://github.com/vendure-ecommerce/vendure/commit/b114428))
+
 ## <small>0.18.2 (2021-01-15)</small>
 
 

+ 204 - 18
docs/content/docs/developer-guide/customizing-models.md

@@ -5,32 +5,218 @@ showtoc: true
  
 # Customizing Models with custom fields
 
-Custom fields allow you to add your own custom data properties to many of the Vendure entities. The entities which may be have custom fields defined are listed in the [CustomFields documentation]({{< relref "/docs/typescript-api/custom-fields" >}})
+Custom fields allow you to add your own custom data properties to many of the Vendure entities. The entities which may have custom fields defined are listed in the [CustomFields documentation]({{< relref "/docs/typescript-api/custom-fields" >}}#signature)
 
 They are specified in the VendureConfig:
 
 ```TypeScript
 const config = {
-    // ...
-    dbConnectionOptions: {
-        // ...
-        synchronize: true,  
-    },
-    customFields: {
-        Product: [
-            { name: 'infoUrl', type: 'string' },
-            { name: 'downloadable', type: 'boolean' },
-            { name: 'shortName', type: 'localeString' },
-        ],
-        User: [
-            { name: 'socialLoginToken', type: 'string' },
-        ],
-    },
+  // ...
+  customFields: {
+    Product: [
+      { name: 'infoUrl', type: 'string' },
+      { name: 'downloadable', type: 'boolean' },
+      { name: 'shortName', type: 'localeString' },
+    ],
+    User: [
+      { name: 'socialLoginToken', type: 'string' },
+    ],
+  },
 }
 ```
 
 With the example config above, the following will occur:
 
-1. The database schema will be altered and a column will be added for each custom field. Note: this step requires the [TypeORM synchronize option](https://typeorm.io/#/connection-options/common-connection-options) to be set to `true` as above.
-2. The GraphQL APIs will be modified on bootstrap to add the custom fields to the `Product` and `User` types respectively.
+1. The database schema will be altered, and a column will be added for each custom field. **Note: changes to custom fields require a database migration**. See the [Migrations guide]({{< relref "migrations" >}}).
+2. The GraphQL APIs will be modified to add the custom fields to the `Product` and `User` types respectively.
 3. If you are using the [admin-ui-plugin]({{< relref "/docs/typescript-api/admin-ui-plugin" >}}), the Admin UI detail pages will now contain form inputs to allow the custom field data to be added or edited.
+
+The values of the custom fields can then be set and queried via the GraphQL APIs:
+
+```GraphQL
+mutation {
+  updateProduct(input: {
+    id: 1
+    customFields: {
+        infoUrl: "https://some-url.com",
+        downloadable: true,
+    }
+    translations: [
+      { languageCode: en, customFields: { shortName: "foo" } }  
+    ]
+  }) {
+    id
+    customFields {
+      infoUrl
+      downloadable
+      shortName
+    }
+  }
+}
+```
+
+## Examples
+
+### defaultValue & nullable
+
+A default value for the custom field may be specified, and also whether the field may be nullable (or empty). Any fields set to `nullable: false` should have a default value specified.
+
+```TypeScript
+Product: [
+  {
+    name: 'downloadable',
+    type: 'boolean',
+    defaultValue: false,
+    nullable: false,
+  },
+]
+```
+
+### Labels and descriptions
+
+Labels and descriptions can be specified to supply more information about the purpose of the custom field, and also to allow translations to be set. In the Admin UI, the `label` will be used in any forms featuring the custom field, and will fall back to `name` if no label is specified.
+
+```TypeScript
+Product: [
+  {
+    name: 'extendedDescription', 
+    type: 'localeString',
+    label: [
+      { languageCode: LanguageCode.en, value: 'Extended description' },
+      { languageCode: LanguageCode.de, value: 'Erweiterte Beschreibung' },
+    ],
+    description: [
+      { languageCode: LanguageCode.en, value: 'Technical specs, external links and images' },
+      { languageCode: LanguageCode.de, value: 'Technische Daten, externe Links und Bilder' },
+    ],
+    length: 65535,
+  },
+]
+```
+
+### Lists
+
+A custom field may hold an array of values by setting the `list` property to `true`:
+
+```TypeScript
+Product: [
+  {
+    name: 'keywords', 
+    type: 'localeString',
+    list: true,
+  },
+]
+```
+
+### Validation
+
+Certain custom field types may be configured with validation parameters:
+
+```TypeScript
+Product: [
+  {
+    name: 'partCode', 
+    type: 'string',
+    pattern: '^[0-9]{4}\-[A-Z]{3-4}$'
+  },
+  {
+    name: 'location', 
+    type: 'string', 
+    options: [
+      { value: 'warehouse1' },
+      { value: 'warehouse2' },
+      { value: 'shop' },
+    ]
+  },
+  {
+    name: 'weight', 
+    type: 'int', 
+    min: 0,
+    max: 9999,
+    step: 1,  
+  },
+]
+```
+
+In the above examples, attempting to set a custom field value which does not conform to the specified parameters (e.g. a `partCode` of `'121A'`) will throw an exception. In the Admin UI, these constraints will also be expressed in the form fields used to enter the data.
+
+For even more control over validation, a `validate` function may be provided to any field type, which will run whenever the value is set via the GraphQL API. This function can even be asynchronous and may use the [Injector]({{< relref "injector" >}}) to access providers. Returning a string or LocalizedString means validation failed.
+
+```TypeScript
+Product: [
+  {
+    name: 'partCode', 
+    type: 'string',
+    validate: async(value, injector) => {
+      const partCodeService = injector.get(PartCodeService);
+      const isValid = await partCodeService.validateCode(value);
+      if (!isValid) {
+        return `Part code ${value} is not valid`;
+      }
+    },
+  },
+]
+```
+
+### public, readonly & internal
+
+Some custom fields may be used internally in your business logic, or for integration with external systems. In this case the can restrict access to the information they contain. In this example, the Customer entity has an externalId relating to an external integration. 
+
+* `public: false` means that it will not be exposed via the Shop API.
+* `readonly: true` means it cannot be updated via the Admin API. It can only be changed programmatically in plugin code.
+* `internal: false` - means the field _will_ be exposed via the Admin API and will also appear (in read-only form) in the Admin UI. Internal custom fields are useful for purely internal implementation details.
+
+```TypeScript
+Customer: [
+  {
+    name: 'externalId',
+    type: 'string',
+    public: false,
+    readonly: true,
+    internal: false,
+  },
+]
+```
+
+### Relations
+
+It is possible to set up custom fields which hold references to other entities using the `'relation'` type:
+
+```TypeScript
+Customer: [
+  {
+    name: 'avatar',
+    type: 'relation',
+    entity: Asset,
+    // may be omitted if the entity name matches the GraphQL type name,
+    // which is true for all built-in entities.
+    graphQLType: 'Asset', 
+    // Whether to "eagerly" load the relation
+    // See https://typeorm.io/#/eager-and-lazy-relations
+    eager: false,
+  },
+]
+```
+
+In this example, we set up a many-to-one relationship from Customer to Asset, allowing us to specify an avatar image for each Customer. Relation custom fields are unique in that the input and output names are not the same - the input will expect an ID and will be named `'<field name>Id'` or `'<field name>Ids'` for list types.
+
+```GraphQL
+mutation {
+  updateCustomer(input: {
+    id: 1
+    customFields: {
+      avatarId: 42,
+    }
+  }) {
+    id
+    customFields {
+      avatar {
+        id
+        name
+        preview
+      }
+    }
+  }
+}
+```
+

+ 1 - 1
docs/content/docs/developer-guide/importing-product-data.md

@@ -102,7 +102,7 @@ export const initialData: InitialData = {
 * `defaultZone`: Sets the default shipping & tax zone for the default Channel. The zone must correspond to a value of `zone` set in the `countries` array. 
 * `taxRates`: For each item, a new [TaxCategory]({{< relref "tax-category" >}}) is created, and then a [TaxRate]({{< relref "tax-rate" >}}) is created for each unique zone defined in the `countries` array. 
 * `shippingMethods`: Allows simple flat-rate [ShippingMethods]({{< relref "shipping-method" >}}) to be defined.
-* `collections`: Allows Collections to be created. Currently only collections based on facet values can be created (`code: 'facet-value-filter'`). The `assetPaths` and `facetValueNames` value must correspond to a values specified in the products csv file.
+* `collections`: Allows Collections to be created. Currently, only collections based on facet values can be created (`code: 'facet-value-filter'`). The `assetPaths` and `facetValueNames` value must correspond to a values specified in the products csv file. The name should match the value specified in the product csv file (or can be a normalized - lower-case & hyphenated - version thereof). If there are FacetValues in multiple Facets with the same name, the facet may be specified with a colon delimiter, e.g. `brand:apple`, `flavour: apple`.
 
 ## Populating The Server
 

+ 5 - 1
e2e-common/e2e-initial-data.ts

@@ -9,7 +9,11 @@ export const initialData: InitialData = {
         { name: 'Reduced Tax', percentage: 10 },
         { name: 'Zero Tax', percentage: 0 },
     ],
-    shippingMethods: [{ name: 'Standard Shipping', price: 500 }, { name: 'Express Shipping', price: 1000 }],
+    shippingMethods: [
+        { name: 'Standard Shipping', price: 500 },
+        { name: 'Express Shipping', price: 1000 },
+    ],
+    paymentMethods: [],
     countries: [
         { name: 'Australia', code: 'AU', zone: 'Oceania' },
         { name: 'Austria', code: 'AT', zone: 'Europe' },

+ 1 - 1
lerna.json

@@ -2,7 +2,7 @@
   "packages": [
     "packages/*"
   ],
-  "version": "0.18.2",
+  "version": "0.18.3",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "command": {

+ 3 - 3
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -19,8 +19,8 @@
   "devDependencies": {
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.2",
+    "@vendure/common": "^0.18.3",
+    "@vendure/core": "^0.18.3",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"

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

@@ -1,51 +1,51 @@
 {
-  "generatedOn": "2021-01-12T10:57:57.374Z",
-  "lastCommit": "b2b37a8e8ec51354855e37ed9ed6f6be03518c15",
+  "generatedOn": "2021-02-01T20:16:12.347Z",
+  "lastCommit": "7eb21d16d0c17f87e9cc267a024b937c2e4deefa",
   "translationStatus": {
     "cs": {
-      "tokenCount": 752,
-      "translatedCount": 751,
-      "percentage": 100
+      "tokenCount": 768,
+      "translatedCount": 753,
+      "percentage": 98
     },
     "de": {
-      "tokenCount": 752,
-      "translatedCount": 596,
-      "percentage": 79
+      "tokenCount": 768,
+      "translatedCount": 594,
+      "percentage": 77
     },
     "en": {
-      "tokenCount": 752,
-      "translatedCount": 752,
+      "tokenCount": 768,
+      "translatedCount": 767,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 752,
-      "translatedCount": 458,
-      "percentage": 61
+      "tokenCount": 768,
+      "translatedCount": 456,
+      "percentage": 59
     },
     "fr": {
-      "tokenCount": 752,
-      "translatedCount": 692,
-      "percentage": 92
+      "tokenCount": 768,
+      "translatedCount": 690,
+      "percentage": 90
     },
     "pl": {
-      "tokenCount": 752,
-      "translatedCount": 551,
-      "percentage": 73
+      "tokenCount": 768,
+      "translatedCount": 549,
+      "percentage": 71
     },
     "pt_BR": {
-      "tokenCount": 752,
-      "translatedCount": 642,
-      "percentage": 85
+      "tokenCount": 768,
+      "translatedCount": 749,
+      "percentage": 98
     },
     "zh_Hans": {
-      "tokenCount": 752,
-      "translatedCount": 533,
-      "percentage": 71
+      "tokenCount": 768,
+      "translatedCount": 531,
+      "percentage": 69
     },
     "zh_Hant": {
-      "tokenCount": 752,
-      "translatedCount": 533,
-      "percentage": 71
+      "tokenCount": 768,
+      "translatedCount": 531,
+      "percentage": 69
     }
   }
 }

+ 2 - 2
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "license": "MIT",
   "scripts": {
     "ng": "ng",
@@ -36,7 +36,7 @@
     "@ng-select/ng-select": "^5.0.3",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/http-loader": "^6.0.0",
-    "@vendure/common": "^0.18.1",
+    "@vendure/common": "^0.18.3",
     "@webcomponents/custom-elements": "^1.2.4",
     "apollo-angular": "^2.0.4",
     "apollo-upload-client": "^12.1.0",

+ 12 - 11
packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.ts

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/
 import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { Asset, BaseDetailComponent, LanguageCode } from '@vendure/admin-ui/core';
+import { Asset, BaseDetailComponent, GetAsset, LanguageCode } from '@vendure/admin-ui/core';
 import { DataService, NotificationService, ServerConfigService } from '@vendure/admin-ui/core';
 
 @Component({
@@ -11,7 +11,7 @@ import { DataService, NotificationService, ServerConfigService } from '@vendure/
     styleUrls: ['./asset-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> implements OnInit, OnDestroy {
+export class AssetDetailComponent extends BaseDetailComponent<GetAsset.Asset> implements OnInit, OnDestroy {
     detailForm = new FormGroup({});
 
     constructor(
@@ -28,6 +28,7 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
     ngOnInit() {
         this.detailForm = new FormGroup({
             name: new FormControl(''),
+            tags: new FormControl([]),
         });
         this.init();
     }
@@ -36,11 +37,10 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
         this.destroy();
     }
 
-    onAssetChange(event: { id: string; name: string }) {
-        // tslint:disable-next-line:no-non-null-assertion
-        this.detailForm.get('name')!.setValue(event.name);
-        // tslint:disable-next-line:no-non-null-assertion
-        this.detailForm.get('name')!.markAsDirty();
+    onAssetChange(event: { id: string; name: string; tags: string[] }) {
+        this.detailForm.get('name')?.setValue(event.name);
+        this.detailForm.get('tags')?.setValue(event.tags);
+        this.detailForm.markAsDirty();
     }
 
     save() {
@@ -48,12 +48,13 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
             .updateAsset({
                 id: this.id,
                 name: this.detailForm.value.name,
+                tags: this.detailForm.value.tags,
             })
             .subscribe(
                 () => {
                     this.notificationService.success(_('common.notify-update-success'), { entity: 'Asset' });
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Asset',
                     });
@@ -61,8 +62,8 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
             );
     }
 
-    protected setFormValues(entity: Asset.Fragment, languageCode: LanguageCode): void {
-        // tslint:disable-next-line:no-non-null-assertion
-        this.detailForm.get('name')!.setValue(entity.name);
+    protected setFormValues(entity: GetAsset.Asset, languageCode: LanguageCode): void {
+        this.detailForm.get('name')?.setValue(entity.name);
+        this.detailForm.get('tags')?.setValue(entity.tags);
     }
 }

+ 7 - 8
packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.html

@@ -1,12 +1,11 @@
 <vdr-action-bar>
-    <vdr-ab-left>
-        <input
-            type="text"
-            name="searchTerm"
-            [formControl]="searchTerm"
-            [placeholder]="'asset.search-asset-name' | translate"
-            class="search-input ml3"
-        />
+    <vdr-ab-left [grow]="true">
+        <vdr-asset-search-input
+            class="pr4 mt1"
+            [tags]="allTags$ | async"
+            (searchTermChange)="searchTerm$.next($event)"
+            (tagsChange)="filterByTags$.next($event)"
+        ></vdr-asset-search-input>
     </vdr-ab-left>
     <vdr-ab-right>
         <vdr-action-bar-items locationId="asset-list"></vdr-action-bar-items>

+ 32 - 18
packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.ts

@@ -8,12 +8,14 @@ import {
     DataService,
     DeletionResult,
     GetAssetList,
+    LogicalOperator,
     ModalService,
     NotificationService,
+    TagFragment,
 } from '@vendure/admin-ui/core';
 import { SortOrder } from '@vendure/common/lib/generated-shop-types';
 import { PaginationInstance } from 'ngx-pagination';
-import { combineLatest, EMPTY, Observable } from 'rxjs';
+import { BehaviorSubject, combineLatest, EMPTY, Observable } from 'rxjs';
 import { debounceTime, finalize, map, switchMap, takeUntil } from 'rxjs/operators';
 
 @Component({
@@ -22,10 +24,12 @@ import { debounceTime, finalize, map, switchMap, takeUntil } from 'rxjs/operator
     styleUrls: ['./asset-list.component.scss'],
 })
 export class AssetListComponent
-    extends BaseListComponent<GetAssetList.Query, GetAssetList.Items>
+    extends BaseListComponent<GetAssetList.Query, GetAssetList.Items, GetAssetList.Variables>
     implements OnInit {
-    searchTerm = new FormControl('');
+    searchTerm$ = new BehaviorSubject<string | undefined>(undefined);
+    filterByTags$ = new BehaviorSubject<TagFragment[] | undefined>(undefined);
     uploading = false;
+    allTags$: Observable<TagFragment[]>;
     paginationConfig$: Observable<PaginationInstance>;
 
     constructor(
@@ -39,20 +43,29 @@ export class AssetListComponent
         super.setQueryFn(
             (...args: any[]) => this.dataService.product.getAssetList(...args),
             data => data.assets,
-            (skip, take) => ({
-                options: {
-                    skip,
-                    take,
-                    filter: {
-                        name: {
-                            contains: this.searchTerm.value,
+            (skip, take) => {
+                const searchTerm = this.searchTerm$.value;
+                const tags = this.filterByTags$.value?.map(t => t.value);
+                return {
+                    options: {
+                        skip,
+                        take,
+                        ...(searchTerm
+                            ? {
+                                  filter: {
+                                      name: { contains: searchTerm },
+                                  },
+                              }
+                            : {}),
+                        sort: {
+                            createdAt: SortOrder.DESC,
                         },
+                        tags,
+                        tagsOperator: LogicalOperator.AND,
                     },
-                    sort: {
-                        createdAt: SortOrder.DESC,
-                    },
-                },
-            }),
+                };
+            },
+            { take: 25, skip: 0 },
         );
     }
 
@@ -61,9 +74,10 @@ export class AssetListComponent
         this.paginationConfig$ = combineLatest(this.itemsPerPage$, this.currentPage$, this.totalItems$).pipe(
             map(([itemsPerPage, currentPage, totalItems]) => ({ itemsPerPage, currentPage, totalItems })),
         );
-        this.searchTerm.valueChanges
-            .pipe(debounceTime(250), takeUntil(this.destroy$))
-            .subscribe(() => this.refresh());
+        this.searchTerm$.pipe(debounceTime(250), takeUntil(this.destroy$)).subscribe(() => this.refresh());
+
+        this.filterByTags$.pipe(takeUntil(this.destroy$)).subscribe(() => this.refresh());
+        this.allTags$ = this.dataService.product.getTagList().mapStream(data => data.tags.items);
     }
 
     filesSelected(files: File[]) {

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts

@@ -133,7 +133,7 @@ export class FacetDetailComponent
                     ) as CreateFacetInput;
                     return this.dataService.facet.createFacet(newFacet);
                 }),
-                switchMap(data => this.dataService.facet.getAllFacets(true).single$.pipe(mapTo(data))),
+                switchMap(data => this.dataService.facet.getAllFacets().single$.pipe(mapTo(data))),
             )
             .subscribe(
                 data => {
@@ -196,7 +196,7 @@ export class FacetDetailComponent
 
                     return forkJoin(updateOperations);
                 }),
-                switchMap(() => this.dataService.facet.getAllFacets(true).single$),
+                switchMap(() => this.dataService.facet.getAllFacets().single$),
             )
             .subscribe(
                 () => {

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts

@@ -52,7 +52,7 @@ export class FacetListComponent extends BaseListComponent<GetFacetList.Query, Ge
                     }
                 }),
                 // Refresh the cached facets to reflect the changes
-                switchMap(() => this.dataService.facet.getAllFacets(true).single$),
+                switchMap(() => this.dataService.facet.getAllFacets().single$),
             )
             .subscribe(
                 () => {

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.html

@@ -6,7 +6,7 @@
                 [src]="featuredAsset | assetPreview:'small'"
                 (click)="previewAsset(featuredAsset)"
             />
-            <div class="placeholder" *ngIf="!featuredAsset">
+            <div class="placeholder" *ngIf="!featuredAsset" (click)="selectAssets()">
                 <clr-icon shape="image" size="128"></clr-icon>
                 <div>{{ 'catalog.no-featured-asset' | translate }}</div>
             </div>
@@ -29,7 +29,7 @@
             (click)="previewAsset(featuredAsset)"
         />
 
-        <div class="placeholder" *ngIf="!featuredAsset"><clr-icon shape="image" size="150"></clr-icon></div>
+        <div class="placeholder" *ngIf="!featuredAsset" (click)="selectAssets()"><clr-icon shape="image" size="150"></clr-icon></div>
     </div>
     <ng-container *ngTemplateOutlet="assetList"></ng-container>
     <button

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.html

@@ -1,5 +1,5 @@
 <ng-select
-    [addTag]="true"
+    [addTag]="addTagFn"
     [placeholder]="'catalog.search-product-name-or-code' | translate"
     [items]="facetValueResults"
     [searchFn]="filterFacetResults"
@@ -20,7 +20,7 @@
         </div>
     </ng-template>
     <ng-template ng-label-tmp let-item="item" let-clear="clear">
-        <ng-container *ngIf="item.facetValue">
+        <ng-container *ngIf="item.value">
             <vdr-facet-value-chip
                 [facetValue]="item.facetValue"
                 [removable]="true"

+ 5 - 10
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.ts

@@ -1,8 +1,7 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
 import { NgSelectComponent, SELECTION_MODEL_FACTORY } from '@ng-select/ng-select';
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-
 import { SearchProducts } from '@vendure/admin-ui/core';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
 import { ProductSearchSelectionModelFactory } from './product-search-selection-model';
 
@@ -69,14 +68,6 @@ export class ProductSearchInputComponent {
         );
     };
 
-    groupByFacet = (item: SearchProducts.FacetValues | { label: string }) => {
-        if (this.isFacetValueItem(item)) {
-            return item.facetValue.facet.name;
-        } else {
-            return '';
-        }
-    };
-
     onSelectChange(selectedItems: Array<SearchProducts.FacetValues | { label: string }>) {
         if (!Array.isArray(selectedItems)) {
             selectedItems = [selectedItems];
@@ -98,6 +89,10 @@ export class ProductSearchInputComponent {
         }
     }
 
+    addTagFn(item: any) {
+        return { label: item };
+    }
+
     isSearchHeaderSelected(): boolean {
         return this.selectComponent.itemsList.markedIndex === -1;
     }

+ 31 - 36
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html

@@ -13,12 +13,7 @@
 <div *ngFor="let group of optionGroups" class="option-groups">
     <div class="name">
         <label>{{ 'catalog.option' | translate }}</label>
-        <input
-            clrInput
-            [(ngModel)]="group.name"
-            name="name"
-            [readonly]="!group.isNew"
-        />
+        <input clrInput [(ngModel)]="group.name" name="name" [readonly]="!group.isNew" />
     </div>
     <div class="values">
         <label>{{ 'catalog.option-values' | translate }}</label>
@@ -31,7 +26,11 @@
         ></vdr-option-value-input>
     </div>
 </div>
-<button class="btn btn-primary-outline btn-sm" (click)="addOption()" *ngIf="product.variants.length === 1">
+<button
+    class="btn btn-primary-outline btn-sm"
+    (click)="addOption()"
+    *ngIf="product?.variants.length === 1 && product?.optionGroups.length === 0"
+>
     <clr-icon shape="plus"></clr-icon>
     {{ 'catalog.add-option' | translate }}
 </button>
@@ -39,74 +38,71 @@
 <div class="variants-preview">
     <table class="table">
         <thead>
-        <tr>
-            <th>{{ 'common.create' | translate }}</th>
-            <th>{{ 'catalog.variant' | translate }}</th>
-            <th>{{ 'catalog.sku' | translate }}</th>
-            <th>{{ 'catalog.price' | translate }}</th>
-            <th>{{ 'catalog.stock-on-hand' | translate }}</th>
-            <th></th>
-        </tr>
+            <tr>
+                <th>{{ 'common.create' | translate }}</th>
+                <th>{{ 'catalog.variant' | translate }}</th>
+                <th>{{ 'catalog.sku' | translate }}</th>
+                <th>{{ 'catalog.price' | translate }}</th>
+                <th>{{ 'catalog.stock-on-hand' | translate }}</th>
+                <th></th>
+            </tr>
         </thead>
-        <tr
-            *ngFor="let variant of variants"
-            [class.disabled]="!variantFormValues[variant.id].enabled || variantFormValues[variant.id].existing"
-        >
+        <tr *ngFor="let variant of generatedVariants" [class.disabled]="!variant.enabled || variant.existing">
             <td>
                 <input
                     type="checkbox"
-                    *ngIf="!variantFormValues[variant.id].existing"
-                    [(ngModel)]="variantFormValues[variant.id].enabled"
+                    *ngIf="!variant.existing"
+                    [(ngModel)]="variant.enabled"
                     name="enabled"
                     clrCheckbox
                     (ngModelChange)="formValueChanged = true"
                 />
             </td>
             <td>
-                {{ getVariantName(variant) }}
+                {{ getVariantName(variant) | translate }}
             </td>
             <td>
-                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                <clr-input-container *ngIf="!variant.existing">
                     <input
                         clrInput
                         type="text"
-                        [(ngModel)]="variantFormValues[variant.id].sku"
+                        [(ngModel)]="variant.sku"
                         [placeholder]="'catalog.sku' | translate"
                         name="sku"
                         required
-                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                        (ngModelChange)="onFormChanged(variant)"
                     />
                 </clr-input-container>
-                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].sku }}</span>
+                <span *ngIf="variant.existing">{{ variant.sku }}</span>
             </td>
             <td>
-                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                <clr-input-container *ngIf="!variant.existing">
                     <vdr-currency-input
                         clrInput
-                        [(ngModel)]="variantFormValues[variant.id].price"
+                        [(ngModel)]="variant.price"
                         name="price"
                         [currencyCode]="currencyCode"
-                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                        (ngModelChange)="onFormChanged(variant)"
                     ></vdr-currency-input>
                 </clr-input-container>
-                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].price | localeCurrency: currencyCode }}</span>
+                <span *ngIf="variant.existing">{{ variant.price | localeCurrency: currencyCode }}</span>
             </td>
             <td>
-                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                <clr-input-container *ngIf="!variant.existing">
                     <input
                         clrInput
                         type="number"
-                        [(ngModel)]="variantFormValues[variant.id].stock"
+                        [(ngModel)]="variant.stock"
                         name="stock"
                         min="0"
                         step="1"
-                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                        (ngModelChange)="onFormChanged(variant)"
                     />
                 </clr-input-container>
-                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].stock }}</span>
+                <span *ngIf="variant.existing">{{ variant.stock }}</span>
             </td>
             <td>
-                <vdr-dropdown *ngIf="variantFormValues[variant.id].productVariantId as productVariantId">
+                <vdr-dropdown *ngIf="variant.productVariantId as productVariantId">
                     <button class="icon-button" vdrDropdownTrigger>
                         <clr-icon shape="ellipsis-vertical"></clr-icon>
                     </button>
@@ -121,7 +117,6 @@
                             {{ 'common.delete' | translate }}
                         </button>
                     </vdr-dropdown-menu>
-
                 </vdr-dropdown>
             </td>
         </tr>

+ 68 - 81
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts

@@ -15,30 +15,30 @@ import {
     ProductOptionGroupWithOptionsFragment,
 } from '@vendure/admin-ui/core';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
+import { pick } from '@vendure/common/lib/pick';
 import { generateAllCombinations, notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { EMPTY, forkJoin, Observable, of } from 'rxjs';
-import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
+import { filter, map, mergeMap, switchMap } from 'rxjs/operators';
 
 import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
 
-export interface VariantInfo {
+export class GeneratedVariant {
+    isDefault: boolean;
+    options: Array<{ name: string; id?: string }>;
     productVariantId?: string;
     enabled: boolean;
     existing: boolean;
-    options: string[];
     sku: string;
     price: number;
     stock: number;
-}
 
-export interface GeneratedVariant {
-    isDefault: boolean;
-    id: string;
-    options: Array<{ name: string; id?: string }>;
+    constructor(config: Partial<GeneratedVariant>) {
+        for (const key of Object.keys(config)) {
+            this[key] = config[key];
+        }
+    }
 }
 
-const DEFAULT_VARIANT_CODE = '__DEFAULT_VARIANT__';
-
 @Component({
     selector: 'vdr-product-variants-editor',
     templateUrl: './product-variants-editor.component.html',
@@ -47,7 +47,7 @@ const DEFAULT_VARIANT_CODE = '__DEFAULT_VARIANT__';
 })
 export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     formValueChanged = false;
-    variants: GeneratedVariant[] = [];
+    generatedVariants: GeneratedVariant[] = [];
     optionGroups: Array<{
         id?: string;
         isNew: boolean;
@@ -58,7 +58,6 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             locked: boolean;
         }>;
     }>;
-    variantFormValues: { [id: string]: VariantInfo } = {};
     product: GetProductVariantOptions.Product;
     currencyCode: CurrencyCode;
     private languageCode: LanguageCode;
@@ -80,7 +79,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         });
     }
 
-    onFormChanged(variantInfo: VariantInfo) {
+    onFormChanged(variantInfo: GeneratedVariant) {
         this.formValueChanged = true;
         variantInfo.enabled = true;
     }
@@ -90,7 +89,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
 
     getVariantsToAdd() {
-        return Object.values(this.variantFormValues).filter(v => !v.existing && v.enabled);
+        return this.generatedVariants.filter(v => !v.existing && v.enabled);
     }
 
     getVariantName(variant: GeneratedVariant) {
@@ -109,28 +108,32 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
 
     generateVariants() {
         const groups = this.optionGroups.map(g => g.values);
-        const previousVariants = this.variants;
-        this.variants = groups.length
-            ? generateAllCombinations(groups).map((options, i) => ({
-                  isDefault: this.product.variants.length === 1 && i === 0,
-                  id: this.generateOptionsId(options),
-                  options,
-              }))
-            : [{ isDefault: true, id: DEFAULT_VARIANT_CODE, options: [] }];
-
-        this.variants.forEach(variant => {
-            if (!this.variantFormValues[variant.id]) {
-                const prototype = this.getVariantPrototype(variant, previousVariants);
-                this.variantFormValues[variant.id] = {
-                    enabled: false,
-                    existing: false,
-                    options: variant.options.map(o => o.name),
-                    price: prototype.price,
-                    sku: prototype.sku,
-                    stock: prototype.stock,
-                };
-            }
-        });
+        const previousVariants = this.generatedVariants;
+        const generatedVariantFactory = (
+            isDefault: boolean,
+            options: GeneratedVariant['options'],
+            existingVariant?: GetProductVariantOptions.Variants,
+        ): GeneratedVariant => {
+            const prototype = this.getVariantPrototype(options, previousVariants);
+            return new GeneratedVariant({
+                enabled: false,
+                existing: !!existingVariant,
+                productVariantId: existingVariant?.id,
+                isDefault,
+                options,
+                price: existingVariant?.price ?? prototype.price,
+                sku: existingVariant?.sku ?? prototype.sku,
+                stock: existingVariant?.stockOnHand ?? prototype.stock,
+            });
+        };
+        this.generatedVariants = groups.length
+            ? generateAllCombinations(groups).map(options => {
+                  const existingVariant = this.product.variants.find(v =>
+                      this.optionsAreEqual(v.options, options),
+                  );
+                  return generatedVariantFactory(false, options, existingVariant);
+              })
+            : [generatedVariantFactory(true, [], this.product.variants[0])];
     }
 
     /**
@@ -138,17 +141,14 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
      * details off.
      */
     private getVariantPrototype(
-        variant: GeneratedVariant,
+        options: GeneratedVariant['options'],
         previousVariants: GeneratedVariant[],
-    ): Pick<VariantInfo, 'sku' | 'price' | 'stock'> {
-        if (variant.isDefault) {
-            return this.variantFormValues[DEFAULT_VARIANT_CODE];
-        }
+    ): Pick<GeneratedVariant, 'sku' | 'price' | 'stock'> {
         const variantsWithSimilarOptions = previousVariants.filter(v =>
-            variant.options.map(o => o.name).filter(name => v.options.map(o => o.name).includes(name)),
+            options.map(o => o.name).filter(name => v.options.map(o => o.name).includes(name)),
         );
         if (variantsWithSimilarOptions.length) {
-            return this.variantFormValues[this.generateOptionsId(variantsWithSimilarOptions[0].options)];
+            return pick(previousVariants[0], ['sku', 'price', 'stock']);
         }
         return {
             sku: '',
@@ -219,7 +219,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
 
     private confirmDeletionOfDefault(): Observable<boolean> {
-        if (this.product.variants.length === 1) {
+        if (this.hasOnlyDefaultVariant(this.product)) {
             return this.modalService
                 .dialog({
                     title: _('catalog.confirm-adding-options-delete-default-title'),
@@ -239,6 +239,10 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         }
     }
 
+    private hasOnlyDefaultVariant(product: GetProductVariantOptions.Product): boolean {
+        return product.variants.length === 1 && product.optionGroups.length === 0;
+    }
+
     private addOptionGroupsToProduct(
         createdOptionGroups: CreateProductOptionGroup.CreateProductOptionGroup[],
     ): Observable<CreateProductOptionGroup.CreateProductOptionGroup[]> {
@@ -306,14 +310,14 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             .filter(notNullOrUndefined)
             .map(og => og.options)
             .reduce((flat, o) => [...flat, ...o], []);
-        const variants = Object.values(this.variantFormValues)
+        const variants = this.generatedVariants
             .filter(v => v.enabled && !v.existing)
             .map(v => ({
                 price: v.price,
                 sku: v.sku,
                 stock: v.stock,
                 optionIds: v.options
-                    .map(name => options.find(o => o.name === name))
+                    .map(name => options.find(o => o.name === name.name))
                     .filter(notNullOrUndefined)
                     .map(o => o.id),
             }));
@@ -326,7 +330,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
 
     private deleteDefaultVariant<T>(input: T): Observable<T> {
-        if (this.product.variants.length === 1) {
+        if (this.hasOnlyDefaultVariant(this.product)) {
             // If the default single product variant has been replaced by multiple variants,
             // delete the original default variant.
             return this.dataService.product
@@ -347,15 +351,15 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         }
     }
 
-    private initOptionsAndVariants() {
-        this.route.data
-            .pipe(
-                switchMap(data => data.entity as Observable<GetProductVariantOptions.Product>),
-                take(1),
-            )
-            .subscribe(product => {
-                this.product = product;
-                this.optionGroups = product.optionGroups.map(og => {
+    initOptionsAndVariants() {
+        this.dataService.product
+            // tslint:disable-next-line:no-non-null-assertion
+            .getProductVariantsOptions(this.route.snapshot.paramMap.get('id')!)
+            // tslint:disable-next-line:no-non-null-assertion
+            .mapSingle(({ product }) => product!)
+            .subscribe(p => {
+                this.product = p;
+                this.optionGroups = p.optionGroups.map(og => {
                     return {
                         id: og.id,
                         isNew: false,
@@ -367,35 +371,18 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
                         })),
                     };
                 });
-                this.variantFormValues = this.getExistingVariants(product.variants);
                 this.generateVariants();
             });
     }
 
-    private getExistingVariants(
-        variants: GetProductVariantOptions.Variants[],
-    ): { [id: string]: VariantInfo } {
-        return variants.reduce((all, v) => {
-            const id = v.options.length ? this.generateOptionsId(v.options) : DEFAULT_VARIANT_CODE;
-            return {
-                ...all,
-                [id]: {
-                    productVariantId: v.id,
-                    enabled: true,
-                    existing: true,
-                    options: v.options.map(o => o.name),
-                    sku: v.sku,
-                    price: v.price,
-                    stock: v.stockOnHand,
-                },
-            };
-        }, {});
-    }
+    private optionsAreEqual(a: Array<{ name: string }>, b: Array<{ name: string }>): boolean {
+        function toOptionString(o: Array<{ name: string }>) {
+            return o
+                .map(x => x.name)
+                .sort()
+                .join('|');
+        }
 
-    private generateOptionsId(options: GeneratedVariant['options']): string {
-        return options
-            .map(o => o.name)
-            .sort()
-            .join('|');
+        return toOptionString(a) === toOptionString(b);
     }
 }

+ 7 - 2
packages/admin-ui/src/lib/core/src/common/base-list.component.ts

@@ -28,6 +28,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
     private onPageChangeFn: OnPageChangeFn<VariableType> = (skip, take) =>
         ({ options: { skip, take } } as any);
     private refresh$ = new BehaviorSubject<undefined>(undefined);
+    private defaults: { take: number; skip: number } = { take: 10, skip: 0 };
 
     constructor(protected router: Router, protected route: ActivatedRoute) {}
 
@@ -38,12 +39,16 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
         listQueryFn: ListQueryFn<ResultType>,
         mappingFn: MappingFn<ItemType, ResultType>,
         onPageChangeFn?: OnPageChangeFn<VariableType>,
+        defaults?: { take: number; skip: number },
     ) {
         this.listQueryFn = listQueryFn;
         this.mappingFn = mappingFn;
         if (onPageChangeFn) {
             this.onPageChangeFn = onPageChangeFn;
         }
+        if (defaults) {
+            this.defaults = defaults;
+        }
     }
 
     ngOnInit() {
@@ -52,7 +57,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
                 `No listQueryFn has been defined. Please call super.setQueryFn() in the constructor.`,
             );
         }
-        this.listQuery = this.listQueryFn(10, 0);
+        this.listQuery = this.listQueryFn(this.defaults.take, this.defaults.skip);
 
         const fetchPage = ([currentPage, itemsPerPage, _]: [number, number, undefined]) => {
             const take = itemsPerPage;
@@ -70,7 +75,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
         );
         this.itemsPerPage$ = this.route.queryParamMap.pipe(
             map(qpm => qpm.get('perPage')),
-            map(perPage => (!perPage ? 10 : +perPage)),
+            map(perPage => (!perPage ? this.defaults.take : +perPage)),
             distinctUntilChanged(),
         );
 

File diff suppressed because it is too large
+ 459 - 81
packages/admin-ui/src/lib/core/src/common/generated-types.ts


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

@@ -86,10 +86,12 @@ const result: PossibleTypesResultData = {
             'PromotionList',
             'RoleList',
             'ShippingMethodList',
+            'TagList',
             'TaxRateList',
         ],
         Node: [
             'Administrator',
+            'Asset',
             'Collection',
             'Customer',
             'Facet',
@@ -109,7 +111,6 @@ const result: PossibleTypesResultData = {
             'Return',
             'Release',
             'Address',
-            'Asset',
             'Channel',
             'Country',
             'CustomerGroup',
@@ -123,6 +124,7 @@ const result: PossibleTypesResultData = {
             'Promotion',
             'Role',
             'ShippingMethod',
+            'Tag',
             'TaxCategory',
             'TaxRate',
             'User',
@@ -171,6 +173,7 @@ const result: PossibleTypesResultData = {
             'FloatCustomFieldConfig',
             'BooleanCustomFieldConfig',
             'DateTimeCustomFieldConfig',
+            'RelationCustomFieldConfig',
         ],
         CustomFieldConfig: [
             'StringCustomFieldConfig',
@@ -179,6 +182,7 @@ const result: PossibleTypesResultData = {
             'FloatCustomFieldConfig',
             'BooleanCustomFieldConfig',
             'DateTimeCustomFieldConfig',
+            'RelationCustomFieldConfig',
         ],
         SearchResultPrice: ['PriceRange', 'SinglePrice'],
     },

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

@@ -77,28 +77,28 @@ export function toConfigurableOperationInput(
     };
 }
 
+export function configurableOperationValueIsValid(
+    def?: ConfigurableOperationDefinition,
+    value?: { code: string; args: { [key: string]: string } },
+) {
+    if (!def || !value) {
+        return false;
+    }
+    if (def.code !== value.code) {
+        return false;
+    }
+    for (const argDef of def.args) {
+        const argVal = value.args[argDef.name];
+        if (argDef.required && (argVal == null || argVal === '' || argVal === '0')) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /**
  * Returns a default value based on the type of the config arg.
  */
 export function getDefaultConfigArgValue(arg: ConfigArgDefinition): any {
-    return arg.list ? [] : getDefaultConfigArgSingleValue(arg.type as ConfigArgType);
-}
-
-export function getDefaultConfigArgSingleValue(type: ConfigArgType | CustomFieldType): any {
-    switch (type) {
-        case 'boolean':
-            return 'false';
-        case 'int':
-        case 'float':
-            return '0';
-        case 'ID':
-            return '';
-        case 'string':
-        case 'localeString':
-            return '';
-        case 'datetime':
-            return new Date();
-        default:
-            assertNever(type);
-    }
+    return arg.list ? [] : arg.defaultValue ?? null;
 }

+ 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);
     }

+ 18 - 10
packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.spec.ts

@@ -5,7 +5,7 @@ import { interpolateDescription } from './interpolate-description';
 describe('interpolateDescription()', () => {
     it('works for single argument', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string', list: false }],
+            args: [{ name: 'foo', type: 'string', list: false, required: false }],
             description: 'The value is { foo }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val' });
@@ -16,8 +16,8 @@ describe('interpolateDescription()', () => {
     it('works for multiple arguments', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
             args: [
-                { name: 'foo', type: 'string', list: false },
-                { name: 'bar', type: 'string', list: false },
+                { name: 'foo', type: 'string', list: false, required: false },
+                { name: 'bar', type: 'string', list: false, required: false },
             ],
             description: 'The value is { foo } and { bar }',
         };
@@ -28,7 +28,7 @@ describe('interpolateDescription()', () => {
 
     it('is case-insensitive', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string', list: false }],
+            args: [{ name: 'foo', type: 'string', list: false, required: false }],
             description: 'The value is { FOo }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val' });
@@ -39,8 +39,8 @@ describe('interpolateDescription()', () => {
     it('ignores whitespaces in interpolation', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
             args: [
-                { name: 'foo', type: 'string', list: false },
-                { name: 'bar', type: 'string', list: false },
+                { name: 'foo', type: 'string', list: false, required: false },
+                { name: 'bar', type: 'string', list: false, required: false },
             ],
             description: 'The value is {foo} and {      bar    }',
         };
@@ -51,7 +51,15 @@ describe('interpolateDescription()', () => {
 
     it('formats currency-form-input value as a decimal', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'price', type: 'int', list: false, ui: { component: 'currency-form-input' } }],
+            args: [
+                {
+                    name: 'price',
+                    type: 'int',
+                    list: false,
+                    ui: { component: 'currency-form-input' },
+                    required: false,
+                },
+            ],
             description: 'The price is { price }',
         };
         const result = interpolateDescription(operation as any, { price: 1234 });
@@ -61,7 +69,7 @@ describe('interpolateDescription()', () => {
 
     it('formats Date object as human-readable', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'date', type: 'datetime', list: false }],
+            args: [{ name: 'date', type: 'datetime', list: false, required: false }],
             description: 'The date is { date }',
         };
         const date = new Date('2017-09-15 00:00:00');
@@ -72,7 +80,7 @@ describe('interpolateDescription()', () => {
 
     it('formats date string object as human-readable', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'date', type: 'datetime', list: false }],
+            args: [{ name: 'date', type: 'datetime', list: false, required: false }],
             description: 'The date is { date }',
         };
         const date = '2017-09-15';
@@ -83,7 +91,7 @@ describe('interpolateDescription()', () => {
 
     it('correctly interprets falsy-looking values', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'int', list: false }],
+            args: [{ name: 'foo', type: 'int', list: false, required: false }],
             description: 'The value is { foo }',
         };
         const result = interpolateDescription(operation as any, { foo: 0 });

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/version.ts

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '0.18.2';
+export const ADMIN_UI_VERSION = '0.18.3';

+ 2 - 2
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html

@@ -5,7 +5,7 @@
                 class="nav-group"
                 [attr.data-section-id]="section.id"
                 [class.collapsible]="section.collapsible"
-                *vdrIfPermissions="section.requiresPermission"
+                *ngIf="shouldDisplayLink(section)"
             >
                 <ng-container *ngIf="navBuilderService.sectionBadges[section.id] | async as sectionBadge">
                     <div *ngIf="sectionBadge !== 'none'" class="status-badge" [class]="sectionBadge"></div>
@@ -14,7 +14,7 @@
                 <label [for]="section.id">{{ section.label | translate }}</label>
                 <ul class="nav-list">
                     <ng-container *ngFor="let item of section.items">
-                        <li *vdrIfPermissions="item.requiresPermission">
+                        <li *ngIf="shouldDisplayLink(item)">
                             <a
                                 class="nav-link"
                                 [attr.data-item-id]="section.id"

+ 45 - 8
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts

@@ -1,8 +1,10 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, OnDestroy, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { Subscription } from 'rxjs';
 import { map, startWith } from 'rxjs/operators';
 
+import { DataService } from '../../data/providers/data.service';
 import { HealthCheckService } from '../../providers/health-check/health-check.service';
 import { JobQueueService } from '../../providers/job-queue/job-queue.service';
 import { NavMenuBadge, NavMenuItem } from '../../providers/nav-builder/nav-builder-types';
@@ -13,16 +15,55 @@ import { NavBuilderService } from '../../providers/nav-builder/nav-builder.servi
     templateUrl: './main-nav.component.html',
     styleUrls: ['./main-nav.component.scss'],
 })
-export class MainNavComponent implements OnInit {
+export class MainNavComponent implements OnInit, OnDestroy {
     constructor(
         private route: ActivatedRoute,
         private router: Router,
         public navBuilderService: NavBuilderService,
         private healthCheckService: HealthCheckService,
         private jobQueueService: JobQueueService,
+        private dataService: DataService,
     ) {}
 
+    private userPermissions: string[];
+    private subscription: Subscription;
+
+    shouldDisplayLink(menuItem: Pick<NavMenuItem, 'requiresPermission'>) {
+        if (!this.userPermissions) {
+            return false;
+        }
+        if (!menuItem.requiresPermission) {
+            return true;
+        }
+        if (typeof menuItem.requiresPermission === 'string') {
+            return this.userPermissions.includes(menuItem.requiresPermission);
+        }
+        if (typeof menuItem.requiresPermission === 'function') {
+            return menuItem.requiresPermission(this.userPermissions);
+        }
+    }
+
     ngOnInit(): void {
+        this.defineNavMenu();
+        this.subscription = this.dataService.client
+            .userStatus()
+            .mapStream(({ userStatus }) => {
+                this.userPermissions = userStatus.permissions;
+            })
+            .subscribe();
+    }
+
+    ngOnDestroy() {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+
+    getRouterLink(item: NavMenuItem) {
+        return this.navBuilderService.getRouterLink(item, this.route);
+    }
+
+    private defineNavMenu() {
         this.navBuilderService.defineNavMenuSections([
             {
                 requiresPermission: 'ReadCatalog',
@@ -186,7 +227,7 @@ export class MainNavComponent implements OnInit {
                         statusBadge: this.jobQueueService.activeJobs$.pipe(
                             startWith([]),
                             map(
-                                (jobs) =>
+                                jobs =>
                                     ({
                                         type: jobs.length === 0 ? 'none' : 'info',
                                         propagateToSection: jobs.length > 0,
@@ -200,7 +241,7 @@ export class MainNavComponent implements OnInit {
                         routerLink: ['/system', 'system-status'],
                         icon: 'rack-server',
                         statusBadge: this.healthCheckService.status$.pipe(
-                            map((status) => ({
+                            map(status => ({
                                 type: status === 'ok' ? 'success' : 'error',
                                 propagateToSection: status === 'error',
                             })),
@@ -210,8 +251,4 @@ export class MainNavComponent implements OnInit {
             },
         ]);
     }
-
-    getRouterLink(item: NavMenuItem) {
-        return this.navBuilderService.getRouterLink(item, this.route);
-    }
 }

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

@@ -22,6 +22,13 @@ export const ASSET_FRAGMENT = gql`
     }
 `;
 
+export const TAG_FRAGMENT = gql`
+    fragment Tag on Tag {
+        id
+        value
+    }
+`;
+
 export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
     fragment ProductOptionGroup on ProductOptionGroup {
         id
@@ -314,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) {
@@ -363,41 +383,59 @@ export const GET_ASSET_LIST = gql`
         assets(options: $options) {
             items {
                 ...Asset
+                tags {
+                    ...Tag
+                }
             }
             totalItems
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const GET_ASSET = gql`
     query GetAsset($id: ID!) {
         asset(id: $id) {
             ...Asset
+            tags {
+                ...Tag
+            }
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const CREATE_ASSETS = gql`
     mutation CreateAssets($input: [CreateAssetInput!]!) {
         createAssets(input: $input) {
             ...Asset
+            ... on Asset {
+                tags {
+                    ...Tag
+                }
+            }
             ... on ErrorResult {
                 message
             }
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const UPDATE_ASSET = gql`
     mutation UpdateAsset($input: UpdateAssetInput!) {
         updateAsset(input: $input) {
             ...Asset
+            tags {
+                ...Tag
+            }
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const DELETE_ASSETS = gql`
@@ -599,6 +637,14 @@ export const GET_PRODUCT_VARIANT = gql`
             id
             name
             sku
+            featuredAsset {
+                id
+                preview
+                focalPoint {
+                    x
+                    y
+                }
+            }
             product {
                 id
                 featuredAsset {
@@ -613,3 +659,83 @@ 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) {
+            items {
+                ...Tag
+            }
+            totalItems
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const GET_TAG = gql`
+    query GetTag($id: ID!) {
+        tag(id: $id) {
+            ...Tag
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const CREATE_TAG = gql`
+    mutation CreateTag($input: CreateTagInput!) {
+        createTag(input: $input) {
+            ...Tag
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const UPDATE_TAG = gql`
+    mutation UpdateTag($input: UpdateTagInput!) {
+        updateTag(input: $input) {
+            ...Tag
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const DELETE_TAG = gql`
+    mutation DeleteTag($id: ID!) {
+        deleteTag(id: $id) {
+            message
+            result
+        }
+    }
+`;

+ 52 - 7
packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts

@@ -1,6 +1,10 @@
 import { gql } from 'apollo-angular';
 
-import { CONFIGURABLE_OPERATION_DEF_FRAGMENT, ERROR_RESULT_FRAGMENT } from './shared-definitions';
+import {
+    CONFIGURABLE_OPERATION_DEF_FRAGMENT,
+    CONFIGURABLE_OPERATION_FRAGMENT,
+    ERROR_RESULT_FRAGMENT,
+} from './shared-definitions';
 
 export const COUNTRY_FRAGMENT = gql`
     fragment Country on Country {
@@ -171,6 +175,7 @@ export const TAX_CATEGORY_FRAGMENT = gql`
         createdAt
         updatedAt
         name
+        isDefault
     }
 `;
 
@@ -374,17 +379,18 @@ export const PAYMENT_METHOD_FRAGMENT = gql`
         id
         createdAt
         updatedAt
+        name
         code
+        description
         enabled
-        configArgs {
-            name
-            value
+        checker {
+            ...ConfigurableOperation
         }
-        definition {
-            ...ConfigurableOperationDef
+        handler {
+            ...ConfigurableOperation
         }
     }
-    ${CONFIGURABLE_OPERATION_DEF_FRAGMENT}
+    ${CONFIGURABLE_OPERATION_FRAGMENT}
 `;
 
 export const GET_PAYMENT_METHOD_LIST = gql`
@@ -399,6 +405,18 @@ export const GET_PAYMENT_METHOD_LIST = gql`
     ${PAYMENT_METHOD_FRAGMENT}
 `;
 
+export const GET_PAYMENT_METHOD_OPERATIONS = gql`
+    query GetPaymentMethodOperations {
+        paymentMethodEligibilityCheckers {
+            ...ConfigurableOperationDef
+        }
+        paymentMethodHandlers {
+            ...ConfigurableOperationDef
+        }
+    }
+    ${CONFIGURABLE_OPERATION_DEF_FRAGMENT}
+`;
+
 export const GET_PAYMENT_METHOD = gql`
     query GetPaymentMethod($id: ID!) {
         paymentMethod(id: $id) {
@@ -408,6 +426,15 @@ export const GET_PAYMENT_METHOD = gql`
     ${PAYMENT_METHOD_FRAGMENT}
 `;
 
+export const CREATE_PAYMENT_METHOD = gql`
+    mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) {
+        createPaymentMethod(input: $input) {
+            ...PaymentMethod
+        }
+    }
+    ${PAYMENT_METHOD_FRAGMENT}
+`;
+
 export const UPDATE_PAYMENT_METHOD = gql`
     mutation UpdatePaymentMethod($input: UpdatePaymentMethodInput!) {
         updatePaymentMethod(input: $input) {
@@ -527,6 +554,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 +583,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 +593,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`
@@ -576,6 +615,12 @@ export const GET_SERVER_CONFIG = gql`
                     Address {
                         ...CustomFields
                     }
+                    Administrator {
+                        ...CustomFields
+                    }
+                    Channel {
+                        ...CustomFields
+                    }
                     Collection {
                         ...CustomFields
                     }

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

@@ -15,6 +15,8 @@ export const CONFIGURABLE_OPERATION_DEF_FRAGMENT = gql`
         args {
             name
             type
+            required
+            defaultValue
             list
             ui
             label

+ 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;

+ 2 - 6
packages/admin-ui/src/lib/core/src/data/providers/facet-data.service.ts

@@ -39,12 +39,8 @@ export class FacetDataService {
         });
     }
 
-    getAllFacets(refresh: boolean = false) {
-        return this.baseDataService.query<GetFacetList.Query, GetFacetList.Variables>(
-            GET_FACET_LIST,
-            {},
-            refresh ? 'network-only' : 'cache-first',
-        );
+    getAllFacets() {
+        return this.baseDataService.query<GetFacetList.Query, GetFacetList.Variables>(GET_FACET_LIST, {});
     }
 
     getFacet(id: string) {

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

@@ -15,19 +15,27 @@ import {
     CreateProductOptionInput,
     CreateProductVariantInput,
     CreateProductVariants,
+    CreateTag,
+    CreateTagInput,
     DeleteAssets,
     DeleteProduct,
     DeleteProductVariant,
+    DeleteTag,
     GetAsset,
     GetAssetList,
     GetProductList,
     GetProductOptionGroup,
     GetProductOptionGroups,
+    GetProductSimple,
     GetProductVariant,
+    GetProductVariantList,
     GetProductVariantOptions,
     GetProductWithVariants,
+    GetTag,
+    GetTagList,
     ProductListOptions,
     ProductSelectorSearch,
+    ProductVariantListOptions,
     Reindex,
     RemoveOptionGroupFromProduct,
     RemoveProductsFromChannel,
@@ -36,6 +44,7 @@ import {
     RemoveVariantsFromChannel,
     SearchProducts,
     SortOrder,
+    TagListOptions,
     UpdateAsset,
     UpdateAssetInput,
     UpdateProduct,
@@ -44,6 +53,8 @@ import {
     UpdateProductOptionInput,
     UpdateProductVariantInput,
     UpdateProductVariants,
+    UpdateTag,
+    UpdateTagInput,
 } from '../../common/generated-types';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
@@ -54,17 +65,23 @@ import {
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
     CREATE_PRODUCT_VARIANTS,
+    CREATE_TAG,
     DELETE_ASSETS,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
+    DELETE_TAG,
     GET_ASSET,
     GET_ASSET_LIST,
     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,
+    GET_TAG_LIST,
     PRODUCT_SELECTOR_SEARCH,
     REMOVE_OPTION_GROUP_FROM_PRODUCT,
     REMOVE_PRODUCTS_FROM_CHANNEL,
@@ -74,6 +91,7 @@ import {
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_OPTION,
     UPDATE_PRODUCT_VARIANTS,
+    UPDATE_TAG,
 } from '../definitions/product-definitions';
 import { REINDEX } from '../definitions/settings-definitions';
 
@@ -122,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,
@@ -354,4 +388,24 @@ export class ProductDataService {
             input,
         });
     }
+
+    getTag(id: string) {
+        return this.baseDataService.query<GetTag.Query, GetTag.Variables>(GET_TAG, { id });
+    }
+
+    getTagList(options?: TagListOptions) {
+        return this.baseDataService.query<GetTagList.Query, GetTagList.Variables>(GET_TAG_LIST, { options });
+    }
+
+    createTag(input: CreateTagInput) {
+        return this.baseDataService.mutate<CreateTag.Mutation, CreateTag.Variables>(CREATE_TAG, { input });
+    }
+
+    updateTag(input: UpdateTagInput) {
+        return this.baseDataService.mutate<UpdateTag.Mutation, UpdateTag.Variables>(UPDATE_TAG, { input });
+    }
+
+    deleteTag(id: string) {
+        return this.baseDataService.mutate<DeleteTag.Mutation, DeleteTag.Variables>(DELETE_TAG, { id });
+    }
 }

+ 18 - 0
packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts

@@ -8,6 +8,8 @@ import {
     CreateChannelInput,
     CreateCountry,
     CreateCountryInput,
+    CreatePaymentMethod,
+    CreatePaymentMethodInput,
     CreateTaxCategory,
     CreateTaxCategoryInput,
     CreateTaxRate,
@@ -32,6 +34,7 @@ import {
     GetJobsById,
     GetPaymentMethod,
     GetPaymentMethodList,
+    GetPaymentMethodOperations,
     GetTaxCategories,
     GetTaxCategory,
     GetTaxRate,
@@ -61,6 +64,7 @@ import {
     CANCEL_JOB,
     CREATE_CHANNEL,
     CREATE_COUNTRY,
+    CREATE_PAYMENT_METHOD,
     CREATE_TAX_CATEGORY,
     CREATE_TAX_RATE,
     CREATE_ZONE,
@@ -82,6 +86,7 @@ import {
     GET_JOB_QUEUE_LIST,
     GET_PAYMENT_METHOD,
     GET_PAYMENT_METHOD_LIST,
+    GET_PAYMENT_METHOD_OPERATIONS,
     GET_TAX_CATEGORIES,
     GET_TAX_CATEGORY,
     GET_TAX_RATE,
@@ -317,6 +322,15 @@ export class SettingsDataService {
         );
     }
 
+    createPaymentMethod(input: CreatePaymentMethodInput) {
+        return this.baseDataService.mutate<CreatePaymentMethod.Mutation, CreatePaymentMethod.Variables>(
+            CREATE_PAYMENT_METHOD,
+            {
+                input,
+            },
+        );
+    }
+
     updatePaymentMethod(input: UpdatePaymentMethodInput) {
         return this.baseDataService.mutate<UpdatePaymentMethod.Mutation, UpdatePaymentMethod.Variables>(
             UPDATE_PAYMENT_METHOD,
@@ -326,6 +340,10 @@ export class SettingsDataService {
         );
     }
 
+    getPaymentMethodOperations() {
+        return this.baseDataService.query<GetPaymentMethodOperations.Query>(GET_PAYMENT_METHOD_OPERATIONS);
+    }
+
     getGlobalSettings(fetchPolicy?: WatchQueryFetchPolicy) {
         return this.baseDataService.query<GetGlobalSettings.Query>(
             GET_GLOBAL_SETTINGS,

+ 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;
                     }),
                 },

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

@@ -17,6 +17,11 @@ export function isEntityCreateOrUpdateMutation(documentNode: DocumentNode): stri
             const namedType = extractInputType(variableDef.type);
             const inputTypeName = namedType.name.value;
 
+            // special cases which don't follow the usual pattern
+            if (inputTypeName === 'UpdateActiveAdministratorInput') {
+                return 'Administrator';
+            }
+
             const createMatch = inputTypeName.match(CREATE_ENTITY_REGEX);
             if (createMatch) {
                 return createMatch[1];
@@ -43,15 +48,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';
+
+/**
+ * Transforms any custom field "relation" type inputs into the corresponding `<name>Id` format,
+ * as expected by the server.
+ */
+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');
+}

+ 8 - 2
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts

@@ -30,7 +30,10 @@ export interface NavMenuItem {
     routerLink: RouterLinkDefinition;
     onClick?: (event: MouseEvent) => void;
     icon?: string;
-    requiresPermission?: string;
+    /**
+     * Control the display of this item based on the user permissions.
+     */
+    requiresPermission?: string | ((userPermissions: string[]) => boolean);
     statusBadge?: Observable<NavMenuBadge>;
 }
 
@@ -42,7 +45,10 @@ export interface NavMenuSection {
     id: string;
     label: string;
     items: NavMenuItem[];
-    requiresPermission?: string;
+    /**
+     * Control the display of this item based on the user permissions.
+     */
+    requiresPermission?: string | ((userPermissions: string[]) => boolean);
     collapsible?: boolean;
     collapsedByDefault?: boolean;
 }

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

@@ -67,6 +67,7 @@ export * from './data/server-config';
 export * from './data/utils/add-custom-fields';
 export * from './data/utils/get-server-location';
 export * from './data/utils/remove-readonly-custom-fields';
+export * from './data/utils/transform-relation-custom-field-inputs';
 export * from './providers/auth/auth.service';
 export * from './providers/component-registry/component-registry.service';
 export * from './providers/custom-field-component/custom-field-component.service';
@@ -95,6 +96,7 @@ export * from './shared/components/asset-gallery/asset-gallery.component';
 export * from './shared/components/asset-picker-dialog/asset-picker-dialog.component';
 export * from './shared/components/asset-preview/asset-preview.component';
 export * from './shared/components/asset-preview-dialog/asset-preview-dialog.component';
+export * from './shared/components/asset-search-input/asset-search-input.component';
 export * from './shared/components/channel-assignment-control/channel-assignment-control.component';
 export * from './shared/components/channel-badge/channel-badge.component';
 export * from './shared/components/chip/chip.component';
@@ -131,6 +133,7 @@ export * from './shared/components/history-entry-detail/history-entry-detail.com
 export * from './shared/components/items-per-page-controls/items-per-page-controls.component';
 export * from './shared/components/labeled-data/labeled-data.component';
 export * from './shared/components/language-selector/language-selector.component';
+export * from './shared/components/manage-tags-dialog/manage-tags-dialog.component';
 export * from './shared/components/modal-dialog/dialog-buttons.directive';
 export * from './shared/components/modal-dialog/dialog-component-outlet.component';
 export * from './shared/components/modal-dialog/dialog-title.directive';
@@ -155,6 +158,7 @@ export * from './shared/components/rich-text-editor/rich-text-editor.component';
 export * from './shared/components/select-toggle/select-toggle.component';
 export * from './shared/components/simple-dialog/simple-dialog.component';
 export * from './shared/components/table-row-action/table-row-action.component';
+export * from './shared/components/tag-selector/tag-selector.component';
 export * from './shared/components/timeline-entry/timeline-entry.component';
 export * from './shared/components/title-input/title-input.component';
 export * from './shared/directives/disabled.directive';
@@ -172,6 +176,13 @@ export * from './shared/dynamic-form-inputs/number-form-input/number-form-input.
 export * from './shared/dynamic-form-inputs/password-form-input/password-form-input.component';
 export * from './shared/dynamic-form-inputs/product-selector-form-input/product-selector-form-input.component';
 export * from './shared/dynamic-form-inputs/register-dynamic-input-components';
+export * from './shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component';
+export * from './shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component';
+export * from './shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component';
+export * from './shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component';
+export * from './shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component';
+export * from './shared/dynamic-form-inputs/relation-form-input/relation-form-input.component';
+export * from './shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component';
 export * from './shared/dynamic-form-inputs/select-form-input/select-form-input.component';
 export * from './shared/dynamic-form-inputs/text-form-input/text-form-input.component';
 export * from './shared/pipes/asset-preview.pipe';

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.html

@@ -35,7 +35,9 @@
         <div class="card-block details" *ngIf="selection.length >= 1">
             <div class="name">{{ lastSelected().name }}</div>
             <div>{{ 'asset.original-asset-size' | translate }}: {{ lastSelected().fileSize | filesize }}</div>
+
             <ng-container *ngIf="selection.length === 1">
+                <vdr-chip *ngFor="let tag of lastSelected().tags" [colorFrom]="tag.value"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag.value }}</vdr-chip>
                 <div>
                     <button (click)="previewAsset(lastSelected())" class="btn btn-link">
                         <clr-icon shape="eye"></clr-icon> {{ 'asset.preview' | translate }}

+ 18 - 14
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts

@@ -1,9 +1,11 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
 
-import { Asset } from '../../../common/generated-types';
+import { Asset, GetAssetList } from '../../../common/generated-types';
 import { ModalService } from '../../../providers/modal/modal.service';
 import { AssetPreviewDialogComponent } from '../asset-preview-dialog/asset-preview-dialog.component';
 
+export type AssetLike = GetAssetList.Items;
+
 @Component({
     selector: 'vdr-asset-gallery',
     templateUrl: './asset-gallery.component.html',
@@ -11,16 +13,16 @@ import { AssetPreviewDialogComponent } from '../asset-preview-dialog/asset-previ
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AssetGalleryComponent implements OnChanges {
-    @Input() assets: Asset[];
+    @Input() assets: AssetLike[];
     /**
      * If true, allows multiple assets to be selected by ctrl+clicking.
      */
     @Input() multiSelect = false;
     @Input() canDelete = false;
-    @Output() selectionChange = new EventEmitter<Asset[]>();
-    @Output() deleteAssets = new EventEmitter<Asset[]>();
+    @Output() selectionChange = new EventEmitter<AssetLike[]>();
+    @Output() deleteAssets = new EventEmitter<AssetLike[]>();
 
-    selection: Asset[] = [];
+    selection: AssetLike[] = [];
 
     constructor(private modalService: ModalService) {}
 
@@ -28,7 +30,7 @@ export class AssetGalleryComponent implements OnChanges {
         if (this.assets) {
             for (const asset of this.selection) {
                 // Update and selected assets with any changes
-                const match = this.assets.find((a) => a.id === asset.id);
+                const match = this.assets.find(a => a.id === asset.id);
                 if (match) {
                     Object.assign(asset, match);
                 }
@@ -37,15 +39,15 @@ export class AssetGalleryComponent implements OnChanges {
     }
 
     toggleSelection(event: MouseEvent, asset: Asset) {
-        const index = this.selection.findIndex((a) => a.id === asset.id);
+        const index = this.selection.findIndex(a => a.id === asset.id);
         if (this.multiSelect && event.shiftKey && 1 <= this.selection.length) {
             const lastSelection = this.selection[this.selection.length - 1];
-            const lastSelectionIndex = this.assets.findIndex((a) => a.id === lastSelection.id);
-            const currentIndex = this.assets.findIndex((a) => a.id === asset.id);
+            const lastSelectionIndex = this.assets.findIndex(a => a.id === lastSelection.id);
+            const currentIndex = this.assets.findIndex(a => a.id === asset.id);
             const start = currentIndex < lastSelectionIndex ? currentIndex : lastSelectionIndex;
             const end = currentIndex > lastSelectionIndex ? currentIndex + 1 : lastSelectionIndex;
             this.selection.push(
-                ...this.assets.slice(start, end).filter((a) => !this.selection.find((s) => s.id === a.id)),
+                ...this.assets.slice(start, end).filter(a => !this.selection.find(s => s.id === a.id)),
             );
         } else if (index === -1) {
             if (this.multiSelect && (event.ctrlKey || event.shiftKey)) {
@@ -62,18 +64,20 @@ export class AssetGalleryComponent implements OnChanges {
                 this.selection.splice(index, 1);
             }
         }
+        // Make the selection mutable
+        this.selection = this.selection.map(x => ({ ...x }));
         this.selectionChange.emit(this.selection);
     }
 
-    isSelected(asset: Asset): boolean {
-        return !!this.selection.find((a) => a.id === asset.id);
+    isSelected(asset: AssetLike): boolean {
+        return !!this.selection.find(a => a.id === asset.id);
     }
 
-    lastSelected(): Asset {
+    lastSelected(): AssetLike {
         return this.selection[this.selection.length - 1];
     }
 
-    previewAsset(asset: Asset) {
+    previewAsset(asset: AssetLike) {
         this.modalService
             .fromComponent(AssetPreviewDialogComponent, {
                 size: 'xl',

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

@@ -1,6 +1,7 @@
 <ng-template vdrDialogTitle>
     <div class="title-row">
-        {{ 'asset.select-assets' | translate }}
+        <span>{{ 'asset.select-assets' | translate }}</span>
+        <div class="flex-spacer"></div>
         <vdr-asset-file-input
             class="ml3"
             (selectFiles)="createAssets($event)"
@@ -9,16 +10,15 @@
         ></vdr-asset-file-input>
     </div>
 </ng-template>
-<input
-    type="text"
-    name="searchTerm"
-    [formControl]="searchTerm"
-    [placeholder]="'asset.search-asset-name' | translate"
-    class="search-input"
-/>
+<vdr-asset-search-input
+    class="mb2"
+    [tags]="allTags$ | async"
+    (searchTermChange)="searchTerm$.next($event)"
+    (tagsChange)="filterByTags$.next($event)"
+></vdr-asset-search-input>
 <vdr-asset-gallery
     [assets]="(assets$ | async)! | paginate: paginationConfig"
-    [multiSelect]="true"
+    [multiSelect]="multiSelect"
     (selectionChange)="selection = $event"
 ></vdr-asset-gallery>
 

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

@@ -2,7 +2,7 @@
 :host {
     display: flex;
     flex-direction: column;
-    height: 90vh;
+    height: 70vh;
 }
 
 .title-row {

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

@@ -1,11 +1,16 @@
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
-import { FormControl } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { PaginationInstance } from 'ngx-pagination';
-import { Observable, Subject } from 'rxjs';
+import { BehaviorSubject, Observable, Subject } from 'rxjs';
 import { debounceTime, finalize, map, takeUntil, tap } from 'rxjs/operators';
 
-import { Asset, GetAssetList, SortOrder } from '../../../common/generated-types';
+import {
+    Asset,
+    GetAssetList,
+    LogicalOperator,
+    SortOrder,
+    TagFragment,
+} from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
 import { QueryResult } from '../../../data/query-result';
 import { Dialog } from '../../../providers/modal/modal.service';
@@ -22,15 +27,19 @@ import { NotificationService } from '../../../providers/notification/notificatio
 })
 export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Asset[]> {
     assets$: Observable<GetAssetList.Items[]>;
+    allTags$: Observable<TagFragment[]>;
     paginationConfig: PaginationInstance = {
         currentPage: 1,
         itemsPerPage: 25,
         totalItems: 1,
     };
 
+    multiSelect = true;
+
     resolveWith: (result?: Asset[]) => void;
     selection: Asset[] = [];
-    searchTerm = new FormControl('');
+    searchTerm$ = new BehaviorSubject<string | undefined>(undefined);
+    filterByTags$ = new BehaviorSubject<TagFragment[] | undefined>(undefined);
     uploading = false;
     private listQuery: QueryResult<GetAssetList.Query, GetAssetList.Variables>;
     private destroy$ = new Subject<void>();
@@ -39,19 +48,17 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
 
     ngOnInit() {
         this.listQuery = this.dataService.product.getAssetList(this.paginationConfig.itemsPerPage, 0);
+        this.allTags$ = this.dataService.product.getTagList().mapSingle(data => data.tags.items);
         this.assets$ = this.listQuery.stream$.pipe(
-            tap((result) => (this.paginationConfig.totalItems = result.assets.totalItems)),
-            map((result) => result.assets.items),
+            tap(result => (this.paginationConfig.totalItems = result.assets.totalItems)),
+            map(result => result.assets.items),
         );
-        this.searchTerm.valueChanges
-            .pipe(debounceTime(250), takeUntil(this.destroy$))
-            .subscribe((searchTerm) => {
-                this.fetchPage(
-                    this.paginationConfig.currentPage,
-                    this.paginationConfig.itemsPerPage,
-                    searchTerm,
-                );
-            });
+        this.searchTerm$.pipe(debounceTime(250), takeUntil(this.destroy$)).subscribe(() => {
+            this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
+        });
+        this.filterByTags$.pipe(takeUntil(this.destroy$)).subscribe(() => {
+            this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
+        });
     }
 
     ngOnDestroy(): void {
@@ -61,20 +68,12 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
 
     pageChange(page: number) {
         this.paginationConfig.currentPage = page;
-        this.fetchPage(
-            this.paginationConfig.currentPage,
-            this.paginationConfig.itemsPerPage,
-            this.searchTerm.value,
-        );
+        this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
     }
 
     itemsPerPageChange(itemsPerPage: number) {
         this.paginationConfig.itemsPerPage = itemsPerPage;
-        this.fetchPage(
-            this.paginationConfig.currentPage,
-            this.paginationConfig.itemsPerPage,
-            this.searchTerm.value,
-        );
+        this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
     }
 
     cancel() {
@@ -91,12 +90,8 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
             this.dataService.product
                 .createAssets(files)
                 .pipe(finalize(() => (this.uploading = false)))
-                .subscribe((res) => {
-                    this.fetchPage(
-                        this.paginationConfig.currentPage,
-                        this.paginationConfig.itemsPerPage,
-                        this.searchTerm.value,
-                    );
+                .subscribe(res => {
+                    this.fetchPage(this.paginationConfig.currentPage, this.paginationConfig.itemsPerPage);
                     this.notificationService.success(_('asset.notify-create-assets-success'), {
                         count: files.length,
                     });
@@ -104,9 +99,11 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
         }
     }
 
-    private fetchPage(currentPage: number, itemsPerPage: number, searchTerm?: string) {
+    private fetchPage(currentPage: number, itemsPerPage: number) {
         const take = +itemsPerPage;
         const skip = (currentPage - 1) * +itemsPerPage;
+        const searchTerm = this.searchTerm$.value;
+        const tags = this.filterByTags$.value?.map(t => t.value);
         this.listQuery.ref.refetch({
             options: {
                 skip,
@@ -119,6 +116,8 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
                 sort: {
                     createdAt: SortOrder.DESC,
                 },
+                tags,
+                tagsOperator: LogicalOperator.AND,
             },
         });
     }

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

@@ -5,7 +5,8 @@
 </ng-template>
 
 <vdr-asset-preview
-    [asset]="asset"
+    *ngIf="assetWithTags$ | async as assetWithTags"
+    [asset]="assetWithTags"
     (assetChange)="assetChanges = $event"
     (editClick)="resolveWith()"
 ></vdr-asset-preview>

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

@@ -1,7 +1,12 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { mergeMap } from 'rxjs/operators';
 
+import { AssetFragment, GetAsset, GetAssetList, UpdateAssetInput } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
 import { Dialog } from '../../../providers/modal/modal.service';
-import { Asset, UpdateAssetInput } from '../../../common/generated-types';
+
+type AssetLike = GetAssetList.Items | AssetFragment;
 
 @Component({
     selector: 'vdr-asset-preview-dialog',
@@ -9,8 +14,27 @@ import { Asset, UpdateAssetInput } from '../../../common/generated-types';
     styleUrls: ['./asset-preview-dialog.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AssetPreviewDialogComponent implements Dialog<void> {
-    asset: Asset;
+export class AssetPreviewDialogComponent implements Dialog<void>, OnInit {
+    constructor(private dataService: DataService) {}
+    asset: AssetLike;
     assetChanges?: UpdateAssetInput;
     resolveWith: (result?: void) => void;
+    assetWithTags$: Observable<GetAsset.Asset>;
+
+    ngOnInit() {
+        this.assetWithTags$ = of(this.asset).pipe(
+            mergeMap(asset => {
+                if (this.hasTags(asset)) {
+                    return of(asset);
+                } else {
+                    // tslint:disable-next-line:no-non-null-assertion
+                    return this.dataService.product.getAsset(asset.id).mapSingle(data => data.asset!);
+                }
+            }),
+        );
+    }
+
+    private hasTags(asset: AssetLike): asset is GetAssetList.Items {
+        return asset.hasOwnProperty('tags');
+    }
 }

+ 20 - 2
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html

@@ -8,7 +8,12 @@
             [editable]="settingFocalPoint"
             (focalPointChange)="onFocalPointChange($event)"
         >
-            <img class="asset-image" [src]="asset | assetPreview:size" #imageElement (load)="onImageLoad()" />
+            <img
+                class="asset-image"
+                [src]="asset | assetPreview: size"
+                #imageElement
+                (load)="onImageLoad()"
+            />
         </vdr-focal-point-control>
         <div class="focal-point-info" *ngIf="settingFocalPoint">
             <button class="icon-button" (click)="setFocalPointCancel()">
@@ -36,7 +41,7 @@
 
         <vdr-labeled-data [label]="'common.name' | translate" *ngIf="!editable">
             <span class="elide">
-            {{ asset.name }}
+                {{ asset.name }}
             </span>
         </vdr-labeled-data>
 
@@ -78,6 +83,19 @@
                 {{ 'asset.unset-focal-point' | translate }}
             </button>
         </vdr-labeled-data>
+        <vdr-labeled-data [label]="'common.tags' | translate">
+            <ng-container *ngIf="editable">
+                <vdr-tag-selector formControlName="tags"></vdr-tag-selector>
+                <button class="btn btn-link btn-sm" (click)="manageTags()">
+                    <clr-icon shape="tags"></clr-icon> {{ 'common.manage-tags' | translate }}
+                </button>
+            </ng-container>
+            <div *ngIf="!editable">
+                <vdr-chip *ngFor="let tag of asset.tags" [colorFrom]="tag.value"
+                    ><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag.value }}</vdr-chip
+                >
+            </div>
+        </vdr-labeled-data>
     </form>
     <div class="flex-spacer"></div>
     <div class="preview-select">

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

@@ -75,7 +75,7 @@
 
     .preview-select {
         display: flex;
-        align-items: flex-end;
+        align-items: center;
         margin-bottom: 12px;
         clr-select-container {
             margin-right: 12px;

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

@@ -15,12 +15,15 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { fromEvent, Subscription } from 'rxjs';
 import { debounceTime } from 'rxjs/operators';
 
+import { GetAsset, GetAssetList, UpdateAssetInput } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
+import { ModalService } from '../../../providers/modal/modal.service';
 import { NotificationService } from '../../../providers/notification/notification.service';
-import { Asset, UpdateAssetInput } from '../../../common/generated-types';
 import { Point } from '../focal-point-control/focal-point-control.component';
+import { ManageTagsDialogComponent } from '../manage-tags-dialog/manage-tags-dialog.component';
 
 export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
+type AssetLike = GetAssetList.Items | GetAsset.Asset;
 
 @Component({
     selector: 'vdr-asset-preview',
@@ -29,7 +32,7 @@ export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | ''
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AssetPreviewComponent implements OnInit, OnDestroy {
-    @Input() asset: Asset;
+    @Input() asset: AssetLike;
     @Input() editable = false;
     @Output() assetChange = new EventEmitter<Omit<UpdateAssetInput, 'focalPoint'>>();
     @Output() editClick = new EventEmitter();
@@ -52,6 +55,7 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
         private dataService: DataService,
         private notificationService: NotificationService,
         private changeDetector: ChangeDetectorRef,
+        private modalService: ModalService,
     ) {}
 
     get fpx(): number | null {
@@ -66,11 +70,13 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
         const { focalPoint } = this.asset;
         this.form = this.formBuilder.group({
             name: [this.asset.name],
+            tags: [this.asset.tags?.map(t => t.value)],
         });
         this.subscription = this.form.valueChanges.subscribe(value => {
             this.assetChange.emit({
                 id: this.asset.id,
                 name: value.name,
+                tags: value.tags,
             });
         });
 
@@ -185,4 +191,16 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
                 );
         }
     }
+
+    manageTags() {
+        this.modalService
+            .fromComponent(ManageTagsDialogComponent, {
+                size: 'sm',
+            })
+            .subscribe(result => {
+                if (result) {
+                    this.notificationService.success(_('common.notify-updated-tags-success'));
+                }
+            });
+    }
 }

+ 35 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.html

@@ -0,0 +1,35 @@
+<ng-select
+    [addTag]="true"
+    [placeholder]="'catalog.search-asset-name-or-tag' | translate"
+    [items]="tags"
+    [searchFn]="filterTagResults"
+    [hideSelected]="true"
+    [multiple]="true"
+    [markFirst]="false"
+    (change)="onSelectChange($event)"
+    #selectComponent
+>
+    <ng-template ng-header-tmp>
+        <div
+            class="search-header"
+            *ngIf="selectComponent.searchTerm"
+            [class.selected]="isSearchHeaderSelected()"
+            (click)="selectComponent.selectTag()"
+        >
+            {{ 'catalog.search-for-term' | translate }}: {{ selectComponent.searchTerm }}
+        </div>
+    </ng-template>
+    <ng-template ng-label-tmp let-item="item" let-clear="clear">
+        <ng-container *ngIf="item.value">
+            <vdr-chip [colorFrom]="item.value" icon="close" (iconClick)="clear(item)"><clr-icon shape="tag" class="mr2"></clr-icon> {{ item.value }}</vdr-chip>
+        </ng-container>
+        <ng-container *ngIf="!item.value">
+            <vdr-chip [icon]="'times'" (iconClick)="clear(item)">"{{ item.label }}"</vdr-chip>
+        </ng-container>
+    </ng-template>
+    <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
+        <ng-container *ngIf="item.value">
+            <vdr-chip [colorFrom]="item.value"><clr-icon shape="tag" class="mr2"></clr-icon> {{ item.value }}</vdr-chip>
+        </ng-container>
+    </ng-template>
+</ng-select>

+ 26 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.scss

@@ -0,0 +1,26 @@
+@import "mixins";
+
+:host {
+    display: block;
+    width: 100%;
+
+    ::ng-deep {
+        @include ng-select-facet-values;
+    }
+}
+
+ng-select {
+    width: 100%;
+    min-width: 300px;
+    margin-right: 12px;
+}
+
+.search-header {
+    padding: 8px 10px;
+    border-bottom: 1px solid var(--color-component-border-100);
+    cursor: pointer;
+
+    &.selected, &:hover {
+        background-color: var(--color-component-bg-200);
+    }
+}

+ 92 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-search-input/asset-search-input.component.ts

@@ -0,0 +1,92 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+import { NgSelectComponent } from '@ng-select/ng-select';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+
+import { SearchProducts, TagFragment } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-asset-search-input',
+    templateUrl: './asset-search-input.component.html',
+    styleUrls: ['./asset-search-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AssetSearchInputComponent {
+    @Input() tags: TagFragment[];
+    @Output() searchTermChange = new EventEmitter<string>();
+    @Output() tagsChange = new EventEmitter<TagFragment[]>();
+    @ViewChild('selectComponent', { static: true }) private selectComponent: NgSelectComponent;
+    private lastTerm = '';
+    private lastTagIds: string[] = [];
+
+    setSearchTerm(term: string | null) {
+        if (term) {
+            this.selectComponent.select({ label: term, value: { label: term } });
+        } else {
+            const currentTerm = this.selectComponent.selectedItems.find(i => !this.isTag(i.value));
+            if (currentTerm) {
+                this.selectComponent.unselect(currentTerm);
+            }
+        }
+    }
+
+    setTags(tags: TagFragment[]) {
+        const items = this.selectComponent.items;
+
+        this.selectComponent.selectedItems.forEach(item => {
+            if (this.isTag(item.value) && !tags.map(t => t.id).includes(item.id)) {
+                this.selectComponent.unselect(item);
+            }
+        });
+
+        tags.map(tag => {
+            return items.find(item => this.isTag(item) && item.id === tag.id);
+        })
+            .filter(notNullOrUndefined)
+            .forEach(item => {
+                const isSelected = this.selectComponent.selectedItems.find(i => {
+                    const val = i.value;
+                    if (this.isTag(val)) {
+                        return val.id === item.id;
+                    }
+                    return false;
+                });
+                if (!isSelected) {
+                    this.selectComponent.select({ label: '', value: item });
+                }
+            });
+    }
+
+    filterTagResults = (term: string, item: SearchProducts.FacetValues | { label: string }) => {
+        if (!this.isTag(item)) {
+            return false;
+        }
+        return item.value.toLowerCase().startsWith(term.toLowerCase());
+    };
+
+    onSelectChange(selectedItems: Array<TagFragment | { label: string }>) {
+        if (!Array.isArray(selectedItems)) {
+            selectedItems = [selectedItems];
+        }
+        const searchTermItem = selectedItems.find(item => !this.isTag(item)) as { label: string } | undefined;
+        const searchTerm = searchTermItem ? searchTermItem.label : '';
+
+        const tags = selectedItems.filter(this.isTag);
+
+        if (searchTerm !== this.lastTerm) {
+            this.searchTermChange.emit(searchTerm);
+            this.lastTerm = searchTerm;
+        }
+        if (this.lastTagIds.join(',') !== tags.map(t => t.id).join(',')) {
+            this.tagsChange.emit(tags);
+            this.lastTagIds = tags.map(t => t.id);
+        }
+    }
+
+    isSearchHeaderSelected(): boolean {
+        return this.selectComponent.itemsList.markedIndex === -1;
+    }
+
+    private isTag = (input: unknown): input is TagFragment => {
+        return typeof input === 'object' && !!input && input.hasOwnProperty('value');
+    };
+}

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.html

@@ -6,6 +6,7 @@
     [clearable]="false"
     [searchable]="false"
     [disabled]="disabled"
+    [compareWith]="compareFn"
     (focus)="focussed()"
     (change)="valueChanged($event)"
 >

+ 5 - 1
packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.ts

@@ -4,7 +4,7 @@ import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 
-import { CurrentUserChannel } from '../../../common/generated-types';
+import { Channel, CurrentUserChannel } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
 
 @Component({
@@ -80,4 +80,8 @@ export class ChannelAssignmentControlComponent implements OnInit, ControlValueAc
             this.onChange([value ? value.id : undefined]);
         }
     }
+
+    compareFn(c1: Channel, c2: Channel): boolean {
+        return c1 && c2 ? c1.id === c2.id : c1 === c2;
+    }
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.ts

@@ -133,7 +133,7 @@ export class ConfigurableInputComponent implements OnChanges, OnDestroy, Control
                 if (value === undefined) {
                     value = getDefaultConfigArgValue(arg);
                 }
-                const validators = arg.list ? undefined : Validators.required;
+                const validators = arg.list ? undefined : arg.required ? Validators.required : undefined;
                 this.form.addControl(arg.name, new FormControl(value, validators));
             }
         }

+ 9 - 5
packages/admin-ui/src/lib/core/src/shared/components/currency-input/currency-input.component.ts

@@ -9,7 +9,7 @@ import {
     SimpleChanges,
 } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
-import { Observable } from 'rxjs';
+import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 
 import { DataService } from '../../../data/providers/data.service';
@@ -41,20 +41,21 @@ export class CurrencyInputComponent implements ControlValueAccessor, OnInit, OnC
     onChange: (val: any) => void;
     onTouch: () => void;
     _decimalValue: string;
+    private currencyCode$ = new BehaviorSubject<string>('');
 
     constructor(private dataService: DataService, private changeDetectorRef: ChangeDetectorRef) {}
 
     ngOnInit() {
         const languageCode$ = this.dataService.client.uiState().mapStream(data => data.uiState.language);
-        const shouldPrefix$ = languageCode$.pipe(
-            map(languageCode => {
-                if (!this.currencyCode) {
+        const shouldPrefix$ = combineLatest(languageCode$, this.currencyCode$).pipe(
+            map(([languageCode, currencyCode]) => {
+                if (!currencyCode) {
                     return '';
                 }
                 const locale = languageCode.replace(/_/g, '-');
                 const localised = new Intl.NumberFormat(locale, {
                     style: 'currency',
-                    currency: this.currencyCode,
+                    currency: currencyCode,
                     currencyDisplay: 'symbol',
                 }).format(undefined as any);
                 return localised.indexOf('NaN') > 0;
@@ -68,6 +69,9 @@ export class CurrencyInputComponent implements ControlValueAccessor, OnInit, OnC
         if ('value' in changes) {
             this.writeValue(changes['value'].currentValue);
         }
+        if ('currencyCode' in changes) {
+            this.currencyCode$.next(this.currencyCode);
+        }
     }
 
     registerOnChange(fn: any) {

+ 26 - 0
packages/admin-ui/src/lib/core/src/shared/components/manage-tags-dialog/manage-tags-dialog.component.html

@@ -0,0 +1,26 @@
+<ng-template vdrDialogTitle>
+    <span>{{ 'common.manage-tags' | translate }}</span>
+</ng-template>
+<p class="mt0 mb4">{{ 'common.manage-tags-description' | translate }}</p>
+<ul class="tag-list" *ngFor="let tag of allTags$ | async">
+    <li class="mb2 p1" [class.to-delete]="markedAsDeleted(tag.id)">
+        <clr-icon shape="tag" class="is-solid mr2" [style.color]="tag.value | stringToColor"></clr-icon>
+        <input type="text" (input)="updateTagValue(tag.id, $event.target.value)" [value]="tag.value" />
+        <button class="icon-button" (click)="toggleDelete(tag.id)">
+            <clr-icon shape="trash" class="is-danger" [class.is-solid]="markedAsDeleted(tag.id)"></clr-icon>
+        </button>
+    </li>
+</ul>
+<ng-template vdrDialogButtons>
+    <button type="submit" (click)="resolveWith(false)" class="btn btn-secondary">
+        {{ 'common.cancel' | translate }}
+    </button>
+    <button
+        type="submit"
+        (click)="saveChanges()"
+        class="btn btn-primary"
+        [disabled]="!toUpdate.length && !toDelete.length"
+    >
+        {{ 'common.update' | translate }}
+    </button>
+</ng-template>

+ 19 - 0
packages/admin-ui/src/lib/core/src/shared/components/manage-tags-dialog/manage-tags-dialog.component.scss

@@ -0,0 +1,19 @@
+.tag-list {
+    list-style-type: none;
+
+    li {
+        display: flex;
+        align-items: center;
+        input {
+            max-width: 170px;
+        }
+    }
+
+    li.to-delete {
+        opacity: 0.7;
+        background-color: var(--color-component-bg-300);
+        input {
+            background-color: transparent !important;
+        }
+    }
+}

+ 60 - 0
packages/admin-ui/src/lib/core/src/shared/components/manage-tags-dialog/manage-tags-dialog.component.ts

@@ -0,0 +1,60 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { forkJoin, Observable } from 'rxjs';
+
+import { GetTagList } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
+import { Dialog } from '../../../providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-manage-tags-dialog',
+    templateUrl: './manage-tags-dialog.component.html',
+    styleUrls: ['./manage-tags-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ManageTagsDialogComponent implements Dialog<boolean>, OnInit {
+    resolveWith: (result: boolean | undefined) => void;
+    allTags$: Observable<GetTagList.Items[]>;
+    toDelete: string[] = [];
+    toUpdate: Array<{ id: string; value: string }> = [];
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit(): void {
+        this.allTags$ = this.dataService.product.getTagList().mapStream(data => data.tags.items);
+    }
+
+    toggleDelete(id: string) {
+        const marked = this.markedAsDeleted(id);
+        if (marked) {
+            this.toDelete = this.toDelete.filter(_id => _id !== id);
+        } else {
+            this.toDelete.push(id);
+        }
+    }
+
+    markedAsDeleted(id: string): boolean {
+        return this.toDelete.includes(id);
+    }
+
+    updateTagValue(id: string, value: string) {
+        const exists = this.toUpdate.find(i => i.id === id);
+        if (exists) {
+            exists.value = value;
+        } else {
+            this.toUpdate.push({ id, value });
+        }
+    }
+
+    saveChanges() {
+        const operations: Array<Observable<any>> = [];
+        for (const id of this.toDelete) {
+            operations.push(this.dataService.product.deleteTag(id));
+        }
+        for (const item of this.toUpdate) {
+            if (!this.toDelete.includes(item.id)) {
+                operations.push(this.dataService.product.updateTag(item));
+            }
+        }
+        return forkJoin(operations).subscribe(() => this.resolveWith(true));
+    }
+}

+ 17 - 0
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.html

@@ -0,0 +1,17 @@
+<ng-select
+    [addTag]="addTagFn"
+    [multiple]="true"
+    [ngModel]="_value"
+    [clearable]="true"
+    [searchable]="true"
+    [disabled]="disabled"
+    [placeholder]="placeholder"
+    (change)="valueChanged($event)"
+>
+    <ng-template ng-label-tmp let-tag="item" let-clear="clear">
+        <vdr-chip [colorFrom]="tag" icon="close" (iconClick)="clear(tag)"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag }}</vdr-chip>
+    </ng-template>
+    <ng-option *ngFor="let tag of allTags$ | async" [value]="tag">
+        <vdr-chip [colorFrom]="tag"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag }}</vdr-chip>
+    </ng-option>
+</ng-select>

+ 11 - 0
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.scss

@@ -0,0 +1,11 @@
+@import "mixins";
+
+:host {
+    display: block;
+    margin-top: 12px;
+    position: relative;
+
+    ::ng-deep {
+        @include ng-select-facet-values;
+    }
+}

+ 61 - 0
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.ts

@@ -0,0 +1,61 @@
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { Observable } from 'rxjs';
+
+import { DataService } from '../../../data/providers/data.service';
+
+@Component({
+    selector: 'vdr-tag-selector',
+    templateUrl: './tag-selector.component.html',
+    styleUrls: ['./tag-selector.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [
+        {
+            provide: NG_VALUE_ACCESSOR,
+            useExisting: TagSelectorComponent,
+            multi: true,
+        },
+    ],
+})
+export class TagSelectorComponent implements OnInit, ControlValueAccessor {
+    @Input() placeholder: string | undefined;
+    allTags$: Observable<string[]>;
+    onChange: (val: any) => void;
+    onTouch: () => void;
+    _value: string[];
+    disabled: boolean;
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit(): void {
+        this.allTags$ = this.dataService.product
+            .getTagList()
+            .mapStream(data => data.tags.items.map(i => i.value));
+    }
+
+    addTagFn(val: string) {
+        return val;
+    }
+
+    registerOnChange(fn: any): void {
+        this.onChange = fn;
+    }
+
+    registerOnTouched(fn: any): void {
+        this.onTouch = fn;
+    }
+
+    setDisabledState(isDisabled: boolean): void {
+        this.disabled = isDisabled;
+    }
+
+    writeValue(obj: unknown): void {
+        if (Array.isArray(obj)) {
+            this._value = obj;
+        }
+    }
+
+    valueChanged(event: string[]) {
+        this.onChange(event);
+    }
+}

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

@@ -29,10 +29,7 @@ import { switchMap, take, takeUntil } from 'rxjs/operators';
 
 import { FormInputComponent } from '../../../common/component-registry-types';
 import { ConfigArgDefinition, CustomFieldConfig } from '../../../common/generated-types';
-import {
-    getConfigArgValue,
-    getDefaultConfigArgSingleValue,
-} from '../../../common/utilities/configurable-operation-utils';
+import { getConfigArgValue } from '../../../common/utilities/configurable-operation-utils';
 import { ComponentRegistryService } from '../../../providers/component-registry/component-registry.service';
 
 type InputListItem = {
@@ -211,7 +208,7 @@ export class DynamicFormInputComponent
         }
         this.listItems.push({
             id: this.listId++,
-            control: new FormControl(getDefaultConfigArgSingleValue(this.def.type as ConfigArgType)),
+            control: new FormControl((this.def as ConfigArgDefinition).defaultValue ?? null),
         });
         this.renderList$.next();
     }
@@ -309,6 +306,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>;
+}

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/pipes/asset-preview.pipe.ts

@@ -1,12 +1,12 @@
 import { Pipe, PipeTransform } from '@angular/core';
 
-import { Asset } from '../../common/generated-types';
+import { AssetFragment } from '../../common/generated-types';
 
 @Pipe({
     name: 'assetPreview',
 })
 export class AssetPreviewPipe implements PipeTransform {
-    transform(asset: Asset, preset: string | number = 'thumb'): string {
+    transform(asset: AssetFragment, preset: string | number = 'thumb'): string {
         if (!asset.preview || typeof asset.preview !== 'string') {
             throw new Error(`Expected an Asset, got ${JSON.stringify(asset)}`);
         }

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

@@ -28,6 +28,7 @@ import { AssetGalleryComponent } from './components/asset-gallery/asset-gallery.
 import { AssetPickerDialogComponent } from './components/asset-picker-dialog/asset-picker-dialog.component';
 import { AssetPreviewDialogComponent } from './components/asset-preview-dialog/asset-preview-dialog.component';
 import { AssetPreviewComponent } from './components/asset-preview/asset-preview.component';
+import { AssetSearchInputComponent } from './components/asset-search-input/asset-search-input.component';
 import { ChannelAssignmentControlComponent } from './components/channel-assignment-control/channel-assignment-control.component';
 import { ChannelBadgeComponent } from './components/channel-badge/channel-badge.component';
 import { ChipComponent } from './components/chip/chip.component';
@@ -58,6 +59,7 @@ import { HistoryEntryDetailComponent } from './components/history-entry-detail/h
 import { ItemsPerPageControlsComponent } from './components/items-per-page-controls/items-per-page-controls.component';
 import { LabeledDataComponent } from './components/labeled-data/labeled-data.component';
 import { LanguageSelectorComponent } from './components/language-selector/language-selector.component';
+import { ManageTagsDialogComponent } from './components/manage-tags-dialog/manage-tags-dialog.component';
 import { DialogButtonsDirective } from './components/modal-dialog/dialog-buttons.directive';
 import { DialogComponentOutletComponent } from './components/modal-dialog/dialog-component-outlet.component';
 import { DialogTitleDirective } from './components/modal-dialog/dialog-title.directive';
@@ -72,6 +74,7 @@ import { RichTextEditorComponent } from './components/rich-text-editor/rich-text
 import { SelectToggleComponent } from './components/select-toggle/select-toggle.component';
 import { SimpleDialogComponent } from './components/simple-dialog/simple-dialog.component';
 import { TableRowActionComponent } from './components/table-row-action/table-row-action.component';
+import { TagSelectorComponent } from './components/tag-selector/tag-selector.component';
 import { TimelineEntryComponent } from './components/timeline-entry/timeline-entry.component';
 import { TitleInputComponent } from './components/title-input/title-input.component';
 import { DisabledDirective } from './directives/disabled.directive';
@@ -87,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';
@@ -124,6 +138,7 @@ const DECLARATIONS = [
     ActionBarRightComponent,
     AssetPreviewComponent,
     AssetPreviewDialogComponent,
+    AssetSearchInputComponent,
     ConfigurableInputComponent,
     AffixedInputComponent,
     ChipComponent,
@@ -197,6 +212,7 @@ const DECLARATIONS = [
     AddressFormComponent,
     LocaleDatePipe,
     LocaleCurrencyPipe,
+    TagSelectorComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [
@@ -209,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],
+    declarations: [
+        ...DECLARATIONS,
+        ...DYNAMIC_FORM_INPUTS,
+        ManageTagsDialogComponent,
+        RelationSelectorDialogComponent,
+        RelationCardComponent,
+    ],
     providers: [
         // This needs to be shared, since lazy-loaded
         // modules have their own entryComponents which

+ 5 - 2
packages/admin-ui/src/lib/order/src/components/fulfill-order-dialog/fulfill-order-dialog.component.ts

@@ -4,6 +4,7 @@ import {
     configurableDefinitionToInstance,
     ConfigurableOperation,
     ConfigurableOperationDefinition,
+    configurableOperationValueIsValid,
     DataService,
     Dialog,
     FulfillOrderInput,
@@ -77,8 +78,10 @@ export class FulfillOrderDialogComponent implements Dialog<FulfillOrderInput>, O
             0,
         );
         const formIsValid =
-            this.fulfillmentHandlerDef?.args.length === 0 ||
-            (this.fulfillmentHandlerControl.valid && this.fulfillmentHandlerControl.touched);
+            configurableOperationValueIsValid(
+                this.fulfillmentHandlerDef,
+                this.fulfillmentHandlerControl.value,
+            ) && this.fulfillmentHandlerControl.valid;
         return formIsValid && 0 < totalCount;
     }
 

+ 19 - 16
packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.html

@@ -108,10 +108,7 @@
                                 <clr-icon shape="trash"></clr-icon>
                             </button>
                         </td>
-                        <td
-                            *ngIf="orderLineCustomFields.length"
-                            class="order-line-custom-field align-middle"
-                        >
+                        <td *ngIf="orderLineCustomFields.length" class="order-line-custom-field align-middle">
                             <ng-container *ngFor="let customField of orderLineCustomFields">
                                 <vdr-custom-field-control
                                     [customField]="customField"
@@ -193,13 +190,21 @@
                         <vdr-product-selector class="mb4" (productSelected)="addItemSelectedVariant = $event">
                         </vdr-product-selector>
                         <div *ngIf="addItemSelectedVariant" class="flex mb4">
-                            <img *ngIf="addItemSelectedVariant.productAsset as asset" [src]="asset | assetPreview: 'tiny'" class="mr4" />
+                            <img
+                                *ngIf="addItemSelectedVariant.productAsset as asset"
+                                [src]="asset | assetPreview: 'tiny'"
+                                class="mr4"
+                            />
                             <div>
                                 <strong class="mr4">{{ addItemSelectedVariant.productVariantName }}</strong>
                                 <small>{{ addItemSelectedVariant.sku }}</small>
-                                <div>{{ getSelectedItemPrice(addItemSelectedVariant) | localeCurrency: order.currencyCode }}</div>
+                                <div>
+                                    {{
+                                        getSelectedItemPrice(addItemSelectedVariant)
+                                            | localeCurrency: order.currencyCode
+                                    }}
+                                </div>
                             </div>
-
                         </div>
                         <ng-container *ngFor="let customField of orderLineCustomFields">
                             <vdr-custom-field-control
@@ -225,10 +230,7 @@
                     <clr-accordion-content *clrIfExpanded>
                         <form [formGroup]="surchargeForm" (submit)="addSurcharge(surchargeForm.value)">
                             <vdr-form-field [label]="'common.description' | translate" for="description"
-                                ><input
-                                    id="description"
-                                    type="text"
-                                    formControlName="description"
+                                ><input id="description" type="text" formControlName="description"
                             /></vdr-form-field>
                             <vdr-form-field [label]="'order.product-sku' | translate" for="sku"
                                 ><input id="sku" type="text" formControlName="sku"
@@ -262,14 +264,15 @@
                                         formControlName="taxRate" /></vdr-affixed-input
                             ></vdr-form-field>
                             <vdr-form-field [label]="'order.tax-description' | translate" for="taxDescription"
-                                ><input
-                                    id="taxDescription"
-                                    type="text"
-                                    formControlName="taxDescription"
+                                ><input id="taxDescription" type="text" formControlName="taxDescription"
                             /></vdr-form-field>
                             <button
                                 class="btn btn-secondary"
-                                [disabled]="surchargeForm.invalid || surchargeForm.pristine || surchargeForm.get('price')?.value === 0"
+                                [disabled]="
+                                    surchargeForm.invalid ||
+                                    surchargeForm.pristine ||
+                                    surchargeForm.get('price')?.value === 0
+                                "
                             >
                                 {{ 'order.add-surcharge' | translate }}
                             </button>

+ 36 - 29
packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.ts

@@ -125,28 +125,32 @@ export class OrderEditorComponent
                 taxRate: new FormControl(0),
                 taxDescription: new FormControl(''),
             });
-            this.shippingAddressForm = new FormGroup({
-                fullName: new FormControl(order.shippingAddress?.fullName),
-                company: new FormControl(order.shippingAddress?.company),
-                streetLine1: new FormControl(order.shippingAddress?.streetLine1),
-                streetLine2: new FormControl(order.shippingAddress?.streetLine2),
-                city: new FormControl(order.shippingAddress?.city),
-                province: new FormControl(order.shippingAddress?.province),
-                postalCode: new FormControl(order.shippingAddress?.postalCode),
-                countryCode: new FormControl(order.shippingAddress?.countryCode),
-                phoneNumber: new FormControl(order.shippingAddress?.phoneNumber),
-            });
-            this.billingAddressForm = new FormGroup({
-                fullName: new FormControl(order.billingAddress?.fullName),
-                company: new FormControl(order.billingAddress?.company),
-                streetLine1: new FormControl(order.billingAddress?.streetLine1),
-                streetLine2: new FormControl(order.billingAddress?.streetLine2),
-                city: new FormControl(order.billingAddress?.city),
-                province: new FormControl(order.billingAddress?.province),
-                postalCode: new FormControl(order.billingAddress?.postalCode),
-                countryCode: new FormControl(order.billingAddress?.countryCode),
-                phoneNumber: new FormControl(order.billingAddress?.phoneNumber),
-            });
+            if (!this.shippingAddressForm) {
+                this.shippingAddressForm = new FormGroup({
+                    fullName: new FormControl(order.shippingAddress?.fullName),
+                    company: new FormControl(order.shippingAddress?.company),
+                    streetLine1: new FormControl(order.shippingAddress?.streetLine1),
+                    streetLine2: new FormControl(order.shippingAddress?.streetLine2),
+                    city: new FormControl(order.shippingAddress?.city),
+                    province: new FormControl(order.shippingAddress?.province),
+                    postalCode: new FormControl(order.shippingAddress?.postalCode),
+                    countryCode: new FormControl(order.shippingAddress?.countryCode),
+                    phoneNumber: new FormControl(order.shippingAddress?.phoneNumber),
+                });
+            }
+            if (!this.billingAddressForm) {
+                this.billingAddressForm = new FormGroup({
+                    fullName: new FormControl(order.billingAddress?.fullName),
+                    company: new FormControl(order.billingAddress?.company),
+                    streetLine1: new FormControl(order.billingAddress?.streetLine1),
+                    streetLine2: new FormControl(order.billingAddress?.streetLine2),
+                    city: new FormControl(order.billingAddress?.city),
+                    province: new FormControl(order.billingAddress?.province),
+                    postalCode: new FormControl(order.billingAddress?.postalCode),
+                    countryCode: new FormControl(order.billingAddress?.countryCode),
+                    phoneNumber: new FormControl(order.billingAddress?.phoneNumber),
+                });
+            }
             this.orderLineCustomFieldsFormArray = new FormArray([]);
             for (const line of order.lines) {
                 const formGroup = new FormGroup({});
@@ -208,12 +212,11 @@ export class OrderEditorComponent
     canPreviewChanges(): boolean {
         const { addItems, adjustOrderLines, surcharges } = this.modifyOrderInput;
         return (
-            (!!addItems?.length ||
-                !!surcharges?.length ||
-                !!adjustOrderLines?.length ||
-                (this.shippingAddressForm.dirty && this.shippingAddressForm.valid) ||
-                (this.billingAddressForm.dirty && this.billingAddressForm.valid)) &&
-            this.note !== ''
+            !!addItems?.length ||
+            !!surcharges?.length ||
+            !!adjustOrderLines?.length ||
+            (this.shippingAddressForm.dirty && this.shippingAddressForm.valid) ||
+            (this.billingAddressForm.dirty && this.billingAddressForm.valid)
         );
     }
 
@@ -341,8 +344,12 @@ export class OrderEditorComponent
     previewAndModify(order: OrderDetail.Fragment) {
         const input: ModifyOrderInput = {
             ...this.modifyOrderInput,
+            ...(this.billingAddressForm.dirty ? { updateBillingAddress: this.billingAddressForm.value } : {}),
+            ...(this.shippingAddressForm.dirty
+                ? { updateShippingAddress: this.shippingAddressForm.value }
+                : {}),
             dryRun: true,
-            note: this.note,
+            note: this.note ?? '',
             options: {
                 recalculateShipping: this.recalculateShipping,
             },

+ 11 - 0
packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.html

@@ -61,6 +61,17 @@
     >
         <input id="password" type="password" formControlName="password" />
     </vdr-form-field>
+    <section formGroupName="customFields" *ngIf="customFields.length">
+        <label>{{ 'common.custom-fields' | translate }}</label>
+        <ng-container *ngFor="let customField of customFields">
+            <vdr-custom-field-control
+                *ngIf="customFieldIsSet(customField.name)"
+                entityName="GlobalSettings"
+                [customFieldsFormGroup]="detailForm.get('customFields')"
+                [customField]="customField"
+            ></vdr-custom-field-control>
+        </ng-container>
+    </section>
     <label class="clr-control-label">{{ 'settings.roles' | translate }}</label>
     <ng-select
         [items]="allRoles$ | async"

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

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
 import { FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { BaseDetailComponent, PermissionDefinition } from '@vendure/admin-ui/core';
+import { BaseDetailComponent, CustomFieldConfig, PermissionDefinition } from '@vendure/admin-ui/core';
 import {
     Administrator,
     CreateAdministratorInput,
@@ -34,6 +34,7 @@ export interface PermissionsByChannel {
 export class AdminDetailComponent
     extends BaseDetailComponent<GetAdministrator.Administrator>
     implements OnInit, OnDestroy {
+    customFields: CustomFieldConfig[];
     administrator$: Observable<GetAdministrator.Administrator>;
     permissionDefinitions: PermissionDefinition[];
     allRoles$: Observable<Role.Fragment[]>;
@@ -56,12 +57,16 @@ export class AdminDetailComponent
         private notificationService: NotificationService,
     ) {
         super(route, router, serverConfigService, dataService);
+        this.customFields = this.getCustomFieldConfig('Administrator');
         this.detailForm = this.formBuilder.group({
             emailAddress: ['', Validators.required],
             firstName: ['', Validators.required],
             lastName: ['', Validators.required],
             password: [''],
             roles: [[]],
+            customFields: this.formBuilder.group(
+                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+            ),
         });
     }
 
@@ -84,6 +89,10 @@ export class AdminDetailComponent
         this.destroy();
     }
 
+    customFieldIsSet(name: string): boolean {
+        return !!this.detailForm.get(['customFields', name]);
+    }
+
     rolesChanged(roles: Role[]) {
         this.buildPermissionsMap();
     }
@@ -116,6 +125,7 @@ export class AdminDetailComponent
             firstName: formValue.firstName,
             lastName: formValue.lastName,
             password: formValue.password,
+            customFields: formValue.customFields,
             roleIds: formValue.roles.map(role => role.id),
         };
         this.dataService.administrator.createAdministrator(administrator).subscribe(
@@ -147,6 +157,7 @@ export class AdminDetailComponent
                         firstName: formValue.firstName,
                         lastName: formValue.lastName,
                         password: formValue.password,
+                        customFields: formValue.customFields,
                         roleIds: formValue.roles.map(role => role.id),
                     };
                     return this.dataService.administrator.updateAdministrator(administrator);
@@ -175,6 +186,18 @@ export class AdminDetailComponent
             lastName: administrator.lastName,
             roles: administrator.user.roles,
         });
+        if (this.customFields.length) {
+            const customFieldsGroup = this.detailForm.get('customFields') as FormGroup;
+
+            for (const fieldDef of this.customFields) {
+                const key = fieldDef.name;
+                const value = (administrator as any).customFields[key];
+                const control = customFieldsGroup.get(key);
+                if (control) {
+                    control.patchValue(value);
+                }
+            }
+        }
         const passwordControl = this.detailForm.get('password');
         if (passwordControl) {
             if (!administrator.id) {

+ 25 - 5
packages/admin-ui/src/lib/settings/src/components/channel-detail/channel-detail.component.html

@@ -50,9 +50,9 @@
             formControlName="defaultLanguageCode"
             [vdrDisabled]="!('SuperAdmin' | hasPermission)"
         >
-            <option *ngFor="let languageCode of availableLanguageCodes$ | async" [value]="languageCode"
-                >{{ 'lang.' + languageCode | translate }} ({{ languageCode | uppercase }})</option
-            >
+            <option *ngFor="let languageCode of availableLanguageCodes$ | async" [value]="languageCode">
+                {{ 'lang.' + languageCode | translate }} ({{ languageCode | uppercase }})
+            </option>
         </select>
     </vdr-form-field>
     <vdr-form-field [label]="'settings.prices-include-tax' | translate" for="pricesIncludeTax">
@@ -76,7 +76,11 @@
             <option *ngFor="let zone of zones$ | async" [value]="zone.id">{{ zone.name }}</option>
         </select>
     </vdr-form-field>
-    <clr-alert *ngIf="detailForm.value.code && !detailForm.value.defaultTaxZoneId" clrAlertType="danger" [clrAlertClosable]="false">
+    <clr-alert
+        *ngIf="detailForm.value.code && !detailForm.value.defaultTaxZoneId"
+        clrAlertType="danger"
+        [clrAlertClosable]="false"
+    >
         <clr-alert-item>
             <span class="alert-text">
                 {{ 'error.no-default-tax-zone-set' | translate }}
@@ -94,11 +98,27 @@
             <option *ngFor="let zone of zones$ | async" [value]="zone.id">{{ zone.name }}</option>
         </select>
     </vdr-form-field>
-    <clr-alert *ngIf="detailForm.value.code && !detailForm.value.defaultShippingZoneId" clrAlertType="warning" [clrAlertClosable]="false">
+    <clr-alert
+        *ngIf="detailForm.value.code && !detailForm.value.defaultShippingZoneId"
+        clrAlertType="warning"
+        [clrAlertClosable]="false"
+    >
         <clr-alert-item>
             <span class="alert-text">
                 {{ 'error.no-default-shipping-zone-set' | translate }}
             </span>
         </clr-alert-item>
     </clr-alert>
+
+    <section formGroupName="customFields" *ngIf="customFields.length">
+        <label>{{ 'common.custom-fields' | translate }}</label>
+        <ng-container *ngFor="let customField of customFields">
+            <vdr-custom-field-control
+                *ngIf="customFieldIsSet(customField.name)"
+                entityName="GlobalSettings"
+                [customFieldsFormGroup]="detailForm.get('customFields')"
+                [customField]="customField"
+            ></vdr-custom-field-control>
+        </ng-container>
+    </section>
 </form>

+ 26 - 2
packages/admin-ui/src/lib/settings/src/components/channel-detail/channel-detail.component.ts

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
 import { FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { BaseDetailComponent } from '@vendure/admin-ui/core';
+import { BaseDetailComponent, CustomFieldConfig } from '@vendure/admin-ui/core';
 import {
     Channel,
     CreateChannelInput,
@@ -24,8 +24,10 @@ import { map, mergeMap, take } from 'rxjs/operators';
     styleUrls: ['./channel-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment>
+export class ChannelDetailComponent
+    extends BaseDetailComponent<Channel.Fragment>
     implements OnInit, OnDestroy {
+    customFields: CustomFieldConfig[];
     zones$: Observable<GetZones.Zones[]>;
     detailForm: FormGroup;
     currencyCodes = Object.values(CurrencyCode);
@@ -41,6 +43,7 @@ export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment
         private notificationService: NotificationService,
     ) {
         super(route, router, serverConfigService, dataService);
+        this.customFields = this.getCustomFieldConfig('Channel');
         this.detailForm = this.formBuilder.group({
             code: ['', Validators.required],
             token: ['', Validators.required],
@@ -49,6 +52,9 @@ export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment
             defaultShippingZoneId: ['', Validators.required],
             defaultLanguageCode: [],
             defaultTaxZoneId: ['', Validators.required],
+            customFields: this.formBuilder.group(
+                this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+            ),
         });
     }
 
@@ -62,6 +68,10 @@ export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment
         this.destroy();
     }
 
+    customFieldIsSet(name: string): boolean {
+        return !!this.detailForm.get(['customFields', name]);
+    }
+
     saveButtonEnabled(): boolean {
         return this.detailForm.dirty && this.detailForm.valid;
     }
@@ -79,6 +89,7 @@ export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment
             currencyCode: formValue.currencyCode,
             defaultShippingZoneId: formValue.defaultShippingZoneId,
             defaultTaxZoneId: formValue.defaultTaxZoneId,
+            customFields: formValue.customFields,
         };
         this.dataService.settings
             .createChannel(input)
@@ -130,6 +141,7 @@ export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment
                         defaultShippingZoneId: formValue.defaultShippingZoneId,
                         defaultLanguageCode: formValue.defaultLanguageCode,
                         defaultTaxZoneId: formValue.defaultTaxZoneId,
+                        customFields: formValue.customFields,
                     } as UpdateChannelInput;
                     return this.dataService.settings.updateChannel(input);
                 }),
@@ -162,6 +174,18 @@ export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment
             defaultLanguageCode: entity.defaultLanguageCode,
             defaultTaxZoneId: entity.defaultTaxZone ? entity.defaultTaxZone.id : '',
         });
+        if (this.customFields.length) {
+            const customFieldsGroup = this.detailForm.get('customFields') as FormGroup;
+
+            for (const fieldDef of this.customFields) {
+                const key = fieldDef.name;
+                const value = (entity as any).customFields[key];
+                const control = customFieldsGroup.get(key);
+                if (control) {
+                    control.patchValue(value);
+                }
+            }
+        }
         if (entity.code === DEFAULT_CHANNEL_CODE) {
             const codeControl = this.detailForm.get('code');
             if (codeControl) {

+ 80 - 25
packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.html

@@ -9,6 +9,7 @@
             class="btn btn-primary"
             *ngIf="isNew$ | async; else updateButton"
             [disabled]="detailForm.pristine || detailForm.invalid"
+            (click)="create()"
         >
             {{ 'common.create' | translate }}
         </button>
@@ -17,7 +18,7 @@
                 *vdrIfPermissions="'UpdateSettings'"
                 class="btn btn-primary"
                 (click)="save()"
-                [disabled]="detailForm.pristine || detailForm.invalid"
+                [disabled]="detailForm.pristine || detailForm.invalid || !selectedHandler"
             >
                 {{ 'common.update' | translate }}
             </button>
@@ -25,8 +26,21 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<form class="form" [formGroup]="detailForm">
-    <vdr-form-field [label]="'common.code' | translate" for="code">
+<form class="form" [formGroup]="detailForm" *ngIf="entity$ | async as paymentMethod">
+    <vdr-form-field [label]="'common.name' | translate" for="name">
+        <input
+            id="name"
+            type="text"
+            formControlName="name"
+            [readonly]="!('UpdateSettings' | hasPermission)"
+            (input)="updateCode(paymentMethod.code, $event.target.value)"
+        />
+    </vdr-form-field>
+    <vdr-form-field
+        [label]="'common.code' | translate"
+        for="code"
+        [readOnlyToggle]="'UpdateSettings' | hasPermission"
+    >
         <input
             id="code"
             type="text"
@@ -34,6 +48,11 @@
             [readonly]="!('UpdateSettings' | hasPermission)"
         />
     </vdr-form-field>
+    <vdr-rich-text-editor
+        formControlName="description"
+        [readonly]="!('UpdateSettings' | hasPermission)"
+        [label]="'common.description' | translate"
+    ></vdr-rich-text-editor>
     <vdr-form-field [label]="'common.enabled' | translate" for="description">
         <clr-toggle-wrapper>
             <input
@@ -46,28 +65,64 @@
         </clr-toggle-wrapper>
     </vdr-form-field>
 
-    <ng-container *ngIf="entity$ | async as paymentMethod">
-        <div
-            class="clr-row"
-            formGroupName="configArgs"
-            *ngIf="paymentMethod?.configArgs?.length && configArgsIsPopulated()"
-        >
-            <div class="clr-col">
-                <label>{{ 'settings.payment-method-config-options' | translate }}</label>
-                <section class="form-block" *ngFor="let arg of paymentMethod.definition.args; index as i">
-                    <vdr-form-field
-                        [label]="arg.label || arg.name"
-                        [for]="arg.name"
-                    >
-                        <vdr-dynamic-form-input
-                            [def]="paymentMethod.definition.args[i]"
-                            [formControlName]="arg.name"
-                            [control]="detailForm.get(['configArgs', arg.name])"
-                            [readonly]="!('UpdateSettings' | hasPermission)"
-                        ></vdr-dynamic-form-input>
-                    </vdr-form-field>
-                </section>
+    <div class="clr-row mt4">
+        <div class="clr-col">
+            <label class="clr-control-label">{{ 'settings.payment-eligibility-checker' | translate }}</label>
+            <vdr-configurable-input
+                *ngIf="selectedChecker && selectedCheckerDefinition"
+                [operation]="selectedChecker"
+                [operationDefinition]="selectedCheckerDefinition"
+                [readonly]="!('UpdateSettings' | hasPermission)"
+                (remove)="removeChecker()"
+                formControlName="checker"
+            ></vdr-configurable-input>
+            <div *ngIf="!selectedChecker || !selectedCheckerDefinition">
+                <vdr-dropdown>
+                    <button class="btn btn-outline" vdrDropdownTrigger>
+                        <clr-icon shape="plus"></clr-icon>
+                        {{ 'common.select' | translate }}
+                    </button>
+                    <vdr-dropdown-menu vdrPosition="bottom-left">
+                        <button
+                            *ngFor="let checker of checkers"
+                            type="button"
+                            vdrDropdownItem
+                            (click)="selectChecker(checker)"
+                        >
+                            {{ checker.description }}
+                        </button>
+                    </vdr-dropdown-menu>
+                </vdr-dropdown>
+            </div>
+        </div>
+        <div class="clr-col">
+            <label class="clr-control-label">{{ 'settings.payment-handler' | translate }}</label>
+            <vdr-configurable-input
+                *ngIf="selectedHandler && selectedHandlerDefinition"
+                [operation]="selectedHandler"
+                [operationDefinition]="selectedHandlerDefinition"
+                [readonly]="!('UpdateSettings' | hasPermission)"
+                (remove)="removeHandler()"
+                formControlName="handler"
+            ></vdr-configurable-input>
+            <div *ngIf="!selectedHandler || !selectedHandlerDefinition">
+                <vdr-dropdown>
+                    <button class="btn btn-outline" vdrDropdownTrigger>
+                        <clr-icon shape="plus"></clr-icon>
+                        {{ 'common.select' | translate }}
+                    </button>
+                    <vdr-dropdown-menu vdrPosition="bottom-left">
+                        <button
+                            *ngFor="let handler of handlers"
+                            type="button"
+                            vdrDropdownItem
+                            (click)="selectHandler(handler)"
+                        >
+                            {{ handler.description }}
+                        </button>
+                    </vdr-dropdown-menu>
+                </vdr-dropdown>
             </div>
         </div>
-    </ng-container>
+    </div>
 </form>

+ 143 - 23
packages/admin-ui/src/lib/settings/src/components/payment-method-detail/payment-method-detail.component.ts

@@ -5,14 +5,21 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     BaseDetailComponent,
     ConfigArgDefinition,
+    configurableDefinitionToInstance,
+    ConfigurableOperation,
+    ConfigurableOperationDefinition,
+    CreatePaymentMethodInput,
     DataService,
     encodeConfigArgValue,
     getConfigArgValue,
     NotificationService,
     PaymentMethod,
     ServerConfigService,
+    toConfigurableOperationInput,
     UpdatePaymentMethodInput,
 } from '@vendure/admin-ui/core';
+import { normalizeString } from '@vendure/common/lib/normalize-string';
+import { combineLatest } from 'rxjs';
 import { mergeMap, take } from 'rxjs/operators';
 
 @Component({
@@ -21,9 +28,16 @@ import { mergeMap, take } from 'rxjs/operators';
     styleUrls: ['./payment-method-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class PaymentMethodDetailComponent extends BaseDetailComponent<PaymentMethod.Fragment>
+export class PaymentMethodDetailComponent
+    extends BaseDetailComponent<PaymentMethod.Fragment>
     implements OnInit, OnDestroy {
     detailForm: FormGroup;
+    checkers: ConfigurableOperationDefinition[] = [];
+    handlers: ConfigurableOperationDefinition[] = [];
+    selectedChecker?: ConfigurableOperation | null;
+    selectedCheckerDefinition?: ConfigurableOperationDefinition;
+    selectedHandler?: ConfigurableOperation | null;
+    selectedHandlerDefinition?: ConfigurableOperationDefinition;
 
     constructor(
         router: Router,
@@ -37,21 +51,48 @@ export class PaymentMethodDetailComponent extends BaseDetailComponent<PaymentMet
         super(route, router, serverConfigService, dataService);
         this.detailForm = this.formBuilder.group({
             code: ['', Validators.required],
+            name: ['', Validators.required],
+            description: '',
             enabled: [true, Validators.required],
-            configArgs: this.formBuilder.group({}),
+            checker: {},
+            handler: {},
         });
     }
 
     ngOnInit() {
         this.init();
+        combineLatest([
+            this.dataService.settings.getPaymentMethodOperations().single$,
+            this.entity$.pipe(take(1)),
+        ]).subscribe(([data, entity]) => {
+            this.checkers = data.paymentMethodEligibilityCheckers;
+            this.handlers = data.paymentMethodHandlers;
+            this.changeDetector.markForCheck();
+            this.selectedCheckerDefinition = data.paymentMethodEligibilityCheckers.find(
+                c => c.code === (entity.checker && entity.checker.code),
+            );
+            this.selectedHandlerDefinition = data.paymentMethodHandlers.find(
+                c => c.code === (entity.handler && entity.handler.code),
+            );
+        });
     }
 
     ngOnDestroy(): void {
         this.destroy();
     }
 
+    updateCode(currentCode: string, nameValue: string) {
+        if (!currentCode) {
+            const codeControl = this.detailForm.get(['code']);
+            if (codeControl && codeControl.pristine) {
+                codeControl.setValue(normalizeString(nameValue, '-'));
+            }
+        }
+    }
+
     getArgDef(paymentMethod: PaymentMethod.Fragment, argName: string): ConfigArgDefinition | undefined {
-        return paymentMethod.definition.args.find(a => a.name === argName);
+        // return paymentMethod.handler.args.find(a => a.name === argName);
+        return;
     }
 
     configArgsIsPopulated(): boolean {
@@ -62,20 +103,102 @@ export class PaymentMethodDetailComponent extends BaseDetailComponent<PaymentMet
         return 0 < Object.keys(configArgsGroup.controls).length;
     }
 
+    selectChecker(checker: ConfigurableOperationDefinition) {
+        this.selectedCheckerDefinition = checker;
+        this.selectedChecker = configurableDefinitionToInstance(checker);
+        const formControl = this.detailForm.get('checker');
+        if (formControl) {
+            formControl.clearValidators();
+            formControl.updateValueAndValidity({ onlySelf: true });
+            formControl.patchValue(this.selectedChecker);
+        }
+        this.detailForm.markAsDirty();
+    }
+
+    selectHandler(handler: ConfigurableOperationDefinition) {
+        this.selectedHandlerDefinition = handler;
+        this.selectedHandler = configurableDefinitionToInstance(handler);
+        const formControl = this.detailForm.get('handler');
+        if (formControl) {
+            formControl.clearValidators();
+            formControl.updateValueAndValidity({ onlySelf: true });
+            formControl.patchValue(this.selectedHandler);
+        }
+        this.detailForm.markAsDirty();
+    }
+
+    removeChecker() {
+        this.selectedChecker = null;
+        this.detailForm.markAsDirty();
+    }
+
+    removeHandler() {
+        this.selectedHandler = null;
+        this.detailForm.markAsDirty();
+    }
+
+    create() {
+        const selectedChecker = this.selectedChecker;
+        const selectedHandler = this.selectedHandler;
+        if (!selectedHandler) {
+            return;
+        }
+        this.entity$
+            .pipe(
+                take(1),
+                mergeMap(({ id }) => {
+                    const formValue = this.detailForm.value;
+                    const input: CreatePaymentMethodInput = {
+                        name: formValue.name,
+                        code: formValue.code,
+                        description: formValue.description,
+                        enabled: formValue.enabled,
+                        checker: selectedChecker
+                            ? toConfigurableOperationInput(selectedChecker, formValue.checker)
+                            : null,
+                        handler: toConfigurableOperationInput(selectedHandler, formValue.handler),
+                    };
+                    return this.dataService.settings.createPaymentMethod(input);
+                }),
+            )
+            .subscribe(
+                data => {
+                    this.notificationService.success(_('common.notify-create-success'), {
+                        entity: 'PaymentMethod',
+                    });
+                    this.detailForm.markAsPristine();
+                    this.changeDetector.markForCheck();
+                    this.router.navigate(['../', data.createPaymentMethod.id], { relativeTo: this.route });
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-create-error'), {
+                        entity: 'PaymentMethod',
+                    });
+                },
+            );
+    }
+
     save() {
+        const selectedChecker = this.selectedChecker;
+        const selectedHandler = this.selectedHandler;
+        if (!selectedHandler) {
+            return;
+        }
         this.entity$
             .pipe(
                 take(1),
-                mergeMap(({ id, configArgs }) => {
+                mergeMap(({ id }) => {
                     const formValue = this.detailForm.value;
                     const input: UpdatePaymentMethodInput = {
                         id,
+                        name: formValue.name,
                         code: formValue.code,
+                        description: formValue.description,
                         enabled: formValue.enabled,
-                        configArgs: Object.entries<any>(formValue.configArgs).map(([name, value], i) => ({
-                            name,
-                            value: encodeConfigArgValue(value),
-                        })),
+                        checker: selectedChecker
+                            ? toConfigurableOperationInput(selectedChecker, formValue.checker)
+                            : null,
+                        handler: toConfigurableOperationInput(selectedHandler, formValue.handler),
                     };
                     return this.dataService.settings.updatePaymentMethod(input);
                 }),
@@ -98,23 +221,20 @@ export class PaymentMethodDetailComponent extends BaseDetailComponent<PaymentMet
 
     protected setFormValues(paymentMethod: PaymentMethod.Fragment): void {
         this.detailForm.patchValue({
+            name: paymentMethod.name,
             code: paymentMethod.code,
+            description: paymentMethod.description,
             enabled: paymentMethod.enabled,
+            checker: paymentMethod.checker || {},
+            handler: paymentMethod.handler || {},
         });
-        const configArgsGroup = this.detailForm.get('configArgs') as FormGroup;
-        if (configArgsGroup) {
-            for (const arg of paymentMethod.configArgs) {
-                let control = configArgsGroup.get(arg.name);
-                const def = this.getArgDef(paymentMethod, arg.name);
-                const value = def?.list === true && arg.value === '' ? [] : getConfigArgValue(arg.value);
-                if (control) {
-                    control.patchValue(value);
-                } else {
-                    control = this.formBuilder.control(value);
-                    configArgsGroup.addControl(arg.name, control);
-                }
-            }
-        }
-        this.changeDetector.markForCheck();
+        this.selectedChecker = paymentMethod.checker && {
+            code: paymentMethod.checker.code,
+            args: paymentMethod.checker.args.map(a => ({ ...a, value: getConfigArgValue(a.value) })),
+        };
+        this.selectedHandler = paymentMethod.handler && {
+            code: paymentMethod.handler.code,
+            args: paymentMethod.handler.args.map(a => ({ ...a, value: getConfigArgValue(a.value) })),
+        };
     }
 }

+ 4 - 0
packages/admin-ui/src/lib/settings/src/components/payment-method-list/payment-method-list.component.html

@@ -1,6 +1,10 @@
 <vdr-action-bar>
     <vdr-ab-right>
         <vdr-action-bar-items locationId="payment-method-list"></vdr-action-bar-items>
+        <a class="btn btn-primary" [routerLink]="['./create']" *vdrIfPermissions="'CreateSettings'">
+            <clr-icon shape="plus"></clr-icon>
+            {{ 'settings.create-new-payment-method' | translate }}
+        </a>
     </vdr-ab-right>
 </vdr-action-bar>
 

+ 11 - 0
packages/admin-ui/src/lib/settings/src/components/profile/profile.component.html

@@ -30,4 +30,15 @@
     <vdr-form-field [label]="'settings.password' | translate" for="password" [readOnlyToggle]="true">
         <input id="password" type="password" formControlName="password" />
     </vdr-form-field>
+    <section formGroupName="customFields" *ngIf="customFields.length">
+        <label>{{ 'common.custom-fields' | translate }}</label>
+        <ng-container *ngFor="let customField of customFields">
+            <vdr-custom-field-control
+                *ngIf="customFieldIsSet(customField.name)"
+                entityName="GlobalSettings"
+                [customFieldsFormGroup]="detailForm.get('customFields')"
+                [customField]="customField"
+            ></vdr-custom-field-control>
+        </ng-container>
+    </section>
 </form>

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