Browse Source

Feature/refs view (#608)

* Views for references.

* Cleanup contents state.

* Temorary

* Paging simplified.

* Cleanup and test improvements.

* Minor fixes after testing.
pull/611/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
b02588aeb6
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      backend/i18n/frontend_en.json
  2. 5
      backend/i18n/frontend_it.json
  3. 5
      backend/i18n/frontend_nl.json
  4. 9
      backend/i18n/source/frontend_en.json
  5. 25
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  7. 14
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs
  8. 37
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs
  9. 11
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs
  10. 37
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs
  11. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs
  12. 64
      backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs
  13. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs
  14. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs
  15. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  17. 19
      backend/src/Squidex.Domain.Apps.Entities/Contents/Operations/ContentOperationContext.cs
  18. 22
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  19. 16
      backend/src/Squidex.Domain.Apps.Entities/Contents/ValidationResult.cs
  20. 19
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs
  21. 2
      backend/src/Squidex.Shared/Permissions.cs
  22. 2
      backend/src/Squidex.Web/Resources.cs
  23. 13
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  24. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs
  25. 12
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs
  26. 12
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  27. 29
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ValidationResultDto.cs
  28. 355
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs
  29. 7
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs
  30. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDomainObjectTests.cs
  31. 2
      frontend/app-config/webpack.config.js
  32. 15
      frontend/app/app.module.ts
  33. 2
      frontend/app/app.routes.ts
  34. 2
      frontend/app/features/administration/pages/users/users-page.component.html
  35. 2
      frontend/app/features/administration/pages/users/users-page.component.ts
  36. 2
      frontend/app/features/administration/services/event-consumers.service.ts
  37. 2
      frontend/app/features/administration/services/users.service.ts
  38. 6
      frontend/app/features/administration/state/users.forms.ts
  39. 28
      frontend/app/features/administration/state/users.state.spec.ts
  40. 61
      frontend/app/features/administration/state/users.state.ts
  41. 2
      frontend/app/features/assets/pages/assets-filters-page.component.html
  42. 7
      frontend/app/features/assets/pages/assets-page.component.html
  43. 8
      frontend/app/features/content/declarations.ts
  44. 5
      frontend/app/features/content/module.ts
  45. 102
      frontend/app/features/content/pages/content/content-page.component.html
  46. 8
      frontend/app/features/content/pages/content/content-page.component.scss
  47. 48
      frontend/app/features/content/pages/content/content-page.component.ts
  48. 24
      frontend/app/features/content/pages/content/editor/content-editor.component.html
  49. 0
      frontend/app/features/content/pages/content/editor/content-editor.component.scss
  50. 47
      frontend/app/features/content/pages/content/editor/content-editor.component.ts
  51. 0
      frontend/app/features/content/pages/content/editor/content-field.component.html
  52. 0
      frontend/app/features/content/pages/content/editor/content-field.component.scss
  53. 4
      frontend/app/features/content/pages/content/editor/content-field.component.ts
  54. 6
      frontend/app/features/content/pages/content/editor/content-section.component.html
  55. 0
      frontend/app/features/content/pages/content/editor/content-section.component.scss
  56. 35
      frontend/app/features/content/pages/content/editor/content-section.component.ts
  57. 0
      frontend/app/features/content/pages/content/editor/field-languages.component.html
  58. 0
      frontend/app/features/content/pages/content/editor/field-languages.component.scss
  59. 0
      frontend/app/features/content/pages/content/editor/field-languages.component.ts
  60. 20
      frontend/app/features/content/pages/content/references/content-references.component.html
  61. 10
      frontend/app/features/content/pages/content/references/content-references.component.scss
  62. 57
      frontend/app/features/content/pages/content/references/content-references.component.ts
  63. 6
      frontend/app/features/content/pages/contents/contents-filters-page.component.html
  64. 8
      frontend/app/features/content/pages/contents/contents-page.component.html
  65. 2
      frontend/app/features/content/pages/sidebar/sidebar-page.component.ts
  66. 6
      frontend/app/features/content/shared/forms/array-item.component.html
  67. 23
      frontend/app/features/content/shared/forms/array-item.component.ts
  68. 18
      frontend/app/features/content/shared/forms/assets-editor.component.ts
  69. 14
      frontend/app/features/content/shared/forms/stock-photo-editor.component.ts
  70. 18
      frontend/app/features/content/shared/list/content-list-cell.directive.ts
  71. 2
      frontend/app/features/content/shared/list/content-list-field.component.html
  72. 19
      frontend/app/features/content/shared/list/content-list-field.component.ts
  73. 2
      frontend/app/features/content/shared/list/content-value-editor.component.ts
  74. 2
      frontend/app/features/content/shared/references/content-creator.component.html
  75. 6
      frontend/app/features/content/shared/references/content-selector.component.html
  76. 7
      frontend/app/features/content/shared/references/reference-item.component.html
  77. 5
      frontend/app/features/content/shared/references/reference-item.component.scss
  78. 17
      frontend/app/features/content/shared/references/reference-item.component.ts
  79. 2
      frontend/app/features/content/shared/references/references-editor.component.html
  80. 15
      frontend/app/features/content/shared/references/references-editor.component.ts
  81. 2
      frontend/app/features/dashboard/pages/cards/content-summary-card.component.html
  82. 25
      frontend/app/features/dashboard/pages/cards/content-summary-card.component.ts
  83. 2
      frontend/app/features/rules/pages/events/rule-events-page.component.html
  84. 7
      frontend/app/features/settings/pages/contributors/contributors-page.component.html
  85. 20
      frontend/app/framework/angular/forms/control-errors.component.ts
  86. 6
      frontend/app/framework/angular/forms/editors/autocomplete.component.ts
  87. 4
      frontend/app/framework/angular/forms/editors/checkbox-group.component.ts
  88. 9
      frontend/app/framework/angular/forms/editors/code-editor.component.ts
  89. 8
      frontend/app/framework/angular/forms/editors/color-picker.component.ts
  90. 4
      frontend/app/framework/angular/forms/editors/date-time-editor.component.html
  91. 25
      frontend/app/framework/angular/forms/editors/date-time-editor.component.ts
  92. 6
      frontend/app/framework/angular/forms/editors/dropdown.component.ts
  93. 9
      frontend/app/framework/angular/forms/editors/iframe-editor.component.ts
  94. 6
      frontend/app/framework/angular/forms/editors/localized-input.component.html
  95. 29
      frontend/app/framework/angular/forms/editors/localized-input.component.ts
  96. 12
      frontend/app/framework/angular/forms/editors/stars.component.ts
  97. 18
      frontend/app/framework/angular/forms/editors/tag-editor.component.ts
  98. 4
      frontend/app/framework/angular/forms/editors/toggle.component.ts
  99. 2
      frontend/app/framework/angular/forms/progress-bar.component.ts
  100. 2
      frontend/app/framework/angular/forms/validators.ts

7
backend/i18n/frontend_en.json

@ -353,6 +353,9 @@
"contents.changeStatusToImmediately": "Set to {action} immediately.", "contents.changeStatusToImmediately": "Set to {action} immediately.",
"contents.changeStatusToLater": "Set to {action} at a later point date and time.", "contents.changeStatusToLater": "Set to {action} at a later point date and time.",
"contents.contentNotValid": "Content element not valid, please check the field with the red bar on the left in all languages (if localizable).", "contents.contentNotValid": "Content element not valid, please check the field with the red bar on the left in all languages (if localizable).",
"contents.contentTab.editor": "Editor",
"contents.contentTab.references": "References",
"contents.contentTab.referencing": "Referencing",
"contents.create": "New", "contents.create": "New",
"contents.createContentTooltip": "New Content (CTRL + SHIFT + G)", "contents.createContentTooltip": "New Content (CTRL + SHIFT + G)",
"contents.created": "Content created successfully.", "contents.created": "Content created successfully.",
@ -366,7 +369,7 @@
"contents.deleteConfirmTitle": "Delete content", "contents.deleteConfirmTitle": "Delete content",
"contents.deleteFailed": "Failed to delete content. Please reload.", "contents.deleteFailed": "Failed to delete content. Please reload.",
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?", "contents.deleteManyConfirmText": "Do you really want to delete the selected content items?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?", "contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete this content?",
"contents.deleteReferrerConfirmTitle": "Delete content", "contents.deleteReferrerConfirmTitle": "Delete content",
"contents.deleteVersionConfirmText": "Do you really want to delete this version?", "contents.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Failed to delete version. Please reload.", "contents.deleteVersionFailed": "Failed to delete version. Please reload.",
@ -391,6 +394,7 @@
"contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will lose them.\n\n**Do you want to continue anyway?**", "contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will lose them.\n\n**Do you want to continue anyway?**",
"contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will lose them.\n\n**Do you want to continue anyway?**", "contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will lose them.\n\n**Do you want to continue anyway?**",
"contents.pendingChangesTitle": "Unsaved changes", "contents.pendingChangesTitle": "Unsaved changes",
"contents.publishAll": "Publish All",
"contents.referencesCreateNew": "Add New", "contents.referencesCreateNew": "Add New",
"contents.referencesCreatePublish": "Create and Publish", "contents.referencesCreatePublish": "Create and Publish",
"contents.referencesLink": "Link selected contents ({count})", "contents.referencesLink": "Link selected contents ({count})",
@ -428,6 +432,7 @@
"contents.unsavedChangesTitle": "Unsaved changes", "contents.unsavedChangesTitle": "Unsaved changes",
"contents.updated": "Content updated successfully.", "contents.updated": "Content updated successfully.",
"contents.updateFailed": "Failed to update content. Please reload.", "contents.updateFailed": "Failed to update content. Please reload.",
"contents.validate": "Validate",
"contents.validationHint": "Please remember to check all languages when you see validation errors.", "contents.validationHint": "Please remember to check all languages when you see validation errors.",
"contents.versionCompare": "Compare", "contents.versionCompare": "Compare",
"contents.versionDelete": "Delete this Version", "contents.versionDelete": "Delete this Version",

5
backend/i18n/frontend_it.json

@ -353,6 +353,9 @@
"contents.changeStatusToImmediately": "Imposta {action} immediatamente.", "contents.changeStatusToImmediately": "Imposta {action} immediatamente.",
"contents.changeStatusToLater": "Imposta {action} ad una data e ora successiva.", "contents.changeStatusToLater": "Imposta {action} ad una data e ora successiva.",
"contents.contentNotValid": "Un elemento del contenuto non è valido, verifica il campo con la barra rossa per tutte le lingue impostate (se presenti).", "contents.contentNotValid": "Un elemento del contenuto non è valido, verifica il campo con la barra rossa per tutte le lingue impostate (se presenti).",
"contents.contentTab.editor": "Editor",
"contents.contentTab.references": "References",
"contents.contentTab.referencing": "Referencing",
"contents.create": "Nuovo", "contents.create": "Nuovo",
"contents.createContentTooltip": "Nuovo contenuto (CTRL + SHIFT + G)", "contents.createContentTooltip": "Nuovo contenuto (CTRL + SHIFT + G)",
"contents.created": "Contenuto creato con successo.", "contents.created": "Contenuto creato con successo.",
@ -391,6 +394,7 @@
"contents.pendingChangesTextToChange": "Non hai salvato le modifiche.\n\nSe cambi lo stato perderai le modifiche.\n\n**Sei sicuro di voler continuare?**", "contents.pendingChangesTextToChange": "Non hai salvato le modifiche.\n\nSe cambi lo stato perderai le modifiche.\n\n**Sei sicuro di voler continuare?**",
"contents.pendingChangesTextToClose": "Non hai salvato le modifiche.\n\nChiudendo il contenuto corrente perderai tutte le modifiche.\n\n**Sei sicuro di voler continuare?**", "contents.pendingChangesTextToClose": "Non hai salvato le modifiche.\n\nChiudendo il contenuto corrente perderai tutte le modifiche.\n\n**Sei sicuro di voler continuare?**",
"contents.pendingChangesTitle": "Modifiche non salvate", "contents.pendingChangesTitle": "Modifiche non salvate",
"contents.publishAll": "Publish All",
"contents.referencesCreateNew": "Aggiungi nuovo", "contents.referencesCreateNew": "Aggiungi nuovo",
"contents.referencesCreatePublish": "Crea e pubblica", "contents.referencesCreatePublish": "Crea e pubblica",
"contents.referencesLink": "Collega i contenuti selezionati ({count})", "contents.referencesLink": "Collega i contenuti selezionati ({count})",
@ -428,6 +432,7 @@
"contents.unsavedChangesTitle": "Modifiche non salvate", "contents.unsavedChangesTitle": "Modifiche non salvate",
"contents.updated": "Contenuto aggiornato con successo.", "contents.updated": "Contenuto aggiornato con successo.",
"contents.updateFailed": "Non è stato possibile aggiornare il contenuto. Per favore ricarica.", "contents.updateFailed": "Non è stato possibile aggiornare il contenuto. Per favore ricarica.",
"contents.validate": "Validate",
"contents.validationHint": "Ricorda di verificare tutte le lingue quando vedi errori di validazione.", "contents.validationHint": "Ricorda di verificare tutte le lingue quando vedi errori di validazione.",
"contents.versionCompare": "Confronta", "contents.versionCompare": "Confronta",
"contents.versionDelete": "Cancella questa Versione", "contents.versionDelete": "Cancella questa Versione",

5
backend/i18n/frontend_nl.json

@ -353,6 +353,9 @@
"contents.changeStatusToImmediately": "Zet onmiddellijk op {action}.", "contents.changeStatusToImmediately": "Zet onmiddellijk op {action}.",
"contents.changeStatusToLater": "Zet op {action} op een latere datum en tijd.", "contents.changeStatusToLater": "Zet op {action} op een latere datum en tijd.",
"contents.contentNotValid": "Inhoudselement niet geldig, controleer het veld met de rode balk aan de linkerkant in alle talen (indien lokaliseerbaar).", "contents.contentNotValid": "Inhoudselement niet geldig, controleer het veld met de rode balk aan de linkerkant in alle talen (indien lokaliseerbaar).",
"contents.contentTab.editor": "Editor",
"contents.contentTab.references": "References",
"contents.contentTab.referencing": "Referencing",
"contents.create": "Nieuw", "contents.create": "Nieuw",
"contents.createContentTooltip": "Nieuwe inhoud (CTRL + SHIFT + G)", "contents.createContentTooltip": "Nieuwe inhoud (CTRL + SHIFT + G)",
"contents.created": "Inhoud succesvol aangemaakt.", "contents.created": "Inhoud succesvol aangemaakt.",
@ -391,6 +394,7 @@
"contents.pendingChangesTextToChange": "Je hebt niet-opgeslagen wijzigingen. \n \n Wanneer je de status wijzigt, raak je ze kwijt. \n \n **Wil je toch doorgaan?**", "contents.pendingChangesTextToChange": "Je hebt niet-opgeslagen wijzigingen. \n \n Wanneer je de status wijzigt, raak je ze kwijt. \n \n **Wil je toch doorgaan?**",
"contents.pendingChangesTextToClose": "Je hebt niet-opgeslagen wijzigingen. \n \n Wanneer je de huidige inhoudsweergave sluit, raak je ze kwijt. \n n **Wil je toch doorgaan?**", "contents.pendingChangesTextToClose": "Je hebt niet-opgeslagen wijzigingen. \n \n Wanneer je de huidige inhoudsweergave sluit, raak je ze kwijt. \n n **Wil je toch doorgaan?**",
"contents.pendingChangesTitle": "Niet-opgeslagen wijzigingen", "contents.pendingChangesTitle": "Niet-opgeslagen wijzigingen",
"contents.publishAll": "Publish All",
"contents.referencesCreateNew": "Nieuwe toevoegen", "contents.referencesCreateNew": "Nieuwe toevoegen",
"contents.referencesCreatePublish": "Maken en publiceren", "contents.referencesCreatePublish": "Maken en publiceren",
"contents.referencesLink": "Link geselecteerde inhoud ({count})", "contents.referencesLink": "Link geselecteerde inhoud ({count})",
@ -428,6 +432,7 @@
"contents.unsavedChangesTitle": "Niet-opgeslagen wijzigingen", "contents.unsavedChangesTitle": "Niet-opgeslagen wijzigingen",
"contents.updated": "Inhoud succesvol bijgewerkt.", "contents.updated": "Inhoud succesvol bijgewerkt.",
"contents.updateFailed": "Bijwerken van inhoud is mislukt. Laad opnieuw.", "contents.updateFailed": "Bijwerken van inhoud is mislukt. Laad opnieuw.",
"contents.validate": "Validate",
"contents.validationHint": "Denk eraan om alle talen te controleren wanneer je validatiefouten ziet.", "contents.validationHint": "Denk eraan om alle talen te controleren wanneer je validatiefouten ziet.",
"contents.versionCompare": "Vergelijk", "contents.versionCompare": "Vergelijk",
"contents.versionDelete": "Verwijder deze versie", "contents.versionDelete": "Verwijder deze versie",

9
backend/i18n/source/frontend_en.json

@ -353,6 +353,9 @@
"contents.changeStatusToImmediately": "Set to {action} immediately.", "contents.changeStatusToImmediately": "Set to {action} immediately.",
"contents.changeStatusToLater": "Set to {action} at a later point date and time.", "contents.changeStatusToLater": "Set to {action} at a later point date and time.",
"contents.contentNotValid": "Content element not valid, please check the field with the red bar on the left in all languages (if localizable).", "contents.contentNotValid": "Content element not valid, please check the field with the red bar on the left in all languages (if localizable).",
"contents.contentTab.editor": "Editor",
"contents.contentTab.references": "References",
"contents.contentTab.referencing": "Referencing",
"contents.create": "New", "contents.create": "New",
"contents.createContentTooltip": "New Content (CTRL + SHIFT + G)", "contents.createContentTooltip": "New Content (CTRL + SHIFT + G)",
"contents.created": "Content created successfully.", "contents.created": "Content created successfully.",
@ -368,8 +371,6 @@
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?", "contents.deleteManyConfirmText": "Do you really want to delete the selected content items?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete this content?", "contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete this content?",
"contents.deleteReferrerConfirmTitle": "Delete content", "contents.deleteReferrerConfirmTitle": "Delete content",
"contents.unpublishReferrerConfirmText": "The content is referenced by another published content item.\n\nDo you really want to unpublish this content?",
"contents.unpublishReferrerConfirmTitle": "Unpublish content",
"contents.deleteVersionConfirmText": "Do you really want to delete this version?", "contents.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Failed to delete version. Please reload.", "contents.deleteVersionFailed": "Failed to delete version. Please reload.",
"contents.draftNew": "New Draft", "contents.draftNew": "New Draft",
@ -393,6 +394,7 @@
"contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will lose them.\n\n**Do you want to continue anyway?**", "contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will lose them.\n\n**Do you want to continue anyway?**",
"contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will lose them.\n\n**Do you want to continue anyway?**", "contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will lose them.\n\n**Do you want to continue anyway?**",
"contents.pendingChangesTitle": "Unsaved changes", "contents.pendingChangesTitle": "Unsaved changes",
"contents.publishAll": "Publish All",
"contents.referencesCreateNew": "Add New", "contents.referencesCreateNew": "Add New",
"contents.referencesCreatePublish": "Create and Publish", "contents.referencesCreatePublish": "Create and Publish",
"contents.referencesLink": "Link selected contents ({count})", "contents.referencesLink": "Link selected contents ({count})",
@ -424,10 +426,13 @@
"contents.tableHeaders.nextStatus": "Next Status", "contents.tableHeaders.nextStatus": "Next Status",
"contents.tableHeaders.status": "Status", "contents.tableHeaders.status": "Status",
"contents.tableHeaders.version": "Version", "contents.tableHeaders.version": "Version",
"contents.unpublishReferrerConfirmText": "The content is referenced by another published content item.\n\nDo you really want to unpublish this content?",
"contents.unpublishReferrerConfirmTitle": "Unpublish content",
"contents.unsavedChangesText": "You have unsaved changes. Do you want to load them now?", "contents.unsavedChangesText": "You have unsaved changes. Do you want to load them now?",
"contents.unsavedChangesTitle": "Unsaved changes", "contents.unsavedChangesTitle": "Unsaved changes",
"contents.updated": "Content updated successfully.", "contents.updated": "Content updated successfully.",
"contents.updateFailed": "Failed to update content. Please reload.", "contents.updateFailed": "Failed to update content. Please reload.",
"contents.validate": "Validate",
"contents.validationHint": "Please remember to check all languages when you see validation errors.", "contents.validationHint": "Please remember to check all languages when you see validation errors.",
"contents.versionCompare": "Compare", "contents.versionCompare": "Compare",
"contents.versionDelete": "Delete this Version", "contents.versionDelete": "Delete this Version",

25
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -89,13 +89,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
if (q.Ids != null && q.Ids.Count > 0) if (q.Ids != null && q.Ids.Count > 0)
{ {
var filter = BuildFilter(appId, q.Ids.ToHashSet());
var assetEntities = var assetEntities =
await Collection.Find(BuildFilter(appId, q.Ids.ToHashSet())).SortByDescending(x => x.LastModified) await Collection.Find(filter).SortByDescending(x => x.LastModified)
.QueryLimit(q.Query) .QueryLimit(q.Query)
.QuerySkip(q.Query) .QuerySkip(q.Query)
.ToListAsync(); .ToListAsync();
long assetTotal = assetEntities.Count;
if (assetTotal >= q.Query.Take || q.Query.Skip > 0)
{
assetTotal = await Collection.Find(filter).CountDocumentsAsync();
}
return ResultList.Create(assetEntities.Count, assetEntities.OfType<IAssetEntity>()); return ResultList.Create(assetTotal, assetEntities.OfType<IAssetEntity>());
} }
else else
{ {
@ -103,17 +111,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
var filter = query.BuildFilter(appId, parentId); var filter = query.BuildFilter(appId, parentId);
var assetCount = Collection.Find(filter).CountDocumentsAsync(); var assetEntities =
var assetItems = await Collection.Find(filter)
Collection.Find(filter)
.QueryLimit(query) .QueryLimit(query)
.QuerySkip(query) .QuerySkip(query)
.QuerySort(query) .QuerySort(query)
.ToListAsync(); .ToListAsync();
long assetTotal = assetEntities.Count;
var (items, total) = await AsyncHelper.WhenAll(assetItems, assetCount); if (assetTotal >= q.Query.Take || q.Query.Skip > 0)
{
assetTotal = await Collection.Find(filter).CountDocumentsAsync();
}
return ResultList.Create<IAssetEntity>(total, items); return ResultList.Create<IAssetEntity>(assetTotal, assetEntities);
} }
} }
catch (MongoQueryException ex) when (ex.Message.Contains("17406")) catch (MongoQueryException ex) when (ex.Message.Contains("17406"))

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
using (Profiler.TraceMethod<MongoContentRepository>()) using (Profiler.TraceMethod<MongoContentRepository>())
{ {
if (q.Ids != null && q.Ids.Count > 0l) if (q.Ids != null && q.Ids.Count > 0)
{ {
return await queryByIds.QueryAsync(app.Id, new List<ISchemaEntity> { schema }, q); return await queryByIds.QueryAsync(app.Id, new List<ISchemaEntity> { schema }, q);
} }

14
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs

@ -50,13 +50,19 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
var filter = CreateFilter(appId, schemas.Select(x => x.Id), q.Ids.ToHashSet()); var filter = CreateFilter(appId, schemas.Select(x => x.Id), q.Ids.ToHashSet());
var items = await FindContentsAsync(q.Query, filter); var contentEntities = await FindContentsAsync(q.Query, filter);
var contentTotal = (long)contentEntities.Count;
if (items.Count > 0) if (contentEntities.Count > 0)
{ {
if (contentTotal >= q.Query.Take || q.Query.Skip > 0)
{
contentTotal = await Collection.Find(filter).CountDocumentsAsync();
}
var contentSchemas = schemas.ToDictionary(x => x.Id); var contentSchemas = schemas.ToDictionary(x => x.Id);
foreach (var content in items) foreach (var content in contentEntities)
{ {
var schema = contentSchemas[content.SchemaId.Id]; var schema = contentSchemas[content.SchemaId.Id];
@ -64,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
} }
} }
return ResultList.Create(items.Count, items); return ResultList.Create(contentTotal, contentEntities);
} }
private async Task<List<MongoContentEntity>> FindContentsAsync(ClrQuery query, FilterDefinition<MongoContentEntity> filter) private async Task<List<MongoContentEntity>> FindContentsAsync(ClrQuery query, FilterDefinition<MongoContentEntity> filter)

