Browse Source

References view (#963)

* First implementation.

* Tests simplified.

* API test and UI fixes

* References view.

* Improve tests.

* Formatting.

* Fix formatting.
pull/965/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
d1c62606fd
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_it.json
  3. 2
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/frontend_pt.json
  5. 2
      backend/i18n/frontend_zh.json
  6. 2
      backend/i18n/source/frontend_en.json
  7. 29
      backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasSearchSource.cs
  8. 4
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs
  9. 47
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/AllContentsByGetDto.cs
  10. 50
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/AllContentsByPostDto.cs
  11. 7
      backend/src/Squidex/Areas/Api/Controllers/QueryDto.cs
  12. 6
      backend/src/Squidex/appsettings.json
  13. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemasSearchSourceTests.cs
  14. 1
      frontend/src/app/features/content/declarations.ts
  15. 7
      frontend/src/app/features/content/module.ts
  16. 2
      frontend/src/app/features/content/pages/content/references/content-references.component.ts
  17. 4
      frontend/src/app/features/content/pages/contents/contents-page.component.ts
  18. 52
      frontend/src/app/features/content/pages/references/references-page.component.html
  19. 0
      frontend/src/app/features/content/pages/references/references-page.component.scss
  20. 78
      frontend/src/app/features/content/pages/references/references-page.component.ts
  21. 6
      frontend/src/app/shared/components/assets/asset-dialog.component.html
  22. 218
      frontend/src/app/shared/services/assets.service.spec.ts
  23. 25
      frontend/src/app/shared/services/assets.service.ts
  24. 200
      frontend/src/app/shared/services/contents.service.spec.ts
  25. 96
      frontend/src/app/shared/services/contents.service.ts
  26. 60
      frontend/src/app/shared/state/contents.state.ts

2
backend/i18n/frontend_en.json

@ -129,6 +129,7 @@
"assets.uploaderUploadHere": "No upload in progress, drop files here.", "assets.uploaderUploadHere": "No upload in progress, drop files here.",
"assets.uploadFailed": "Failed to upload asset. Please reload.", "assets.uploadFailed": "Failed to upload asset. Please reload.",
"assets.uploadHint": "Drop file on existing item to replace the asset with a newer version.", "assets.uploadHint": "Drop file on existing item to replace the asset with a newer version.",
"assets.viewReferences": "View all content items referencing this asset.",
"assetScripts.reloaded": "Asset Scripts reloaded.", "assetScripts.reloaded": "Asset Scripts reloaded.",
"backups.backupCountAssetsLabel": "Assets", "backups.backupCountAssetsLabel": "Assets",
"backups.backupCountAssetsTooltip": "Archived assets", "backups.backupCountAssetsTooltip": "Archived assets",
@ -356,6 +357,7 @@
"common.queryOperators.matchs": "matchs", "common.queryOperators.matchs": "matchs",
"common.queryOperators.ne": "is not equal to", "common.queryOperators.ne": "is not equal to",
"common.queryOperators.startsWith": "starts with", "common.queryOperators.startsWith": "starts with",
"common.references": "References",
"common.refresh": "Refresh", "common.refresh": "Refresh",
"common.remember": "Don't ask again", "common.remember": "Don't ask again",
"common.rename": "Rename", "common.rename": "Rename",

2
backend/i18n/frontend_it.json

@ -129,6 +129,7 @@
"assets.uploaderUploadHere": "Nessun caricamento in corso, trascina qui i file.", "assets.uploaderUploadHere": "Nessun caricamento in corso, trascina qui i file.",
"assets.uploadFailed": "Non è stato possibile caricare la risorsa. Per favore ricarica.", "assets.uploadFailed": "Non è stato possibile caricare la risorsa. Per favore ricarica.",
"assets.uploadHint": "Trascina il file sull'elemento esistente per poterlo sostituire con una versione più recente.", "assets.uploadHint": "Trascina il file sull'elemento esistente per poterlo sostituire con una versione più recente.",
"assets.viewReferences": "View all content items referencing this asset.",
"assetScripts.reloaded": "Asset Scripts reloaded.", "assetScripts.reloaded": "Asset Scripts reloaded.",
"backups.backupCountAssetsLabel": "Risorse", "backups.backupCountAssetsLabel": "Risorse",
"backups.backupCountAssetsTooltip": "Risorse archiviate", "backups.backupCountAssetsTooltip": "Risorse archiviate",
@ -356,6 +357,7 @@
"common.queryOperators.matchs": "matchs", "common.queryOperators.matchs": "matchs",
"common.queryOperators.ne": "non è uguale a", "common.queryOperators.ne": "non è uguale a",
"common.queryOperators.startsWith": "inizia con", "common.queryOperators.startsWith": "inizia con",
"common.references": "References",
"common.refresh": "Aggiorna", "common.refresh": "Aggiorna",
"common.remember": "Ricorda la mia decisione", "common.remember": "Ricorda la mia decisione",
"common.rename": "Rinomina", "common.rename": "Rinomina",

2
backend/i18n/frontend_nl.json

@ -129,6 +129,7 @@
"assets.uploaderUploadHere": "Geen upload bezig, zet bestanden hier neer.", "assets.uploaderUploadHere": "Geen upload bezig, zet bestanden hier neer.",
"assets.uploadFailed": "Uploaden van item is mislukt. Laad opnieuw.", "assets.uploadFailed": "Uploaden van item is mislukt. Laad opnieuw.",
"assets.uploadHint": "Zet het bestand neer op bestaand item om het bestand te vervangen door een nieuwere versie.", "assets.uploadHint": "Zet het bestand neer op bestaand item om het bestand te vervangen door een nieuwere versie.",
"assets.viewReferences": "View all content items referencing this asset.",
"assetScripts.reloaded": "Asset Scripts reloaded.", "assetScripts.reloaded": "Asset Scripts reloaded.",
"backups.backupCountAssetsLabel": "Bestanden", "backups.backupCountAssetsLabel": "Bestanden",
"backups.backupCountAssetsTooltip": "Gearchiveerde middelen", "backups.backupCountAssetsTooltip": "Gearchiveerde middelen",
@ -356,6 +357,7 @@
"common.queryOperators.matchs": "komt overeen met", "common.queryOperators.matchs": "komt overeen met",
"common.queryOperators.ne": "is niet gelijk aan", "common.queryOperators.ne": "is niet gelijk aan",
"common.queryOperators.startsWith": "begint met", "common.queryOperators.startsWith": "begint met",
"common.references": "References",
"common.refresh": "Vernieuwen", "common.refresh": "Vernieuwen",
"common.remember": "Onthoud mijn keuze", "common.remember": "Onthoud mijn keuze",
"common.rename": "Hernoemen", "common.rename": "Hernoemen",

2
backend/i18n/frontend_pt.json

@ -129,6 +129,7 @@
"assets.uploaderUploadHere": "Sem upload em andamento, deixe cair os ficheiros aqui.", "assets.uploaderUploadHere": "Sem upload em andamento, deixe cair os ficheiros aqui.",
"assets.uploadFailed": "Falhou no upload do ficheiro. Por favor, recarregue.", "assets.uploadFailed": "Falhou no upload do ficheiro. Por favor, recarregue.",
"assets.uploadHint": "Deixe cair o ficheiro no item existente para substituir o ficheiro por uma versão mais recente.", "assets.uploadHint": "Deixe cair o ficheiro no item existente para substituir o ficheiro por uma versão mais recente.",
"assets.viewReferences": "View all content items referencing this asset.",
"assetScripts.reloaded": "Scripts de ficheiros recarregados.", "assetScripts.reloaded": "Scripts de ficheiros recarregados.",
"backups.backupCountAssetsLabel": "Ficheiros", "backups.backupCountAssetsLabel": "Ficheiros",
"backups.backupCountAssetsTooltip": "Ficheiros arquivados", "backups.backupCountAssetsTooltip": "Ficheiros arquivados",
@ -356,6 +357,7 @@
"common.queryOperators.matchs": "combina com", "common.queryOperators.matchs": "combina com",
"common.queryOperators.ne": "não é igual a", "common.queryOperators.ne": "não é igual a",
"common.queryOperators.startsWith": "começa com", "common.queryOperators.startsWith": "começa com",
"common.references": "References",
"common.refresh": "Refrescar", "common.refresh": "Refrescar",
"common.remember": "Não pergunte de novo.", "common.remember": "Não pergunte de novo.",
"common.rename": "Renomear", "common.rename": "Renomear",

2
backend/i18n/frontend_zh.json

@ -129,6 +129,7 @@
"assets.uploaderUploadHere": "没有正在进行的上传,将文件拖到这里。", "assets.uploaderUploadHere": "没有正在进行的上传,将文件拖到这里。",
"assets.uploadFailed": "资源上传失败,请重新加载。", "assets.uploadFailed": "资源上传失败,请重新加载。",
"assets.uploadHint": "在现有项目上放置文件以使用更新版本替换资源。", "assets.uploadHint": "在现有项目上放置文件以使用更新版本替换资源。",
"assets.viewReferences": "View all content items referencing this asset.",
"assetScripts.reloaded": "Asset Scripts reloaded.", "assetScripts.reloaded": "Asset Scripts reloaded.",
"backups.backupCountAssetsLabel": "资源", "backups.backupCountAssetsLabel": "资源",
"backups.backupCountAssetsTooltip": "存档资源", "backups.backupCountAssetsTooltip": "存档资源",
@ -356,6 +357,7 @@
"common.queryOperators.matchs": "匹配", "common.queryOperators.matchs": "匹配",
"common.queryOperators.ne": "不等于", "common.queryOperators.ne": "不等于",
"common.queryOperators.startsWith": "开始于", "common.queryOperators.startsWith": "开始于",
"common.references": "References",
"common.refresh": "刷新", "common.refresh": "刷新",
"common.remember": "不要再问了", "common.remember": "不要再问了",
"common.rename": "重命名", "common.rename": "重命名",

2
backend/i18n/source/frontend_en.json

@ -129,6 +129,7 @@
"assets.uploaderUploadHere": "No upload in progress, drop files here.", "assets.uploaderUploadHere": "No upload in progress, drop files here.",
"assets.uploadFailed": "Failed to upload asset. Please reload.", "assets.uploadFailed": "Failed to upload asset. Please reload.",
"assets.uploadHint": "Drop file on existing item to replace the asset with a newer version.", "assets.uploadHint": "Drop file on existing item to replace the asset with a newer version.",
"assets.viewReferences": "View all content items referencing this asset.",
"assetScripts.reloaded": "Asset Scripts reloaded.", "assetScripts.reloaded": "Asset Scripts reloaded.",
"backups.backupCountAssetsLabel": "Assets", "backups.backupCountAssetsLabel": "Assets",
"backups.backupCountAssetsTooltip": "Archived assets", "backups.backupCountAssetsTooltip": "Archived assets",
@ -356,6 +357,7 @@
"common.queryOperators.matchs": "matchs", "common.queryOperators.matchs": "matchs",
"common.queryOperators.ne": "is not equal to", "common.queryOperators.ne": "is not equal to",
"common.queryOperators.startsWith": "starts with", "common.queryOperators.startsWith": "starts with",
"common.references": "References",
"common.refresh": "Refresh", "common.refresh": "Refresh",
"common.remember": "Don't ask again", "common.remember": "Don't ask again",
"common.rename": "Rename", "common.rename": "Rename",

29
backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasSearchSource.cs

@ -23,7 +23,6 @@ public sealed class SchemasSearchSource : ISearchSource
public SchemasSearchSource(IAppProvider appProvider, IUrlGenerator urlGenerator) public SchemasSearchSource(IAppProvider appProvider, IUrlGenerator urlGenerator)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.urlGenerator = urlGenerator; this.urlGenerator = urlGenerator;
} }
@ -34,24 +33,26 @@ public sealed class SchemasSearchSource : ISearchSource
var schemas = await appProvider.GetSchemasAsync(context.App.Id, ct); var schemas = await appProvider.GetSchemasAsync(context.App.Id, ct);
if (schemas.Count > 0) if (schemas.Count <= 0)
{ {
var appId = context.App.NamedId(); return result;
}
foreach (var schema in schemas) var appId = context.App.NamedId();
{
var schemaId = schema.NamedId(); foreach (var schema in schemas)
{
var schemaId = schema.NamedId();
var name = schema.SchemaDef.DisplayNameUnchanged(); var name = schema.SchemaDef.DisplayNameUnchanged();
if (name.Contains(query, StringComparison.OrdinalIgnoreCase)) if (name.Contains(query, StringComparison.OrdinalIgnoreCase))
{ {
AddSchemaUrl(result, appId, schemaId, name); AddSchemaUrl(result, appId, schemaId, name);
if (schema.SchemaDef.Type != SchemaType.Component && HasPermission(context, schemaId)) if (schema.SchemaDef.Type != SchemaType.Component && HasPermission(context, schemaId))
{ {
AddContentsUrl(result, appId, schema, schemaId, name); AddContentsUrl(result, appId, schema, schemaId, name);
}
} }
} }
} }

