Просмотр исходного кода

Merge branch 'master' into minor

Michael Bromley 2 лет назад
Родитель
Сommit
b049d376c3
19 измененных файлов с 457 добавлено и 249 удалено
  1. 2 1
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html
  2. 2 2
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  3. 166 166
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  4. 1 18
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  5. 158 0
      packages/core/e2e/default-search-plugin/default-search-plugin-sort-by.e2e-spec.ts
  6. 16 0
      packages/core/e2e/default-search-plugin/fixtures/default-search-plugin-sort-by.csv
  7. 19 0
      packages/core/e2e/graphql/admin-definitions.ts
  8. 7 7
      packages/core/src/api/resolvers/entity/administrator-entity.resolver.ts
  9. 3 3
      packages/core/src/i18n/messages/de.json
  10. 11 6
      packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts
  11. 8 6
      packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts
  12. 13 10
      packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts
  13. 2 0
      packages/core/src/service/helpers/translatable-saver/translatable-saver.ts
  14. 1 0
      packages/dev-server/.gitignore
  15. 14 0
      packages/dev-server/docker-compose.yml
  16. 2 1
      packages/dev-server/test-plugins/keycloak-auth/keycloak-auth-plugin.ts
  17. 11 9
      packages/dev-server/test-plugins/keycloak-auth/keycloak-authentication-strategy.ts
  18. 20 20
      packages/dev-server/test-plugins/keycloak-auth/public/index.html
  19. 1 0
      packages/payments-plugin/src/stripe/types.ts

+ 2 - 1
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html

@@ -138,7 +138,8 @@
                     <a
                     <a
                         class="button-small bg-weight-150"
                         class="button-small bg-weight-150"
                         [routerLink]="['./', { contents: collection.id }]"
                         [routerLink]="['./', { contents: collection.id }]"
-                        queryParamsHandling="preserve"
+                        [queryParams]="{ contentsPage: 1 }"
+                        queryParamsHandling="merge"
                     >
                     >
                         <span>{{ 'common.view-contents' | translate }}</span>
                         <span>{{ 'common.view-contents' | translate }}</span>
                         <clr-icon shape="file-group"></clr-icon>
                         <clr-icon shape="file-group"></clr-icon>

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

@@ -477,7 +477,7 @@
   },
   },
   "error": {
   "error": {
     "403-forbidden": "No momento, você não está autorizado a acessar \"{ path }\". Você não tem permissão ou sua sessão expirou.",
     "403-forbidden": "No momento, você não está autorizado a acessar \"{ path }\". Você não tem permissão ou sua sessão expirou.",
-    "could-not-connect-to-server": "Não foi possível ao servidor Vendure no link { url }",
+    "could-not-connect-to-server": "Não foi possível conectar ao servidor Vendure no link { url }",
     "health-check-failed": "Falha na verificação de integridade do sistema",
     "health-check-failed": "Falha na verificação de integridade do sistema",
     "no-default-shipping-zone-set": "Este canal não possui zona de entrega padrão. Isso pode causar erros ao calcular as despesas de envio do pedido.",
     "no-default-shipping-zone-set": "Este canal não possui zona de entrega padrão. Isso pode causar erros ao calcular as despesas de envio do pedido.",
     "no-default-tax-zone-set": "Este canal não possui zona de imposto padrão, o que causará erros no cálculo de preços. Por favor, crie ou selecione uma zona."
     "no-default-tax-zone-set": "Este canal não possui zona de imposto padrão, o que causará erros no cálculo de preços. Por favor, crie ou selecione uma zona."
@@ -800,4 +800,4 @@
     "job-state-pending": "Pendente",
     "job-state-pending": "Pendente",
     "job-state-running": "Em execução"
     "job-state-running": "Em execução"
   }
   }
-}
+}

+ 166 - 166
packages/admin-ui/src/lib/static/i18n-messages/uk.json

@@ -40,13 +40,13 @@
     "modifying-order": "Модифікація замовлення",
     "modifying-order": "Модифікація замовлення",
     "orders": "Замовлення",
     "orders": "Замовлення",
     "payment-methods": "Способи оплати",
     "payment-methods": "Способи оплати",
-    "product-options": "",
+    "product-options": "Опції товару",
     "products": "Товари",
     "products": "Товари",
     "profile": "Профіль",
     "profile": "Профіль",
     "promotions": "Акції",
     "promotions": "Акції",
     "roles": "Ролі",
     "roles": "Ролі",
-    "seller-orders": "",
-    "sellers": "",
+    "seller-orders": "Замовлення продавця",
+    "sellers": "Продавці",
     "shipping-methods": "Способи доставки",
     "shipping-methods": "Способи доставки",
     "stock-locations": "Місця зберігання",
     "stock-locations": "Місця зберігання",
     "system-status": "Стан системи",
     "system-status": "Стан системи",
@@ -58,12 +58,12 @@
     "add-facet-value": "Додати значення тегу",
     "add-facet-value": "Додати значення тегу",
     "add-facets": "Додати тег",
     "add-facets": "Додати тег",
     "add-option": "Додати опцію",
     "add-option": "Додати опцію",
-    "add-price-in-another-currency": "",
-    "add-stock-location": "",
-    "add-stock-to-location": "",
-    "asset": "",
-    "asset-preview-links": "",
-    "assets": "",
+    "add-price-in-another-currency": "Додати ціну в іншій валюті",
+    "add-stock-location": "Додати склад",
+    "add-stock-to-location": "Додати до складу",
+    "asset": "Медіа-об'єкт",
+    "asset-preview-links": "Посилання на медіа-об'єкт",
+    "assets": "Медіа-об'єкти",
     "assign-product-to-channel-success": "Товар успішно доданий в канал \"{ channel }\"",
     "assign-product-to-channel-success": "Товар успішно доданий в канал \"{ channel }\"",
     "assign-products-to-channel": "Додати товари в канал",
     "assign-products-to-channel": "Додати товари в канал",
     "assign-to-channel": "Додати в канал",
     "assign-to-channel": "Додати в канал",
@@ -72,84 +72,84 @@
     "assign-variants-to-channel": "Додати варіанти товару в канал",
     "assign-variants-to-channel": "Додати варіанти товару в канал",
     "auto-update-option-variant-name": "Автоматично оновлювати назви варіантів товару, використовуючи цю опцію",
     "auto-update-option-variant-name": "Автоматично оновлювати назви варіантів товару, використовуючи цю опцію",
     "auto-update-product-variant-name": "Автоматично оновлювати назви варіантів товару",
     "auto-update-product-variant-name": "Автоматично оновлювати назви варіантів товару",
-    "cannot-create-variants-without-options": "",
+    "cannot-create-variants-without-options": "Не можна створити варіант без опцій",
     "channel-price-preview": "Попередній перегляд цін каналу",
     "channel-price-preview": "Попередній перегляд цін каналу",
-    "collection": "",
+    "collection": "Колекція",
     "collection-contents": "Зміст колекції",
     "collection-contents": "Зміст колекції",
-    "collections": "",
-    "confirm-bulk-delete-products": "",
-    "confirm-cancel": "",
+    "collections": "Колекції",
+    "confirm-bulk-delete-products": "Видалити {count} {count, plural, one {продукт} few {продукти} other {продуктів}}?",
+    "confirm-cancel": "Скасувати?",
     "confirm-delete-assets": "Видалити {count} {count, plural, one {медіа-об'єкт} other {медіа-об'єктів}}?",
     "confirm-delete-assets": "Видалити {count} {count, plural, one {медіа-об'єкт} other {медіа-об'єктів}}?",
     "confirm-delete-facet-value": "Видалити значення тегу?",
     "confirm-delete-facet-value": "Видалити значення тегу?",
     "confirm-delete-product": "Видалити товар?",
     "confirm-delete-product": "Видалити товар?",
-    "confirm-delete-product-option": "",
-    "confirm-delete-product-option-group": "",
-    "confirm-delete-product-option-group-body": "",
+    "confirm-delete-product-option": "Видалити опцію товару \"{name}\"?",
+    "confirm-delete-product-option-group": "Видалити групу опцій  \"{name}\"?",
+    "confirm-delete-product-option-group-body": "Ця група опцій використовується {count} {count, plural, one {варіантом} other {варіантами}}. Ви впевнені, що хочете її видалити?",
     "confirm-delete-product-variant": "Видалити варіант товару?",
     "confirm-delete-product-variant": "Видалити варіант товару?",
     "confirm-deletion-of-unused-variants-body": "Наступні варіанти товару застаріли через додавання нових опцій. Вони будуть видалені під час створення нових варіантів товару.",
     "confirm-deletion-of-unused-variants-body": "Наступні варіанти товару застаріли через додавання нових опцій. Вони будуть видалені під час створення нових варіантів товару.",
     "confirm-deletion-of-unused-variants-title": "Видалити застарілі варіанти товару?",
     "confirm-deletion-of-unused-variants-title": "Видалити застарілі варіанти товару?",
-    "create-draft-order": "",
+    "create-draft-order": "Створити чернетку замовлення",
     "create-facet-value": "Створити нове значення тегу",
     "create-facet-value": "Створити нове значення тегу",
     "create-new-collection": "Створити нову колекцію",
     "create-new-collection": "Створити нову колекцію",
     "create-new-facet": "Створити новий тег",
     "create-new-facet": "Створити новий тег",
     "create-new-product": "Створити новий товар",
     "create-new-product": "Створити новий товар",
-    "create-new-stock-location": "",
-    "create-product-option-group": "",
-    "create-product-variant": "",
-    "default-currency": "",
-    "do-not-inherit-filters": "",
+    "create-new-stock-location": "Створити новий склад",
+    "create-product-option-group": "Створити групу опцій товару",
+    "create-product-variant": "Створити варіант товару",
+    "default-currency": "Основна валюта",
+    "do-not-inherit-filters": "Не наслідувати фільтри",
     "drop-files-to-upload": "Перетягніть файли для завантаження",
     "drop-files-to-upload": "Перетягніть файли для завантаження",
