Browse Source

Move asset dialog (#966)

* Started to move.

* Fix tests

* More fixes

* Fix tests
pull/967/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
dddb665317
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      backend/i18n/frontend_en.json
  2. 4
      backend/i18n/frontend_it.json
  3. 4
      backend/i18n/frontend_nl.json
  4. 4
      backend/i18n/frontend_pt.json
  5. 4
      backend/i18n/frontend_zh.json
  6. 4
      backend/i18n/source/frontend_en.json
  7. 2
      backend/i18n/source/frontend_it.json
  8. 2
      backend/i18n/source/frontend_nl.json
  9. 2
      backend/i18n/source/frontend_pt.json
  10. 2
      backend/i18n/source/frontend_zh.json
  11. 7
      frontend/src/app/features/administration/state/event-consumers.state.spec.ts
  12. 5
      frontend/src/app/features/administration/state/users.state.spec.ts
  13. 19
      frontend/src/app/features/assets/pages/assets-page.component.html
  14. 16
      frontend/src/app/features/assets/pages/assets-page.component.ts
  15. 9
      frontend/src/app/features/content/pages/content/editor/content-editor.component.html
  16. 21
      frontend/src/app/features/content/shared/forms/assets-editor.component.html
  17. 15
      frontend/src/app/features/content/shared/forms/assets-editor.component.ts
  18. 2
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  19. 8
      frontend/src/app/features/content/shared/references/references-tags.component.html
  20. 3
      frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html
  21. 3
      frontend/src/app/features/schemas/pages/schema/fields/types/components-validation.component.html
  22. 2
      frontend/src/app/features/schemas/pages/schema/fields/types/number-ui.component.html
  23. 3
      frontend/src/app/features/schemas/pages/schema/fields/types/references-validation.component.html
  24. 3
      frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.html
  25. 4
      frontend/src/app/features/settings/pages/clients/client.component.html
  26. 2
      frontend/src/app/features/settings/pages/clients/client.component.ts
  27. 4
      frontend/src/app/features/settings/pages/contributors/contributor-add-form.component.html
  28. 2
      frontend/src/app/features/settings/pages/languages/language-add-form.component.html
  29. 2
      frontend/src/app/features/settings/pages/roles/role.component.html
  30. 6
      frontend/src/app/features/settings/pages/workflows/workflow-step.component.html
  31. 8
      frontend/src/app/features/settings/pages/workflows/workflow-transition.component.html
  32. 17
      frontend/src/app/features/settings/pages/workflows/workflow.component.html
  33. 2
      frontend/src/app/features/teams/pages/contributors/contributor-add-form.component.html
  34. 6
      frontend/src/app/features/teams/state/team-contributors.state.spec.ts
  35. 9
      frontend/src/app/features/teams/state/team-plans.state.spec.ts
  36. 12
      frontend/src/app/framework/angular/forms/editors/autocomplete.component.html
  37. 11
      frontend/src/app/framework/angular/forms/editors/autocomplete.component.ts
  38. 6
      frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts
  39. 4
      frontend/src/app/framework/angular/forms/editors/dropdown.component.html
  40. 44
      frontend/src/app/framework/angular/forms/editors/tag-editor.component.html
  41. 109
      frontend/src/app/framework/angular/forms/editors/tag-editor.component.ts
  42. 36
      frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts
  43. 5
      frontend/src/app/framework/angular/modals/modal.directive.ts
  44. 44
      frontend/src/app/framework/utils/rxjs-extensions.ts
  45. 34
      frontend/src/app/shared/components/assets/asset-dialog.component.html
  46. 4
      frontend/src/app/shared/components/assets/asset-dialog.component.scss
  47. 139
      frontend/src/app/shared/components/assets/asset-dialog.component.ts
  48. 19
      frontend/src/app/shared/components/assets/asset-folder-dropdown-item.component.html
  49. 4
      frontend/src/app/shared/components/assets/asset-folder-dropdown-item.component.scss
  50. 27
      frontend/src/app/shared/components/assets/asset-folder-dropdown-item.component.ts
  51. 2
      frontend/src/app/shared/components/assets/asset-folder-dropdown.component.html
  52. 2
      frontend/src/app/shared/components/assets/asset-folder-dropdown.component.ts
  53. 6
      frontend/src/app/shared/components/assets/asset-folder.component.html
  54. 14
      frontend/src/app/shared/components/assets/asset-folder.component.ts
  55. 30
      frontend/src/app/shared/components/assets/asset-selector.component.html
  56. 4
      frontend/src/app/shared/components/assets/asset-selector.component.ts
  57. 11
      frontend/src/app/shared/components/assets/asset-uploader.component.ts
  58. 18
      frontend/src/app/shared/components/assets/asset.component.html
  59. 40
      frontend/src/app/shared/components/assets/asset.component.ts
  60. 140
      frontend/src/app/shared/components/assets/assets-list.component.html
  61. 13
      frontend/src/app/shared/components/assets/assets-list.component.ts
  62. 17
      frontend/src/app/shared/components/contents/content-list-cell.directive.ts
  63. 2
      frontend/src/app/shared/components/forms/markdown-editor.component.ts
  64. 4
      frontend/src/app/shared/components/forms/rich-editor.component.ts
  65. 9
      frontend/src/app/shared/interceptors/auth.interceptor.spec.ts
  66. 2
      frontend/src/app/shared/module.ts
  67. 40
      frontend/src/app/shared/services/assets.service.spec.ts
  68. 17
      frontend/src/app/shared/services/assets.service.ts
  69. 5
      frontend/src/app/shared/services/users-provider.service.spec.ts
  70. 5
      frontend/src/app/shared/state/asset-scripts.state.spec.ts
  71. 33
      frontend/src/app/shared/state/asset-uploader.state.spec.ts
  72. 32
      frontend/src/app/shared/state/asset-uploader.state.ts
  73. 12
      frontend/src/app/shared/state/assets.forms.ts
  74. 97
      frontend/src/app/shared/state/assets.state.spec.ts
  75. 108
      frontend/src/app/shared/state/assets.state.ts
  76. 9
      frontend/src/app/shared/state/backups.state.spec.ts
  77. 5
      frontend/src/app/shared/state/clients.state.spec.ts
  78. 5
      frontend/src/app/shared/state/contributors.state.spec.ts
  79. 5
      frontend/src/app/shared/state/languages.state.spec.ts
  80. 9
      frontend/src/app/shared/state/plans.state.spec.ts
  81. 5
      frontend/src/app/shared/state/roles.state.spec.ts
  82. 5
      frontend/src/app/shared/state/rule-events.state.spec.ts
  83. 5
      frontend/src/app/shared/state/rule-simulator.state.spec.ts
  84. 5
      frontend/src/app/shared/state/rules.state.spec.ts
  85. 5
      frontend/src/app/shared/state/schemas.state.spec.ts
  86. 5
      frontend/src/app/shared/state/template.state.spec.ts
  87. 5
      frontend/src/app/shared/state/workflows.state.spec.ts
  88. 6
      frontend/src/app/shell/pages/internal/notification-dropdown.component.ts
  89. 3
      frontend/src/app/shell/pages/internal/search-menu.component.html

4
backend/i18n/frontend_en.json

@ -98,6 +98,7 @@
"assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "Metadata", "assets.metadata": "Metadata",
"assets.metadataAdd": "Add Metadata", "assets.metadataAdd": "Add Metadata",
"assets.move": "Move",
"assets.moveFailed": "Failed to move asset. Please reload.", "assets.moveFailed": "Failed to move asset. Please reload.",
"assets.protected": "Protected", "assets.protected": "Protected",
"assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.", "assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.",
@ -535,7 +536,7 @@
"contributors.import.run": "Add Contributors", "contributors.import.run": "Add Contributors",
"contributors.import.run2": "Import", "contributors.import.run2": "Import",
"contributors.importButton": "Add many contributors at once", "contributors.importButton": "Add many contributors at once",
"contributors.importHintg": "Big team?", "contributors.importHint": "Big team?",
"contributors.importTitle": "Import contributors", "contributors.importTitle": "Import contributors",
"contributors.loadFailed": "Failed to load contributors. Please reload.", "contributors.loadFailed": "Failed to load contributors. Please reload.",
"contributors.planHint": "Your plan allows up to {maxContributors} contributors.", "contributors.planHint": "Your plan allows up to {maxContributors} contributors.",
@ -665,7 +666,6 @@
"roles.loadPermissionsFailed": "Failed to load permissions. Please reload.", "roles.loadPermissionsFailed": "Failed to load permissions. Please reload.",
"roles.permissions": "Permissions", "roles.permissions": "Permissions",
"roles.permissionsDescription": "Permissions restrict the allowed operations and queries at API level and are a security feature.", "roles.permissionsDescription": "Permissions restrict the allowed operations and queries at API level and are a security feature.",
"roles.permissionsPlaceholder": "Start typing to search for permissions",
"roles.properties": "Properties", "roles.properties": "Properties",
"roles.properties.hideAPI": "Hide API", "roles.properties.hideAPI": "Hide API",
"roles.properties.hideAssets": "Hide Assets", "roles.properties.hideAssets": "Hide Assets",

4
backend/i18n/frontend_it.json

@ -98,6 +98,7 @@
"assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "Metadati", "assets.metadata": "Metadati",
"assets.metadataAdd": "Aggiungi un metadato", "assets.metadataAdd": "Aggiungi un metadato",
"assets.move": "Move",
"assets.moveFailed": "Non è stato possibile spostare la risorsa. Per favore ricarica.", "assets.moveFailed": "Non è stato possibile spostare la risorsa. Per favore ricarica.",
"assets.protected": "Protetto", "assets.protected": "Protetto",
"assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.", "assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.",
@ -535,7 +536,7 @@
"contributors.import.run": "Aggiungi Collaboratori", "contributors.import.run": "Aggiungi Collaboratori",
"contributors.import.run2": "Importa", "contributors.import.run2": "Importa",
"contributors.importButton": "Aggiungi più collaboratori contemporaneamente", "contributors.importButton": "Aggiungi più collaboratori contemporaneamente",
"contributors.importHintg": "Team numeroso?", "contributors.importHint": "Team numeroso?",
"contributors.importTitle": "Importa collaboratori", "contributors.importTitle": "Importa collaboratori",
"contributors.loadFailed": "Non è stato possibile caricare contributors. Per favore ricarica.", "contributors.loadFailed": "Non è stato possibile caricare contributors. Per favore ricarica.",
"contributors.planHint": "Il tuo piano prevede un numero massimo di {maxContributors} collaboratori.", "contributors.planHint": "Il tuo piano prevede un numero massimo di {maxContributors} collaboratori.",
@ -665,7 +666,6 @@
"roles.loadPermissionsFailed": "Non è stato possibile caricare i permessi. Per favore ricarica.", "roles.loadPermissionsFailed": "Non è stato possibile caricare i permessi. Per favore ricarica.",
"roles.permissions": "Permessi", "roles.permissions": "Permessi",
"roles.permissionsDescription": "I permessi limitano le operazioni consentite e le interrogazioni (query) a livello di API e sono una funzionalità per garantire la sicurezza.", "roles.permissionsDescription": "I permessi limitano le operazioni consentite e le interrogazioni (query) a livello di API e sono una funzionalità per garantire la sicurezza.",
"roles.permissionsPlaceholder": "Inizia a digitare per ricercare i permessi",
"roles.properties": "Proprietà", "roles.properties": "Proprietà",
"roles.properties.hideAPI": "Nascondi le API", "roles.properties.hideAPI": "Nascondi le API",
"roles.properties.hideAssets": "Nascondi le Risorse", "roles.properties.hideAssets": "Nascondi le Risorse",

4
backend/i18n/frontend_nl.json

@ -98,6 +98,7 @@
"assets.loadTagsFailed": "Laden van tags is mislukt. Laad opnieuw.", "assets.loadTagsFailed": "Laden van tags is mislukt. Laad opnieuw.",
"assets.metadata": "Metadata", "assets.metadata": "Metadata",
"assets.metadataAdd": "Metadata toevoegen", "assets.metadataAdd": "Metadata toevoegen",
"assets.move": "Move",
"assets.moveFailed": "Verplaatsen van item is mislukt. Laad opnieuw.", "assets.moveFailed": "Verplaatsen van item is mislukt. Laad opnieuw.",
"assets.protected": "Beschermd", "assets.protected": "Beschermd",
"assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.", "assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.",
@ -535,7 +536,7 @@
"contributors.import.run": "Bijdrager toevoegen", "contributors.import.run": "Bijdrager toevoegen",
"contributors.import.run2": "Importeren", "contributors.import.run2": "Importeren",
"contributors.importButton": "Voeg veel bijdragers tegelijk toe", "contributors.importButton": "Voeg veel bijdragers tegelijk toe",
"contributors.importHintg": "Groot team?", "contributors.importHint": "Groot team?",
"contributors.importTitle": "Bijdragers importeren", "contributors.importTitle": "Bijdragers importeren",
"contributors.loadFailed": "Laden van bijdragers is mislukt. Laad opnieuw.", "contributors.loadFailed": "Laden van bijdragers is mislukt. Laad opnieuw.",
"contributors.planHint": "Uw plan staat maximaal {maxContributors} bijdragers toe.", "contributors.planHint": "Uw plan staat maximaal {maxContributors} bijdragers toe.",
@ -665,7 +666,6 @@
"roles.loadPermissionsFailed": "Kan machtigingen niet laden. Laad opnieuw.", "roles.loadPermissionsFailed": "Kan machtigingen niet laden. Laad opnieuw.",
"roles.permissions": "Rechten", "roles.permissions": "Rechten",
"roles.permissionsDescription": "Machtigingen beperken de toegestane bewerkingen en zoekopdrachten op API-niveau en zijn een beveiligingsfunctie.", "roles.permissionsDescription": "Machtigingen beperken de toegestane bewerkingen en zoekopdrachten op API-niveau en zijn een beveiligingsfunctie.",
"roles.permissionsPlaceholder": "Begin met typen om naar rechten te zoeken",
"roles.properties": "Eigenschappen", "roles.properties": "Eigenschappen",
"roles.properties.hideAPI": "API verbergen", "roles.properties.hideAPI": "API verbergen",
"roles.properties.hideAssets": "Assets verbergen", "roles.properties.hideAssets": "Assets verbergen",

4
backend/i18n/frontend_pt.json

@ -98,6 +98,7 @@
"assets.loadTagsFailed": "Falhou em carregar etiquetas. Por favor, recarregue.", "assets.loadTagsFailed": "Falhou em carregar etiquetas. Por favor, recarregue.",
"assets.metadata": "Metadados", "assets.metadata": "Metadados",
"assets.metadataAdd": "Adicionar metadados", "assets.metadataAdd": "Adicionar metadados",
"assets.move": "Move",
"assets.moveFailed": "Falha ao mover ficheiro. Por favor, recarregue.", "assets.moveFailed": "Falha ao mover ficheiro. Por favor, recarregue.",
"assets.protected": "Protegido", "assets.protected": "Protegido",
"assets.protectedHint": "Os ativos são públicos por defeito. Todos com o link podem descarregar o ficheiro. Se fizer um ficheiro protegido, apenas utilizadores autenticados (normalmente um cliente) podem descarregar o ficheiro.", "assets.protectedHint": "Os ativos são públicos por defeito. Todos com o link podem descarregar o ficheiro. Se fizer um ficheiro protegido, apenas utilizadores autenticados (normalmente um cliente) podem descarregar o ficheiro.",
@ -535,7 +536,7 @@
"contributors.import.run": "Adicionar Contribuintes", "contributors.import.run": "Adicionar Contribuintes",
"contributors.import.run2": "Importação", "contributors.import.run2": "Importação",
"contributors.importButton": "Adicionar muitos contribuintes ao mesmo tempo", "contributors.importButton": "Adicionar muitos contribuintes ao mesmo tempo",
"contributors.importHintg": "Uma grande equipa?", "contributors.importHint": "Uma grande equipa?",
"contributors.importTitle": "Contribuintes de importação", "contributors.importTitle": "Contribuintes de importação",
"contributors.loadFailed": "Falhou em carregar os contribuintes. Por favor, recarregue.", "contributors.loadFailed": "Falhou em carregar os contribuintes. Por favor, recarregue.",
"contributors.planHint": "O seu plano permite até contribuintes {maxContributors} .", "contributors.planHint": "O seu plano permite até contribuintes {maxContributors} .",
@ -665,7 +666,6 @@
"roles.loadPermissionsFailed": "Falhou em carregar permissões. Por favor, recarregue.", "roles.loadPermissionsFailed": "Falhou em carregar permissões. Por favor, recarregue.",
"roles.permissions": "Permissões", "roles.permissions": "Permissões",
"roles.permissionsDescription": "As permissões restringem as operações e consultas permitidas a nível API e são uma funcionalidade de segurança.", "roles.permissionsDescription": "As permissões restringem as operações e consultas permitidas a nível API e são uma funcionalidade de segurança.",
"roles.permissionsPlaceholder": "Comece a escrever para procurar permissões",
"roles.properties": "Propriedades", "roles.properties": "Propriedades",
"roles.properties.hideAPI": "Ocultar API", "roles.properties.hideAPI": "Ocultar API",
"roles.properties.hideAssets": "Ocultar ficheiros", "roles.properties.hideAssets": "Ocultar ficheiros",

4
backend/i18n/frontend_zh.json

@ -98,6 +98,7 @@
"assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "元数据", "assets.metadata": "元数据",
"assets.metadataAdd": "添加元数据", "assets.metadataAdd": "添加元数据",
"assets.move": "Move",
"assets.moveFailed": "资源移动失败。请重新加载。", "assets.moveFailed": "资源移动失败。请重新加载。",
"assets.protected": "受保护", "assets.protected": "受保护",
"assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.", "assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.",
@ -535,7 +536,7 @@
"contributors.import.run": "添加贡献者", "contributors.import.run": "添加贡献者",
"contributors.import.run2": "导入", "contributors.import.run2": "导入",
"contributors.importButton": "一次添加多个贡献者", "contributors.importButton": "一次添加多个贡献者",
"contributors.importHintg": "大团队?", "contributors.importHint": "大团队?",
"contributors.importTitle": "导入贡献者", "contributors.importTitle": "导入贡献者",
"contributors.loadFailed": "加载贡献者失败。请重新加载。", "contributors.loadFailed": "加载贡献者失败。请重新加载。",
"contributors.planHint": "您的计划允许最多 {maxContributors} 个贡献者。", "contributors.planHint": "您的计划允许最多 {maxContributors} 个贡献者。",
@ -665,7 +666,6 @@
"roles.loadPermissionsFailed": "加载权限失败。请重新加载。", "roles.loadPermissionsFailed": "加载权限失败。请重新加载。",
"roles.permissions": "权限", "roles.permissions": "权限",
"roles.permissionsDescription": "权限在 API 级别限制允许的操作和查询,是一项安全功能。", "roles.permissionsDescription": "权限在 API 级别限制允许的操作和查询,是一项安全功能。",
"roles.permissionsPlaceholder": "开始输入以搜索权限",
"roles.properties": "属性", "roles.properties": "属性",
"roles.properties.hideAPI": "隐藏 API", "roles.properties.hideAPI": "隐藏 API",
"roles.properties.hideAssets": "隐藏资源", "roles.properties.hideAssets": "隐藏资源",

4
backend/i18n/source/frontend_en.json

@ -98,6 +98,7 @@
"assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "Metadata", "assets.metadata": "Metadata",
"assets.metadataAdd": "Add Metadata", "assets.metadataAdd": "Add Metadata",
"assets.move": "Move",
"assets.moveFailed": "Failed to move asset. Please reload.", "assets.moveFailed": "Failed to move asset. Please reload.",
"assets.protected": "Protected", "assets.protected": "Protected",
"assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.", "assets.protectedHint": "Assets are public by default. Everybody with the link can download the file. If you make an asset protected, only authenticated users (usually a client) can download the asset.",
@ -535,7 +536,7 @@
"contributors.import.run": "Add Contributors", "contributors.import.run": "Add Contributors",
"contributors.import.run2": "Import", "contributors.import.run2": "Import",
"contributors.importButton": "Add many contributors at once", "contributors.importButton": "Add many contributors at once",
"contributors.importHintg": "Big team?", "contributors.importHint": "Big team?",
"contributors.importTitle": "Import contributors", "contributors.importTitle": "Import contributors",
"contributors.loadFailed": "Failed to load contributors. Please reload.", "contributors.loadFailed": "Failed to load contributors. Please reload.",
"contributors.planHint": "Your plan allows up to {maxContributors} contributors.", "contributors.planHint": "Your plan allows up to {maxContributors} contributors.",
@ -665,7 +666,6 @@
"roles.loadPermissionsFailed": "Failed to load permissions. Please reload.", "roles.loadPermissionsFailed": "Failed to load permissions. Please reload.",
"roles.permissions": "Permissions", "roles.permissions": "Permissions",
"roles.permissionsDescription": "Permissions restrict the allowed operations and queries at API level and are a security feature.", "roles.permissionsDescription": "Permissions restrict the allowed operations and queries at API level and are a security feature.",
"roles.permissionsPlaceholder": "Start typing to search for permissions",
"roles.properties": "Properties", "roles.properties": "Properties",
"roles.properties.hideAPI": "Hide API", "roles.properties.hideAPI": "Hide API",
"roles.properties.hideAssets": "Hide Assets", "roles.properties.hideAssets": "Hide Assets",

2
backend/i18n/source/frontend_it.json

@ -431,7 +431,7 @@
"contributors.import.run": "Aggiungi Collaboratori", "contributors.import.run": "Aggiungi Collaboratori",
"contributors.import.run2": "Importa", "contributors.import.run2": "Importa",
"contributors.importButton": "Aggiungi più collaboratori contemporaneamente", "contributors.importButton": "Aggiungi più collaboratori contemporaneamente",
"contributors.importHintg": "Team numeroso?", "contributors.importHint": "Team numeroso?",
"contributors.importTitle": "Importa collaboratori", "contributors.importTitle": "Importa collaboratori",
"contributors.loadFailed": "Non è stato possibile caricare contributors. Per favore ricarica.", "contributors.loadFailed": "Non è stato possibile caricare contributors. Per favore ricarica.",
"contributors.planHint": "Il tuo piano prevede un numero massimo di {maxContributors} collaboratori.", "contributors.planHint": "Il tuo piano prevede un numero massimo di {maxContributors} collaboratori.",

2
backend/i18n/source/frontend_nl.json

@ -500,7 +500,7 @@
"contributors.import.run": "Bijdrager toevoegen", "contributors.import.run": "Bijdrager toevoegen",
"contributors.import.run2": "Importeren", "contributors.import.run2": "Importeren",
"contributors.importButton": "Voeg veel bijdragers tegelijk toe", "contributors.importButton": "Voeg veel bijdragers tegelijk toe",
"contributors.importHintg": "Groot team?", "contributors.importHint": "Groot team?",
"contributors.importTitle": "Bijdragers importeren", "contributors.importTitle": "Bijdragers importeren",
"contributors.loadFailed": "Laden van bijdragers is mislukt. Laad opnieuw.", "contributors.loadFailed": "Laden van bijdragers is mislukt. Laad opnieuw.",
"contributors.planHint": "Uw plan staat maximaal {maxContributors} bijdragers toe.", "contributors.planHint": "Uw plan staat maximaal {maxContributors} bijdragers toe.",

2
backend/i18n/source/frontend_pt.json

@ -524,7 +524,7 @@
"contributors.import.run": "Adicionar Contribuintes", "contributors.import.run": "Adicionar Contribuintes",
"contributors.import.run2": "Importação", "contributors.import.run2": "Importação",
"contributors.importButton": "Adicionar muitos contribuintes ao mesmo tempo", "contributors.importButton": "Adicionar muitos contribuintes ao mesmo tempo",
"contributors.importHintg": "Uma grande equipa?", "contributors.importHint": "Uma grande equipa?",
"contributors.importTitle": "Contribuintes de importação", "contributors.importTitle": "Contribuintes de importação",
"contributors.loadFailed": "Falhou em carregar os contribuintes. Por favor, recarregue.", "contributors.loadFailed": "Falhou em carregar os contribuintes. Por favor, recarregue.",
"contributors.planHint": "O seu plano permite até contribuintes {maxContributors} .", "contributors.planHint": "O seu plano permite até contribuintes {maxContributors} .",

2
backend/i18n/source/frontend_zh.json

@ -453,7 +453,7 @@
"contributors.import.run": "添加贡献者", "contributors.import.run": "添加贡献者",
"contributors.import.run2": "导入", "contributors.import.run2": "导入",
"contributors.importButton": "一次添加多个贡献者", "contributors.importButton": "一次添加多个贡献者",
"contributors.importHintg": "大团队?", "contributors.importHint": "大团队?",
"contributors.importTitle": "导入贡献者", "contributors.importTitle": "导入贡献者",
"contributors.loadFailed": "加载贡献者失败。请重新加载。", "contributors.loadFailed": "加载贡献者失败。请重新加载。",
"contributors.planHint": "您的计划允许最多 {maxContributors} 个贡献者。", "contributors.planHint": "您的计划允许最多 {maxContributors} 个贡献者。",

7
frontend/src/app/features/administration/state/event-consumers.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { EventConsumersService } from '@app/features/administration/internal'; import { EventConsumersService } from '@app/features/administration/internal';
import { DialogService } from '@app/framework'; import { DialogService } from '@app/framework';
@ -50,7 +49,7 @@ describe('EventConsumersState', () => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
eventConsumersState.load().pipe(onErrorResumeNext()).subscribe(); eventConsumersState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(eventConsumersState.snapshot.isLoading).toBeFalsy(); expect(eventConsumersState.snapshot.isLoading).toBeFalsy();
}); });
@ -70,7 +69,7 @@ describe('EventConsumersState', () => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => throwError(() => 'Service Error')).verifiable(); .returns(() => throwError(() => 'Service Error')).verifiable();
eventConsumersState.load(true, false).pipe(onErrorResumeNext()).subscribe(); eventConsumersState.load(true, false).pipe(onErrorResumeNextWith()).subscribe();
expect().nothing(); expect().nothing();