37
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs

@ -116,22 +116,25 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), fullTextIds, query, q.Reference); var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), fullTextIds, query, q.Reference);
var contentCount = Collection.Find(filter).CountDocumentsAsync(); var contentEntities = await FindContentsAsync(query, filter);
var contentItems = FindContentsAsync(query, filter); var contentTotal = (long)contentEntities.Count;
var (items, total) = await AsyncHelper.WhenAll(contentItems, contentCount); if (contentEntities.Count > 0)
if (items.Count > 0)
{ {
if (contentTotal >= q.Query.Take || q.Query.Skip > 0)
{
contentTotal = await Collection.Find(filter).CountDocumentsAsync();
}
var contentSchemas = schemas.ToDictionary(x => x.Id); var contentSchemas = schemas.ToDictionary(x => x.Id);
foreach (var entity in items) foreach (var entity in contentEntities)
{ {
entity.ParseData(contentSchemas[entity.IndexedSchemaId].SchemaDef, DataConverter); entity.ParseData(contentSchemas[entity.IndexedSchemaId].SchemaDef, DataConverter);
} }
} }
return ResultList.Create<IContentEntity>(total, items); return ResultList.Create<IContentEntity>(contentTotal, contentEntities);
} }
catch (MongoCommandException ex) when (ex.Code == 96) catch (MongoCommandException ex) when (ex.Code == 96)
{ {
@ -169,17 +172,23 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
var filter = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), fullTextIds, query, q.Reference); var filter = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), fullTextIds, query, q.Reference);
var contentCount = Collection.Find(filter).CountDocumentsAsync(); var contentEntities = await FindContentsAsync(query, filter);
var contentItems = FindContentsAsync(query, filter); var contentTotal = (long)contentEntities.Count;
var (items, total) = await AsyncHelper.WhenAll(contentItems, contentCount);
foreach (var entity in items) if (contentEntities.Count > 0)
{ {
entity.ParseData(schema.SchemaDef, DataConverter); if (contentTotal >= q.Query.Take || q.Query.Skip > 0)
{
contentTotal = await Collection.Find(filter).CountDocumentsAsync();
}
foreach (var entity in contentEntities)
{
entity.ParseData(schema.SchemaDef, DataConverter);
}
} }
return ResultList.Create<IContentEntity>(total, items); return ResultList.Create<IContentEntity>(contentTotal, contentEntities);
} }
catch (MongoCommandException ex) when (ex.Code == 96) catch (MongoCommandException ex) when (ex.Code == 96)
{ {

11
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs

@ -73,12 +73,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
filter = Filter.And(filter, Filter.Eq(x => x.RuleId, ruleId.Value)); filter = Filter.And(filter, Filter.Eq(x => x.RuleId, ruleId.Value));
} }
var taskForItems = Collection.Find(filter).Skip(skip).Limit(take).SortByDescending(x => x.Created).ToListAsync(); var ruleEventEntities = await Collection.Find(filter).Skip(skip).Limit(take).SortByDescending(x => x.Created).ToListAsync();
var taskForCount = Collection.Find(filter).CountDocumentsAsync(); var ruleEventTotal = (long)ruleEventEntities.Count;
var (items, total) = await AsyncHelper.WhenAll(taskForItems, taskForCount); if (ruleEventTotal >= take || skip > 0)
{
ruleEventTotal = await Collection.Find(filter).CountDocumentsAsync();
}
return ResultList.Create(total, items); return ResultList.Create(ruleEventTotal, ruleEventEntities);
} }
public async Task<IRuleEventEntity> FindAsync(DomainId id) public async Task<IRuleEventEntity> FindAsync(DomainId id)

37
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs

@ -72,32 +72,41 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
await GuardAsset.CanCreate(c, assetQuery); await GuardAsset.CanCreate(c, assetQuery);
c.Tags = await NormalizeTagsAsync(c.AppId.Id, c.Tags); if (c.Tags != null)
{
c.Tags = await NormalizeTagsAsync(c.AppId.Id, c.Tags);
}
Create(c); Create(c);
return Snapshot; return Snapshot;
}); });
case UpdateAsset updateAsset:
return UpdateReturn(updateAsset, c =>
{
GuardAsset.CanUpdate(c);
Update(c);
return Snapshot;
});
case AnnotateAsset annotateAsset: case AnnotateAsset annotateAsset:
return UpdateReturnAsync(annotateAsset, async c => return UpdateReturnAsync(annotateAsset, async c =>
{ {
GuardAsset.CanAnnotate(c); GuardAsset.CanAnnotate(c);
c.Tags = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags); if (c.Tags != null)
{
c.Tags = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
}
Annotate(c); Annotate(c);
return Snapshot; return Snapshot;
}); });
case UpdateAsset updateAsset:
return UpdateReturn(updateAsset, c =>
{
GuardAsset.CanUpdate(c);
Update(c);
return Snapshot;
});
case MoveAsset moveAsset: case MoveAsset moveAsset:
return UpdateReturnAsync(moveAsset, async c => return UpdateReturnAsync(moveAsset, async c =>
{ {
@ -107,6 +116,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return Snapshot; return Snapshot;
}); });
case DeleteAsset deleteAsset: case DeleteAsset deleteAsset:
return UpdateAsync(deleteAsset, async c => return UpdateAsync(deleteAsset, async c =>
{ {
@ -121,13 +131,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
} }
} }
private async Task<HashSet<string>?> NormalizeTagsAsync(DomainId appId, HashSet<string> tags) private async Task<HashSet<string>> NormalizeTagsAsync(DomainId appId, HashSet<string> tags)
{ {
if (tags == null)
{
return null;
}
var normalized = await assetTags.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags); var normalized = await assetTags.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags);
return new HashSet<string>(normalized.Values); return new HashSet<string>(normalized.Values);

4
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs

@ -64,6 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return Snapshot; return Snapshot;
}); });
case MoveAssetFolder moveAssetFolder: case MoveAssetFolder moveAssetFolder:
return UpdateReturnAsync(moveAssetFolder, async c => return UpdateReturnAsync(moveAssetFolder, async c =>
{ {
@ -73,6 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return Snapshot; return Snapshot;
}); });
case RenameAssetFolder renameAssetFolder: case RenameAssetFolder renameAssetFolder:
return UpdateReturn(renameAssetFolder, c => return UpdateReturn(renameAssetFolder, c =>
{ {
@ -82,6 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return Snapshot; return Snapshot;
}); });
case DeleteAssetFolder deleteAssetFolder: case DeleteAssetFolder deleteAssetFolder:
return Update(deleteAssetFolder, c => return Update(deleteAssetFolder, c =>
{ {
@ -89,6 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Delete(c); Delete(c);
}); });
default: default:
throw new NotSupportedException(); throw new NotSupportedException();
} }

64
backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs

@ -10,10 +10,13 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow; using System.Threading.Tasks.Dataflow;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Shared;
#pragma warning disable CA1826 // Do not use Enumerable methods on indexable collections #pragma warning disable CA1826 // Do not use Enumerable methods on indexable collections
@ -42,6 +45,29 @@ namespace Squidex.Domain.Apps.Entities.Contents
var requestContext = contextProvider.Context.WithoutContentEnrichment().WithUnpublished(true); var requestContext = contextProvider.Context.WithoutContentEnrichment().WithUnpublished(true);
var requestedSchema = bulkUpdates.SchemaId.Name; var requestedSchema = bulkUpdates.SchemaId.Name;
async Task PublishAsync<TCommand>(BulkUpdateJob job, TCommand command, string permissionId) where TCommand : ContentCommand
{
SimpleMapper.Map(bulkUpdates, command);
if (!string.IsNullOrWhiteSpace(job.Schema))
{
var schema = await contentQuery.GetSchemaOrThrowAsync(requestContext, job.Schema);
command.SchemaId = schema.NamedId();
}
var permission = Permissions.ForApp(permissionId, command.AppId.Name, command.SchemaId.Name);
if (!requestContext.Permissions.Allows(permission))
{
throw new DomainForbiddenException("Forbidden");
}
command.ExpectedVersion = job.ExpectedVersion;
await context.CommandBus.PublishAsync(command);
}
var results = new BulkUpdateResultItem[bulkUpdates.Jobs.Length]; var results = new BulkUpdateResultItem[bulkUpdates.Jobs.Length];
var actionBlock = new ActionBlock<int>(async index => var actionBlock = new ActionBlock<int>(async index =>
@ -54,13 +80,18 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var id = await FindIdAsync(requestContext, requestedSchema, job); var id = await FindIdAsync(requestContext, requestedSchema, job);
if (job.Type != BulkUpdateType.Upsert && (id == null || id == DomainId.Empty))
{
throw new DomainObjectNotFoundException("undefined");
}
result.ContentId = id; result.ContentId = id;
switch (job.Type) switch (job.Type)
{ {
case BulkUpdateType.Upsert: case BulkUpdateType.Upsert:
{ {
var command = SimpleMapper.Map(bulkUpdates, new UpsertContent { Data = job.Data }); var command = new UpsertContent { Data = job.Data! };
if (id != null && id != DomainId.Empty) if (id != null && id != DomainId.Empty)
{ {
@ -69,38 +100,31 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.ContentId = command.ContentId; result.ContentId = command.ContentId;
await context.CommandBus.PublishAsync(command); await PublishAsync(job, command, Permissions.AppContentsUpsert);
break; break;
} }
case BulkUpdateType.ChangeStatus: case BulkUpdateType.Validate:
{ {
if (id == null || id == DomainId.Empty) var command = new ValidateContent { ContentId = id.Value };
{
throw new DomainObjectNotFoundException("undefined");
}
var command = SimpleMapper.Map(bulkUpdates, new ChangeContentStatus { ContentId = id.Value }); await PublishAsync(job, command, Permissions.AppContentsRead);
break;
}
if (job.Status != null) case BulkUpdateType.ChangeStatus:
{ {
command.Status = job.Status.Value; var command = new ChangeContentStatus { ContentId = id.Value, Status = job.Status };
}
await context.CommandBus.PublishAsync(command); await PublishAsync(job, command, Permissions.AppContentsUpdate);
break; break;
} }
case BulkUpdateType.Delete: case BulkUpdateType.Delete:
{ {
if (id == null || id == DomainId.Empty) var command = new DeleteContent { ContentId = id.Value };
{
throw new DomainObjectNotFoundException("undefined");
}
var command = SimpleMapper.Map(bulkUpdates, new DeleteContent { ContentId = id.Value });
await context.CommandBus.PublishAsync(command); await PublishAsync(job, command, Permissions.AppContentsDelete);
break; break;
} }
} }

10
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs

@ -18,10 +18,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
public DomainId? Id { get; set; } public DomainId? Id { get; set; }
public NamedContentData Data { get; set; } public Status Status { get; set; }
public Status? Status { get; set; }
public BulkUpdateType Type { get; set; } public BulkUpdateType Type { get; set; }
public NamedContentData? Data { get; set; }
public string? Schema { get; set; }
public long ExpectedVersion { get; set; } = EtagVersion.Any;
} }
} }

3
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs

@ -11,6 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
{ {
Upsert, Upsert,
ChangeStatus, ChangeStatus,
Delete Delete,
Validate
} }
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs

@ -132,11 +132,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
await LoadContext(c); await LoadContext(c);
var errors = await context.GetErrorsAsync(Snapshot.Data); await context.ValidateContentAndInputAsync(Snapshot.Data);
var result = new ValidationResult { Errors = errors.ToArray() }; return true;
return result;
}); });
case CreateContentDraft createContentDraft: case CreateContentDraft createContentDraft:

2
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs

@ -21,5 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
Task<IEnrichedContentEntity> FindAsync(Context context, string schemaIdOrName, DomainId id, long version = EtagVersion.Any); Task<IEnrichedContentEntity> FindAsync(Context context, string schemaIdOrName, DomainId id, long version = EtagVersion.Any);
Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName); Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName);
Task<ISchemaEntity?> GetSchemaAsync(Context context, string schemaIdOrName);
} }
} }

19
backend/src/Squidex.Domain.Apps.Entities/Contents/Operations/ContentOperationContext.cs