-    "edit-facet-values": "",
-    "edit-options": "",
-    "facet": "",
-    "facet-value-not-available": "",
+    "edit-facet-values": "Редагувати значення тега",
+    "edit-options": "Редагувати опції",
+    "facet": "Тег",
+    "facet-value-not-available": "Значення тегу \"{ id }\" недоступне",
     "facet-values": "Значення тегу",
     "facet-values": "Значення тегу",
-    "facets": "",
+    "facets": "Теги",
     "filter-by-name": "Фільтр по імені",
     "filter-by-name": "Фільтр по імені",
-    "filter-inheritance": "",
+    "filter-inheritance": "Наслідування фільтрів",
     "filters": "Фільтри",
     "filters": "Фільтри",
-    "inherit-filters-from-parent": "",
-    "live-preview-contents": "",
+    "inherit-filters-from-parent": "Наслідувати фільтри від батька",
+    "live-preview-contents": "Попередній перегляд вмісту",
     "manage-variants": "Управління варіантами",
     "manage-variants": "Управління варіантами",
-    "move-collection-to": "",
-    "move-collections": "",
-    "move-collections-success": "",
+    "move-collection-to": "Перемістити до { name }",
+    "move-collections": "Перемістити колекції",
+    "move-collections-success": "Переміщено {count, plural, one {1 колекцію} few {{count} колекції} other {{count} колекцій}}",
     "move-down": "Рухати вниз",
     "move-down": "Рухати вниз",
     "move-to": "Рухати до",
     "move-to": "Рухати до",
     "move-up": "Рухати вгору",
     "move-up": "Рухати вгору",
-    "name": "",
+    "name": "Назва",
     "no-channel-selected": "Канал не вибрано",
     "no-channel-selected": "Канал не вибрано",
     "no-featured-asset": "Немає обраного медіа-об'єкта",
     "no-featured-asset": "Немає обраного медіа-об'єкта",
     "no-selection": "Не вибрано",
     "no-selection": "Не вибрано",
-    "no-stock-locations-available-on-current-channel": "",
-    "notify-bulk-delete-products-success": "",
-    "notify-remove-facets-from-channel-success": "",
+    "no-stock-locations-available-on-current-channel": "На поточному каналі немає доступних складів. Налаштуйте принаймні один склад перед додаванням продуктів.",
+    "notify-bulk-delete-products-success": "Успішно видалено {count, plural, one {1 продукт} few {{count} продукти} other {{count} продуктів}}",
+    "notify-remove-facets-from-channel-success": "Успішно видалено {count, plural, one {1 тег} few {{count} теги} other {{count} тегів}} з { channelCode }",
     "notify-remove-product-from-channel-error": "Не вдалося видалити товар з каналу",
     "notify-remove-product-from-channel-error": "Не вдалося видалити товар з каналу",
     "notify-remove-product-from-channel-success": "Товар успішно видалений з каналу",
     "notify-remove-product-from-channel-success": "Товар успішно видалений з каналу",
     "notify-remove-variant-from-channel-error": "Не вдалося видалити варіант товару з каналу",
     "notify-remove-variant-from-channel-error": "Не вдалося видалити варіант товару з каналу",
     "notify-remove-variant-from-channel-success": "Варіант товару успішно видалений з каналу",
     "notify-remove-variant-from-channel-success": "Варіант товару успішно видалений з каналу",
-    "number-of-variants": "",
+    "number-of-variants": "# варіантів",
     "option": "Опції",
     "option": "Опції",
     "option-name": "Ім'я опції",
     "option-name": "Ім'я опції",
     "option-values": "Значення опції",
     "option-values": "Значення опції",
     "out-of-stock-threshold": "Поріг «немає в наявності»",
     "out-of-stock-threshold": "Поріг «немає в наявності»",
     "out-of-stock-threshold-tooltip": "Встановіть поріг залишків на складі, після якого буде вважатися, що варіанту товару немає в наявності. При використанні від'ємного значення порога, включається підтримка попереднього замовлення.",
     "out-of-stock-threshold-tooltip": "Встановіть поріг залишків на складі, після якого буде вважатися, що варіанту товару немає в наявності. При використанні від'ємного значення порога, включається підтримка попереднього замовлення.",
-    "page-description-options-editor": "",
+    "page-description-options-editor": "Редагуйте назви та коди опцій цього продукту. Щоб додати або видалити опції, використовуйте кнопку \"управління варіантами\" нижче у списку варіантів продукту.",
     "price": "Ціна",
     "price": "Ціна",
-    "price-and-tax": "",
+    "price-and-tax": "Ціна та податок",
     "price-conversion-factor": "Коефіцієнт перерахунку ціни",
     "price-conversion-factor": "Коефіцієнт перерахунку ціни",
     "price-in-channel": "Ціна в { channel }",
     "price-in-channel": "Ціна в { channel }",
     "price-includes-tax-at": "Включає податок в { rate }%",
     "price-includes-tax-at": "Включає податок в { rate }%",
     "price-with-tax-in-default-zone": "Вкл. { rate }% податок: { price }",
     "price-with-tax-in-default-zone": "Вкл. { rate }% податок: { price }",
     "private": "Приватний",
     "private": "Приватний",
-    "product": "",
+    "product": "Товар",
     "product-name": "Ім'я товару",
     "product-name": "Ім'я товару",
-    "product-options": "",
-    "product-variant-exists": "",
+    "product-options": "Опції продукту",
+    "product-variant-exists": "Варіант продукту з цими опціями вже існує",
     "product-variants": "Варіант товару",
     "product-variants": "Варіант товару",
     "products": "Товари",
     "products": "Товари",
     "public": "Публічний",
     "public": "Публічний",
-    "quick-jump-placeholder": "",
+    "quick-jump-placeholder": "Перейти до варіанту",
     "rebuild-search-index": "Відновити пошуковий індекс",
     "rebuild-search-index": "Відновити пошуковий індекс",
     "reindex-error": "Помилка при перебудові індексу пошуку",
     "reindex-error": "Помилка при перебудові індексу пошуку",
     "reindex-successful": "Проіндексовано {count, plural, one {варіант товару} other {{count} варіантів товару}} за {time}мс",
     "reindex-successful": "Проіндексовано {count, plural, one {варіант товару} other {{count} варіантів товару}} за {time}мс",
@@ -158,10 +158,10 @@
     "remove-option": "Видалити опції",
     "remove-option": "Видалити опції",
     "remove-product-from-channel": "Видалити товар з каналу",
     "remove-product-from-channel": "Видалити товар з каналу",
     "remove-product-variant-from-channel": "Видалити варіант товару з каналу",
     "remove-product-variant-from-channel": "Видалити варіант товару з каналу",
-    "reorder-collection": "",
-    "root-collection": "",
-    "run-pending-search-index-updates": "",
-    "running-search-index-updates": "",
+    "reorder-collection": "Переупорядкувати колекцію",
+    "root-collection": "Коренева колекція",
+    "run-pending-search-index-updates": "Індекс пошуку: виконує {count, plural, one {1 оновлення, що очікує} other {{count} оновлень, що очікують}}",
+    "running-search-index-updates": "Виконується {count, plural, one {1 оновлення} other {{count} оновлень}} для індексу пошуку",
     "search-asset-name-or-tag": "Пошук за назвою медіа-об'єкта або тегами",
     "search-asset-name-or-tag": "Пошук за назвою медіа-об'єкта або тегами",
     "search-for-term": "Шукати по фразі",
     "search-for-term": "Шукати по фразі",
     "search-product-name-or-code": "Пошук за назвою товару або кодом",
     "search-product-name-or-code": "Пошук за назвою товару або кодом",
@@ -171,7 +171,7 @@
     "slug": "Код",
     "slug": "Код",
     "slug-pattern-error": "Код заданий некоректно",
     "slug-pattern-error": "Код заданий некоректно",
     "stock-allocated": "Зарезервовано",
     "stock-allocated": "Зарезервовано",
-    "stock-levels": "",
+    "stock-levels": "Рівні запасів",
     "stock-location": "Mісце зберігання",
     "stock-location": "Mісце зберігання",
     "stock-locations": "Місця зберігання",
     "stock-locations": "Місця зберігання",
     "stock-on-hand": "На складі",
     "stock-on-hand": "На складі",
@@ -186,40 +186,40 @@
     "use-global-value": "Використовувати глобальне значення",
     "use-global-value": "Використовувати глобальне значення",
     "values": "Значення",
     "values": "Значення",
     "variant": "Варіант",
     "variant": "Варіант",