5
frontend/src/app/features/administration/state/users.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { firstValueFrom, of, throwError } from 'rxjs'; import { firstValueFrom, of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { UpsertUserDto, UsersService } from '@app/features/administration/internal'; import { UpsertUserDto, UsersService } from '@app/features/administration/internal';
import { DialogService } from '@app/shared'; import { DialogService } from '@app/shared';
@ -53,7 +52,7 @@ describe('UsersState', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
usersState.load().pipe(onErrorResumeNext()).subscribe(); usersState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(usersState.snapshot.isLoading).toBeFalsy(); expect(usersState.snapshot.isLoading).toBeFalsy();
}); });

19
frontend/src/app/features/assets/pages/assets-page.component.html

@ -11,10 +11,11 @@
<div class="col" style="width: 300px;"> <div class="col" style="width: 300px;">
<div class="row g-0 search"> <div class="row g-0 search">
<div class="col-6"> <div class="col-6">
<sqx-tag-editor class="tags" [singleLine]="true" placeholder="{{ 'assets.searchByTags' | sqxTranslate }}" <sqx-tag-editor class="tags" placeholder="{{ 'assets.searchByTags' | sqxTranslate }}"
[suggestions]="assetsState.tagsNames | async" [itemsSource]="assetsState.tagsNames | async"
[ngModel]="assetsState.selectedTagNames | async" [ngModel]="assetsState.selectedTagNames | async"
(ngModelChange)="selectTags($event)" (ngModelChange)="selectTags($event)"
[styleScrollable]="true"
[undefinedWhenEmpty]="false"> [undefinedWhenEmpty]="false">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
@ -57,9 +58,10 @@
<div *ngIf="assetsState.path | async; let path"> <div *ngIf="assetsState.path | async; let path">
<sqx-assets-list <sqx-assets-list
[assetsState]="assetsState" [assetsState]="assetsState"
[showPager]="false" (edit)="editStart($event)"
[showFolderIcon]="path.length === 0" [isDisabled]="false"
[isListView]="isListView"> [isListView]="isListView"
[showFolderIcon]="path.length === 0">
</sqx-assets-list> </sqx-assets-list>
</div> </div>
@ -83,3 +85,10 @@
<ng-container *sqxModal="addAssetFolderDialog"> <ng-container *sqxModal="addAssetFolderDialog">
<sqx-asset-folder-dialog (complete)="addAssetFolderDialog.hide()"></sqx-asset-folder-dialog> <sqx-asset-folder-dialog (complete)="addAssetFolderDialog.hide()"></sqx-asset-folder-dialog>
</ng-container> </ng-container>
<sqx-asset-dialog *sqxModal="editAsset;isDialog:true"
[asset]="editAsset!"
(assetUpdated)="replaceAsset($event)"
(assetReplaced)="replaceAsset($event)"
(complete)="editDone()">
</sqx-asset-dialog>

16
frontend/src/app/features/assets/pages/assets-page.component.ts