@ -146,33 +146,26 @@ namespace Squidex.Domain.Apps.Entities.Contents.Operations
CheckErrors(validator); CheckErrors(validator);
} }
public async Task<IEnumerable<ValidationError>> GetErrorsAsync(NamedContentData data) public async Task ValidateContentAndInputAsync(NamedContentData data)
{ {
var validator = var validator =
new ContentValidator(Partition(), new ContentValidator(Partition(),
validationContext, validators, log); validationContext.AsPublishing(), validators, log);
await validator.ValidateInputAsync(data); await validator.ValidateInputAsync(data);
await validator.ValidateContentAsync(data); await validator.ValidateContentAsync(data);
return validator.Errors; CheckErrors(validator);
} }
public async Task ValidateOnPublishAsync(NamedContentData data) public Task ValidateOnPublishAsync(NamedContentData data)
{ {
if (!schema.SchemaDef.Properties.ValidateOnPublish) if (!schema.SchemaDef.Properties.ValidateOnPublish)
{ {
return; return Task.CompletedTask;
} }
var validator = return ValidateContentAndInputAsync(data);
new ContentValidator(Partition(),
validationContext.AsPublishing(), validators, log);
await validator.ValidateInputAsync(data);
await validator.ValidateContentAsync(data);
CheckErrors(validator);
} }
private static void CheckErrors(ContentValidator validator) private static void CheckErrors(ContentValidator validator)

22
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs

@ -162,6 +162,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
public async Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName) public async Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName)
{ {
var schema = await GetSchemaAsync(context, schemaIdOrName);
if (schema == null)
{
throw new DomainObjectNotFoundException(schemaIdOrName);
}
return schema;
}
public async Task<ISchemaEntity?> GetSchemaAsync(Context context, string schemaIdOrName)
{
Guard.NotNull(context, nameof(context));
Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName));
ISchemaEntity? schema = null; ISchemaEntity? schema = null;
var canCache = !context.IsFrontendClient; var canCache = !context.IsFrontendClient;
@ -178,12 +193,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName, canCache); schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName, canCache);
} }
if (schema == null) if (schema != null && !HasPermission(context, schema))
{
throw new DomainObjectNotFoundException(schemaIdOrName);
}
if (!HasPermission(context, schema))
{ {
throw new DomainForbiddenException(T.Get("schemas.noPermission")); throw new DomainForbiddenException(T.Get("schemas.noPermission"));
} }

16
backend/src/Squidex.Domain.Apps.Entities/Contents/ValidationResult.cs

@ -1,16 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ValidationResult
{
public ValidationError[] Errors { get; set; }
}
}

19
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs

@ -67,6 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
return Snapshot; return Snapshot;
}); });
case UpdateRule updateRule: case UpdateRule updateRule:
return UpdateReturnAsync(updateRule, async c => return UpdateReturnAsync(updateRule, async c =>
{ {
@ -76,6 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
return Snapshot; return Snapshot;
}); });
case EnableRule enableRule: case EnableRule enableRule:
return UpdateReturn(enableRule, c => return UpdateReturn(enableRule, c =>
{ {
@ -85,6 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
return Snapshot; return Snapshot;
}); });
case DisableRule disableRule: case DisableRule disableRule:
return UpdateReturn(disableRule, c => return UpdateReturn(disableRule, c =>
{ {
@ -94,6 +97,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
return Snapshot; return Snapshot;
}); });
case DeleteRule deleteRule: case DeleteRule deleteRule:
return Update(deleteRule, c => return Update(deleteRule, c =>
{ {
@ -101,22 +105,25 @@ namespace Squidex.Domain.Apps.Entities.Rules
Delete(c); Delete(c);
}); });
case TriggerRule triggerRule: case TriggerRule triggerRule:
return Trigger(triggerRule); return UpdateReturnAsync(triggerRule, async c =>
{
await Trigger(triggerRule);
return true;
});
default: default:
throw new NotSupportedException(); throw new NotSupportedException();
} }
} }
private async Task<object?> Trigger(TriggerRule command) private async Task Trigger(TriggerRule command)
{ {
await EnsureLoadedAsync();
var @event = SimpleMapper.Map(command, new RuleManuallyTriggered { RuleId = Snapshot.Id, AppId = Snapshot.AppId }); var @event = SimpleMapper.Map(command, new RuleManuallyTriggered { RuleId = Snapshot.Id, AppId = Snapshot.AppId });
await ruleEnqueuer.EnqueueAsync(Snapshot.RuleDef, Snapshot.Id, Envelope.Create(@event)); await ruleEnqueuer.EnqueueAsync(Snapshot.RuleDef, Snapshot.Id, Envelope.Create(@event));
return null;
} }
public void Create(CreateRule command) public void Create(CreateRule command)

2
backend/src/Squidex.Shared/Permissions.cs

@ -140,7 +140,7 @@ namespace Squidex.Shared
public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read";
public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create";
public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update";
public const string AppContentsUpdatePartial = "squidex.apps.{app}.contents.{name}.update.partial"; public const string AppContentsUpsert = "squidex.apps.{app}.contents.{name}.upsert";
public const string AppContentsVersionCreate = "squidex.apps.{app}.contents.{name}.version.create"; public const string AppContentsVersionCreate = "squidex.apps.{app}.contents.{name}.version.create";
public const string AppContentsVersionDelete = "squidex.apps.{app}.contents.{name}.version.delete"; public const string AppContentsVersionDelete = "squidex.apps.{app}.contents.{name}.version.delete";
public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete";

2
backend/src/Squidex.Web/Resources.cs

@ -32,8 +32,6 @@ namespace Squidex.Web
public bool CanUpdateContent(string schema) => IsAllowedForSchema(P.AppContentsUpdate, schema); public bool CanUpdateContent(string schema) => IsAllowedForSchema(P.AppContentsUpdate, schema);
public bool CanUpdateContentPartial(string schema) => IsAllowedForSchema(P.AppContentsUpdatePartial, schema);
// Schemas // Schemas
public bool CanUpdateSchema(string schema) => IsAllowedForSchema(P.AppSchemasDelete, schema); public bool CanUpdateSchema(string schema) => IsAllowedForSchema(P.AppSchemasDelete, schema);

13
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -292,7 +292,8 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param> /// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content to fetch.</param> /// <param name="id">The id of the content to fetch.</param>
/// <returns> /// <returns>
/// 200 => Content validation result returned. /// 204 => Content is valid.
/// 400 => Content not valid.
/// 404 => Content, schema or app not found. /// 404 => Content, schema or app not found.
/// </returns> /// </returns>
/// <remarks> /// <remarks>
@ -300,19 +301,15 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks> /// </remarks>
[HttpGet] [HttpGet]
[Route("content/{app}/{name}/{id}/validity")] [Route("content/{app}/{name}/{id}/validity")]
[ProducesResponseType(typeof(ValidationResultDto), 200)]
[ApiPermissionOrAnonymous] [ApiPermissionOrAnonymous]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetContentValidity(string app, string name, DomainId id) public async Task<IActionResult> GetContentValidity(string app, string name, DomainId id)
{ {
var command = new ValidateContent { ContentId = id }; var command = new ValidateContent { ContentId = id };
var context = await CommandBus.PublishAsync(command); await CommandBus.PublishAsync(command);
var result = context.Result<ValidationResult>();
var response = ValidationResultDto.FromResult(result);
return Ok(response); return NoContent();
} }
/// <summary> /// <summary>
@ -520,7 +517,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPost] [HttpPost]
[Route("content/{app}/{name}/{id}/")] [Route("content/{app}/{name}/{id}/")]
[ProducesResponseType(typeof(ContentsDto), 200)] [ProducesResponseType(typeof(ContentsDto), 200)]
[ApiPermissionOrAnonymous(Permissions.AppContentsUpdate)] [ApiPermissionOrAnonymous(Permissions.AppContentsUpsert)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PostContent(string app, string name, DomainId id, [FromBody] NamedContentData request, [FromQuery] bool publish = false) public async Task<IActionResult> PostContent(string app, string name, DomainId id, [FromBody] NamedContentData request, [FromQuery] bool publish = false)
{ {

5
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs

@ -30,6 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary> /// </summary>
public bool DoNotScript { get; set; } = true; public bool DoNotScript { get; set; } = true;
/// <summary>
/// True to check referrers of this content.
/// </summary>
public bool CheckReferrers { get; set; }
/// <summary> /// <summary>
/// True to turn off costly validation: Unique checks, asset checks and reference checks. Default: true. /// True to turn off costly validation: Unique checks, asset checks and reference checks. Default: true.
/// </summary> /// </summary>

12
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs

@ -34,13 +34,23 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// <summary> /// <summary>
/// The new status when the type is set to 'ChangeStatus'. /// The new status when the type is set to 'ChangeStatus'.
/// </summary> /// </summary>
public Status? Status { get; set; } public Status Status { get; set; }
/// <summary> /// <summary>
/// The update type. /// The update type.
/// </summary> /// </summary>
public BulkUpdateType Type { get; set; } public BulkUpdateType Type { get; set; }
/// <summary>
/// The optional schema id or name.
/// </summary>
public string? Schema { get; set; }
/// <summary>
/// The expected version.
/// </summary>
public long ExpectedVersion { get; set; } = EtagVersion.Any;
public BulkUpdateJob ToJob() public BulkUpdateJob ToJob()
{ {
return SimpleMapper.Map(this, new BulkUpdateJob()); return SimpleMapper.Map(this, new BulkUpdateJob());

12
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -177,17 +177,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
AddDeleteLink("delete", resources.Url<ContentsController>(x => nameof(x.DeleteContent), values)); AddDeleteLink("delete", resources.Url<ContentsController>(x => nameof(x.DeleteContent), values));
} }
if (content.CanUpdate) if (content.CanUpdate && resources.CanUpdateContent(schema))
{ {
if (resources.CanUpdateContent(schema)) AddPutLink("update", resources.Url<ContentsController>(x => nameof(x.PutContent), values));
{
AddPutLink("update", resources.Url<ContentsController>(x => nameof(x.PutContent), values));
}
if (resources.CanUpdateContentPartial(schema)) AddPatchLink("patch", resources.Url<ContentsController>(x => nameof(x.PatchContent), values));
{
AddPatchLink("patch", resources.Url<ContentsController>(x => nameof(x.PatchContent), values));
}
} }
return this; return this;

29
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ValidationResultDto.cs

@ -1,29 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Contents.Models
{
public class ValidationResultDto
{
/// <summary>
/// The validation errors.
/// </summary>
public string[] Errors { get; set; }
public static ValidationResultDto FromResult(ValidationResult result)
{
return new ValidationResultDto
{
Errors = ApiExceptionConverter.ToErrors(result.Errors).ToArray()
};
}
}
}

355
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
@ -15,6 +16,8 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
using Squidex.Shared;
using Squidex.Shared.Identity;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
@ -24,15 +27,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>(); private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>(); private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
private readonly ICommandBus commandBus = A.Dummy<ICommandBus>(); private readonly ICommandBus commandBus = A.Dummy<ICommandBus>();
private readonly Context requestContext = Context.Anonymous(); private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); private readonly NamedId<DomainId> schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema");
private readonly BulkUpdateCommandMiddleware sut; private readonly BulkUpdateCommandMiddleware sut;
public BulkUpdateCommandMiddlewareTests() public BulkUpdateCommandMiddlewareTests()
{ {
A.CallTo(() => contextProvider.Context)
.Returns(requestContext);
sut = new BulkUpdateCommandMiddleware(contentQuery, contextProvider); sut = new BulkUpdateCommandMiddleware(contentQuery, contextProvider);
} }
@ -41,11 +41,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var command = new BulkUpdateContents(); var command = new BulkUpdateContents();
var context = new CommandContext(command, commandBus); var result = await PublishAsync(command);
await sut.HandleAsync(context);
Assert.True(context.PlainResult is BulkUpdateResult); Assert.Empty(result);
} }
[Fact] [Fact]
@ -53,39 +51,63 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var command = new BulkUpdateContents { Jobs = Array.Empty<BulkUpdateJob>() }; var command = new BulkUpdateContents { Jobs = Array.Empty<BulkUpdateJob>() };
var context = new CommandContext(command, commandBus); var result = await PublishAsync(command);
await sut.HandleAsync(context); Assert.Empty(result);
}
[Fact]
public async Task Should_throw_exception_when_content_cannot_be_resolved()
{
SetupContext(Permissions.AppContentsUpdate);
Assert.True(context.PlainResult is BulkUpdateResult); var (_, _, query) = CreateTestData(true);
var command = BulkCommand(BulkUpdateType.ChangeStatus);
var result = await PublishAsync(command);
Assert.Single(result, x => x.ContentId == null && x.Exception is DomainObjectNotFoundException);
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_upsert_content_with_random_id_if_no_query_and_id_defined() public async Task Should_throw_exception_when_query_resolves_multiple_contents()
{ {
var (_, data, _) = CreateTestData(false); var requestContext = SetupContext(Permissions.AppContentsUpdate);
var command = new BulkUpdateContents var (id, data, query) = CreateTestData(true);
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.Upsert,
Data = data
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus); A.CallTo(() => contentQuery.QueryAsync(requestContext, A<string>._, A<Q>.That.Matches(x => x.JsonQuery == query)))
.Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id)));
await sut.HandleAsync(context); var command = BulkCommand(BulkUpdateType.ChangeStatus, query);
var result = await PublishAsync(command);
Assert.Single(result, x => x.ContentId == null && x.Exception is DomainException);
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_upsert_content_with_with_resolved_id()
{
var requestContext = SetupContext(Permissions.AppContentsUpsert);
var (id, data, query) = CreateTestData(false);
A.CallTo(() => contentQuery.QueryAsync(requestContext, A<string>._, A<Q>.That.Matches(x => x.JsonQuery == query)))
.Returns(ResultList.CreateFrom(1, CreateContent(id)));
var result = context.Result<BulkUpdateResult>(); var command = BulkCommand(BulkUpdateType.Upsert, query: query, data: data);
Assert.Single(result); var result = await PublishAsync(command);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
Assert.Single(result, x => x.ContentId != default && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId.ToString().Length == 36))) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId.ToString().Length == 36)))
@ -93,32 +115,35 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
[Fact] [Fact]
public async Task Should_upsert_content_with_random_id_if_query_returns_no_result() public async Task Should_upsert_content_with_random_id_if_no_query_and_id_defined()
{ {
var (_, data, query) = CreateTestData(false); SetupContext(Permissions.AppContentsUpsert);
var command = new BulkUpdateContents var (_, data, _) = CreateTestData(false);
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.Upsert,
Data = data,
Query = query
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus); var command = BulkCommand(BulkUpdateType.Upsert, data: data);
await sut.HandleAsync(context); var result = await PublishAsync(command);
Assert.Single(result, x => x.ContentId != default && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId.ToString().Length == 36)))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_upsert_content_with_random_id_if_query_returns_no_result()
{
SetupContext(Permissions.AppContentsUpsert);
var result = context.Result<BulkUpdateResult>(); var (_, data, query) = CreateTestData(false);
var command = BulkCommand(BulkUpdateType.Upsert, query: query, data: data);
var result = await PublishAsync(command);
Assert.Single(result); Assert.Single(result, x => x.ContentId != default && x.Exception == null);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId.ToString().Length == 36))) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId.ToString().Length == 36)))
@ -128,30 +153,15 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_upsert_content_when_id_defined() public async Task Should_upsert_content_when_id_defined()
{ {
var (id, data, _) = CreateTestData(false); SetupContext(Permissions.AppContentsUpsert);
var command = new BulkUpdateContents
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.Upsert,
Data = data,
Id = id
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus); var (id, data, _) = CreateTestData(false);
await sut.HandleAsync(context); var command = BulkCommand(BulkUpdateType.Upsert, id: id, data: data);
var result = context.Result<BulkUpdateResult>(); var result = await PublishAsync(command);
Assert.Single(result); Assert.Single(result, x => x.ContentId != default && x.Exception == null);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id))) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id)))
@ -161,33 +171,15 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_upsert_content_with_custom_id() public async Task Should_upsert_content_with_custom_id()
{ {
var (id, data, query) = CreateTestData(true); var requestContext = SetupContext(Permissions.AppContentsUpsert);
A.CallTo(() => contentQuery.QueryAsync(requestContext, A<string>._, A<Q>.That.Matches(x => x.JsonQuery == query))) var (id, data, _) = CreateTestData(true);
.Returns(ResultList.CreateFrom(1, CreateContent(id)));
var command = new BulkUpdateContents var command = BulkCommand(BulkUpdateType.Upsert, id: id, data: data);
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.Upsert,
Data = data,
Query = query
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus); var result = await PublishAsync(command);
await sut.HandleAsync(context);
var result = context.Result<BulkUpdateResult>(); Assert.Single(result, x => x.ContentId != default && x.Exception == null);
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id))) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id)))
@ -195,97 +187,69 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
[Fact] [Fact]
public async Task Should_throw_exception_when_query_resolves_multiple_contents() public async Task Should_change_content_status()
{ {
var (id, data, query) = CreateTestData(true); SetupContext(Permissions.AppContentsUpdate);
A.CallTo(() => contentQuery.QueryAsync(requestContext, A<string>._, A<Q>.That.Matches(x => x.JsonQuery == query))) var (id, _, _) = CreateTestData(false);
.Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id)));
var command = new BulkUpdateContents var command = BulkCommand(BulkUpdateType.ChangeStatus, id: id);
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.Upsert,
Data = data,
Query = query
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus); var result = await PublishAsync(command);
await sut.HandleAsync(context); Assert.Single(result, x => x.ContentId == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(A<ChangeContentStatus>.That.Matches(x => x.ContentId == id)))
.MustHaveHappened();
}
[Fact]
public async Task Should_throw_security_exception_when_user_has_no_permission_for_changing_status()
{
SetupContext(Permissions.AppContentsRead);
var (id, _, _) = CreateTestData(false);
var command = BulkCommand(BulkUpdateType.ChangeStatus, id: id);
var result = context.Result<BulkUpdateResult>(); var result = await PublishAsync(command);
Assert.Single(result); Assert.Single(result, x => x.ContentId == id && x.Exception is DomainForbiddenException);
Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainException));
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._)) A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_change_content_status() public async Task Should_validate_content()
{ {
var (id, _, _) = CreateTestData(false); SetupContext(Permissions.AppContentsRead);
var command = new BulkUpdateContents var (id, _, _) = CreateTestData(false);
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.ChangeStatus,
Id = id
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context); var command = BulkCommand(BulkUpdateType.Validate, id: id);
var result = context.Result<BulkUpdateResult>(); var result = await PublishAsync(command);
Assert.Single(result); Assert.Single(result, x => x.ContentId == id && x.Exception == null);
Assert.Equal(1, result.Count(x => x.ContentId == id));
A.CallTo(() => commandBus.PublishAsync(A<ChangeContentStatus>.That.Matches(x => x.ContentId == id))) A.CallTo(() => commandBus.PublishAsync(
A<ValidateContent>.That.Matches(x => x.ContentId == id)))
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_throw_exception_when_content_id_to_change_cannot_be_resolved() public async Task Should_throw_security_exception_when_user_has_no_permission_for_validation()
{ {
var (_, _, query) = CreateTestData(true); SetupContext(Permissions.AppContentsDelete);
var command = new BulkUpdateContents var (id, _, _) = CreateTestData(false);
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.ChangeStatus,
Query = query
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context); var command = BulkCommand(BulkUpdateType.Validate, id: id);
var result = context.Result<BulkUpdateResult>(); var result = await PublishAsync(command);
Assert.Single(result); Assert.Single(result, x => x.ContentId == id && x.Exception is DomainForbiddenException);
Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException));
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._)) A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -294,29 +258,15 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_delete_content() public async Task Should_delete_content()
{ {
var (id, _, _) = CreateTestData(false); SetupContext(Permissions.AppContentsDelete);
var command = new BulkUpdateContents var (id, _, _) = CreateTestData(false);
{
Jobs = new[]
{
new BulkUpdateJob
{
Type = BulkUpdateType.Delete,
Id = id
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context); var command = BulkCommand(BulkUpdateType.Delete, id: id);
var result = context.Result<BulkUpdateResult>(); var result = await PublishAsync(command);
Assert.Single(result); Assert.Single(result, x => x.ContentId == id && x.Exception == null);
Assert.Equal(1, result.Count(x => x.ContentId == id));
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<DeleteContent>.That.Matches(x => x.ContentId == id))) A<DeleteContent>.That.Matches(x => x.ContentId == id)))
@ -324,34 +274,59 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
[Fact] [Fact]
public async Task Should_throw_exception_when_content_id_to_delete_cannot_be_resolved() public async Task Should_throw_security_exception_when_user_has_no_permission_for_deletion()
{ {
var (_, _, query) = CreateTestData(true); SetupContext(Permissions.AppContentsRead);
var (id, _, _) = CreateTestData(false);
var command = BulkCommand(BulkUpdateType.Delete, id: id);
var result = await PublishAsync(command);
var command = new BulkUpdateContents Assert.Single(result, x => x.ContentId == id && x.Exception is DomainForbiddenException);
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
private async Task<BulkUpdateResult> PublishAsync(ICommand command)
{
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
return (context.PlainResult as BulkUpdateResult)!;
}
private BulkUpdateContents BulkCommand(BulkUpdateType type, Query<IJsonValue>? query = null, DomainId? id = null, NamedContentData? data = null)
{
return new BulkUpdateContents
{ {
AppId = appId,
Jobs = new[] Jobs = new[]
{ {
new BulkUpdateJob new BulkUpdateJob { Type = type, Query = query, Id = id, Data = data! }
{
Type = BulkUpdateType.Delete,
Query = query
}
}, },
SchemaId = schemaId SchemaId = schemaId
}; };
}
var context = new CommandContext(command, commandBus); private Context SetupContext(string id)
{
var permission = Permissions.ForApp(id, appId.Name, schemaId.Name).Id;
await sut.HandleAsync(context); var claimsIdentity = new ClaimsIdentity();
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
var result = context.Result<BulkUpdateResult>(); claimsIdentity.AddClaim(new Claim(SquidexClaimTypes.Permissions, permission));
Assert.Single(result); var requestContext = new Context(claimsPrincipal);
Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException));
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._)) A.CallTo(() => contextProvider.Context)
.MustNotHaveHappened(); .Returns(requestContext);
return requestContext;
} }
private static (DomainId Id, NamedContentData Data, Query<IJsonValue>? Query) CreateTestData(bool withQuery) private static (DomainId Id, NamedContentData Data, Query<IJsonValue>? Query) CreateTestData(bool withQuery)