-    "variant-count": "",
+    "variant-count": "{count, plural, one {1 варіант} few {{count} варіанти} other {{count} варіантів}}",
     "view-contents": "Перегляд вмісту",
     "view-contents": "Перегляд вмісту",
     "visibility": "Видимість"
     "visibility": "Видимість"
   },
   },
   "common": {
   "common": {
     "ID": "ID",
     "ID": "ID",
-    "add-filter": "",
+    "add-filter": "Додати фільтр",
     "add-item-to-list": "Додати позицію в список",
     "add-item-to-list": "Додати позицію в список",
     "add-note": "Додати замітку",
     "add-note": "Додати замітку",
-    "apply": "",
-    "assign-to-channel": "",
-    "available-currencies": "",
+    "apply": "Застосувати",
+    "assign-to-channel": "Призначити каналу",
+    "available-currencies": "Доступні валюти",
     "available-languages": "Доступні мови",
     "available-languages": "Доступні мови",
-    "boolean-and": "",
-    "boolean-false": "",
-    "boolean-or": "",
-    "boolean-true": "",
-    "breadcrumb": "",
-    "browser-default": "",
+    "boolean-and": "і",
+    "boolean-false": "неправда",
+    "boolean-or": "або",
+    "boolean-true": "правда",
+    "breadcrumb": "Навігаційний ланцюжок",
+    "browser-default": "За замовчуванням у браузері",
     "cancel": "Скасування",
     "cancel": "Скасування",
     "cancel-navigation": "Скасувати навігацію",
     "cancel-navigation": "Скасувати навігацію",
     "change-selection": "Змінити вибір",
     "change-selection": "Змінити вибір",
     "channel": "Канал",
     "channel": "Канал",
     "channels": "Канали",
     "channels": "Канали",
-    "clear-selection": "",
+    "clear-selection": "Очистити вибір",
     "code": "Код",
     "code": "Код",
     "collapse-entries": "Згорнути записи",
     "collapse-entries": "Згорнути записи",
     "confirm": "Підтверджувати",
     "confirm": "Підтверджувати",
-    "confirm-bulk-assign-to-channel": "",
-    "confirm-bulk-delete": "",
-    "confirm-bulk-remove-from-channel": "",
+    "confirm-bulk-assign-to-channel": "Призначити елементи каналу?",
+    "confirm-bulk-delete": "Видалити обрані елементи?",
+    "confirm-bulk-remove-from-channel": "Видалити елементи з поточного каналу?",
     "confirm-delete-note": "Видалити замітку?",
     "confirm-delete-note": "Видалити замітку?",
     "confirm-navigation": "Підтвердіть навігацію",
     "confirm-navigation": "Підтвердіть навігацію",
-    "contents": "",
+    "contents": "Вміст",
     "create": "Створити",
     "create": "Створити",
     "created-at": "Створено в",
     "created-at": "Створено в",
     "custom-fields": "Настроювані поля",
     "custom-fields": "Настроювані поля",
@@ -238,93 +238,93 @@
     "edit-field": "Редагувати поле",
     "edit-field": "Редагувати поле",
     "edit-note": "Редагувати замітку",
     "edit-note": "Редагувати замітку",
     "enabled": "Включений",
     "enabled": "Включений",
-    "end-date": "",
+    "end-date": "Дата закінчення",
     "expand-entries": "Розгорнути записи",
     "expand-entries": "Розгорнути записи",
     "extension-running-in-separate-window": "Розширення працює в окремому вікні",
     "extension-running-in-separate-window": "Розширення працює в окремому вікні",
     "filter": "Фільтр",
     "filter": "Фільтр",
     "filter-preset-name": "Назва передустановки фільтра",
     "filter-preset-name": "Назва передустановки фільтра",
-    "force-delete": "",
-    "force-remove": "",
-    "general": "",
+    "force-delete": "Примусове видалення",
+    "force-remove": "Примусове видалення",
+    "general": "Загальне",
     "guest": "Гість",
     "guest": "Гість",
-    "id": "",
-    "image": "",
+    "id": "ID",
+    "image": "Зображення",
     "items-per-page-option": "{ count } на сторінці",
     "items-per-page-option": "{ count } на сторінці",
-    "items-selected-count": "",
-    "keep-editing": "",
+    "items-selected-count": "{ count } {count, plural, one {елемент} few {елементи} other {елементів}} вибрано",
+    "keep-editing": "Продовжити редагування",
     "language": "Мова",
     "language": "Мова",
     "launch-extension": "Запуск розширення",
     "launch-extension": "Запуск розширення",
-    "list-items-and-n-more": "",
+    "list-items-and-n-more": "{ items } та ще {nMore}",
     "live-update": "Оновлення в режимі реального часу",
     "live-update": "Оновлення в режимі реального часу",
-    "locale": "",
+    "locale": "Локаль",
     "log-out": "Вийти",
     "log-out": "Вийти",
     "login": "Увійти",
     "login": "Увійти",
-    "login-image-title": "",
-    "login-title": "",
+    "login-image-title": "Привіт! Раді знову вас бачити. Добре вас бачити.",
+    "login-title": "Увійти у {brand}",
     "manage-tags": "Керування тегами",
     "manage-tags": "Керування тегами",
     "manage-tags-description": "Оновлення або видалення тегів глобально.",
     "manage-tags-description": "Оновлення або видалення тегів глобально.",
-    "medium-date": "",
+    "medium-date": "Середня дата",
     "more": "Більше...",
     "more": "Більше...",
     "name": "Ім'я",
     "name": "Ім'я",
-    "no-alerts": "",
-    "no-bulk-actions-available": "",
+    "no-alerts": "Немає сповіщень",
+    "no-bulk-actions-available": "Масові дії недоступні",
     "no-results": "Немає результатів",
     "no-results": "Немає результатів",
-    "not-applicable": "",
+    "not-applicable": "Не застосовно",
     "not-set": "Не задано",
     "not-set": "Не задано",
-    "notify-assign-to-channel-success-with-count": "",
-    "notify-bulk-update-success": "",
+    "notify-assign-to-channel-success-with-count": "Успішно призначено {count, plural, one {1 елемент} few {{count} елементи} other {{count} елементів}} каналу { channelCode }",
+    "notify-bulk-update-success": "Оновлено { count } { entity }",
     "notify-create-error": "Помилка, не вдалося створити { entity }",
     "notify-create-error": "Помилка, не вдалося створити { entity }",
     "notify-create-success": "Створено нове { entity }",
     "notify-create-success": "Створено нове { entity }",
     "notify-delete-error": "Помилка, не вдалося видалити { entity }",
     "notify-delete-error": "Помилка, не вдалося видалити { entity }",
-    "notify-delete-error-with-count": "",
+    "notify-delete-error-with-count": "Не вдалося видалити {count, plural, one {1 елемент} few {{count} елементи} other {{count} елементів}}",
     "notify-delete-success": "Видалено { entity }",
     "notify-delete-success": "Видалено { entity }",
-    "notify-delete-success-with-count": "",
-    "notify-remove-from-channel-success-with-count": "",
+    "notify-delete-success-with-count": "Успішно видалено {count, plural, one {1 елемент} few {{count} елементи}  other {{count} елементів}}",
+    "notify-remove-from-channel-success-with-count": "Успішно видалено { count } елементів з каналу",
     "notify-save-changes-error": "Сталася помилка, не вдалося зберегти зміни",
     "notify-save-changes-error": "Сталася помилка, не вдалося зберегти зміни",
     "notify-saved-changes": "Збережені зміни",
     "notify-saved-changes": "Збережені зміни",
     "notify-update-error": "Сталася помилка, не вдалося оновити { entity }",
     "notify-update-error": "Сталася помилка, не вдалося оновити { entity }",
     "notify-update-success": "Оновлено { entity }",
     "notify-update-success": "Оновлено { entity }",
     "notify-updated-tags-success": "Успішно оновлені теги",
     "notify-updated-tags-success": "Успішно оновлені теги",
-    "okay": "",
-    "operator-contains": "",
-    "operator-eq": "",
-    "operator-gt": "",
-    "operator-lt": "",
-    "operator-not-contains": "",
-    "operator-not-eq": "",
-    "operator-notContains": "",
-    "operator-regex": "",
+    "okay": "Гаразд",
+    "operator-contains": "містить",
+    "operator-eq": "дорівнює",
+    "operator-gt": "більше ніж",
+    "operator-lt": "менше ніж",
+    "operator-not-contains": "не містить",
+    "operator-not-eq": "не дорівнює",
+    "operator-notContains": "не містить",
+    "operator-regex": "відповідає регулярному виразу",
     "password": "Пароль",
     "password": "Пароль",
-    "position": "",
+    "position": "Позиція",
     "price": "Ціна",
     "price": "Ціна",
     "price-with-tax": "Ціна з податком",
     "price-with-tax": "Ціна з податком",
     "private": "Службова",
     "private": "Службова",
     "public": "Публічна",
     "public": "Публічна",
     "remember-me": "Запам'ятати мене",
     "remember-me": "Запам'ятати мене",
     "remove": "Видалити",
     "remove": "Видалити",
-    "remove-from-channel": "",
+    "remove-from-channel": "Видалити з поточного каналу",
     "remove-item-from-list": "Видалити позицію зі списку",
     "remove-item-from-list": "Видалити позицію зі списку",
     "rename-filter-preset": "Перейменувати пресет",
     "rename-filter-preset": "Перейменувати пресет",
-    "reset-columns": "",
+    "reset-columns": "Скинути стовпці",
     "results-count": "{ count } {count, plural, one {результат} other {результатів}}",
     "results-count": "{ count } {count, plural, one {результат} other {результатів}}",
-    "sample-formatting": "",
+    "sample-formatting": "Приклад форматування",
     "save-filter-preset": "Зберегти як передустановку",
     "save-filter-preset": "Зберегти як передустановку",
-    "search-and-filter-list": "",
-    "search-by-name": "",
+    "search-and-filter-list": "Пошук і фільтрація цього списку",
+    "search-by-name": "Пошук за назвою",
     "select": "Вибрати...",
     "select": "Вибрати...",
     "select-display-language": "Виберіть мову відображення",
     "select-display-language": "Виберіть мову відображення",
-    "select-items-with-count": "",
-    "select-products": "",
-    "select-relation-id": "",
-    "select-table-columns": "",
+    "select-items-with-count": "Вибрати { count } {count, plural, one {елемент} few {елементи} other {елементів}}",
+    "select-products": "Вибрати продукти",
+    "select-relation-id": "Вибрати повізаний ID",
+    "select-table-columns": "Вибрати стовпці таблиці",
     "select-today": "Виберіть сьогодні",
     "select-today": "Виберіть сьогодні",
-    "select-variants": "",
-    "seller": "",
-    "set-language": "",
-    "short-date": "",
-    "slug": "",
-    "start-date": "",
-    "status": "",
+    "select-variants": "Вибрати варіанти",
+    "seller": "Продавець",
+    "set-language": "Встановити мову",
+    "short-date": "Короткий формат дати",
+    "slug": "Slug",
+    "start-date": "Дата початку",
+    "status": "Статус",
     "tags": "Теги",
     "tags": "Теги",
     "theme": "Тема",
     "theme": "Тема",
     "there-are-unsaved-changes": "Є незбережені зміни. Якщо ви вийдете, ці зміни будуть втрачені.",
     "there-are-unsaved-changes": "Є незбережені зміни. Якщо ви вийдете, ці зміни будуть втрачені.",
@@ -333,11 +333,11 @@
     "update": "Оновити",
     "update": "Оновити",
     "updated-at": "Оновлено в",
     "updated-at": "Оновлено в",
     "username": "Ім'я користувача",
     "username": "Ім'я користувача",
-    "value": "",
-    "view-contents": "",
+    "value": "Значення",
+    "view-contents": "Переглянути вміст",
     "view-next-month": "Переглянути наступний місяць",
     "view-next-month": "Переглянути наступний місяць",
     "view-previous-month": "Переглянути попередній місяць",
     "view-previous-month": "Переглянути попередній місяць",
-    "visibility": "",
+    "visibility": "Видимість",
     "with-selected": "З вибраним..."
     "with-selected": "З вибраним..."
   },
   },
   "customer": {
   "customer": {
@@ -349,18 +349,18 @@
     "add-customers-to-group-with-name": "Додати клієнтів в \"{ groupName }\"",
     "add-customers-to-group-with-name": "Додати клієнтів в \"{ groupName }\"",
     "addresses": "Адреси",
     "addresses": "Адреси",
     "city": "Місто",
     "city": "Місто",
-    "company": "",
+    "company": "Компанія",
     "confirm-remove-customer-from-group": "Видалити клієнта з групи?",
     "confirm-remove-customer-from-group": "Видалити клієнта з групи?",
     "country": "Країна",
     "country": "Країна",
     "create-customer-group": "Створити групу клієнтів",
     "create-customer-group": "Створити групу клієнтів",
     "create-new-address": "Створити нову адресу",
     "create-new-address": "Створити нову адресу",
     "create-new-customer": "Створити нового клієнта",
     "create-new-customer": "Створити нового клієнта",
     "create-new-customer-group": "Створити нову групу клієнтів",
     "create-new-customer-group": "Створити нову групу клієнтів",
-    "customer": "",
-    "customer-group": "",
+    "customer": "Клієнт",
+    "customer-group": "Група клієнтів",
     "customer-groups": "Групи клієнтів",
     "customer-groups": "Групи клієнтів",
     "customer-history": "Історія зміни клієнтів",
     "customer-history": "Історія зміни клієнтів",
-    "customers": "",
+    "customers": "Клієнти",
     "default-billing-address": "Білінг за замовчуванням",
     "default-billing-address": "Білінг за замовчуванням",
     "default-shipping-address": "Доставка за замовчуванням",
     "default-shipping-address": "Доставка за замовчуванням",
     "email-address": "Адреса електронної пошти",
     "email-address": "Адреса електронної пошти",
@@ -399,7 +399,7 @@
     "remove-customers-from-group-success": "Видалено {customerCount, plural, one {1 клієнт} other {{customerCount} клієнтів}} из \"{ groupName }\"",
     "remove-customers-from-group-success": "Видалено {customerCount, plural, one {1 клієнт} other {{customerCount} клієнтів}} из \"{ groupName }\"",
     "remove-from-group": "Вилучити з цієї групи",
     "remove-from-group": "Вилучити з цієї групи",
     "search-customers-by-email": "Пошук за адресою електронної пошти",
     "search-customers-by-email": "Пошук за адресою електронної пошти",
-    "search-customers-by-email-last-name-postal-code": "",
+    "search-customers-by-email-last-name-postal-code": "Пошук за електронною поштою / прізвищем / поштовим індексом",
     "select-customer": "Виберіть клієнта",
     "select-customer": "Виберіть клієнта",
     "set-as-default-billing-address": "Встановити як білінг за замовчуванням",
     "set-as-default-billing-address": "Встановити як білінг за замовчуванням",
     "set-as-default-shipping-address": "Встановити як доставку за замовчуванням",
     "set-as-default-shipping-address": "Встановити як доставку за замовчуванням",
@@ -413,10 +413,10 @@
   "dashboard": {
   "dashboard": {
     "add-widget": "Додати віджет",
     "add-widget": "Додати віджет",
     "latest-orders": "Останні замовлення",
     "latest-orders": "Останні замовлення",
-    "metric-average-order-value": "",
-    "metric-number-of-orders": "",
-    "metric-order-total-value": "",
-    "metrics": "",
+    "metric-average-order-value": "Середня вартість замовлення",
+    "metric-number-of-orders": "Кількість замовлень",
+    "metric-order-total-value": "Загальна вартість замовлень",
+    "metrics": "Метрики",
     "orders-summary": "Зведення замовлень",
     "orders-summary": "Зведення замовлень",
     "remove-widget": "Видалити віджет",
     "remove-widget": "Видалити віджет",
     "thisMonth": "Цей місяць",
     "thisMonth": "Цей місяць",
@@ -470,7 +470,7 @@
     "image-title": "Заголовок",
     "image-title": "Заголовок",
     "insert-image": "Вставити зображення",
     "insert-image": "Вставити зображення",
     "link-href": "Посилання",
     "link-href": "Посилання",
-    "link-target": "",
+    "link-target": "Ціль посилання",
     "link-title": "Заголовок посилання",
     "link-title": "Заголовок посилання",
     "remove-link": "Удалить",
     "remove-link": "Удалить",
     "set-link": "Встановити посилання"
     "set-link": "Встановити посилання"
@@ -492,8 +492,8 @@
     "ends-at": "Закінчується в",
     "ends-at": "Закінчується в",
     "per-customer-limit": "Ліміт на клієнта",
     "per-customer-limit": "Ліміт на клієнта",
     "per-customer-limit-tooltip": "Максимальна кількість разів, коли цей промокод може бути використаний одним клієнтом",
     "per-customer-limit-tooltip": "Максимальна кількість разів, коли цей промокод може бути використаний одним клієнтом",
-    "promotion": "",
-    "search-by-name-or-coupon-code": "",
+    "promotion": "Акція",
+    "search-by-name-or-coupon-code": "Пошук за назвою або кодом купона",
     "starts-at": "Починається в",
     "starts-at": "Починається в",
     "usage-limit": "Загальний ліміт використання",
     "usage-limit": "Загальний ліміт використання",
     "usage-limit-tooltip": "Максимальна кількість разів, коли цей промокод може бути використаний в загальному"
     "usage-limit-tooltip": "Максимальна кількість разів, коли цей промокод може бути використаний в загальному"
@@ -517,7 +517,7 @@
     "promotions": "Промо-акції",
     "promotions": "Промо-акції",
     "roles": "Ролі",
     "roles": "Ролі",
     "sales": "Продажі",
     "sales": "Продажі",
-    "sellers": "",
+    "sellers": "Продавці",
     "settings": "Налаштування",
     "settings": "Налаштування",
     "shipping-methods": "Способи доставки",
     "shipping-methods": "Способи доставки",
     "stock-locations": "Місця зберігання",
     "stock-locations": "Місця зберігання",
@@ -540,7 +540,7 @@
     "assign-order-to-another-customer": "Призначити замовлення іншому клієнту",
     "assign-order-to-another-customer": "Призначити замовлення іншому клієнту",
     "billing-address": "Платіжна адреса",
     "billing-address": "Платіжна адреса",
     "cancel": "Скасування",
     "cancel": "Скасування",
-    "cancel-entire-order": "",
+    "cancel-entire-order": "Скасувати все замовлення",
     "cancel-fulfillment": "Скасувати виконання",
     "cancel-fulfillment": "Скасувати виконання",
     "cancel-modification": "Скасувати зміну",
     "cancel-modification": "Скасувати зміну",
     "cancel-order": "Скасувати замовлення",
     "cancel-order": "Скасувати замовлення",
@@ -548,24 +548,24 @@
     "cancel-reason-customer-request": "Запит клієнта",
     "cancel-reason-customer-request": "Запит клієнта",
     "cancel-reason-not-available": "Недоступний",
     "cancel-reason-not-available": "Недоступний",
     "cancel-selected-items": "Скасувати вибрані позиції",
     "cancel-selected-items": "Скасувати вибрані позиції",
-    "cancel-specified-items": "",
+    "cancel-specified-items": "Скасувати вказані товари",
     "cancellation-reason": "Причина скасування",
     "cancellation-reason": "Причина скасування",
     "cancelled-order-items-success": "Скасовано { count } { count, plural, one {позицію} other {позицій} } замовлення",
     "cancelled-order-items-success": "Скасовано { count } { count, plural, one {позицію} other {позицій} } замовлення",
     "cancelled-order-success": "Успішно скасоване замовлення",
     "cancelled-order-success": "Успішно скасоване замовлення",
-    "complete-draft-order": "",
+    "complete-draft-order": "Завершити чернетку",
     "confirm-modifications": "Підтвердіть зміни",
     "confirm-modifications": "Підтвердіть зміни",
     "contents": "Вміст",
     "contents": "Вміст",
     "create-fulfillment": "Створити виконання",
     "create-fulfillment": "Створити виконання",
     "create-fulfillment-success": "Створене виконання",
     "create-fulfillment-success": "Створене виконання",
     "customer": "Клієнт",
     "customer": "Клієнт",
-    "delete-draft-order": "",
-    "draft-order": "",
+    "delete-draft-order": "Видалити чернетку",
+    "draft-order": "Чернетка замовлення",
     "edit-billing-address": "Змінити платіжну адресу",
     "edit-billing-address": "Змінити платіжну адресу",
     "edit-shipping-address": "Змінити адресу доставки",
     "edit-shipping-address": "Змінити адресу доставки",
     "error-message": "Повідомлення про помилку",
     "error-message": "Повідомлення про помилку",
-    "existing-address": "",
-    "existing-customer": "",
-    "filter-is-active": "",
+    "existing-address": "Існуюча адреса",
+    "existing-customer": "Існуючий клієнт",
+    "filter-is-active": "Активний",
     "fulfill": "Виконати",
     "fulfill": "Виконати",
     "fulfill-order": "Виконати замовлення",
     "fulfill-order": "Виконати замовлення",
     "fulfillment": "Виконання",
     "fulfillment": "Виконання",
@@ -610,15 +610,15 @@
     "note-is-private": "Примітка є службовою",
     "note-is-private": "Примітка є службовою",
     "note-only-visible-to-administrators": "Доступно тільки адміністраторам",
     "note-only-visible-to-administrators": "Доступно тільки адміністраторам",
     "note-visible-to-customer": "Доступно адміністраторам і клієнтам",
     "note-visible-to-customer": "Доступно адміністраторам і клієнтам",
-    "order": "",
+    "order": "Замовлення",
     "order-history": "Історія замовлень",
     "order-history": "Історія замовлень",
-    "order-is-empty": "",
+    "order-is-empty": "Замовлення порожнє",
     "order-state-diagram": "Діаграма стану замовлення",
     "order-state-diagram": "Діаграма стану замовлення",
-    "order-type": "",
-    "order-type-aggregate": "",
-    "order-type-regular": "",
-    "order-type-seller": "",
-    "orders": "",
+    "order-type": "Тип замовлення",
+    "order-type-aggregate": "Агрегований",
+    "order-type-regular": "Звичайний",
+    "order-type-seller": "Продавець",
+    "orders": "Замовлення",
     "original-quantity-at-checkout": "Початкова кількість при оформленні замовлення",
     "original-quantity-at-checkout": "Початкова кількість при оформленні замовлення",
     "payment": "Оплата",
     "payment": "Оплата",
     "payment-amount": "Сума до оплати",
     "payment-amount": "Сума до оплати",
@@ -626,7 +626,7 @@
     "payment-method": "Спосіб оплати",
     "payment-method": "Спосіб оплати",
     "payment-state": "Стан",
     "payment-state": "Стан",
     "payment-to-refund": "Платіж до повернення",
     "payment-to-refund": "Платіж до повернення",
-    "payments": "",
+    "payments": "Платежі",
     "placed-at": "Розміщено в",
     "placed-at": "Розміщено в",
     "preview-changes": "Попередній перегляд змін",
     "preview-changes": "Попередній перегляд змін",
     "previous-customer": "Попередній клієнт",
     "previous-customer": "Попередній клієнт",
@@ -657,17 +657,17 @@
     "removed-items": "Видалені позиції",
     "removed-items": "Видалені позиції",
     "return-to-stock": "Повернутися на склад",
     "return-to-stock": "Повернутися на склад",
     "search-by-order-filters": "Пошук за коду замовлення / прізвищем клієнта / ID транзакції",
     "search-by-order-filters": "Пошук за коду замовлення / прізвищем клієнта / ID транзакції",
-    "select-address": "",
-    "select-shipping-method": "",
+    "select-address": "Вибрати адресу",
+    "select-shipping-method": "Вибрати спосіб доставки",
     "select-state": "Виберіть стан",
     "select-state": "Виберіть стан",
-    "seller-orders": "",
-    "set-billing-address": "",
-    "set-coupon-codes": "",
-    "set-customer-for-order": "",
+    "seller-orders": "Замовлення продавця",
+    "set-billing-address": "Встановити адресу для рахунків",
+    "set-coupon-codes": "Встановити промокоди",
+    "set-customer-for-order": "Встановити клієнта",
     "set-customer-success": "",
     "set-customer-success": "",
     "set-fulfillment-state": "Помітити як {state}",
     "set-fulfillment-state": "Помітити як {state}",
-    "set-shipping-address": "",
-    "set-shipping-method": "",
+    "set-shipping-address": "Встановити адресу доставки",
+    "set-shipping-method": "Встановити спосіб доставки",
     "settle-payment": "Розрахунок платежу",
     "settle-payment": "Розрахунок платежу",
     "settle-payment-error": "Не вдалося провести оплату",
     "settle-payment-error": "Не вдалося провести оплату",
     "settle-payment-success": "Успішно проведений платіж",
     "settle-payment-success": "Успішно проведений платіж",
@@ -676,7 +676,7 @@
     "settle-refund-success": "Повернення успішно здійснено",
     "settle-refund-success": "Повернення успішно здійснено",
     "shipping": "Доставка",
     "shipping": "Доставка",
     "shipping-address": "Адреса доставки",
     "shipping-address": "Адреса доставки",
-    "shipping-cancelled": "",
+    "shipping-cancelled": "Доставку скасовано",
     "shipping-method": "Спосіб доставки",
     "shipping-method": "Спосіб доставки",
     "state": "Стан",
     "state": "Стан",
     "sub-total": "Проміжний підсумок",
     "sub-total": "Проміжний підсумок",
@@ -701,30 +701,30 @@
     "add-countries-to-zone": "Додати країни в { zoneName }",
     "add-countries-to-zone": "Додати країни в { zoneName }",
     "add-countries-to-zone-success": "Додано { countryCount } {countryCount, plural, one {країна} other {країн}} в зону \"{ zoneName }\"",
     "add-countries-to-zone-success": "Додано { countryCount } {countryCount, plural, one {країна} other {країн}} в зону \"{ zoneName }\"",
     "add-products-to-test-order": "Додати товари до тестового замовлення",
     "add-products-to-test-order": "Додати товари до тестового замовлення",
-    "administrator": "",
+    "administrator": "Адміністратор",
     "channel": "Канал",
     "channel": "Канал",
     "channel-token": "Токен каналу",
     "channel-token": "Токен каналу",
-    "country": "",
+    "country": "Країна",
     "create-new-channel": "Створити новий канал",
     "create-new-channel": "Створити новий канал",
     "create-new-country": "Створити нову країну",
     "create-new-country": "Створити нову країну",
     "create-new-payment-method": "Створити новий спосіб оплати",
     "create-new-payment-method": "Створити новий спосіб оплати",
     "create-new-role": "Створити нову роль",
     "create-new-role": "Створити нову роль",
-    "create-new-seller": "",
+    "create-new-seller": "Створити нового продавця",
     "create-new-shipping-method": "Створити новий спосіб доставки",
     "create-new-shipping-method": "Створити новий спосіб доставки",
     "create-new-tax-category": "Створити нову податкову категорію",
     "create-new-tax-category": "Створити нову податкову категорію",
     "create-new-tax-rate": "Створити нову податкову ставку",
     "create-new-tax-rate": "Створити нову податкову ставку",
     "create-new-zone": "Створити нову зону",
     "create-new-zone": "Створити нову зону",
-    "default-currency": "",
+    "default-currency": "Основна валюта",
     "default-role-label": "Це роль за замовчуванням, яку неможливо змінити.",
     "default-role-label": "Це роль за замовчуванням, яку неможливо змінити.",
     "default-shipping-zone": "Зона доставки за замовчуванням",
     "default-shipping-zone": "Зона доставки за замовчуванням",
     "default-tax-zone": "Податкова зона за замовчуванням",
     "default-tax-zone": "Податкова зона за замовчуванням",
-    "defaults": "",
+    "defaults": "За замовчуванням",
     "eligible": "Який має право",
     "eligible": "Який має право",
     "email-address": "Адреса електронної пошти",
     "email-address": "Адреса електронної пошти",
-    "email-address-or-identifier": "",
+    "email-address-or-identifier": "Електронна адреса або ідентифікатор",
     "first-name": "Ім'я",
     "first-name": "Ім'я",
     "fulfillment-handler": "Обробник виконання",
     "fulfillment-handler": "Обробник виконання",
-    "global-available-languages-tooltip": "",
+    "global-available-languages-tooltip": "Встановлює мови, доступні для всіх каналів. Окремі канали можуть підтримувати підмножину цих мов.",
     "global-out-of-stock-threshold": "Глобальний поріг відсутності на складі",
     "global-out-of-stock-threshold": "Глобальний поріг відсутності на складі",
     "global-out-of-stock-threshold-tooltip": "Встановлює рівень запасу, при якому цей варіант вважається відсутнім. Використання від'ємного значення включає підтримку відкладеного замовлення. Може бути скасовано варіантами товару.",
     "global-out-of-stock-threshold-tooltip": "Встановлює рівень запасу, при якому цей варіант вважається відсутнім. Використання від'ємного значення включає підтримку відкладеного замовлення. Може бути скасовано варіантами товару.",
     "last-name": "Прізвище",
     "last-name": "Прізвище",
@@ -732,17 +732,17 @@
     "password": "Пароль",
     "password": "Пароль",
     "payment-eligibility-checker": "Контролер прийнятності оплати",
     "payment-eligibility-checker": "Контролер прийнятності оплати",
     "payment-handler": "Обробник платежів",
     "payment-handler": "Обробник платежів",
-    "payment-method": "",
+    "payment-method": "Спосіб оплати",
     "permissions": "Дозволи",
     "permissions": "Дозволи",
     "prices-include-tax": "Ціни включають податок для зони за замовчуванням",
     "prices-include-tax": "Ціни включають податок для зони за замовчуванням",
     "profile": "Профіль",
     "profile": "Профіль",
     "rate": "Показник",
     "rate": "Показник",
     "remove-countries-from-zone-success": "Видалити { countryCount } {countryCount, plural, one {країна} other {країн}} із зони \"{ zoneName }\"",
     "remove-countries-from-zone-success": "Видалити { countryCount } {countryCount, plural, one {країна} other {країн}} із зони \"{ zoneName }\"",
     "remove-from-zone": "Видалити із зони",
     "remove-from-zone": "Видалити із зони",
-    "role": "",
+    "role": "Роль",
     "roles": "Ролі",
     "roles": "Ролі",
     "search-by-product-name-or-sku": "Пошук за назвою товару або артикулу",
     "search-by-product-name-or-sku": "Пошук за назвою товару або артикулу",
-    "seller": "",
+    "seller": "Продавець",
     "shipping-calculator": "Калькулятор доставки",
     "shipping-calculator": "Калькулятор доставки",
     "shipping-eligibility-checker": "Контролер прийнятності доставки",
     "shipping-eligibility-checker": "Контролер прийнятності доставки",
     "shipping-method": "Спосіб доставки",
     "shipping-method": "Спосіб доставки",
@@ -765,7 +765,7 @@
     "created": "Створено",
     "created": "Створено",
     "declined": "Відхилено",
     "declined": "Відхилено",
     "delivered": "Доставлено",
     "delivered": "Доставлено",
-    "draft": "",
+    "draft": "Чернетка",
     "error": "Помилка",
     "error": "Помилка",
     "failed": "Невдало",
     "failed": "Невдало",
     "modifying": "Зміна",
     "modifying": "Зміна",
@@ -800,4 +800,4 @@
     "job-state-pending": "В очікуванні",
     "job-state-pending": "В очікуванні",
     "job-state-running": "Виконується"
     "job-state-running": "Виконується"
   }
   }
-}
+}