4
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs

@ -60,7 +60,7 @@ public sealed class ContentsSharedController : ApiController
/// Queries contents. /// Queries contents.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="query">The required query object.</param> /// <param name="query">The query object.</param>
/// <response code="200">Contents returned.</response>. /// <response code="200">Contents returned.</response>.
/// <response code="404">App not found.</response>. /// <response code="404">App not found.</response>.
/// <remarks> /// <remarks>
@ -73,7 +73,7 @@ public sealed class ContentsSharedController : ApiController
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetAllContents(string app, AllContentsByGetDto query) public async Task<IActionResult> GetAllContents(string app, AllContentsByGetDto query)
{ {
var contents = await contentQuery.QueryAsync(Context, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); var contents = await contentQuery.QueryAsync(Context, (query ?? new AllContentsByGetDto()).ToQuery(Request), HttpContext.RequestAborted);
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {

47
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/AllContentsByGetDto.cs

@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
@ -33,18 +34,54 @@ public sealed class AllContentsByGetDto
[FromQuery] [FromQuery]
public Instant? ScheduledTo { get; set; } public Instant? ScheduledTo { get; set; }
public Q ToQuery() /// <summary>
/// The ID of the referencing content item.
/// </summary>
[FromQuery]
public DomainId? Referencing { get; set; }
/// <summary>
/// The ID of the reference content item.
/// </summary>
[FromQuery]
public DomainId? References { get; set; }
/// <summary>
/// The optional json query.
/// </summary>
[FromQuery(Name = "q")]
public string? JsonQuery { get; set; }
public Q ToQuery(HttpRequest request)
{ {
var result = Q.Empty;
if (!string.IsNullOrWhiteSpace(Ids)) if (!string.IsNullOrWhiteSpace(Ids))
{ {
return Q.Empty.WithIds(Ids); result = result.WithIds(Ids);
}
else if (ScheduledFrom != null && ScheduledTo != null)
{
result = result.WithSchedule(ScheduledFrom.Value, ScheduledTo.Value);
}
else if (Referencing != null)
{
result = result.WithReferencing(Referencing.Value);
}
else if (References != null)
{
result = result.WithReference(References.Value);
}
else
{
throw new ValidationException(T.Get("contents.invalidAllQuery"));
} }
if (ScheduledFrom != null && ScheduledTo != null) if (JsonQuery != null)
{ {
return Q.Empty.WithSchedule(ScheduledFrom.Value, ScheduledTo.Value); result = result.WithJsonQuery(JsonQuery);
} }
throw new ValidationException(T.Get("contents.invalidAllQuery")); return result.WithODataQuery(request.Query.ToString());
} }
} }

50
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/AllContentsByPostDto.cs

@ -8,8 +8,11 @@
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Squidex.Areas.Api.Controllers.Contents.Models; namespace Squidex.Areas.Api.Controllers.Contents.Models;
@ -30,18 +33,57 @@ public sealed class AllContentsByPostDto
/// </summary> /// </summary>
public Instant? ScheduledTo { get; set; } public Instant? ScheduledTo { get; set; }
/// <summary>
/// The ID of the referencing content item.
/// </summary>
public DomainId? Referencing { get; set; }
/// <summary>
/// The ID of the reference content item.
/// </summary>
public DomainId? References { get; set; }
/// <summary>
/// The optional odata query.
/// </summary>
public string? OData { get; set; }
/// <summary>
/// The optional json query.
/// </summary>
[JsonPropertyName("q")]
public JsonDocument? JsonQuery { get; set; }
public Q ToQuery() public Q ToQuery()
{ {
var result = Q.Empty;
if (Ids?.Length > 0) if (Ids?.Length > 0)
{ {
return Q.Empty.WithIds(Ids); result = result.WithIds(Ids);
}
else if (ScheduledFrom != null && ScheduledTo != null)
{
result = result.WithSchedule(ScheduledFrom.Value, ScheduledTo.Value);
}
else if (Referencing != null)
{
result = result.WithReferencing(Referencing.Value);
}
else if (References != null)
{
result = result.WithReference(References.Value);
}
else
{
throw new ValidationException(T.Get("contents.invalidAllQuery"));
} }
if (ScheduledFrom != null && ScheduledTo != null) if (JsonQuery != null)
{ {
return Q.Empty.WithSchedule(ScheduledFrom.Value, ScheduledTo.Value); result = result.WithJsonQuery(JsonQuery.RootElement.ToString());
} }
throw new ValidationException(T.Get("contents.invalidAllQuery")); return result.WithODataQuery(OData);
} }
} }