7
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
@ -600,11 +599,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
var command = new ValidateContent(); var command = new ValidateContent();
var result = await PublishAsync(command); await PublishAsync(command);
result.ShouldBeEquivalent(new ValidationResult { Errors = Array.Empty<ValidationError>() });
Assert.Equal(0, sut.Snapshot.Version); Assert.Equal(0, sut.Version);
} }
[Fact] [Fact]

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDomainObjectTests.cs

@ -160,9 +160,9 @@ namespace Squidex.Domain.Apps.Entities.Rules
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(command); await PublishAsync(command);
Assert.Null(result); Assert.Equal(0, sut.Version);
A.CallTo(() => ruleEnqueuer.EnqueueAsync(sut.Snapshot.RuleDef, sut.Snapshot.Id, A.CallTo(() => ruleEnqueuer.EnqueueAsync(sut.Snapshot.RuleDef, sut.Snapshot.Id,
A<Envelope<IEvent>>.That.Matches(x => x.Payload is RuleManuallyTriggered))) A<Envelope<IEvent>>.That.Matches(x => x.Payload is RuleManuallyTriggered)))

2
frontend/app-config/webpack.config.js

@ -43,7 +43,7 @@ module.exports = function (env) {
const isTests = env && env.target === 'tests'; const isTests = env && env.target === 'tests';
const isTestCoverage = env && env.coverage; const isTestCoverage = env && env.coverage;
const isAnalyzing = isProduction && env.analyze; const isAnalyzing = isProduction && env.analyze;
const isAot = !isDevServer; const isAot = !isDevServer && !isTests && !isTestCoverage;
const configFile = isTests ? 'tsconfig.spec.json' : 'tsconfig.app.json'; const configFile = isTests ? 'tsconfig.spec.json' : 'tsconfig.app.json';

15
frontend/app/app.module.ts

@ -1,3 +1,4 @@
/* /*
* Squidex Headless CMS * Squidex Headless CMS
* *
@ -19,7 +20,7 @@ import { SqxShellModule } from './shell';
DateHelper.setlocale(window['options']?.more?.culture); DateHelper.setlocale(window['options']?.more?.culture);
export function configApiUrl() { function configApiUrl() {
const baseElements = document.getElementsByTagName('base'); const baseElements = document.getElementsByTagName('base');
let baseHref = null; let baseHref = null;
@ -35,27 +36,27 @@ export function configApiUrl() {
if (baseHref.indexOf(window.location.protocol) === 0) { if (baseHref.indexOf(window.location.protocol) === 0) {
return new ApiUrlConfig(baseHref); return new ApiUrlConfig(baseHref);
} else { } else {
return new ApiUrlConfig(window.location.protocol + '//' + window.location.host + baseHref); return new ApiUrlConfig(`${window.location.protocol}//${window.location.host}${baseHref}`);
} }
} }
export function configUIOptions() { function configUIOptions() {
return new UIOptions(window['options']); return new UIOptions(window['options']);
} }
export function configTitles() { function configTitles() {
return new TitlesConfig(undefined, 'i18n:common.product'); return new TitlesConfig(undefined, 'i18n:common.product');
} }
export function configDecimalSeparator() { function configDecimalSeparator() {
return new DecimalSeparatorConfig('.'); return new DecimalSeparatorConfig('.');
} }
export function configCurrency() { function configCurrency() {
return new CurrencyConfig('EUR', '€', true); return new CurrencyConfig('EUR', '€', true);
} }
export function configLocalizerService() { function configLocalizerService() {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
return new LocalizerService(window['texts']); return new LocalizerService(window['texts']);
} else { } else {

2
frontend/app/app.routes.ts

@ -10,7 +10,7 @@ import { RouterModule, Routes } from '@angular/router';
import { AppMustExistGuard, LoadAppsGuard, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, UnsetAppGuard } from './shared'; import { AppMustExistGuard, LoadAppsGuard, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, UnsetAppGuard } from './shared';
import { AppAreaComponent, ForbiddenPageComponent, HomePageComponent, InternalAreaComponent, LoginPageComponent, LogoutPageComponent, NotFoundPageComponent } from './shell'; import { AppAreaComponent, ForbiddenPageComponent, HomePageComponent, InternalAreaComponent, LoginPageComponent, LogoutPageComponent, NotFoundPageComponent } from './shell';
export const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
component: HomePageComponent, component: HomePageComponent,

2
frontend/app/features/administration/pages/users/users-page.component.html

@ -58,7 +58,7 @@
</div> </div>
<ng-container footer> <ng-container footer>
<sqx-pager [pager]="usersState.usersPager | async" (pagerChange)="usersState.setPager($event)"></sqx-pager> <sqx-pager [paging]="usersState.paging | async" (pagingChange)="usersState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

2
frontend/app/features/administration/pages/users/users-page.component.ts

@ -28,7 +28,7 @@ export class UsersPageComponent extends ResourceOwner implements OnInit {
super(); super();
this.own( this.own(
this.usersState.usersQuery this.usersState.query
.subscribe(q => this.usersFilter.setValue(q || ''))); .subscribe(q => this.usersFilter.setValue(q || '')));
} }

2
frontend/app/features/administration/services/event-consumers.service.ts

@ -57,7 +57,7 @@ export class EventConsumersService {
return this.http.get<{ items: any[] } & Resource>(url).pipe( return this.http.get<{ items: any[] } & Resource>(url).pipe(
map(({ items, _links }) => { map(({ items, _links }) => {
const eventConsumers = items.map(item => parseEventConsumer(item)); const eventConsumers = items.map(parseEventConsumer);
return new EventConsumersDto(eventConsumers, _links); return new EventConsumersDto(eventConsumers, _links);
}), }),

2
frontend/app/features/administration/services/users.service.ts

@ -66,7 +66,7 @@ export class UsersService {
return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe( return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe(
map(({ total, items, _links }) => { map(({ total, items, _links }) => {
const users = items.map(item => parseUser(item)); const users = items.map(parseUser);
return new UsersDto(total, users, _links); return new UsersDto(total, users, _links);
}), }),

6
frontend/app/features/administration/state/users.forms.ts

@ -54,10 +54,12 @@ export class UserForm extends Form<FormGroup, UpdateUserDto, UserDto> {
protected transformLoad(user: Partial<UserDto>) { protected transformLoad(user: Partial<UserDto>) {
const permissions = user.permissions?.join('\n') || ''; const permissions = user.permissions?.join('\n') || '';
return { ...user, permissions: permissions }; return { ...user, permissions };
} }
protected transformSubmit(value: any) { protected transformSubmit(value: any) {
return { ...value, permissions: value['permissions'].split('\n').filter((x: any) => !!x) }; const permissions = value['permissions'].split('\n').filter((x: any) => !!x);
return { ...value, permissions };
} }
} }

28
frontend/app/features/administration/state/users.state.spec.ts

@ -6,7 +6,7 @@
*/ */
import { UserDto, UsersDto, UsersService } from '@app/features/administration/internal'; import { UserDto, UsersDto, UsersService } from '@app/features/administration/internal';
import { DialogService, Pager } from '@app/shared'; import { DialogService } from '@app/shared';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
@ -46,10 +46,10 @@ describe('UsersState', () => {
usersState.load().subscribe(); usersState.load().subscribe();
expect(usersState.snapshot.users).toEqual([user1, user2]);
expect(usersState.snapshot.isLoaded).toBeTruthy(); expect(usersState.snapshot.isLoaded).toBeTruthy();
expect(usersState.snapshot.isLoading).toBeFalsy(); expect(usersState.snapshot.isLoading).toBeFalsy();
expect(usersState.snapshot.users).toEqual([user1, user2]); expect(usersState.snapshot.total).toEqual(200);
expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
}); });
@ -97,7 +97,7 @@ describe('UsersState', () => {
usersService.setup(x => x.getUsers(10, 10, undefined)) usersService.setup(x => x.getUsers(10, 10, undefined))
.returns(() => of(new UsersDto(200, []))).verifiable(); .returns(() => of(new UsersDto(200, []))).verifiable();
usersState.setPager(new Pager(200, 1, 10)).subscribe(); usersState.page({ page: 1, pageSize: 10 }).subscribe();
expect().nothing(); expect().nothing();
}); });
@ -108,7 +108,7 @@ describe('UsersState', () => {
usersState.search('my-query').subscribe(); usersState.search('my-query').subscribe();
expect(usersState.snapshot.usersQuery).toEqual('my-query'); expect(usersState.snapshot.query).toEqual('my-query');
}); });
it('should load when synchronizer triggered', () => { it('should load when synchronizer triggered', () => {
@ -239,7 +239,23 @@ describe('UsersState', () => {
usersState.create(request).subscribe(); usersState.create(request).subscribe();
expect(usersState.snapshot.users).toEqual([newUser, user1, user2]); expect(usersState.snapshot.users).toEqual([newUser, user1, user2]);
expect(usersState.snapshot.usersPager.numberOfItems).toBe(201); expect(usersState.snapshot.total).toBe(201);
});
it('should truncate users when page size reached', () => {
const request = { ...newUser, password: 'password' };
usersService.setup(x => x.getUsers(2, 0, undefined))
.returns(() => of(new UsersDto(200, [user1, user2]))).verifiable();
usersService.setup(x => x.postUser(request))
.returns(() => of(newUser)).verifiable();
usersState.page({ page: 0, pageSize: 2 }).subscribe();
usersState.create(request).subscribe();
expect(usersState.snapshot.users).toEqual([newUser, user1]);
expect(usersState.snapshot.total).toBe(201);
}); });
}); });
}); });

61
frontend/app/features/administration/state/users.state.ts