+ 1 - 18
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -20,6 +20,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 
+import { SEARCH_PRODUCTS_ADMIN } from './graphql/admin-definitions';
 import {
 import {
     ChannelFragment,
     ChannelFragment,
     CurrencyCode,
     CurrencyCode,
@@ -1920,24 +1921,6 @@ export const REINDEX = gql`
     }
     }
 `;
 `;
 
 
-export const SEARCH_PRODUCTS_ADMIN = gql`
-    query SearchProductsAdmin($input: SearchInput!) {
-        search(input: $input) {
-            totalItems
-            items {
-                enabled
-                productId
-                productName
-                slug
-                description
-                productVariantId
-                productVariantName
-                sku
-            }
-        }
-    }
-`;
-
 export const SEARCH_GET_FACET_VALUES = gql`
 export const SEARCH_GET_FACET_VALUES = gql`
     query SearchFacetValues($input: SearchInput!) {
     query SearchFacetValues($input: SearchInput!) {
         search(input: $input) {
         search(input: $input) {

+ 158 - 0
packages/core/e2e/default-search-plugin/default-search-plugin-sort-by.e2e-spec.ts

@@ -0,0 +1,158 @@
+import { DefaultJobQueuePlugin, DefaultSearchPlugin, mergeConfig } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../../e2e-common/test-config';
+import { SEARCH_PRODUCTS_ADMIN } from '../graphql/admin-definitions';
+import {
+    SearchResultSortParameter,
+    SortOrder,
+    SearchProductsAdminQuery,
+    SearchProductsAdminQueryVariables,
+} from '../graphql/generated-e2e-admin-types';
+import {
+    SearchProductsShopQuery,
+    SearchProductsShopQueryVariables,
+} from '../graphql/generated-e2e-shop-types';
+import { SEARCH_PRODUCTS_SHOP } from '../graphql/shop-definitions';
+import { awaitRunningJobs } from '../utils/await-running-jobs';
+
+describe('Default search plugin', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            plugins: [
+                DefaultSearchPlugin.init({
+                    indexStockStatus: true,
+                }),
+                DefaultJobQueuePlugin,
+            ],
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures', 'default-search-plugin-sort-by.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+        await awaitRunningJobs(adminClient);
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await awaitRunningJobs(adminClient);
+        await server.destroy();
+    });
+
+    function searchProductsShop(queryVariables: SearchProductsShopQueryVariables) {
+        return shopClient.query<SearchProductsShopQuery, SearchProductsShopQueryVariables>(
+            SEARCH_PRODUCTS_SHOP,
+            queryVariables,
+        );
+    }
+
+    function searchProductsAdmin(queryVariables: SearchProductsAdminQueryVariables) {
+        return adminClient.query<SearchProductsAdminQuery, SearchProductsAdminQueryVariables>(
+            SEARCH_PRODUCTS_ADMIN,
+            queryVariables,
+        );
+    }
+
+    type SearchProducts = (
+        queryVariables: SearchProductsShopQueryVariables | SearchProductsAdminQueryVariables,
+    ) => Promise<SearchProductsShopQuery | SearchProductsAdminQuery>;
+
+    async function testSearchProducts(
+        searchProducts: SearchProducts,
+        groupByProduct: boolean,
+        sortBy: keyof SearchResultSortParameter,
+        sortOrder: (typeof SortOrder)[keyof typeof SortOrder],
+        skip: number,
+        take: number,
+    ) {
+        return searchProducts({
+            input: {
+                groupByProduct,
+                sort: {
+                    [sortBy]: sortOrder,
+                },
+                skip,
+                take,
+            },
+        });
+    }
+
+    async function testSortByPriceAsc(searchProducts: SearchProducts) {
+        const resultPage1 = await testSearchProducts(searchProducts, false, 'price', SortOrder.ASC, 0, 3);
+        const resultPage2 = await testSearchProducts(searchProducts, false, 'price', SortOrder.ASC, 3, 3);
+
+        const pvIds1 = resultPage1.search.items.map(i => i.productVariantId);
+        const pvIds2 = resultPage2.search.items.map(i => i.productVariantId);
+        const pvIds3 = pvIds1.concat(pvIds2);
+
+        expect(new Set(pvIds3).size).equals(6);
+        expect(resultPage1.search.items.map(i => i.productVariantId)).toEqual(['T_4', 'T_5', 'T_6']);
+        expect(resultPage2.search.items.map(i => i.productVariantId)).toEqual(['T_7', 'T_8', 'T_9']);
+    }
+
+    async function testSortByPriceDesc(searchProducts: SearchProducts) {
+        const resultPage1 = await testSearchProducts(searchProducts, false, 'price', SortOrder.DESC, 0, 3);
+        const resultPage2 = await testSearchProducts(searchProducts, false, 'price', SortOrder.DESC, 3, 3);
+
+        const pvIds1 = resultPage1.search.items.map(i => i.productVariantId);
+        const pvIds2 = resultPage2.search.items.map(i => i.productVariantId);
+        const pvIds3 = pvIds1.concat(pvIds2);
+
+        expect(new Set(pvIds3).size).equals(6);
+        expect(resultPage1.search.items.map(i => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_3']);
+        expect(resultPage2.search.items.map(i => i.productVariantId)).toEqual(['T_4', 'T_5', 'T_6']);
+    }
+
+    async function testSortByPriceAscGroupByProduct(searchProducts: SearchProducts) {
+        const resultPage1 = await testSearchProducts(searchProducts, true, 'price', SortOrder.ASC, 0, 3);
+        const resultPage2 = await testSearchProducts(searchProducts, true, 'price', SortOrder.ASC, 3, 3);
+
+        const pvIds1 = resultPage1.search.items.map(i => i.productVariantId);
+        const pvIds2 = resultPage2.search.items.map(i => i.productVariantId);
+        const pvIds3 = pvIds1.concat(pvIds2);
+
+        expect(new Set(pvIds3).size).equals(6);
+        expect(resultPage1.search.items.map(i => i.productId)).toEqual(['T_4', 'T_5', 'T_6']);
+        expect(resultPage2.search.items.map(i => i.productId)).toEqual(['T_1', 'T_2', 'T_3']);
+    }
+
+    async function testSortByPriceDescGroupByProduct(searchProducts: SearchProducts) {
+        const resultPage1 = await testSearchProducts(searchProducts, true, 'price', SortOrder.DESC, 0, 3);
+        const resultPage2 = await testSearchProducts(searchProducts, true, 'price', SortOrder.DESC, 3, 3);
+
+        const pvIds1 = resultPage1.search.items.map(i => i.productVariantId);
+        const pvIds2 = resultPage2.search.items.map(i => i.productVariantId);
+        const pvIds3 = pvIds1.concat(pvIds2);
+
+        expect(new Set(pvIds3).size).equals(6);
+        expect(resultPage1.search.items.map(i => i.productId)).toEqual(['T_1', 'T_2', 'T_3']);
+        expect(resultPage2.search.items.map(i => i.productId)).toEqual(['T_4', 'T_5', 'T_6']);
+    }
+
+    describe('Search products shop', () => {
+        const searchProducts = searchProductsShop;
+
+        it('sort by price ASC', () => testSortByPriceAsc(searchProducts));
+        it('sort by price DESC', () => testSortByPriceDesc(searchProducts));
+
+        it('sort by price ASC group by product', () => testSortByPriceAscGroupByProduct(searchProducts));
+        it('sort by price DESC group by product', () => testSortByPriceDescGroupByProduct(searchProducts));
+    });
+
+    describe('Search products admin', () => {
+        const searchProducts = searchProductsAdmin;
+
+        it('sort by price ACS', () => testSortByPriceAsc(searchProducts));
+        it('sort by price DESC', () => testSortByPriceDesc(searchProducts));
+
+        it('sort by price ASC group by product', () => testSortByPriceAscGroupByProduct(searchProducts));
+        it('sort by price DESC group by product', () => testSortByPriceDescGroupByProduct(searchProducts));
+    });
+});

+ 16 - 0
packages/core/e2e/default-search-plugin/fixtures/default-search-plugin-sort-by.csv

@@ -0,0 +1,16 @@
+name,slug,description,assets,facets,optionGroups,optionValues,sku,price,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets
+Boot A,boot-a,Boot A Size 40,,category:sports equipment,shoe size,Size 40,BA40,100,standard,100,true,,
+Boot B,boot-b,Boot B Size 40,,category:sports equipment,shoe size,Size 40,BB40,100,standard,100,true,,
+Boot C,boot-c,Boot C Size 40,,category:sports equipment,shoe size,Size 40,BC40,100,standard,100,true,,
+Sneaker A,sneaker-a,Sneaker A Size 40,,category:sports equipment,shoe size,Size 40,SA40,99.99,standard,100,true,,
+,,Sneaker A Size 41,,,,Size 41,SA41,99.99,standard,100,true,,
+,,Sneaker A Size 42,,,,Size 42,SA42,99.99,standard,100,true,,
+,,Sneaker A Size 43,,,,Size 43,SA43,99.99,standard,100,true,,
+Sneaker B,sneaker-b,Sneaker B Size 40,,category:sports equipment,shoe size,Size 40,SB40,99.99,standard,100,true,,
+,,Sneaker B Size 41,,,,Size 41,SB41,99.99,standard,100,true,,
+,,Sneaker B Size 42,,,,Size 42,SB42,99.99,standard,100,true,,
+,,Sneaker B Size 43,,,,Size 43,SB43,99.99,standard,100,true,,
+Sneaker C,sneaker-c,Sneaker C Size 40,,category:sports equipment,shoe size,Size 40,SC40,99.99,standard,100,true,,
+,,Sneaker C Size 41,,,,Size 41,SC41,99.99,standard,100,true,,
+,,Sneaker C Size 42,,,,Size 42,SC42,99.99,standard,100,true,,
+,,Sneaker C Size 43,,,,Size 43,SC43,99.99,standard,100,true,,

+ 19 - 0
packages/core/e2e/graphql/admin-definitions.ts

@@ -0,0 +1,19 @@
+import gql from 'graphql-tag';
+
+export const SEARCH_PRODUCTS_ADMIN = gql`
+    query SearchProductsAdmin($input: SearchInput!) {
+        search(input: $input) {
+            totalItems
+            items {
+                enabled
+                productId
+                productName
+                slug
+                description
+                productVariantId
+                productVariantName
+                sku
+            }
+        }
+    }
+`;

+ 7 - 7
packages/core/src/api/resolvers/entity/administrator-entity.resolver.ts

@@ -1,25 +1,25 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
 
-import { EntityNotFoundError, InternalServerError } from '../../../common/error/errors';
+import { TransactionalConnection } from '../../../connection/index';
 import { Administrator } from '../../../entity/administrator/administrator.entity';
 import { Administrator } from '../../../entity/administrator/administrator.entity';
 import { User } from '../../../entity/user/user.entity';
 import { User } from '../../../entity/user/user.entity';
-import { UserService } from '../../../service/services/user.service';
 import { RequestContext } from '../../common/request-context';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 
 @Resolver('Administrator')
 @Resolver('Administrator')
 export class AdministratorEntityResolver {
 export class AdministratorEntityResolver {
-    constructor(private userService: UserService) {}
+    constructor(private connection: TransactionalConnection) {}
 
 
     @ResolveField()
     @ResolveField()
     async user(@Ctx() ctx: RequestContext, @Parent() administrator: Administrator): Promise<User> {
     async user(@Ctx() ctx: RequestContext, @Parent() administrator: Administrator): Promise<User> {
         if (administrator.user) {
         if (administrator.user) {
             return administrator.user;
             return administrator.user;
         }
         }
-        const user = await this.userService.getUserByEmailAddress(ctx, administrator.emailAddress);
-        if (!user) {
-            throw new EntityNotFoundError('User', '<not found>');
-        }
+        const { user } = await this.connection.getEntityOrThrow(ctx, Administrator, administrator.id, {
+            relations: {
+                user: { roles: true },
+            },
+        });
         return user;
         return user;
     }
     }
 }
 }

+ 3 - 3
packages/core/src/i18n/messages/de.json

@@ -73,10 +73,10 @@
     "ORDER_LIMIT_ERROR": "Der Artikel konnte nicht hinzugefügt werden. Eine Bestellung kann maximal { maxItems } Artikel enthalten",
     "ORDER_LIMIT_ERROR": "Der Artikel konnte nicht hinzugefügt werden. Eine Bestellung kann maximal { maxItems } Artikel enthalten",
     "ORDER_MODIFICATION_ERROR": "Der Inhalt der Bestellung kann nur im Status \"AddingItems\" geändert werden",
     "ORDER_MODIFICATION_ERROR": "Der Inhalt der Bestellung kann nur im Status \"AddingItems\" geändert werden",
     "ORDER_PAYMENT_STATE_ERROR": "Eine Zahlung kann nur im Status \"ArrangingPayment\" hinzugefügt werden",
     "ORDER_PAYMENT_STATE_ERROR": "Eine Zahlung kann nur im Status \"ArrangingPayment\" hinzugefügt werden",
-    "ORDER_STATE_TRANSITION_ERROR": "Der Status der Bestellung kan nicht von \"{ fromState }\" zu \"{ toState }\" geändert werden",
+    "ORDER_STATE_TRANSITION_ERROR": "Der Status der Bestellung kann nicht von \"{ fromState }\" zu \"{ toState }\" geändert werden",
     "PASSWORD_ALREADY_SET_ERROR": "Ein Passwort wurde während der Registrierung bereits festgelegt",
     "PASSWORD_ALREADY_SET_ERROR": "Ein Passwort wurde während der Registrierung bereits festgelegt",
-    "PASSWORD_RESET_TOKEN_EXPIRED_ERROR": "Das Token zum Passwortzurücksetzen ist leider abgelaufen",
-    "PASSWORD_RESET_TOKEN_INVALID_ERROR": "Das Token zum Passwortzurücksetzen ist leider falsch",
+    "PASSWORD_RESET_TOKEN_EXPIRED_ERROR": "Das Token zum Zurücksetzen des Passwortes ist leider abgelaufen",
+    "PASSWORD_RESET_TOKEN_INVALID_ERROR": "Das Token zum Zurücksetzen des Passwortes ist leider falsch",
     "PAYMENT_DECLINED_ERROR": "Die Zahlung wurde abgelehnt",
     "PAYMENT_DECLINED_ERROR": "Die Zahlung wurde abgelehnt",
     "PAYMENT_FAILED_ERROR": "Zahlung fehlgeschlagen",
     "PAYMENT_FAILED_ERROR": "Zahlung fehlgeschlagen",
     "PAYMENT_ORDER_MISMATCH_ERROR": "Die Zahlung und OrderLines gehören nicht zueinander",
     "PAYMENT_ORDER_MISMATCH_ERROR": "Die Zahlung und OrderLines gehören nicht zueinander",

+ 11 - 6
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -95,19 +95,24 @@ export class MysqlSearchStrategy implements SearchStrategy {
                 .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
                 .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
                 .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
                 .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
         }
         }
+
         this.applyTermAndFilters(ctx, qb, input);
         this.applyTermAndFilters(ctx, qb, input);
+
         if (sort) {
         if (sort) {
             if (sort.name) {
             if (sort.name) {
-                qb.addOrderBy(input.groupByProduct ? 'MIN(si.productName)' : 'si.productName', sort.name);
+                qb.addOrderBy('si_productName', sort.name);
             }
             }
             if (sort.price) {
             if (sort.price) {
-                qb.addOrderBy(input.groupByProduct ? 'MIN(si.price)' : 'si.price', sort.price);
-            }
-        } else {
-            if (input.term && input.term.length > this.minTermLength) {
-                qb.orderBy('score', 'DESC');
+                qb.addOrderBy('si_price', sort.price);
             }
             }
+        } else if (input.term && input.term.length > this.minTermLength) {
+            qb.addOrderBy('score', 'DESC');
         }
         }
+
+        // Required to ensure deterministic sorting.
+        // E.g. in case of sorting products with duplicate name, price or score results.
+        qb.addOrderBy('si_productVariantId', 'ASC');
+
         if (enabledOnly) {
         if (enabledOnly) {
             qb.andWhere('si.enabled = :enabled', { enabled: true });
             qb.andWhere('si.enabled = :enabled', { enabled: true });
         }
         }

+ 8 - 6
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -95,6 +95,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
                 .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
                 .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
                 .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
         }
         }
+
         this.applyTermAndFilters(ctx, qb, input);
         this.applyTermAndFilters(ctx, qb, input);
 
 
         if (sort) {
         if (sort) {
@@ -104,13 +105,14 @@ export class PostgresSearchStrategy implements SearchStrategy {
             if (sort.price) {
             if (sort.price) {
                 qb.addOrderBy('"si_price"', sort.price);
                 qb.addOrderBy('"si_price"', sort.price);
             }
             }
-        } else {
-            if (input.term && input.term.length > this.minTermLength) {
-                qb.addOrderBy('score', 'DESC');
-            } else {
-                qb.addOrderBy('"si_productVariantId"', 'ASC');
-            }
+        } else if (input.term && input.term.length > this.minTermLength) {
+            qb.addOrderBy('score', 'DESC');
         }
         }
+
+        // Required to ensure deterministic sorting.
+        // E.g. in case of sorting products with duplicate name, price or score results.
+        qb.addOrderBy('"si_productVariantId"', 'ASC');
+
         if (enabledOnly) {
         if (enabledOnly) {
             qb.andWhere('"si"."enabled" = :enabled', { enabled: true });
             qb.andWhere('"si"."enabled" = :enabled', { enabled: true });
         }
         }

+ 13 - 10
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -87,16 +87,14 @@ export class SqliteSearchStrategy implements SearchStrategy {
         const sort = input.sort;
         const sort = input.sort;
         const qb = this.connection.getRepository(ctx, SearchIndexItem).createQueryBuilder('si');
         const qb = this.connection.getRepository(ctx, SearchIndexItem).createQueryBuilder('si');
         if (input.groupByProduct) {
         if (input.groupByProduct) {
-            qb.addSelect('MIN(si.price)', 'minPrice').addSelect('MAX(si.price)', 'maxPrice');
-            qb.addSelect('MIN(si.priceWithTax)', 'minPriceWithTax').addSelect(
-                'MAX(si.priceWithTax)',
-                'maxPriceWithTax',
-            );
+            qb.addSelect('MIN(si.price)', 'minPrice');
+            qb.addSelect('MAX(si.price)', 'maxPrice');
+            qb.addSelect('MIN(si.priceWithTax)', 'minPriceWithTax');
+            qb.addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
         }
         }
+
         this.applyTermAndFilters(ctx, qb, input);
         this.applyTermAndFilters(ctx, qb, input);
-        if (input.term && input.term.length > this.minTermLength) {
-            qb.orderBy('score', 'DESC');
-        }
+
         if (sort) {
         if (sort) {
             if (sort.name) {
             if (sort.name) {
                 // TODO: v3 - set the collation on the SearchIndexItem entity
                 // TODO: v3 - set the collation on the SearchIndexItem entity
@@ -105,9 +103,14 @@ export class SqliteSearchStrategy implements SearchStrategy {
             if (sort.price) {
             if (sort.price) {
                 qb.addOrderBy('si.price', sort.price);
                 qb.addOrderBy('si.price', sort.price);
             }
             }
-        } else {
-            qb.addOrderBy('si.productVariantId', 'ASC');
+        } else if (input.term && input.term.length > this.minTermLength) {
+            qb.addOrderBy('score', 'DESC');
         }
         }
+
+        // Required to ensure deterministic sorting.
+        // E.g. in case of sorting products with duplicate name, price or score results.
+        qb.addOrderBy('si.productVariantId', 'ASC');
+
         if (enabledOnly) {
         if (enabledOnly) {
             qb.andWhere('si.enabled = :enabled', { enabled: true });
             qb.andWhere('si.enabled = :enabled', { enabled: true });
         }
         }

+ 2 - 0
packages/core/src/service/helpers/translatable-saver/translatable-saver.ts

@@ -105,6 +105,8 @@ export class TranslatableSaver {
             new entityType({ ...input, translations: existingTranslations }),
             new entityType({ ...input, translations: existingTranslations }),
             diff,
             diff,
         );
         );
+        entity.updatedAt = new Date();
+
         const updatedEntity = patchEntity(entity as any, omit(input, ['translations']));
         const updatedEntity = patchEntity(entity as any, omit(input, ['translations']));
         if (typeof beforeSave === 'function') {
         if (typeof beforeSave === 'function') {
             await beforeSave(entity);
             await beforeSave(entity);

+ 1 - 0
packages/dev-server/.gitignore

@@ -11,3 +11,4 @@ dev-config-override.ts
 vendure.log
 vendure.log
 scripts/dev-test
 scripts/dev-test
 custom-admin-ui
 custom-admin-ui
+docker-compose.*.yml

+ 14 - 0
packages/dev-server/docker-compose.yml

@@ -84,6 +84,18 @@ services:
       - pgadmin_data:/var/lib/pgadmin
       - pgadmin_data:/var/lib/pgadmin
     links:
     links:
       - "postgres:pgsql-server"
       - "postgres:pgsql-server"
+  keycloak:
+    image: quay.io/keycloak/keycloak
+    ports:
+      - "9000:8080"
+    environment:
+      KEYCLOAK_ADMIN: admin
+      KEYCLOAK_ADMIN_PASSWORD: admin
+    command:
+      - start-dev
+      - --import-realm
+    volumes:
+      - keycloak_data:/opt/keycloak/data
 volumes:
 volumes:
   postgres_data:
   postgres_data:
     driver: local
     driver: local
@@ -95,3 +107,5 @@ volumes:
     driver: local
     driver: local
   phpmyadmin_data:
   phpmyadmin_data:
     driver: local
     driver: local
+  keycloak_data:
+    driver: local

+ 2 - 1
packages/dev-server/test-plugins/keycloak-auth/keycloak-auth-plugin.ts

@@ -1,3 +1,4 @@
+import { HttpModule } from '@nestjs/axios';
 import { MiddlewareConsumer, NestModule } from '@nestjs/common';
 import { MiddlewareConsumer, NestModule } from '@nestjs/common';
 import { PluginCommonModule, VendurePlugin } from '@vendure/core';
 import { PluginCommonModule, VendurePlugin } from '@vendure/core';
 import express from 'express';
 import express from 'express';
@@ -17,7 +18,7 @@ import { KeycloakAuthenticationStrategy } from './keycloak-authentication-strate
  * Video demo of this: https://youtu.be/Tj4kwjNd2nM
  * Video demo of this: https://youtu.be/Tj4kwjNd2nM
  */
  */
 @VendurePlugin({
 @VendurePlugin({
-    imports: [PluginCommonModule],
+    imports: [PluginCommonModule, HttpModule],
     configuration: config => {
     configuration: config => {
         config.authOptions.adminAuthenticationStrategy = [
         config.authOptions.adminAuthenticationStrategy = [
             ...config.authOptions.adminAuthenticationStrategy,
             ...config.authOptions.adminAuthenticationStrategy,

+ 11 - 9
packages/dev-server/test-plugins/keycloak-auth/keycloak-authentication-strategy.ts

@@ -5,7 +5,8 @@ import {
     Injector,
     Injector,
     Logger,
     Logger,
     RequestContext,
     RequestContext,
-    RoleService,
+    Role,
+    TransactionalConnection,
     User,
     User,
 } from '@vendure/core';
 } from '@vendure/core';
 import { DocumentNode } from 'graphql';
 import { DocumentNode } from 'graphql';
@@ -29,13 +30,13 @@ export class KeycloakAuthenticationStrategy implements AuthenticationStrategy<Ke
     readonly name = 'keycloak';
     readonly name = 'keycloak';
     private externalAuthenticationService: ExternalAuthenticationService;
     private externalAuthenticationService: ExternalAuthenticationService;
     private httpService: HttpService;
     private httpService: HttpService;
-    private roleService: RoleService;
+    private connection: TransactionalConnection;
     private bearerToken: string;
     private bearerToken: string;
 
 
     init(injector: Injector) {
     init(injector: Injector) {
         this.externalAuthenticationService = injector.get(ExternalAuthenticationService);
         this.externalAuthenticationService = injector.get(ExternalAuthenticationService);
         this.httpService = injector.get(HttpService);
         this.httpService = injector.get(HttpService);
-        this.roleService = injector.get(RoleService);
+        this.connection = injector.get(TransactionalConnection);
     }
     }
 
 
     defineInputType(): DocumentNode {
     defineInputType(): DocumentNode {
@@ -51,13 +52,13 @@ export class KeycloakAuthenticationStrategy implements AuthenticationStrategy<Ke
         this.bearerToken = data.token;
         this.bearerToken = data.token;
         try {
         try {
             const response = await this.httpService
             const response = await this.httpService
-                .get('http://localhost:9000/auth/realms/myrealm/protocol/openid-connect/userinfo', {
+                .get('http://localhost:9000/realms/myrealm/protocol/openid-connect/userinfo', {
                     headers: {
                     headers: {
                         Authorization: `Bearer ${this.bearerToken}`,
                         Authorization: `Bearer ${this.bearerToken}`,
                     },
                     },
                 })
                 })
                 .toPromise();
                 .toPromise();
-            userInfo = response.data;
+            userInfo = response?.data;
         } catch (e: any) {
         } catch (e: any) {
             Logger.error(e);
             Logger.error(e);
             return false;
             return false;
@@ -75,8 +76,9 @@ export class KeycloakAuthenticationStrategy implements AuthenticationStrategy<Ke
             return user;
             return user;
         }
         }
 
 
-        const roles = await this.roleService.findAll(ctx);
-        const merchantRole = roles.items.find(r => r.code === 'merchant');
+        const merchantRole = await this.connection.getRepository(ctx, Role).findOne({
+            where: { code: 'merchant' },
+        });
 
 
         if (!merchantRole) {
         if (!merchantRole) {
             Logger.error(`Could not find "merchant" role`);
             Logger.error(`Could not find "merchant" role`);
@@ -88,8 +90,8 @@ export class KeycloakAuthenticationStrategy implements AuthenticationStrategy<Ke
             externalIdentifier: userInfo.sub,
             externalIdentifier: userInfo.sub,
             identifier: userInfo.preferred_username,
             identifier: userInfo.preferred_username,
             emailAddress: userInfo.email,
             emailAddress: userInfo.email,
-            firstName: userInfo.given_name,
-            lastName: userInfo.family_name,
+            firstName: userInfo.given_name ?? userInfo.preferred_username,
+            lastName: userInfo.family_name ?? userInfo.preferred_username,
             roles: [merchantRole],
             roles: [merchantRole],
         });
         });
     }
     }

+ 20 - 20
packages/dev-server/test-plugins/keycloak-auth/public/index.html

@@ -9,7 +9,7 @@
             integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
             integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
             crossorigin="anonymous"
             crossorigin="anonymous"
         />
         />
-        <script src="http://localhost:9000/auth/js/keycloak.js"></script>
+        <script src="http://localhost:9000/js/keycloak.js"></script>
         <style>
         <style>
             #logout.hidden {
             #logout.hidden {
                 display: none;
                 display: none;
@@ -25,9 +25,7 @@
                 <button class="btn btn-sm btn-secondary hidden" id="logout">Log out of intranet</button>
                 <button class="btn btn-sm btn-secondary hidden" id="logout">Log out of intranet</button>
             </p>
             </p>
             <div class="text-center mt-4">
             <div class="text-center mt-4">
-                <button id="login" class="btn btn-primary">
-                    Log In To Vendure
-                </button>
+                <button id="login" class="btn btn-primary">Log In To Vendure</button>
             </div>
             </div>
         </div>
         </div>
         <script>
         <script>
@@ -82,27 +80,29 @@
 
 
             function loginToAdminUi() {
             function loginToAdminUi() {
                 return graphQlQuery(
                 return graphQlQuery(
-                    `
-                     mutation Authenticate($token: String!) {
-                         authenticate(input: {
-                           keycloak: {
-                             token: $token
-                           }
-                         }) {
-                             user { id }
-                         }
-                     }
-                     `,
+                    /* GraphQL */ `
+                        mutation Authenticate($token: String!) {
+                            authenticate(input: { keycloak: { token: $token } }) {
+                                ... on CurrentUser {
+                                    id
+                                }
+                                ... on ErrorResult {
+                                    errorCode
+                                    message
+                                }
+                            }
+                        }
+                    `,
                     { token: keycloak.token },
                     { token: keycloak.token },
                 )
                 )
-                    .then((result) => {
+                    .then(result => {
                         console.log(result);
                         console.log(result);
-                        if (result.data?.authenticate.user) {
+                        if (result.data?.authenticate.id) {
                             // successfully authenticated
                             // successfully authenticated
-                            window.location.replace('http://localhost:3000/admin');
+                            window.location.replace('http://localhost:4200/admin');
                         }
                         }
                     })
                     })
-                    .catch((err) => {
+                    .catch(err => {
                         console.log('error', err);
                         console.log('error', err);
                     });
                     });
             }
             }
@@ -115,7 +115,7 @@
                         Accept: 'application/json',
                         Accept: 'application/json',
                     },
                     },
                     body: JSON.stringify({ query, variables }),
                     body: JSON.stringify({ query, variables }),
-                }).then((r) => {
+                }).then(r => {
                     return r.json();
                     return r.json();
                 });
                 });
             }
             }

+ 1 - 0
packages/payments-plugin/src/stripe/types.ts

@@ -59,6 +59,7 @@ export interface StripePluginOptions {
      *     }),
      *     }),
      *   ],
      *   ],
      * };
      * };
+     * ```
      *
      *
      * Note: If the `paymentIntentCreateParams` is also used and returns a `metadata` key, then the values
      * Note: If the `paymentIntentCreateParams` is also used and returns a `metadata` key, then the values
      * returned by both functions will be merged.
      * returned by both functions will be merged.