From b02588aeb6c7a9fe7e49b7c03ce0c08517fcfcf1 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 9 Dec 2020 15:10:18 +0100 Subject: [PATCH] Feature/refs view (#608) * Views for references. * Cleanup contents state. * Temorary * Paging simplified. * Cleanup and test improvements. * Minor fixes after testing. --- backend/i18n/frontend_en.json | 7 +- backend/i18n/frontend_it.json | 5 + backend/i18n/frontend_nl.json | 5 + backend/i18n/source/frontend_en.json | 9 +- .../Assets/MongoAssetRepository.cs | 25 +- .../Contents/MongoContentCollection.cs | 2 +- .../Contents/Operations/QueryByIds.cs | 14 +- .../Contents/Operations/QueryByQuery.cs | 37 +- .../Rules/MongoRuleEventRepository.cs | 11 +- .../Assets/AssetDomainObject.cs | 37 +- .../Assets/AssetFolderDomainObject.cs | 4 + .../Contents/BulkUpdateCommandMiddleware.cs | 64 +++- .../Contents/Commands/BulkUpdateJob.cs | 10 +- .../Contents/Commands/BulkUpdateType.cs | 3 +- .../Contents/ContentDomainObject.cs | 6 +- .../Contents/IContentQueryService.cs | 2 + .../Operations/ContentOperationContext.cs | 19 +- .../Contents/Queries/ContentQueryService.cs | 22 +- .../Contents/ValidationResult.cs | 16 - .../Rules/RuleDomainObject.cs | 19 +- backend/src/Squidex.Shared/Permissions.cs | 2 +- backend/src/Squidex.Web/Resources.cs | 2 - .../Contents/ContentsController.cs | 13 +- .../Contents/Models/BulkUpdateDto.cs | 5 + .../Contents/Models/BulkUpdateJobDto.cs | 12 +- .../Controllers/Contents/Models/ContentDto.cs | 12 +- .../Contents/Models/ValidationResultDto.cs | 29 -- .../BulkUpdateCommandMiddlewareTests.cs | 355 ++++++++---------- .../Contents/ContentDomainObjectTests.cs | 7 +- .../Rules/RuleDomainObjectTests.cs | 4 +- frontend/app-config/webpack.config.js | 2 +- frontend/app/app.module.ts | 15 +- frontend/app/app.routes.ts | 2 +- .../pages/users/users-page.component.html | 2 +- .../pages/users/users-page.component.ts | 2 +- .../services/event-consumers.service.ts | 2 +- .../administration/services/users.service.ts | 2 +- .../administration/state/users.forms.ts | 6 +- .../administration/state/users.state.spec.ts | 28 +- .../administration/state/users.state.ts | 61 ++- .../pages/assets-filters-page.component.html | 2 +- .../assets/pages/assets-page.component.html | 7 +- frontend/app/features/content/declarations.ts | 8 +- frontend/app/features/content/module.ts | 5 +- .../pages/content/content-page.component.html | 102 +++-- .../pages/content/content-page.component.scss | 8 + .../pages/content/content-page.component.ts | 48 ++- .../editor/content-editor.component.html | 24 ++ .../content-editor.component.scss} | 0 .../editor/content-editor.component.ts | 47 +++ .../{ => editor}/content-field.component.html | 0 .../{ => editor}/content-field.component.scss | 0 .../{ => editor}/content-field.component.ts | 4 +- .../content-section.component.html | 6 +- .../content-section.component.scss | 0 .../{ => editor}/content-section.component.ts | 35 +- .../field-languages.component.html | 0 .../editor/field-languages.component.scss | 0 .../{ => editor}/field-languages.component.ts | 0 .../content-references.component.html | 20 + .../content-references.component.scss | 10 + .../content-references.component.ts | 57 +++ .../contents-filters-page.component.html | 6 +- .../contents/contents-page.component.html | 8 +- .../pages/sidebar/sidebar-page.component.ts | 2 +- .../shared/forms/array-item.component.html | 6 +- .../shared/forms/array-item.component.ts | 23 +- .../shared/forms/assets-editor.component.ts | 18 +- .../forms/stock-photo-editor.component.ts | 14 +- .../list/content-list-cell.directive.ts | 18 +- .../list/content-list-field.component.html | 2 +- .../list/content-list-field.component.ts | 19 +- .../list/content-value-editor.component.ts | 2 +- .../references/content-creator.component.html | 2 +- .../content-selector.component.html | 6 +- .../references/reference-item.component.html | 7 +- .../references/reference-item.component.scss | 5 + .../references/reference-item.component.ts | 17 +- .../references-editor.component.html | 2 +- .../references/references-editor.component.ts | 15 +- .../cards/content-summary-card.component.html | 2 +- .../cards/content-summary-card.component.ts | 25 +- .../events/rule-events-page.component.html | 2 +- .../contributors-page.component.html | 7 +- .../angular/forms/control-errors.component.ts | 20 +- .../forms/editors/autocomplete.component.ts | 6 +- .../forms/editors/checkbox-group.component.ts | 4 +- .../forms/editors/code-editor.component.ts | 9 +- .../forms/editors/color-picker.component.ts | 8 +- .../editors/date-time-editor.component.html | 4 +- .../editors/date-time-editor.component.ts | 25 +- .../forms/editors/dropdown.component.ts | 6 +- .../forms/editors/iframe-editor.component.ts | 9 +- .../editors/localized-input.component.html | 6 +- .../editors/localized-input.component.ts | 29 +- .../angular/forms/editors/stars.component.ts | 12 +- .../forms/editors/tag-editor.component.ts | 18 +- .../angular/forms/editors/toggle.component.ts | 4 +- .../angular/forms/progress-bar.component.ts | 2 +- .../app/framework/angular/forms/validators.ts | 2 +- .../framework/angular/http/http-extensions.ts | 4 + .../angular/image-source.directive.ts | 4 +- .../angular/language-selector.component.ts | 2 +- .../framework/angular/list-view.component.ts | 26 +- .../modals/dialog-renderer.component.ts | 17 +- .../framework/angular/pager.component.html | 10 +- .../framework/angular/pager.component.spec.ts | 138 +++++++ .../app/framework/angular/pager.component.ts | 55 ++- .../angular/panel-container.directive.ts | 4 +- .../app/framework/angular/panel.component.ts | 12 +- .../framework/angular/pipes/colors.pipes.ts | 8 +- .../app/framework/angular/pipes/money.pipe.ts | 6 +- .../framework/angular/pipes/numbers.pipes.ts | 5 +- .../angular/routers/router-2-state.spec.ts | 126 +++---- .../angular/routers/router-2-state.ts | 144 +++---- .../framework/angular/stateful.component.ts | 6 +- frontend/app/framework/configurations.ts | 2 +- frontend/app/framework/internal.ts | 1 - .../framework/services/analytics.service.ts | 2 +- .../framework/services/message-bus.service.ts | 9 +- .../services/resource-loader.service.ts | 4 +- frontend/app/framework/state.ts | 44 ++- frontend/app/framework/utils/cookies.ts | 2 +- frontend/app/framework/utils/date-time.ts | 2 +- frontend/app/framework/utils/math-helper.ts | 10 +- frontend/app/framework/utils/pager.spec.ts | 214 ----------- frontend/app/framework/utils/pager.ts | 85 ----- .../app/framework/utils/rxjs-extensions.ts | 2 +- frontend/app/framework/utils/types.ts | 6 +- .../assets/asset-dialog.component.html | 11 +- .../assets/asset-dialog.component.ts | 5 +- .../assets/asset-folder-dialog.component.ts | 5 +- .../components/assets/asset.component.html | 12 +- .../components/assets/asset.component.ts | 23 +- .../assets/assets-list.component.html | 37 +- .../assets/assets-list.component.ts | 50 ++- .../assets/assets-selector.component.html | 10 +- .../assets/assets-selector.component.ts | 2 +- .../comments/comment.component.html | 32 +- .../components/comments/comment.component.ts | 21 +- .../components/comments/comments.component.ts | 2 +- .../forms/geolocation-editor.component.ts | 6 +- .../forms/markdown-editor.component.ts | 2 +- .../forms/references-checkboxes.component.ts | 4 +- .../forms/references-dropdown.component.ts | 12 +- .../forms/references-tags.component.ts | 4 +- .../components/forms/rich-editor.component.ts | 11 +- .../app/shared/components/notifo.component.ts | 7 +- frontend/app/shared/services/apps.service.ts | 2 +- .../app/shared/services/assets.service.ts | 10 +- .../shared/services/autosave.service.spec.ts | 18 +- .../app/shared/services/autosave.service.ts | 4 +- .../app/shared/services/backups.service.ts | 2 +- .../shared/services/clients.service.spec.ts | 2 +- .../shared/services/comments.service.spec.ts | 6 +- .../shared/services/contents.service.spec.ts | 60 ++- .../app/shared/services/contents.service.ts | 190 +++++++--- .../services/contributors.service.spec.ts | 2 +- .../shared/services/patterns.service.spec.ts | 2 +- .../app/shared/services/roles.service.spec.ts | 2 +- frontend/app/shared/services/rules.service.ts | 2 +- .../app/shared/services/schemas.service.ts | 2 +- .../shared/services/workflows.service.spec.ts | 4 +- .../app/shared/services/workflows.service.ts | 3 +- frontend/app/shared/state/_test-helpers.ts | 4 +- .../shared/state/asset-uploader.state.spec.ts | 8 +- .../app/shared/state/assets.state.spec.ts | 47 ++- frontend/app/shared/state/assets.state.ts | 208 +++++----- frontend/app/shared/state/contents.forms.ts | 2 +- frontend/app/shared/state/contents.state.ts | 307 +++++++-------- .../shared/state/contributors.state.spec.ts | 21 +- .../app/shared/state/contributors.state.ts | 90 ++--- frontend/app/shared/state/languages.state.ts | 4 +- frontend/app/shared/state/plans.state.ts | 2 +- frontend/app/shared/state/queries.ts | 6 +- frontend/app/shared/state/query.spec.ts | 32 +- frontend/app/shared/state/query.ts | 16 +- .../shared/state/rule-events.state.spec.ts | 8 +- .../app/shared/state/rule-events.state.ts | 44 +-- frontend/app/shared/state/rules.state.spec.ts | 8 +- frontend/app/shared/state/ui.state.ts | 3 +- frontend/app/shared/utils/array-helper.ts | 10 - .../internal/profile-menu.component.html | 5 +- .../pages/internal/profile-menu.component.ts | 17 +- .../pages/internal/search-menu.component.html | 3 +- .../pages/internal/search-menu.component.ts | 2 - frontend/tslint.json | 4 + 187 files changed, 2148 insertions(+), 1855 deletions(-) delete mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/ValidationResult.cs delete mode 100644 backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ValidationResultDto.cs create mode 100644 frontend/app/features/content/pages/content/editor/content-editor.component.html rename frontend/app/features/content/pages/content/{field-languages.component.scss => editor/content-editor.component.scss} (100%) create mode 100644 frontend/app/features/content/pages/content/editor/content-editor.component.ts rename frontend/app/features/content/pages/content/{ => editor}/content-field.component.html (100%) rename frontend/app/features/content/pages/content/{ => editor}/content-field.component.scss (100%) rename frontend/app/features/content/pages/content/{ => editor}/content-field.component.ts (98%) rename frontend/app/features/content/pages/content/{ => editor}/content-section.component.html (81%) rename frontend/app/features/content/pages/content/{ => editor}/content-section.component.scss (100%) rename frontend/app/features/content/pages/content/{ => editor}/content-section.component.ts (60%) rename frontend/app/features/content/pages/content/{ => editor}/field-languages.component.html (100%) create mode 100644 frontend/app/features/content/pages/content/editor/field-languages.component.scss rename frontend/app/features/content/pages/content/{ => editor}/field-languages.component.ts (100%) create mode 100644 frontend/app/features/content/pages/content/references/content-references.component.html create mode 100644 frontend/app/features/content/pages/content/references/content-references.component.scss create mode 100644 frontend/app/features/content/pages/content/references/content-references.component.ts create mode 100644 frontend/app/framework/angular/pager.component.spec.ts delete mode 100644 frontend/app/framework/utils/pager.spec.ts delete mode 100644 frontend/app/framework/utils/pager.ts delete mode 100644 frontend/app/shared/utils/array-helper.ts diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index cf9d4950d..c94ecb42f 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -353,6 +353,9 @@ "contents.changeStatusToImmediately": "Set to {action} immediately.", "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.contentTab.editor": "Editor", + "contents.contentTab.references": "References", + "contents.contentTab.referencing": "Referencing", "contents.create": "New", "contents.createContentTooltip": "New Content (CTRL + SHIFT + G)", "contents.created": "Content created successfully.", @@ -366,7 +369,7 @@ "contents.deleteConfirmTitle": "Delete content", "contents.deleteFailed": "Failed to delete content. Please reload.", "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.deleteVersionConfirmText": "Do you really want to delete this version?", "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.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.publishAll": "Publish All", "contents.referencesCreateNew": "Add New", "contents.referencesCreatePublish": "Create and Publish", "contents.referencesLink": "Link selected contents ({count})", @@ -428,6 +432,7 @@ "contents.unsavedChangesTitle": "Unsaved changes", "contents.updated": "Content updated successfully.", "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.versionCompare": "Compare", "contents.versionDelete": "Delete this Version", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index efe762f51..3ba2452f2 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -353,6 +353,9 @@ "contents.changeStatusToImmediately": "Imposta {action} immediatamente.", "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.contentTab.editor": "Editor", + "contents.contentTab.references": "References", + "contents.contentTab.referencing": "Referencing", "contents.create": "Nuovo", "contents.createContentTooltip": "Nuovo contenuto (CTRL + SHIFT + G)", "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.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.publishAll": "Publish All", "contents.referencesCreateNew": "Aggiungi nuovo", "contents.referencesCreatePublish": "Crea e pubblica", "contents.referencesLink": "Collega i contenuti selezionati ({count})", @@ -428,6 +432,7 @@ "contents.unsavedChangesTitle": "Modifiche non salvate", "contents.updated": "Contenuto aggiornato con successo.", "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.versionCompare": "Confronta", "contents.versionDelete": "Cancella questa Versione", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 4dede1bec..b944fd00d 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -353,6 +353,9 @@ "contents.changeStatusToImmediately": "Zet onmiddellijk op {action}.", "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.contentTab.editor": "Editor", + "contents.contentTab.references": "References", + "contents.contentTab.referencing": "Referencing", "contents.create": "Nieuw", "contents.createContentTooltip": "Nieuwe inhoud (CTRL + SHIFT + G)", "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.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.publishAll": "Publish All", "contents.referencesCreateNew": "Nieuwe toevoegen", "contents.referencesCreatePublish": "Maken en publiceren", "contents.referencesLink": "Link geselecteerde inhoud ({count})", @@ -428,6 +432,7 @@ "contents.unsavedChangesTitle": "Niet-opgeslagen wijzigingen", "contents.updated": "Inhoud succesvol bijgewerkt.", "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.versionCompare": "Vergelijk", "contents.versionDelete": "Verwijder deze versie", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index dd313a50f..c94ecb42f 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -353,6 +353,9 @@ "contents.changeStatusToImmediately": "Set to {action} immediately.", "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.contentTab.editor": "Editor", + "contents.contentTab.references": "References", + "contents.contentTab.referencing": "Referencing", "contents.create": "New", "contents.createContentTooltip": "New Content (CTRL + SHIFT + G)", "contents.created": "Content created successfully.", @@ -368,8 +371,6 @@ "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.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.deleteVersionFailed": "Failed to delete version. Please reload.", "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.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.publishAll": "Publish All", "contents.referencesCreateNew": "Add New", "contents.referencesCreatePublish": "Create and Publish", "contents.referencesLink": "Link selected contents ({count})", @@ -424,10 +426,13 @@ "contents.tableHeaders.nextStatus": "Next Status", "contents.tableHeaders.status": "Status", "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.unsavedChangesTitle": "Unsaved changes", "contents.updated": "Content updated successfully.", "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.versionCompare": "Compare", "contents.versionDelete": "Delete this Version", diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 4bf245c0c..c6a405970 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/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) { + var filter = BuildFilter(appId, q.Ids.ToHashSet()); + 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) .QuerySkip(q.Query) .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()); + return ResultList.Create(assetTotal, assetEntities.OfType()); } else { @@ -103,17 +111,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets var filter = query.BuildFilter(appId, parentId); - var assetCount = Collection.Find(filter).CountDocumentsAsync(); - var assetItems = - Collection.Find(filter) + var assetEntities = + await Collection.Find(filter) .QueryLimit(query) .QuerySkip(query) .QuerySort(query) .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(total, items); + return ResultList.Create(assetTotal, assetEntities); } } catch (MongoQueryException ex) when (ex.Message.Contains("17406")) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index abb2186c1..83d6a853b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Profiler.TraceMethod()) { - if (q.Ids != null && q.Ids.Count > 0l) + if (q.Ids != null && q.Ids.Count > 0) { return await queryByIds.QueryAsync(app.Id, new List { schema }, q); } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs index a1475e935..1af002a3b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs +++ b/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 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); - foreach (var content in items) + foreach (var content in contentEntities) { 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> FindContentsAsync(ClrQuery query, FilterDefinition filter) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs index feb45e63b..064269047 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs +++ b/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 contentCount = Collection.Find(filter).CountDocumentsAsync(); - var contentItems = FindContentsAsync(query, filter); + var contentEntities = await FindContentsAsync(query, filter); + var contentTotal = (long)contentEntities.Count; - var (items, total) = await AsyncHelper.WhenAll(contentItems, contentCount); - - 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); - foreach (var entity in items) + foreach (var entity in contentEntities) { entity.ParseData(contentSchemas[entity.IndexedSchemaId].SchemaDef, DataConverter); } } - return ResultList.Create(total, items); + return ResultList.Create(contentTotal, contentEntities); } 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 contentCount = Collection.Find(filter).CountDocumentsAsync(); - var contentItems = FindContentsAsync(query, filter); - - var (items, total) = await AsyncHelper.WhenAll(contentItems, contentCount); + var contentEntities = await FindContentsAsync(query, filter); + var contentTotal = (long)contentEntities.Count; - 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(total, items); + return ResultList.Create(contentTotal, contentEntities); } catch (MongoCommandException ex) when (ex.Code == 96) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs index 65952f99d..b06b7e2b7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/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)); } - var taskForItems = Collection.Find(filter).Skip(skip).Limit(take).SortByDescending(x => x.Created).ToListAsync(); - var taskForCount = Collection.Find(filter).CountDocumentsAsync(); + var ruleEventEntities = await Collection.Find(filter).Skip(skip).Limit(take).SortByDescending(x => x.Created).ToListAsync(); + 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 FindAsync(DomainId id) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs index fa76b72de..e9314c967 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs @@ -72,32 +72,41 @@ namespace Squidex.Domain.Apps.Entities.Assets { 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); return Snapshot; }); - case UpdateAsset updateAsset: - return UpdateReturn(updateAsset, c => - { - GuardAsset.CanUpdate(c); - - Update(c); - return Snapshot; - }); case AnnotateAsset annotateAsset: return UpdateReturnAsync(annotateAsset, async 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); return Snapshot; }); + + case UpdateAsset updateAsset: + return UpdateReturn(updateAsset, c => + { + GuardAsset.CanUpdate(c); + + Update(c); + + return Snapshot; + }); + case MoveAsset moveAsset: return UpdateReturnAsync(moveAsset, async c => { @@ -107,6 +116,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return Snapshot; }); + case DeleteAsset deleteAsset: return UpdateAsync(deleteAsset, async c => { @@ -121,13 +131,8 @@ namespace Squidex.Domain.Apps.Entities.Assets } } - private async Task?> NormalizeTagsAsync(DomainId appId, HashSet tags) + private async Task> NormalizeTagsAsync(DomainId appId, HashSet tags) { - if (tags == null) - { - return null; - } - var normalized = await assetTags.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags); return new HashSet(normalized.Values); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs index 45137f887..c58784a3d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs @@ -64,6 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return Snapshot; }); + case MoveAssetFolder moveAssetFolder: return UpdateReturnAsync(moveAssetFolder, async c => { @@ -73,6 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return Snapshot; }); + case RenameAssetFolder renameAssetFolder: return UpdateReturn(renameAssetFolder, c => { @@ -82,6 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return Snapshot; }); + case DeleteAssetFolder deleteAssetFolder: return Update(deleteAssetFolder, c => { @@ -89,6 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Assets Delete(c); }); + default: throw new NotSupportedException(); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs index b876e822a..673cf6c62 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs @@ -10,10 +10,13 @@ using System.Linq; using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Translations; +using Squidex.Shared; #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 requestedSchema = bulkUpdates.SchemaId.Name; + async Task PublishAsync(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 actionBlock = new ActionBlock(async index => @@ -54,13 +80,18 @@ namespace Squidex.Domain.Apps.Entities.Contents { var id = await FindIdAsync(requestContext, requestedSchema, job); + if (job.Type != BulkUpdateType.Upsert && (id == null || id == DomainId.Empty)) + { + throw new DomainObjectNotFoundException("undefined"); + } + result.ContentId = id; switch (job.Type) { 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) { @@ -69,38 +100,31 @@ namespace Squidex.Domain.Apps.Entities.Contents result.ContentId = command.ContentId; - await context.CommandBus.PublishAsync(command); + await PublishAsync(job, command, Permissions.AppContentsUpsert); break; } - case BulkUpdateType.ChangeStatus: + case BulkUpdateType.Validate: { - if (id == null || id == DomainId.Empty) - { - throw new DomainObjectNotFoundException("undefined"); - } + var command = new ValidateContent { ContentId = id.Value }; - var command = SimpleMapper.Map(bulkUpdates, new ChangeContentStatus { ContentId = id.Value }); + await PublishAsync(job, command, Permissions.AppContentsRead); + break; + } - if (job.Status != null) - { - command.Status = job.Status.Value; - } + case BulkUpdateType.ChangeStatus: + { + var command = new ChangeContentStatus { ContentId = id.Value, Status = job.Status }; - await context.CommandBus.PublishAsync(command); + await PublishAsync(job, command, Permissions.AppContentsUpdate); break; } case BulkUpdateType.Delete: { - if (id == null || id == DomainId.Empty) - { - throw new DomainObjectNotFoundException("undefined"); - } - - var command = SimpleMapper.Map(bulkUpdates, new DeleteContent { ContentId = id.Value }); + var command = new DeleteContent { ContentId = id.Value }; - await context.CommandBus.PublishAsync(command); + await PublishAsync(job, command, Permissions.AppContentsDelete); break; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs index e28824871..ffe55f49d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs +++ b/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 NamedContentData Data { get; set; } - - public Status? Status { get; set; } + public Status Status { get; set; } public BulkUpdateType Type { get; set; } + + public NamedContentData? Data { get; set; } + + public string? Schema { get; set; } + + public long ExpectedVersion { get; set; } = EtagVersion.Any; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs index eadfbd212..47107999d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs @@ -11,6 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { Upsert, ChangeStatus, - Delete + Delete, + Validate } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs index d7d7a68d9..25d49bb5c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs @@ -132,11 +132,9 @@ namespace Squidex.Domain.Apps.Entities.Contents { await LoadContext(c); - var errors = await context.GetErrorsAsync(Snapshot.Data); + await context.ValidateContentAndInputAsync(Snapshot.Data); - var result = new ValidationResult { Errors = errors.ToArray() }; - - return result; + return true; }); case CreateContentDraft createContentDraft: diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index ce9f61cd4..d488707b2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs @@ -21,5 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Contents Task FindAsync(Context context, string schemaIdOrName, DomainId id, long version = EtagVersion.Any); Task GetSchemaOrThrowAsync(Context context, string schemaIdOrName); + + Task GetSchemaAsync(Context context, string schemaIdOrName); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Operations/ContentOperationContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Operations/ContentOperationContext.cs index 97c8efd9c..452e786fb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Operations/ContentOperationContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Operations/ContentOperationContext.cs @@ -146,33 +146,26 @@ namespace Squidex.Domain.Apps.Entities.Contents.Operations CheckErrors(validator); } - public async Task> GetErrorsAsync(NamedContentData data) + public async Task ValidateContentAndInputAsync(NamedContentData data) { var validator = new ContentValidator(Partition(), - validationContext, validators, log); + validationContext.AsPublishing(), validators, log); await validator.ValidateInputAsync(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) { - return; + return Task.CompletedTask; } - var validator = - new ContentValidator(Partition(), - validationContext.AsPublishing(), validators, log); - - await validator.ValidateInputAsync(data); - await validator.ValidateContentAsync(data); - - CheckErrors(validator); + return ValidateContentAndInputAsync(data); } private static void CheckErrors(ContentValidator validator) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs index e5aa2a57c..28828bf0c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -162,6 +162,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public async Task GetSchemaOrThrowAsync(Context context, string schemaIdOrName) { + var schema = await GetSchemaAsync(context, schemaIdOrName); + + if (schema == null) + { + throw new DomainObjectNotFoundException(schemaIdOrName); + } + + return schema; + } + + public async Task GetSchemaAsync(Context context, string schemaIdOrName) + { + Guard.NotNull(context, nameof(context)); + Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName)); + ISchemaEntity? schema = null; var canCache = !context.IsFrontendClient; @@ -178,12 +193,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName, canCache); } - if (schema == null) - { - throw new DomainObjectNotFoundException(schemaIdOrName); - } - - if (!HasPermission(context, schema)) + if (schema != null && !HasPermission(context, schema)) { throw new DomainForbiddenException(T.Get("schemas.noPermission")); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ValidationResult.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ValidationResult.cs deleted file mode 100644 index 220cfc64d..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ValidationResult.cs +++ /dev/null @@ -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; } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs index ecf3f251c..6a5f76722 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs @@ -67,6 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Rules return Snapshot; }); + case UpdateRule updateRule: return UpdateReturnAsync(updateRule, async c => { @@ -76,6 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Rules return Snapshot; }); + case EnableRule enableRule: return UpdateReturn(enableRule, c => { @@ -85,6 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Rules return Snapshot; }); + case DisableRule disableRule: return UpdateReturn(disableRule, c => { @@ -94,6 +97,7 @@ namespace Squidex.Domain.Apps.Entities.Rules return Snapshot; }); + case DeleteRule deleteRule: return Update(deleteRule, c => { @@ -101,22 +105,25 @@ namespace Squidex.Domain.Apps.Entities.Rules Delete(c); }); + case TriggerRule triggerRule: - return Trigger(triggerRule); + return UpdateReturnAsync(triggerRule, async c => + { + await Trigger(triggerRule); + + return true; + }); + default: throw new NotSupportedException(); } } - private async Task Trigger(TriggerRule command) + private async Task Trigger(TriggerRule command) { - await EnsureLoadedAsync(); - var @event = SimpleMapper.Map(command, new RuleManuallyTriggered { RuleId = Snapshot.Id, AppId = Snapshot.AppId }); await ruleEnqueuer.EnqueueAsync(Snapshot.RuleDef, Snapshot.Id, Envelope.Create(@event)); - - return null; } public void Create(CreateRule command) diff --git a/backend/src/Squidex.Shared/Permissions.cs b/backend/src/Squidex.Shared/Permissions.cs index fb476e64b..0d78e45e9 100644 --- a/backend/src/Squidex.Shared/Permissions.cs +++ b/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 AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; 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 AppContentsVersionDelete = "squidex.apps.{app}.contents.{name}.version.delete"; public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs index 75763f401..1d7a81d2a 100644 --- a/backend/src/Squidex.Web/Resources.cs +++ b/backend/src/Squidex.Web/Resources.cs @@ -32,8 +32,6 @@ namespace Squidex.Web public bool CanUpdateContent(string schema) => IsAllowedForSchema(P.AppContentsUpdate, schema); - public bool CanUpdateContentPartial(string schema) => IsAllowedForSchema(P.AppContentsUpdatePartial, schema); - // Schemas public bool CanUpdateSchema(string schema) => IsAllowedForSchema(P.AppSchemasDelete, schema); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index c3a825c09..0ed3600a9 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -292,7 +292,8 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the schema. /// The id of the content to fetch. /// - /// 200 => Content validation result returned. + /// 204 => Content is valid. + /// 400 => Content not valid. /// 404 => Content, schema or app not found. /// /// @@ -300,19 +301,15 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpGet] [Route("content/{app}/{name}/{id}/validity")] - [ProducesResponseType(typeof(ValidationResultDto), 200)] [ApiPermissionOrAnonymous] [ApiCosts(1)] public async Task GetContentValidity(string app, string name, DomainId id) { var command = new ValidateContent { ContentId = id }; - var context = await CommandBus.PublishAsync(command); - - var result = context.Result(); - var response = ValidationResultDto.FromResult(result); + await CommandBus.PublishAsync(command); - return Ok(response); + return NoContent(); } /// @@ -520,7 +517,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPost] [Route("content/{app}/{name}/{id}/")] [ProducesResponseType(typeof(ContentsDto), 200)] - [ApiPermissionOrAnonymous(Permissions.AppContentsUpdate)] + [ApiPermissionOrAnonymous(Permissions.AppContentsUpsert)] [ApiCosts(1)] public async Task PostContent(string app, string name, DomainId id, [FromBody] NamedContentData request, [FromQuery] bool publish = false) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs index 8e920634a..7c83b07af 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs @@ -30,6 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// public bool DoNotScript { get; set; } = true; + /// + /// True to check referrers of this content. + /// + public bool CheckReferrers { get; set; } + /// /// True to turn off costly validation: Unique checks, asset checks and reference checks. Default: true. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs index 68e2df912..8ea57c571 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs @@ -34,13 +34,23 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// /// The new status when the type is set to 'ChangeStatus'. /// - public Status? Status { get; set; } + public Status Status { get; set; } /// /// The update type. /// public BulkUpdateType Type { get; set; } + /// + /// The optional schema id or name. + /// + public string? Schema { get; set; } + + /// + /// The expected version. + /// + public long ExpectedVersion { get; set; } = EtagVersion.Any; + public BulkUpdateJob ToJob() { return SimpleMapper.Map(this, new BulkUpdateJob()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 19e1939a7..693158bc2 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/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(x => nameof(x.DeleteContent), values)); } - if (content.CanUpdate) + if (content.CanUpdate && resources.CanUpdateContent(schema)) { - if (resources.CanUpdateContent(schema)) - { - AddPutLink("update", resources.Url(x => nameof(x.PutContent), values)); - } + AddPutLink("update", resources.Url(x => nameof(x.PutContent), values)); - if (resources.CanUpdateContentPartial(schema)) - { - AddPatchLink("patch", resources.Url(x => nameof(x.PatchContent), values)); - } + AddPatchLink("patch", resources.Url(x => nameof(x.PatchContent), values)); } return this; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ValidationResultDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ValidationResultDto.cs deleted file mode 100644 index f972ba1e9..000000000 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ValidationResultDto.cs +++ /dev/null @@ -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 - { - /// - /// The validation errors. - /// - public string[] Errors { get; set; } - - public static ValidationResultDto FromResult(ValidationResult result) - { - return new ValidationResultDto - { - Errors = ApiExceptionConverter.ToErrors(result.Errors).ToArray() - }; - } - } -} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs index f93a2da71..181450dde 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs @@ -7,6 +7,7 @@ using System; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.Contents; @@ -15,6 +16,8 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Queries; +using Squidex.Shared; +using Squidex.Shared.Identity; using Xunit; namespace Squidex.Domain.Apps.Entities.Contents @@ -24,15 +27,12 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly IContentQueryService contentQuery = A.Fake(); private readonly IContextProvider contextProvider = A.Fake(); private readonly ICommandBus commandBus = A.Dummy(); - private readonly Context requestContext = Context.Anonymous(); + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); private readonly BulkUpdateCommandMiddleware sut; public BulkUpdateCommandMiddlewareTests() { - A.CallTo(() => contextProvider.Context) - .Returns(requestContext); - sut = new BulkUpdateCommandMiddleware(contentQuery, contextProvider); } @@ -41,11 +41,9 @@ namespace Squidex.Domain.Apps.Entities.Contents { var command = new BulkUpdateContents(); - var context = new CommandContext(command, commandBus); - - await sut.HandleAsync(context); + var result = await PublishAsync(command); - Assert.True(context.PlainResult is BulkUpdateResult); + Assert.Empty(result); } [Fact] @@ -53,39 +51,63 @@ namespace Squidex.Domain.Apps.Entities.Contents { var command = new BulkUpdateContents { Jobs = Array.Empty() }; - 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._)) + .MustNotHaveHappened(); } [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 - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.Upsert, - Data = data - } - }, - SchemaId = schemaId - }; + var (id, data, query) = CreateTestData(true); - var context = new CommandContext(command, commandBus); + A.CallTo(() => contentQuery.QueryAsync(requestContext, A._, A.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._)) + .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._, A.That.Matches(x => x.JsonQuery == query))) + .Returns(ResultList.CreateFrom(1, CreateContent(id))); - var result = context.Result(); + var command = BulkCommand(BulkUpdateType.Upsert, query: query, data: data); - Assert.Single(result); - Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null)); + var result = await PublishAsync(command); + + Assert.Single(result, x => x.ContentId != default && x.Exception == null); A.CallTo(() => commandBus.PublishAsync( A.That.Matches(x => x.Data == data && x.ContentId.ToString().Length == 36))) @@ -93,32 +115,35 @@ namespace Squidex.Domain.Apps.Entities.Contents } [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 - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.Upsert, - Data = data, - Query = query - } - }, - SchemaId = schemaId - }; + var (_, data, _) = CreateTestData(false); - 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.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(); + var (_, data, query) = CreateTestData(false); + + var command = BulkCommand(BulkUpdateType.Upsert, query: query, data: data); + + var result = await PublishAsync(command); - Assert.Single(result); - 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.That.Matches(x => x.Data == data && x.ContentId.ToString().Length == 36))) @@ -128,30 +153,15 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public async Task Should_upsert_content_when_id_defined() { - var (id, data, _) = CreateTestData(false); - - var command = new BulkUpdateContents - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.Upsert, - Data = data, - Id = id - } - }, - SchemaId = schemaId - }; + SetupContext(Permissions.AppContentsUpsert); - 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(); + var result = await PublishAsync(command); - Assert.Single(result); - 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.That.Matches(x => x.Data == data && x.ContentId == id))) @@ -161,33 +171,15 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] 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._, A.That.Matches(x => x.JsonQuery == query))) - .Returns(ResultList.CreateFrom(1, CreateContent(id))); + var (id, data, _) = CreateTestData(true); - var command = new BulkUpdateContents - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.Upsert, - Data = data, - Query = query - } - }, - SchemaId = schemaId - }; + var command = BulkCommand(BulkUpdateType.Upsert, id: id, data: data); - var context = new CommandContext(command, commandBus); - - await sut.HandleAsync(context); + var result = await PublishAsync(command); - var result = context.Result(); - - Assert.Single(result); - 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.That.Matches(x => x.Data == data && x.ContentId == id))) @@ -195,97 +187,69 @@ namespace Squidex.Domain.Apps.Entities.Contents } [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._, A.That.Matches(x => x.JsonQuery == query))) - .Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id))); + var (id, _, _) = CreateTestData(false); - var command = new BulkUpdateContents - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.Upsert, - Data = data, - Query = query - } - }, - SchemaId = schemaId - }; + var command = BulkCommand(BulkUpdateType.ChangeStatus, id: id); - 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.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(); + var result = await PublishAsync(command); - Assert.Single(result); - Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainException)); + Assert.Single(result, x => x.ContentId == id && x.Exception is DomainForbiddenException); A.CallTo(() => commandBus.PublishAsync(A._)) .MustNotHaveHappened(); } [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 - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.ChangeStatus, - Id = id - } - }, - SchemaId = schemaId - }; - - var context = new CommandContext(command, commandBus); + var (id, _, _) = CreateTestData(false); - await sut.HandleAsync(context); + var command = BulkCommand(BulkUpdateType.Validate, id: id); - var result = context.Result(); + var result = await PublishAsync(command); - Assert.Single(result); - Assert.Equal(1, result.Count(x => x.ContentId == id)); + Assert.Single(result, x => x.ContentId == id && x.Exception == null); - A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.ContentId == id))) + A.CallTo(() => commandBus.PublishAsync( + A.That.Matches(x => x.ContentId == id))) .MustHaveHappened(); } [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 - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.ChangeStatus, - Query = query - } - }, - SchemaId = schemaId - }; - - var context = new CommandContext(command, commandBus); + var (id, _, _) = CreateTestData(false); - await sut.HandleAsync(context); + var command = BulkCommand(BulkUpdateType.Validate, id: id); - var result = context.Result(); + var result = await PublishAsync(command); - Assert.Single(result); - Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException)); + Assert.Single(result, x => x.ContentId == id && x.Exception is DomainForbiddenException); A.CallTo(() => commandBus.PublishAsync(A._)) .MustNotHaveHappened(); @@ -294,29 +258,15 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public async Task Should_delete_content() { - var (id, _, _) = CreateTestData(false); + SetupContext(Permissions.AppContentsDelete); - var command = new BulkUpdateContents - { - Jobs = new[] - { - new BulkUpdateJob - { - Type = BulkUpdateType.Delete, - Id = id - } - }, - SchemaId = schemaId - }; - - var context = new CommandContext(command, commandBus); + var (id, _, _) = CreateTestData(false); - await sut.HandleAsync(context); + var command = BulkCommand(BulkUpdateType.Delete, id: id); - var result = context.Result(); + var result = await PublishAsync(command); - Assert.Single(result); - Assert.Equal(1, result.Count(x => x.ContentId == id)); + Assert.Single(result, x => x.ContentId == id && x.Exception == null); A.CallTo(() => commandBus.PublishAsync( A.That.Matches(x => x.ContentId == id))) @@ -324,34 +274,59 @@ namespace Squidex.Domain.Apps.Entities.Contents } [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._)) + .MustNotHaveHappened(); + } + + private async Task PublishAsync(ICommand command) + { + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + return (context.PlainResult as BulkUpdateResult)!; + } + + private BulkUpdateContents BulkCommand(BulkUpdateType type, Query? query = null, DomainId? id = null, NamedContentData? data = null) + { + return new BulkUpdateContents { + AppId = appId, Jobs = new[] { - new BulkUpdateJob - { - Type = BulkUpdateType.Delete, - Query = query - } + new BulkUpdateJob { Type = type, Query = query, Id = id, Data = data! } }, 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(); + claimsIdentity.AddClaim(new Claim(SquidexClaimTypes.Permissions, permission)); - Assert.Single(result); - Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException)); + var requestContext = new Context(claimsPrincipal); - A.CallTo(() => commandBus.PublishAsync(A._)) - .MustNotHaveHappened(); + A.CallTo(() => contextProvider.Context) + .Returns(requestContext); + + return requestContext; } private static (DomainId Id, NamedContentData Data, Query? Query) CreateTestData(bool withQuery) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs index 062077628..808319b47 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Linq; using System.Threading.Tasks; using FakeItEasy; @@ -600,11 +599,9 @@ namespace Squidex.Domain.Apps.Entities.Contents var command = new ValidateContent(); - var result = await PublishAsync(command); - - result.ShouldBeEquivalent(new ValidationResult { Errors = Array.Empty() }); + await PublishAsync(command); - Assert.Equal(0, sut.Snapshot.Version); + Assert.Equal(0, sut.Version); } [Fact] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDomainObjectTests.cs index 5c7fa6bef..f1124daeb 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDomainObjectTests.cs @@ -160,9 +160,9 @@ namespace Squidex.Domain.Apps.Entities.Rules 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>.That.Matches(x => x.Payload is RuleManuallyTriggered))) diff --git a/frontend/app-config/webpack.config.js b/frontend/app-config/webpack.config.js index cf5deeebe..4987a78f6 100644 --- a/frontend/app-config/webpack.config.js +++ b/frontend/app-config/webpack.config.js @@ -43,7 +43,7 @@ module.exports = function (env) { const isTests = env && env.target === 'tests'; const isTestCoverage = env && env.coverage; const isAnalyzing = isProduction && env.analyze; - const isAot = !isDevServer; + const isAot = !isDevServer && !isTests && !isTestCoverage; const configFile = isTests ? 'tsconfig.spec.json' : 'tsconfig.app.json'; diff --git a/frontend/app/app.module.ts b/frontend/app/app.module.ts index fa0dedd2d..90bcfbf7e 100644 --- a/frontend/app/app.module.ts +++ b/frontend/app/app.module.ts @@ -1,3 +1,4 @@ + /* * Squidex Headless CMS * @@ -19,7 +20,7 @@ import { SqxShellModule } from './shell'; DateHelper.setlocale(window['options']?.more?.culture); -export function configApiUrl() { +function configApiUrl() { const baseElements = document.getElementsByTagName('base'); let baseHref = null; @@ -35,27 +36,27 @@ export function configApiUrl() { if (baseHref.indexOf(window.location.protocol) === 0) { return new ApiUrlConfig(baseHref); } 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']); } -export function configTitles() { +function configTitles() { return new TitlesConfig(undefined, 'i18n:common.product'); } -export function configDecimalSeparator() { +function configDecimalSeparator() { return new DecimalSeparatorConfig('.'); } -export function configCurrency() { +function configCurrency() { return new CurrencyConfig('EUR', '€', true); } -export function configLocalizerService() { +function configLocalizerService() { if (process.env.NODE_ENV === 'production') { return new LocalizerService(window['texts']); } else { diff --git a/frontend/app/app.routes.ts b/frontend/app/app.routes.ts index e8ed6b9e2..5f4e58d98 100644 --- a/frontend/app/app.routes.ts +++ b/frontend/app/app.routes.ts @@ -10,7 +10,7 @@ import { RouterModule, Routes } from '@angular/router'; import { AppMustExistGuard, LoadAppsGuard, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, UnsetAppGuard } from './shared'; import { AppAreaComponent, ForbiddenPageComponent, HomePageComponent, InternalAreaComponent, LoginPageComponent, LogoutPageComponent, NotFoundPageComponent } from './shell'; -export const routes: Routes = [ +const routes: Routes = [ { path: '', component: HomePageComponent, diff --git a/frontend/app/features/administration/pages/users/users-page.component.html b/frontend/app/features/administration/pages/users/users-page.component.html index 429fd1e25..4db957e2d 100644 --- a/frontend/app/features/administration/pages/users/users-page.component.html +++ b/frontend/app/features/administration/pages/users/users-page.component.html @@ -58,7 +58,7 @@ - + diff --git a/frontend/app/features/administration/pages/users/users-page.component.ts b/frontend/app/features/administration/pages/users/users-page.component.ts index e45532772..ba0f33629 100644 --- a/frontend/app/features/administration/pages/users/users-page.component.ts +++ b/frontend/app/features/administration/pages/users/users-page.component.ts @@ -28,7 +28,7 @@ export class UsersPageComponent extends ResourceOwner implements OnInit { super(); this.own( - this.usersState.usersQuery + this.usersState.query .subscribe(q => this.usersFilter.setValue(q || ''))); } diff --git a/frontend/app/features/administration/services/event-consumers.service.ts b/frontend/app/features/administration/services/event-consumers.service.ts index dd3996378..de78b8efa 100644 --- a/frontend/app/features/administration/services/event-consumers.service.ts +++ b/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( map(({ items, _links }) => { - const eventConsumers = items.map(item => parseEventConsumer(item)); + const eventConsumers = items.map(parseEventConsumer); return new EventConsumersDto(eventConsumers, _links); }), diff --git a/frontend/app/features/administration/services/users.service.ts b/frontend/app/features/administration/services/users.service.ts index 0012ed121..b6c32af22 100644 --- a/frontend/app/features/administration/services/users.service.ts +++ b/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( map(({ total, items, _links }) => { - const users = items.map(item => parseUser(item)); + const users = items.map(parseUser); return new UsersDto(total, users, _links); }), diff --git a/frontend/app/features/administration/state/users.forms.ts b/frontend/app/features/administration/state/users.forms.ts index 31c7aa396..1029aa6f3 100644 --- a/frontend/app/features/administration/state/users.forms.ts +++ b/frontend/app/features/administration/state/users.forms.ts @@ -54,10 +54,12 @@ export class UserForm extends Form { protected transformLoad(user: Partial) { const permissions = user.permissions?.join('\n') || ''; - return { ...user, permissions: permissions }; + return { ...user, permissions }; } 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 }; } } \ No newline at end of file diff --git a/frontend/app/features/administration/state/users.state.spec.ts b/frontend/app/features/administration/state/users.state.spec.ts index 1e47f0e05..24a3a1419 100644 --- a/frontend/app/features/administration/state/users.state.spec.ts +++ b/frontend/app/features/administration/state/users.state.spec.ts @@ -6,7 +6,7 @@ */ 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 { onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; @@ -46,10 +46,10 @@ describe('UsersState', () => { usersState.load().subscribe(); + expect(usersState.snapshot.users).toEqual([user1, user2]); expect(usersState.snapshot.isLoaded).toBeTruthy(); expect(usersState.snapshot.isLoading).toBeFalsy(); - expect(usersState.snapshot.users).toEqual([user1, user2]); - expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); + expect(usersState.snapshot.total).toEqual(200); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); @@ -97,7 +97,7 @@ describe('UsersState', () => { usersService.setup(x => x.getUsers(10, 10, undefined)) .returns(() => of(new UsersDto(200, []))).verifiable(); - usersState.setPager(new Pager(200, 1, 10)).subscribe(); + usersState.page({ page: 1, pageSize: 10 }).subscribe(); expect().nothing(); }); @@ -108,7 +108,7 @@ describe('UsersState', () => { 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', () => { @@ -239,7 +239,23 @@ describe('UsersState', () => { usersState.create(request).subscribe(); 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); }); }); }); \ No newline at end of file diff --git a/frontend/app/features/administration/state/users.state.ts b/frontend/app/features/administration/state/users.state.ts index 3434c439c..aea22a04e 100644 --- a/frontend/app/features/administration/state/users.state.ts +++ b/frontend/app/features/administration/state/users.state.ts @@ -7,26 +7,14 @@ import { Injectable } from '@angular/core'; 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 { catchError, finalize, tap } from 'rxjs/operators'; import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service'; -interface Snapshot { +interface Snapshot extends ListState { // The current users. - users: UsersList; - - // 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; + users: ReadonlyArray; // The selected user. selectedUser?: UserDto | null; @@ -43,11 +31,11 @@ export class UsersState extends State { public users = this.project(x => x.users); - public usersPager = - this.project(x => x.usersPager); + public paging = + this.project(x => getPagingInfo(x, x.users.length)); - public usersQuery = - this.project(x => x.usersQuery); + public query = + this.project(x => x.query); public selectedUser = this.project(x => x.selectedUser); @@ -67,14 +55,16 @@ export class UsersState extends State { ) { super({ users: [], - usersPager: new Pager(0) + page: 0, + pageSize: 10, + total: 0 }); } public select(id: string | null): Observable { return this.loadUser(id).pipe( tap(selectedUser => { - this.next(s => ({ ...s, selectedUser })); + this.next({ selectedUser }); }), shareSubscribed(this.dialogs, { silent: true })); } @@ -96,8 +86,8 @@ export class UsersState extends State { public loadAndListen(synchronizer: StateSynchronizer) { synchronizer.mapTo(this) .keep('selectedUser') - .withPager('usersPager', 'users', 10) - .withString('usersQuery', 'q') + .withPaging('users', 10) + .withString('query', 'q') .whenSynced(() => this.loadInternal(false)) .build(); } @@ -113,18 +103,18 @@ export class UsersState extends State { private loadInternal(isReload: boolean): Observable { this.next({ isLoading: true }); + const { page, pageSize, query } = this.snapshot; + return this.usersService.getUsers( - this.snapshot.usersPager.pageSize, - this.snapshot.usersPager.skip, - this.snapshot.usersQuery).pipe( + pageSize, + pageSize * page, + query).pipe( tap(({ total, items: users, canCreate }) => { if (isReload) { this.dialogs.notifyInfo('i18n:users.reloaded'); } this.next(s => { - const usersPager = s.usersPager.setCount(total); - let selectedUser = s.selectedUser; if (selectedUser) { @@ -133,11 +123,11 @@ export class UsersState extends State { return { ...s, canCreate, + users, isLoaded: true, isLoading: false, selectedUser, - users, - usersPager + total }; }); }), @@ -151,10 +141,9 @@ export class UsersState extends State { return this.usersService.postUser(request).pipe( tap(created => { this.next(s => { - const users = [created, ...s.users]; - const usersPager = s.usersPager.incrementCount(); + const users = [created, ...s.users].slice(0, s.pageSize); - return { ...s, users, usersPager }; + return { ...s, users, total: s.total + 1 }; }); }), shareSubscribed(this.dialogs, { silent: true })); @@ -185,13 +174,13 @@ export class UsersState extends State { } public search(query: string): Observable { - this.next(s => ({ ...s, usersPager: s.usersPager.reset(), usersQuery: query })); + this.next({ query, page: 0 }); return this.loadInternal(false); } - public setPager(usersPager: Pager) { - this.next({ usersPager }); + public page(paging: { page: number, pageSize: number }) { + this.next(paging); return this.loadInternal(false); } diff --git a/frontend/app/features/assets/pages/assets-filters-page.component.html b/frontend/app/features/assets/pages/assets-filters-page.component.html index 41f7258cf..730c9cb1e 100644 --- a/frontend/app/features/assets/pages/assets-filters-page.component.html +++ b/frontend/app/features/assets/pages/assets-filters-page.component.html @@ -16,7 +16,7 @@ diff --git a/frontend/app/features/assets/pages/assets-page.component.html b/frontend/app/features/assets/pages/assets-page.component.html index 1e52189ea..a2debdc6a 100644 --- a/frontend/app/features/assets/pages/assets-page.component.html +++ b/frontend/app/features/assets/pages/assets-page.component.html @@ -26,13 +26,14 @@
+
@@ -61,11 +62,11 @@
- +
- + diff --git a/frontend/app/features/content/declarations.ts b/frontend/app/features/content/declarations.ts index 7b1c41dcf..85b34b406 100644 --- a/frontend/app/features/content/declarations.ts +++ b/frontend/app/features/content/declarations.ts @@ -7,11 +7,13 @@ export * from './pages/comments/comments-page.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-page.component'; -export * from './pages/content/content-section.component'; -export * from './pages/content/field-languages.component'; +export * from './pages/content/editor/content-editor.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-page.component'; export * from './pages/contents/custom-view-editor.component'; diff --git a/frontend/app/features/content/module.ts b/frontend/app/features/content/module.ts index 4848f56f9..bbea70e91 100644 --- a/frontend/app/features/content/module.ts +++ b/frontend/app/features/content/module.ts @@ -10,7 +10,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; 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 = [ { @@ -89,6 +89,7 @@ const routes: Routes = [ CommentsPageComponent, ContentComponent, ContentCreatorComponent, + ContentEditorComponent, ContentEventComponent, ContentFieldComponent, ContentHistoryPageComponent, @@ -96,12 +97,14 @@ const routes: Routes = [ ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, + ContentsColumnsPipe, ContentPageComponent, ContentSectionComponent, ContentSelectorComponent, ContentSelectorItemComponent, ContentsFiltersPageComponent, ContentsPageComponent, + ContentReferencesComponent, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, diff --git a/frontend/app/features/content/pages/content/content-page.component.html b/frontend/app/features/content/pages/content/content-page.component.html index c992a5e47..1ea1aa459 100644 --- a/frontend/app/features/content/pages/content/content-page.component.html +++ b/frontend/app/features/content/pages/content/content-page.component.html @@ -7,22 +7,29 @@ - - - - {{ 'contents.editTitle' | sqxTranslate }} - - + {{ 'contents.createTitle' | sqxTranslate }} - + + + + + + + + + - - + + @@ -43,15 +50,25 @@
- - - + + + + + + + - - + + @@ -70,30 +87,41 @@ - - - + + + + + + + + + + + + + + + -
- - -
-
- + + + +
diff --git a/frontend/app/features/content/pages/content/content-page.component.scss b/frontend/app/features/content/pages/content/content-page.component.scss index aeff91dbe..7f2a04d32 100644 --- a/frontend/app/features/content/pages/content/content-page.component.scss +++ b/frontend/app/features/content/pages/content/content-page.component.scss @@ -4,4 +4,12 @@ .btn-outline-secondary { color: $color-text; +} + +.nav-tabs2 { + @include absolute(auto, auto, 0, 5rem); + + a { + padding-bottom: 1.5rem; + } } \ No newline at end of file diff --git a/frontend/app/features/content/pages/content/content-page.component.ts b/frontend/app/features/content/pages/content/content-page.component.ts index ede263e82..d93ebca4a 100644 --- a/frontend/app/features/content/pages/content/content-page.component.ts +++ b/frontend/app/features/content/pages/content/content-page.component.ts @@ -7,11 +7,18 @@ // 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 { 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 { filter, tap } from 'rxjs/operators'; +import { ContentReferencesComponent } from './references/content-references.component'; + +const TABS: ReadonlyArray = [ + 'i18n:contents.contentTab.editor', + 'i18n:contents.contentTab.references', + 'i18n:contents.contentTab.referencing' +]; @Component({ selector: 'sqx-content-page', @@ -25,6 +32,9 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD private isLoadingContent: boolean; private autoSaveKey: AutoSaveKey; + @ViewChild(ContentReferencesComponent) + public references: ContentReferencesComponent; + public schema: SchemaDetailsDto; public formContext: any; @@ -34,6 +44,9 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD public contentForm: EditContentForm; public contentFormCompare: EditContentForm | null = null; + public selectableTabs = TABS; + public selectedTab = this.selectableTabs[0]; + public dropdown = new ModalModel(); public language: AppLanguageDto; @@ -92,21 +105,16 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD 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(content.data, true); - } + this.loadContent(dataCloned || content?.data || {}, true); - const clone = this.tempService.fetch(); - - if (clone) { - this.loadContent(clone, true); - } else if (isNewContent && autosaved && this.contentForm.hasChanges(autosaved)) { + if (isNewContent && dataAutosaved && this.contentForm.hasChanges(dataAutosaved)) { this.dialogs.confirm('i18n:contents.unsavedChangesTitle', 'i18n:contents.unsavedChangesText') .subscribe(shouldLoad => { if (shouldLoad) { - this.loadContent(autosaved, false); + this.loadContent(dataAutosaved, false); } else { 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() { this.saveContent(true); } @@ -257,10 +277,6 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD this.isLoadingContent = false; } } - - public trackBySection(_index: number, section: FieldSection) { - return section.separator?.fieldId; - } } function isOtherContent(lhs: ContentDto | null | undefined, rhs: ContentDto | null | undefined) { diff --git a/frontend/app/features/content/pages/content/editor/content-editor.component.html b/frontend/app/features/content/pages/content/editor/content-editor.component.html new file mode 100644 index 000000000..386366201 --- /dev/null +++ b/frontend/app/features/content/pages/content/editor/content-editor.component.html @@ -0,0 +1,24 @@ + + + + + + +
+ + +
+
\ No newline at end of file diff --git a/frontend/app/features/content/pages/content/field-languages.component.scss b/frontend/app/features/content/pages/content/editor/content-editor.component.scss similarity index 100% rename from frontend/app/features/content/pages/content/field-languages.component.scss rename to frontend/app/features/content/pages/content/editor/content-editor.component.scss diff --git a/frontend/app/features/content/pages/content/editor/content-editor.component.ts b/frontend/app/features/content/pages/content/editor/content-editor.component.ts new file mode 100644 index 000000000..fa2705eb6c --- /dev/null +++ b/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(); + + @Output() + public loadLatest = new EventEmitter(); + + @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; + + @Input() + public language: AppLanguageDto; + + public trackBySection(_index: number, section: FieldSection) { + return section.separator?.fieldId; + } +} \ No newline at end of file diff --git a/frontend/app/features/content/pages/content/content-field.component.html b/frontend/app/features/content/pages/content/editor/content-field.component.html similarity index 100% rename from frontend/app/features/content/pages/content/content-field.component.html rename to frontend/app/features/content/pages/content/editor/content-field.component.html diff --git a/frontend/app/features/content/pages/content/content-field.component.scss b/frontend/app/features/content/pages/content/editor/content-field.component.scss similarity index 100% rename from frontend/app/features/content/pages/content/content-field.component.scss rename to frontend/app/features/content/pages/content/editor/content-field.component.scss diff --git a/frontend/app/features/content/pages/content/content-field.component.ts b/frontend/app/features/content/pages/content/editor/content-field.component.ts similarity index 98% rename from frontend/app/features/content/pages/content/content-field.component.ts rename to frontend/app/features/content/pages/content/editor/content-field.component.ts index bea1b080f..f44a23fbe 100644 --- a/frontend/app/features/content/pages/content/content-field.component.ts +++ b/frontend/app/features/content/pages/content/editor/content-field.component.ts @@ -20,7 +20,7 @@ export class ContentFieldComponent implements OnChanges { public languageChange = new EventEmitter(); @Input() - public compact = false; + public isCompact = false; @Input() public form: EditContentForm; @@ -52,7 +52,7 @@ export class ContentFieldComponent implements OnChanges { } 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; diff --git a/frontend/app/features/content/pages/content/content-section.component.html b/frontend/app/features/content/pages/content/editor/content-section.component.html similarity index 81% rename from frontend/app/features/content/pages/content/content-section.component.html rename to frontend/app/features/content/pages/content/editor/content-section.component.html index 179d8f92a..1ee96da7b 100644 --- a/frontend/app/features/content/pages/content/content-section.component.html +++ b/frontend/app/features/content/pages/content/editor/content-section.component.html @@ -3,7 +3,7 @@
@@ -17,10 +17,10 @@
-
+
implements OnChanges { @Output() public languageChange = new EventEmitter(); @Input() - public compact = false; + public isCompact = false; @Input() public form: EditContentForm; @@ -42,21 +47,29 @@ export class ContentSectionComponent implements OnChanges { @Input() public languages: ReadonlyArray; - public isCollapsed: boolean; - - constructor( + constructor(changeDetector: ChangeDetectorRef, private readonly localStore: LocalStoreService ) { + super(changeDetector, { + isCollapsed: false + }); + + this.changes.subscribe(state => { + this.localStore.setBoolean(this.configKey(), state.isCollapsed); + }); } public ngOnChanges() { - this.isCollapsed = this.localStore.getBoolean(this.configKey()); + const isCollapsed = this.localStore.getBoolean(this.configKey()); + + this.next({ isCollapsed }); } public toggle() { - this.isCollapsed = !this.isCollapsed; - - this.localStore.setBoolean(this.configKey(), this.isCollapsed); + this.next(s => ({ + ...s, + isCollapsed: !s.isCollapsed + })); } public getFieldFormCompare(formState: FieldForm) { diff --git a/frontend/app/features/content/pages/content/field-languages.component.html b/frontend/app/features/content/pages/content/editor/field-languages.component.html similarity index 100% rename from frontend/app/features/content/pages/content/field-languages.component.html rename to frontend/app/features/content/pages/content/editor/field-languages.component.html diff --git a/frontend/app/features/content/pages/content/editor/field-languages.component.scss b/frontend/app/features/content/pages/content/editor/field-languages.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/app/features/content/pages/content/field-languages.component.ts b/frontend/app/features/content/pages/content/editor/field-languages.component.ts similarity index 100% rename from frontend/app/features/content/pages/content/field-languages.component.ts rename to frontend/app/features/content/pages/content/editor/field-languages.component.ts diff --git a/frontend/app/features/content/pages/content/references/content-references.component.html b/frontend/app/features/content/pages/content/references/content-references.component.html new file mode 100644 index 000000000..052e1f355 --- /dev/null +++ b/frontend/app/features/content/pages/content/references/content-references.component.html @@ -0,0 +1,20 @@ + + + + + +
+
+ + + + +
\ No newline at end of file diff --git a/frontend/app/features/content/pages/content/references/content-references.component.scss b/frontend/app/features/content/pages/content/references/content-references.component.scss new file mode 100644 index 000000000..0ab531df6 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/frontend/app/features/content/pages/content/references/content-references.component.ts b/frontend/app/features/content/pages/content/references/content-references.component.ts new file mode 100644 index 000000000..33ae19b40 --- /dev/null +++ b/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; + } +} \ No newline at end of file diff --git a/frontend/app/features/content/pages/contents/contents-filters-page.component.html b/frontend/app/features/content/pages/contents/contents-filters-page.component.html index 09351f80a..4b578fb50 100644 --- a/frontend/app/features/content/pages/contents/contents-filters-page.component.html +++ b/frontend/app/features/content/pages/contents/contents-filters-page.component.html @@ -6,7 +6,7 @@ @@ -18,7 +18,7 @@ @@ -28,7 +28,7 @@ diff --git a/frontend/app/features/content/pages/contents/contents-page.component.html b/frontend/app/features/content/pages/contents/contents-page.component.html index 241524d64..2f844dbb1 100644 --- a/frontend/app/features/content/pages/contents/contents-page.component.html +++ b/frontend/app/features/content/pages/contents/contents-page.component.html @@ -8,7 +8,7 @@
- + @@ -19,7 +19,7 @@
@@ -97,7 +97,7 @@ @@ -126,7 +126,7 @@ - + diff --git a/frontend/app/features/content/pages/sidebar/sidebar-page.component.ts b/frontend/app/features/content/pages/sidebar/sidebar-page.component.ts index e54fee3d8..9f8f7e29a 100644 --- a/frontend/app/features/content/pages/sidebar/sidebar-page.component.ts +++ b/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') { const { height } = event.data; - this.iframe.nativeElement.height = height + 'px'; + this.iframe.nativeElement.height = `${height}px`; } else if (type === 'navigate') { const { url } = event.data; diff --git a/frontend/app/features/content/shared/forms/array-item.component.html b/frontend/app/features/content/shared/forms/array-item.component.html index f9a5a0daa..218987d44 100644 --- a/frontend/app/features/content/shared/forms/array-item.component.html +++ b/frontend/app/features/content/shared/forms/array-item.component.html @@ -23,10 +23,10 @@ - -
@@ -42,7 +42,7 @@
-
+
implements OnChanges { @Output() public remove = new EventEmitter(); @@ -66,9 +71,11 @@ export class ArrayItemComponent implements OnChanges { public title: Observable; - constructor( - private readonly changeDetector: ChangeDetectorRef + constructor(changeDetector: ChangeDetectorRef ) { + super(changeDetector, { + isCollapsed: false + }); } public ngOnChanges(changes: SimpleChanges) { @@ -98,15 +105,11 @@ export class ArrayItemComponent implements OnChanges { } public collapse() { - this.isCollapsed = true; - - this.changeDetector.markForCheck(); + this.next({ isCollapsed: true }); } public expand() { - this.isCollapsed = false; - - this.changeDetector.markForCheck(); + this.next({ isCollapsed: false }); } public moveTop() { diff --git a/frontend/app/features/content/shared/forms/assets-editor.component.ts b/frontend/app/features/content/shared/forms/assets-editor.component.ts index e5a8586d6..4c6ee2399 100644 --- a/frontend/app/features/content/shared/forms/assets-editor.component.ts +++ b/frontend/app/features/content/shared/forms/assets-editor.component.ts @@ -46,8 +46,6 @@ interface State { changeDetection: ChangeDetectionStrategy.OnPush }) export class AssetsEditorComponent extends StatefulControlComponent> implements OnInit { - public isCompact = false; - public assetsDialog = new DialogModel(); constructor(changeDetector: ChangeDetectorRef, @@ -99,16 +97,19 @@ export class AssetsEditorComponent extends StatefulControlComponent ({ ...s, isCompact: isCompact })); + this.next({ isCompact }); } public setAssets(assets: ReadonlyArray) { - this.next(s => ({ ...s, assets })); + this.next({ assets }); } public addFiles(files: ReadonlyArray) { 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 ({ ...s, assetFiles: s.assetFiles.removed(file) })); + this.next(s => ({ + ...s, assetFiles: + s.assetFiles.removed(file) + })); } public changeView(isListView: boolean) { - this.next(s => ({ ...s, isListView })); + this.next({ isListView }); this.localStore.setBoolean('squidex.assets.list-view', isListView); } diff --git a/frontend/app/features/content/shared/forms/stock-photo-editor.component.ts b/frontend/app/features/content/shared/forms/stock-photo-editor.component.ts index 688b0973f..4b9f18d23 100644 --- a/frontend/app/features/content/shared/forms/stock-photo-editor.component.ts +++ b/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 { 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 { // True when loading assets. isLoading?: boolean; @@ -19,12 +25,6 @@ interface State { 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({ selector: 'sqx-stock-photo-editor', styleUrls: ['./stock-photo-editor.component.scss'], @@ -93,7 +93,7 @@ export class StockPhotoEditorComponent extends StatefulControlComponent ({ ...s, isCompact: isCompact })); + this.next({ isCompact }); } public selectPhoto(photo: StockPhotoDto) { diff --git a/frontend/app/features/content/shared/list/content-list-cell.directive.ts b/frontend/app/features/content/shared/list/content-list-cell.directive.ts index d8a296bdf..d6e312e58 100644 --- a/frontend/app/features/content/shared/list/content-list-cell.directive.ts +++ b/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 { MetaFields, RootFieldDto, TableField, Types } from '@app/shared'; +import { ContentDto, MetaFields, RootFieldDto, TableField, Types } from '@app/shared'; export function getTableWidth(fields: ReadonlyArray) { 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) { + let columns = 1; + + for (const content of value) { + columns = Math.max(columns, content.referenceFields.length); + } + + return columns; + } +} + @Pipe({ name: 'sqxContentListWidth', pure: true diff --git a/frontend/app/features/content/shared/list/content-list-field.component.html b/frontend/app/features/content/shared/list/content-list-field.component.html index 3bafdb10e..91e675751 100644 --- a/frontend/app/features/content/shared/list/content-list-field.component.html +++ b/frontend/app/features/content/shared/list/content-list-field.component.html @@ -99,7 +99,7 @@ - + \ No newline at end of file diff --git a/frontend/app/features/content/shared/list/content-list-field.component.ts b/frontend/app/features/content/shared/list/content-list-field.component.ts index 15caacb1a..a52b0813d 100644 --- a/frontend/app/features/content/shared/list/content-list-field.component.ts +++ b/frontend/app/features/content/shared/list/content-list-field.component.ts @@ -5,9 +5,14 @@ * 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 { 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({ selector: 'sqx-content-list-field', @@ -15,7 +20,7 @@ import { ContentDto, getContentValue, LanguageDto, MetaFields, RootFieldDto, Tab templateUrl: './content-list-field.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) -export class ContentListFieldComponent implements OnChanges { +export class ContentListFieldComponent extends StatefulComponent implements OnChanges { @Input() public field: TableField; @@ -31,7 +36,11 @@ export class ContentListFieldComponent implements OnChanges { @Input() public language: LanguageDto; - public value: any; + constructor(changeDetector: ChangeDetectorRef) { + super(changeDetector, { + formatted: '' + }); + } public ngOnChanges() { this.reset(); @@ -49,7 +58,7 @@ export class ContentListFieldComponent implements OnChanges { } } - this.value = formatted; + this.next({ formatted }); } } diff --git a/frontend/app/features/content/shared/list/content-value-editor.component.ts b/frontend/app/features/content/shared/list/content-value-editor.component.ts index 566df9c9f..64d0d5add 100644 --- a/frontend/app/features/content/shared/list/content-value-editor.component.ts +++ b/frontend/app/features/content/shared/list/content-value-editor.component.ts @@ -22,5 +22,5 @@ export class ContentValueEditorComponent { @Input() public form: FormGroup; - public uniqueId = MathHelper.guid(); + public readonly uniqueId = MathHelper.guid(); } \ No newline at end of file diff --git a/frontend/app/features/content/shared/references/content-creator.component.html b/frontend/app/features/content/shared/references/content-creator.component.html index 3c4a8fad8..ff268187f 100644 --- a/frontend/app/features/content/shared/references/content-creator.component.html +++ b/frontend/app/features/content/shared/references/content-creator.component.html @@ -38,7 +38,7 @@
@@ -54,7 +54,7 @@ @@ -83,7 +83,7 @@ - + diff --git a/frontend/app/features/content/shared/references/reference-item.component.html b/frontend/app/features/content/shared/references/reference-item.component.html index 10318c83d..c3afb7707 100644 --- a/frontend/app/features/content/shared/references/reference-item.component.html +++ b/frontend/app/features/content/shared/references/reference-item.component.html @@ -11,6 +11,11 @@ + + VALID + INVALID + + @@ -30,7 +35,7 @@ -
\ No newline at end of file diff --git a/frontend/app/features/dashboard/pages/cards/content-summary-card.component.ts b/frontend/app/features/dashboard/pages/cards/content-summary-card.component.ts index a22fd86cd..ac2129d57 100644 --- a/frontend/app/features/dashboard/pages/cards/content-summary-card.component.ts +++ b/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 { 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({ selector: 'sqx-content-summary-card', @@ -17,19 +22,19 @@ import { AppDto, ContentsService, fadeAnimation, Types } from '@app/shared'; ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class ContentSummaryCardComponent implements OnInit { +export class ContentSummaryCardComponent extends StatefulComponent implements OnInit { @Input() public app: AppDto; @Input() public options: any; - public itemCount = 0; - - constructor( - private readonly changeDetector: ChangeDetectorRef, + constructor(changeDetector: ChangeDetectorRef, private readonly contentsService: ContentsService ) { + super(changeDetector, { + itemCount: 0 + }); } public ngOnInit() { @@ -46,13 +51,11 @@ export class ContentSummaryCardComponent implements OnInit { query.take = 0; this.contentsService.getContents(this.app.name, this.options.schema, { query }) - .subscribe(dto => { - this.itemCount = dto.total; - - this.changeDetector.detectChanges(); + .subscribe(({ total: itemCount }) => { + this.next({ itemCount }); }, () => { - this.itemCount = 0; + this.next({ itemCount: 0 }); }); } } \ No newline at end of file diff --git a/frontend/app/features/rules/pages/events/rule-events-page.component.html b/frontend/app/features/rules/pages/events/rule-events-page.component.html index eab2ecc61..0a0f96f29 100644 --- a/frontend/app/features/rules/pages/events/rule-events-page.component.html +++ b/frontend/app/features/rules/pages/events/rule-events-page.component.html @@ -92,7 +92,7 @@ - + diff --git a/frontend/app/features/settings/pages/contributors/contributors-page.component.html b/frontend/app/features/settings/pages/contributors/contributors-page.component.html index a797ed0b7..ef47064e6 100644 --- a/frontend/app/features/settings/pages/contributors/contributors-page.component.html +++ b/frontend/app/features/settings/pages/contributors/contributors-page.component.html @@ -41,11 +41,12 @@
- + - +
@@ -60,7 +61,7 @@
- + diff --git a/frontend/app/framework/angular/forms/control-errors.component.ts b/frontend/app/framework/angular/forms/control-errors.component.ts index 4dd217379..791dc567e 100644 --- a/frontend/app/framework/angular/forms/control-errors.component.ts +++ b/frontend/app/framework/angular/forms/control-errors.component.ts @@ -28,7 +28,7 @@ interface State { export class ControlErrorsComponent extends StatefulComponent implements OnChanges, OnDestroy { private displayFieldName: string; private control: AbstractControl; - private originalMarkAsTouched: any; + private controlOriginalMarkAsTouched: any; @Input() public for: string | AbstractControl; @@ -95,12 +95,12 @@ export class ControlErrorsComponent extends StatefulComponent implements this.createMessages(); })); - this.originalMarkAsTouched = this.control.markAsTouched; + this.controlOriginalMarkAsTouched = this.control.markAsTouched; const self = this; this.control['markAsTouched'] = function () { - self.originalMarkAsTouched.apply(this, arguments); + self.controlOriginalMarkAsTouched.apply(this, arguments); self.createMessages(); }; @@ -111,13 +111,13 @@ export class ControlErrorsComponent extends StatefulComponent implements } private unsetCustomMarkAsTouchedFunction() { - if (this.control && this.originalMarkAsTouched) { - this.control['markAsTouched'] = this.originalMarkAsTouched; + if (this.control && this.controlOriginalMarkAsTouched) { + this.control['markAsTouched'] = this.controlOriginalMarkAsTouched; } } private createMessages() { - const errors: string[] = []; + const errorMessages: string[] = []; if (this.control && this.control.invalid && this.isTouched && this.control.errors) { for (const key in this.control.errors) { @@ -125,18 +125,16 @@ export class ControlErrorsComponent extends StatefulComponent implements const message = formatError(this.localizer, this.displayFieldName, key, this.control.errors[key], this.control.value); if (Types.isString(message)) { - errors.push(message); + errorMessages.push(message); } else if (Types.isArray(message)) { for (const error of message) { - errors.push(error); + errorMessages.push(error); } } } } } - if (errors.length !== this.snapshot.errorMessages.length || errors.length > 0) { - this.next(s => ({ ...s, errorMessages: errors })); - } + this.next({ errorMessages }); } } diff --git a/frontend/app/framework/angular/forms/editors/autocomplete.component.ts b/frontend/app/framework/angular/forms/editors/autocomplete.component.ts index de35bc71b..290dd01f9 100644 --- a/frontend/app/framework/angular/forms/editors/autocomplete.component.ts +++ b/frontend/app/framework/angular/forms/editors/autocomplete.component.ts @@ -231,10 +231,10 @@ export class AutocompleteComponent extends StatefulControlComponent ({ ...s, isLoading: true })); + this.next({ isLoading: true }); } else { this.timer = setTimeout(() => { - this.next(s => ({ ...s, isLoading: false })); + this.next({ isLoading: false }); }, 250); } } @@ -248,7 +248,7 @@ export class AutocompleteComponent extends StatefulControlComponent ({ ...s, suggestedIndex })); + this.next({ suggestedIndex }); } private up() { diff --git a/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts b/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts index e4c5a71c2..0010c773f 100644 --- a/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts +++ b/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts @@ -153,7 +153,7 @@ export class CheckboxGroupComponent extends StatefulControlComponent obj.indexOf(x.value) >= 0); } - this.next(s => ({ ...s, checkedValues })); + this.next({ checkedValues }); } public check(isChecked: boolean, value: TagValue) { @@ -165,7 +165,7 @@ export class CheckboxGroupComponent extends StatefulControlComponent ({ ...s, checkedValues })); + this.next({ checkedValues }); this.callChange(checkedValues.map(x => x.id)); } diff --git a/frontend/app/framework/angular/forms/editors/code-editor.component.ts b/frontend/app/framework/angular/forms/editors/code-editor.component.ts index bf4d6a10f..de1a5bab4 100644 --- a/frontend/app/framework/angular/forms/editors/code-editor.component.ts +++ b/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 }) -export class CodeEditorComponent extends StatefulControlComponent implements AfterViewInit, FocusComponent { +export class CodeEditorComponent extends StatefulControlComponent<{}, string> implements AfterViewInit, FocusComponent { private valueChanged = new Subject(); private aceEditor: any; private value: string; - private isDisabled = false; @ViewChild('editor', { static: false }) public editor: ElementRef; @@ -48,7 +47,7 @@ export class CodeEditorComponent extends StatefulControlComponent ({ ...s, value: obj, foreground })); + this.next({ value, foreground }); } } diff --git a/frontend/app/framework/angular/forms/editors/date-time-editor.component.html b/frontend/app/framework/angular/forms/editors/date-time-editor.component.html index f9f763363..f94f2ede9 100644 --- a/frontend/app/framework/angular/forms/editors/date-time-editor.component.html +++ b/frontend/app/framework/angular/forms/editors/date-time-editor.component.html @@ -2,10 +2,10 @@
- -
diff --git a/frontend/app/framework/angular/forms/editors/date-time-editor.component.ts b/frontend/app/framework/angular/forms/editors/date-time-editor.component.ts index 9e5bdddd4..fd5e051bd 100644 --- a/frontend/app/framework/angular/forms/editors/date-time-editor.component.ts +++ b/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 }; +interface State { + // True when the editor is in local mode. + isLocal: boolean; +} + @Component({ selector: 'sqx-date-time-editor', styleUrls: ['./date-time-editor.component.scss'], @@ -29,7 +34,7 @@ const NO_EMIT = { emitEvent: false }; ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class DateTimeEditorComponent extends StatefulControlComponent<{}, string | null> implements OnInit, AfterViewInit, FocusComponent { +export class DateTimeEditorComponent extends StatefulControlComponent implements OnInit, AfterViewInit, FocusComponent { private readonly hideDateButtonsSettings: boolean; private readonly hideDateTimeModeButtonSetting: boolean; private picker: any; @@ -63,8 +68,6 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string public timeControl = new FormControl(); public dateControl = new FormControl(); - public isLocalMode = true; - public get shouldShowDateButtons() { return !this.hideDateButtonsSettings && !this.hideDateButtons; } @@ -82,7 +85,9 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string } constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions) { - super(changeDetector, {}); + super(changeDetector, { + isLocal: false + }); this.hideDateButtonsSettings = !!uiOptions.get('hideDateButtons'); this.hideDateTimeModeButtonSetting = !!uiOptions.get('hideDateTimeModeButton'); @@ -199,7 +204,7 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string if (this.isDateTimeMode && 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); @@ -209,7 +214,7 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string this.suppressEvents = true; if (this.dateTime && this.isDateTimeMode) { - if (this.isLocalMode) { + if (this.snapshot.isLocal) { this.timeControl.setValue(this.dateTime.toStringFormat('HH:mm:ss'), NO_EMIT); } else { 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) { let dateString: string; - if (this.isDateTimeMode && this.isLocalMode) { + if (this.isDateTimeMode && this.snapshot.isLocal) { dateString = this.dateTime.toStringFormat('yyyy-MM-dd'); this.picker.setDate(DateHelper.getLocalDate(this.dateTime.raw), true); @@ -239,14 +244,14 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string this.suppressEvents = false; } - public setLocalMode(isLocalMode: boolean) { - this.isLocalMode = isLocalMode; + public setLocalMode(isLocal: boolean) { + this.next({ isLocal }); this.updateControls(); } public setCompact(isCompact: boolean) { - this.next(s => ({ ...s, isCompact })); + this.isCompact = isCompact; } } diff --git a/frontend/app/framework/angular/forms/editors/dropdown.component.ts b/frontend/app/framework/angular/forms/editors/dropdown.component.ts index a3f305787..2405a83d4 100644 --- a/frontend/app/framework/angular/forms/editors/dropdown.component.ts +++ b/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 }; +const NO_EMIT = { emitEvent: false }; + interface State { // The suggested item. suggestedItems: ReadonlyArray; @@ -27,8 +29,6 @@ interface State { query?: RegExp; } -const NO_EMIT = { emitEvent: false }; - @Component({ selector: 'sqx-dropdown', styleUrls: ['./dropdown.component.scss'], @@ -229,7 +229,7 @@ export class DropdownComponent extends StatefulControlComponent ({ ...s, selectedIndex })); + this.next({ selectedIndex }); } private getSelectedIndex(value: any) { diff --git a/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts b/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts index 3fd9bf35d..18f4517fd 100644 --- a/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts +++ b/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts @@ -30,7 +30,6 @@ interface State { }) export class IFrameEditorComponent extends StatefulControlComponent implements OnChanges, OnDestroy, AfterViewInit { private value: any; - private isDisabled = false; private isInitialized = false; @ViewChild('iframe', { static: false }) @@ -109,7 +108,7 @@ export class IFrameEditorComponent extends StatefulControlComponent } else if (type === 'resize') { 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') { const { url } = event.data; @@ -144,7 +143,7 @@ export class IFrameEditorComponent extends StatefulControlComponent } public setDisabledState(isDisabled: boolean): void { - this.isDisabled = isDisabled; + super.setDisabledState(isDisabled); this.sendDisabled(); } @@ -162,7 +161,7 @@ export class IFrameEditorComponent extends StatefulControlComponent } private sendDisabled() { - this.sendMessage('disabled', { isDisabled: this.isDisabled }); + this.sendMessage('disabled', { isDisabled: this.snapshot.isDisabled }); } private sendFormValue() { @@ -178,7 +177,7 @@ export class IFrameEditorComponent extends StatefulControlComponent } private toggleFullscreen(isFullscreen: boolean) { - this.next(s => ({ ...s, isFullscreen })); + this.next({ isFullscreen }); let target = this.container.nativeElement; diff --git a/frontend/app/framework/angular/forms/editors/localized-input.component.html b/frontend/app/framework/angular/forms/editors/localized-input.component.html index be9d70a9e..0a61a2156 100644 --- a/frontend/app/framework/angular/forms/editors/localized-input.component.html +++ b/frontend/app/framework/angular/forms/editors/localized-input.component.html @@ -1,6 +1,10 @@
- + +
- diff --git a/frontend/app/framework/angular/pager.component.spec.ts b/frontend/app/framework/angular/pager.component.spec.ts new file mode 100644 index 000000000..abc83d8e0 --- /dev/null +++ b/frontend/app/framework/angular/pager.component.spec.ts @@ -0,0 +1,138 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { PagerComponent } from './pager.component'; + +describe('Pager', () => { + it('should init with default values', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 0, pageSize: 10, count: 0, total: 0 }; + pager.ngOnChanges(); + + expect(pager.canGoNext).toBeFalse(); + expect(pager.canGoPrev).toBeFalse(); + + expect(pager.itemFirst).toEqual(0); + expect(pager.itemLast).toEqual(0); + }); + + it('should init with first full page', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 0, pageSize: 10, count: 10, total: 100 }; + pager.ngOnChanges(); + + expect(pager.canGoNext).toBeTrue(); + expect(pager.canGoPrev).toBeFalse(); + + expect(pager.itemFirst).toEqual(1); + expect(pager.itemLast).toEqual(10); + }); + it('should init with middle page', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 4, pageSize: 10, count: 10, total: 100 }; + pager.ngOnChanges(); + + expect(pager.canGoNext).toBeTrue(); + expect(pager.canGoPrev).toBeTrue(); + + expect(pager.itemFirst).toEqual(41); + expect(pager.itemLast).toEqual(50); + }); + + it('should init with last full page', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 9, pageSize: 10, count: 10, total: 100 }; + pager.ngOnChanges(); + + expect(pager.canGoNext).toBeFalse(); + expect(pager.canGoPrev).toBeTrue(); + + expect(pager.itemFirst).toEqual(91); + expect(pager.itemLast).toEqual(100); + }); + + it('should init with last partly page', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 9, pageSize: 10, count: 4, total: 100 }; + pager.ngOnChanges(); + + expect(pager.canGoNext).toBeFalse(); + expect(pager.canGoPrev).toBeTrue(); + + expect(pager.itemFirst).toEqual(91); + expect(pager.itemLast).toEqual(94); + }); + + it('should init with last partly page 2', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 9, pageSize: 10, count: 9, total: 100 }; + pager.ngOnChanges(); + + expect(pager.canGoNext).toBeFalse(); + expect(pager.canGoPrev).toBeTrue(); + + expect(pager.itemFirst).toEqual(91); + expect(pager.itemLast).toEqual(99); + }); + + it('should emit when changing size', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 4, pageSize: 10, count: 10, total: 100 }; + pager.ngOnChanges(); + + let emitted: any; + + pager.pagingChange.subscribe((value: any) => { + emitted = value; + }); + + pager.setPageSize(20); + + expect(emitted).toEqual({ page: 0, pageSize: 20 }); + }); + + it('should emit when going next', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 4, pageSize: 10, count: 10, total: 100 }; + pager.ngOnChanges(); + + let emitted: any; + + pager.pagingChange.subscribe((value: any) => { + emitted = value; + }); + + pager.goNext(); + + expect(emitted).toEqual({ page: 5, pageSize: 10 }); + }); + + it('should emit when going prev', () => { + const pager = new PagerComponent(); + + pager.paging = { page: 4, pageSize: 10, count: 10, total: 100 }; + pager.ngOnChanges(); + + let emitted: any; + + pager.pagingChange.subscribe((value: any) => { + emitted = value; + }); + + pager.goPrev(); + + expect(emitted).toEqual({ page: 3, pageSize: 10 }); + }); +}); \ No newline at end of file diff --git a/frontend/app/framework/angular/pager.component.ts b/frontend/app/framework/angular/pager.component.ts index 5225eac80..b1804fe10 100644 --- a/frontend/app/framework/angular/pager.component.ts +++ b/frontend/app/framework/angular/pager.component.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { Pager } from '@app/framework/internal'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { PagingInfo } from '../state'; export const PAGE_SIZES: ReadonlyArray = [10, 20, 30, 50]; @@ -16,27 +16,66 @@ export const PAGE_SIZES: ReadonlyArray = [10, 20, 30, 50]; templateUrl: './pager.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) -export class PagerComponent { +export class PagerComponent implements OnChanges { @Output() - public pagerChange = new EventEmitter(); + public pagingChange = new EventEmitter<{ page: number, pageSize: number }>(); @Input() - public pager: Pager; + public paging: PagingInfo; @Input() public autoHide = false; + public totalPages = 0; + + public itemFirst = 0; + public itemLast = 0; + + public canGoPrev = false; + public canGoNext = false; + + public translationInfo: any; + public pageSizes = PAGE_SIZES; + public ngOnChanges() { + const { page, pageSize, count, total } = this.paging; + + const totalPages = Math.ceil(total / pageSize); + + if (count > 0) { + const offset = page * pageSize; + + this.itemFirst = offset + 1; + this.itemLast = offset + count; + + this.canGoNext = page < totalPages - 1; + this.canGoPrev = page > 0; + } else { + this.canGoNext = false; + this.canGoPrev = false; + } + + this.translationInfo = { + itemFirst: this.itemFirst, + itemLast: this.itemLast, + numberOfItems: total + }; + } + public goPrev() { - this.pagerChange.emit(this.pager.goPrev()); + const { page, pageSize } = this.paging; + + this.pagingChange.emit({ page: page - 1, pageSize }); } public goNext() { - this.pagerChange.emit(this.pager.goNext()); + const { page, pageSize } = this.paging; + + this.pagingChange.emit({ page: page + 1, pageSize }); } public setPageSize(pageSize: number) { - this.pagerChange.emit(this.pager.setPageSize(pageSize)); + this.pagingChange.emit({ page: 0, pageSize }); } } \ No newline at end of file diff --git a/frontend/app/framework/angular/panel-container.directive.ts b/frontend/app/framework/angular/panel-container.directive.ts index c7cc849f4..81a859cf0 100644 --- a/frontend/app/framework/angular/panel-container.directive.ts +++ b/frontend/app/framework/angular/panel-container.directive.ts @@ -81,7 +81,7 @@ export class PanelContainerDirective implements AfterViewInit { if (panel.desiredWidth === '*') { const layoutWidth = (this.containerWidth - currentSize) / panelsWidthSpread; - panel.measure(layoutWidth + 'px'); + panel.measure(`${layoutWidth}px`); currentSize += panel.renderWidth; } @@ -91,7 +91,7 @@ export class PanelContainerDirective implements AfterViewInit { let currentLayer = panels.length * 10; for (const panel of panels) { - panel.arrange(currentPosition + 'px', currentLayer.toString()); + panel.arrange(`${currentPosition}px`, currentLayer.toString()); currentPosition += panel.renderWidth; currentLayer -= 10; diff --git a/frontend/app/framework/angular/panel.component.ts b/frontend/app/framework/angular/panel.component.ts index ee413871c..3d6c1204b 100644 --- a/frontend/app/framework/angular/panel.component.ts +++ b/frontend/app/framework/angular/panel.component.ts @@ -20,8 +20,8 @@ import { PanelContainerDirective } from './panel-container.directive'; changeDetection: ChangeDetectionStrategy.OnPush }) export class PanelComponent implements AfterViewInit, OnChanges, OnDestroy, OnInit { - private styleWidth: string; - private renderWidthField = 0; + private widthPrevious: string; + private widthToRender = 0; private isViewInitField = false; @Output() @@ -83,7 +83,7 @@ export class PanelComponent implements AfterViewInit, OnChanges, OnDestroy, OnIn } public get renderWidth() { - return this.renderWidthField; + return this.widthToRender; } public get isViewInit() { @@ -117,8 +117,8 @@ export class PanelComponent implements AfterViewInit, OnChanges, OnDestroy, OnIn } public measure(size: string) { - if (this.styleWidth !== size && this.isViewInitField) { - this.styleWidth = size; + if (this.widthPrevious !== size && this.isViewInitField) { + this.widthPrevious = size; const element = this.panel.nativeElement; @@ -126,7 +126,7 @@ export class PanelComponent implements AfterViewInit, OnChanges, OnDestroy, OnIn this.renderer.setStyle(element, 'width', size); this.renderer.setStyle(element, 'minWidth', this.minWidth); - this.renderWidthField = element.offsetWidth; + this.widthToRender = element.offsetWidth; } } } diff --git a/frontend/app/framework/angular/pipes/colors.pipes.ts b/frontend/app/framework/angular/pipes/colors.pipes.ts index 62ce70879..17f7f3215 100644 --- a/frontend/app/framework/angular/pipes/colors.pipes.ts +++ b/frontend/app/framework/angular/pipes/colors.pipes.ts @@ -121,16 +121,16 @@ function colorString({ r, g, b }: RGBColor) { let bs = Math.round(b * 255).toString(16); if (rs.length === 1) { - rs = '0' + rs; + rs = `0${rs}`; } if (gs.length === 1) { - gs = '0' + gs; + gs = `0${gs}`; } if (bs.length === 1) { - bs = '0' + bs; + bs = `0${bs}`; } - return '#' + rs + gs + bs; + return `#${rs}${gs}${bs}`; } @Pipe({ diff --git a/frontend/app/framework/angular/pipes/money.pipe.ts b/frontend/app/framework/angular/pipes/money.pipe.ts index 7c27afd6e..b8840a046 100644 --- a/frontend/app/framework/angular/pipes/money.pipe.ts +++ b/frontend/app/framework/angular/pipes/money.pipe.ts @@ -22,12 +22,12 @@ export class MoneyPipe implements PipeTransform { public transform(value: number): any { const money = value.toFixed(2).toString(); - let result = money.substr(0, money.length - 3) + this.separator.value + '' + money.substr(money.length - 2, 2) + ''; + let result = `${money.substr(0, money.length - 3) + this.separator.value}${money.substr(money.length - 2, 2)}`; if (this.currency.showAfter) { - result = result + ' ' + this.currency.symbol; + result = `${result} ${this.currency.symbol}`; } else { - result = this.currency.symbol + ' ' + result; + result = `${this.currency.symbol} ${result}`; } return result; diff --git a/frontend/app/framework/angular/pipes/numbers.pipes.ts b/frontend/app/framework/angular/pipes/numbers.pipes.ts index 5a69154f5..46d3e8b1c 100644 --- a/frontend/app/framework/angular/pipes/numbers.pipes.ts +++ b/frontend/app/framework/angular/pipes/numbers.pipes.ts @@ -22,7 +22,7 @@ export class KNumberPipe implements PipeTransform { value = Math.round(value); } - return value + 'k'; + return `${value}k`; } else if (value < 0) { return ''; } else { @@ -49,5 +49,6 @@ export function calculateFileSize(value: number, factor = 1024) { u++; } - return (u ? value.toFixed(1) + ' ' : value) + ' kMGTPEZY'[u] + 'B'; + // tslint:disable-next-line: prefer-template + return (u ? `${value.toFixed(1)} ` : value) + ' kMGTPEZY'[u] + 'B'; } \ No newline at end of file diff --git a/frontend/app/framework/angular/routers/router-2-state.spec.ts b/frontend/app/framework/angular/routers/router-2-state.spec.ts index bd4f80602..2e0067b3b 100644 --- a/frontend/app/framework/angular/routers/router-2-state.spec.ts +++ b/frontend/app/framework/angular/routers/router-2-state.spec.ts @@ -6,22 +6,22 @@ */ import { NavigationEnd, NavigationExtras, NavigationStart, Params, Router } from '@angular/router'; -import { LocalStoreService, MathHelper, Pager } from '@app/framework/internal'; +import { LocalStoreService, MathHelper } from '@app/framework/internal'; import { BehaviorSubject, Subject } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { State } from './../../state'; -import { PagerSynchronizer, Router2State, StringKeysSynchronizer, StringSynchronizer } from './router-2-state'; +import { PagingSynchronizer, Router2State, StringKeysSynchronizer, StringSynchronizer } from './router-2-state'; describe('Router2State', () => { describe('Strings', () => { - const synchronizer = new StringSynchronizer('key'); + const synchronizer = new StringSynchronizer('key', 'key'); it('should write string to route', () => { const params: Params = {}; const value = 'my-string'; - synchronizer.writeValue(value, params); + synchronizer.writeValuesToRoute({ key: value }, params); expect(params).toEqual({ key: 'my-string' }); }); @@ -31,7 +31,7 @@ describe('Router2State', () => { const value = 123; - synchronizer.writeValue(value, params); + synchronizer.writeValuesToRoute(value, params); expect(params).toEqual({ key: undefined }); }); @@ -41,14 +41,14 @@ describe('Router2State', () => { key: 'my-string' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); - expect(value).toEqual('my-string'); + expect(value).toEqual({ key: 'my-string' }); }); }); describe('StringKeys', () => { - const synchronizer = new StringKeysSynchronizer('key'); + const synchronizer = new StringKeysSynchronizer('key', 'key'); it('should write object keys to route', () => { const params: Params = {}; @@ -58,7 +58,7 @@ describe('Router2State', () => { flag2: false }; - synchronizer.writeValue(value, params); + synchronizer.writeValuesToRoute({ key: value }, params); expect(params).toEqual({ key: 'flag1,flag2' }); }); @@ -68,7 +68,7 @@ describe('Router2State', () => { const value = {}; - synchronizer.writeValue(value, params); + synchronizer.writeValuesToRoute({ key: value }, params); expect(params).toEqual({ key: undefined }); }); @@ -78,7 +78,7 @@ describe('Router2State', () => { const value = 123; - synchronizer.writeValue(value, params); + synchronizer.writeValuesToRoute({ key: value }, params); expect(params).toEqual({ key: undefined }); }); @@ -86,84 +86,36 @@ describe('Router2State', () => { it('should get object from route', () => { const params: Params = { key: 'flag1,flag2' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); - expect(value).toEqual({ flag1: true, flag2: true }); + expect(value).toEqual({ key: { flag1: true, flag2: true } }); }); it('should get object with empty keys from route', () => { const params: Params = { key: 'flag1,,,flag2' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); - expect(value).toEqual({ flag1: true, flag2: true }); + expect(value).toEqual({ key: { flag1: true, flag2: true } }); }); }); - describe('Pager', () => { - let synchronizer: PagerSynchronizer; + describe('Paging', () => { + let synchronizer: PagingSynchronizer; let localStore: IMock; beforeEach(() => { localStore = Mock.ofType(); - synchronizer = new PagerSynchronizer(localStore.object, 'contents', 30); - }); - - it('should write pager to route and local store', () => { - const params: Params = {}; - - const value = new Pager(0, 10, 20, true); - - synchronizer.writeValue(value, params); - - expect(params).toEqual({ take: '20', page: '10' }); - - localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); - }); - - it('Should write undefined when page number is zero', () => { - const params: Params = {}; - - const value = new Pager(0, 0, 20, true); - - synchronizer.writeValue(value, params); - - expect(params).toEqual({ take: '20', page: undefined }); - - localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); - }); - - it('should write undefined when value not a pager', () => { - const params: Params = {}; - - const value = 123; - - synchronizer.writeValue(value, params); - - expect(params).toEqual({ take: undefined, page: undefined }); - - localStore.verify(x => x.setInt('contents.pageSize', 20), Times.never()); - }); - - it('should write undefined when value is null', () => { - const params: Params = {}; - - const value = null; - - synchronizer.writeValue(value, params); - - expect(params).toEqual({ take: undefined, page: undefined }); - - localStore.verify(x => x.setInt('contents.pageSize', 20), Times.never()); + synchronizer = new PagingSynchronizer(localStore.object, 'contents', 30); }); it('should get page and size from route', () => { - const params: Params = { page: '10', take: '40' }; + const params: Params = { page: '10', pageSize: '40' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); - expect(value).toEqual(new Pager(0, 10, 40, true)); + expect(value).toEqual({ page: 10, pageSize: 40 }); }); it('should get page size from local store as fallback', () => { @@ -172,9 +124,9 @@ describe('Router2State', () => { const params: Params = { page: '10' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); - expect(value).toEqual(new Pager(0, 10, 40, true)); + expect(value).toEqual({ page: 10, pageSize: 40 }); }); it('should get page size from default if local store is invalid', () => { @@ -183,25 +135,45 @@ describe('Router2State', () => { const params: Params = { page: '10' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); - expect(value).toEqual(new Pager(0, 10, 30, true)); + expect(value).toEqual({ page: 10, pageSize: 30 }); }); it('should get page size from default as last fallback', () => { const params: Params = { page: '10' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); - expect(value).toEqual(new Pager(0, 10, 30, true)); + expect(value).toEqual({ page: 10, pageSize: 30 }); }); it('should fix page number if invalid', () => { const params: Params = { page: '-10' }; - const value = synchronizer.getValue(params); + const value = synchronizer.parseValuesFromRoute(params); + + expect(value).toEqual({ page: 0, pageSize: 30 }); + }); + + it('should write pager to route and local store', () => { + const params: Params = {}; + + synchronizer.writeValuesToRoute({ page: 10, pageSize: 20 }, params); + + expect(params).toEqual({ page: '10', pageSize: '20' }); + + localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); + }); + + it('Should write undefined when page number is zero', () => { + const params: Params = {}; + + synchronizer.writeValuesToRoute({ page: 0, pageSize: 20 }, params); + + expect(params).toEqual({ page: undefined, pageSize: '20' }); - expect(value).toEqual(new Pager(0, 0, 30, true)); + localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); }); }); diff --git a/frontend/app/framework/angular/routers/router-2-state.ts b/frontend/app/framework/angular/routers/router-2-state.ts index 0e934f73f..4eb928f5f 100644 --- a/frontend/app/framework/angular/routers/router-2-state.ts +++ b/frontend/app/framework/angular/routers/router-2-state.ts @@ -9,17 +9,17 @@ import { Injectable, OnDestroy } from '@angular/core'; import { ActivatedRoute, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, Router } from '@angular/router'; -import { LocalStoreService, Pager, Types } from '@app/framework/internal'; +import { LocalStoreService, Types } from '@app/framework/internal'; import { State } from '@app/framework/state'; import { Subscription } from 'rxjs'; export interface RouteSynchronizer { - getValue(params: Params): any; + parseValuesFromRoute(params: Params): object; - writeValue(state: any, params: Params): void; + writeValuesToRoute(state: any, params: Params): void; } -export class PagerSynchronizer implements RouteSynchronizer { +export class PagingSynchronizer implements RouteSynchronizer { constructor( private readonly localStore: LocalStoreService, private readonly storeName: string, @@ -27,10 +27,10 @@ export class PagerSynchronizer implements RouteSynchronizer { ) { } - public getValue(params: Params) { + public parseValuesFromRoute(params: Params) { let pageSize = 0; - const pageSizeValue = params['take']; + const pageSizeValue = params['pageSize']; if (Types.isString(pageSizeValue)) { pageSize = parseInt(pageSizeValue, 10); @@ -50,56 +50,59 @@ export class PagerSynchronizer implements RouteSynchronizer { page = 0; } - return new Pager(0, page, pageSize, true); + return { page, pageSize }; } - public writeValue(state: any, params: Params) { - params['page'] = undefined; - params['take'] = undefined; + public writeValuesToRoute(state: any, params: Params) { + const page: number = state.page; - if (Types.is(state, Pager)) { - if (state.page > 0) { - params['page'] = state.page.toString(); - } + if (page > 0) { + params['page'] = page.toString(); + } else { + params['page'] = undefined; + } - if (state.pageSize > 0) { - params['take'] = state.pageSize.toString(); + const pageSize: number = state.pageSize; - this.localStore.setInt(`${this.storeName}.pageSize`, state.pageSize); - } - } + params['pageSize'] = pageSize.toString(); + + this.localStore.setInt(`${this.storeName}.pageSize`, pageSize); } } export class StringSynchronizer implements RouteSynchronizer { constructor( - private readonly name: string + private readonly nameState: string, + private readonly nameUrl: string ) { } - public getValue(params: Params) { - const value = params[this.name]; + public parseValuesFromRoute(params: Params) { + const value = params[this.nameUrl]; - return value; + return { [this.nameState]: value }; } - public writeValue(state: any, params: Params) { - params[this.name] = undefined; + public writeValuesToRoute(state: any, params: Params) { + params[this.nameUrl] = undefined; - if (Types.isString(state)) { - params[this.name] = state; + const value = state[this.nameState]; + + if (Types.isString(value)) { + params[this.nameUrl] = value; } } } export class StringKeysSynchronizer implements RouteSynchronizer { constructor( - private readonly name: string + private readonly nameState: string, + private readonly nameUrl: string ) { } - public getValue(params: Params) { - const value = params[this.name]; + public parseValuesFromRoute(params: Params) { + const value = params[this.nameUrl]; const result: { [key: string]: boolean } = {}; @@ -111,17 +114,19 @@ export class StringKeysSynchronizer implements RouteSynchronizer { } } - return result; + return { [this.nameState]: result }; } - public writeValue(state: any, params: Params) { - params[this.name] = undefined; + public writeValuesToRoute(state: any, params: Params) { + params[this.nameUrl] = undefined; + + const value = state[this.nameState]; - if (Types.isObject(state)) { - const value = Object.keys(state).join(','); + if (Types.isObject(value)) { + const items = Object.keys(value).join(','); - if (value.length > 0) { - params[this.name] = value; + if (items.length > 0) { + params[this.nameUrl] = items; } } } @@ -138,11 +143,11 @@ export interface StateSynchronizerMap { withStrings(key: keyof T & string, urlName: string): this; - withPager(key: keyof T & string, storeName: string, defaultSize: number): this; + withPaging(storeName: string, defaultSize: number): this; whenSynced(action: () => void): this; - withSynchronizer(key: keyof T & string, synchronizer: RouteSynchronizer): this; + withSynchronizer(synchronizer: RouteSynchronizer): this; build(): void; } @@ -171,7 +176,7 @@ export class Router2State implements OnDestroy, StateSynchronizer { } export class Router2StateMap implements StateSynchronizerMap { - private readonly syncs: { [field: string]: { synchronizer: RouteSynchronizer, value: any } } = {}; + private readonly syncs: { synchronizer: RouteSynchronizer, value: object }[] = []; private readonly keysToKeep: string[] = []; private syncDone: (() => void)[] = []; private lastSyncedParams: Params | undefined; @@ -227,18 +232,21 @@ export class Router2StateMap implements StateSynchronizerMap implements StateSynchronizerMap implements StateSynchronizerMap = {}; - for (const key in this.syncs) { - if (this.syncs.hasOwnProperty(key)) { - const target = this.syncs[key]; - - const value = target.synchronizer.getValue(query); + for (const target of this.syncs) { + const values = target.synchronizer.parseValuesFromRoute(query); - if (value) { - update[key] = value; + for (const key in values) { + if (values.hasOwnProperty(key)) { + update[key] = values[key]; } } + + target.value = values; } for (const key of this.keysToKeep) { @@ -313,27 +317,25 @@ export class Router2StateMap implements StateSynchronizerMap void) { - this.syncDone.push(action); + public withSynchronizer(synchronizer: RouteSynchronizer) { + this.syncs.push({ synchronizer, value: {} }); return this; } - public withSynchronizer(key: keyof T & string, synchronizer: RouteSynchronizer) { - const previous = this.syncs[key]; - - this.syncs[key] = { synchronizer, value: previous?.value }; + public whenSynced(action: () => void) { + this.syncDone.push(action); return this; } diff --git a/frontend/app/framework/angular/stateful.component.ts b/frontend/app/framework/angular/stateful.component.ts index 5e5410740..db98c05f4 100644 --- a/frontend/app/framework/angular/stateful.component.ts +++ b/frontend/app/framework/angular/stateful.component.ts @@ -92,7 +92,9 @@ export abstract class StatefulComponent extends State implements OnD } } -export abstract class StatefulControlComponent extends StatefulComponent implements ControlValueAccessor { +type Disabled = { isDisabled: boolean }; + +export abstract class StatefulControlComponent extends StatefulComponent implements ControlValueAccessor { private fnChanged = (v: any) => { /* NOOP */ }; private fnTouched = () => { /* NOOP */ }; @@ -117,7 +119,7 @@ export abstract class StatefulControlComponent extends StatefulCompon } public setDisabledState(isDisabled: boolean): void { - this.next(s => ({ ...s, isDisabled })); + this.next({ isDisabled } as any); } public abstract writeValue(obj: any): void; diff --git a/frontend/app/framework/configurations.ts b/frontend/app/framework/configurations.ts index 2ad33cdb3..86e9ec84c 100644 --- a/frontend/app/framework/configurations.ts +++ b/frontend/app/framework/configurations.ts @@ -39,7 +39,7 @@ export class ApiUrlConfig { constructor(value: string) { if (value.indexOf('/', value.length - 1) < 0) { - value = value + '/'; + value = `${value}/`; } this.value = value; diff --git a/frontend/app/framework/internal.ts b/frontend/app/framework/internal.ts index 01355f25c..cbf09e27c 100644 --- a/frontend/app/framework/internal.ts +++ b/frontend/app/framework/internal.ts @@ -35,7 +35,6 @@ export * from './utils/keys'; export * from './utils/math-helper'; export * from './utils/modal-positioner'; export * from './utils/modal-view'; -export * from './utils/pager'; export * from './utils/picasso'; export * from './utils/rxjs-extensions'; export * from './utils/string-helper'; diff --git a/frontend/app/framework/services/analytics.service.ts b/frontend/app/framework/services/analytics.service.ts index 9700c072b..6bcada103 100644 --- a/frontend/app/framework/services/analytics.service.ts +++ b/frontend/app/framework/services/analytics.service.ts @@ -46,7 +46,7 @@ export class AnalyticsService { event_category: category, event_action: action, event_label: label, - value: value + value }); } diff --git a/frontend/app/framework/services/message-bus.service.ts b/frontend/app/framework/services/message-bus.service.ts index 58f1811a1..0204774d4 100644 --- a/frontend/app/framework/services/message-bus.service.ts +++ b/frontend/app/framework/services/message-bus.service.ts @@ -10,7 +10,10 @@ import { Observable, Subject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; interface Message { + // The target. channel: string; + + // The message payload. data: any; } @@ -22,10 +25,10 @@ export const MessageBusFactory = () => { export class MessageBus { private message$ = new Subject(); - public emit(message: T): void { - const channel = ((message)['constructor']).name; + public emit(data: T): void { + const channel = ((data)['constructor']).name; - this.message$.next({ channel: channel, data: message }); + this.message$.next({ channel, data }); } public of(messageType: { new(...args: ReadonlyArray): T }): Observable { diff --git a/frontend/app/framework/services/resource-loader.service.ts b/frontend/app/framework/services/resource-loader.service.ts index a4002c1a1..f0f2a61ef 100644 --- a/frontend/app/framework/services/resource-loader.service.ts +++ b/frontend/app/framework/services/resource-loader.service.ts @@ -25,7 +25,7 @@ export class ResourceLoaderService { result = new Promise(resolve => { const style = this.renderer.createElement('link'); - this.renderer.listen(style, 'load', () => resolve()); + this.renderer.listen(style, 'load', resolve); this.renderer.setProperty(style, 'rel', 'stylesheet'); this.renderer.setProperty(style, 'href', url); this.renderer.setProperty(style, 'type', 'text/css'); @@ -51,7 +51,7 @@ export class ResourceLoaderService { this.renderer.appendChild(document.body, script); result = new Promise((resolve) => { - this.renderer.listen(script, 'load', () => resolve()); + this.renderer.listen(script, 'load', resolve); }); this.cache[key] = result; diff --git a/frontend/app/framework/state.ts b/frontend/app/framework/state.ts index 24fe34164..00bf7bd4b 100644 --- a/frontend/app/framework/state.ts +++ b/frontend/app/framework/state.ts @@ -60,6 +60,46 @@ export class ResultSet { } } +export interface PagingInfo { + // The current page. + page: number; + + // The current page size. + pageSize: number; + + // The total number of items. + total: number; + + // The current number of items. + count: number; +} + +export function getPagingInfo(state: ListState, count: number) { + const { page, pageSize, total } = state; + + return { page, pageSize, total, count }; +} + +export interface ListState { + // The total number of items. + total: number; + + // True if currently loading. + isLoading?: boolean; + + // True if already loaded. + isLoaded?: boolean; + + // The current page. + page: number; + + // The current page size. + pageSize: number; + + // The query. + query?: TQuery; +} + export class State { private readonly state: BehaviorSubject>; @@ -73,12 +113,12 @@ export class State { public project(project: (value: T) => M, compare?: (x: M, y: M) => boolean) { return this.changes.pipe( - map(x => project(x)), distinctUntilChanged(compare), shareReplay(1)); + map(project), distinctUntilChanged(compare), shareReplay(1)); } public projectFrom(source: Observable, project: (value: M) => N, compare?: (x: N, y: N) => boolean) { return source.pipe( - map(x => project(x)), distinctUntilChanged(compare), shareReplay(1)); + map(project), distinctUntilChanged(compare), shareReplay(1)); } public projectFrom2(lhs: Observable, rhs: Observable, project: (l: M, r: N) => O, compare?: (x: O, y: O) => boolean) { diff --git a/frontend/app/framework/utils/cookies.ts b/frontend/app/framework/utils/cookies.ts index b3b3cbaf6..26bb64096 100644 --- a/frontend/app/framework/utils/cookies.ts +++ b/frontend/app/framework/utils/cookies.ts @@ -14,7 +14,7 @@ export module Cookies { date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); - expires = '; expires=' + date.toUTCString(); + expires = `; expires=${date.toUTCString()}`; } document.cookie = `${name}=${value || ''}${expires}; path=/`; diff --git a/frontend/app/framework/utils/date-time.ts b/frontend/app/framework/utils/date-time.ts index 1ed217185..ba9079009 100644 --- a/frontend/app/framework/utils/date-time.ts +++ b/frontend/app/framework/utils/date-time.ts @@ -197,7 +197,7 @@ export class DateTime { let result = this.value.toISOString(); if (withoutMilliseconds) { - result = result.slice(0, 19) + 'Z'; + result = `${result.slice(0, 19)}Z`; } return result; diff --git a/frontend/app/framework/utils/math-helper.ts b/frontend/app/framework/utils/math-helper.ts index 81852679f..585126a40 100644 --- a/frontend/app/framework/utils/math-helper.ts +++ b/frontend/app/framework/utils/math-helper.ts @@ -91,7 +91,7 @@ export module MathHelper { } export function guid(): string { - return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); + return `${s4() + s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; } export function s4(): string { @@ -170,16 +170,16 @@ export module MathHelper { let b = Math.round(color.b * 255).toString(16); if (r.length === 1) { - r = '0' + r; + r = `0${r}`; } if (g.length === 1) { - g = '0' + g; + g = `0${g}`; } if (b.length === 1) { - b = '0' + b; + b = `0${b}`; } - return '#' + r + g + b; + return `#${r}${g}${b}`; } export function colorFromHsv(h: number, s: number, v: number): Color { diff --git a/frontend/app/framework/utils/pager.spec.ts b/frontend/app/framework/utils/pager.spec.ts deleted file mode 100644 index 96e2e7fc2..000000000 --- a/frontend/app/framework/utils/pager.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { Pager } from './pager'; - -describe('Pager', () => { - it('should init with default values', () => { - const pager_1 = new Pager(0); - - expect(Object.assign({}, pager_1)).toEqual({ - page: 0, - pageSize: 10, - itemFirst: 0, - itemLast: 0, - skip: 0, - numberOfItems: 0, - canGoNext: false, - canGoPrev: false - }); - }); - - it('should init with page size and page', () => { - const pager_1 = new Pager(23, 2, 10); - - expect(Object.assign({}, pager_1)).toEqual({ - page: 2, - pageSize: 10, - itemFirst: 21, - itemLast: 23, - skip: 20, - numberOfItems: 23, - canGoNext: false, - canGoPrev: true - }); - }); - - it('should reset page on reset', () => { - const pager_1 = new Pager(23, 2, 10); - const pager_2 = pager_1.reset(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 0, - pageSize: 10, - itemFirst: 0, - itemLast: 0, - skip: 0, - numberOfItems: 0, - canGoNext: false, - canGoPrev: false - }); - }); - - it('should return same instance when go next and being on last page', () => { - const pager_1 = new Pager(23, 2, 10); - const pager_2 = pager_1.goNext(); - - expect(pager_2).toBe(pager_1); - }); - - it('should update page when going next', () => { - const pager_1 = new Pager(23, 0, 10); - const pager_2 = pager_1.goNext(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 1, - pageSize: 10, - itemFirst: 11, - itemLast: 20, - skip: 10, - numberOfItems: 23, - canGoNext: true, - canGoPrev: true - }); - }); - - it('should return same instance when go prev and being on first page', () => { - const pager_1 = new Pager(23, 0, 10); - const pager_2 = pager_1.goPrev(); - - expect(pager_2).toBe(pager_1); - }); - - it('should update page when going prev', () => { - const pager_1 = new Pager(23, 2, 10); - const pager_2 = pager_1.goPrev(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 1, - pageSize: 10, - itemFirst: 11, - itemLast: 20, - skip: 10, - numberOfItems: 23, - canGoNext: true, - canGoPrev: true - }); - }); - - it('should update count when setting it', () => { - const pager_1 = new Pager(23, 2, 10); - const pager_2 = pager_1.setCount(30); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 2, - pageSize: 10, - itemFirst: 21, - itemLast: 30, - skip: 20, - numberOfItems: 30, - canGoNext: false, - canGoPrev: true - }); - }); - - it('should update count when incrementing it', () => { - const pager_1 = new Pager(23, 1, 10); - const pager_2 = pager_1.incrementCount().incrementCount(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 1, - pageSize: 10, - itemFirst: 11, - itemLast: 22, - skip: 10, - numberOfItems: 25, - canGoNext: true, - canGoPrev: true - }); - }); - - it('should update count for last page when incrementing it', () => { - const pager_1 = new Pager(23, 2, 10); - const pager_2 = pager_1.incrementCount(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 2, - pageSize: 10, - itemFirst: 21, - itemLast: 24, - skip: 20, - numberOfItems: 24, - canGoNext: false, - canGoPrev: true - }); - }); - - it('should update count when decrementing it', () => { - const pager_1 = new Pager(23, 1, 10); - const pager_2 = pager_1.decrementCount().decrementCount(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 1, - pageSize: 10, - itemFirst: 11, - itemLast: 18, - skip: 10, - numberOfItems: 21, - canGoNext: true, - canGoPrev: true - }); - }); - - it('should update count for last page when decrementing it', () => { - const pager_1 = new Pager(23, 2, 10); - const pager_2 = pager_1.decrementCount(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 2, - pageSize: 10, - itemFirst: 21, - itemLast: 22, - skip: 20, - numberOfItems: 22, - canGoNext: false, - canGoPrev: true - }); - }); - - it('should also update page when new page is bigger than max page', () => { - const pager_1 = new Pager(21, 2, 10); - const pager_2 = pager_1.decrementCount(); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 1, - pageSize: 10, - itemFirst: 11, - itemLast: 20, - skip: 10, - numberOfItems: 20, - canGoNext: false, - canGoPrev: true - }); - }); - - it('should update page size', () => { - const pager_1 = new Pager(21, 0, 10); - const pager_2 = pager_1.setPageSize(30); - - expect(Object.assign({}, pager_2)).toEqual({ - page: 0, - pageSize: 30, - itemFirst: 1, - itemLast: 21, - skip: 0, - numberOfItems: 21, - canGoNext: false, - canGoPrev: false - }); - }); -}); \ No newline at end of file diff --git a/frontend/app/framework/utils/pager.ts b/frontend/app/framework/utils/pager.ts deleted file mode 100644 index d751fa3cb..000000000 --- a/frontend/app/framework/utils/pager.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -export class Pager { - public canGoNext = false; - public canGoPrev = false; - - public itemFirst = 0; - public itemLast = 0; - - public skip = 0; - - constructor( - public readonly numberOfItems: number, - public readonly page = 0, - public readonly pageSize = 10, - unsafe = false - ) { - const totalPages = Math.ceil(numberOfItems / this.pageSize); - - if (this.page >= totalPages && this.page > 0 && !unsafe) { - this.page = page = totalPages - 1; - } - - this.itemFirst = numberOfItems === 0 ? 0 : page * this.pageSize + 1; - this.itemLast = Math.min(numberOfItems, (page + 1) * this.pageSize); - - this.canGoNext = page < totalPages - 1; - this.canGoPrev = page > 0; - - this.skip = page * pageSize; - } - - public goNext(): Pager { - if (!this.canGoNext) { - return this; - } - - return new Pager(this.numberOfItems, this.page + 1, this.pageSize); - } - - public goPrev(): Pager { - if (!this.canGoPrev) { - return this; - } - - return new Pager(this.numberOfItems, this.page - 1, this.pageSize); - } - - public reset(): Pager { - return new Pager(0, 0, this.pageSize); - } - - public setPageSize(pageSize: number): Pager { - return new Pager(this.numberOfItems, this.page, pageSize); - } - - public setCount(numberOfItems: number): Pager { - return new Pager(numberOfItems, this.page, this.pageSize); - } - - public incrementCount(): Pager { - const result = new Pager(this.numberOfItems + 1, this.page, this.pageSize); - - if (result.canGoNext) { - result.itemLast = this.itemLast + 1; - } - - return result; - } - - public decrementCount(): Pager { - const result = new Pager(this.numberOfItems - 1, this.page, this.pageSize); - - if (result.canGoNext) { - result.itemLast = this.itemLast - 1; - } - - return result; - } -} \ No newline at end of file diff --git a/frontend/app/framework/utils/rxjs-extensions.ts b/frontend/app/framework/utils/rxjs-extensions.ts index b4197965f..a8124d054 100644 --- a/frontend/app/framework/utils/rxjs-extensions.ts +++ b/frontend/app/framework/utils/rxjs-extensions.ts @@ -44,7 +44,7 @@ export function shareMapSubscribed(dialogs: DialogService, project: (v })) .subscribe(); - return shared.pipe(map(x => project(x))); + return shared.pipe(map(project)); }; } diff --git a/frontend/app/framework/utils/types.ts b/frontend/app/framework/utils/types.ts index 65d1cccf7..23edcb6cb 100644 --- a/frontend/app/framework/utils/types.ts +++ b/frontend/app/framework/utils/types.ts @@ -53,15 +53,15 @@ export module Types { } export function isArrayOfNumber(value: any): value is Array { - return isArrayOf(value, v => isNumber(v)); + return isArrayOf(value, isNumber); } export function isArrayOfObject(value: any): value is Array { - return isArrayOf(value, v => isObject(v)); + return isArrayOf(value, isObject); } export function isArrayOfString(value: any): value is Array { - return isArrayOf(value, v => isString(v)); + return isArrayOf(value, isString); } export function isArrayOf(value: any, validator: (v: any) => boolean): boolean { diff --git a/frontend/app/shared/components/assets/asset-dialog.component.html b/frontend/app/shared/components/assets/asset-dialog.component.html index b07ce7203..7e58ce5e8 100644 --- a/frontend/app/shared/components/assets/asset-dialog.component.html +++ b/frontend/app/shared/components/assets/asset-dialog.component.html @@ -8,6 +8,11 @@ + + + - - - @@ -57,7 +57,6 @@ -