7
backend/src/Squidex/Areas/Api/Controllers/QueryDto.cs

@ -44,16 +44,11 @@ public sealed class QueryDto
result = result.WithIds(Ids); result = result.WithIds(Ids);
} }
if (OData != null)
{
result = result.WithODataQuery(OData);
}
if (JsonQuery != null) if (JsonQuery != null)
{ {
result = result.WithJsonQuery(JsonQuery.RootElement.ToString()); result = result.WithJsonQuery(JsonQuery.RootElement.ToString());
} }
return result; return result.WithODataQuery(OData);
} }
} }

6
backend/src/Squidex/appsettings.json

@ -314,12 +314,10 @@
// The log level of the default log adapter. // The log level of the default log adapter.
"logLevel": { "logLevel": {
"Default": "Information", "default": "Information",
// Only logs issued tokens. // Only logs issued tokens and general request information.
"OpenIddict": "Warning", "OpenIddict": "Warning",
// Only logs request infos.
"Microsoft.AspNetCore": "Warning", "Microsoft.AspNetCore": "Warning",
"Microsoft.Identity": "Warning", "Microsoft.Identity": "Warning",
"Runtime": "Warning" "Runtime": "Warning"

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemasSearchSourceTests.cs

@ -16,7 +16,8 @@ using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Schemas;
public class SchemasSearchSourceTests : GivenContext, IClassFixture<TranslationsFixture> public class SchemasSearchSourceTests : GivenContext, IClassFixture<TranslationsFixture>
{ private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>(); {
private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>();
private readonly SchemasSearchSource sut; private readonly SchemasSearchSource sut;
public SchemasSearchSourceTests() public SchemasSearchSourceTests()

1
frontend/src/app/features/content/declarations.ts

@ -22,6 +22,7 @@ export * from './pages/contents/contents-page.component';
export * from './pages/contents/custom-view-editor.component'; export * from './pages/contents/custom-view-editor.component';
export * from './pages/schemas/schemas-page.component'; export * from './pages/schemas/schemas-page.component';
export * from './pages/sidebar/sidebar-page.component'; export * from './pages/sidebar/sidebar-page.component';
export * from './pages/references/references-page.component';
export * from './shared/content-extension.component'; export * from './shared/content-extension.component';
export * from './shared/due-time-selector.component'; export * from './shared/due-time-selector.component';
export * from './shared/forms/array-editor.component'; export * from './shared/forms/array-editor.component';

7
frontend/src/app/features/content/module.ts

@ -9,7 +9,7 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { VirtualScrollerModule } from 'ngx-virtual-scroller'; import { VirtualScrollerModule } from 'ngx-virtual-scroller';
import { CanDeactivateGuard, ContentMustExistGuard, LoadLanguagesGuard, LoadSchemasGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SqxFrameworkModule, SqxSharedModule } from '@app/shared'; import { CanDeactivateGuard, ContentMustExistGuard, LoadLanguagesGuard, LoadSchemasGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { ArrayEditorComponent, ArrayItemComponent, AssetsEditorComponent, CalendarPageComponent, CommentsPageComponent, ComponentComponent, ComponentSectionComponent, ContentComponent, ContentCreatorComponent, ContentEditorComponent, ContentEventComponent, ContentExtensionComponent, ContentFieldComponent, ContentHistoryPageComponent, ContentInspectionComponent, ContentPageComponent, ContentReferencesComponent, ContentSectionComponent, ContentsFiltersPageComponent, ContentsPageComponent, CustomViewEditorComponent, DueTimeSelectorComponent, FieldCopyButtonComponent, FieldEditorComponent, FieldLanguagesComponent, IFrameEditorComponent, PreviewButtonComponent, ReferenceDropdownComponent, ReferenceItemComponent, ReferencesCheckboxesComponent, ReferencesEditorComponent, ReferencesTagsComponent, SchemasPageComponent, SidebarPageComponent, StockPhotoEditorComponent } from './declarations'; import { ArrayEditorComponent, ArrayItemComponent, AssetsEditorComponent, CalendarPageComponent, CommentsPageComponent, ComponentComponent, ComponentSectionComponent, ContentComponent, ContentCreatorComponent, ContentEditorComponent, ContentEventComponent, ContentExtensionComponent, ContentFieldComponent, ContentHistoryPageComponent, ContentInspectionComponent, ContentPageComponent, ContentReferencesComponent, ContentSectionComponent, ContentsFiltersPageComponent, ContentsPageComponent, CustomViewEditorComponent, DueTimeSelectorComponent, FieldCopyButtonComponent, FieldEditorComponent, FieldLanguagesComponent, IFrameEditorComponent, PreviewButtonComponent, ReferenceDropdownComponent, ReferenceItemComponent, ReferencesCheckboxesComponent, ReferencesEditorComponent, ReferencesPageComponent, ReferencesTagsComponent, SchemasPageComponent, SidebarPageComponent, StockPhotoEditorComponent } from './declarations';
const routes: Routes = [ const routes: Routes = [
{ {
@ -21,6 +21,10 @@ const routes: Routes = [
path: '__calendar', path: '__calendar',
component: CalendarPageComponent, component: CalendarPageComponent,
}, },
{
path: '__references/:referenceId',
component: ReferencesPageComponent,
},
{ {
path: ':schemaName', path: ':schemaName',
canActivate: [SchemaMustExistPublishedGuard], canActivate: [SchemaMustExistPublishedGuard],
@ -121,6 +125,7 @@ const routes: Routes = [
ReferencesCheckboxesComponent, ReferencesCheckboxesComponent,
ReferencesEditorComponent, ReferencesEditorComponent,
ReferencesEditorComponent, ReferencesEditorComponent,
ReferencesPageComponent,
ReferencesTagsComponent, ReferencesTagsComponent,
SchemasPageComponent, SchemasPageComponent,
SidebarPageComponent, SidebarPageComponent,

2
frontend/src/app/features/content/pages/content/references/content-references.component.ts

@ -67,7 +67,7 @@ export class ContentReferencesComponent implements OnChanges, OnInit, OnDestroy
.getInitial(); .getInitial();
if (this.mode === 'references') { if (this.mode === 'references') {
this.contentsState.loadReference(this.content.id, initial); this.contentsState.loadReferences(this.content.id, initial);
} else { } else {
this.contentsState.loadReferencing(this.content.id, initial); this.contentsState.loadReferencing(this.content.id, initial);
} }

4
frontend/src/app/features/content/pages/contents/contents-page.component.ts

@ -96,8 +96,6 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.schema = schema; this.schema = schema;
this.tableSettings = new TableSettings(this.uiState, schema);
const initial = const initial =
this.contentsRoute.mapTo(this.contentsState) this.contentsRoute.mapTo(this.contentsState)
.withPaging('contents', 10) .withPaging('contents', 10)
@ -107,6 +105,8 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.load(false, true, initial); this.contentsState.load(false, true, initial);
this.contentsRoute.listen(); this.contentsRoute.listen();
this.tableSettings = new TableSettings(this.uiState, schema);
const languageKey = this.localStore.get(this.languageKey()); const languageKey = this.localStore.get(this.languageKey());
const language = this.languages.find(x => x.iso2Code === languageKey); const language = this.languages.find(x => x.iso2Code === languageKey);

52
frontend/src/app/features/content/pages/references/references-page.component.html

@ -0,0 +1,52 @@
<sqx-title message="i18n:common.references"></sqx-title>
<sqx-layout layout="main" titleText="i18n:common.references" titleIcon="contents">
<ng-container menu>
<div class="row flex-nowrap flex-grow-1 gx-2">
<div class="col-auto ms-8">
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="i18n:contents.refreshTooltip" shortcut="CTRL + B">
<i class="icon-reset"></i> {{ 'common.refresh' | sqxTranslate }}
</button>
</div>
<div class="col-auto" *ngIf="languages.length > 1">
<sqx-language-selector class="languages-buttons"
(languageChange)="changeLanguage($event)"
[language]="language"
[languages]="languages">
</sqx-language-selector>
</div>
</div>
</ng-container>
<ng-container>
<sqx-list-view [isLoading]="contentsState.isLoading | async" [table]="true">
<ng-container>
<table class="table table-items table-fixed" *ngIf="contentsState.contents | async; let contents">
<tbody *ngFor="let content of contents; trackBy: trackByContent"
[sqxReferenceItem]="content"
[canRemove]="false"
[columns]="contents | sqxContentsColumns"
[isCompact]="false"
[isDisabled]="false"
[language]="language"
[languages]="languages"
[validations]="(contentsState.validationResults | async)!"
[validityVisible]="true">
</tbody>
<tbody *ngIf="(contentsState.isLoaded | async) && contents.length === 0">
<tr>
<td class="table-items-row-empty">
{{ 'contents.noReferencing' | sqxTranslate }}
</td>
</tr>
</tbody>
</table>
</ng-container>
<ng-container footer>
<sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager>
</ng-container>
</sqx-list-view>
</ng-container>
</sqx-layout>

0
frontend/src/app/features/content/pages/references/references-page.component.scss

78
frontend/src/app/features/content/pages/references/references-page.component.ts

@ -0,0 +1,78 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { AppLanguageDto, ComponentContentsState, ContentDto, LanguagesState, QuerySynchronizer, ResourceOwner, Router2State } from '@app/shared';
@Component({
selector: 'sqx-references-page',
styleUrls: ['./references-page.component.scss'],
templateUrl: './references-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
Router2State, ComponentContentsState,
],
})
export class ReferencesPageComponent extends ResourceOwner implements OnInit {
public language!: AppLanguageDto;
public languages!: ReadonlyArray<AppLanguageDto>;
constructor(
public readonly contentsRoute: Router2State,
public readonly contentsState: ComponentContentsState,
public readonly languagesState: LanguagesState,
private readonly route: ActivatedRoute,
) {
super();
}
public ngOnInit() {
this.own(
this.languagesState.isoMasterLanguage
.subscribe(language => {
this.language = language;
}));
this.own(
this.languagesState.isoLanguages
.subscribe(languages => {
this.languages = languages;
}));
this.own(
getReferenceId(this.route)
.subscribe(referenceId => {
const initial =
this.contentsRoute.mapTo(this.contentsState)
.withPaging('contents', 10)
.withSynchronizer(QuerySynchronizer.INSTANCE)
.getInitial();
this.contentsState.schema = { name: null! };
this.contentsState.loadReferences(referenceId, initial);
this.contentsRoute.listen();
}));
}
public reload() {
this.contentsState.load(true);
}
public changeLanguage(language: AppLanguageDto) {
this.language = language;
}
public trackByContent(_index: number, content: ContentDto) {
return content.id;
}
}
function getReferenceId(route: ActivatedRoute) {
return route.params.pipe(map(x => x['referenceId'] as string), distinctUntilChanged());
}

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

@ -176,6 +176,12 @@
{{ 'assets.protectedHint' | sqxTranslate }} {{ 'assets.protectedHint' | sqxTranslate }}
</sqx-form-hint> </sqx-form-hint>
</div> </div>
<hr />
<div class="form-group">
<a [routerLink]="['../content/__references', asset.id]" target="_blank">{{ 'assets.viewReferences' | sqxTranslate }}</a>
</div>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="1"> <ng-container *ngSwitchCase="1">

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

@ -28,6 +28,58 @@ describe('AssetsService', () => {
httpMock.verify(); httpMock.verify();
})); }));
const tests = [
{
name: 'basic query',
query: { take: 17, skip: 13 },
requestBody: { q: sanitize({ take: 17, skip: 13 }), parentId: undefined },
noSlowTotal: null,
noTotal: null,
},
{
name: 'basic query without total',
query: { take: 17, skip: 13, noTotal: true, noSlowTotal: true },
requestBody: { q: sanitize({ take: 17, skip: 13 }), parentId: undefined },
noSlowTotal: '1',
noTotal: '1',
},
{
name: 'query by parent',
query: { take: 17, skip: 13, parentId: '1' },
requestBody: { q: sanitize({ take: 17, skip: 13 }), parentId: '1' },
noSlowTotal: null,
noTotal: null,
},
{
name: 'query by name',
query: { take: 17, skip: 13, query: { fullText: 'my-query' } },
requestBody: { q: sanitize({ filter: { and: [{ path: 'fileName', op: 'contains', value: 'my-query' }] }, take: 17, skip: 13 }), parentId: undefined },
noSlowTotal: null,
noTotal: null,
},
{
name: 'query by tag',
query: { take: 17, skip: 13, tags: ['tag1'] },
requestBody: { q: sanitize({ filter: { and: [{ path: 'tags', op: 'eq', value: 'tag1' }] }, take: 17, skip: 13 }), parentId: undefined },
noSlowTotal: null,
noTotal: null,
},
{
name: 'query by ids',
query: { ids: ['1', '2'] },
requestBody: { ids: ['1', '2'] },
noSlowTotal: null,
noTotal: null,
},
{
name: 'query by ref',
query: { ref: '1' },
requestBody: { q: sanitize({ filter: { or: [{ path: 'id', op: 'eq', value: '1' }, { path: 'slug', op: 'eq', value: '1' }] }, take: 1 }) },
noSlowTotal: null,
noTotal: '1',
},
];
it('should make get request to get asset tags', it('should make get request to get asset tags',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let tags: any; let tags: any;
@ -79,46 +131,46 @@ describe('AssetsService', () => {
}); });
})); }));
it('should make post request to get assets', tests.forEach(x => {
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { it(`should make post request to get assets with ${x.name}`,
let assets: AssetsDto; inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let assets: AssetsDto;
assetsService.getAssets('my-app', { take: 17, skip: 13 }).subscribe(result => {
assets = result; assetsService.getAssets('my-app', x.query).subscribe(result => {
}); assets = result;
});
const expectedQuery = { take: 17, skip: 13 };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.method).toEqual('POST'); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('X-NoSlowTotal')).toEqual(x.noSlowTotal);
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull(); expect(req.request.headers.get('X-NoTotal')).toEqual(x.noTotal);
expect(req.request.headers.get('X-NoTotal')).toBeNull(); expect(req.request.body).toEqual(x.requestBody);
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({
req.flush({ total: 10,
total: 10, items: [
items: [ assetResponse(12),
assetResponse(12), assetResponse(13),
assetResponse(13), ],
], folders: [
folders: [ assetFolderResponse(22),
assetFolderResponse(22), assetFolderResponse(23),
assetFolderResponse(23), ],
], });
});
expect(assets!).toEqual({
expect(assets!).toEqual({ items: [
items: [ createAsset(12),
createAsset(12), createAsset(13),
createAsset(13), ],
], total: 10,
total: 10, canCreate: false,
canCreate: false, canRenameTag: false,
canRenameTag: false, });
}); }));
})); });
it('should make get request to get asset folders', it('should make get request to get asset folders',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
@ -174,92 +226,6 @@ describe('AssetsService', () => {
expect(asset!).toEqual(createAsset(12)); expect(asset!).toEqual(createAsset(12));
})); }));
it('should make post request to get assets by name',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const query = { fullText: 'my-query' };
assetsService.getAssets('my-app', { take: 17, skip: 13, query }).subscribe();
const expectedQuery = { filter: { and: [{ path: 'fileName', op: 'contains', value: 'my-query' }] }, take: 17, skip: 13 };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ total: 10, items: [] });
}));
it('should make post request to get assets by tag',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
assetsService.getAssets('my-app', { take: 17, skip: 13, tags: ['tag1'] }).subscribe();
const expectedQuery = { filter: { and: [{ path: 'tags', op: 'eq', value: 'tag1' }] }, take: 17, skip: 13 };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ total: 10, items: [] });
}));
it('should make post request to get assets by ids',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const ids = ['1', '2'];
assetsService.getAssets('my-app', { ids }).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ ids });
req.flush({ total: 10, items: [] });
}));
it('should make post request to get assets by ref',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const value = '1', op = 'eq';
assetsService.getAssets('my-app', { ref: value }).subscribe();
const expectedQuery = { filter: { or: [{ path: 'id', op, value }, { path: 'slug', op, value }] }, take: 1 };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toEqual('1');
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ total: 10, items: [] });
}));
it('should make post request to get assets without total',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
assetsService.getAssets('my-app', { take: 17, skip: 13, noSlowTotal: true, noTotal: true }).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBe('1');
expect(req.request.headers.get('X-NoSlowTotal')).toBe('1');
req.flush({ total: 10, items: [] });
}));
it('should make post request to create asset', it('should make post request to create asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let asset: AssetDto; let asset: AssetDto;

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

@ -418,12 +418,6 @@ function buildHeaders(q: AssetsQuery | undefined, noTotal: boolean) {
function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef) { function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef) {
const { ids, parentId, query, ref, skip, tags, take } = q || {}; const { ids, parentId, query, ref, skip, tags, take } = q || {};
const body: any = {};
if (parentId) {
body.parentId = parentId;
}
if (ref) { if (ref) {
const queryObj: Query = { const queryObj: Query = {
filter: { filter: {
@ -440,28 +434,27 @@ function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef)
take: 1, take: 1,
}; };
body.q = sanitize(queryObj); return { q: sanitize(queryObj) };
} else if (Types.isArray(ids)) { } else if (Types.isArray(ids)) {
body.ids = ids; return { ids };
} else { } else {
const queryObj: Query = {}; const queryObj: Query = {};
const queryFilters: any[] = [];
const filters: any[] = [];
if (query && query.fullText && query.fullText.length > 0) { if (query && query.fullText && query.fullText.length > 0) {
filters.push({ path: 'fileName', op: 'contains', value: query.fullText }); queryFilters.push({ path: 'fileName', op: 'contains', value: query.fullText });
} }
if (tags) { if (tags) {
for (const tag of tags) { for (const tag of tags) {
if (tag && tag.length > 0) { if (tag && tag.length > 0) {
filters.push({ path: 'tags', op: 'eq', value: tag }); queryFilters.push({ path: 'tags', op: 'eq', value: tag });
} }
} }
} }
if (filters.length > 0) { if (queryFilters.length > 0) {
queryObj.filter = { and: filters }; queryObj.filter = { and: queryFilters };
} }
if (take && take > 0) { if (take && take > 0) {
@ -472,10 +465,8 @@ function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef)
queryObj.skip = skip; queryObj.skip = skip;
} }
body.q = sanitize(queryObj); return { q: sanitize(queryObj), parentId };
} }
return body;
} }
function parseAssets(response: { items: any[]; total: number } & Resource): AssetsDto { function parseAssets(response: { items: any[]; total: number } & Resource): AssetsDto {

200
frontend/src/app/shared/services/contents.service.spec.ts

@ -31,112 +31,126 @@ describe('ContentsService', () => {
httpMock.verify(); httpMock.verify();
})); }));
it('should make post request to get contents with json query', const tests = [
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { {
const query = { fullText: 'my-query' }; name: 'json query',
query: { take: 17, skip: 13, query: { fullText: 'my-query' } },
let contents: ContentsDto; requestBody: { q: sanitize({ fullText: 'my-query', take: 17, skip: 13 }) },
requestString: `q=${JSON.stringify(sanitize({ fullText: 'my-query', take: 17, skip: 13 }))}`,
contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query }).subscribe(result => { noSlowTotal: null,
contents = result; noTotal: null,
}); },
{
const expectedQuery = { ...query, take: 17, skip: 13 }; name: 'odata filter',
query: { take: 17, skip: 13, query: { fullText: '$filter=my-filter' } },
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query'); requestBody: { odata: '$filter=my-filter&$top=17&$skip=13' },
requestString: '$filter=my-filter&$top=17&$skip=13',
expect(req.request.method).toEqual('POST'); noSlowTotal: null,
expect(req.request.headers.get('If-Match')).toBeNull(); noTotal: null,
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull(); },
expect(req.request.headers.get('X-NoTotal')).toBeNull(); {
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) }); name: 'json query without total',
query: { take: 17, skip: 13, query: { fullText: 'my-query' }, noTotal: true, noSlowTotal: true },
req.flush({ requestBody: { q: sanitize({ fullText: 'my-query', take: 17, skip: 13 }) },
total: 10, requestString: `q=${JSON.stringify(sanitize({ fullText: 'my-query', take: 17, skip: 13 }))}`,
items: [ noSlowTotal: '1',
contentResponse(12), noTotal: '1',
contentResponse(13), },
], ];
statuses: [{
status: 'Draft', color: 'Gray', tests.forEach(x => {
}], it(`should make post request to get contents using ${x.name}`,
}); inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
let contents: ContentsDto;
expect(contents!).toEqual({
items: [ contentsService.getContents('my-app', 'my-schema', x.query).subscribe(result => {
createContent(12), contents = result;
createContent(13), });
],
total: 10, const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query');
statuses: [
{ status: 'Draft', color: 'Gray' }, expect(req.request.method).toEqual('POST');
], expect(req.request.headers.get('If-Match')).toBeNull();
canCreate: false, expect(req.request.headers.get('X-NoSlowTotal')).toEqual(x.noSlowTotal);
canCreateAndPublish: false, expect(req.request.headers.get('X-NoTotal')).toEqual(x.noTotal);
}); expect(req.request.body).toEqual({ ...x.requestBody });
}));
req.flush({
it('should make post request to get contents with odata filter', total: 10,
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { items: [
const query = { fullText: '$filter=my-filter' }; contentResponse(12),
contentResponse(13),
contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query }).subscribe(); ],
statuses: [{
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query'); status: 'Draft', color: 'Gray',
}],
expect(req.request.method).toEqual('POST'); });
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull(); expect(contents!).toEqual({
expect(req.request.headers.get('X-NoTotal')).toBeNull(); items: [
expect(req.request.body).toEqual({ odata: '$filter=my-filter&$top=17&$skip=13' }); createContent(12),
createContent(13),
],
total: 10,
statuses: [
{ status: 'Draft', color: 'Gray' },
],
canCreate: false,
canCreateAndPublish: false,
});
}));
});
req.flush({ total: 10, items: [] }); tests.forEach(x => {
})); it(`should make post request to get all contents using ${x.name}`,
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
const ids = ['1', '2', '3'];
it('should make post request to get all contents by ids', contentsService.getAllContents('my-app', { ids }, x.query).subscribe();
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
const ids = ['1', '2', '3'];
contentsService.getAllContents('my-app', { ids }).subscribe(); const req = httpMock.expectOne('http://service/p/api/content/my-app');
const req = httpMock.expectOne('http://service/p/api/content/my-app'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toEqual(x.noSlowTotal);
expect(req.request.headers.get('X-NoTotal')).toEqual(x.noTotal);
expect(req.request.body).toEqual({ ids, ...x.requestBody });
expect(req.request.method).toEqual('POST'); req.flush({ total: 10, items: [] });
expect(req.request.headers.get('If-Match')).toBeNull(); }));
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull(); });
expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ ids });
req.flush({ total: 10, items: [] }); tests.forEach(x => {
})); it(`should make get request to get references with using ${x.name}`,
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getContentReferences('my-app', 'my-schema', '42', x.query).subscribe();
it('should make post request to get contents without total', const req = httpMock.expectOne(`http://service/p/api/content/my-app/my-schema/42/references?${x.requestString}`);
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getContents('my-app', 'my-schema', { noTotal: true, noSlowTotal: true }).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toEqual(x.noSlowTotal);
expect(req.request.headers.get('X-NoTotal')).toEqual(x.noTotal);
expect(req.request.method).toEqual('POST'); req.flush({ total: 10, items: [] });
expect(req.request.headers.get('If-Match')).toBeNull(); }));
expect(req.request.headers.get('X-NoSlowTotal')).toBe('1'); });
expect(req.request.headers.get('X-NoTotal')).toBe('1');
req.flush({ total: 10, items: [] }); tests.forEach(x => {
})); it(`should make get request to get referencing with using ${x.name}`,
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getContentReferencing('my-app', 'my-schema', '42', x.query).subscribe();
it('should make post request to get all contents without total', const req = httpMock.expectOne(`http://service/p/api/content/my-app/my-schema/42/referencing?${x.requestString}`);
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getAllContents('my-app', { ids: [], noTotal: true, noSlowTotal: true }).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toEqual(x.noSlowTotal);
expect(req.request.headers.get('X-NoTotal')).toEqual(x.noTotal);
expect(req.request.method).toEqual('POST'); req.flush({ total: 10, items: [] });
expect(req.request.headers.get('If-Match')).toBeNull(); }));
expect(req.request.headers.get('X-NoSlowTotal')).toBe('1'); });
expect(req.request.headers.get('X-NoTotal')).toBe('1');
req.flush({ total: 10, items: [] });
}));
it('should make get request to get content', it('should make get request to get content',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {

96
frontend/src/app/shared/services/contents.service.ts

@ -168,7 +168,7 @@ export type ContentsQuery = Readonly<{
export type ContentsByIds = Readonly<{ export type ContentsByIds = Readonly<{
// The Ids of the contents to query. // The Ids of the contents to query.
ids: ReadonlyArray<string>; ids: ReadonlyArray<string>;
}> & ContentsQuery; }>;
export type ContentsBySchedule = Readonly<{ export type ContentsBySchedule = Readonly<{
// The start of the time frame for scheduled content items. // The start of the time frame for scheduled content items.
@ -176,7 +176,17 @@ export type ContentsBySchedule = Readonly<{
// The end of the time frame for scheduled content items. // The end of the time frame for scheduled content items.
scheduledTo: string | null; scheduledTo: string | null;
}> & ContentsQuery; }>;
export type ContentsByReferences = Readonly<{
// The reference content id.
references: string;
}>;
export type ContentsByReferencing = Readonly<{
// The referencing content id.
referencing: string;
}>;
export type ContentsByQuery = Readonly<{ export type ContentsByQuery = Readonly<{
// The JSON query. // The JSON query.
@ -189,6 +199,8 @@ export type ContentsByQuery = Readonly<{
take?: number; take?: number;
}> & ContentsQuery; }> & ContentsQuery;
type FullQuery = ContentsByIds | ContentsBySchedule | ContentsByReferences | ContentsByReferencing;
@Injectable() @Injectable()
export class ContentsService { export class ContentsService {
constructor( constructor(
@ -202,36 +214,19 @@ export class ContentsService {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/query`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/query`);
return this.http.post<any>(url, body, buildHeaders(q, false)).pipe( return this.http.post<any>(url, body, buildHeaders(q)).pipe(
map(body => { map(body => {
return parseContents(body); return parseContents(body);
}), }),
pretifyError('i18n:contents.loadFailed')); pretifyError('i18n:contents.loadFailed'));
} }
public getContent(appName: string, schemaName: string, id: string, language?: string): Observable<ContentDto> { public getAllContents(appName: string, primary: FullQuery, q?: ContentsByQuery): Observable<ContentsDto> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); const body = buildFullQuery(primary, q);
let headers = new HttpHeaders();
if (language) {
headers = headers.set('X-Flatten', '1');
headers = headers.set('X-Languages', language);
}
return HTTP.getVersioned(this.http, url, undefined, headers).pipe(
map(({ payload }) => {
return parseContent(payload.body);
}),
pretifyError('i18n:contents.loadContentFailed'));
}
public getAllContents(appName: string, q: ContentsByIds | ContentsBySchedule): Observable<ContentsDto> {
const { ...body } = q;
const url = this.apiUrl.buildUrl(`/api/content/${appName}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}`);
return this.http.post<any>(url, body, buildHeaders(q, false)).pipe( return this.http.post<any>(url, body, buildHeaders(q)).pipe(
map(body => { map(body => {
return parseContents(body); return parseContents(body);
}), }),
@ -239,11 +234,11 @@ export class ContentsService {
} }
public getContentReferences(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable<ContentsDto> { public getContentReferences(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable<ContentsDto> {
const { fullQuery } = buildQuery(q); const query = buildQuery(q);
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/references?${fullQuery}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/references?${buildQueryString(query)}`);
return this.http.get<any>(url, buildHeaders(q, false)).pipe( return this.http.get<any>(url, buildHeaders(q)).pipe(
map(body => { map(body => {
return parseContents(body); return parseContents(body);
}), }),
@ -251,17 +246,34 @@ export class ContentsService {
} }
public getContentReferencing(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable<ContentsDto> { public getContentReferencing(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable<ContentsDto> {
const { fullQuery } = buildQuery(q); const query = buildQuery(q);
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/referencing?${fullQuery}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/referencing?${buildQueryString(query)}`);
return this.http.get<any>(url).pipe( return this.http.get<any>(url, buildHeaders(q)).pipe(
map(body => { map(body => {
return parseContents(body); return parseContents(body);
}), }),
pretifyError('i18n:contents.loadFailed')); pretifyError('i18n:contents.loadFailed'));
} }
public getContent(appName: string, schemaName: string, id: string, language?: string): Observable<ContentDto> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);
let headers = new HttpHeaders();
if (language) {
headers = headers.set('X-Flatten', '1');
headers = headers.set('X-Languages', language);
}
return HTTP.getVersioned(this.http, url, undefined, headers).pipe(
map(({ payload }) => {
return parseContent(payload.body);
}),
pretifyError('i18n:contents.loadContentFailed'));
}
public getVersionData(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> { public getVersionData(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/${version.value}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/${version.value}`);
@ -353,12 +365,12 @@ export class ContentsService {
} }
} }
function buildHeaders(q: ContentsQuery | undefined, noTotal: boolean) { function buildHeaders(q: ContentsQuery | undefined) {
let options = { let options = {
headers: {}, headers: {},
}; };
if (q?.noTotal || noTotal) { if (q?.noTotal) {
options.headers['X-NoTotal'] = '1'; options.headers['X-NoTotal'] = '1';
} }
@ -369,10 +381,20 @@ function buildHeaders(q: ContentsQuery | undefined, noTotal: boolean) {
return options; return options;
} }
function buildQuery(q?: ContentsByQuery) { function buildFullQuery(primary: FullQuery, q?: ContentsByQuery) {
const { query, skip, take } = q || {}; const query = buildQuery(q);
return { ...query, ...primary };
}
function buildQueryString(input: { q?: object; odata?: string }) {
const { odata, q } = input;
return q ? `q=${JSON.stringify(q)}` : odata;
}
const body: any = {}; function buildQuery(q?: ContentsByQuery): { q?: object; odata?: string } {
const { query, skip, take } = q || {};
if (query && query.fullText && query.fullText.indexOf('$') >= 0) { if (query && query.fullText && query.fullText.indexOf('$') >= 0) {
const odataParts: string[] = [ const odataParts: string[] = [
@ -387,7 +409,7 @@ function buildQuery(q?: ContentsByQuery) {
odataParts.push(`$skip=${skip}`); odataParts.push(`$skip=${skip}`);
} }
body.odata = odataParts.join('&'); return { odata: odataParts.join('&') };
} else { } else {
const queryObj: Query = { ...query }; const queryObj: Query = { ...query };
@ -399,10 +421,8 @@ function buildQuery(q?: ContentsByQuery) {
queryObj.skip = skip; queryObj.skip = skip;
} }
body.q = sanitize(queryObj); return { q: sanitize(queryObj) };
} }
return body;
} }
function parseContents(response: { items: any[]; total: number; statuses: any } & Resource): ContentsDto { function parseContents(response: { items: any[]; total: number; statuses: any } & Resource): ContentsDto {

60
frontend/src/app/shared/state/contents.state.ts

@ -28,7 +28,7 @@ interface Snapshot extends ListState<Query> {
referencing?: string; referencing?: string;
// The reference content id. // The reference content id.
reference?: string; references?: string;
// The statuses. // The statuses.
statuses?: ReadonlyArray<StatusInfo>; statuses?: ReadonlyArray<StatusInfo>;
@ -129,14 +129,14 @@ export abstract class ContentsStateBase extends State<Snapshot> {
})); }));
} }
public loadReference(contentId: string, update: Partial<Snapshot> = {}) { public loadReferences(contentId: string, update: Partial<Snapshot> = {}) {
this.resetState({ reference: contentId, referencing: undefined, ...update }); this.resetState({ references: contentId, referencing: undefined, ...update });
return this.loadInternal(false, true); return this.loadInternal(false, true);
} }
public loadReferencing(contentId: string, update: Partial<Snapshot> = {}) { public loadReferencing(contentId: string, update: Partial<Snapshot> = {}) {
this.resetState({ referencing: contentId, reference: undefined, ...update }); this.resetState({ referencing: contentId, references: undefined, ...update });
return this.loadInternal(false, true); return this.loadInternal(false, true);
} }
@ -162,32 +162,29 @@ export abstract class ContentsStateBase extends State<Snapshot> {
} }
private loadInternalCore(isReload: boolean, noSlowTotal: boolean) { private loadInternalCore(isReload: boolean, noSlowTotal: boolean) {
if (!this.appName || !this.schemaName) { if (!this.appName) {
return EMPTY; return EMPTY;
} }
this.next({ isLoading: true }, 'Loading Started'); this.next({ isLoading: true }, 'Loading Started');
const { page, pageSize, query, reference, referencing, total } = this.snapshot; const { references, referencing } = this.snapshot;
const query = createQuery(this.snapshot, noSlowTotal);
const q: any = { take: pageSize, skip: pageSize * page, noSlowTotal };
if (query) {
q.query = query;
}
if (page > 0 && total > 0) {
q.noTotal = true;
}
let content$: Observable<ContentsDto>; let content$: Observable<ContentsDto>;
if (referencing) { if (referencing && this.schemaName) {
content$ = this.contentsService.getContentReferencing(this.appName, this.schemaName, referencing, q); content$ = this.contentsService.getContentReferencing(this.appName, this.schemaName, referencing, query);
} else if (reference) { } else if (referencing) {
content$ = this.contentsService.getContentReferences(this.appName, this.schemaName, reference, q); content$ = this.contentsService.getAllContents(this.appName, { referencing }, query);
} else if (references && this.schemaName) {
content$ = this.contentsService.getContentReferences(this.appName, this.schemaName, references, query);
} else if (references) {
content$ = this.contentsService.getAllContents(this.appName, { references }, query);
} else if (this.schemaName) {
content$ = this.contentsService.getContents(this.appName, this.schemaName, query);
} else { } else {
content$ = this.contentsService.getContents(this.appName, this.schemaName, q); return EMPTY;
} }
return content$.pipe( return content$.pipe(
@ -481,6 +478,27 @@ function getStatusQueries(statuses: ReadonlyArray<StatusInfo> | undefined): Read
return statuses?.map(buildStatusQuery) || []; return statuses?.map(buildStatusQuery) || [];
} }
function createQuery(snapshot: Snapshot, noSlowTotal: boolean) {
const {
page,
pageSize,
query,
total,
} = snapshot;
const result: any = { take: pageSize, skip: pageSize * page, noSlowTotal };
if (query) {
result.query = query;
}
if (page > 0 && total > 0) {
result.noTotal = true;
}
return result;
}
function buildStatusQuery(s: StatusInfo) { function buildStatusQuery(s: StatusInfo) {
const query = { const query = {
filter: { filter: {

Loading…
Cancel
Save