@ -6,7 +6,7 @@
*/ */
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { AssetsState, DialogModel, LocalStoreService, MathHelper, Queries, Query, QueryFullTextSynchronizer, ResourceOwner, Router2State, UIState } from '@app/shared'; import { AssetDto, AssetsState, DialogModel, LocalStoreService, MathHelper, Queries, Query, QueryFullTextSynchronizer, ResourceOwner, Router2State, UIState } from '@app/shared';
import { Settings } from '@app/shared/state/settings'; import { Settings } from '@app/shared/state/settings';
@Component({ @Component({
@ -20,6 +20,8 @@ import { Settings } from '@app/shared/state/settings';
export class AssetsPageComponent extends ResourceOwner implements OnInit { export class AssetsPageComponent extends ResourceOwner implements OnInit {
public queries = new Queries(this.uiState, 'assets'); public queries = new Queries(this.uiState, 'assets');
public editAsset?: AssetDto;
public addAssetFolderDialog = new DialogModel(); public addAssetFolderDialog = new DialogModel();
public isListView = false; public isListView = false;
@ -57,10 +59,22 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
this.assetsState.load(true, false); this.assetsState.load(true, false);
} }
public editStart(asset: AssetDto) {
this.editAsset = asset;
}
public editDone() {
this.editAsset = undefined;
}
public search(query: Query) { public search(query: Query) {
this.assetsState.search(query); this.assetsState.search(query);
} }
public replaceAsset(asset: AssetDto) {
this.assetsState.replaceAsset(asset);
}
public selectTags(tags: ReadonlyArray<string>) { public selectTags(tags: ReadonlyArray<string>) {
this.assetsState.selectTags(tags); this.assetsState.selectTags(tags);
} }

9
frontend/src/app/features/content/pages/content/editor/content-editor.component.html

@ -1,14 +1,7 @@
<sqx-form-error [bubble]="true" [closeable]="true" [error]="(contentForm.error | async)"></sqx-form-error> <sqx-form-error [bubble]="true" [closeable]="true" [error]="(contentForm.error | async)"></sqx-form-error>
<sqx-list-view> <sqx-list-view>
<ng-container topHeader> <ng-container topHeader>
<div *ngIf="isNew && showIdInput">
<input class="form-control" placeholder="{{ 'contents.idPlaceholder' | sqxTranslate }}"
[ngModel]="contentId"
(ngModelChange)="contentIdChange.emit($event)" />
</div>
<div class="alert alert-danger" *ngIf="!contentVersion && isDeleted"> <div class="alert alert-danger" *ngIf="!contentVersion && isDeleted">
{{ 'contents.deleted' | sqxTranslate }} {{ 'contents.deleted' | sqxTranslate }}
</div> </div>

21
frontend/src/app/features/content/shared/forms/assets-editor.component.html

@ -31,12 +31,13 @@
[isDisabled]="snapshot.isDisabled" [isDisabled]="snapshot.isDisabled"
[isCompact]="snapshot.isCompact" [isCompact]="snapshot.isCompact"
[folderId]="folderId" [folderId]="folderId"
(loadError)="removeLoadingAsset(file)" (loadDone)="addAsset(file, $event)"
(load)="addAsset(file, $event)"> (loadError)="removeLoadingAsset(file)">
</sqx-asset> </sqx-asset>
<sqx-asset *ngFor="let asset of snapshot.assets; trackBy: trackByAsset" <sqx-asset *ngFor="let asset of snapshot.assets; trackBy: trackByAsset"
[asset]="asset" [asset]="asset"
(edit)="editStart($event)"
[isDisabled]="snapshot.isDisabled" [isDisabled]="snapshot.isDisabled"
[isCompact]="snapshot.isCompact" [isCompact]="snapshot.isCompact"
[removeMode]="true" [removeMode]="true"
@ -49,13 +50,13 @@
<ng-template #listTemplate> <ng-template #listTemplate>
<div class="list-view"> <div class="list-view">
<sqx-asset *ngFor="let file of snapshot.assetFiles" <sqx-asset *ngFor="let file of snapshot.assetFiles"
(load)="addAsset(file, $event)"
(loadError)="removeLoadingAsset(file)"
[assetFile]="file" [assetFile]="file"
[folderId]="folderId" [folderId]="folderId"
[isCompact]="snapshot.isCompact" [isCompact]="snapshot.isCompact"
[isDisabled]="snapshot.isDisabled" [isDisabled]="snapshot.isDisabled"
[isListView]="true"> [isListView]="true"
(loadDone)="addAsset(file, $event)"
(loadError)="removeLoadingAsset(file)">
</sqx-asset> </sqx-asset>
<div cdkDropList <div cdkDropList
@ -65,6 +66,7 @@
<div *ngFor="let asset of snapshot.assets; trackBy: trackByAsset" class="table-drag" cdkDrag cdkDragLockAxis="y"> <div *ngFor="let asset of snapshot.assets; trackBy: trackByAsset" class="table-drag" cdkDrag cdkDragLockAxis="y">
<sqx-asset <sqx-asset
[asset]="asset" [asset]="asset"
(edit)="editStart($event)"
[isListView]="true" [isListView]="true"
[isDisabled]="snapshot.isDisabled" [isDisabled]="snapshot.isDisabled"
[isCompact]="snapshot.isCompact" [isCompact]="snapshot.isCompact"
@ -81,4 +83,11 @@
<ng-container *sqxModal="assetsDialog"> <ng-container *sqxModal="assetsDialog">
<sqx-asset-selector (select)="selectAssets($event)"></sqx-asset-selector> <sqx-asset-selector (select)="selectAssets($event)"></sqx-asset-selector>
</ng-container> </ng-container>
<sqx-asset-dialog *sqxModal="snapshot.editAsset;isDialog:true"
[asset]="snapshot.editAsset!"
(assetReplaced)="notifyOthers($event)"
(assetUpdated)="notifyOthers($event)"
(complete)="editDone()">
</sqx-asset-dialog>

15
frontend/src/app/features/content/shared/forms/assets-editor.component.ts

@ -30,6 +30,9 @@ interface State {
// The assets to render. // The assets to render.
assets: ReadonlyArray<AssetDto>; assets: ReadonlyArray<AssetDto>;
// The asset to edit.
editAsset?: AssetDto;
// True when showing the assets as list. // True when showing the assets as list.
isListView: boolean; isListView: boolean;
@ -107,9 +110,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
this.own( this.own(
this.messageBus.of(AssetUpdated) this.messageBus.of(AssetUpdated)
.subscribe(event => { .subscribe(event => {
if (event.source !== this) { this.setAssets(this.snapshot.assets.replacedBy('id', event.asset));
this.setAssets(this.snapshot.assets.replacedBy('id', event.asset));
}
})); }));
} }
@ -179,6 +180,14 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
this.next({ isListView }); this.next({ isListView });
} }
public editStart(asset: AssetDto) {
this.next({ editAsset: asset });
}
public editDone() {
this.next({ editAsset: undefined });
}
private updateValue() { private updateValue() {
const ids = this.snapshot.assets.map(x => x.id); const ids = this.snapshot.assets.map(x => x.id);

2
frontend/src/app/features/content/shared/forms/field-editor.component.html

@ -221,7 +221,7 @@
<ng-container *ngSwitchCase="'Tags'"> <ng-container *ngSwitchCase="'Tags'">
<ng-container [ngSwitch]="field.rawProperties.editor"> <ng-container [ngSwitch]="field.rawProperties.editor">
<ng-container *ngSwitchCase="'Tags'"> <ng-container *ngSwitchCase="'Tags'">
<sqx-tag-editor [formControl]="$any(fieldForm)" [placeholder]="field.displayPlaceholder" [suggestions]="field.rawProperties.allowedValues"></sqx-tag-editor> <sqx-tag-editor [formControl]="$any(fieldForm)" [placeholder]="field.displayPlaceholder" [itemsSource]="field.rawProperties.allowedValues"></sqx-tag-editor>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Checkboxes'"> <ng-container *ngSwitchCase="'Checkboxes'">
<sqx-checkbox-group [formControl]="$any(fieldForm)" [values]="field.rawProperties.allowedValues"></sqx-checkbox-group> <sqx-checkbox-group [formControl]="$any(fieldForm)" [values]="field.rawProperties.allowedValues"></sqx-checkbox-group>

8
frontend/src/app/features/content/shared/references/references-tags.component.html

@ -1,9 +1,9 @@
<sqx-tag-editor placeholder="{{ 'common.tagAddReference' | sqxTranslate }}" <sqx-tag-editor placeholder="{{ 'common.tagAddReference' | sqxTranslate }}"
(open)="onOpened()" (open)="onOpened()"
[formControl]="control"
[allowDuplicates]="false" [allowDuplicates]="false"
[allowOpen]="true" [allowOpen]="true"
[converter]="snapshot.converter" [formControl]="control"
[suggestions]="snapshot.converter.suggestions" [itemConverter]="snapshot.converter"
[suggestionsLoading]="snapshot.isLoading"> [itemsSource]="snapshot.converter.suggestions"
[itemsSourceLoading]="snapshot.isLoading">
</sqx-tag-editor> </sqx-tag-editor>

3
frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html

@ -4,7 +4,8 @@
<div class="col-9"> <div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds" <sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"
[converter]="(schemasSource.converter | async)!" [suggestions]="(schemasSource.converter | async)?.suggestions"> [itemConverter]="(schemasSource.converter | async)!"
[itemsSource]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
</div> </div>

3
frontend/src/app/features/schemas/pages/schema/fields/types/components-validation.component.html

@ -22,7 +22,8 @@
<div class="col-9"> <div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds" <sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"
[converter]="(schemasSource.converter | async)!" [suggestions]="(schemasSource.converter | async)?.suggestions"> [itemConverter]="(schemasSource.converter | async)!"
[itemsSource]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
</div> </div>

2
frontend/src/app/features/schemas/pages/schema/fields/types/number-ui.component.html

@ -27,7 +27,7 @@
<label class="col-3 col-form-label">{{ 'schemas.field.allowedValues' | sqxTranslate }}</label> <label class="col-3 col-form-label">{{ 'schemas.field.allowedValues' | sqxTranslate }}</label>
<div class="col-9"> <div class="col-9">
<sqx-tag-editor formControlName="allowedValues" [converter]="converter"></sqx-tag-editor> <sqx-tag-editor formControlName="allowedValues" [itemConverter]="converter"></sqx-tag-editor>
</div> </div>
</div> </div>
<div class="form-group row" [class.hidden]="hideInlineEditable | async"> <div class="form-group row" [class.hidden]="hideInlineEditable | async">

3
frontend/src/app/features/schemas/pages/schema/fields/types/references-validation.component.html

@ -4,7 +4,8 @@
<div class="col-9"> <div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds" <sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"
[converter]="(schemasSource.normalConverter | async)!" [suggestions]="(schemasSource.normalConverter | async)?.suggestions"> [itemConverter]="(schemasSource.normalConverter | async)!"
[itemsSource]="(schemasSource.normalConverter | async)?.suggestions">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
</div> </div>

3
frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.html

@ -91,7 +91,8 @@
<div class="col-9"> <div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds" <sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"
[converter]="(schemasSource.normalConverter | async)!" [suggestions]="(schemasSource.normalConverter | async)?.suggestions"> [itemConverter]="(schemasSource.normalConverter | async)!"
[itemsSource]="(schemasSource.normalConverter | async)?.suggestions">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
</div> </div>

4
frontend/src/app/features/settings/pages/clients/client.component.html

@ -33,9 +33,9 @@
</label> </label>
<div class="col"> <div class="col">
<div class="input-group"> <div class="input-group">
<input readonly class="form-control" value="{{appsState.appName}}:{{client.id}}" #inputName> <input readonly class="form-control" value="{{appsState.appName}}:{{client.id}}" #clientId>
<button type="button" class="btn btn-outline-secondary" [sqxCopy]="inputName"> <button type="button" class="btn btn-outline-secondary" [sqxCopy]="clientId">
<i class="icon-copy"></i> <i class="icon-copy"></i>
</button> </button>
</div> </div>

2
frontend/src/app/features/settings/pages/clients/client.component.ts

@ -50,7 +50,7 @@ export class ClientComponent implements OnChanges {
} }
public updateApiCallsLimit() { public updateApiCallsLimit() {
this.clientsState.update(this.client, { apiCallsLimit: this.client.apiCallsLimit }); this.clientsState.update(this.client, { apiCallsLimit: this.apiCallsLimit });
} }
public rename(name: string) { public rename(name: string) {

4
frontend/src/app/features/settings/pages/contributors/contributor-add-form.component.html

@ -4,7 +4,7 @@
<form [formGroup]="assignContributorForm.form" (ngSubmit)="assignContributor()"> <form [formGroup]="assignContributorForm.form" (ngSubmit)="assignContributor()">
<div class="row gx-2"> <div class="row gx-2">
<div class="col"> <div class="col">
<sqx-autocomplete [source]="usersDataSource" formControlName="user" icon="search" inputName="contributor" placeholder="{{ 'contributors.emailPlaceholder' | sqxTranslate }}" displayProperty="displayName"> <sqx-autocomplete [itemsSource]="usersDataSource" formControlName="user" icon="search" placeholder="{{ 'contributors.emailPlaceholder' | sqxTranslate }}" displayProperty="displayName">
<ng-template let-user="$implicit"> <ng-template let-user="$implicit">
<span class="autocomplete-user"> <span class="autocomplete-user">
<img class="user-picture" [src]="user | sqxUserDtoPicture"> <img class="user-picture" [src]="user | sqxUserDtoPicture">
@ -29,7 +29,7 @@
<div class="import-hint"> <div class="import-hint">
<sqx-form-hint> <sqx-form-hint>
{{ 'contributors.importHintg' | sqxTranslate }} <a class="force" (click)="importDialog.show()">{{ 'contributors.importButton' | sqxTranslate }}</a> {{ 'contributors.importHint' | sqxTranslate }} <a class="force" (click)="importDialog.show()">{{ 'contributors.importButton' | sqxTranslate }}</a>
</sqx-form-hint> </sqx-form-hint>
</div> </div>
</div> </div>

2
frontend/src/app/features/settings/pages/languages/language-add-form.component.html

@ -4,7 +4,7 @@
<form [formGroup]="addLanguageForm.form" (ngSubmit)="addLanguage()"> <form [formGroup]="addLanguageForm.form" (ngSubmit)="addLanguage()">
<div class="row gx-2"> <div class="row gx-2">
<div class="col"> <div class="col">
<sqx-autocomplete formControlName="language" displayProperty="iso2Code" valueProperty="iso2Code" [source]="addLanguagesSource"> <sqx-autocomplete formControlName="language" displayProperty="iso2Code" valueProperty="iso2Code" [itemsSource]="addLanguagesSource">
<ng-template let-language="$implicit"> <ng-template let-language="$implicit">
{{language.iso2Code}} ({{language.englishName}}) {{language.iso2Code}} ({{language.englishName}})
</ng-template> </ng-template>

2
frontend/src/app/features/settings/pages/roles/role.component.html

@ -56,7 +56,7 @@
<div class="col"> <div class="col">
<sqx-control-errors [for]="control" [fieldName]="'Permission'"></sqx-control-errors> <sqx-control-errors [for]="control" [fieldName]="'Permission'"></sqx-control-errors>
<sqx-autocomplete [formControl]="control" [source]="allPermissions"></sqx-autocomplete> <sqx-autocomplete [formControl]="control" [itemsSource]="allPermissions"></sqx-autocomplete>
</div> </div>
<div class="col-auto" *ngIf="isEditable"> <div class="col-auto" *ngIf="isEditable">
<button type="button" class="btn btn-text-danger" (click)="editForm.form.removeAt(i)"> <button type="button" class="btn btn-text-danger" (click)="editForm.form.removeAt(i)">

6
frontend/src/app/features/settings/pages/workflows/workflow-step.component.html

@ -95,12 +95,12 @@
<sqx-tag-editor <sqx-tag-editor
[allowDuplicates]="false" [allowDuplicates]="false"
[disabled]="!!disabled" [disabled]="!!disabled"
[itemsSource]="roles" placeholder="{{ 'common.role' | sqxTranslate }}"
[ngModelOptions]="onBlur" [ngModelOptions]="onBlur"
[ngModel]="step.noUpdateRoles" [ngModel]="step.noUpdateRoles"
(ngModelChange)="changeNoUpdateRoles($event)" (ngModelChange)="changeNoUpdateRoles($event)"
[singleLine]="true" [styleDashed]="true"
[suggestions]="roles" placeholder="{{ 'common.role' | sqxTranslate }}" [styleScrollable]="true">
[styleDashed]="true">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
<div class="col col-button"></div> <div class="col col-button"></div>

8
frontend/src/app/features/settings/pages/workflows/workflow-transition.component.html

@ -22,15 +22,15 @@
<span class="text-decent">{{ 'workflows.syntax.for' | sqxTranslate }}</span> <span class="text-decent">{{ 'workflows.syntax.for' | sqxTranslate }}</span>
</div> </div>
<div class="col col-roles"> <div class="col col-roles">
<sqx-tag-editor <sqx-tag-editor placeholder="{{ 'common.role' | sqxTranslate }}"
[allowDuplicates]="false" [allowDuplicates]="false"
[disabled]="!!disabled" [disabled]="!!disabled"
[itemsSource]="roles"
[ngModelOptions]="onBlur" [ngModelOptions]="onBlur"
[ngModel]="transition.roles" [ngModel]="transition.roles"
(ngModelChange)="changeRole($event)" (ngModelChange)="changeRole($event)"
[singleLine]="true" [styleDashed]="true"
[styleDashed]="true" [styleScrollable]="true">
[suggestions]="roles" placeholder="{{ 'common.role' | sqxTranslate }}">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>

17
frontend/src/app/features/settings/pages/workflows/workflow.component.html

@ -4,10 +4,12 @@
<span class="workflow-name">{{workflow.displayName}}</span> <span class="workflow-name">{{workflow.displayName}}</span>
</div> </div>
<div class="col col-tags"> <div class="col col-tags">
<sqx-tag-editor [converter]="(schemasSource.normalConverter | async)!" [ngModel]="workflow.schemaIds" <sqx-tag-editor
[styleBlank]="true" [itemConverter]="(schemasSource.normalConverter | async)!"
[singleLine]="true" [ngModel]="workflow.schemaIds"
[readonly]="true"> [readonly]="true">
[styleBlank]="true"
[styleScrollable]="true"
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
<div class="col-options"> <div class="col-options">
@ -77,11 +79,12 @@
<label class="col-form-label" for="{{workflow.id}}_schemas">{{ 'common.schemas' | sqxTranslate }}</label> <label class="col-form-label" for="{{workflow.id}}_schemas">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col"> <div class="col">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" [converter]="(schemasSource.converter | async)!" <sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}"
[disabled]="!isEditable" [disabled]="!isEditable"
[ngModel]="workflow.schemaIds" [itemConverter]="(schemasSource.converter | async)!"
(ngModelChange)="changeSchemaIds($event)" [itemsSource]="(schemasSource.normalConverter | async)?.suggestions"
[suggestions]="(schemasSource.normalConverter | async)?.suggestions"> [ngModel]="workflow.schemaIds"
(ngModelChange)="changeSchemaIds($event)">
</sqx-tag-editor> </sqx-tag-editor>
<sqx-form-hint> <sqx-form-hint>

2
frontend/src/app/features/teams/pages/contributors/contributor-add-form.component.html

@ -4,7 +4,7 @@
<form [formGroup]="assignContributorForm.form" (ngSubmit)="assignContributor()"> <form [formGroup]="assignContributorForm.form" (ngSubmit)="assignContributor()">
<div class="row gx-2"> <div class="row gx-2">
<div class="col"> <div class="col">
<sqx-autocomplete [source]="usersDataSource" formControlName="user" icon="search" inputName="contributor" placeholder="{{ 'contributors.emailPlaceholder' | sqxTranslate }}" displayProperty="displayName"> <sqx-autocomplete [itemsSource]="usersDataSource" formControlName="user" icon="search" placeholder="{{ 'contributors.emailPlaceholder' | sqxTranslate }}" displayProperty="displayName">
<ng-template let-user="$implicit"> <ng-template let-user="$implicit">
<span class="autocomplete-user"> <span class="autocomplete-user">
<img class="user-picture" [src]="user | sqxUserDtoPicture"> <img class="user-picture" [src]="user | sqxUserDtoPicture">

6
frontend/src/app/features/teams/state/team-contributors.state.spec.ts

@ -5,8 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { EMPTY, of, throwError } from 'rxjs'; import { EMPTY, of, onErrorResumeNextWith, throwError } from 'rxjs';
import { catchError, onErrorResumeNext } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { TeamContributorsService, TeamContributorsState } from '@app/features/teams/internal'; import { TeamContributorsService, TeamContributorsState } from '@app/features/teams/internal';
import { ContributorDto, ContributorsPayload, DialogService, ErrorDto, versioned } from '@app/shared'; import { ContributorDto, ContributorsPayload, DialogService, ErrorDto, versioned } from '@app/shared';
@ -65,7 +65,7 @@ describe('TeamContributorsState', () => {
contributorsService.setup(x => x.getContributors(team)) contributorsService.setup(x => x.getContributors(team))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
contributorsState.load().pipe(onErrorResumeNext()).subscribe(); contributorsState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(contributorsState.snapshot.isLoading).toBeFalsy(); expect(contributorsState.snapshot.isLoading).toBeFalsy();
}); });

9
frontend/src/app/features/teams/state/team-plans.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { TeamPlansService, TeamPlansState } from '@app/features/teams/internal'; import { TeamPlansService, TeamPlansState } from '@app/features/teams/internal';
import { DialogService, PlanDto, PlanLockedReason, versioned } from '@app/shared'; import { DialogService, PlanDto, PlanLockedReason, versioned } from '@app/shared';
@ -84,7 +83,7 @@ describe('TeamPlansState', () => {
plansService.setup(x => x.getPlans(team)) plansService.setup(x => x.getPlans(team))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
plansState.load().pipe(onErrorResumeNext()).subscribe(); plansState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(plansState.snapshot.isLoading).toBeFalsy(); expect(plansState.snapshot.isLoading).toBeFalsy();
}); });
@ -119,7 +118,7 @@ describe('TeamPlansState', () => {
plansService.setup(x => x.putPlan(team, It.isAny(), version)) plansService.setup(x => x.putPlan(team, It.isAny(), version))
.returns(() => of(versioned(newVersion, result))); .returns(() => of(versioned(newVersion, result)));
plansState.change('free').pipe(onErrorResumeNext()).subscribe(); plansState.change('free').pipe(onErrorResumeNextWith()).subscribe();
expect(plansState.snapshot.plans).toEqual([ expect(plansState.snapshot.plans).toEqual([
{ isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] },
@ -133,7 +132,7 @@ describe('TeamPlansState', () => {
plansService.setup(x => x.putPlan(team, It.isAny(), version)) plansService.setup(x => x.putPlan(team, It.isAny(), version))
.returns(() => of(versioned(newVersion, { redirectUri: '' }))); .returns(() => of(versioned(newVersion, { redirectUri: '' })));
plansState.change('id2_yearly').pipe(onErrorResumeNext()).subscribe(); plansState.change('id2_yearly').pipe(onErrorResumeNextWith()).subscribe();
expect(plansState.snapshot.plans).toEqual([ expect(plansState.snapshot.plans).toEqual([
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] },

12
frontend/src/app/framework/angular/forms/editors/autocomplete.component.html

@ -1,15 +1,15 @@
<div class="control-container"> <div class="control-container">
<input class="form-control" (blur)="blur()" (keydown)="onKeyDown($event)" #input <input class="form-control" #input
(blur)="blur()" (keydown)="onKeyDown($event)"
[sqxFocusOnInit]="autoFocus" [sqxFocusOnInit]="autoFocus"
[name]="inputName" autocapitalize="off"
[placeholder]="placeholder"
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
autocapitalize="off"
[class.form-empty]="inputStyle === 'empty'" [class.form-empty]="inputStyle === 'empty'"
[class.form-underlined]="inputStyle === 'underlined'"
[class.form-icon]="!!icon" [class.form-icon]="!!icon"
[formControl]="queryInput"> [class.form-underlined]="inputStyle === 'underlined'"
[formControl]="queryInput"
[placeholder]="placeholder">
<div class="icon" *ngIf="icon"> <div class="icon" *ngIf="icon">
<ng-container *ngIf="snapshot.isLoading; else notLoading"> <ng-container *ngIf="snapshot.isLoading; else notLoading">

11
frontend/src/app/framework/angular/forms/editors/autocomplete.component.ts

@ -36,7 +36,7 @@ interface State {
const NO_EMIT = { emitEvent: false }; const NO_EMIT = { emitEvent: false };
@Component({ @Component({
selector: 'sqx-autocomplete', selector: 'sqx-autocomplete[itemsSource]',
styleUrls: ['./autocomplete.component.scss'], styleUrls: ['./autocomplete.component.scss'],
templateUrl: './autocomplete.component.html', templateUrl: './autocomplete.component.html',
providers: [ providers: [
@ -49,10 +49,7 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
private timer: any; private timer: any;
@Input() @Input()
public source!: AutocompleteSource; public itemsSource!: AutocompleteSource;
@Input()
public inputName = 'autocompletion';
@Input() @Input()
public inputStyle?: 'underlined' | 'empty'; public inputStyle?: 'underlined' | 'empty';
@ -139,12 +136,12 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
this.own( this.own(
merge(inputStream, this.modalStream).pipe( merge(inputStream, this.modalStream).pipe(
switchMap(query => { switchMap(query => {
if (!this.source) { if (!this.itemsSource) {
return of([]); return of([]);
} else { } else {
this.setLoading(true); this.setLoading(true);
return this.source.find(query).pipe( return this.itemsSource.find(query).pipe(
finalize(() => { finalize(() => {
this.setLoading(false); this.setLoading(false);
}), }),

6
frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts

@ -50,7 +50,7 @@ const Template: Story<AutocompleteComponent & { model: any }> = (args: Autocompl
[disabled]="disabled" [disabled]="disabled"
[icon]="icon" [icon]="icon"
[inputStyle]="inputStyle" [inputStyle]="inputStyle"
[source]="source"> [itemsSource]="itemsSource">
</sqx-autocomplete> </sqx-autocomplete>
</sqx-root-view> </sqx-root-view>
`, `,
@ -71,7 +71,7 @@ class Source implements AutocompleteSource {
export const Default = Template.bind({}); export const Default = Template.bind({});
Default.args = { Default.args = {
source: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing']), itemsSource: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing']),
}; };
export const Disabled = Template.bind({}); export const Disabled = Template.bind({});
@ -101,6 +101,6 @@ StyleUnderlined.args = {
export const IconLoading = Template.bind({}); export const IconLoading = Template.bind({});
IconLoading.args = { IconLoading.args = {
source: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], 4000), itemsSource: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], 4000),
icon: 'user', icon: 'user',
}; };

4
frontend/src/app/framework/angular/forms/editors/dropdown.component.html

@ -29,9 +29,9 @@
<div class="control-dropdown-items" #container> <div class="control-dropdown-items" #container>
<div *ngFor="let item of snapshot.suggestedItems; let i = index;" class="control-dropdown-item control-dropdown-item-selectable" <div *ngFor="let item of snapshot.suggestedItems; let i = index;" class="control-dropdown-item control-dropdown-item-selectable"
(mousedown)="selectIndexAndClose(i)"
[class.active]="i === snapshot.suggestedIndex" [class.active]="i === snapshot.suggestedIndex"
[class.separated]="itemSeparator" [class.separated]="itemSeparator"
(mousedown)="selectIndexAndClose(i)"
[sqxScrollActive]="i === snapshot.suggestedIndex" [sqxScrollActive]="i === snapshot.suggestedIndex"
[sqxScrollContainer]="container"> [sqxScrollContainer]="container">
<ng-container *ngIf="!templateItem">{{item}}</ng-container> <ng-container *ngIf="!templateItem">{{item}}</ng-container>

44
frontend/src/app/framework/angular/forms/editors/tag-editor.component.html

@ -1,35 +1,33 @@
<div class="form-container"> <div class="form-container">
<div class="form-control tags" tabindex="0" #form <div class="form-control tags" tabindex="0" #form
(mousedown)="focusInput($event)" (focus)="focusInput($event)" (mousedown)="focusInput($event)"
(focus)="focusInput($event)"
[class.blank]="styleBlank" [class.blank]="styleBlank"
[class.singleline]="singleLine" [class.dashed]="styleDashed && !(snapshot.tags.length > 0)"
[class.readonly]="readonly"
[class.suggested]="suggestionsSorted.length > 0"
[class.multiline]="!singleLine"
[class.focus]="snapshot.hasFocus"
[class.disabled]="snapshot.isDisabled" [class.disabled]="snapshot.isDisabled"
[class.dashed]="styleDashed && !(snapshot.items.length > 0)"> [class.focus]="snapshot.hasFocus"
<span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled"> [class.multiline]="!styleScrollable"
{{item}} <i class="icon-close" (click)="remove(i)"></i> [class.readonly]="readonly"
[class.singleline]="styleScrollable"
[class.suggested]="itemsSorted.length > 0">
<span class="item" *ngFor="let tag of snapshot.tags; let i = index" [class.disabled]="addInput.disabled">
{{tag}} <i class="icon-close" (click)="remove(i)"></i>
</span> </span>
<input class="blank text-input" #input <input class="blank text-input" #input
(blur)="markTouched()" (copy)="onCopy($event)" (cut)="onCut($event)" (focus)="focus()" (keydown)="onKeyDown($event)" (paste)="onPaste($event)" (blur)="markTouched()" (copy)="onCopy($event)" (cut)="onCut($event)" (focus)="focus()" (keydown)="onKeyDown($event)" (paste)="onPaste($event)"
[name]="inputName"
[placeholder]="placeholder | sqxTranslate" [placeholder]="placeholder | sqxTranslate"
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
[formControl]="addInput"> [formControl]="addInput">
</div> </div>
<div class="btn btn-sm" (click)="openModal()" sqxStopClick *ngIf="!readonly && (allowOpen || suggestionsSorted.length > 0)"> <div class="btn btn-sm" (click)="openModal()" sqxStopClick *ngIf="!readonly && (allowOpen || itemsSorted.length > 0)">
<i class="icon-caret-down"></i> <i class="icon-caret-down"></i>
</div> </div>
<ng-container *sqxModal="snapshot.suggestedItems.length > 0"> <ng-container *sqxModal="snapshot.itemsList.length > 0">
<sqx-dropdown-menu class="control-dropdown" <sqx-dropdown-menu class="control-dropdown"
[sqxAnchoredTo]="form" [sqxAnchoredTo]="form"
[adjustWidth]="true" [adjustWidth]="true"
@ -37,27 +35,27 @@
[scrollY]="true" [scrollY]="true"
[style.minWidth]="dropdownWidth" [style.minWidth]="dropdownWidth"
position="bottom-left" #container> position="bottom-left" #container>
<div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable" <div *ngFor="let item of snapshot.itemsList; let i = index" class="control-dropdown-item control-dropdown-item-selectable"
[class.active]="i === snapshot.suggestedIndex" [class.active]="i === snapshot.itemsIndex"
[class.separated]="itemSeparator" [class.separated]="itemSeparator"
(mousedown)="selectValue(item)" (mousedown)="selectValue(item)"
(mouseover)="selectIndex(i)" (mouseover)="selectIndex(i)"
[sqxScrollActive]="i === snapshot.suggestedIndex" [sqxScrollActive]="i === snapshot.itemsIndex"
[sqxScrollContainer]="container.nativeElement"> [sqxScrollContainer]="container.nativeElement">
<ng-container>{{item}}</ng-container> <ng-container>{{item}}</ng-container>
</div> </div>
</sqx-dropdown-menu> </sqx-dropdown-menu>
</ng-container> </ng-container>
<ng-container *sqxModal="suggestionsModal"> <ng-container *sqxModal="itemsModal">
<sqx-dropdown-menu class="control-dropdown suggestions-dropdown" <sqx-dropdown-menu class="control-dropdown suggestions-dropdown"
[sqxAnchoredTo]="form" [sqxAnchoredTo]="form"
[adjustWidth]="false" [adjustWidth]="false"
[adjustHeight]="false" [adjustHeight]="false"
[scrollY]="true" [scrollY]="true"
position="bottom-left"> position="bottom-left">
<div class="row"> <div class="row">
<div class=" col-6" *ngFor="let item of suggestionsSorted; let i = index"> <div class=" col-6" *ngFor="let item of itemsSorted; let i = index">
<div class="form-check form-check"> <div class="form-check form-check">
<input class="form-check-input" type="checkbox" id="tag_{{i}}" <input class="form-check-input" type="checkbox" id="tag_{{i}}"
[ngModel]="isSelected(item)" [ngModel]="isSelected(item)"
@ -71,13 +69,13 @@
</div> </div>
</div> </div>
<div class="text-decent" *ngIf="suggestionsSorted.length === 0"> <div class="text-decent" *ngIf="itemsSorted.length === 0">
<ng-container *ngIf="suggestionsLoading; else notLoading"> <ng-container *ngIf="itemsSourceLoading; else notLoading">
<sqx-loader color="input"></sqx-loader> <sqx-loader color="input"></sqx-loader>
</ng-container> </ng-container>
<ng-template #notLoading> <ng-template #notLoading>
<small>{{suggestionsEmptyText | sqxTranslate}}</small> <small>{{itemsSourceEmptyText | sqxTranslate}}</small>
</ng-template> </ng-template>
</div> </div>
</sqx-dropdown-menu> </sqx-dropdown-menu>

109
frontend/src/app/framework/angular/forms/editors/tag-editor.component.ts

@ -19,13 +19,13 @@ interface State {
hasFocus: boolean; hasFocus: boolean;
// The suggested item. // The suggested item.
suggestedItems: ReadonlyArray<TagValue>; itemsList: ReadonlyArray<TagValue>;
// The index of the selected suggested items. // The index of the selected suggested items.
suggestedIndex: number; itemsIndex: number;
// All available tag values. // All available tag values.
items: ReadonlyArray<TagValue>; tags: ReadonlyArray<TagValue>;
} }
@Component({ @Component({
@ -58,7 +58,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
public blur = new EventEmitter(); public blur = new EventEmitter();
@Input() @Input()
public converter = StringConverter.INSTANCE; public itemConverter = StringConverter.INSTANCE;
@Input() @Input()
public undefinedWhenEmpty?: boolean | null = true; public undefinedWhenEmpty?: boolean | null = true;
@ -72,12 +72,6 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
@Input() @Input()
public allowDuplicates?: boolean | null = true; public allowDuplicates?: boolean | null = true;
@Input()
public itemSeparator?: boolean | null;
@Input()
public singleLine?: boolean | null;
@Input() @Input()
public readonly?: boolean | null; public readonly?: boolean | null;
@ -88,19 +82,22 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
public styleBlank?: boolean | null; public styleBlank?: boolean | null;
@Input() @Input()
public placeholder = 'i18n:common.tagAdd'; public styleScrollable?: boolean | null;
@Input() @Input()
public inputName = 'tag-editor'; public placeholder = 'i18n:common.tagAdd';
@Input() @Input()
public dropdownWidth = '18rem'; public dropdownWidth = '18rem';
@Input() @Input()
public suggestionsLoading?: boolean | null; public itemSeparator?: boolean | null;
@Input()
public itemsSourceLoading?: boolean | null;
@Input() @Input()
public suggestionsEmptyText = 'i18n:common.empty'; public itemsSourceEmptyText = 'i18n:common.empty';
@Input() @Input()
public set disabled(value: boolean | undefined | null) { public set disabled(value: boolean | undefined | null) {
@ -108,32 +105,32 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
@Input() @Input()
public set suggestions(value: ReadonlyArray<string | TagValue> | undefined | null) { public set itemsSource(value: ReadonlyArray<string | TagValue> | undefined | null) {
this.suggestionsSorted = getTagValues(value); this.itemsSorted = getTagValues(value);
if (this.addInput.value) { if (this.addInput.value) {
const query = this.addInput.value; const query = this.addInput.value;
const items = this.suggestionsSorted.filter(s => s.lowerCaseName.includes(query) && !this.snapshot.items.find(x => x.id === s.id)); const items = this.itemsSorted.filter(s => s.lowerCaseName.includes(query) && !this.snapshot.tags.find(x => x.id === s.id));
this.next({ this.next({
suggestedIndex: -1, itemsIndex: -1,
suggestedItems: items || [], itemsList: items || [],
}); });
} }
} }
public suggestionsSorted: ReadonlyArray<TagValue> = []; public itemsSorted: ReadonlyArray<TagValue> = [];
public suggestionsModal = new ModalModel(); public itemsModal = new ModalModel();
public addInput = new UntypedFormControl(); public addInput = new UntypedFormControl();
constructor(changeDetector: ChangeDetectorRef) { constructor(changeDetector: ChangeDetectorRef) {
super(changeDetector, { super(changeDetector, {
hasFocus: false, hasFocus: false,
suggestedItems: [], itemsList: [],
suggestedIndex: 0, itemsIndex: 0,
items: [], tags: [],
}); });
this.textMeasurer = new TextMeasurer(() => this.inputElement); this.textMeasurer = new TextMeasurer(() => this.inputElement);
@ -175,16 +172,16 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
map(query => { map(query => {
if (!query) { if (!query) {
return []; return [];
} else if (Types.isArray(this.suggestionsSorted)) { } else if (Types.isArray(this.itemsSorted)) {
return this.suggestionsSorted.filter(s => s.lowerCaseName.includes(query) && !this.snapshot.items.find(x => x.id === s.id)); return this.itemsSorted.filter(s => s.lowerCaseName.includes(query) && !this.snapshot.tags.find(x => x.id === s.id));
} else { } else {
return []; return [];
} }
})) }))
.subscribe(suggestedItems => { .subscribe(suggestedItems => {
this.next({ this.next({
suggestedIndex: -1, itemsIndex: -1,
suggestedItems, itemsList: suggestedItems,
}); });
})); }));
} }
@ -199,12 +196,12 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
const items: any[] = []; const items: any[] = [];
if (this.converter && Types.isArray(obj)) { if (this.itemConverter && Types.isArray(obj)) {
for (const value of obj) { for (const value of obj) {
if (Types.is(value, TagValue)) { if (Types.is(value, TagValue)) {
items.push(value); items.push(value);
} else { } else {
const converted = this.converter.convertValue(value); const converted = this.itemConverter.convertValue(value);
if (converted) { if (converted) {
items.push(converted); items.push(converted);
@ -213,7 +210,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
} }
this.next({ items }); this.next({ tags: items });
} }
public onDisabled(isDisabled: boolean) { public onDisabled(isDisabled: boolean) {
@ -240,7 +237,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
public remove(index: number) { public remove(index: number) {
this.updateItems(this.snapshot.items.filter((_, i) => i !== index), true); this.updateItems(this.snapshot.tags.filter((_, i) => i !== index), true);
} }
public resetSize() { public resetSize() {
@ -261,7 +258,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
this.inputElement.nativeElement.style.width = `${width + 5}px`; this.inputElement.nativeElement.style.width = `${width + 5}px`;
if (this.singleLine) { if (this.styleScrollable) {
setTimeout(() => { setTimeout(() => {
this.formElement.nativeElement.scrollLeft = this.formElement.nativeElement.scrollWidth; this.formElement.nativeElement.scrollLeft = this.formElement.nativeElement.scrollWidth;
}, 0); }, 0);
@ -275,11 +272,11 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
const value = this.addInput.value as string; const value = this.addInput.value as string;
if (!value || value.length === 0) { if (!value || value.length === 0) {
this.updateItems(this.snapshot.items.slice(0, this.snapshot.items.length - 1), false); this.updateItems(this.snapshot.tags.slice(0, this.snapshot.tags.length - 1), false);
return false; return false;
} }
} else if (Keys.isEscape(event) && this.suggestionsModal.isOpen) { } else if (Keys.isEscape(event) && this.itemsModal.isOpen) {
this.closeModal(); this.closeModal();
return false; return false;
} else if (Keys.isUp(event)) { } else if (Keys.isUp(event)) {
@ -289,8 +286,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
this.selectNextIndex(); this.selectNextIndex();
return false; return false;
} else if (Keys.isEnter(event)) { } else if (Keys.isEnter(event)) {
if (this.snapshot.suggestedIndex >= 0) { if (this.snapshot.itemsIndex >= 0) {
if (this.selectValue(this.snapshot.suggestedItems[this.snapshot.suggestedIndex])) { if (this.selectValue(this.snapshot.itemsList[this.snapshot.itemsIndex])) {
return false; return false;
} }
} else if (this.acceptEnter) { } else if (this.acceptEnter) {
@ -311,14 +308,14 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
let tagValue: TagValue | null; let tagValue: TagValue | null;
if (Types.isString(value)) { if (Types.isString(value)) {
tagValue = this.converter.convertInput(value); tagValue = this.itemConverter.convertInput(value);
} else { } else {
tagValue = value; tagValue = value;
} }
if (tagValue) { if (tagValue) {
if (this.allowDuplicates || !this.isSelected(tagValue)) { if (this.allowDuplicates || !this.isSelected(tagValue)) {
this.updateItems([...this.snapshot.items, tagValue], true); this.updateItems([...this.snapshot.tags, tagValue], true);
} }
this.resetForm(); this.resetForm();
@ -331,18 +328,18 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
public toggleValue(isSelected: boolean, tagValue: TagValue) { public toggleValue(isSelected: boolean, tagValue: TagValue) {
if (isSelected) { if (isSelected) {
this.updateItems([...this.snapshot.items, tagValue], true); this.updateItems([...this.snapshot.tags, tagValue], true);
} else { } else {
this.updateItems(this.snapshot.items.filter(x => x.id !== tagValue.id), true); this.updateItems(this.snapshot.tags.filter(x => x.id !== tagValue.id), true);
} }
} }
public selectPrevIndex() { public selectPrevIndex() {
this.selectIndex(this.snapshot.suggestedIndex - 1); this.selectIndex(this.snapshot.itemsIndex - 1);
} }
public selectNextIndex() { public selectNextIndex() {
this.selectIndex(this.snapshot.suggestedIndex + 1); this.selectIndex(this.snapshot.itemsIndex + 1);
} }
public selectIndex(suggestedIndex: number) { public selectIndex(suggestedIndex: number) {
@ -350,11 +347,11 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
suggestedIndex = 0; suggestedIndex = 0;
} }
if (suggestedIndex >= this.snapshot.suggestedItems.length) { if (suggestedIndex >= this.snapshot.itemsList.length) {
suggestedIndex = this.snapshot.suggestedItems.length - 1; suggestedIndex = this.snapshot.itemsList.length - 1;
} }
this.next({ suggestedIndex }); this.next({ itemsIndex: suggestedIndex });
} }
public resetFocus(): any { public resetFocus(): any {
@ -362,7 +359,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
private resetAutocompletion() { private resetAutocompletion() {
this.next({ suggestedItems: [], suggestedIndex: -1 }); this.next({ itemsList: [], itemsIndex: -1 });
} }
private resetForm() { private resetForm() {
@ -370,22 +367,22 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
public isSelected(tagValue: TagValue) { public isSelected(tagValue: TagValue) {
return this.snapshot.items.find(x => x.id === tagValue.id); return this.snapshot.tags.find(x => x.id === tagValue.id);
} }
public closeModal() { public closeModal() {
if (this.suggestionsModal.isOpen) { if (this.itemsModal.isOpen) {
this.close.emit(); this.close.emit();
this.suggestionsModal.hide(); this.itemsModal.hide();
} }
} }
public openModal() { public openModal() {
if (!this.suggestionsModal.isOpen) { if (!this.itemsModal.isOpen) {
this.open.emit(); this.open.emit();
this.suggestionsModal.show(); this.itemsModal.show();
} }
} }
@ -412,7 +409,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
public onCopy(event: ClipboardEvent) { public onCopy(event: ClipboardEvent) {
if (!this.hasSelection()) { if (!this.hasSelection()) {
if (event.clipboardData) { if (event.clipboardData) {
event.clipboardData.setData('text/plain', this.snapshot.items.map(x => x.name).join(',')); event.clipboardData.setData('text/plain', this.snapshot.tags.map(x => x.name).join(','));
} }
event.preventDefault(); event.preventDefault();
@ -426,10 +423,10 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
if (value) { if (value) {
this.resetForm(); this.resetForm();
const values = [...this.snapshot.items]; const values = [...this.snapshot.tags];
for (const part of value.split(',')) { for (const part of value.split(',')) {
const converted = this.converter.convertInput(part); const converted = this.itemConverter.convertInput(part);
if (converted) { if (converted) {
values.push(converted); values.push(converted);
@ -451,7 +448,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
private updateItems(items: ReadonlyArray<TagValue>, touched: boolean) { private updateItems(items: ReadonlyArray<TagValue>, touched: boolean) {
this.next({ items }); this.next({ tags: items });
if (items.length === 0 && this.undefinedWhenEmpty) { if (items.length === 0 && this.undefinedWhenEmpty) {
this.callChange(undefined); this.callChange(undefined);

36
frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts

@ -23,24 +23,24 @@ const TRANSLATIONS = {
<sqx-root-view> <sqx-root-view>
<sqx-tag-editor <sqx-tag-editor
[allowOpen]="true" [allowOpen]="true"
[suggestions]="suggestions" [itemsSource]="itemsSource"
[suggestionsLoading]="suggestionsLoading" [itemsSourceLoading]="itemsSourceLoading"
(open)="load()"> (open)="load()">
</sqx-tag-editor> </sqx-tag-editor>
</sqx-root-view> </sqx-root-view>
`, `,
}) })
class TestComponent { class TestComponent {
public suggestions: string[] = []; public itemsSource: string[] = [];
public suggestionsLoading = false; public itemsSourceLoading = false;
public load() { public load() {
this.suggestions = []; this.itemsSource = [];
this.suggestionsLoading = true; this.itemsSourceLoading = true;
setTimeout(() => { setTimeout(() => {
this.suggestions = ['A', 'B']; this.itemsSource = ['A', 'B'];
this.suggestionsLoading = false; this.itemsSourceLoading = false;
}, 1000); }, 1000);
} }
} }
@ -80,12 +80,12 @@ const Template: Story<TagEditorComponent & { ngModel: any }> = (args: TagEditorC
<sqx-tag-editor <sqx-tag-editor
[allowOpen]="allowOpen" [allowOpen]="allowOpen"
[disabled]="disabled" [disabled]="disabled"
[itemsSource]="itemsSource"
[itemsSourceLoading]="itemsSourceLoading"
[ngModel]="ngModel" [ngModel]="ngModel"
[singleLine]="singleLine" [styleScrollable]="styleScrollable"
[styleBlank]="styleBlank" [styleBlank]="styleBlank"
[styleDashed]="styleDashed" [styleDashed]="styleDashed">
[suggestions]="suggestions"
[suggestionsLoading]="suggestionsLoading">
</sqx-tag-editor> </sqx-tag-editor>
</sqx-root-view> </sqx-root-view>
`, `,
@ -103,28 +103,28 @@ export const Default = Template.bind({});
export const Suggestions = Template.bind({}); export const Suggestions = Template.bind({});
Suggestions.args = { Suggestions.args = {
suggestions: ['A', 'B', 'C'], itemsSource: ['A', 'B', 'C'],
allowOpen: true, allowOpen: true,
}; };
export const SuggestionsEmpty = Template.bind({}); export const SuggestionsEmpty = Template.bind({});
SuggestionsEmpty.args = { SuggestionsEmpty.args = {
suggestions: [], itemsSource: [],
allowOpen: true, allowOpen: true,
}; };
export const SuggestionsLoading = Template.bind({}); export const SuggestionsLoading = Template.bind({});
SuggestionsLoading.args = { SuggestionsLoading.args = {
suggestionsLoading: true, itemsSourceLoading: true,
allowOpen: true, allowOpen: true,
}; };
export const Values = Template.bind({}); export const Values = Template.bind({});
Values.args = { Values.args = {
suggestions: [], itemsSource: [],
ngModel: ['A', 'A', 'B'], ngModel: ['A', 'A', 'B'],
}; };
@ -159,14 +159,14 @@ StyleBlankValues.args = {
export const Multiline = Template.bind({}); export const Multiline = Template.bind({});
Multiline.args = { Multiline.args = {
singleLine: false, styleScrollable: false,
ngModel: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor', 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua'], ngModel: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor', 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua'],
}; };
export const SingleLine = Template.bind({}); export const SingleLine = Template.bind({});
SingleLine.args = { SingleLine.args = {
singleLine: true, styleScrollable: true,
ngModel: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor', 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua'], ngModel: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor', 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua'],
}; };

5
frontend/src/app/framework/angular/modals/modal.directive.ts

@ -43,6 +43,9 @@ export class ModalDirective implements OnDestroy {
@Input('sqxModalCloseAlways') @Input('sqxModalCloseAlways')
public closeAlways = false; public closeAlways = false;
@Input('sqxModalIsDialog')
public isDialog = false;
constructor( constructor(
private readonly changeDetector: ChangeDetectorRef, private readonly changeDetector: ChangeDetectorRef,
private readonly renderer: Renderer2, private readonly renderer: Renderer2,
@ -115,7 +118,7 @@ export class ModalDirective implements OnDestroy {
} }
private subscribeToView() { private subscribeToView() {
if (Types.is(this.currentModel, DialogModel)) { if (Types.is(this.currentModel, DialogModel) || this.isDialog) {
return; return;
} }

44
frontend/src/app/framework/utils/rxjs-extensions.ts

@ -5,9 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { EMPTY, Observable, ReplaySubject, throwError } from 'rxjs'; import { EMPTY, Observable, of, onErrorResumeNextWith, ReplaySubject, throwError } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, map, share, switchMap } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, filter, map, share, switchMap, tap } from 'rxjs/operators';
import { onErrorResumeNext } from 'rxjs/operators';
import { DialogService } from './../services/dialog.service'; import { DialogService } from './../services/dialog.service';
import { Version, versioned, Versioned } from './version'; import { Version, versioned, Versioned } from './version';
@ -55,9 +54,9 @@ export function shareMapSubscribed<T, R = T>(dialogs: DialogService, project: (v
export function debounceTimeSafe<T>(duration: number) { export function debounceTimeSafe<T>(duration: number) {
return function mapOperation(source: Observable<T>) { return function mapOperation(source: Observable<T>) {
if (duration > 0) { if (duration > 0) {
return source.pipe(debounceTime(duration), onErrorResumeNext()); return source.pipe(debounceTime(duration), onErrorResumeNextWith());
} else { } else {
return source.pipe(onErrorResumeNext()); return source.pipe(onErrorResumeNextWith());
} }
}; };
} }
@ -69,22 +68,27 @@ export function defined<T>() {
} }
export function switchSafe<T, R>(project: (source: T) => Observable<R>) { export function switchSafe<T, R>(project: (source: T) => Observable<R>) {
return function mapOperation(source: Observable<T>) { return switchMap<T, Observable<R>>(x => {
return source.pipe( try {
switchMap(x => { return project(x).pipe(catchError(_ => EMPTY));
try { } catch {
return project(x).pipe(catchError(_ => EMPTY)); return EMPTY;
} catch { }
return EMPTY; });
}
}));
};
} }
export function ofForever<T>(...values: ReadonlyArray<T>) { export function switchMapCached<R>(project: (source: string) => Observable<R>) {
return new Observable<T>(s => { const cache: { [key: string]: R } = {};
for (const value of values) {
s.next(value); return switchMap<string, Observable<R>>(x => {
const cached = cache[x];
if (cached) {
return of(cached);
} }
return project(x).pipe(tap(result => {
cache[x] = result;
}));
}); });
} }

34
frontend/src/app/shared/components/assets/asset-dialog.component.html

@ -77,7 +77,35 @@
<label for="id">{{ 'common.folder' | sqxTranslate }}</label> <label for="id">{{ 'common.folder' | sqxTranslate }}</label>
<div class="path"> <div class="path">
<sqx-asset-path [path]="path | async" [all]="true" (navigate)="navigate($event.id)"></sqx-asset-path> <ng-container *ngIf="isMoving; else pathTemplate">
<form [formGroup]="moveForm.form" (ngSubmit)="moveAsset()">
<div class="row align-items-center g-2">
<div class="col">
<sqx-asset-folder-dropdown formControlName="parentId"></sqx-asset-folder-dropdown>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">
{{ 'assets.move' | sqxTranslate }}
</button>
</div>
</div>
</form>
</ng-container>
<ng-template #pathTemplate>
<div class="row align-items-center g-2">
<div class="col">
<sqx-asset-path [path]="pathItems | async" [all]="true" (navigate)="navigate($event.id)"></sqx-asset-path>
</div>
<div class="col-auto" *ngIf="isMoveable">
<button type="button" class="btn btn-outline-secondary" (click)="startMoving()">
{{ 'assets.move' | sqxTranslate }}
</button>
</div>
</div>
</ng-template>
</div> </div>
</div> </div>
@ -125,12 +153,12 @@
</button> </button>
</div> </div>
<div class="form-group"> <div class="form-group" *ngIf="annotateTags | async; let tags">
<label>{{ 'common.tags' | sqxTranslate }}</label> <label>{{ 'common.tags' | sqxTranslate }}</label>
<sqx-control-errors for="tags"></sqx-control-errors> <sqx-control-errors for="tags"></sqx-control-errors>
<sqx-tag-editor [suggestions]="allTags" [allowDuplicates]="false" [undefinedWhenEmpty]="false" formControlName="tags"></sqx-tag-editor> <sqx-tag-editor [itemsSource]="tags" [allowDuplicates]="false" [undefinedWhenEmpty]="false" formControlName="tags"></sqx-tag-editor>
</div> </div>
<div class="form-group"> <div class="form-group">

4
frontend/src/app/shared/components/assets/asset-dialog.component.scss

@ -41,10 +41,6 @@
} }
} }
.path {
min-height: 2.5rem;
}
.slug { .slug {
padding-right: 6rem; padding-right: 6rem;
} }

139
frontend/src/app/shared/components/assets/asset-dialog.component.ts

@ -5,33 +5,33 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, QueryList, ViewChildren } from '@angular/core'; import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, QueryList, ViewChildren } from '@angular/core';
import { Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AnnotateAssetDto, AnnotateAssetForm, AppsState, AssetDto, AssetsState, AssetUploaderState, AuthService, DialogService, Types, UploadCanceled } from '@app/shared/internal'; import { AnnotateAssetDto, AnnotateAssetForm, AppsState, AssetDto, AssetsState, AssetUploaderState, AuthService, DialogService, MoveAssetForm, switchMapCached, Types, UploadCanceled } from '@app/shared/internal';
import { AssetsService } from '@app/shared/services/assets.service'; import { AssetsService, MoveAssetItemDto } from '@app/shared/services/assets.service';
import { AssetPathItem, ROOT_ITEM } from '@app/shared/state/assets.state'; import { AssetPathItem, ROOT_ITEM } from '@app/shared/state/assets.state';
import { AssetTextEditorComponent } from './asset-text-editor.component'; import { AssetTextEditorComponent } from './asset-text-editor.component';
import { ImageCropperComponent } from './image-cropper.component'; import { ImageCropperComponent } from './image-cropper.component';
import { ImageFocusPointComponent } from './image-focus-point.component'; import { ImageFocusPointComponent } from './image-focus-point.component';
@Component({ @Component({
selector: 'sqx-asset-dialog[allTags][asset]', selector: 'sqx-asset-dialog[asset]',
styleUrls: ['./asset-dialog.component.scss'], styleUrls: ['./asset-dialog.component.scss'],
templateUrl: './asset-dialog.component.html', templateUrl: './asset-dialog.component.html',
}) })
export class AssetDialogComponent implements OnChanges { export class AssetDialogComponent implements OnInit {
@Output() @Output()
public complete = new EventEmitter(); public complete = new EventEmitter();
@Output() @Output()
public changed = new EventEmitter<AssetDto>(); public assetReplaced = new EventEmitter<AssetDto>();
@Input() @Output()
public asset!: AssetDto; public assetUpdated = new EventEmitter<AssetDto>();
@Input() @Input()
public allTags!: ReadonlyArray<string>; public asset!: AssetDto;
@ViewChildren(ImageCropperComponent) @ViewChildren(ImageCropperComponent)
public imageCropper!: QueryList<ImageCropperComponent>; public imageCropper!: QueryList<ImageCropperComponent>;
@ -42,15 +42,21 @@ export class AssetDialogComponent implements OnChanges {
@ViewChildren(AssetTextEditorComponent) @ViewChildren(AssetTextEditorComponent)
public textEditor!: QueryList<AssetTextEditorComponent>; public textEditor!: QueryList<AssetTextEditorComponent>;
public path!: Observable<ReadonlyArray<AssetPathItem>>; public pathSource = new BehaviorSubject<string>('');
public pathItems!: Observable<ReadonlyArray<AssetPathItem>>;
public progress = 0;
public selectedTab = 0; public selectedTab = 0;
public isEditable = false; public isEditable = false;
public isEditableAny = false; public isEditableAny = false;
public isUploadable = false; public isUploadable = false;
public isMoving = false;
public isMoveable = false;
public progress = 0; public moveForm = new MoveAssetForm();
public annotateTags!: Observable<string[]>;
public annotateForm = new AnnotateAssetForm(); public annotateForm = new AnnotateAssetForm();
public get isImage() { public get isImage() {
@ -76,32 +82,52 @@ export class AssetDialogComponent implements OnChanges {
) { ) {
} }
public ngOnChanges() { public ngOnInit() {
this.annotateTags =
this.assetsService.getTags(this.appsState.appName).pipe(
map(tags => Object.keys(tags)));
this.pathItems =
this.pathSource.pipe(
switchMapCached(x => this.assetsService.getAssetFolders(this.appsState.appName, x, 'Path')), map(({ path }) => [ROOT_ITEM, ...path]));
this.selectTab(0); this.selectTab(0);
this.isEditable = this.asset.canUpdate; this.assetchanged(this.asset);
this.isUploadable = this.asset.canUpload; }
private assetchanged(asset: AssetDto) {
this.pathSource.next(asset.parentId);
this.isEditable = asset.canUpdate;
this.isUploadable = asset.canUpload;
this.isMoveable = asset.canMove;
this.annotateForm.load(this.asset); this.annotateForm.load(asset);
this.annotateForm.setEnabled(this.isEditable); this.annotateForm.setEnabled(this.isEditable);
this.path = this.moveForm.load(asset);
this.assetsService.getAssetFolders(this.appsState.appName, this.asset.parentId, 'Path').pipe( this.moveForm.setEnabled(this.isMoveable);
map(folders => [ROOT_ITEM, ...folders.path]));
}
public navigate(id: string) { this.asset = asset;
this.assetsState.navigate(id);
} }
public selectTab(tab: number) { public selectTab(tab: number) {
this.selectedTab = tab; this.selectedTab = tab;
} }
public navigate(id: string) {
this.assetsState.navigate(id);
}
public generateSlug() { public generateSlug() {
this.annotateForm.generateSlug(this.asset); this.annotateForm.generateSlug(this.asset);
} }
public startMoving() {
this.isMoving = true;
}
public emitComplete() { public emitComplete() {
this.complete.emit(); this.complete.emit();
} }
@ -133,7 +159,8 @@ export class AssetDialogComponent implements OnChanges {
if (Types.isNumber(dto)) { if (Types.isNumber(dto)) {
this.setProgress(dto); this.setProgress(dto);
} else { } else {
this.changed.emit(dto); this.assetReplaced.emit(dto);
this.assetchanged(dto);
this.dialogs.notifyInfo('i18n:assets.updated'); this.dialogs.notifyInfo('i18n:assets.updated');
} }
@ -144,7 +171,7 @@ export class AssetDialogComponent implements OnChanges {
} }
}, },
complete: () => { complete: () => {
this.setProgress(0); this.setProgress(0);
}, },
}); });
} else { } else {
@ -158,7 +185,7 @@ export class AssetDialogComponent implements OnChanges {
return; return;
} }
this.annoateAssetInternal(this.imageFocus.first.submit(this.asset)); this.annotateInternal(this.imageFocus.first.submit(this.asset));
} }
public annotateAsset() { public annotateAsset() {
@ -166,25 +193,59 @@ export class AssetDialogComponent implements OnChanges {
return; return;
} }
this.annoateAssetInternal(this.annotateForm.submit(this.asset)); this.annotateInternal(this.annotateForm.submit(this.asset));
} }
private annoateAssetInternal(value: AnnotateAssetDto | null) { public moveAsset() {
if (value) { if (!this.isMoveable) {
this.assetsState.updateAsset(this.asset, value) return;
.subscribe({ }
next: () => {
this.annotateForm.submitCompleted({ noReset: true });
this.dialogs.notifyInfo('i18n:assets.updated'); this.moveInternal(this.moveForm.submit());
}, }
error: error => {
this.annotateForm.submitFailed(error); private annotateInternal(value: AnnotateAssetDto | null) {
}, if (!value) {
});
} else {
this.dialogs.notifyInfo('i18n:common.nothingChanged'); this.dialogs.notifyInfo('i18n:common.nothingChanged');
return;
}
this.assetsState.updateAsset(this.asset, value)
.subscribe({
next: dto => {
this.assetUpdated.emit(dto);
this.assetchanged(dto);
this.annotateForm.submitCompleted({ noReset: true });
this.dialogs.notifyInfo('i18n:assets.updated');
},
error: error => {
this.annotateForm.submitFailed(error);
},
});
}
private moveInternal(values: MoveAssetItemDto | null) {
if (!values) {
this.isMoving = false;
return;
} }
this.assetsState.moveAsset(this.asset, values.parentId)
.subscribe({
next: (dto) => {
this.assetUpdated.emit(dto);
this.assetchanged(dto);
this.annotateForm.submitCompleted({ noReset: true });
this.dialogs.notifyInfo('i18n:assets.moved');
},
complete: () => {
this.isMoving = false;
},
});
} }
public setProgress(progress: number) { public setProgress(progress: number) {

19
frontend/src/app/shared/components/assets/asset-folder-dropdown-item.component.html

@ -1,26 +1,25 @@
<div class="control-dropdown-item d-flex align-items-center" [class.active]="node.isSelected" [style.paddingLeft]="paddingLeft" (click)="selectNode.emit(node)"> <div class="control-dropdown-item d-flex align-items-center" [class.active]="nodeModel.isSelected" [style.paddingLeft]="paddingLeft" (click)="selectNode.emit(nodeModel)">
<ng-container *ngIf="nodeModel.isLoading; else notLoading" class="loader">
<ng-container *ngIf="node.isLoading; else notLoading" class="loader">
<button type="button" class="btn btn-sm btn-decent btn-text-secondary"> <button type="button" class="btn btn-sm btn-decent btn-text-secondary">
<sqx-loader></sqx-loader>> <sqx-loader [size]="14"></sqx-loader>
</button> </button>
</ng-container> </ng-container>
<ng-template #notLoading> <ng-template #notLoading>
<button type="button" class="btn btn-sm btn-decent btn-text-secondary" (click)="toggle()" sqxStopClick [class.invisible]="node.isLoaded && node.children.length === 0"> <button type="button" class="btn btn-sm btn-decent btn-text-secondary" (click)="toggle()" sqxStopClick [class.invisible]="nodeModel.isLoaded && nodeModel.children.length === 0">
<i [class.icon-caret-right]="!node.isExpanded || !node.isLoaded" [class.icon-caret-down]="node.isExpanded && node.isLoaded"></i> <i [class.icon-caret-right]="!nodeModel.isExpanded || !nodeModel.isLoaded" [class.icon-caret-down]="nodeModel.isExpanded && nodeModel.isLoaded"></i>
</button> </button>
</ng-template> </ng-template>
<div class="name truncate"> <div class="name truncate">
{{node.item.folderName | sqxTranslate}} {{nodeModel.item.folderName | sqxTranslate}}
</div> </div>
</div> </div>
<div class="tree-children" *ngIf="node.isExpanded || node.isSelected"> <div class="tree-children" *ngIf="nodeModel.isExpanded || nodeModel.isSelected">
<sqx-asset-folder-dropdown-item *ngFor="let child of node.children; trackBy: trackByNode" <sqx-asset-folder-dropdown-item *ngFor="let child of nodeModel.children; trackBy: trackByNode"
[appName]="appName" [appName]="appName"
[node]="child" [nodeModel]="child"
[nodeLevel]="nodeLevel + 1" [nodeLevel]="nodeLevel + 1"
(selectNode)="selectNode.emit($event)"> (selectNode)="selectNode.emit($event)">
</sqx-asset-folder-dropdown-item> </sqx-asset-folder-dropdown-item>

4
frontend/src/app/shared/components/assets/asset-folder-dropdown-item.component.scss

@ -1,6 +1,10 @@
@import 'mixins'; @import 'mixins';
@import 'vars'; @import 'vars';
.name {
cursor: default;
}
.loader { .loader {
font-size: 60%; font-size: 60%;
flex-grow: 0; flex-grow: 0;

27
frontend/src/app/shared/components/assets/asset-folder-dropdown-item.component.ts

@ -5,12 +5,12 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core';
import { AssetsService } from '@app/shared/internal'; import { AssetsService } from '@app/shared/internal';
import { AssetFolderDropdowNode } from './asset-folder-dropdown.state'; import { AssetFolderDropdowNode } from './asset-folder-dropdown.state';
@Component({ @Component({
selector: 'sqx-asset-folder-dropdown-item[appName][node]', selector: 'sqx-asset-folder-dropdown-item[appName][nodeModel]',
styleUrls: ['./asset-folder-dropdown-item.component.scss'], styleUrls: ['./asset-folder-dropdown-item.component.scss'],
templateUrl: './asset-folder-dropdown-item.component.html', templateUrl: './asset-folder-dropdown-item.component.html',
}) })
@ -19,7 +19,7 @@ export class AssetFolderDropdownItemComponent {
public appName!: string; public appName!: string;
@Input() @Input()
public node!: AssetFolderDropdowNode; public nodeModel!: AssetFolderDropdowNode;
@Input() @Input()
public nodeLevel = 0; public nodeLevel = 0;
@ -33,11 +33,12 @@ export class AssetFolderDropdownItemComponent {
constructor( constructor(
private readonly assetsService: AssetsService, private readonly assetsService: AssetsService,
private readonly changeDetector: ChangeDetectorRef,
) { ) {
} }
public toggle() { public toggle() {
if (this.node.isExpanded && this.node.isLoaded) { if (this.nodeModel.isExpanded && this.nodeModel.isLoaded) {
this.collapse(); this.collapse();
} else { } else {
this.expand(); this.expand();
@ -45,27 +46,27 @@ export class AssetFolderDropdownItemComponent {
} }
public collapse() { public collapse() {
this.node.isExpanded = false; this.nodeModel.isExpanded = false;
} }
public expand() { public expand() {
this.node.isExpanded = true; this.nodeModel.isExpanded = true;
this.loadChildren(); this.loadChildren();
} }
public loadChildren() { public loadChildren() {
if (this.node.isLoading || this.node.isLoaded) { if (this.nodeModel.isLoading || this.nodeModel.isLoaded) {
return; return;
} }
this.node.isLoading = true; this.nodeModel.isLoading = true;
this.assetsService.getAssetFolders(this.appName, this.node.item.id, 'Items') this.assetsService.getAssetFolders(this.appName, this.nodeModel.item.id, 'Items')
.subscribe({ .subscribe({
next: dto => { next: dto => {
if (dto.items.length > 0) { if (dto.items.length > 0) {
const parent = this.node; const parent = this.nodeModel;
for (const item of dto.items) { for (const item of dto.items) {
if (!parent.children.find(x => x.item.id === item.id)) { if (!parent.children.find(x => x.item.id === item.id)) {
@ -76,11 +77,13 @@ export class AssetFolderDropdownItemComponent {
parent.children.sortByString(x => x.item.folderName); parent.children.sortByString(x => x.item.folderName);
} }
this.node.isLoaded = true; this.nodeModel.isLoaded = true;
this.changeDetector.detectChanges();
}, },
complete: () => { complete: () => {
setTimeout(() => { setTimeout(() => {
this.node.isLoading = false; this.nodeModel.isLoading = false;
this.changeDetector.detectChanges();
}, 250); }, 250);
}, },
}); });

2
frontend/src/app/shared/components/assets/asset-folder-dropdown.component.html

@ -14,7 +14,7 @@
<div class="control-dropdown" [sqxAnchoredTo]="input" [scrollY]="true" position="bottom-left"> <div class="control-dropdown" [sqxAnchoredTo]="input" [scrollY]="true" position="bottom-left">
<sqx-asset-folder-dropdown-item <sqx-asset-folder-dropdown-item
[appName]="appName" [appName]="appName"
[node]="root" [nodeModel]="root"
[nodeLevel]="0" [nodeLevel]="0"
(selectNode)="select($event)"> (selectNode)="select($event)">
</sqx-asset-folder-dropdown-item> </sqx-asset-folder-dropdown-item>

2
frontend/src/app/shared/components/assets/asset-folder-dropdown.component.ts

@ -113,6 +113,8 @@ export class AssetFolderDropdownComponent extends StatefulControlComponent<any,
this.dropdown.hide(); this.dropdown.hide();
} }
this.detectChanges();
} }
private resetSelected(node: AssetFolderDropdowNode) { private resetSelected(node: AssetFolderDropdowNode) {

6
frontend/src/app/shared/components/assets/asset-folder.component.html

@ -8,12 +8,12 @@
{{assetFolder.folderName | sqxTranslate}} {{assetFolder.folderName | sqxTranslate}}
</div> </div>
<div class="col-auto"> <div class="col-auto">
<ng-container *ngIf="canDelete || canUpdate"> <ng-container *ngIf="(canDelete || canUpdate) && !isDisabled">
<button type="button" class="btn btn-sm btn-text-secondary" (click)="dropdown.toggle()" #buttonOptions> <button type="button" class="btn btn-sm btn-text-secondary" (click)="editDropdown.toggle()" #buttonOptions>
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>
<ng-container *sqxModal="dropdown;closeAlways:true"> <ng-container *sqxModal="editDropdown;closeAlways:true">
<sqx-dropdown-menu [sqxAnchoredTo]="buttonOptions" [scrollY]="true"> <sqx-dropdown-menu [sqxAnchoredTo]="buttonOptions" [scrollY]="true">
<ng-container *ngIf="canUpdate"> <ng-container *ngIf="canUpdate">
<a class="dropdown-item" (click)="editDialog.show()"> <a class="dropdown-item" (click)="editDialog.show()">

14
frontend/src/app/shared/components/assets/asset-folder.component.ts

@ -22,10 +22,12 @@ export class AssetFolderComponent {
public delete = new EventEmitter<AssetFolderDto>(); public delete = new EventEmitter<AssetFolderDto>();
@Input() @Input()
public assetPathItem!: AssetPathItem; public isDisabled?: boolean | null;
public dropdown = new ModalModel(); @Input()
public assetPathItem!: AssetPathItem;
public editDropdown = new ModalModel();
public editDialog = new DialogModel(); public editDialog = new DialogModel();
public get assetFolder(): AssetFolderDto { public get assetFolder(): AssetFolderDto {
@ -47,10 +49,18 @@ export class AssetFolderComponent {
} }
public emitDelete() { public emitDelete() {
if (this.isDisabled) {
return;
}
this.delete.emit(this.assetFolder); this.delete.emit(this.assetFolder);
} }
public emitNavigate() { public emitNavigate() {
if (this.isDisabled) {
return;
}
this.navigate.emit(this.assetPathItem); this.navigate.emit(this.assetPathItem);
} }
} }

30
frontend/src/app/shared/components/assets/asset-selector.component.html

@ -1,4 +1,4 @@
<sqx-modal-dialog (close)="emitComplete()" size="lg" [fullHeight]="true" [hasTabs]="false"> <sqx-modal-dialog (close)="emitComplete()" size="lg" [fullHeight]="true" [flexBody]="true" [hasTabs]="false">
<ng-container title> <ng-container title>
{{ 'assets.selectMany' | sqxTranslate }} {{ 'assets.selectMany' | sqxTranslate }}
</ng-container> </ng-container>
@ -13,10 +13,11 @@
<div class="col"> <div class="col">
<div class="row g-0 search"> <div class="row g-0 search">
<div class="col-6"> <div class="col-6">
<sqx-tag-editor class="tags" [singleLine]="true" placeholder="{{ 'assets.searchByTags' | sqxTranslate }}" <sqx-tag-editor class="tags" placeholder="{{ 'assets.searchByTags' | sqxTranslate }}"
[itemsSource]="assetsState.tagsNames | async"
(ngModelChange)="selectTags($event)" [undefinedWhenEmpty]="false" (ngModelChange)="selectTags($event)" [undefinedWhenEmpty]="false"
[ngModel]="assetsState.selectedTagNames | async" [ngModel]="assetsState.selectedTagNames | async"
[suggestions]="assetsState.tagsNames | async"> [styleScrollable]="true">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
<div class="col-6"> <div class="col-6">
@ -43,14 +44,21 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<sqx-assets-list <sqx-list-view [isLoading]="assetsState.isLoading | async" [overflow]="true">
[assetsState]="assetsState" <ng-container content>
[indicateLoading]="true" <sqx-assets-list
[isDisabled]="true" [assetsState]="assetsState"
[isListView]="snapshot.isListView" [isDisabled]="true"
(select)="selectAsset($event)" [isListView]="snapshot.isListView"
[selectedIds]="snapshot.selectedAssets"> (select)="selectAsset($event)"
</sqx-assets-list> [selectedIds]="snapshot.selectedAssets">
</sqx-assets-list>
</ng-container>
<ng-container footer>
<sqx-pager (loadTotal)="reloadTotal()" [paging]="assetsState.paging | async" (pagingChange)="assetsState.page($event)"></sqx-pager>
</ng-container>
</sqx-list-view>
</ng-container> </ng-container>
<ng-container footer> <ng-container footer>

4
frontend/src/app/shared/components/assets/asset-selector.component.ts

@ -55,6 +55,10 @@ export class AssetSelectorComponent extends StatefulComponent<State> implements
this.assetsState.load(true); this.assetsState.load(true);
} }
public reloadTotal() {
this.assetsState.load(true, false);
}
public search(query: Query) { public search(query: Query) {
this.assetsState.search(query); this.assetsState.search(query);
} }

11
frontend/src/app/shared/components/assets/asset-uploader.component.ts

@ -6,7 +6,7 @@
*/ */
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { AppsState, AssetsState, AssetUploaderState, ModalModel, Upload } from '@app/shared/internal'; import { AppsState, AssetsState, AssetUploaderState, ModalModel, Types, Upload } from '@app/shared/internal';
@Component({ @Component({
selector: 'sqx-asset-uploader', selector: 'sqx-asset-uploader',
@ -26,7 +26,14 @@ export class AssetUploaderComponent {
public addFiles(files: ReadonlyArray<File>) { public addFiles(files: ReadonlyArray<File>) {
for (const file of files) { for (const file of files) {
this.assetUploader.uploadFile(file, this.assetsState); this.assetUploader.uploadFile(file)
.subscribe({
next: assetOrProgress => {
if (!Types.isNumber(assetOrProgress)) {
this.assetsState.addAsset(assetOrProgress);
}
},
});
} }
this.modalMenu.show(); this.modalMenu.show();

18
frontend/src/app/shared/components/assets/asset.component.html

@ -26,7 +26,7 @@
<div class="overlay-background"></div> <div class="overlay-background"></div>
<div class="overlay-menu"> <div class="overlay-menu">
<a class="file-edit ms-2" (click)="edit()" *ngIf="!isDisabled"> <a class="file-edit ms-2" (click)="emitEdit()" *ngIf="!isDisabled">
<i class="icon-pencil"></i> <i class="icon-pencil"></i>
</a> </a>
@ -81,10 +81,10 @@
<sqx-progress-bar mode="Circle" [value]="snapshot.progress"></sqx-progress-bar> <sqx-progress-bar mode="Circle" [value]="snapshot.progress"></sqx-progress-bar>
</div> </div>
</div> </div>
<div class="card-footer" (dblclick)="edit()"> <div class="card-footer" (dblclick)="emitEdit()">
<ng-container *ngIf="asset"> <ng-container *ngIf="asset">
<div> <div>
<div class="file-name truncate editable" (click)="edit()"> <div class="file-name truncate editable" (click)="emitEdit()">
<i class="icon-lock" *ngIf="asset?.isProtected"></i> <i class="icon-lock" *ngIf="asset?.isProtected"></i>
{{asset.fileName}} {{asset.fileName}}
@ -124,7 +124,7 @@
<table class="table-fixed"> <table class="table-fixed">
<tr> <tr>
<td class="col-name"> <td class="col-name">
<div class="file-name truncate editable" (click)="edit()"> <div class="file-name truncate editable" (click)="emitEdit()">
<i class="icon-lock" *ngIf="asset?.isProtected"></i> <i class="icon-lock" *ngIf="asset?.isProtected"></i>
{{asset.fileName}} {{asset.fileName}}
@ -184,12 +184,4 @@
</sqx-progress-bar> </sqx-progress-bar>
</div> </div>
</div> </div>
</ng-template> </ng-template>
<ng-container *ngIf="asset">
<ng-container *sqxModal="editDialog">
<sqx-asset-dialog [allTags]="allTags"
[asset]="asset" (changed)="setAsset($event)" (complete)="editDialog.hide()">
</sqx-asset-dialog>
</ng-container>
</ng-container>

40
frontend/src/app/shared/components/assets/asset.component.ts

@ -6,7 +6,7 @@
*/ */
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core';
import { AssetDto, AssetsState, AssetUploaderState, DialogModel, DialogService, StatefulComponent, Types, UploadCanceled } from '@app/shared/internal'; import { AssetDto, AssetUploaderState, DialogService, StatefulComponent, Types, UploadCanceled } from '@app/shared/internal';
interface State { interface State {
// The download progress. // The download progress.
@ -21,7 +21,7 @@ interface State {
}) })
export class AssetComponent extends StatefulComponent<State> implements OnInit { export class AssetComponent extends StatefulComponent<State> implements OnInit {
@Output() @Output()
public load = new EventEmitter<AssetDto>(); public loadDone = new EventEmitter<AssetDto>();
@Output() @Output()
public loadError = new EventEmitter(); public loadError = new EventEmitter();
@ -32,6 +32,9 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
@Output() @Output()
public delete = new EventEmitter(); public delete = new EventEmitter();
@Output()
public edit = new EventEmitter<AssetDto>();
@Output() @Output()
public select = new EventEmitter(); public select = new EventEmitter();
@ -44,9 +47,6 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
@Input() @Input()
public asset?: AssetDto; public asset?: AssetDto;
@Input()
public assetsState!: AssetsState;
@Input() @Input()
public folderId?: string; public folderId?: string;
@ -71,11 +71,6 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
@Input() @HostBinding('class.isListView') @Input() @HostBinding('class.isListView')
public isListView?: boolean | null; public isListView?: boolean | null;
@Input()
public allTags!: ReadonlyArray<string>;
public editDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef, constructor(changeDetector: ChangeDetectorRef,
private readonly assetUploader: AssetUploaderState, private readonly assetUploader: AssetUploaderState,
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
@ -91,13 +86,13 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
if (assetFile) { if (assetFile) {
this.setProgress(1); this.setProgress(1);
this.assetUploader.uploadFile(assetFile, this.assetsState, this.folderId) this.assetUploader.uploadFile(assetFile, this.folderId)
.subscribe({ .subscribe({
next: dto => { next: assetOrProgress => {
if (Types.isNumber(dto)) { if (Types.isNumber(assetOrProgress)) {
this.setProgress(dto); this.setProgress(assetOrProgress);
} else { } else {
this.emitLoad(dto); this.emitLoad(assetOrProgress);
} }
}, },
error: error => { error: error => {
@ -122,12 +117,11 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
this.assetUploader.uploadAsset(asset, files[0]) this.assetUploader.uploadAsset(asset, files[0])
.subscribe({ .subscribe({
next: asset => { next: assetOrProgress => {
if (Types.isNumber(asset)) { if (Types.isNumber(assetOrProgress)) {
this.setProgress(asset); this.setProgress(assetOrProgress);
} else { } else {
this.setProgress(0); this.emitLoad(assetOrProgress);
this.setAsset(asset);
} }
}, },
error: error => { error: error => {
@ -144,14 +138,14 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
} }
} }
public edit() { public emitEdit() {
if (!this.isDisabled) { if (!this.isDisabled) {
this.editDialog.show(); this.edit.emit(this.asset);
} }
} }
public emitLoad(asset: AssetDto) { public emitLoad(asset: AssetDto) {
this.load.emit(asset); this.loadDone.emit(asset);
} }
public emitLoadError(error: any) { public emitLoadError(error: any) {

140
frontend/src/app/shared/components/assets/assets-list.component.html

@ -14,81 +14,75 @@
<div class="file-drop-info">{{ 'assets.uploadHint' | sqxTranslate }}</div> <div class="file-drop-info">{{ 'assets.uploadHint' | sqxTranslate }}</div>
</div> </div>
<sqx-list-view [isLoading]="(assetsState.isLoading | async) && indicateLoading" [overflow]="true"> <div cdkDropListGroup *ngIf="assetsState.path | async; let path">
<ng-container topHeader> <div class="folders" *ngIf="path.length > 0">
<div cdkDropListGroup *ngIf="assetsState.path | async; let path"> <ng-container *ngIf="(assetsState.hasFolders | async) || (assetsState.parentFolder | async)">
<div class="folders" *ngIf="path.length > 0"> <h5>{{ 'common.folders' | sqxTranslate }}</h5>
<ng-container *ngIf="(assetsState.hasFolders | async) || (assetsState.parentFolder | async)"> </ng-container>
<h5>{{ 'common.folders' | sqxTranslate }}</h5>
</ng-container> <div class="row g-0">
<div class="folder-container" *ngIf="assetsState.parentFolder | async; let parent"
<div class="row g-0"> cdkDropList
<div class="folder-container" *ngIf="assetsState.parentFolder | async; let parent" [cdkDropListData]="parent?.id"
cdkDropList (cdkDropListDropped)="move($event)">
[cdkDropListData]="parent?.id" <div class="folder-container-over"></div>
(cdkDropListDropped)="move($event)">
<div class="folder-container-over"></div> <sqx-asset-folder [assetPathItem]="parent"
(navigate)="assetsState.navigate($event.id)">
</sqx-asset-folder>
</div>
<sqx-asset-folder [assetPathItem]="parent" <div class="folder-container" *ngFor="let assetFolder of assetsState.folders | async; trackBy: trackByAssetFolder"
(navigate)="assetsState.navigate($event.id)"> cdkDropList
</sqx-asset-folder> cdkDropListSortingDisabled
</div> [cdkDropListEnterPredicate]="canEnter"
[cdkDropListData]="assetFolder.id"
<div class="folder-container" *ngFor="let assetFolder of assetsState.folders | async; trackBy: trackByAssetFolder" (cdkDropListDropped)="move($event)">
cdkDropList <div class="folder-container-over"></div>
cdkDropListSortingDisabled
[cdkDropListEnterPredicate]="canEnter" <sqx-asset-folder [assetPathItem]="assetFolder"
[cdkDropListData]="assetFolder.id" cdkDrag
(cdkDropListDropped)="move($event)"> [cdkDragData]="assetFolder"
<div class="folder-container-over"></div> [cdkDragDisabled]="isDisabled || !assetFolder.canMove"
[isDisabled]="isDisabled"
<sqx-asset-folder [assetPathItem]="assetFolder" (navigate)="assetsState.navigate($event.id)" (delete)="deleteAssetFolder($event)">
cdkDrag </sqx-asset-folder>
[cdkDragData]="assetFolder"
[cdkDragDisabled]="isDisabled || !assetFolder.canMove"
(navigate)="assetsState.navigate($event.id)" (delete)="deleteAssetFolder($event)">
</sqx-asset-folder>
</div>
</div>
</div> </div>
</div>
</div>
<ng-container *ngIf="assetsState.assets | async; let assets">
<ng-container *ngIf="assets.length > 0 || snapshot.newFiles.length > 0">
<h5>{{ 'common.files' | sqxTranslate }}</h5>
</ng-container>
<ng-container *ngIf="assetsState.assets | async; let assets"> <div class="row g-0" [class.unrow]="isListView"
<ng-container *ngIf="assets.length > 0 || snapshot.newFiles.length > 0"> cdkDropList
<h5>{{ 'common.files' | sqxTranslate }}</h5> cdkDropListSortingDisabled>
</ng-container> <sqx-asset *ngFor="let file of snapshot.newFiles"
[assetFile]="file"
<div class="row g-0" [class.unrow]="isListView" *ngIf="assetsState.tagsNames | async; let tags" [isDisabled]="isDisabled"
cdkDropList [isListView]="isListView"
cdkDropListSortingDisabled> (loadDone)="add(file, $event)"
<sqx-asset *ngFor="let file of snapshot.newFiles" (loadError)="remove(file)">
[assetFile]="file" </sqx-asset>
[assetsState]="assetsState"
[isListView]="isListView" <sqx-asset *ngFor="let asset of assets; trackBy: trackByAsset"
(loadError)="remove(file)" cdkDrag
(load)="add(file, $event)"> [asset]="asset"
</sqx-asset> [cdkDragData]="asset"
[cdkDragDisabled]="isDisabled || !asset.canMove"
<sqx-asset *ngFor="let asset of assets; trackBy: trackByAsset" (delete)="deleteAsset(asset)"
cdkDrag (edit)="edit.emit($event)"
[cdkDragData]="asset" [folderIcon]="showFolderIcon && path.length === 0"
[cdkDragDisabled]="isDisabled || !asset.canMove" [isDisabled]="isDisabled"
[allTags]="tags" [isListView]="isListView"
[asset]="asset" [isSelectable]="!!selectedIds"
[assetsState]="assetsState" [isSelected]="isSelected(asset)"
[isListView]="isListView" (loadDone)="replaceAsset($event)"
[isDisabled]="isDisabled" (select)="select.emit(asset)"
[isSelectable]="!!selectedIds" (selectFolder)="selectFolder(asset)">
[isSelected]="isSelected(asset)" </sqx-asset>
[folderIcon]="showFolderIcon && path.length === 0"
(select)="select.emit(asset)" (delete)="deleteAsset(asset)"
(selectFolder)="selectFolder(asset)">
</sqx-asset>
</div>
</ng-container>
</div> </div>
</ng-container> </ng-container>
</sqx-list-view> </div>
<ng-container *ngIf="showPager">
<sqx-pager (loadTotal)="reloadTotal()" [autoHide]="true" [paging]="assetsState.paging | async" (pagingChange)="assetsState.page($event)"></sqx-pager>
</ng-container>

13
frontend/src/app/shared/components/assets/assets-list.component.ts

@ -21,6 +21,9 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AssetsListComponent extends StatefulComponent<State> { export class AssetsListComponent extends StatefulComponent<State> {
@Output()
public edit = new EventEmitter<AssetDto>();
@Output() @Output()
public select = new EventEmitter<AssetDto>(); public select = new EventEmitter<AssetDto>();
@ -33,18 +36,12 @@ export class AssetsListComponent extends StatefulComponent<State> {
@Input() @Input()
public isListView?: boolean | null; public isListView?: boolean | null;
@Input()
public indicateLoading?: boolean | null;
@Input() @Input()
public selectedIds?: {}; public selectedIds?: {};
@Input() @Input()
public showFolderIcon?: boolean | null = true; public showFolderIcon?: boolean | null = true;
@Input()
public showPager?: boolean | null = true;
constructor(changeDetector: ChangeDetectorRef) { constructor(changeDetector: ChangeDetectorRef) {
super(changeDetector, { super(changeDetector, {
newFiles: [], newFiles: [],
@ -75,8 +72,8 @@ export class AssetsListComponent extends StatefulComponent<State> {
} }
} }
public reloadTotal() { public replaceAsset(asset: AssetDto) {
this.assetsState.load(true, false); this.assetsState.replaceAsset(asset);
} }
public selectFolder(asset: AssetDto) { public selectFolder(asset: AssetDto) {

17
frontend/src/app/shared/components/contents/content-list-cell.directive.ts

@ -169,7 +169,7 @@ export class ContentListCellDirective extends ResourceOwner implements OnChanges
} }
@Directive({ @Directive({
selector: '[sqxContentListCellResize]', selector: '[sqxContentListCellResize][field][fields]',
}) })
export class ContentListCellResizeDirective implements OnInit, OnDestroy { export class ContentListCellResizeDirective implements OnInit, OnDestroy {
private mouseMove?: Function; private mouseMove?: Function;
@ -179,13 +179,10 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
private windowBlur?: Function; private windowBlur?: Function;
private startOffset = 0; private startOffset = 0;
private startWidth = 0; private startWidth = 0;
private fieldName?: string;
private resizer: any; private resizer: any;
@Input() @Input()
public set field(value: TableField) { public field!: TableField;
this.fieldName = value.rootField?.name;
}
@Input('fields') @Input('fields')
public tableFields?: TableSettings; public tableFields?: TableSettings;
@ -209,7 +206,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
} }
public ngOnInit() { public ngOnInit() {
if (!this.tableFields || !this.fieldName) { if (!this.tableFields || !this.field) {
return; return;
} }
@ -223,7 +220,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
} }
private onMouseDown = (event: MouseEvent) => { private onMouseDown = (event: MouseEvent) => {
if (!this.tableFields || !this.fieldName) { if (!this.tableFields || !this.field) {
return; return;
} }
@ -238,7 +235,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
}; };
private onMouseMove = (event: MouseEvent) => { private onMouseMove = (event: MouseEvent) => {
if (!this.mouseMove || !this.tableFields || !this.fieldName) { if (!this.mouseMove || !this.tableFields || !this.field) {
return; return;
} }
@ -250,7 +247,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
}; };
private onMouseUp = (event: MouseEvent) => { private onMouseUp = (event: MouseEvent) => {
if (!this.mouseMove || !this.tableFields || !this.fieldName) { if (!this.mouseMove || !this.tableFields || !this.field) {
return; return;
} }
@ -272,7 +269,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
width = this.minimumWidth; width = this.minimumWidth;
} }
this.tableFields!.updateSize(this.fieldName!, width, save); this.tableFields!.updateSize(this.field.name!, width, save);
} }
private resetMovement() { private resetMovement() {

2
frontend/src/app/shared/components/forms/markdown-editor.component.ts

@ -292,7 +292,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
} }
}; };
this.assetUploader.uploadFile(file, undefined, this.folderId) this.assetUploader.uploadFile(file, this.folderId)
.subscribe({ .subscribe({
next: asset => { next: asset => {
if (Types.is(asset, AssetDto)) { if (Types.is(asset, AssetDto)) {

4
frontend/src/app/shared/components/forms/rich-editor.component.ts

@ -119,7 +119,7 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
images_upload_handler: (blob: any, success: (url: string) => void, failure: (message: string) => void) => { images_upload_handler: (blob: any, success: (url: string) => void, failure: (message: string) => void) => {
const file = new File([blob.blob()], blob.filename(), { lastModified: new Date().getTime() }); const file = new File([blob.blob()], blob.filename(), { lastModified: new Date().getTime() });
self.assetUploader.uploadFile(file, undefined, this.folderId) self.assetUploader.uploadFile(file, this.folderId)
.subscribe({ .subscribe({
next: asset => { next: asset => {
if (Types.is(asset, AssetDto)) { if (Types.is(asset, AssetDto)) {
@ -292,7 +292,7 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
this.tinyEditor.setContent(content); this.tinyEditor.setContent(content);
}; };
this.assetUploader.uploadFile(file, undefined, this.folderId) this.assetUploader.uploadFile(file, this.folderId)
.subscribe({ .subscribe({
next: asset => { next: asset => {
if (Types.is(asset, AssetDto)) { if (Types.is(asset, AssetDto)) {

9
frontend/src/app/shared/interceptors/auth.interceptor.spec.ts

@ -12,8 +12,7 @@ import { HTTP_INTERCEPTORS, HttpClient, HttpHeaders } from '@angular/common/http
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { of } from 'rxjs'; import { of, onErrorResumeNextWith } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { ApiUrlConfig, AuthService } from '@app/shared/internal'; import { ApiUrlConfig, AuthService } from '@app/shared/internal';
import { AuthInterceptor } from './auth.interceptor'; import { AuthInterceptor } from './auth.interceptor';
@ -101,7 +100,7 @@ describe('AuthInterceptor', () => {
authService.setup(x => x.userChanges).returns(() => of(<any>{ authToken: 'letmein' })); authService.setup(x => x.userChanges).returns(() => of(<any>{ authToken: 'letmein' }));
authService.setup(x => x.loginSilent()).returns(() => of(<any>{ authToken: 'letmereallyin' })); authService.setup(x => x.loginSilent()).returns(() => of(<any>{ authToken: 'letmereallyin' }));
http.get('http://service/p/apps').pipe(onErrorResumeNext()).subscribe(); http.get('http://service/p/apps').pipe(onErrorResumeNextWith()).subscribe();
httpMock.expectOne('http://service/p/apps').error(<any>{}, { status: 401 }); httpMock.expectOne('http://service/p/apps').error(<any>{}, { status: 401 });
httpMock.expectOne('http://service/p/apps').error(<any>{}, { status: 401 }); httpMock.expectOne('http://service/p/apps').error(<any>{}, { status: 401 });
@ -118,7 +117,7 @@ describe('AuthInterceptor', () => {
inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => { inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => {
authService.setup(x => x.userChanges).returns(() => of(<any>{ authToken: 'letmein' })); authService.setup(x => x.userChanges).returns(() => of(<any>{ authToken: 'letmein' }));
http.get('http://service/p/apps').pipe(onErrorResumeNext()).subscribe(); http.get('http://service/p/apps').pipe(onErrorResumeNextWith()).subscribe();
const req = httpMock.expectOne('http://service/p/apps'); const req = httpMock.expectOne('http://service/p/apps');
@ -137,7 +136,7 @@ describe('AuthInterceptor', () => {
inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => { inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => {
authService.setup(x => x.userChanges).returns(() => of(<any>{ authToken: 'letmein' })); authService.setup(x => x.userChanges).returns(() => of(<any>{ authToken: 'letmein' }));
http.get('http://service/p/apps').pipe(onErrorResumeNext()).subscribe(); http.get('http://service/p/apps').pipe(onErrorResumeNextWith()).subscribe();
const req = httpMock.expectOne('http://service/p/apps'); const req = httpMock.expectOne('http://service/p/apps');

2
frontend/src/app/shared/module.ts

@ -13,7 +13,7 @@ import { MentionModule } from 'angular-mentions';
import { NgChartsModule } from 'ng2-charts'; import { NgChartsModule } from 'ng2-charts';
import { NgxDocViewerModule } from 'ngx-doc-viewer'; import { NgxDocViewerModule } from 'ngx-doc-viewer';
import { SqxFrameworkModule } from '@app/framework'; import { SqxFrameworkModule } from '@app/framework';
import { ApiCallsCardComponent, ApiCallsSummaryCardComponent, ApiPerformanceCardComponent, ApiTrafficCardComponent, ApiTrafficSummaryCardComponent, AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetsListComponent, AssetSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUploadsCountCardComponent, AssetUploadsSizeCardComponent, AssetUploadsSizeSummaryCardComponent, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListCellResizeDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthDirective, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, FilterOperatorPipe, GeolocationEditorComponent, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, IFrameCardComponent, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, LoadTeamsGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, RandomCatCardComponent, RandomDogCardComponent, ReferenceInputComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, SupportCardComponent, TableHeaderComponent, TeamFormComponent, TeamMustExistGuard, TeamsService, TeamsState, TemplatesService, TemplatesState, TranslationsService, TranslationStatusComponent, UIService, UIState, UnsetAppGuard, UnsetTeamGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WatchingUsersComponent, WorkflowsService, WorkflowsState, ScriptNamePipe } from './declarations'; import { ApiCallsCardComponent, ApiCallsSummaryCardComponent, ApiPerformanceCardComponent, ApiTrafficCardComponent, ApiTrafficSummaryCardComponent, AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetSelectorComponent, AssetsListComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUploadsCountCardComponent, AssetUploadsSizeCardComponent, AssetUploadsSizeSummaryCardComponent, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListCellResizeDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthDirective, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, FilterOperatorPipe, GeolocationEditorComponent, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, IFrameCardComponent, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, LoadTeamsGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, RandomCatCardComponent, RandomDogCardComponent, ReferenceInputComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, ScriptNamePipe, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, SupportCardComponent, TableHeaderComponent, TeamFormComponent, TeamMustExistGuard, TeamsService, TeamsState, TemplatesService, TemplatesState, TranslationsService, TranslationStatusComponent, UIService, UIState, UnsetAppGuard, UnsetTeamGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WatchingUsersComponent, WorkflowsService, WorkflowsState } from './declarations';
@NgModule({ @NgModule({
imports: [ imports: [

40
frontend/src/app/shared/services/assets.service.spec.ts

@ -358,24 +358,52 @@ describe('AssetsService', () => {
expect(asset!).toEqual(createAsset(123)); expect(asset!).toEqual(createAsset(123));
})); }));
it('should make delete request to move asset item', it('should make put request to move asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: Resource = {
_links: { _links: {
move: { method: 'DELETE', href: 'api/apps/my-app/assets/123/parent' }, move: { method: 'PUT', href: 'api/apps/my-app/assets/123/parent' },
}, },
}; };
const dto = { parentId: 'parent1' }; let asset: AssetDto;
assetsService.putAssetItemParent('my-app', resource, dto, version).subscribe(); assetsService.putAssetParent('my-app', resource, { parentId: 'parent1' }, version).subscribe(result => {
asset = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123/parent'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123/parent');
expect(req.request.method).toEqual('DELETE'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value); expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({}); req.flush(assetResponse(123));
expect(asset!).toEqual(createAsset(123));
}));
it('should make put request to move asset folder',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const resource: Resource = {
_links: {
move: { method: 'PUT', href: 'api/apps/my-app/assets/folders/123/parent' },
},
};
let assetFolder: AssetFolderDto;
assetsService.putAssetFolderParent('my-app', resource, { parentId: 'parent1' }, version).subscribe(result => {
assetFolder = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/folders/123/parent');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush(assetFolderResponse(123));
expect(assetFolder!).toEqual(createAssetFolder(123));
})); }));
it('should make delete request to delete asset item', it('should make delete request to delete asset item',

17
frontend/src/app/shared/services/assets.service.ts

@ -374,12 +374,27 @@ export class AssetsService {
pretifyError('i18n:assets.updateFolderFailed')); pretifyError('i18n:assets.updateFolderFailed'));
} }
public putAssetItemParent(appName: string, resource: Resource, dto: MoveAssetItemDto, version: Version): Observable<Versioned<any>> { public putAssetParent(appName: string, resource: Resource, dto: MoveAssetItemDto, version: Version): Observable<AssetDto> {
const link = resource._links['move']; const link = resource._links['move'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ payload }) => {
return parseAsset(payload.body);
}),
pretifyError('i18n:assets.moveFailed'));
}
public putAssetFolderParent(appName: string, resource: Resource, dto: MoveAssetItemDto, version: Version): Observable<AssetFolderDto> {
const link = resource._links['move'];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ payload }) => {
return parseAssetFolder(payload.body);
}),
pretifyError('i18n:assets.moveFailed')); pretifyError('i18n:assets.moveFailed'));
} }

5
frontend/src/app/shared/services/users-provider.service.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { AuthService, Profile, UserDto, UsersProviderService, UsersService } from '@app/shared/internal'; import { AuthService, Profile, UserDto, UsersProviderService, UsersService } from '@app/shared/internal';
@ -86,7 +85,7 @@ describe('UsersProviderService', () => {
let resultingUser: UserDto; let resultingUser: UserDto;
usersProviderService.getUser('123').pipe(onErrorResumeNext()).subscribe(result => { usersProviderService.getUser('123').pipe(onErrorResumeNextWith()).subscribe(result => {
resultingUser = result; resultingUser = result;
}).unsubscribe(); }).unsubscribe();

5
frontend/src/app/shared/state/asset-scripts.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, versioned } from '@app/shared/internal'; import { DialogService, versioned } from '@app/shared/internal';
import { AppsService, AssetScriptsPayload } from './../services/apps.service'; import { AppsService, AssetScriptsPayload } from './../services/apps.service';
@ -58,7 +57,7 @@ describe('AssetScriptsState', () => {
appsService.setup(x => x.getAssetScripts(app)) appsService.setup(x => x.getAssetScripts(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
assetScriptsState.load().pipe(onErrorResumeNext()).subscribe(); assetScriptsState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(assetScriptsState.snapshot.isLoading).toBeFalsy(); expect(assetScriptsState.snapshot.isLoading).toBeFalsy();
}); });

33
frontend/src/app/shared/state/asset-uploader.state.spec.ts

@ -5,10 +5,9 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { lastValueFrom, NEVER, of, throwError } from 'rxjs'; import { lastValueFrom, NEVER, Observable, of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, Mock } from 'typemoq'; import { IMock, Mock } from 'typemoq';
import { AssetsService, AssetsState, AssetUploaderState, DialogService, ofForever } from '@app/shared/internal'; import { AssetsService, AssetUploaderState, DialogService } from '@app/shared/internal';
import { createAsset } from './../services/assets.service.spec'; import { createAsset } from './../services/assets.service.spec';
import { TestValues } from './_test-helpers'; import { TestValues } from './_test-helpers';
@ -49,22 +48,6 @@ describe('AssetUploaderState', () => {
expect(upload.progress).toBe(1); expect(upload.progress).toBe(1);
}); });
it('should upload file with folder id from asset state', () => {
const assetsState = Mock.ofType<AssetsState>();
assetsState.setup(x => x.parentId)
.returns(() => 'parent1');
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.postAssetFile(app, file, 'parent1'))
.returns(() => NEVER).verifiable();
assetUploader.uploadFile(file, assetsState.object).subscribe();
expect().nothing();
});
it('should update progress if uploading file makes progress', () => { it('should update progress if uploading file makes progress', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
@ -85,7 +68,7 @@ describe('AssetUploaderState', () => {
assetsService.setup(x => x.postAssetFile(app, file, undefined)) assetsService.setup(x => x.postAssetFile(app, file, undefined))
.returns(() => throwError(() => 'Service Error')).verifiable(); .returns(() => throwError(() => 'Service Error')).verifiable();
assetUploader.uploadFile(file).pipe(onErrorResumeNext()).subscribe(); assetUploader.uploadFile(file).pipe(onErrorResumeNextWith()).subscribe();
const upload = assetUploader.snapshot.uploads[0]; const upload = assetUploader.snapshot.uploads[0];
@ -142,7 +125,7 @@ describe('AssetUploaderState', () => {
assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version)) assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version))
.returns(() => throwError(() => 'Service Error')).verifiable(); .returns(() => throwError(() => 'Service Error')).verifiable();
assetUploader.uploadAsset(asset, file).pipe(onErrorResumeNext()).subscribe(); assetUploader.uploadAsset(asset, file).pipe(onErrorResumeNextWith()).subscribe();
const upload = assetUploader.snapshot.uploads[0]; const upload = assetUploader.snapshot.uploads[0];
@ -167,3 +150,11 @@ describe('AssetUploaderState', () => {
expect(uploadedAsset!).toEqual(updated); expect(uploadedAsset!).toEqual(updated);
}); });
}); });
function ofForever<T>(...values: ReadonlyArray<T>) {
return new Observable<T>(s => {
for (const value of values) {
s.next(value);
}
});
}

32
frontend/src/app/shared/state/asset-uploader.state.ts

@ -6,11 +6,10 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { map, Observable, shareReplay, Subject, takeUntil } from 'rxjs'; import { Observable, shareReplay, Subject, takeUntil } from 'rxjs';
import { DialogService, MathHelper, State, Types } from '@app/framework'; import { DialogService, MathHelper, State, Types } from '@app/framework';
import { AssetDto, AssetsService } from './../services/assets.service'; import { AssetDto, AssetsService } from './../services/assets.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { AssetsState } from './assets.state';
export interface Upload { export interface Upload {
// Unique id. // Unique id.
@ -67,18 +66,10 @@ export class AssetUploaderState extends State<Snapshot> {
}, 'Stopped'); }, 'Stopped');
} }
public uploadFile(file: File, target?: AssetsState, parentId?: string): Observable<AssetDto | number> { public uploadFile(file: File, parentId?: string): Observable<AssetDto | number> {
const stream = this.assetsService.postAssetFile(this.appName, file, parentId ?? target?.parentId); const stream = this.assetsService.postAssetFile(this.appName, file, parentId);
return this.upload(stream, MathHelper.guid(), file.name, asset => { return this.upload(stream, MathHelper.guid(), file.name);
if (asset.isDuplicate) {
this.dialogs.notifyError('i18n:assets.duplicateFile');
} else if (target) {
target.addAsset(asset);
}
return asset;
});
} }
public uploadAsset(asset: AssetDto, file: Blob): Observable<AssetDto | number> { public uploadAsset(asset: AssetDto, file: Blob): Observable<AssetDto | number> {
@ -87,26 +78,19 @@ export class AssetUploaderState extends State<Snapshot> {
return this.upload(stream, asset.id, file['name'] || asset.fileName); return this.upload(stream, asset.id, file['name'] || asset.fileName);
} }
private upload(source: Observable<number | AssetDto>, id: string, name: string, complete?: ((completion: AssetDto) => AssetDto)) { private upload(source: Observable<number | AssetDto>, id: string, name: string) {
let upload = { id, name, progress: 1, status: 'Running', cancel: new Subject() }; let upload = { id, name, progress: 1, status: 'Running', cancel: new Subject() };
this.addUpload(upload); this.addUpload(upload);
const stream = source.pipe(takeUntil(upload.cancel), const stream = source.pipe(takeUntil(upload.cancel), shareReplay());
map(event => {
if (Types.isNumber(event)) {
return event;
} else if (complete) {
return complete(event);
} else {
return event;
}
}), shareReplay());
stream.subscribe({ stream.subscribe({
next: event => { next: event => {
if (Types.isNumber(event)) { if (Types.isNumber(event)) {
upload = this.update(upload, { progress: event }); upload = this.update(upload, { progress: event });
} else if (event.isDuplicate) {
this.dialogs.notifyError('i18n:assets.duplicateFile');
} }
}, },
error: () => { error: () => {

12
frontend/src/app/shared/state/assets.forms.ts

@ -8,7 +8,7 @@
import { UntypedFormControl, Validators } from '@angular/forms'; import { UntypedFormControl, Validators } from '@angular/forms';
import slugify from 'slugify'; import slugify from 'slugify';
import { ExtendedFormGroup, Form, Mutable, TemplatedFormArray, Types } from '@app/framework'; import { ExtendedFormGroup, Form, Mutable, TemplatedFormArray, Types } from '@app/framework';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, RenameAssetFolderDto, RenameAssetTagDto } from './../services/assets.service'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, MoveAssetItemDto, RenameAssetFolderDto, RenameAssetTagDto } from './../services/assets.service';
export class AnnotateAssetForm extends Form<ExtendedFormGroup, AnnotateAssetDto, AssetDto> { export class AnnotateAssetForm extends Form<ExtendedFormGroup, AnnotateAssetDto, AssetDto> {
public get metadata() { public get metadata() {
@ -219,3 +219,13 @@ export class RenameAssetTagForm extends Form<ExtendedFormGroup, RenameAssetTagDt
})); }));
} }
} }
export class MoveAssetForm extends Form<ExtendedFormGroup, AssetDto, MoveAssetItemDto> {
constructor() {
super(new ExtendedFormGroup({
parentId: new UntypedFormControl('',
Validators.required,
),
}));
}
}

97
frontend/src/app/shared/state/assets.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { ErrorDto } from '@app/framework'; import { ErrorDto } from '@app/framework';
import { AssetsService, AssetsState, DialogService, MathHelper, versioned } from '@app/shared/internal'; import { AssetsService, AssetsState, DialogService, MathHelper, versioned } from '@app/shared/internal';
@ -205,8 +204,8 @@ describe('AssetsState', () => {
assetsState.load(true).subscribe(); assetsState.load(true).subscribe();
}); });
it('should add asset to snapshot if created', () => { it('should add asset to snapshot', () => {
const newAsset = createAsset(5, ['new']); const newAsset = createAsset(3, ['new']);
assetsState.addAsset(newAsset); assetsState.addAsset(newAsset);
@ -214,8 +213,17 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.total).toBe(201); expect(assetsState.snapshot.total).toBe(201);
}); });
it('should not add asset to snapshot if it already exist', () => {
const newAsset = createAsset(1, ['new']);
assetsState.addAsset(newAsset);
expect(assetsState.snapshot.assets).toEqual([asset1, asset2]);
expect(assetsState.snapshot.total).toBe(200);
});
it('should truncate assets if page size reached', () => { it('should truncate assets if page size reached', () => {
const newAsset = createAsset(5, ['new']); const newAsset = createAsset(3, ['new']);
assetsState.page({ page: 0, pageSize: 2 }).subscribe(); assetsState.page({ page: 0, pageSize: 2 }).subscribe();
assetsState.addAsset(newAsset); assetsState.addAsset(newAsset);
@ -225,7 +233,7 @@ describe('AssetsState', () => {
}); });
it('should not add asset to snapshot if parent id is not the same', () => { it('should not add asset to snapshot if parent id is not the same', () => {
const newAsset = createAsset(5, ['new'], '_new', 'other-parent'); const newAsset = createAsset(3, ['new'], '_new', 'other-parent');
assetsState.addAsset(newAsset); assetsState.addAsset(newAsset);
@ -240,6 +248,24 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.tagsAvailable).toEqual({ tag1: 1, tag2: 1, shared: 2, new: 2 }); expect(assetsState.snapshot.tagsAvailable).toEqual({ tag1: 1, tag2: 1, shared: 2, new: 2 });
}); });
it('should replace asset in snapshot', () => {
const newAsset = createAsset(2, ['new']);
assetsState.replaceAsset(newAsset);
expect(assetsState.snapshot.assets).toEqual([asset1, newAsset]);
expect(assetsState.snapshot.total).toBe(200);
});
it('should not replace asset in snapshot if it does not exist', () => {
const newAsset = createAsset(3, ['new']);
assetsState.replaceAsset(newAsset);
expect(assetsState.snapshot.assets).toEqual([asset1, asset2]);
expect(assetsState.snapshot.total).toBe(200);
});
it('should add asset folder if created', () => { it('should add asset folder if created', () => {
const request = { folderName: 'New Folder', parentId: MathHelper.EMPTY_GUID }; const request = { folderName: 'New Folder', parentId: MathHelper.EMPTY_GUID };
@ -292,21 +318,37 @@ describe('AssetsState', () => {
}); });
it('should remove asset from snapshot if moved to other folder', () => { it('should remove asset from snapshot if moved to other folder', () => {
const updated = createAsset(1, ['new'], '_new');
const request = { parentId: 'newParent' }; const request = { parentId: 'newParent' };
assetsService.setup(x => x.putAssetItemParent(app, asset1, It.isValue(request), asset1.version)) assetsService.setup(x => x.putAssetParent(app, asset1, It.isValue(request), asset1.version))
.returns(() => of(versioned(newVersion))); .returns(() => of(updated));
assetsState.moveAsset(asset1, request.parentId).subscribe(); assetsState.moveAsset(asset1, request.parentId).subscribe();
expect(assetsState.snapshot.assets.length).toBe(1); expect(assetsState.snapshot.assets.length).toBe(1);
expect(assetsState.snapshot.total).toBe(200); expect(assetsState.snapshot.total).toBe(199);
});
it('should add asset to snapshot if moved to current folder', () => {
const asset3 = createAsset(3, undefined, undefined, 'oldParent');
const request = { parentId: assetsState.snapshot.parentId };
assetsService.setup(x => x.putAssetParent(app, asset3, It.isValue(request), asset3.version))
.returns(() => of(asset3));
assetsState.moveAsset(asset3, request.parentId).subscribe();
expect(assetsState.snapshot.assets).toEqual([asset3, asset1, asset2]);
expect(assetsState.snapshot.total).toBe(201);
}); });
it('should not do anything if moving asset to current parent', () => { it('should not do anything if moving asset to same parent', () => {
const request = { parentId: MathHelper.EMPTY_GUID }; const request = { parentId: MathHelper.EMPTY_GUID };
assetsState.moveAsset(asset1, request.parentId).pipe(onErrorResumeNext()).subscribe(); assetsState.moveAsset(asset1, request.parentId).pipe(onErrorResumeNextWith()).subscribe();
expect(assetsState.snapshot.assets.length).toBe(2); expect(assetsState.snapshot.assets.length).toBe(2);
expect(assetsState.snapshot.total).toBe(200); expect(assetsState.snapshot.total).toBe(200);
@ -315,30 +357,45 @@ describe('AssetsState', () => {
it('should move asset back to snapshot if moving via api failed', () => { it('should move asset back to snapshot if moving via api failed', () => {
const request = { parentId: 'newParent' }; const request = { parentId: 'newParent' };
assetsService.setup(x => x.putAssetItemParent(app, asset1, It.isValue(request), asset1.version)) assetsService.setup(x => x.putAssetParent(app, asset1, It.isValue(request), asset1.version))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
assetsState.moveAsset(asset1, request.parentId).pipe(onErrorResumeNext()).subscribe(); assetsState.moveAsset(asset1, request.parentId).pipe(onErrorResumeNextWith()).subscribe();
expect(assetsState.snapshot.assets.length).toBe(2); expect(assetsState.snapshot.assets.length).toBe(2);
expect(assetsState.snapshot.total).toBe(200); expect(assetsState.snapshot.total).toBe(200);
}); });
it('should remove asset folder from snapshot if moved to other folder', () => { it('should remove asset folder from snapshot if moved to other folder', () => {
const updated = createAssetFolder(1, '_new');
const request = { parentId: 'newParent' }; const request = { parentId: 'newParent' };
assetsService.setup(x => x.putAssetItemParent(app, assetFolder1, It.isValue(request), assetFolder1.version)) assetsService.setup(x => x.putAssetFolderParent(app, assetFolder1, It.isValue(request), assetFolder1.version))
.returns(() => of(versioned(newVersion))); .returns(() => of(updated));
assetsState.moveAssetFolder(assetFolder1, request.parentId).subscribe(); assetsState.moveAssetFolder(assetFolder1, request.parentId).subscribe();
expect(assetsState.snapshot.folders).toEqual([assetFolder2]); expect(assetsState.snapshot.folders).toEqual([assetFolder2]);
}); });
it('should add asset folder to snapshot if moved to current folder', () => {
const assetFolder3 = createAssetFolder(3, undefined, 'oldParent');
const request = { parentId: assetsState.snapshot.parentId };
assetsService.setup(x => x.putAssetFolderParent(app, assetFolder3, It.isValue(request), assetFolder3.version))
.returns(() => of(assetFolder3));
assetsState.moveAssetFolder(assetFolder3, request.parentId).subscribe();
expect(assetsState.snapshot.folders).toEqual([assetFolder1, assetFolder2, assetFolder3]);
});
it('should not do anything if moving asset folder to itself', () => { it('should not do anything if moving asset folder to itself', () => {
const request = { parentId: assetFolder1.id }; const request = { parentId: assetFolder1.id };
assetsState.moveAssetFolder(assetFolder1, request.parentId).pipe(onErrorResumeNext()).subscribe(); assetsState.moveAssetFolder(assetFolder1, request.parentId).pipe(onErrorResumeNextWith()).subscribe();
expect(assetsState.snapshot.assets.length).toBe(2); expect(assetsState.snapshot.assets.length).toBe(2);
}); });
@ -346,7 +403,7 @@ describe('AssetsState', () => {
it('should not do anything if moving asset folder to current parent', () => { it('should not do anything if moving asset folder to current parent', () => {
const request = { parentId: MathHelper.EMPTY_GUID }; const request = { parentId: MathHelper.EMPTY_GUID };
assetsState.moveAssetFolder(assetFolder1, request.parentId).pipe(onErrorResumeNext()).subscribe(); assetsState.moveAssetFolder(assetFolder1, request.parentId).pipe(onErrorResumeNextWith()).subscribe();
expect(assetsState.snapshot.assets.length).toBe(2); expect(assetsState.snapshot.assets.length).toBe(2);
}); });
@ -354,10 +411,10 @@ describe('AssetsState', () => {
it('should move asset folder back to snapshot if moving via api failed', () => { it('should move asset folder back to snapshot if moving via api failed', () => {
const request = { parentId: 'newParent' }; const request = { parentId: 'newParent' };
assetsService.setup(x => x.putAssetItemParent(app, assetFolder1, It.isValue(request), assetFolder1.version)) assetsService.setup(x => x.putAssetFolderParent(app, assetFolder1, It.isValue(request), assetFolder1.version))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
assetsState.moveAssetFolder(assetFolder1, request.parentId).pipe(onErrorResumeNext()).subscribe(); assetsState.moveAssetFolder(assetFolder1, request.parentId).pipe(onErrorResumeNextWith()).subscribe();
expect(assetsState.snapshot.assets.length).toBe(2); expect(assetsState.snapshot.assets.length).toBe(2);
}); });
@ -397,7 +454,7 @@ describe('AssetsState', () => {
dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString(), It.isAnyString())) dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString(), It.isAnyString()))
.returns(() => of(false)); .returns(() => of(false));
assetsState.deleteAsset(asset1).pipe(onErrorResumeNext()).subscribe(); assetsState.deleteAsset(asset1).pipe(onErrorResumeNextWith()).subscribe();
expect(assetsState.snapshot.assets.length).toBe(2); expect(assetsState.snapshot.assets.length).toBe(2);
}); });

108
frontend/src/app/shared/state/assets.state.ts

@ -200,17 +200,61 @@ export abstract class AssetsStateBase extends State<Snapshot> {
} }
public addAsset(asset: AssetDto) { public addAsset(asset: AssetDto) {
if (asset.parentId !== this.snapshot.parentId || this.snapshot.assets.find(x => x.id === asset.id)) { if (asset.parentId !== this.snapshot.parentId) {
return;
}
const existing = this.snapshot.assets.find(x => x.id === asset.id);
if (existing) {
return; return;
} }
this.next(s => { this.next(s => {
const assets = [asset, ...s.assets].slice(0, s.pageSize); const assets = [asset, ...s.assets].slice(0, s.pageSize);
const tags = updateTags(s, asset); const tags = updateTags(s, asset, existing);
return { ...s, assets, total: s.total + 1, ...tags }; return { ...s, assets, total: s.total + 1, ...tags };
}, 'Asset Created'); }, 'Asset Added');
}
public replaceAsset(asset: AssetDto) {
if (asset.parentId !== this.snapshot.parentId) {
return;
}
const existing = this.snapshot.assets.find(x => x.id === asset.id);
if (!existing) {
return;
}
this.next(s => {
const assets = s.assets.replacedBy('id', asset);
const tags = updateTags(s, asset, existing);
return { ...s, assets, ...tags };
}, 'Asset Replaced');
}
public addFolder(folder: AssetFolderDto) {
if (folder.parentId !== this.snapshot.parentId) {
return;
}
const existing = this.snapshot.folders.find(x => x.id === folder.id);
if (existing) {
return;
}
this.next(s => {
const folders = [folder, ...s.folders].sortByString(x => x.folderName);
return { ...s, folders };
}, 'Asset Folder Added');
} }
public createAssetFolder(folderName: string) { public createAssetFolder(folderName: string) {
@ -260,19 +304,29 @@ export abstract class AssetsStateBase extends State<Snapshot> {
return EMPTY; return EMPTY;
} }
this.next(s => { const moveIn = parentId === this.snapshot.parentId;
const assets = s.assets.filter(x => x.id !== asset.id);
return { ...s, assets };
}, 'Asset Moving Started');
return this.assetsService.putAssetItemParent(this.appName, asset, { parentId }, asset.version).pipe( function moveOutOrIn(state: AssetsState, asset: AssetDto, moveIn: boolean, name: string) {
catchError(error => { if (moveIn) {
this.next(s => { state.next(s => {
const assets = [asset, ...s.assets]; const assets = [asset, ...s.assets];
return { ...s, assets }; return { ...s, assets, total: s.total + 1 };
}, 'Asset Moving Failed'); }, name);
} else {
state.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id);
return { ...s, assets, total: s.total - 1 };
}, name);
}
}
moveOutOrIn(this, asset, moveIn, 'Asset Moving');
return this.assetsService.putAssetParent(this.appName, asset, { parentId }, asset.version).pipe(
catchError(error => {
moveOutOrIn(this, asset, !moveIn, 'Asset Moving reverted.');
return throwError(() => error); return throwError(() => error);
}), }),
@ -284,19 +338,29 @@ export abstract class AssetsStateBase extends State<Snapshot> {
return EMPTY; return EMPTY;
} }
this.next(s => { const moveIn = parentId === this.snapshot.parentId;
const folders = s.folders.filter(x => x.id !== folder.id);
return { ...s, folders }; function moveOutOrIn(state: AssetsState, folder: AssetFolderDto, moveIn: boolean, name: string) {
}, 'Folder Moving Started'); if (moveIn) {
state.next(s => {
const folders = [folder, ...s.folders].sortByString(x => x.folderName);
return this.assetsService.putAssetItemParent(this.appName, folder, { parentId }, folder.version).pipe( return { ...s, folders };
catchError(error => { }, name);
this.next(s => { } else {
const folders = [...s.folders, folder].sortByString(x => x.folderName); state.next(s => {
const folders = s.folders.filter(x => x.id !== folder.id);
return { ...s, folders }; return { ...s, folders };
}, 'Folder Moving Done'); }, name);
}
}
moveOutOrIn(this, folder, moveIn, 'Asset Folder Moving');
return this.assetsService.putAssetFolderParent(this.appName, folder, { parentId }, folder.version).pipe(
catchError(error => {
moveOutOrIn(this, folder, !moveIn, 'Asset Folder Moving reverted.');
return throwError(() => error); return throwError(() => error);
}), }),

9
frontend/src/app/shared/state/backups.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { BackupsService, BackupsState, DialogService } from '@app/shared/internal'; import { BackupsService, BackupsState, DialogService } from '@app/shared/internal';
import { createBackup } from './../services/backups.service.spec'; import { createBackup } from './../services/backups.service.spec';
@ -54,7 +53,7 @@ describe('BackupsState', () => {
backupsService.setup(x => x.getBackups(app)) backupsService.setup(x => x.getBackups(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
backupsState.load().pipe(onErrorResumeNext()).subscribe(); backupsState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(backupsState.snapshot.isLoading).toBeFalsy(); expect(backupsState.snapshot.isLoading).toBeFalsy();
}); });
@ -74,7 +73,7 @@ describe('BackupsState', () => {
backupsService.setup(x => x.getBackups(app)) backupsService.setup(x => x.getBackups(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
backupsState.load(true, false).pipe(onErrorResumeNext()).subscribe(); backupsState.load(true, false).pipe(onErrorResumeNextWith()).subscribe();
expect().nothing(); expect().nothing();
@ -85,7 +84,7 @@ describe('BackupsState', () => {
backupsService.setup(x => x.getBackups(app)) backupsService.setup(x => x.getBackups(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
backupsState.load(true, true).pipe(onErrorResumeNext()).subscribe(); backupsState.load(true, true).pipe(onErrorResumeNextWith()).subscribe();
expect().nothing(); expect().nothing();

5
frontend/src/app/shared/state/clients.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { ClientsPayload, ClientsService, ClientsState, DialogService, versioned } from '@app/shared/internal'; import { ClientsPayload, ClientsService, ClientsState, DialogService, versioned } from '@app/shared/internal';
import { createClients } from './../services/clients.service.spec'; import { createClients } from './../services/clients.service.spec';
@ -56,7 +55,7 @@ describe('ClientsState', () => {
clientsService.setup(x => x.getClients(app)) clientsService.setup(x => x.getClients(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
clientsState.load().pipe(onErrorResumeNext()).subscribe(); clientsState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(clientsState.snapshot.isLoading).toBeFalsy(); expect(clientsState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/contributors.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { EMPTY, of, throwError } from 'rxjs'; import { catchError, EMPTY, of, onErrorResumeNextWith, throwError } from 'rxjs';
import { catchError, onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { ErrorDto } from '@app/framework'; import { ErrorDto } from '@app/framework';
import { ContributorDto, ContributorsPayload, ContributorsService, ContributorsState, DialogService, versioned } from '@app/shared/internal'; import { ContributorDto, ContributorsPayload, ContributorsService, ContributorsState, DialogService, versioned } from '@app/shared/internal';
@ -65,7 +64,7 @@ describe('ContributorsState', () => {
contributorsService.setup(x => x.getContributors(app)) contributorsService.setup(x => x.getContributors(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
contributorsState.load().pipe(onErrorResumeNext()).subscribe(); contributorsState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(contributorsState.snapshot.isLoading).toBeFalsy(); expect(contributorsState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/languages.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { AppLanguagesPayload, AppLanguagesService, DialogService, LanguageDto, LanguagesService, LanguagesState, versioned } from '@app/shared/internal'; import { AppLanguagesPayload, AppLanguagesService, DialogService, LanguageDto, LanguagesService, LanguagesState, versioned } from '@app/shared/internal';
import { createLanguages } from './../services/app-languages.service.spec'; import { createLanguages } from './../services/app-languages.service.spec';
@ -79,7 +78,7 @@ describe('LanguagesState', () => {
languagesService.setup(x => x.getLanguages(app)) languagesService.setup(x => x.getLanguages(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
languagesState.load().pipe(onErrorResumeNext()).subscribe(); languagesState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(languagesState.snapshot.isLoading).toBeFalsy(); expect(languagesState.snapshot.isLoading).toBeFalsy();
}); });

9
frontend/src/app/shared/state/plans.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, PlanDto, PlanLockedReason, PlansService, PlansState, versioned } from '@app/shared/internal'; import { DialogService, PlanDto, PlanLockedReason, PlansService, PlansState, versioned } from '@app/shared/internal';
import { TestValues } from './_test-helpers'; import { TestValues } from './_test-helpers';
@ -83,7 +82,7 @@ describe('PlansState', () => {
plansService.setup(x => x.getPlans(app)) plansService.setup(x => x.getPlans(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
plansState.load().pipe(onErrorResumeNext()).subscribe(); plansState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(plansState.snapshot.isLoading).toBeFalsy(); expect(plansState.snapshot.isLoading).toBeFalsy();
}); });
@ -118,7 +117,7 @@ describe('PlansState', () => {
plansService.setup(x => x.putPlan(app, It.isAny(), version)) plansService.setup(x => x.putPlan(app, It.isAny(), version))
.returns(() => of(versioned(newVersion, result))); .returns(() => of(versioned(newVersion, result)));
plansState.change('free').pipe(onErrorResumeNext()).subscribe(); plansState.change('free').pipe(onErrorResumeNextWith()).subscribe();
expect(plansState.snapshot.plans).toEqual([ expect(plansState.snapshot.plans).toEqual([
{ isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] },
@ -132,7 +131,7 @@ describe('PlansState', () => {
plansService.setup(x => x.putPlan(app, It.isAny(), version)) plansService.setup(x => x.putPlan(app, It.isAny(), version))
.returns(() => of(versioned(newVersion, { redirectUri: '' }))); .returns(() => of(versioned(newVersion, { redirectUri: '' })));
plansState.change('id2_yearly').pipe(onErrorResumeNext()).subscribe(); plansState.change('id2_yearly').pipe(onErrorResumeNextWith()).subscribe();
expect(plansState.snapshot.plans).toEqual([ expect(plansState.snapshot.plans).toEqual([
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] },

5
frontend/src/app/shared/state/roles.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, RolesPayload, RolesService, RolesState, versioned } from '@app/shared/internal'; import { DialogService, RolesPayload, RolesService, RolesState, versioned } from '@app/shared/internal';
import { createRoles } from './../services/roles.service.spec'; import { createRoles } from './../services/roles.service.spec';
@ -52,7 +51,7 @@ describe('RolesState', () => {
rolesService.setup(x => x.getRoles(app)) rolesService.setup(x => x.getRoles(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
rolesState.load().pipe(onErrorResumeNext()).subscribe(); rolesState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(rolesState.snapshot.isLoading).toBeFalsy(); expect(rolesState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/rule-events.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, RuleEventsState, RulesService } from '@app/shared/internal'; import { DialogService, RuleEventsState, RulesService } from '@app/shared/internal';
import { createRuleEvent } from './../services/rules.service.spec'; import { createRuleEvent } from './../services/rules.service.spec';
@ -51,7 +50,7 @@ describe('RuleEventsState', () => {
rulesService.setup(x => x.getEvents(app, 30, 0, undefined)) rulesService.setup(x => x.getEvents(app, 30, 0, undefined))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
ruleEventsState.load().pipe(onErrorResumeNext()).subscribe(); ruleEventsState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(ruleEventsState.snapshot.isLoading).toBeFalsy(); expect(ruleEventsState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/rule-simulator.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, RulesService } from '@app/shared/internal'; import { DialogService, RulesService } from '@app/shared/internal';
import { createSimulatedRuleEvent } from './../services/rules.service.spec'; import { createSimulatedRuleEvent } from './../services/rules.service.spec';
@ -71,7 +70,7 @@ describe('RuleSimulatorState', () => {
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
ruleSimulatorState.selectRule('12'); ruleSimulatorState.selectRule('12');
ruleSimulatorState.load().pipe(onErrorResumeNext()).subscribe(); ruleSimulatorState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(ruleSimulatorState.snapshot.isLoading).toBeFalsy(); expect(ruleSimulatorState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/rules.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { firstValueFrom, of, throwError } from 'rxjs'; import { firstValueFrom, of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, RulesService, versioned } from '@app/shared/internal'; import { DialogService, RulesService, versioned } from '@app/shared/internal';
import { RuleDto } from './../services/rules.service'; import { RuleDto } from './../services/rules.service';
@ -68,7 +67,7 @@ describe('RulesState', () => {
rulesService.setup(x => x.getRules(app)) rulesService.setup(x => x.getRules(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
rulesState.load().pipe(onErrorResumeNext()).subscribe(); rulesState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(rulesState.snapshot.isLoading).toBeFalsy(); expect(rulesState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/schemas.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { firstValueFrom, of, throwError } from 'rxjs'; import { firstValueFrom, of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, SchemaDto, SchemasService, UpdateSchemaCategoryDto, versioned } from '@app/shared/internal'; import { DialogService, SchemaDto, SchemasService, UpdateSchemaCategoryDto, versioned } from '@app/shared/internal';
import { createSchema } from './../services/schemas.service.spec'; import { createSchema } from './../services/schemas.service.spec';
@ -79,7 +78,7 @@ describe('SchemasState', () => {
schemasService.setup(x => x.getSchemas(app)) schemasService.setup(x => x.getSchemas(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
schemasState.load().pipe(onErrorResumeNext()).subscribe(); schemasState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(schemasState.snapshot.isLoading).toBeFalsy(); expect(schemasState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/template.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, TemplatesService, TemplatesState } from '@app/shared/internal'; import { DialogService, TemplatesService, TemplatesState } from '@app/shared/internal';
import { createTemplate } from './../services/templates.service.spec'; import { createTemplate } from './../services/templates.service.spec';
@ -48,7 +47,7 @@ describe('TemplatesState', () => {
templatesService.setup(x => x.getTemplates()) templatesService.setup(x => x.getTemplates())
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
templatesState.load().pipe(onErrorResumeNext()).subscribe(); templatesState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(templatesState.snapshot.isLoading).toBeFalsy(); expect(templatesState.snapshot.isLoading).toBeFalsy();
}); });

5
frontend/src/app/shared/state/workflows.state.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, versioned, WorkflowsPayload, WorkflowsService, WorkflowsState } from '@app/shared/internal'; import { DialogService, versioned, WorkflowsPayload, WorkflowsService, WorkflowsState } from '@app/shared/internal';
import { createWorkflows } from './../services/workflows.service.spec'; import { createWorkflows } from './../services/workflows.service.spec';
@ -56,7 +55,7 @@ describe('WorkflowsState', () => {
workflowsService.setup(x => x.getWorkflows(app)) workflowsService.setup(x => x.getWorkflows(app))
.returns(() => throwError(() => 'Service Error')); .returns(() => throwError(() => 'Service Error'));
workflowsState.load().pipe(onErrorResumeNext()).subscribe(); workflowsState.load().pipe(onErrorResumeNextWith()).subscribe();
expect(workflowsState.snapshot.isLoading).toBeFalsy(); expect(workflowsState.snapshot.isLoading).toBeFalsy();
}); });

6
frontend/src/app/shell/pages/internal/notification-dropdown.component.ts

@ -6,8 +6,8 @@
*/ */
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { timer } from 'rxjs'; import { onErrorResumeNextWith, timer } from 'rxjs';
import { onErrorResumeNext, switchMap, tap } from 'rxjs/operators'; import { switchMap, tap } from 'rxjs/operators';
import { AuthService, CommentDto, CommentsService, CommentsState, DialogService, LocalStoreService, ModalModel, ResourceOwner, Settings } from '@app/shared'; import { AuthService, CommentDto, CommentsService, CommentsState, DialogService, LocalStoreService, ModalModel, ResourceOwner, Settings } from '@app/shared';
@Component({ @Component({
@ -69,7 +69,7 @@ export class NotificationDropdownComponent extends ResourceOwner implements OnIn
this.changeDetector.detectChanges(); this.changeDetector.detectChanges();
}))); })));
this.own(timer(0, 4000).pipe(switchMap(() => this.commentsState.load(true).pipe(onErrorResumeNext())))); this.own(timer(0, 4000).pipe(switchMap(() => this.commentsState.load(true).pipe(onErrorResumeNextWith()))));
} }
public trackByComment(_index: number, comment: CommentDto) { public trackByComment(_index: number, comment: CommentDto) {

3
frontend/src/app/shell/pages/internal/search-menu.component.html

@ -4,13 +4,12 @@
[dropdownStyles]="{ width: '30rem' }" [dropdownStyles]="{ width: '30rem' }"
[dropdownPosition]="'bottom-right'" [dropdownPosition]="'bottom-right'"
icon="search" icon="search"
inputName="searchMenu"
(ngModelChange)="selectResult($event)" (ngModelChange)="selectResult($event)"
[ngModel]="searchResult" [ngModel]="searchResult"
placeholder="{{ 'search.quickNavPlaceholder' | sqxTranslate }}" placeholder="{{ 'search.quickNavPlaceholder' | sqxTranslate }}"
shortcut="q" shortcut="q"
shortcutAction="none" shortcutAction="none"
[source]="searchSource"> [itemsSource]="searchSource">
<ng-template let-result="$implicit"> <ng-template let-result="$implicit">
<div class="row g-0"> <div class="row g-0">
<div class="col-auto pe-4"> <div class="col-auto pe-4">

Loading…
Cancel
Save