@ -7,26 +7,14 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import '@app/framework/utils/rxjs-extensions'; import '@app/framework/utils/rxjs-extensions';
import { DialogService, Pager, shareSubscribed, State, StateSynchronizer } from '@app/shared'; import { DialogService, getPagingInfo, ListState, shareSubscribed, State, StateSynchronizer } from '@app/shared';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service'; import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service';
interface Snapshot { interface Snapshot extends ListState<string> {
// The current users. // The current users.
users: UsersList; users: ReadonlyArray<UserDto>;
// The pagination information.
usersPager: Pager;
// The query to filter users.
usersQuery?: string;
// Indicates if the users are loaded.
isLoaded?: boolean;
// Indicates if the users are loading.
isLoading?: boolean;
// The selected user. // The selected user.
selectedUser?: UserDto | null; selectedUser?: UserDto | null;
@ -43,11 +31,11 @@ export class UsersState extends State<Snapshot> {
public users = public users =
this.project(x => x.users); this.project(x => x.users);
public usersPager = public paging =
this.project(x => x.usersPager); this.project(x => getPagingInfo(x, x.users.length));
public usersQuery = public query =
this.project(x => x.usersQuery); this.project(x => x.query);
public selectedUser = public selectedUser =
this.project(x => x.selectedUser); this.project(x => x.selectedUser);
@ -67,14 +55,16 @@ export class UsersState extends State<Snapshot> {
) { ) {
super({ super({
users: [], users: [],
usersPager: new Pager(0) page: 0,
pageSize: 10,
total: 0
}); });
} }
public select(id: string | null): Observable<UserDto | null> { public select(id: string | null): Observable<UserDto | null> {
return this.loadUser(id).pipe( return this.loadUser(id).pipe(
tap(selectedUser => { tap(selectedUser => {
this.next(s => ({ ...s, selectedUser })); this.next({ selectedUser });
}), }),
shareSubscribed(this.dialogs, { silent: true })); shareSubscribed(this.dialogs, { silent: true }));
} }
@ -96,8 +86,8 @@ export class UsersState extends State<Snapshot> {
public loadAndListen(synchronizer: StateSynchronizer) { public loadAndListen(synchronizer: StateSynchronizer) {
synchronizer.mapTo(this) synchronizer.mapTo(this)
.keep('selectedUser') .keep('selectedUser')
.withPager('usersPager', 'users', 10) .withPaging('users', 10)
.withString('usersQuery', 'q') .withString('query', 'q')
.whenSynced(() => this.loadInternal(false)) .whenSynced(() => this.loadInternal(false))
.build(); .build();
} }
@ -113,18 +103,18 @@ export class UsersState extends State<Snapshot> {
private loadInternal(isReload: boolean): Observable<any> { private loadInternal(isReload: boolean): Observable<any> {
this.next({ isLoading: true }); this.next({ isLoading: true });
const { page, pageSize, query } = this.snapshot;
return this.usersService.getUsers( return this.usersService.getUsers(
this.snapshot.usersPager.pageSize, pageSize,
this.snapshot.usersPager.skip, pageSize * page,
this.snapshot.usersQuery).pipe( query).pipe(
tap(({ total, items: users, canCreate }) => { tap(({ total, items: users, canCreate }) => {
if (isReload) { if (isReload) {
this.dialogs.notifyInfo('i18n:users.reloaded'); this.dialogs.notifyInfo('i18n:users.reloaded');
} }
this.next(s => { this.next(s => {
const usersPager = s.usersPager.setCount(total);
let selectedUser = s.selectedUser; let selectedUser = s.selectedUser;
if (selectedUser) { if (selectedUser) {
@ -133,11 +123,11 @@ export class UsersState extends State<Snapshot> {
return { ...s, return { ...s,
canCreate, canCreate,
users,
isLoaded: true, isLoaded: true,
isLoading: false, isLoading: false,
selectedUser, selectedUser,
users, total
usersPager
}; };
}); });
}), }),
@ -151,10 +141,9 @@ export class UsersState extends State<Snapshot> {
return this.usersService.postUser(request).pipe( return this.usersService.postUser(request).pipe(
tap(created => { tap(created => {
this.next(s => { this.next(s => {
const users = [created, ...s.users]; const users = [created, ...s.users].slice(0, s.pageSize);
const usersPager = s.usersPager.incrementCount();
return { ...s, users, usersPager }; return { ...s, users, total: s.total + 1 };
}); });
}), }),
shareSubscribed(this.dialogs, { silent: true })); shareSubscribed(this.dialogs, { silent: true }));
@ -185,13 +174,13 @@ export class UsersState extends State<Snapshot> {
} }
public search(query: string): Observable<UsersResult> { public search(query: string): Observable<UsersResult> {
this.next(s => ({ ...s, usersPager: s.usersPager.reset(), usersQuery: query })); this.next({ query, page: 0 });
return this.loadInternal(false); return this.loadInternal(false);
} }
public setPager(usersPager: Pager) { public page(paging: { page: number, pageSize: number }) {
this.next({ usersPager }); this.next(paging);
return this.loadInternal(false); return this.loadInternal(false);
} }

2
frontend/app/features/assets/pages/assets-filters-page.component.html

@ -16,7 +16,7 @@
<sqx-shared-queries <sqx-shared-queries
[types]="'common.assets' | sqxTranslate" [types]="'common.assets' | sqxTranslate"
[queryUsed]="assetsState.assetsQuery | async" [queryUsed]="assetsState.query | async"
[queries]="assetsQueries" [queries]="assetsQueries"
(search)="search($event)"> (search)="search($event)">
</sqx-shared-queries> </sqx-shared-queries>

7
frontend/app/features/assets/pages/assets-page.component.html

@ -26,13 +26,14 @@
</div> </div>
<div class="col-6"> <div class="col-6">
<sqx-search-form formClass="form" placeholder="{{ 'assets.searchByName' | sqxTranslate }}" fieldExample="fileSize" <sqx-search-form formClass="form" placeholder="{{ 'assets.searchByName' | sqxTranslate }}" fieldExample="fileSize"
[query]="assetsState.assetsQuery | async" [query]="assetsState.query | async"
[queries]="queries" [queries]="queries"
(queryChange)="search($event)" (queryChange)="search($event)"
enableShortcut="true"> enableShortcut="true">
</sqx-search-form> </sqx-search-form>
</div> </div>
</div> </div>
</div> </div>
<div class="col-auto pl-1"> <div class="col-auto pl-1">
<div class="btn-group"> <div class="btn-group">
@ -61,11 +62,11 @@
</ng-container> </ng-container>
<div content> <div content>
<sqx-assets-list [state]="assetsState" [showPager]="false" [isListView]="isListView"></sqx-assets-list> <sqx-assets-list [assetsState]="assetsState" [showPager]="false" [isListView]="isListView"></sqx-assets-list>
</div> </div>
<ng-container footer> <ng-container footer>
<sqx-pager [pager]="assetsState.assetsPager | async" (pagerChange)="assetsState.setPager($event)"></sqx-pager> <sqx-pager [paging]="assetsState.paging | async" (pagingChange)="assetsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

8
frontend/app/features/content/declarations.ts

@ -7,11 +7,13 @@
export * from './pages/comments/comments-page.component'; export * from './pages/comments/comments-page.component';
export * from './pages/content/content-event.component'; export * from './pages/content/content-event.component';
export * from './pages/content/content-field.component';
export * from './pages/content/content-history-page.component'; export * from './pages/content/content-history-page.component';
export * from './pages/content/content-page.component'; export * from './pages/content/content-page.component';
export * from './pages/content/content-section.component'; export * from './pages/content/editor/content-editor.component';
export * from './pages/content/field-languages.component'; export * from './pages/content/editor/content-field.component';
export * from './pages/content/editor/content-section.component';
export * from './pages/content/editor/field-languages.component';
export * from './pages/content/references/content-references.component';
export * from './pages/contents/contents-filters-page.component'; export * from './pages/contents/contents-filters-page.component';
export * from './pages/contents/contents-page.component'; export * from './pages/contents/contents-page.component';
export * from './pages/contents/custom-view-editor.component'; export * from './pages/contents/custom-view-editor.component';

5
frontend/app/features/content/module.ts

@ -10,7 +10,7 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { CanDeactivateGuard, ContentMustExistGuard, LoadLanguagesGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SqxFrameworkModule, SqxSharedModule, UnsetContentGuard } from '@app/shared'; import { CanDeactivateGuard, ContentMustExistGuard, LoadLanguagesGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SqxFrameworkModule, SqxSharedModule, UnsetContentGuard } from '@app/shared';
import { ArrayEditorComponent, ArrayItemComponent, ArraySectionComponent, AssetsEditorComponent, CommentsPageComponent, ContentComponent, ContentCreatorComponent, ContentEventComponent, ContentFieldComponent, ContentHistoryPageComponent, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentPageComponent, ContentSectionComponent, ContentSelectorComponent, ContentSelectorItemComponent, ContentsFiltersPageComponent, ContentsPageComponent, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, CustomViewEditorComponent, DueTimeSelectorComponent, FieldEditorComponent, FieldLanguagesComponent, PreviewButtonComponent, ReferenceItemComponent, ReferencesEditorComponent, SchemasPageComponent, SidebarPageComponent, StockPhotoEditorComponent } from './declarations'; import { ArrayEditorComponent, ArrayItemComponent, ArraySectionComponent, AssetsEditorComponent, CommentsPageComponent, ContentComponent, ContentCreatorComponent, ContentEditorComponent, ContentEventComponent, ContentFieldComponent, ContentHistoryPageComponent, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentPageComponent, ContentReferencesComponent, ContentsColumnsPipe, ContentSectionComponent, ContentSelectorComponent, ContentSelectorItemComponent, ContentsFiltersPageComponent, ContentsPageComponent, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, CustomViewEditorComponent, DueTimeSelectorComponent, FieldEditorComponent, FieldLanguagesComponent, PreviewButtonComponent, ReferenceItemComponent, ReferencesEditorComponent, SchemasPageComponent, SidebarPageComponent, StockPhotoEditorComponent } from './declarations';
const routes: Routes = [ const routes: Routes = [
{ {
@ -89,6 +89,7 @@ const routes: Routes = [
CommentsPageComponent, CommentsPageComponent,
ContentComponent, ContentComponent,
ContentCreatorComponent, ContentCreatorComponent,
ContentEditorComponent,
ContentEventComponent, ContentEventComponent,
ContentFieldComponent, ContentFieldComponent,
ContentHistoryPageComponent, ContentHistoryPageComponent,
@ -96,12 +97,14 @@ const routes: Routes = [
ContentListFieldComponent, ContentListFieldComponent,
ContentListHeaderComponent, ContentListHeaderComponent,
ContentListWidthPipe, ContentListWidthPipe,
ContentsColumnsPipe,
ContentPageComponent, ContentPageComponent,
ContentSectionComponent, ContentSectionComponent,
ContentSelectorComponent, ContentSelectorComponent,
ContentSelectorItemComponent, ContentSelectorItemComponent,
ContentsFiltersPageComponent, ContentsFiltersPageComponent,
ContentsPageComponent, ContentsPageComponent,
ContentReferencesComponent,
ContentStatusComponent, ContentStatusComponent,
ContentValueComponent, ContentValueComponent,
ContentValueEditorComponent, ContentValueEditorComponent,

102
frontend/app/features/content/pages/content/content-page.component.html

@ -7,22 +7,29 @@
<i class="icon-angle-left"></i> <i class="icon-angle-left"></i>
</a> </a>
<ng-container *ngIf="content else noContentTitle"> <ng-container *ngIf="!content">
<sqx-title message="i18n:contents.editPageTitle"></sqx-title>
{{ 'contents.editTitle' | sqxTranslate }}
</ng-container>
<ng-template #noContentTitle>
<sqx-title message="i18n:contents.createPageTitle"></sqx-title> <sqx-title message="i18n:contents.createPageTitle"></sqx-title>
{{ 'contents.createTitle' | sqxTranslate }} {{ 'contents.createTitle' | sqxTranslate }}
</ng-template> </ng-container>
</ng-container>
<ng-container header>
<ng-container *ngIf="content">
<sqx-title message="i18n:contents.editPageTitle"></sqx-title>
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs">
<a class="nav-link" [class.active]="selectedTab === tab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a>
</li>
</ul>
</ng-container>
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<ng-container *ngIf="content; else noContent"> <ng-container *ngIf="content; else noContent">
<ng-container> <ng-container *ngIf="selectedTab === 'i18n:contents.contentTab.editor'; else referenceHeader">
<sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{contentsState.schemaId}}/contents/{{content.id}}"></sqx-notifo> <sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{schema?.name}}/contents/{{content.id}}"></sqx-notifo>
<sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button> <sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button>
@ -43,15 +50,25 @@
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container>
<ng-container *ngIf="content?.canUpdate"> <ng-container *ngIf="content?.canUpdate">
<button type="submit" class="btn btn-primary ml-1" title="i18n:common.saveShortcut"> <button type="submit" class="btn btn-primary ml-1" title="i18n:common.saveShortcut">
{{ 'common.save' | sqxTranslate }} {{ 'common.save' | sqxTranslate }}
</button>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut>
</ng-container>
</ng-container>
<ng-template #referenceHeader>
<button type="button" class="btn btn-primary ml-1" (click)="publish()">
{{ 'contents.publishAll' | sqxTranslate }}
</button> </button>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut> <button type="button" class="btn btn-primary ml-1" (click)="validate()">
</ng-container> {{ 'contents.validate' | sqxTranslate }}
</button>
</ng-template>
</ng-container> </ng-container>
<ng-template #noContent> <ng-template #noContent>
@ -70,30 +87,41 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<sqx-list-view> <ng-container *ngIf="content; else noContentEditor">
<ng-container topHeader> <ng-container [ngSwitch]="selectedTab">
<div class="panel-alert panel-alert-danger" *ngIf="contentVersion"> <ng-container *ngSwitchCase="'i18n:contents.contentTab.editor'">
<div class="float-right"> <sqx-content-editor
<a class="force" (click)="loadLatest()">{{ 'contents.viewLatest' | sqxTranslate }}</a> [(language)]="language"
</div> [contentForm]="contentForm"
[contentFormCompare]="contentFormCompare"
<div [innerHTML]="'contents.versionViewing' | sqxTranslate: { version: contentVersion } | sqxMarkdownInline"></div> [formContext]="formContext"
</div> [languages]="languages"
[schema]="schema">
</sqx-content-editor>
</ng-container>
<ng-container *ngSwitchCase="'i18n:contents.contentTab.references'">
<sqx-content-references mode="references"
[content]="content">
</sqx-content-references>
</ng-container>
<ng-container *ngSwitchCase="'i18n:contents.contentTab.referencing'">
<sqx-content-references mode="referencing"
[content]="content">
</sqx-content-references>
</ng-container>
</ng-container> </ng-container>
</ng-container>
<div content> <ng-template #noContentEditor>
<sqx-content-section *ngFor="let section of contentForm.sections; trackBy: trackBySection" <sqx-content-editor
[(language)]="language" [(language)]="language"
[form]="contentForm" [contentForm]="contentForm"
[formCompare]="contentFormCompare" [contentFormCompare]="contentFormCompare"
[formContext]="formContext" [formContext]="formContext"
[formSection]="section" [languages]="languages"
[languages]="languages" [schema]="schema">
[schema]="schema"> </sqx-content-editor>
</sqx-content-section> </ng-template>
</div>
</sqx-list-view>
</ng-container> </ng-container>
<ng-container sidebar> <ng-container sidebar>

8
frontend/app/features/content/pages/content/content-page.component.scss

@ -4,4 +4,12 @@
.btn-outline-secondary { .btn-outline-secondary {
color: $color-text; color: $color-text;
}
.nav-tabs2 {
@include absolute(auto, auto, 0, 5rem);
a {
padding-bottom: 1.5rem;
}
} }

48
frontend/app/features/content/pages/content/content-page.component.ts

@ -7,11 +7,18 @@
// tslint:disable: max-line-length // tslint:disable: max-line-length
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, DialogService, EditContentForm, fadeAnimation, FieldForm, FieldSection, LanguagesState, ModalModel, ResourceOwner, RootFieldDto, SchemaDetailsDto, SchemasState, TempService, Version } from '@app/shared'; import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, DialogService, EditContentForm, fadeAnimation, LanguagesState, ModalModel, ResourceOwner, SchemaDetailsDto, SchemasState, TempService, Version } from '@app/shared';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { filter, tap } from 'rxjs/operators'; import { filter, tap } from 'rxjs/operators';
import { ContentReferencesComponent } from './references/content-references.component';
const TABS: ReadonlyArray<string> = [
'i18n:contents.contentTab.editor',
'i18n:contents.contentTab.references',
'i18n:contents.contentTab.referencing'
];
@Component({ @Component({
selector: 'sqx-content-page', selector: 'sqx-content-page',
@ -25,6 +32,9 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
private isLoadingContent: boolean; private isLoadingContent: boolean;
private autoSaveKey: AutoSaveKey; private autoSaveKey: AutoSaveKey;
@ViewChild(ContentReferencesComponent)
public references: ContentReferencesComponent;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public formContext: any; public formContext: any;
@ -34,6 +44,9 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
public contentForm: EditContentForm; public contentForm: EditContentForm;
public contentFormCompare: EditContentForm | null = null; public contentFormCompare: EditContentForm | null = null;
public selectableTabs = TABS;
public selectedTab = this.selectableTabs[0];
public dropdown = new ModalModel(); public dropdown = new ModalModel();
public language: AppLanguageDto; public language: AppLanguageDto;
@ -92,21 +105,16 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
contentId: content?.id contentId: content?.id
}; };
const autosaved = this.autoSaveService.get(this.autoSaveKey); const dataAutosaved = this.autoSaveService.fetch(this.autoSaveKey);
const dataCloned = this.tempService.fetch();
if (content) { this.loadContent(dataCloned || content?.data || {}, true);
this.loadContent(content.data, true);
}
const clone = this.tempService.fetch(); if (isNewContent && dataAutosaved && this.contentForm.hasChanges(dataAutosaved)) {
if (clone) {
this.loadContent(clone, true);
} else if (isNewContent && autosaved && this.contentForm.hasChanges(autosaved)) {
this.dialogs.confirm('i18n:contents.unsavedChangesTitle', 'i18n:contents.unsavedChangesText') this.dialogs.confirm('i18n:contents.unsavedChangesTitle', 'i18n:contents.unsavedChangesText')
.subscribe(shouldLoad => { .subscribe(shouldLoad => {
if (shouldLoad) { if (shouldLoad) {
this.loadContent(autosaved, false); this.loadContent(dataAutosaved, false);
} else { } else {
this.autoSaveService.remove(this.autoSaveKey); this.autoSaveService.remove(this.autoSaveKey);
} }
@ -131,6 +139,18 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
); );
} }
public selectTab(tab: string) {
this.selectedTab = tab;
}
public validate() {
this.references?.validate();
}
public publish() {
this.references?.publish();
}
public saveAndPublish() { public saveAndPublish() {
this.saveContent(true); this.saveContent(true);
} }
@ -257,10 +277,6 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.isLoadingContent = false; this.isLoadingContent = false;
} }
} }
public trackBySection(_index: number, section: FieldSection<RootFieldDto, FieldForm>) {
return section.separator?.fieldId;
}
} }
function isOtherContent(lhs: ContentDto | null | undefined, rhs: ContentDto | null | undefined) { function isOtherContent(lhs: ContentDto | null | undefined, rhs: ContentDto | null | undefined) {

24
frontend/app/features/content/pages/content/editor/content-editor.component.html

@ -0,0 +1,24 @@
<sqx-list-view>
<ng-container topHeader>
<div class="panel-alert panel-alert-danger" *ngIf="contentVersion">
<div class="float-right">
<a class="force" (click)="loadLatest.emit()">{{ 'contents.viewLatest' | sqxTranslate }}</a>
</div>
<div [innerHTML]="'contents.versionViewing' | sqxTranslate: { version: contentVersion } | sqxMarkdownInline"></div>
</div>
</ng-container>
<div content>
<sqx-content-section *ngFor="let section of contentForm.sections; trackBy: trackBySection"
[(language)]="language"
[form]="contentForm"
[formCompare]="contentFormCompare"
[formContext]="formContext"
[formSection]="section"
[languages]="languages"
[schema]="schema">
</sqx-content-section>
</div>
</sqx-list-view>

0
frontend/app/features/content/pages/content/field-languages.component.scss → frontend/app/features/content/pages/content/editor/content-editor.component.scss

47
frontend/app/features/content/pages/content/editor/content-editor.component.ts

@ -0,0 +1,47 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { AppLanguageDto, EditContentForm, FieldForm, FieldSection, RootFieldDto, SchemaDetailsDto, Version } from '@app/shared';
@Component({
selector: 'sqx-content-editor',
styleUrls: ['./content-editor.component.scss'],
templateUrl: './content-editor.component.html'
})
export class ContentEditorComponent {
@Output()
public languageChange = new EventEmitter<AppLanguageDto>();
@Output()
public loadLatest = new EventEmitter<any>();
@Input()
public contentForm: EditContentForm;
@Input()
public contentVersion: Version | null;
@Input()
public contentFormCompare?: EditContentForm;
@Input()
public schema: SchemaDetailsDto;
@Input()
public formContext: any;
@Input()
public languages: ReadonlyArray<AppLanguageDto>;
@Input()
public language: AppLanguageDto;
public trackBySection(_index: number, section: FieldSection<RootFieldDto, FieldForm>) {
return section.separator?.fieldId;
}
}

0
frontend/app/features/content/pages/content/content-field.component.html → frontend/app/features/content/pages/content/editor/content-field.component.html

0
frontend/app/features/content/pages/content/content-field.component.scss → frontend/app/features/content/pages/content/editor/content-field.component.scss

4
frontend/app/features/content/pages/content/content-field.component.ts → frontend/app/features/content/pages/content/editor/content-field.component.ts

@ -20,7 +20,7 @@ export class ContentFieldComponent implements OnChanges {
public languageChange = new EventEmitter<AppLanguageDto>(); public languageChange = new EventEmitter<AppLanguageDto>();
@Input() @Input()
public compact = false; public isCompact = false;
@Input() @Input()
public form: EditContentForm; public form: EditContentForm;
@ -52,7 +52,7 @@ export class ContentFieldComponent implements OnChanges {
} }
public get isHalfWidth() { public get isHalfWidth() {
return this.formModel.field.properties.isHalfWidth && !this.compact && !this.formCompare; return this.formModel.field.properties.isHalfWidth && !this.isCompact && !this.formCompare;
} }
public showAllControls = false; public showAllControls = false;

6
frontend/app/features/content/pages/content/content-section.component.html → frontend/app/features/content/pages/content/editor/content-section.component.html

@ -3,7 +3,7 @@
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col-auto"> <div class="col-auto">
<button type="button" class="btn btn-sm btn-text-secondary" (click)="toggle()"> <button type="button" class="btn btn-sm btn-text-secondary" (click)="toggle()">
<i [class.icon-caret-right]="isCollapsed" [class.icon-caret-down]="!isCollapsed"></i> <i [class.icon-caret-right]="snapshot.isCollapsed" [class.icon-caret-down]="!snapshot.isCollapsed"></i>
</button> </button>
</div> </div>
<div class="col"> <div class="col">
@ -17,10 +17,10 @@
</div> </div>
</ng-container> </ng-container>
<div class="row small-gutters" [class.hidden]="isCollapsed && !formCompare"> <div class="row small-gutters" [class.hidden]="snapshot.isCollapsed && !formCompare">
<sqx-content-field *ngFor="let field of formSection.fields; trackBy: trackByField" <sqx-content-field *ngFor="let field of formSection.fields; trackBy: trackByField"
(languageChange)="languageChange.emit($event)" (languageChange)="languageChange.emit($event)"
[compact]="compact" [isCompact]="isCompact"
[form]="form" [form]="form"
[formCompare]="formCompare" [formCompare]="formCompare"
[formContext]="formContext" [formContext]="formContext"

0
frontend/app/features/content/pages/content/content-section.component.scss → frontend/app/features/content/pages/content/editor/content-section.component.scss

35
frontend/app/features/content/pages/content/content-section.component.ts → frontend/app/features/content/pages/content/editor/content-section.component.ts

@ -5,8 +5,13 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { AppLanguageDto, EditContentForm, FieldForm, FieldSection, LocalStoreService, RootFieldDto, SchemaDto, Settings } from '@app/shared'; import { AppLanguageDto, EditContentForm, FieldForm, FieldSection, LocalStoreService, RootFieldDto, SchemaDto, Settings, StatefulComponent } from '@app/shared';
interface State {
// The when the section is collapsed.
isCollapsed: boolean;
}
@Component({ @Component({
selector: 'sqx-content-section', selector: 'sqx-content-section',
@ -14,12 +19,12 @@ import { AppLanguageDto, EditContentForm, FieldForm, FieldSection, LocalStoreSer
templateUrl: './content-section.component.html', templateUrl: './content-section.component.html',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ContentSectionComponent implements OnChanges { export class ContentSectionComponent extends StatefulComponent<State> implements OnChanges {
@Output() @Output()
public languageChange = new EventEmitter<AppLanguageDto>(); public languageChange = new EventEmitter<AppLanguageDto>();
@Input() @Input()
public compact = false; public isCompact = false;
@Input() @Input()
public form: EditContentForm; public form: EditContentForm;
@ -42,21 +47,29 @@ export class ContentSectionComponent implements OnChanges {
@Input() @Input()
public languages: ReadonlyArray<AppLanguageDto>; public languages: ReadonlyArray<AppLanguageDto>;
public isCollapsed: boolean; constructor(changeDetector: ChangeDetectorRef,
constructor(
private readonly localStore: LocalStoreService private readonly localStore: LocalStoreService
) { ) {
super(changeDetector, {
isCollapsed: false
});
this.changes.subscribe(state => {
this.localStore.setBoolean(this.configKey(), state.isCollapsed);
});
} }
public ngOnChanges() { public ngOnChanges() {
this.isCollapsed = this.localStore.getBoolean(this.configKey()); const isCollapsed = this.localStore.getBoolean(this.configKey());
this.next({ isCollapsed });
} }
public toggle() { public toggle() {
this.isCollapsed = !this.isCollapsed; this.next(s => ({
...s,
this.localStore.setBoolean(this.configKey(), this.isCollapsed); isCollapsed: !s.isCollapsed
}));
} }
public getFieldFormCompare(formState: FieldForm) { public getFieldFormCompare(formState: FieldForm) {

0
frontend/app/features/content/pages/content/field-languages.component.html → frontend/app/features/content/pages/content/editor/field-languages.component.html

0
frontend/app/features/content/pages/content/editor/field-languages.component.scss

0
frontend/app/features/content/pages/content/field-languages.component.ts → frontend/app/features/content/pages/content/editor/field-languages.component.ts

20
frontend/app/features/content/pages/content/references/content-references.component.html

@ -0,0 +1,20 @@
<sqx-list-view [isLoading]="contentsState.isLoading | async" table="true">
<ng-container content>
<table class="table table-items table-fixed" *ngIf="contentsState.contents | async; let references">
<tbody *ngFor="let reference of references; trackBy: trackByContent"
[sqxReferenceItem]="reference"
[canRemove]="false"
[columns]="references | sqxContentsColumns"
[isCompact]="false"
[isDisabled]="false"
[validations]="contentsState.validationResults | async"
[validityVisible]="true"
[language]="language">
</tbody>
</table>
</ng-container>
<ng-container footer>
<sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager>
</ng-container>
</sqx-list-view>

10
frontend/app/features/content/pages/content/references/content-references.component.scss

@ -0,0 +1,10 @@
:host {
display: flex;
flex-direction: column;
flex-grow: 1;
max-height: 100%;
}
.table {
margin: 1rem 0;
}

57
frontend/app/features/content/pages/content/references/content-references.component.ts

@ -0,0 +1,57 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { AppLanguageDto, ContentDto, ManualContentsState } from '@app/shared';
@Component({
selector: 'sqx-content-references',
styleUrls: ['./content-references.component.scss'],
templateUrl: './content-references.component.html',
providers: [
ManualContentsState
]
})
export class ContentReferencesComponent implements OnChanges {
@Input()
public content: ContentDto;
@Input()
public language: AppLanguageDto;
@Input()
public mode: 'references' | 'referencing' = 'references';
constructor(
public readonly contentsState: ManualContentsState
) {
}
public ngOnChanges(changes: SimpleChanges) {
if (changes['content'] || changes['mode']) {
this.contentsState.schema = { name: this.content.schemaName };
if (this.mode === 'references') {
this.contentsState.loadReference(this.content.id);
} else {
this.contentsState.loadReferencing(this.content.id);
}
}
}
public validate() {
this.contentsState.validate(this.contentsState.snapshot.contents);
}
public publish() {
this.contentsState.changeManyStatus(this.contentsState.snapshot.contents.filter(x => x.canPublish), 'Published');
}
public trackByContent(_index: number, content: ContentDto) {
return content.id;
}
}

6
frontend/app/features/content/pages/contents/contents-filters-page.component.html

@ -6,7 +6,7 @@
<ng-container content> <ng-container content>
<sqx-query-list <sqx-query-list
[types]="'common.contents' | sqxTranslate" [types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.contentsQuery | async" [queryUsed]="contentsState.query | async"
[queries]="schemaQueries.defaultQueries" [queries]="schemaQueries.defaultQueries"
(search)="search($event)"> (search)="search($event)">
</sqx-query-list> </sqx-query-list>
@ -18,7 +18,7 @@
<sqx-query-list <sqx-query-list
[types]="'common.contents' | sqxTranslate" [types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.contentsQuery | async" [queryUsed]="contentsState.query | async"
[queries]="contentsState.statusQueries | async" [queries]="contentsState.statusQueries | async"
(search)="search($event)"> (search)="search($event)">
</sqx-query-list> </sqx-query-list>
@ -28,7 +28,7 @@
<sqx-shared-queries <sqx-shared-queries
[types]="'common.contents' | sqxTranslate" [types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.contentsQuery | async" [queryUsed]="contentsState.query | async"
[queries]="schemaQueries" [queries]="schemaQueries"
(search)="search($event)"> (search)="search($event)">
</sqx-shared-queries> </sqx-shared-queries>

8
frontend/app/features/content/pages/contents/contents-page.component.html

@ -8,7 +8,7 @@
<ng-container menu> <ng-container menu>
<div class="row no-gutters pl-1"> <div class="row no-gutters pl-1">
<div class="col-auto ml-8"> <div class="col-auto ml-8">
<sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{contentsState.schemaId}}/contents"></sqx-notifo> <sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{schema?.id}}/contents"></sqx-notifo>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut> <sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
@ -19,7 +19,7 @@
<div class="col pl-1"> <div class="col pl-1">
<sqx-search-form formClass="form" placeholder="{{ 'contents.searchPlaceholder' | sqxTranslate }}" <sqx-search-form formClass="form" placeholder="{{ 'contents.searchPlaceholder' | sqxTranslate }}"
(queryChange)="search($event)" (queryChange)="search($event)"
[query]="contentsState.contentsQuery | async" [query]="contentsState.query | async"
[queries]="queries" [queries]="queries"
[queryModel]="queryModel" [queryModel]="queryModel"
[language]="languageMaster" enableShortcut="true"> [language]="languageMaster" enableShortcut="true">
@ -97,7 +97,7 @@
<sqx-content-list-header <sqx-content-list-header
[field]="field" [field]="field"
(queryChange)="search($event)" (queryChange)="search($event)"
[query]="contentsState.contentsQuery | async" [query]="contentsState.query | async"
[language]="language"> [language]="language">
</sqx-content-list-header> </sqx-content-list-header>
</th> </th>
@ -126,7 +126,7 @@
</ng-container> </ng-container>
<ng-container footer> <ng-container footer>
<sqx-pager [pager]="contentsState.contentsPager | async" (pagerChange)="contentsState.setPager($event)"></sqx-pager> <sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

2
frontend/app/features/content/pages/sidebar/sidebar-page.component.ts

@ -79,7 +79,7 @@ export class SidebarPageComponent extends ResourceOwner implements AfterViewInit
} else if (type === 'resize') { } else if (type === 'resize') {
const { height } = event.data; const { height } = event.data;
this.iframe.nativeElement.height = height + 'px'; this.iframe.nativeElement.height = `${height}px`;
} else if (type === 'navigate') { } else if (type === 'navigate') {
const { url } = event.data; const { url } = event.data;

6
frontend/app/features/content/shared/forms/array-item.component.html

@ -23,10 +23,10 @@
<button type="button" class="btn btn-text-secondary" [disabled]="isDisabled || isLast" (click)="moveBottom()" title="i18n:contents.arrayMoveBottom"> <button type="button" class="btn btn-text-secondary" [disabled]="isDisabled || isLast" (click)="moveBottom()" title="i18n:contents.arrayMoveBottom">
<i class="icon-caret-bottom"></i> <i class="icon-caret-bottom"></i>
</button> </button>
<button type="button" class="btn btn-text-secondary" [class.hidden]="!isCollapsed" (click)="expand()" title="i18n:contents.arrayExpandItem"> <button type="button" class="btn btn-text-secondary" [class.hidden]="!snapshot.isCollapsed" (click)="expand()" title="i18n:contents.arrayExpandItem">
<i class="icon-plus-square"></i> <i class="icon-plus-square"></i>
</button> </button>
<button type="button" class="btn btn-text-secondary" [class.hidden]="isCollapsed" (click)="collapse()" title="i18n:contents.arrayCollapseItem"> <button type="button" class="btn btn-text-secondary" [class.hidden]="snapshot.isCollapsed" (click)="collapse()" title="i18n:contents.arrayCollapseItem">
<i class="icon-minus-square"></i> <i class="icon-minus-square"></i>
</button> </button>
</div> </div>
@ -42,7 +42,7 @@
</div> </div>
</div> </div>
<div class="card-body" [class.hidden]="isCollapsed"> <div class="card-body" [class.hidden]="snapshot.isCollapsed">
<div class="form-group" *ngFor="let section of formModel.sections"> <div class="form-group" *ngFor="let section of formModel.sections">
<sqx-array-section <sqx-array-section
[canUnset]="canUnset" [canUnset]="canUnset"

23
frontend/app/features/content/shared/forms/array-item.component.ts

@ -6,19 +6,24 @@
*/ */
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
import { AppLanguageDto, EditContentForm, FieldArrayItemForm, FieldArrayItemValueForm, FieldFormatter, FieldSection, invalid$, NestedFieldDto, value$ } from '@app/shared'; import { AppLanguageDto, EditContentForm, FieldArrayItemForm, FieldArrayItemValueForm, FieldFormatter, FieldSection, invalid$, NestedFieldDto, StatefulComponent, value$ } from '@app/shared';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ArraySectionComponent } from './array-section.component'; import { ArraySectionComponent } from './array-section.component';
import { FieldEditorComponent } from './field-editor.component'; import { FieldEditorComponent } from './field-editor.component';
interface State {
// The when the section is collapsed.
isCollapsed: boolean;
}
@Component({ @Component({
selector: 'sqx-array-item', selector: 'sqx-array-item',
styleUrls: ['./array-item.component.scss'], styleUrls: ['./array-item.component.scss'],
templateUrl: './array-item.component.html', templateUrl: './array-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ArrayItemComponent implements OnChanges { export class ArrayItemComponent extends StatefulComponent<State> implements OnChanges {
@Output() @Output()
public remove = new EventEmitter(); public remove = new EventEmitter();
@ -66,9 +71,11 @@ export class ArrayItemComponent implements OnChanges {
public title: Observable<string>; public title: Observable<string>;
constructor( constructor(changeDetector: ChangeDetectorRef
private readonly changeDetector: ChangeDetectorRef
) { ) {
super(changeDetector, {
isCollapsed: false
});
} }
public ngOnChanges(changes: SimpleChanges) { public ngOnChanges(changes: SimpleChanges) {
@ -98,15 +105,11 @@ export class ArrayItemComponent implements OnChanges {
} }
public collapse() { public collapse() {
this.isCollapsed = true; this.next({ isCollapsed: true });
this.changeDetector.markForCheck();
} }
public expand() { public expand() {
this.isCollapsed = false; this.next({ isCollapsed: false });
this.changeDetector.markForCheck();
} }
public moveTop() { public moveTop() {

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

@ -46,8 +46,6 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AssetsEditorComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnInit { export class AssetsEditorComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnInit {
public isCompact = false;
public assetsDialog = new DialogModel(); public assetsDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef, constructor(changeDetector: ChangeDetectorRef,
@ -99,16 +97,19 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
} }
public setCompact(isCompact: boolean) { public setCompact(isCompact: boolean) {
this.next(s => ({ ...s, isCompact: isCompact })); this.next({ isCompact });
} }
public setAssets(assets: ReadonlyArray<AssetDto>) { public setAssets(assets: ReadonlyArray<AssetDto>) {
this.next(s => ({ ...s, assets })); this.next({ assets });
} }
public addFiles(files: ReadonlyArray<File>) { public addFiles(files: ReadonlyArray<File>) {
for (const file of files) { for (const file of files) {
this.next(s => ({ ...s, assetFiles: [file, ...s.assetFiles] })); this.next(s => ({
...s,
assetFiles: [file, ...s.assetFiles]
}));
} }
} }
@ -151,11 +152,14 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
} }
public removeLoadingAsset(file: File) { public removeLoadingAsset(file: File) {
this.next(s => ({ ...s, assetFiles: s.assetFiles.removed(file) })); this.next(s => ({
...s, assetFiles:
s.assetFiles.removed(file)
}));
} }
public changeView(isListView: boolean) { public changeView(isListView: boolean) {
this.next(s => ({ ...s, isListView })); this.next({ isListView });
this.localStore.setBoolean('squidex.assets.list-view', isListView); this.localStore.setBoolean('squidex.assets.list-view', isListView);
} }

14
frontend/app/features/content/shared/forms/stock-photo-editor.component.ts

@ -11,6 +11,12 @@ import { StatefulControlComponent, StockPhotoDto, StockPhotoService, thumbnail,
import { of } from 'rxjs'; import { of } from 'rxjs';
import { debounceTime, map, switchMap, tap } from 'rxjs/operators'; import { debounceTime, map, switchMap, tap } from 'rxjs/operators';
export const SQX_STOCK_PHOTO_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StockPhotoEditorComponent), multi: true
};
const NO_EMIT = { emitEvent: false };
interface State { interface State {
// True when loading assets. // True when loading assets.
isLoading?: boolean; isLoading?: boolean;
@ -19,12 +25,6 @@ interface State {
isCompact?: boolean; isCompact?: boolean;
} }
export const SQX_STOCK_PHOTO_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StockPhotoEditorComponent), multi: true
};
const NO_EMIT = { emitEvent: false };
@Component({ @Component({
selector: 'sqx-stock-photo-editor', selector: 'sqx-stock-photo-editor',
styleUrls: ['./stock-photo-editor.component.scss'], styleUrls: ['./stock-photo-editor.component.scss'],
@ -93,7 +93,7 @@ export class StockPhotoEditorComponent extends StatefulControlComponent<State, s
} }
public setCompact(isCompact: boolean) { public setCompact(isCompact: boolean) {
this.next(s => ({ ...s, isCompact: isCompact })); this.next({ isCompact });
} }
public selectPhoto(photo: StockPhotoDto) { public selectPhoto(photo: StockPhotoDto) {

18
frontend/app/features/content/shared/list/content-list-cell.directive.ts

@ -6,7 +6,7 @@
*/ */
import { Directive, ElementRef, Input, OnChanges, Pipe, PipeTransform, Renderer2 } from '@angular/core'; import { Directive, ElementRef, Input, OnChanges, Pipe, PipeTransform, Renderer2 } from '@angular/core';
import { MetaFields, RootFieldDto, TableField, Types } from '@app/shared'; import { ContentDto, MetaFields, RootFieldDto, TableField, Types } from '@app/shared';
export function getTableWidth(fields: ReadonlyArray<TableField>) { export function getTableWidth(fields: ReadonlyArray<TableField>) {
let result = 0; let result = 0;
@ -51,6 +51,22 @@ export function getCellWidth(field: TableField) {
} }
} }
@Pipe({
name: 'sqxContentsColumns',
pure: true
})
export class ContentsColumnsPipe implements PipeTransform {
public transform(value: ReadonlyArray<ContentDto>) {
let columns = 1;
for (const content of value) {
columns = Math.max(columns, content.referenceFields.length);
}
return columns;
}
}
@Pipe({ @Pipe({
name: 'sqxContentListWidth', name: 'sqxContentListWidth',
pure: true pure: true

2
frontend/app/features/content/shared/list/content-list-field.component.html

@ -99,7 +99,7 @@
</ng-container> </ng-container>
<ng-template #displayTemplate> <ng-template #displayTemplate>
<sqx-content-value [value]="value"></sqx-content-value> <sqx-content-value [value]="snapshot.formatted"></sqx-content-value>
</ng-template> </ng-template>
</ng-container> </ng-container>
</ng-container> </ng-container>

19
frontend/app/features/content/shared/list/content-list-field.component.ts

@ -5,9 +5,14 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { ContentDto, getContentValue, LanguageDto, MetaFields, RootFieldDto, TableField, Types } from '@app/shared'; import { ContentDto, FieldValue, getContentValue, LanguageDto, MetaFields, RootFieldDto, StatefulComponent, TableField, Types } from '@app/shared';
interface State {
// The formatted value.
formatted: FieldValue;
}
@Component({ @Component({
selector: 'sqx-content-list-field', selector: 'sqx-content-list-field',
@ -15,7 +20,7 @@ import { ContentDto, getContentValue, LanguageDto, MetaFields, RootFieldDto, Tab
templateUrl: './content-list-field.component.html', templateUrl: './content-list-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ContentListFieldComponent implements OnChanges { export class ContentListFieldComponent extends StatefulComponent<State> implements OnChanges {
@Input() @Input()
public field: TableField; public field: TableField;
@ -31,7 +36,11 @@ export class ContentListFieldComponent implements OnChanges {
@Input() @Input()
public language: LanguageDto; public language: LanguageDto;
public value: any; constructor(changeDetector: ChangeDetectorRef) {
super(changeDetector, {
formatted: ''
});
}
public ngOnChanges() { public ngOnChanges() {
this.reset(); this.reset();
@ -49,7 +58,7 @@ export class ContentListFieldComponent implements OnChanges {
} }
} }
this.value = formatted; this.next({ formatted });
} }
} }

2
frontend/app/features/content/shared/list/content-value-editor.component.ts

@ -22,5 +22,5 @@ export class ContentValueEditorComponent {
@Input() @Input()
public form: FormGroup; public form: FormGroup;
public uniqueId = MathHelper.guid(); public readonly uniqueId = MathHelper.guid();
} }

2
frontend/app/features/content/shared/references/content-creator.component.html

@ -38,7 +38,7 @@
<form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()"> <form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()">
<sqx-content-section *ngFor="let section of contentForm.sections" <sqx-content-section *ngFor="let section of contentForm.sections"
[(language)]="language" [(language)]="language"
[compact]="true" [isCompact]="true"
[form]="contentForm" [form]="contentForm"
[formContext]="contentFormContext" [formContext]="contentFormContext"
[formSection]="section" [formSection]="section"

6
frontend/app/features/content/shared/references/content-selector.component.html

@ -21,7 +21,7 @@
</div> </div>
<div class="col pl-1"> <div class="col pl-1">
<sqx-search-form formClass="form" placeholder="{{ 'contents.searchPlaceholder' | sqxTranslate }}" <sqx-search-form formClass="form" placeholder="{{ 'contents.searchPlaceholder' | sqxTranslate }}"
[query]="contentsState.contentsQuery | async" [query]="contentsState.query | async"
[queryModel]="queryModel" [queryModel]="queryModel"
(queryChange)="search($event)"> (queryChange)="search($event)">
</sqx-search-form> </sqx-search-form>
@ -54,7 +54,7 @@
<th *ngFor="let field of schema.defaultReferenceFields" [sqxContentListCell]="field"> <th *ngFor="let field of schema.defaultReferenceFields" [sqxContentListCell]="field">
<sqx-content-list-header <sqx-content-list-header
[field]="field" [field]="field"
[query]="contentsState.contentsQuery | async" [query]="contentsState.query | async"
(queryChange)="search($event)" (queryChange)="search($event)"
[language]="language"> [language]="language">
</sqx-content-list-header> </sqx-content-list-header>
@ -83,7 +83,7 @@
</ng-container> </ng-container>
<ng-container footer> <ng-container footer>
<sqx-pager [pager]="contentsState.contentsPager | async" (pagerChange)="contentsState.setPager($event)"></sqx-pager> <sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

7
frontend/app/features/content/shared/references/reference-item.component.html

@ -11,6 +11,11 @@
<sqx-content-value [value]="value"></sqx-content-value> <sqx-content-value [value]="value"></sqx-content-value>
</td> </td>
<td class="cell-valid" *ngIf="validityVisible">
<span class="badge badge-pill badge-success" *ngIf="valid === true">VALID</span>
<span class="badge badge-pill badge-danger" *ngIf="valid === false">INVALID</span>
</td>
<td sqxContentListCell="meta.status.color"> <td sqxContentListCell="meta.status.color">
<sqx-content-list-field field="meta.status.color" [content]="content"></sqx-content-list-field> <sqx-content-list-field field="meta.status.color" [content]="content"></sqx-content-list-field>
</td> </td>
@ -30,7 +35,7 @@
<i class="icon-pencil"></i> <i class="icon-pencil"></i>
</a> </a>
<button type="button" class="btn btn-text-secondary" <button type="button" class="btn btn-text-secondary" *ngIf="canRemove"
(sqxConfirmClick)="delete.emit()" (sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:contents.removeConfirmTitle" confirmTitle="i18n:contents.removeConfirmTitle"
confirmText="i18n:contents.removeConfirmText" confirmText="i18n:contents.removeConfirmText"

5
frontend/app/features/content/shared/references/reference-item.component.scss

@ -7,12 +7,17 @@
.reference-menu { .reference-menu {
@include absolute(0, -.25rem, auto, auto); @include absolute(0, -.25rem, auto, auto);
background: $color-table-background; background: $color-table-background;
border: 0;
border-left: 1px solid $color-border; border-left: 1px solid $color-border;
padding-left: 1rem; padding-left: 1rem;
white-space: nowrap; white-space: nowrap;
} }
} }
.cell-valid {
width: 100px;
}
.badge { .badge {
@include truncate; @include truncate;
} }

17
frontend/app/features/content/shared/references/reference-item.component.ts

@ -23,25 +23,34 @@ export class ReferenceItemComponent implements OnChanges {
@Input() @Input()
public language: AppLanguageDto; public language: AppLanguageDto;
@Input()
public canRemove = true;
@Input() @Input()
public isCompact = false; public isCompact = false;
@Input() @Input()
public isDisabled = false; public isDisabled = false;
@Input()
public validations: { [id: string]: boolean };
@Input()
public validityVisible: boolean;
@Input() @Input()
public columns = 0; public columns = 0;
@Input('sqxReferenceItem') @Input('sqxReferenceItem')
public content: ContentDto; public content: ContentDto;
public get valid() {
return !this.validations ? undefined : this.validations[this.content.id];
}
public values: ReadonlyArray<any> = []; public values: ReadonlyArray<any> = [];
public ngOnChanges() { public ngOnChanges() {
this.updateValues();
}
private updateValues() {
const values = []; const values = [];
for (let i = 0; i < this.columns; i++) { for (let i = 0; i < this.columns; i++) {

2
frontend/app/features/content/shared/references/references-editor.component.html

@ -23,7 +23,7 @@
class="table-drag" class="table-drag"
cdkDrag cdkDrag
cdkDragLockAxis="y" cdkDragLockAxis="y"
[columns]="snapshot.columns" [columns]="snapshot.contentItems | sqxContentsColumns"
[isCompact]="snapshot.isCompact" [isCompact]="snapshot.isCompact"
[isDisabled]="snapshot.isDisabled" [isDisabled]="snapshot.isDisabled"
[language]="language" [language]="language"

15
frontend/app/features/content/shared/references/references-editor.component.ts

@ -18,9 +18,6 @@ interface State {
// The content items to show. // The content items to show.
contentItems: ReadonlyArray<ContentDto>; contentItems: ReadonlyArray<ContentDto>;
// The maximum number of columns.
columns: number;
// True, when width less than 600 pixels. // True, when width less than 600 pixels.
isCompact?: boolean; isCompact?: boolean;
} }
@ -54,7 +51,7 @@ export class ReferencesEditorComponent extends StatefulControlComponent<State, R
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly contentsService: ContentsService private readonly contentsService: ContentsService
) { ) {
super(changeDetector, { contentItems: [], columns: 0 }); super(changeDetector, { contentItems: [] });
} }
public writeValue(obj: any) { public writeValue(obj: any) {
@ -79,13 +76,7 @@ export class ReferencesEditorComponent extends StatefulControlComponent<State, R
} }
public setContentItems(contentItems: ReadonlyArray<ContentDto>) { public setContentItems(contentItems: ReadonlyArray<ContentDto>) {
let columns = 1; this.next({ contentItems });
for (const content of contentItems) {
columns = Math.max(columns, content.referenceFields.length);
}
this.next(s => ({ ...s, contentItems, columns }));
} }
public select(contents: ReadonlyArray<ContentDto>) { public select(contents: ReadonlyArray<ContentDto>) {
@ -128,7 +119,7 @@ export class ReferencesEditorComponent extends StatefulControlComponent<State, R
} }
public setCompact(isCompact: boolean) { public setCompact(isCompact: boolean) {
this.next(s => ({ ...s, isCompact })); this.next({ isCompact });
} }
public trackByContent(_index: number, content: ContentDto) { public trackByContent(_index: number, content: ContentDto) {

2
frontend/app/features/dashboard/pages/cards/content-summary-card.component.html

@ -3,7 +3,7 @@
<div class="card-body"> <div class="card-body">
<div class="aggregation"> <div class="aggregation">
<div class="aggregation-label">{{ 'dashboard.contentsSummaryCard' | sqxTranslate }}</div> <div class="aggregation-label">{{ 'dashboard.contentsSummaryCard' | sqxTranslate }}</div>
<div class="aggregation-value">{{itemCount}}</div> <div class="aggregation-value">{{snapshot.itemCount}}</div>
</div> </div>
</div> </div>
</div> </div>

25
frontend/app/features/dashboard/pages/cards/content-summary-card.component.ts

@ -6,7 +6,12 @@
*/ */
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { AppDto, ContentsService, fadeAnimation, Types } from '@app/shared'; import { AppDto, ContentsService, fadeAnimation, StatefulComponent, Types } from '@app/shared';
interface State {
// The number of items.
itemCount: number;
}
@Component({ @Component({
selector: 'sqx-content-summary-card', selector: 'sqx-content-summary-card',
@ -17,19 +22,19 @@ import { AppDto, ContentsService, fadeAnimation, Types } from '@app/shared';
], ],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ContentSummaryCardComponent implements OnInit { export class ContentSummaryCardComponent extends StatefulComponent<State> implements OnInit {
@Input() @Input()
public app: AppDto; public app: AppDto;
@Input() @Input()
public options: any; public options: any;
public itemCount = 0; constructor(changeDetector: ChangeDetectorRef,
constructor(
private readonly changeDetector: ChangeDetectorRef,
private readonly contentsService: ContentsService private readonly contentsService: ContentsService
) { ) {
super(changeDetector, {
itemCount: 0
});
} }
public ngOnInit() { public ngOnInit() {
@ -46,13 +51,11 @@ export class ContentSummaryCardComponent implements OnInit {
query.take = 0; query.take = 0;
this.contentsService.getContents(this.app.name, this.options.schema, { query }) this.contentsService.getContents(this.app.name, this.options.schema, { query })
.subscribe(dto => { .subscribe(({ total: itemCount }) => {
this.itemCount = dto.total; this.next({ itemCount });
this.changeDetector.detectChanges();
}, },
() => { () => {
this.itemCount = 0; this.next({ itemCount: 0 });
}); });
} }
} }

2
frontend/app/features/rules/pages/events/rule-events-page.component.html

@ -92,7 +92,7 @@
</tbody> </tbody>
</table> </table>
<sqx-pager [autoHide]="true" [pager]="ruleEventsState.ruleEventsPager | async" (pagerChange)="ruleEventsState.setPager($event)"></sqx-pager> <sqx-pager [autoHide]="true" [paging]="ruleEventsState.paging | async" (pagingChange)="ruleEventsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

7
frontend/app/features/settings/pages/contributors/contributors-page.component.html

@ -41,11 +41,12 @@
</ng-container> </ng-container>
<div content> <div content>
<ng-container *ngIf="contributorsState.contributorsPaged | async; let contributors"> <ng-container *ngIf="contributorsState.contributorsFiltered | async; let contributors">
<ng-container *ngIf="rolesState.roles | async; let roles"> <ng-container *ngIf="rolesState.roles | async; let roles">
<ng-container *ngIf="contributors.length > 0; else noContributors"> <ng-container *ngIf="contributors.length > 0; else noContributors">
<table class="table table-items table-fixed"> <table class="table table-items table-fixed">
<tbody *ngFor="let contributor of contributors; trackBy: trackByContributor" [sqxContributor]="contributor" [search]="contributorsState.queryRegex | async" [roles]="roles"> <tbody *ngFor="let contributor of contributors; trackBy: trackByContributor" [sqxContributor]="contributor"
[search]="contributorsState.queryRegex | async" [roles]="roles">
</tbody> </tbody>
</table> </table>
</ng-container> </ng-container>
@ -60,7 +61,7 @@
</div> </div>
<ng-container footer> <ng-container footer>
<sqx-pager [pager]="contributorsState.contributorsPager | async" (pagerChange)="contributorsState.setPager($event)"></sqx-pager> <sqx-pager [paging]="contributorsState.paging | async" (pagingChange)="contributorsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

20
frontend/app/framework/angular/forms/control-errors.component.ts

@ -28,7 +28,7 @@ interface State {
export class ControlErrorsComponent extends StatefulComponent<State> implements OnChanges, OnDestroy { export class ControlErrorsComponent extends StatefulComponent<State> implements OnChanges, OnDestroy {
private displayFieldName: string; private displayFieldName: string;
private control: AbstractControl; private control: AbstractControl;
private originalMarkAsTouched: any; private controlOriginalMarkAsTouched: any;
@Input() @Input()
public for: string | AbstractControl; public for: string | AbstractControl;
@ -95,12 +95,12 @@ export class ControlErrorsComponent extends StatefulComponent<State> implements
this.createMessages(); this.createMessages();
})); }));
this.originalMarkAsTouched = this.control.markAsTouched; this.controlOriginalMarkAsTouched = this.control.markAsTouched;
const self = this; const self = this;
this.control['markAsTouched'] = function () { this.control['markAsTouched'] = function () {
self.originalMarkAsTouched.apply(this, arguments); self.controlOriginalMarkAsTouched.apply(this, arguments);
self.createMessages(); self.createMessages();
}; };
@ -111,13 +111,13 @@ export class ControlErrorsComponent extends StatefulComponent<State> implements
} }
private unsetCustomMarkAsTouchedFunction() { private unsetCustomMarkAsTouchedFunction() {
if (this.control && this.originalMarkAsTouched) { if (this.control && this.controlOriginalMarkAsTouched) {
this.control['markAsTouched'] = this.originalMarkAsTouched; this.control['markAsTouched'] = this.controlOriginalMarkAsTouched;
} }
} }
private createMessages() { private createMessages() {
const errors: string[] = []; const errorMessages: string[] = [];
if (this.control && this.control.invalid && this.isTouched && this.control.errors) { if (this.control && this.control.invalid && this.isTouched && this.control.errors) {
for (const key in <any>this.control.errors) { for (const key in <any>this.control.errors) {
@ -125,18 +125,16 @@ export class ControlErrorsComponent extends StatefulComponent<State> implements
const message = formatError(this.localizer, this.displayFieldName, key, this.control.errors[key], this.control.value); const message = formatError(this.localizer, this.displayFieldName, key, this.control.errors[key], this.control.value);
if (Types.isString(message)) { if (Types.isString(message)) {
errors.push(message); errorMessages.push(message);
} else if (Types.isArray(message)) { } else if (Types.isArray(message)) {
for (const error of message) { for (const error of message) {
errors.push(error); errorMessages.push(error);
} }
} }
} }
} }
} }
if (errors.length !== this.snapshot.errorMessages.length || errors.length > 0) { this.next({ errorMessages });
this.next(s => ({ ...s, errorMessages: errors }));
}
} }
} }

6
frontend/app/framework/angular/forms/editors/autocomplete.component.ts

@ -231,10 +231,10 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
clearTimeout(this.timer); clearTimeout(this.timer);
if (value) { if (value) {
this.next(s => ({ ...s, isLoading: true })); this.next({ isLoading: true });
} else { } else {
this.timer = setTimeout(() => { this.timer = setTimeout(() => {
this.next(s => ({ ...s, isLoading: false })); this.next({ isLoading: false });
}, 250); }, 250);
} }
} }
@ -248,7 +248,7 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
suggestedIndex = this.snapshot.suggestedItems.length - 1; suggestedIndex = this.snapshot.suggestedItems.length - 1;
} }
this.next(s => ({ ...s, suggestedIndex })); this.next({ suggestedIndex });
} }
private up() { private up() {

4
frontend/app/framework/angular/forms/editors/checkbox-group.component.ts

@ -153,7 +153,7 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
checkedValues = this.valuesSorted.filter(x => obj.indexOf(x.value) >= 0); checkedValues = this.valuesSorted.filter(x => obj.indexOf(x.value) >= 0);
} }
this.next(s => ({ ...s, checkedValues })); this.next({ checkedValues });
} }
public check(isChecked: boolean, value: TagValue) { public check(isChecked: boolean, value: TagValue) {
@ -165,7 +165,7 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
checkedValues = checkedValues.removed(value); checkedValues = checkedValues.removed(value);
} }
this.next(s => ({ ...s, checkedValues })); this.next({ checkedValues });
this.callChange(checkedValues.map(x => x.id)); this.callChange(checkedValues.map(x => x.id));
} }

9
frontend/app/framework/angular/forms/editors/code-editor.component.ts

@ -27,11 +27,10 @@ export const SQX_CODE_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
], ],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CodeEditorComponent extends StatefulControlComponent<undefined, string> implements AfterViewInit, FocusComponent { export class CodeEditorComponent extends StatefulControlComponent<{}, string> implements AfterViewInit, FocusComponent {
private valueChanged = new Subject(); private valueChanged = new Subject();
private aceEditor: any; private aceEditor: any;
private value: string; private value: string;
private isDisabled = false;
@ViewChild('editor', { static: false }) @ViewChild('editor', { static: false })
public editor: ElementRef; public editor: ElementRef;
@ -48,7 +47,7 @@ export class CodeEditorComponent extends StatefulControlComponent<undefined, str
constructor(changeDetector: ChangeDetectorRef, constructor(changeDetector: ChangeDetectorRef,
private readonly resourceLoader: ResourceLoaderService private readonly resourceLoader: ResourceLoaderService
) { ) {
super(changeDetector, undefined); super(changeDetector, {});
} }
public writeValue(obj: string) { public writeValue(obj: string) {
@ -60,7 +59,7 @@ export class CodeEditorComponent extends StatefulControlComponent<undefined, str
} }
public setDisabledState(isDisabled: boolean): void { public setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled; super.setDisabledState(isDisabled);
if (this.aceEditor) { if (this.aceEditor) {
this.aceEditor.setReadOnly(isDisabled); this.aceEditor.setReadOnly(isDisabled);
@ -87,7 +86,7 @@ export class CodeEditorComponent extends StatefulControlComponent<undefined, str
this.aceEditor = ace.edit(this.editor.nativeElement); this.aceEditor = ace.edit(this.editor.nativeElement);
this.aceEditor.getSession().setMode(this.mode); this.aceEditor.getSession().setMode(this.mode);
this.aceEditor.setReadOnly(this.isDisabled); this.aceEditor.setReadOnly(this.snapshot.isDisabled);
this.aceEditor.setFontSize(14); this.aceEditor.setFontSize(14);
this.setValue(this.value); this.setValue(this.value);

8
frontend/app/framework/angular/forms/editors/color-picker.component.ts

@ -63,17 +63,17 @@ export class ColorPickerComponent extends StatefulControlComponent<State, string
}); });
} }
public writeValue(obj: any) { public writeValue(value: any) {
const previousColor = this.snapshot.value; const previousColor = this.snapshot.value;
if (previousColor !== obj) { if (previousColor !== value) {
let foreground = 'black'; let foreground = 'black';
if (MathHelper.toLuminance(MathHelper.parseColor(obj)!) < .5) { if (MathHelper.toLuminance(MathHelper.parseColor(value)!) < .5) {
foreground = 'white'; foreground = 'white';
} }
this.next(s => ({ ...s, value: obj, foreground })); this.next({ value, foreground });
} }
} }

4
frontend/app/framework/angular/forms/editors/date-time-editor.component.html

@ -2,10 +2,10 @@
<div class="form-inline"> <div class="form-inline">
<div class="form-group mr-1"> <div class="form-group mr-1">
<div *ngIf="!isCompact && isDateTimeMode && shouldShowDateTimeModeButton"> <div *ngIf="!isCompact && isDateTimeMode && shouldShowDateTimeModeButton">
<button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(false)" *ngIf="isLocalMode"> <button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(false)" *ngIf="snapshot.isLocal">
{{ 'common.dateTimeEditor.local' | sqxTranslate }} {{ 'common.dateTimeEditor.local' | sqxTranslate }}
</button> </button>
<button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(true)" *ngIf="!isLocalMode"> <button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(true)" *ngIf="!snapshot.isLocal">
{{ 'common.dateTimeEditor.utc' | sqxTranslate }} {{ 'common.dateTimeEditor.utc' | sqxTranslate }}
</button> </button>
</div> </div>

25
frontend/app/framework/angular/forms/editors/date-time-editor.component.ts

@ -20,6 +20,11 @@ export const SQX_DATE_TIME_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
const NO_EMIT = { emitEvent: false }; const NO_EMIT = { emitEvent: false };
interface State {
// True when the editor is in local mode.
isLocal: boolean;
}
@Component({ @Component({
selector: 'sqx-date-time-editor', selector: 'sqx-date-time-editor',
styleUrls: ['./date-time-editor.component.scss'], styleUrls: ['./date-time-editor.component.scss'],
@ -29,7 +34,7 @@ const NO_EMIT = { emitEvent: false };
], ],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class DateTimeEditorComponent extends StatefulControlComponent<{}, string | null> implements OnInit, AfterViewInit, FocusComponent { export class DateTimeEditorComponent extends StatefulControlComponent<State, string | null> implements OnInit, AfterViewInit, FocusComponent {
private readonly hideDateButtonsSettings: boolean; private readonly hideDateButtonsSettings: boolean;
private readonly hideDateTimeModeButtonSetting: boolean; private readonly hideDateTimeModeButtonSetting: boolean;
private picker: any; private picker: any;
@ -63,8 +68,6 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
public timeControl = new FormControl(); public timeControl = new FormControl();
public dateControl = new FormControl(); public dateControl = new FormControl();
public isLocalMode = true;
public get shouldShowDateButtons() { public get shouldShowDateButtons() {
return !this.hideDateButtonsSettings && !this.hideDateButtons; return !this.hideDateButtonsSettings && !this.hideDateButtons;
} }
@ -82,7 +85,9 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
} }
constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions) { constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions) {
super(changeDetector, {}); super(changeDetector, {
isLocal: false
});
this.hideDateButtonsSettings = !!uiOptions.get('hideDateButtons'); this.hideDateButtonsSettings = !!uiOptions.get('hideDateButtons');
this.hideDateTimeModeButtonSetting = !!uiOptions.get('hideDateTimeModeButton'); this.hideDateTimeModeButtonSetting = !!uiOptions.get('hideDateTimeModeButton');
@ -199,7 +204,7 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
if (this.isDateTimeMode && this.timeControl.value) { if (this.isDateTimeMode && this.timeControl.value) {
const combined = `${this.dateControl.value}T${this.timeControl.value}`; const combined = `${this.dateControl.value}T${this.timeControl.value}`;
return DateTime.tryParseISO(combined, !this.isLocalMode); return DateTime.tryParseISO(combined, !this.snapshot.isLocal);
} }
return DateTime.tryParseISO(this.dateControl.value); return DateTime.tryParseISO(this.dateControl.value);
@ -209,7 +214,7 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
this.suppressEvents = true; this.suppressEvents = true;
if (this.dateTime && this.isDateTimeMode) { if (this.dateTime && this.isDateTimeMode) {
if (this.isLocalMode) { if (this.snapshot.isLocal) {
this.timeControl.setValue(this.dateTime.toStringFormat('HH:mm:ss'), NO_EMIT); this.timeControl.setValue(this.dateTime.toStringFormat('HH:mm:ss'), NO_EMIT);
} else { } else {
this.timeControl.setValue(this.dateTime.toStringFormatUTC('HH:mm:ss'), NO_EMIT); this.timeControl.setValue(this.dateTime.toStringFormatUTC('HH:mm:ss'), NO_EMIT);
@ -221,7 +226,7 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
if (this.dateTime && this.picker) { if (this.dateTime && this.picker) {
let dateString: string; let dateString: string;
if (this.isDateTimeMode && this.isLocalMode) { if (this.isDateTimeMode && this.snapshot.isLocal) {
dateString = this.dateTime.toStringFormat('yyyy-MM-dd'); dateString = this.dateTime.toStringFormat('yyyy-MM-dd');
this.picker.setDate(DateHelper.getLocalDate(this.dateTime.raw), true); this.picker.setDate(DateHelper.getLocalDate(this.dateTime.raw), true);
@ -239,14 +244,14 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
this.suppressEvents = false; this.suppressEvents = false;
} }
public setLocalMode(isLocalMode: boolean) { public setLocalMode(isLocal: boolean) {
this.isLocalMode = isLocalMode; this.next({ isLocal });
this.updateControls(); this.updateControls();
} }
public setCompact(isCompact: boolean) { public setCompact(isCompact: boolean) {
this.next(s => ({ ...s, isCompact })); this.isCompact = isCompact;
} }
} }

6
frontend/app/framework/angular/forms/editors/dropdown.component.ts

@ -16,6 +16,8 @@ export const SQX_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => DropdownComponent), multi: true provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => DropdownComponent), multi: true
}; };
const NO_EMIT = { emitEvent: false };
interface State { interface State {
// The suggested item. // The suggested item.
suggestedItems: ReadonlyArray<any>; suggestedItems: ReadonlyArray<any>;
@ -27,8 +29,6 @@ interface State {
query?: RegExp; query?: RegExp;
} }
const NO_EMIT = { emitEvent: false };
@Component({ @Component({
selector: 'sqx-dropdown', selector: 'sqx-dropdown',
styleUrls: ['./dropdown.component.scss'], styleUrls: ['./dropdown.component.scss'],
@ -229,7 +229,7 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
} }
} }
this.next(s => ({ ...s, selectedIndex })); this.next({ selectedIndex });
} }
private getSelectedIndex(value: any) { private getSelectedIndex(value: any) {

9
frontend/app/framework/angular/forms/editors/iframe-editor.component.ts

@ -30,7 +30,6 @@ interface State {
}) })
export class IFrameEditorComponent extends StatefulControlComponent<State, any> implements OnChanges, OnDestroy, AfterViewInit { export class IFrameEditorComponent extends StatefulControlComponent<State, any> implements OnChanges, OnDestroy, AfterViewInit {
private value: any; private value: any;
private isDisabled = false;
private isInitialized = false; private isInitialized = false;
@ViewChild('iframe', { static: false }) @ViewChild('iframe', { static: false })
@ -109,7 +108,7 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
} else if (type === 'resize') { } else if (type === 'resize') {
const { height } = event.data; const { height } = event.data;
this.renderer.setStyle(this.iframe.nativeElement, 'height', height + 'px'); this.renderer.setStyle(this.iframe.nativeElement, 'height', `${height}px`);
} else if (type === 'navigate') { } else if (type === 'navigate') {
const { url } = event.data; const { url } = event.data;
@ -144,7 +143,7 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
} }
public setDisabledState(isDisabled: boolean): void { public setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled; super.setDisabledState(isDisabled);
this.sendDisabled(); this.sendDisabled();
} }
@ -162,7 +161,7 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
} }
private sendDisabled() { private sendDisabled() {
this.sendMessage('disabled', { isDisabled: this.isDisabled }); this.sendMessage('disabled', { isDisabled: this.snapshot.isDisabled });
} }
private sendFormValue() { private sendFormValue() {
@ -178,7 +177,7 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
} }
private toggleFullscreen(isFullscreen: boolean) { private toggleFullscreen(isFullscreen: boolean) {
this.next(s => ({ ...s, isFullscreen })); this.next({ isFullscreen });
let target = this.container.nativeElement; let target = this.container.nativeElement;

6
frontend/app/framework/angular/forms/editors/localized-input.component.html

@ -1,6 +1,10 @@
<div class="row mb-1"> <div class="row mb-1">
<div class="col"> <div class="col">
<sqx-language-selector size="sm" [languages]="languages" [(selectedLanguage)]="selectedLanguage"></sqx-language-selector> <sqx-language-selector size="sm"
[languages]="languages"
[selectedLanguage]="snapshot.language"
(selectedLanguageChange)="setLanguage($event)">
</sqx-language-selector>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button type="button" class="btn btn-sm btn-secondary" [disabled]="isEmpty" (click)="unset()"> <button type="button" class="btn btn-sm btn-secondary" [disabled]="isEmpty" (click)="unset()">

29
frontend/app/framework/angular/forms/editors/localized-input.component.ts

@ -16,7 +16,12 @@ export const SQX_LOCALIZED_INPUT_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => LocalizedInputComponent), multi: true provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => LocalizedInputComponent), multi: true
}; };
const DEFAULT_LANGUAGE = { iso2Code: 'iv' }; const DEFAULT_LANGUAGE = { iso2Code: 'iv', englishName: 'Invariant' };
interface State {
// The selected language.
language: Language;
}
@Component({ @Component({
selector: 'sqx-localized-input', selector: 'sqx-localized-input',
@ -30,7 +35,7 @@ const DEFAULT_LANGUAGE = { iso2Code: 'iv' };
], ],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class LocalizedInputComponent extends StatefulControlComponent<{}, { [key: string]: any }> { export class LocalizedInputComponent extends StatefulControlComponent<State, { [key: string]: any }> {
private value: { [key: string]: any } | undefined; private value: { [key: string]: any } | undefined;
@Input() @Input()
@ -45,32 +50,34 @@ export class LocalizedInputComponent extends StatefulControlComponent<{}, { [key
@Input() @Input()
public id: string; public id: string;
public selectedLanguage: Language;
public dropdown = new ModalModel(); public dropdown = new ModalModel();
public get currentValue() { public get currentValue() {
if (!this.selectedLanguage || !this.value) { if (!this.snapshot.language || !this.value) {
return undefined; return undefined;
} }
return this.value[this.selectedLanguage.iso2Code]; return this.value[this.snapshot.language.iso2Code];
} }
public get isEmpty() { public get isEmpty() {
if (!this.selectedLanguage || !this.value) { if (!this.snapshot.language || !this.value) {
return true; return true;
} }
return !this.value.hasOwnProperty(this.selectedLanguage.iso2Code); return !this.value.hasOwnProperty(this.snapshot.language.iso2Code);
} }
constructor(changeDetector: ChangeDetectorRef) { constructor(changeDetector: ChangeDetectorRef) {
super(changeDetector, { super(changeDetector, {
selectedLanguage: DEFAULT_LANGUAGE language: DEFAULT_LANGUAGE
}); });
} }
public setLanguage(language: Language) {
this.next({ language });
}
public writeValue(obj: any) { public writeValue(obj: any) {
if (Types.isObject(obj)) { if (Types.isObject(obj)) {
this.value = obj; this.value = obj;
@ -81,7 +88,7 @@ export class LocalizedInputComponent extends StatefulControlComponent<{}, { [key
public setValue(value: any) { public setValue(value: any) {
this.value = { ...this.value || {} }; this.value = { ...this.value || {} };
this.value[this.selectedLanguage.iso2Code] = value; this.value[this.snapshot.language.iso2Code] = value;
this.callChange(this.value); this.callChange(this.value);
} }
@ -89,7 +96,7 @@ export class LocalizedInputComponent extends StatefulControlComponent<{}, { [key
public unset() { public unset() {
this.value= { ...this.value || {} }; this.value= { ...this.value || {} };
delete this.value[this.selectedLanguage.iso2Code]; delete this.value[this.snapshot.language.iso2Code];
if (Object.keys(this.value).length === 0) { if (Object.keys(this.value).length === 0) {
this.value = undefined; this.value = undefined;

12
frontend/app/framework/angular/forms/editors/stars.component.ts

@ -49,7 +49,7 @@ export class StarsComponent extends StatefulControlComponent<State, number | nul
starsArray.push(i); starsArray.push(i);
} }
this.next(s => ({ ...s, starsArray })); this.next({ starsArray });
} }
} }
@ -66,9 +66,9 @@ export class StarsComponent extends StatefulControlComponent<State, number | nul
} }
public writeValue(obj: any) { public writeValue(obj: any) {
const value = Types.isNumber(obj) ? obj : 0; const stars = Types.isNumber(obj) ? obj : 0;
this.next(s => ({ ...s, stars: value, value })); this.next({ stars });
} }
public setPreview(stars: number) { public setPreview(stars: number) {
@ -76,7 +76,7 @@ export class StarsComponent extends StatefulControlComponent<State, number | nul
return; return;
} }
this.next(s => ({ ...s, stars })); this.next({ stars });
} }
public stopPreview() { public stopPreview() {
@ -93,7 +93,7 @@ export class StarsComponent extends StatefulControlComponent<State, number | nul
} }
if (this.snapshot.value) { if (this.snapshot.value) {
this.next(s => ({ ...s, stars: -1, value: null })); this.next({ stars: -1, value: null });
this.callChange(null); this.callChange(null);
this.callTouched(); this.callTouched();
@ -108,7 +108,7 @@ export class StarsComponent extends StatefulControlComponent<State, number | nul
} }
if (this.snapshot.value !== value) { if (this.snapshot.value !== value) {
this.next(s => ({ ...s, stars: value, value })); this.next({ stars: value, value });
this.callChange(value); this.callChange(value);
this.callTouched(); this.callTouched();

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

@ -20,6 +20,8 @@ export const SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
let CACHED_FONT: string; let CACHED_FONT: string;
const NO_EMIT = { emitEvent: false };
interface State { interface State {
// True, when the item has the focus. // True, when the item has the focus.
hasFocus: boolean; hasFocus: boolean;
@ -34,8 +36,6 @@ interface State {
items: ReadonlyArray<TagValue>; items: ReadonlyArray<TagValue>;
} }
const NO_EMIT = { emitEvent: false };
@Component({ @Component({
selector: 'sqx-tag-editor', selector: 'sqx-tag-editor',
styleUrls: ['./tag-editor.component.scss'], styleUrls: ['./tag-editor.component.scss'],
@ -186,7 +186,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
} }
this.next(s => ({ ...s, items })); this.next({ items });
} }
public setDisabledState(isDisabled: boolean): void { public setDisabledState(isDisabled: boolean): void {
@ -201,7 +201,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
public focus() { public focus() {
if (this.addInput.enabled) { if (this.addInput.enabled) {
this.next(s => ({ ...s, hasFocus: true })); this.next({ hasFocus: true });
} }
} }
@ -244,7 +244,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
const width = Math.max(widthText, widthPlaceholder); const width = Math.max(widthText, widthPlaceholder);
this.inputElement.nativeElement.style.width = <any>((width + 5) + 'px'); this.inputElement.nativeElement.style.width = `${width + 5}px`;
} }
} }
@ -361,15 +361,15 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
suggestedIndex = this.snapshot.suggestedItems.length - 1; suggestedIndex = this.snapshot.suggestedItems.length - 1;
} }
this.next(s => ({ ...s, suggestedIndex })); this.next({ suggestedIndex });
} }
public resetFocus(): any { public resetFocus(): any {
this.next(s => ({ ...s, hasFocus: false })); this.next({ hasFocus: false });
} }
private resetAutocompletion() { private resetAutocompletion() {
this.next(s => ({ ...s, suggestedItems: [], suggestedIndex: -1 })); this.next({ suggestedItems: [], suggestedIndex: -1 });
} }
private resetForm() { private resetForm() {
@ -436,7 +436,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
private updateItems(items: ReadonlyArray<TagValue>, touched: boolean) { private updateItems(items: ReadonlyArray<TagValue>, touched: boolean) {
this.next(s => ({ ...s, items })); this.next({ items });
if (items.length === 0 && this.undefinedWhenEmpty) { if (items.length === 0 && this.undefinedWhenEmpty) {
this.callChange(undefined); this.callChange(undefined);

4
frontend/app/framework/angular/forms/editors/toggle.component.ts

@ -43,7 +43,7 @@ export class ToggleComponent extends StatefulControlComponent<State, boolean | n
public writeValue(obj: any) { public writeValue(obj: any) {
const isChecked = Types.isBoolean(obj) ? obj : null; const isChecked = Types.isBoolean(obj) ? obj : null;
this.next(s => ({ ...s, isChecked })); this.next({ isChecked });
} }
public changeState(event: MouseEvent) { public changeState(event: MouseEvent) {
@ -67,7 +67,7 @@ export class ToggleComponent extends StatefulControlComponent<State, boolean | n
isChecked = !(isChecked === true); isChecked = !(isChecked === true);
} }
this.next(s => ({ ...s, isChecked })); this.next({ isChecked });
this.callChange(isChecked); this.callChange(isChecked);
this.callTouched(); this.callTouched();

2
frontend/app/framework/angular/forms/progress-bar.component.ts

@ -88,7 +88,7 @@ export class ProgressBarComponent implements OnChanges, OnInit {
} }
if (value > 0 && this.showText) { if (value > 0 && this.showText) {
this.progressBar.setText(Math.round(value) + '%'); this.progressBar.setText(`${Math.round(value)}%`);
} }
} }
} }

2
frontend/app/framework/angular/forms/validators.ts

@ -94,7 +94,7 @@ export module ValidatorsEx {
} }
} else { } else {
if (isNaN(value) || value < min || value > max) { if (isNaN(value) || value < min || value > max) {
return { between: { min: min, max: max, actual: value }}; return { between: { min, max, actual: value }};
} }
} }

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

Loading…
Cancel
Save