Browse Source

Advanced query UI (#399)

* Improved Query UI using JSON queries.
pull/400/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
3fb9668be1
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs
  2. 2
      src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  3. 21
      src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs
  4. 3
      src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs
  5. 2
      src/Squidex/Areas/Api/Controllers/Assets/Models/AnnotateAssetDto.cs
  6. 6
      src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs
  7. 12
      src/Squidex/app/features/assets/pages/assets-filters-page.component.html
  8. 8
      src/Squidex/app/features/assets/pages/assets-filters-page.component.ts
  9. 4
      src/Squidex/app/features/assets/pages/assets-page.component.html
  10. 14
      src/Squidex/app/features/assets/pages/assets-page.component.ts
  11. 1
      src/Squidex/app/features/content/declarations.ts
  12. 2
      src/Squidex/app/features/content/module.ts
  13. 20
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html
  14. 8
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts
  15. 25
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  16. 59
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  17. 4
      src/Squidex/app/features/content/shared/assets-editor.component.ts
  18. 24
      src/Squidex/app/features/content/shared/contents-selector.component.html
  19. 40
      src/Squidex/app/features/content/shared/contents-selector.component.ts
  20. 2
      src/Squidex/app/features/content/shared/due-time-selector.component.html
  21. 2
      src/Squidex/app/features/content/shared/references-editor.component.html
  22. 2
      src/Squidex/app/features/schemas/pages/schema/field.component.scss
  23. 4
      src/Squidex/app/features/schemas/pages/schema/forms/field-form-common.component.ts
  24. 2
      src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.html
  25. 2
      src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.ts
  26. 2
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
  27. 7
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss
  28. 2
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html
  29. 2
      src/Squidex/app/features/settings/pages/workflows/workflow.component.html
  30. 1
      src/Squidex/app/framework/angular/forms/date-time-editor.component.scss
  31. 24
      src/Squidex/app/framework/angular/forms/date-time-editor.component.ts
  32. 4
      src/Squidex/app/framework/angular/modals/modal.directive.ts
  33. 2
      src/Squidex/app/framework/angular/modals/onboarding-tooltip.component.html
  34. 4
      src/Squidex/app/framework/angular/modals/onboarding-tooltip.component.ts
  35. 44
      src/Squidex/app/framework/utils/types.spec.ts
  36. 26
      src/Squidex/app/framework/utils/types.ts
  37. 2
      src/Squidex/app/shared/components/asset-uploader.component.html
  38. 4
      src/Squidex/app/shared/components/assets-selector.component.html
  39. 14
      src/Squidex/app/shared/components/assets-selector.component.ts
  40. 67
      src/Squidex/app/shared/components/queries/filter-comparison.component.html
  41. 6
      src/Squidex/app/shared/components/queries/filter-comparison.component.scss
  42. 106
      src/Squidex/app/shared/components/queries/filter-comparison.component.ts
  43. 45
      src/Squidex/app/shared/components/queries/filter-logical.component.html
  44. 35
      src/Squidex/app/shared/components/queries/filter-logical.component.scss
  45. 100
      src/Squidex/app/shared/components/queries/filter-logical.component.ts
  46. 59
      src/Squidex/app/shared/components/queries/filter-node.component.ts
  47. 83
      src/Squidex/app/shared/components/queries/query.component.ts
  48. 65
      src/Squidex/app/shared/components/queries/sorting.component.ts
  49. 33
      src/Squidex/app/shared/components/references-dropdown.component.ts
  50. 92
      src/Squidex/app/shared/components/search-form.component.html
  51. 36
      src/Squidex/app/shared/components/search-form.component.scss
  52. 69
      src/Squidex/app/shared/components/search-form.component.ts
  53. 76
      src/Squidex/app/shared/components/table-header.component.ts
  54. 7
      src/Squidex/app/shared/declarations.ts
  55. 2
      src/Squidex/app/shared/internal.ts
  56. 13
      src/Squidex/app/shared/module.ts
  57. 13
      src/Squidex/app/shared/services/assets.service.spec.ts
  58. 27
      src/Squidex/app/shared/services/assets.service.ts
  59. 15
      src/Squidex/app/shared/services/contents.service.spec.ts
  60. 46
      src/Squidex/app/shared/services/contents.service.ts
  61. 10
      src/Squidex/app/shared/state/assets.state.spec.ts
  62. 17
      src/Squidex/app/shared/state/assets.state.ts
  63. 73
      src/Squidex/app/shared/state/contents.state.ts
  64. 150
      src/Squidex/app/shared/state/filter.state.spec.ts
  65. 215
      src/Squidex/app/shared/state/filter.state.ts
  66. 37
      src/Squidex/app/shared/state/queries.spec.ts
  67. 95
      src/Squidex/app/shared/state/queries.ts
  68. 218
      src/Squidex/app/shared/state/query.ts
  69. 8
      src/Squidex/app/theme/icomoon/demo-files/demo.css
  70. 1820
      src/Squidex/app/theme/icomoon/demo.html
  71. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.eot
  72. 1
      src/Squidex/app/theme/icomoon/fonts/icomoon.svg
  73. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.ttf
  74. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.woff
  75. 2
      src/Squidex/app/theme/icomoon/selection.json
  76. 265
      src/Squidex/app/theme/icomoon/style.css
  77. 381
      tests/Squidex.Infrastructure.Tests/Queries/JsonQueryConversionTests.cs

3
src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs

@ -31,7 +31,8 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema
["created"] = Builder.DateTimeProperty($"The date and time when the {schemaName} content has been created.", true),
["createdBy"] = Builder.StringProperty($"The user that has created the {schemaName} content.", true),
["lastModified"] = Builder.DateTimeProperty($"The date and time when the {schemaName} content has been modified last.", true),
["lastModifiedBy"] = Builder.StringProperty($"The user that has updated the {schemaName} content last.", true)
["lastModifiedBy"] = Builder.StringProperty($"The user that has updated the {schemaName} content last.", true),
["status"] = Builder.StringProperty($"The status of the content.", true)
},
Type = JsonObjectType.Object
};

2
src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs

@ -105,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
var cacheKey = BuildJsonCacheKey(context.App, schema, context.IsFrontendClient);
var result = Cache.GetOrCreate<JsonSchema>(cacheKey, entry =>
var result = Cache.GetOrCreate(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheTime;

21
src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs

@ -7,6 +7,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using NJsonSchema;
using NodaTime;
using NodaTime.Text;
@ -18,6 +19,13 @@ namespace Squidex.Infrastructure.Queries.Json
{
private delegate bool Parser<T>(List<string> errors, PropertyPath path, IJsonValue value, out T result);
private static readonly InstantPattern[] InstantPatterns =
{
InstantPattern.General,
InstantPattern.ExtendedIso,
InstantPattern.CreateWithInvariantCulture("yyyy-MM-dd")
};
public static ClrValue Convert(JsonSchema schema, IJsonValue value, PropertyPath path, List<string> errors)
{
ClrValue result = null;
@ -196,13 +204,16 @@ namespace Squidex.Infrastructure.Queries.Json
if (value is JsonString jsonString)
{
var parsed = InstantPattern.General.Parse(jsonString.Value);
if (parsed.Success)
foreach (var pattern in InstantPatterns)
{
result = parsed.Value;
var parsed = pattern.Parse(jsonString.Value);
return true;
if (parsed.Success)
{
result = parsed.Value;
return true;
}
}
errors.Add($"Expected ISO8601 DateTime String for path '{path}', but got invalid String.");

3
src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs

@ -7,7 +7,6 @@
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Commands;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
@ -19,7 +18,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required]
public string Name { get; set; }
public ICommand ToCommand()
public AddWorkflow ToCommand()
{
return new AddWorkflow { Name = Name };
}

2
src/Squidex/Areas/Api/Controllers/Assets/Models/AnnotateAssetDto.cs

@ -29,7 +29,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public HashSet<string> Tags { get; set; }
public AssetCommand ToCommand(Guid id)
public AnnotateAsset ToCommand(Guid id)
{
return SimpleMapper.Map(this, new AnnotateAsset { AssetId = id });
}

6
src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs

@ -68,18 +68,18 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models
foreach (var rootFieldDto in dto.Fields)
{
var rootProps = rootFieldDto?.Properties.ToProperties();
var rootProps = rootFieldDto?.Properties?.ToProperties();
var rootField = new UpsertSchemaField { Properties = rootProps };
SimpleMapper.Map(rootFieldDto, rootField);
if (rootFieldDto.Nested?.Count > 0)
if (rootFieldDto?.Nested?.Count > 0)
{
rootField.Nested = new List<UpsertSchemaNestedField>();
foreach (var nestedFieldDto in rootFieldDto.Nested)
{
var nestedProps = nestedFieldDto?.Properties.ToProperties();
var nestedProps = nestedFieldDto?.Properties?.ToProperties();
var nestedField = new UpsertSchemaNestedField { Properties = nestedProps };
SimpleMapper.Map(nestedFieldDto, nestedField);

12
src/Squidex/app/features/assets/pages/assets-filters-page.component.html

@ -31,14 +31,14 @@
<div class="sidebar-section">
<h3>Saved queries</h3>
<ng-container *ngIf="queries.queries | async; let queries">
<ng-container *ngIf="queries.length > 0; else noQuery">
<a class="sidebar-item" *ngFor="let query of queries; trackBy: trackByQuery" (click)="search(query.filter)"
[class.active]="isSelectedQuery(query.filter)">
<ng-container *ngIf="queries.queries | async; let assetQueries">
<ng-container *ngIf="assetQueries.length > 0; else noQuery">
<a class="sidebar-item" *ngFor="let saved of assetQueries; trackBy: trackByQuery" (click)="search(saved.query)"
[class.active]="isSelectedQuery(saved)">
{{query.name}}
{{saved.name}}
<a class="sidebar-item-remove float-right" (click)="queries.remove(query.name)">
<a class="sidebar-item-remove float-right" (click)="queries.remove(saved)" sqxStopClick>
<i class="icon-close"></i>
</a>
</a>

8
src/Squidex/app/features/assets/pages/assets-filters-page.component.ts

@ -10,6 +10,8 @@ import { Component } from '@angular/core';
import {
AssetsState,
Queries,
Query,
SavedQuery,
UIState
} from '@app/shared';
@ -27,7 +29,7 @@ export class AssetsFiltersPageComponent {
) {
}
public search(query: string) {
public search(query: Query) {
this.assetsState.search(query);
}
@ -43,8 +45,8 @@ export class AssetsFiltersPageComponent {
this.assetsState.resetTags();
}
public isSelectedQuery(query: string) {
return query === this.assetsState.snapshot.assetsQuery || (!query && !this.assetsState.assetsQuery);
public isSelectedQuery(saved: SavedQuery) {
return this.assetsState.isQueryUsed(saved);
}
public trackByTag(index: number, tag: { name: string }) {

4
src/Squidex/app/features/assets/pages/assets-page.component.html

@ -26,9 +26,9 @@
</div>
<div class="col-6">
<sqx-search-form formClass="form" placeholder="Search by name" fieldExample="fileSize"
[filter]="filter"
(querySubmit)="search()"
[query]="assetsState.assetsQuery | async"
[queries]="queries"
(queryChange)="search($event)"
enableShortcut="true">
</sqx-search-form>
</div>

14
src/Squidex/app/features/assets/pages/assets-page.component.ts

@ -11,9 +11,9 @@ import { FormControl } from '@angular/forms';
import {
AppsState,
AssetsState,
FilterState,
LocalStoreService,
Queries,
Query,
ResourceOwner,
UIState
} from '@app/shared';
@ -28,8 +28,6 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
public queries = new Queries(this.uiState, 'assets');
public filter = new FilterState();
public isListView: boolean;
constructor(
@ -44,12 +42,6 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.own(
this.assetsState.assetsQuery
.subscribe(query => {
this.filter.setQuery(query);
}));
this.assetsState.load();
}
@ -57,8 +49,8 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
this.assetsState.load(true);
}
public search() {
this.assetsState.search(this.filter.apiFilter);
public search(query: Query) {
this.assetsState.search(query);
}
public selectTags(tags: string[]) {

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

@ -25,5 +25,4 @@ export * from './shared/contents-selector.component';
export * from './shared/due-time-selector.component';
export * from './shared/field-editor.component';
export * from './shared/preview-button.component';
export * from './shared/references-dropdown.component';
export * from './shared/references-editor.component';

2
src/Squidex/app/features/content/module.ts

@ -39,7 +39,6 @@ import {
FieldEditorComponent,
FieldLanguagesComponent,
PreviewButtonComponent,
ReferencesDropdownComponent,
ReferencesEditorComponent,
SchemasPageComponent
} from './declarations';
@ -125,7 +124,6 @@ const routes: Routes = [
FieldEditorComponent,
FieldLanguagesComponent,
PreviewButtonComponent,
ReferencesDropdownComponent,
ReferencesEditorComponent,
SchemasPageComponent
]

20
src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html

@ -4,9 +4,9 @@
</ng-container>
<ng-container content>
<a class="sidebar-item" *ngFor="let query of schemaQueries.defaultQueries; trackBy: trackByTag" (click)="search(query.filter)"
[class.active]="isSelectedQuery(query.filter)">
{{query.name}}
<a class="sidebar-item" *ngFor="let default of schemaQueries.defaultQueries; trackBy: trackByTag" (click)="search(default.query)"
[class.active]="isSelectedQuery(default)">
{{default.name}}
</a>
<hr />
@ -14,10 +14,10 @@
<div class="sidebar-section">
<h3>Status Queries</h3>
<a class="sidebar-item status" *ngFor="let query of contentsState.statusQueries | async; trackBy: trackByQuery" (click)="search(query.filter)"
[class.active]="isSelectedQuery(query.filter)">
<a class="sidebar-item status" *ngFor="let status of contentsState.statusQueries | async; trackBy: trackByQuery" (click)="search(status.query)"
[class.active]="isSelectedQuery(status)">
<i class="icon-circle" [style.color]="query.color"></i> {{query.name}}
<i class="icon-circle" [style.color]="status.color"></i> {{status.name}}
</a>
</div>
@ -28,12 +28,12 @@
<ng-container *ngIf="schemaQueries.queries | async; let queries">
<ng-container *ngIf="queries.length > 0; else noQuery">
<a class="sidebar-item" *ngFor="let query of queries; trackBy: trackByQuery" (click)="search(query.filter)"
[class.active]="isSelectedQuery(query.filter)">
<a class="sidebar-item" *ngFor="let saved of queries; trackBy: trackByQuery" (click)="search(saved.query)"
[class.active]="isSelectedQuery(saved)">
{{query.name}}
{{saved.name}}
<a class="sidebar-item-remove float-right" (click)="schemaQueries.remove(query.name)">
<a class="sidebar-item-remove float-right" (click)="schemaQueries.remove(saved)" sqxStopClick>
<i class="icon-close"></i>
</a>
</a>

8
src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts

@ -10,7 +10,9 @@ import { Component, OnInit } from '@angular/core';
import {
ContentsState,
Queries,
Query,
ResourceOwner,
SavedQuery,
SchemasState,
UIState
} from '@app/shared';
@ -41,12 +43,12 @@ export class ContentsFiltersPageComponent extends ResourceOwner implements OnIni
}));
}
public search(query: string) {
public search(query: Query) {
this.contentsState.search(query);
}
public isSelectedQuery(query: string) {
return query === this.contentsState.snapshot.contentsQuery || (!query && !this.contentsState.snapshot.contentsQuery);
public isSelectedQuery(saved: SavedQuery) {
return this.contentsState.isQueryUsed(saved);
}
public trackByTag(index: number, tag: { name: string }) {

25
src/Squidex/app/features/content/pages/contents/contents-page.component.html

@ -15,12 +15,11 @@
</button>
</div>
<div class="col pl-1">
<sqx-search-form formClass="form" placeholder="Search for content" fieldExample="data/[MY_FIELD]/iv"
[filter]="filter"
(querySubmit)="search()"
[queries]="schemaQueries"
expandable="true"
enableArchive="true"
<sqx-search-form formClass="form" placeholder="Search for content"
[query]="contentsState.contentsQuery | async"
[queries]="queries"
[queryModel]="queryModel"
(queryChange)="search($event)"
enableShortcut="true">
</sqx-search-form>
</div>
@ -43,7 +42,7 @@
<thead>
<tr>
<th class="cell-select">
<input type="checkbox" class="form-check" [ngModel]="isAllSelected" (ngModelChange)="selectAll($event)" />
<input type="checkbox" class="form-check" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)" />
</th>
<th class="cell-actions cell-actions-left">
Actions
@ -54,15 +53,19 @@
<th class="cell-auto cell-content" *ngFor="let field of schema.listFields">
<sqx-table-header [text]="field.displayName"
[sortable]="field.properties.isSortable"
[sorting]="filter.sortMode(field) | async"
(sortingChange)="sort(field, $event)">
[field]="field"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-table-header>
</th>
<th class="cell-time">
<sqx-table-header text="Updated"
[sortable]="true"
[sorting]="filter.sortMode('lastModified') | async"
(sortingChange)="sort('lastModified', $event)">
[field]="'lastModified'"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-table-header>
</th>
</tr>

59
src/Squidex/app/features/content/pages/contents/contents-page.component.ts

@ -13,16 +13,16 @@ import {
AppsState,
ContentDto,
ContentsState,
FilterState,
ImmutableArray,
LanguagesState,
ModalModel,
Queries,
Query,
QueryModel,
queryModelFromSchema,
ResourceOwner,
RootFieldDto,
SchemaDetailsDto,
SchemasState,
Sorting,
UIState
} from '@app/shared';
@ -35,11 +35,11 @@ import { DueTimeSelectorComponent } from './../../shared/due-time-selector.compo
})
export class ContentsPageComponent extends ResourceOwner implements OnInit {
public schema: SchemaDetailsDto;
public schemaQueries: Queries;
public searchModal = new ModalModel();
public selectedItems: { [id: string]: boolean; } = {};
public selectedAll = false;
public selectionCount = 0;
public selectionCanDelete = false;
@ -48,9 +48,8 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public language: AppLanguageDto;
public languages: ImmutableArray<AppLanguageDto>;
public filter = new FilterState();
public isAllSelected = false;
public queryModel: QueryModel;
public queries: Queries;
public minWidth: string;
@ -71,23 +70,22 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.own(
this.schemasState.selectedSchema
.subscribe(schema => {
this.filter = new FilterState();
this.filter.setLanguage(this.language);
this.resetSelection();
this.schema = schema!;
this.schemaQueries = new Queries(this.uiState, `schemas.${this.schema.name}`);
this.minWidth = `${300 + (200 * this.schema.listFields.length)}px`;
this.contentsState.load();
this.updateQueries();
this.updateModel();
}));
this.own(
this.contentsState.contentsQuery
.subscribe(query => {
this.filter.setQuery(query);
this.contentsState.statuses
.subscribe(() => {
this.updateModel();
}));
this.own(
@ -102,7 +100,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.languages = languages.map(x => x.language);
this.language = this.languages.at(0);
this.filter.setLanguage(this.language);
this.updateModel();
}));
}
@ -152,14 +150,12 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.goNext();
}
public search() {
this.contentsState.search(this.filter.apiFilter);
public search(query: Query) {
this.contentsState.search(query);
}
public selectLanguage(language: AppLanguageDto) {
this.language = language;
this.filter.setLanguage(language);
}
public isItemSelected(content: ContentDto): boolean {
@ -194,19 +190,12 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.updateSelectionSummary();
}
public sort(field: string | RootFieldDto, sorting: Sorting) {
this.filter.setOrderField(field, sorting);
this.search();
}
public trackByContent(index: number, content: ContentDto): string {
public trackByContent(content: ContentDto): string {
return content.id;
}
private updateSelectionSummary() {
this.isAllSelected = this.contentsState.snapshot.contents.length > 0;
this.selectedAll = this.contentsState.snapshot.contents.length > 0;
this.selectionCount = 0;
this.selectionCanDelete = true;
@ -232,11 +221,23 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.selectionCanDelete = false;
}
} else {
this.isAllSelected = false;
this.selectedAll = false;
}
}
this.nextStatuses = Object.keys(allActions);
}
private updateQueries() {
if (this.schema) {
this.queries = new Queries(this.uiState, `schemas.${this.schema.name}`);
}
}
private updateModel() {
if (this.schema && this.languages) {
this.queryModel = queryModelFromSchema(this.schema, this.languages.values, this.contentsState.snapshot.statuses);
}
}
}

4
src/Squidex/app/features/content/shared/assets-editor.component.ts

@ -48,11 +48,11 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AssetsEditorComponent extends StatefulControlComponent<State, string[]> implements OnInit {
public assetsDialog = new DialogModel();
@Input()
public isCompact = false;
public assetsDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState,
private readonly assetsService: AssetsService,

24
src/Squidex/app/features/content/shared/contents-selector.component.html

@ -11,11 +11,10 @@
</button>
</div>
<div class="col pl-1">
<sqx-search-form formClass="form" placeholder="Search for content"
expandable="true"
[fieldExample]="'data/[MY_FIELD]/iv'"
[filter]="filter"
(querySubmit)="search()">
<sqx-search-form formClass="form" placeholder="Search for content"
[query]="contentsState.contentsQuery | async"
[queryModel]="queryModel"
(queryChange)="search($event)">
</sqx-search-form>
</div>
@ -31,7 +30,7 @@
<thead>
<tr>
<th class="cell-select">
<input type="checkbox" class="form-check" [ngModel]="isAllSelected" (ngModelChange)="selectAll($event)" />
<input type="checkbox" class="form-check" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)" />
</th>
<th class="cell-user">
<sqx-table-header text="By"></sqx-table-header>
@ -39,15 +38,20 @@
<th class="cell-content" *ngFor="let field of schema.referenceFields">
<sqx-table-header [text]="field.displayName"
[sortable]="field.properties.isSortable"
[sorting]="filter.sortMode(field) | async"
(sortingChange)="sort(field, $event)">
[field]="field"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-table-header>
</th>
<th class="cell-time">
<sqx-table-header text="Updated"
[sortable]="true"
[sorting]="filter.sortMode('lastModified') | async"
(sortingChange)="sort('lastModified', $event)">
[sortable]="field.properties.isSortable"
[field]="'lastModified'"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-table-header>
</th>
</tr>

40
src/Squidex/app/features/content/shared/contents-selector.component.ts

@ -9,12 +9,13 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import {
ContentDto,
FilterState,
LanguageDto,
ManualContentsState,
RootFieldDto,
SchemaDetailsDto,
Sorting
Query,
QueryModel,
queryModelFromSchema,
ResourceOwner,
SchemaDetailsDto
} from '@app/shared';
@Component({
@ -25,7 +26,7 @@ import {
ManualContentsState
]
})
export class ContentsSelectorComponent implements OnInit {
export class ContentsSelectorComponent extends ResourceOwner implements OnInit {
@Input()
public language: LanguageDto;
@ -44,23 +45,29 @@ export class ContentsSelectorComponent implements OnInit {
@Output()
public select = new EventEmitter<ContentDto[]>();
public filter = new FilterState();
public queryModel: QueryModel;
public selectedItems: { [id: string]: ContentDto; } = {};
public selectionCount = 0;
public isAllSelected = false;
public selectedAll = false;
public minWidth: string;
constructor(
public readonly contentsState: ManualContentsState
) {
super();
}
public ngOnInit() {
this.minWidth = `${200 + (200 * this.schema.referenceFields.length)}px`;
this.own(
this.contentsState.statuses
.subscribe(() => {
this.updateModel();
}));
this.contentsState.schema = this.schema;
this.contentsState.load();
}
@ -69,8 +76,8 @@ export class ContentsSelectorComponent implements OnInit {
this.contentsState.load(true);
}
public search() {
this.contentsState.search(this.filter.apiFilter);
public search(query: Query) {
this.contentsState.search(query);
}
public goNext() {
@ -123,19 +130,16 @@ export class ContentsSelectorComponent implements OnInit {
this.updateSelectionSummary();
}
public sort(field: string | RootFieldDto, sorting: Sorting) {
this.filter.setOrderField(field, sorting);
this.search();
}
private updateSelectionSummary() {
this.selectionCount = Object.keys(this.selectedItems).length;
this.selectedAll = this.selectionCount === this.contentsState.snapshot.contents.length;
}
this.isAllSelected = this.selectionCount === this.contentsState.snapshot.contents.length;
private updateModel() {
this.queryModel = queryModelFromSchema(this.schema, this.languages, this.contentsState.snapshot.statuses);
}
public trackByContent(index: number, content: ContentDto): string {
public trackByContent(content: ContentDto): string {
return content.id;
}
}

2
src/Squidex/app/features/content/shared/due-time-selector.component.html

@ -1,4 +1,4 @@
<ng-container *sqxModal="dueTimeDialog;closeAuto:false">
<ng-container *sqxModal="dueTimeDialog">
<sqx-modal-dialog (close)="cancelStatusChange()">
<ng-container title>
Change content item(s) to {{dueTimeAction}}

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

@ -28,7 +28,7 @@
</div>
</div>
<ng-container *sqxModal="selectorDialog">
<ng-container *sqxModal="selectorDialog;closeAuto:false">
<sqx-contents-selector
[allowDuplicates]="allowDuplicates"
[alreadySelected]="snapshot.contentItems"

2
src/Squidex/app/features/schemas/pages/schema/field.component.scss

@ -58,12 +58,14 @@ $padding: 1rem;
&-line-v {
@include absolute($padding, auto, 3 * $padding + .25rem, $padding);
border: 0;
border-left: 2px dashed $field-line;
width: 2px;
}
&-line-h {
@include absolute(-2px, auto, 50%, -$padding);
border: 0;
border-bottom: 2px dashed $field-line;
width: $padding - .25rem;
}

4
src/Squidex/app/features/schemas/pages/schema/forms/field-form-common.component.ts

@ -18,7 +18,7 @@ import { FieldDto } from '@app/shared';
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldName">Name</label>
<div class="col-6">
<input type="text" class="form-control" id="{{field.fieldId}}_fieldName" readonly [ngModel]="field.name" [ngModelOptions]="{standalone: true}" />
<input type="text" class="form-control" id="{{field.fieldId}}_fieldName" readonly [ngModel]="field.name" [ngModelOptions]="standalone" />
<sqx-form-hint>
The name of the field in the API response.
@ -87,6 +87,8 @@ import { FieldDto } from '@app/shared';
`
})
export class FieldFormCommonComponent implements OnInit {
public readonly standalone = { standalone: true };
@Input()
public editForm: FormGroup;

2
src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.html

@ -8,7 +8,7 @@
<div class="form-group">
<label for="schemaName">Name</label>
<input type="text" class="form-control" id="schemaName" readonly [ngModel]="schema.name" [ngModelOptions]="{standalone: true}" />
<input type="text" class="form-control" id="schemaName" readonly [ngModel]="schema.name" [ngModelOptions]="standalone" />
</div>
<div class="form-group">

2
src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.ts

@ -20,6 +20,8 @@ import {
templateUrl: './schema-edit-form.component.html'
})
export class SchemaEditFormComponent implements OnInit {
public readonly standalone = { standalone: true };
@Output()
public complete = new EventEmitter();

2
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html

@ -51,7 +51,7 @@
<div class="col col-step">
<sqx-dropdown [items]="openSteps" [(ngModel)]="openStep" [canSearch]="false">
<ng-template let-target="$implicit">
<div class="color-circle" [style.background]="target.color"></div> {{target.name}}
<i class="icon-circle" [style.color]="target.color"></i> {{target.name}}
</ng-template>
</sqx-dropdown>
</div>

7
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss

@ -6,13 +6,6 @@
line-height: 2.8rem;
}
.color-circle {
@include circle(12px);
border: 1px solid $color-border-dark;
background: $color-border;
display: inline-block;
}
.col-label {
padding: 0 .5rem;
}

2
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html

@ -4,7 +4,7 @@
</div>
<div class="col col-step">
<div class="transition-to">
<div class="color-circle" [style.background]="transition.step.color"></div> {{transition.to}}
<i class="icon-circle" [style.color]="transition.step.color"></i> {{transition.to}}
</div>
</div>
<div class="col-auto col-label">

2
src/Squidex/app/features/settings/pages/workflows/workflow.component.html

@ -6,7 +6,7 @@
</div>
<div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource" [ngModel]="workflow.schemaIds"
placeholder=""
placeholder=""
styleGray="true"
styleBlank="true"
singleLine="true"

1
src/Squidex/app/framework/angular/forms/date-time-editor.component.scss

@ -25,7 +25,6 @@
width: 7.5rem;
&:last-child {
width: 9rem;
position: relative;
padding-right: 2rem;
}

24
src/Squidex/app/framework/angular/forms/date-time-editor.component.ts

@ -19,6 +19,8 @@ export const SQX_DATE_TIME_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => DateTimeEditorComponent), multi: true
};
const NO_EMIT = { emitEvent: false };
@Component({
selector: 'sqx-date-time-editor',
styleUrls: ['./date-time-editor.component.scss'],
@ -33,7 +35,7 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
private suppressEvents = false;
@Input()
public mode: string;
public mode: 'DateTime' | 'Date';
@Input()
public enforceTime: boolean;
@ -104,11 +106,11 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
super.setDisabledState(isDisabled);
if (isDisabled) {
this.dateControl.disable({ emitEvent: false });
this.timeControl.disable({ emitEvent: false });
this.dateControl.disable(NO_EMIT);
this.timeControl.disable(NO_EMIT);
} else {
this.dateControl.enable({ emitEvent: false });
this.timeControl.enable({ emitEvent: false });
this.dateControl.enable(NO_EMIT);
this.timeControl.enable(NO_EMIT);
}
}
@ -147,8 +149,8 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
}
public reset() {
this.timeControl.setValue(null, { emitEvent: false });
this.dateControl.setValue(null, { emitEvent: false });
this.timeControl.setValue(null, NO_EMIT);
this.dateControl.setValue(null, NO_EMIT);
this.dateValue = null;
@ -188,20 +190,20 @@ export class DateTimeEditorComponent extends StatefulControlComponent<{}, string
this.suppressEvents = true;
if (this.timeValue && this.timeValue.isValid()) {
this.timeControl.setValue(this.timeValue.format('HH:mm:ss'), { emitEvent: false });
this.timeControl.setValue(this.timeValue.format('HH:mm:ss'), NO_EMIT);
} else {
this.timeControl.setValue(null, { emitEvent: false });
this.timeControl.setValue(null, NO_EMIT);
}
if (this.dateValue && this.dateValue.isValid() && this.picker) {
const dateString = this.dateValue.format('YYYY-MM-DD');
const dateLocal = moment(dateString);
this.dateControl.setValue(dateString, { emitEvent: false });
this.dateControl.setValue(dateString, NO_EMIT);
this.picker.setMoment(dateLocal);
} else {
this.dateControl.setValue(null, { emitEvent: false });
this.dateControl.setValue(null, NO_EMIT);
}
this.suppressEvents = false;

4
src/Squidex/app/framework/angular/modals/modal.directive.ts

@ -121,6 +121,10 @@ export class ModalDirective implements OnDestroy {
}
private subscribeToView() {
if (Types.is(this.model, DialogModel)) {
return;
}
if (this.closeAuto) {
document.addEventListener('mousedown', this.documentClickListener, true);

2
src/Squidex/app/framework/angular/modals/onboarding-tooltip.component.html

@ -1,4 +1,4 @@
<ng-container *sqxModal="tooltipModal;closeAuto:false">
<ng-container *sqxModal="tooltipModal">
<div class="onboarding-rect" [sqxAnchoredTo]="for" [offset]="4" [position]="'full'"></div>
<div class="onboarding-help" [sqxAnchoredTo]="for" [offset]="4" [position]="position" @fade>
<div class="onboarding-text">

4
src/Squidex/app/framework/angular/modals/onboarding-tooltip.component.ts

@ -9,8 +9,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy
import { timer } from 'rxjs';
import {
DialogModel,
fadeAnimation,
ModalModel,
OnboardingService,
StatefulComponent,
Types
@ -38,7 +38,7 @@ export class OnboardingTooltipComponent extends StatefulComponent implements OnD
@Input()
public position = 'left';
public tooltipModal = new ModalModel();
public tooltipModal = new DialogModel();
constructor(changeDetector: ChangeDetectorRef,
private readonly onboardingService: OnboardingService,

44
src/Squidex/app/framework/utils/types.spec.ts

@ -108,6 +108,50 @@ describe('Types', () => {
expect(Types.jsJsonEquals({ a: 1, b: 2 }, { b: 2, a: 1 })).toBeFalsy();
});
it('should not treat zero as empty', () => {
expect(Types.isEmpty(0)).toBeFalsy();
});
it('should not treat empty string as empty', () => {
expect(Types.isEmpty('')).toBeFalsy();
});
it('should not treat false as empty', () => {
expect(Types.isEmpty(false)).toBeFalsy();
});
it('should not treat array with at least one non-empty value as empty', () => {
expect(Types.isEmpty([null, 0])).toBeFalsy();
});
it('should not treat array with at least one non-empty value as empty', () => {
expect(Types.isEmpty({ a: null, b: 0 })).toBeFalsy();
});
it('should treat empty object as empty', () => {
expect(Types.isEmpty({})).toBeTruthy();
});
it('should treat array object as empty', () => {
expect(Types.isEmpty([])).toBeTruthy();
});
it('should treat null as empty', () => {
expect(Types.isEmpty(null)).toBeTruthy();
});
it('should treat undefined as empty', () => {
expect(Types.isEmpty(undefined)).toBeTruthy();
});
it('should treat array of empty values as empty', () => {
expect(Types.isEmpty([])).toBeTruthy();
});
it('should treat object of empty values as empty', () => {
expect(Types.isEmpty({ a: null, b: null })).toBeTruthy();
});
});
class MyClass {

26
src/Squidex/app/framework/utils/types.ts

@ -101,4 +101,30 @@ export module Types {
return true;
}
export function isEmpty(value: any): boolean {
if (Types.isArray(value)) {
for (let v of value) {
if (!isEmpty(v)) {
return false;
}
}
return true;
}
if (Types.isObject(value)) {
for (let key in value) {
if (value.hasOwnProperty(key)) {
if (!isEmpty(value[key])) {
return false;
}
}
}
return true;
}
return Types.isUndefined(value) === true || Types.isNull(value) === true;
}
}

2
src/Squidex/app/shared/components/asset-uploader.component.html

@ -8,7 +8,7 @@
<span>{{uploads.length}}</span>
</span>
<ng-container *sqxModal="modalMenu;closeAuto:false;onRoot:false">
<ng-container *sqxModal="modalMenu;onRoot:false">
<div class="dropdown-menu container" (sqxDropFile)="addFiles($event)" @fade>
<div class="uploads">
<small class="uploads-empty text-muted" *ngIf="uploads.length === 0">

4
src/Squidex/app/shared/components/assets-selector.component.html

@ -21,7 +21,9 @@
</sqx-tag-editor>
</div>
<div class="col-6">
<sqx-search-form formClass="form" placeholder="Search by name" fieldExample="fileSize" [filter]="filter" (querySubmit)="search()"
<sqx-search-form formClass="form" placeholder="Search by name"
[query]="assetsState.assetsQuery | async"
(queryChange)="search($event)"
enableShortcut="true">
</sqx-search-form>
</div>

14
src/Squidex/app/shared/components/assets-selector.component.ts

@ -11,8 +11,8 @@ import {
AssetDto,
AssetsState,
fadeAnimation,
FilterState,
LocalStoreService,
Query,
StatefulComponent
} from '@app/shared/internal';
@ -36,8 +36,6 @@ export class AssetsSelectorComponent extends StatefulComponent<State> implements
@Output()
public select = new EventEmitter<AssetDto[]>();
public filter = new FilterState();
constructor(changeDector: ChangeDetectorRef,
public readonly assetsState: AssetsState,
public readonly localStore: LocalStoreService
@ -50,12 +48,6 @@ export class AssetsSelectorComponent extends StatefulComponent<State> implements
}
public ngOnInit() {
this.own(
this.assetsState.assetsQuery
.subscribe(query => {
this.filter.setQuery(query);
}));
this.assetsState.load();
}
@ -63,8 +55,8 @@ export class AssetsSelectorComponent extends StatefulComponent<State> implements
this.assetsState.load(true);
}
public search() {
this.assetsState.search(this.filter.apiFilter);
public search(query: Query) {
this.assetsState.search(query);
}
public emitComplete() {

67
src/Squidex/app/shared/components/queries/filter-comparison.component.html

@ -0,0 +1,67 @@
<div class="row">
<div class="col">
<div class="form-inline">
<select class="form-control path mb-1 mr-2" [ngModel]="filter.path" (ngModelChange)="changePath($event)">
<option *ngFor="let fieldName of model.fields | sqxKeys" [ngValue]="fieldName">{{fieldName}}</option>
</select>
<ng-container *ngIf="fieldModel">
<select class="form-control mb-1 mr-2" [ngModel]="filter.op" (ngModelChange)="changeOp($event)">
<option *ngFor="let operator of fieldModel.operators" [ngValue]="operator.value">{{operator.name || operator.value}}</option>
</select>
<div class="mb-1" *ngIf="!noValue" [ngSwitch]="fieldModel.type">
<ng-container *ngSwitchCase="'boolean'">
<input type="checkbox" class="form-control"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)" />
</ng-container>
<ng-container *ngSwitchCase="'date'">
<sqx-date-time-editor mode="Date"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)">
</sqx-date-time-editor>
</ng-container>
<ng-container *ngSwitchCase="'datetime'">
<sqx-date-time-editor mode="DateTime"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)">
</sqx-date-time-editor>
</ng-container>
<ng-container *ngSwitchCase="'number'">
<input type="number" class="form-control"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)" />
</ng-container>
<ng-container *ngSwitchCase="'reference'">
<sqx-references-dropdown [schemaId]="fieldModel.extra"
mode="Single"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)">
</sqx-references-dropdown>
</ng-container>
<ng-container *ngSwitchCase="'status'">
<sqx-dropdown [items]="fieldModel.extra"
[ngModel]="getStatus(fieldModel.extra)"
(ngModelChange)="changeStatus($event)"
[canSearch]="false">
<ng-template let-target="$implicit">
<i class="icon-circle" [style.color]="target.color"></i> {{target.status}}
</ng-template>
</sqx-dropdown>
</ng-container>
<ng-container *ngSwitchCase="'string'">
<input type="text" class="form-control" *ngIf="!fieldModel.extra"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)" />
</ng-container>
</div>
</ng-container>
</div>
</div>
<div class="col-auto pl-2">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()">
<i class="icon-bin2"></i>
</button>
</div>
</div>

6
src/Squidex/app/shared/components/queries/filter-comparison.component.scss

@ -0,0 +1,6 @@
@import '_vars';
@import '_mixins';
.path {
max-width: 12rem;
}

106
src/Squidex/app/shared/components/queries/filter-comparison.component.ts

@ -0,0 +1,106 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import {
fadeAnimation,
FilterComparison,
QueryFieldModel,
QueryModel
} from '@app/shared/internal';
import { StatusInfo } from '@app/shared/services/contents.service';
@Component({
selector: 'sqx-filter-comparison',
styleUrls: ['./filter-comparison.component.scss'],
templateUrl: './filter-comparison.component.html',
animations: [
fadeAnimation
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterComparisonComponent implements OnChanges {
public fieldModel: QueryFieldModel;
public noValue = false;
@Output()
public change = new EventEmitter();
@Output()
public remove = new EventEmitter();
@Input()
public model: QueryModel;
@Input()
public filter: FilterComparison;
public ngOnChanges(changes: SimpleChanges) {
if (changes['filter']) {
this.updatePath(false);
this.updateOperator();
}
}
public getStatus(statuses: StatusInfo[]) {
return statuses.find(x => x.status === this.filter.value);
}
public changeStatus(status: StatusInfo) {
this.changeValue(status.status);
}
public changeValue(value: any) {
this.filter.value = value;
this.emitChange();
}
public changeOp(op: string) {
this.filter.op = op;
this.updateOperator();
this.emitChange();
}
public changePath(path: string) {
this.filter.path = path;
this.updatePath(true);
this.emitChange();
}
private updateOperator() {
if (this.fieldModel) {
const operator = this.fieldModel.operators.find(x => x.value === this.filter.op);
this.noValue = !!(operator && operator.noValue);
}
}
private updatePath(refresh: boolean) {
const newModel = this.model.fields[this.filter.path];
if (newModel && refresh) {
if (!newModel.operators.find(x => x.value === this.filter.op)) {
this.filter.op = newModel.operators[0].value;
}
this.filter.value = null;
}
this.fieldModel = newModel;
}
private emitChange() {
this.change.emit();
}
}

45
src/Squidex/app/shared/components/queries/filter-logical.component.html

@ -0,0 +1,45 @@
<div class="group">
<div class="row no-gutters">
<div class="col">
<div class="btn-group">
<button type="button" class="btn btn-secondary btn-toggle" [class.btn-primary]="filter.and" [disabled]="filter.and" (click)="toggleType()">
AND
</button>
<button type="button" class="btn btn-secondary btn-toggle" [class.btn-primary]="filter.or" [disabled]="filter.or" (click)="toggleType()">
OR
</button>
</div>
</div>
<div class="col-auto pl-2" *ngIf="!isRoot">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()">
<i class="icon-bin2"></i>
</button>
</div>
</div>
<div class="filters">
<span class="filter-line-v"></span>
<div class="filter mt-3" *ngFor="let filter of filters">
<span class="filter-line-h"></span>
<sqx-filter-node [filter]="filter" [model]="model" [level]="level + 1"
(remove)="removeFilter(filter)" (change)="change.emit()">
</sqx-filter-node>
</div>
<div class="filter filter-add mt-3">
<span class="filter-line-h"></span>
<button class="btn btn-outline-success btn-sm mr-2" (click)="addComparison()">
Add Filter
</button>
<button class="btn btn-outline-success btn-sm" (click)="addLogical()" *ngIf="level < 1">
Add Group
</button>
</div>
</div>
</div>

35
src/Squidex/app/shared/components/queries/filter-logical.component.scss

@ -0,0 +1,35 @@
@import '_vars';
@import '_mixins';
$field-line: #e1e8ef;
.filters {
position: relative;
padding-left: 3rem;
}
.filter {
& {
position: relative;
}
&-line-v {
@include absolute(-.5rem, auto, 1.8rem, 1.5rem);
border: 0;
border-left: 2px dashed $field-line;
width: 2px;
}
&-line-h {
@include absolute(1.2rem, auto, auto, -1.5rem);
border: 0;
border-bottom: 2px dashed $field-line;
width: 1.5rem;
}
&-add {
background: none;
border: 2px dashed $field-line;
padding: .5rem;
}
}

100
src/Squidex/app/shared/components/queries/filter-logical.component.ts

@ -0,0 +1,100 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import {
fadeAnimation,
FilterLogical,
FilterNode,
QueryModel
} from '@app/shared/internal';
@Component({
selector: 'sqx-filter-logical',
styleUrls: ['./filter-logical.component.scss'],
templateUrl: './filter-logical.component.html',
animations: [
fadeAnimation
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterLogicalComponent {
private filterValue: FilterLogical;
public filters: FilterNode[] = [];
@Output()
public change = new EventEmitter();
@Output()
public remove = new EventEmitter();
@Input()
public level = 0;
@Input()
public isRoot = false;
@Input()
public model: QueryModel;
@Input()
public set filter(filter: FilterLogical) {
this.filterValue = filter;
this.updateFilters(filter);
}
public get filter() {
return this.filterValue;
}
public addComparison() {
this.filters.push(<any>{ path: Object.keys(this.model.fields)[0] });
this.emitChange();
}
public addLogical() {
this.filters.push({ and: [] });
this.emitChange();
}
public removeFilter(node: FilterNode) {
this.filters.splice(this.filters.indexOf(node), 1);
this.emitChange();
}
public toggleType() {
if (this.filterValue.and) {
this.filterValue.or = this.filterValue.and;
delete this.filterValue.and;
} else {
this.filterValue.and = this.filterValue.or;
delete this.filterValue.or;
}
this.emitChange();
}
private updateFilters(filter: FilterLogical) {
if (filter) {
this.filters = filter.and || filter.or || [];
} else {
this.filters = [];
}
}
private emitChange() {
this.change.emit();
}
}

59
src/Squidex/app/shared/components/queries/filter-node.component.ts

@ -0,0 +1,59 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import {
FilterComparison,
FilterLogical,
FilterNode,
QueryModel
} from '@app/shared/internal';
@Component({
selector: 'sqx-filter-node',
template: `
<ng-container *ngIf="logical">
<sqx-filter-logical [model]="model" [filter]="logical" [level]="level"
(remove)="remove.emit()" (change)="change.emit()">
</sqx-filter-logical>
</ng-container>
<ng-container *ngIf="comparison">
<sqx-filter-comparison [model]="model" [filter]="comparison"
(remove)="remove.emit()" (change)="change.emit()">
</sqx-filter-comparison>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterNodeComponent {
public comparison: FilterComparison;
public logical: FilterLogical;
@Output()
public change = new EventEmitter();
@Output()
public remove = new EventEmitter();
@Input()
public level: number;
@Input()
public model: QueryModel;
@Input()
public set filter(value: FilterNode) {
if (value['and'] || value['or']) {
this.logical = <FilterLogical>value;
} else {
this.comparison = <FilterComparison>value;
}
}
}

83
src/Squidex/app/shared/components/queries/query.component.ts

@ -0,0 +1,83 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import {
Query,
QueryModel,
QuerySorting
} from '@app/shared/internal';
@Component({
selector: 'sqx-query',
template: `
<div>
<h4>Filter</h4>
<sqx-filter-logical isRoot="true" [filter]="queryValue.filter" [model]="model"
(change)="emitQueryChange()">
</sqx-filter-logical>
<h4 class="mt-4">Sorting</h4>
<div class="mb-2" *ngFor="let sorting of queryValue.sort">
<sqx-sorting [sorting]="sorting" [model]="model"
(remove)="removeSorting(sorting)" (change)="emitQueryChange()">
</sqx-sorting>
</div>
<button class="btn btn-outline-success btn-sm mr-2" (click)="addSorting()">
Add Sorting
</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QueryComponent {
public queryValue: Query;
@Output()
public queryChange = new EventEmitter<Query>();
@Input()
public model: QueryModel;
@Input()
public set query(query: Query) {
if (!query) {
query = {};
}
if (query) {
if (!query.filter) {
query.filter = {
and: []
};
}
if (!query.sort) {
query.sort = [];
}
this.queryValue = query;
}
}
constructor() {
this.query = {};
}
public addSorting() {
this.queryValue.sort!.push({ path: Object.keys(this.model.fields)[0], order: 'ascending' });
this.emitQueryChange();
}
public removeSorting(sorting: QuerySorting) {
this.queryValue.sort!.splice(this.queryValue.sort!.indexOf(sorting), 1);
this.emitQueryChange();
}
public emitQueryChange() {
this.queryChange.emit(this.queryValue);
}
}

65
src/Squidex/app/shared/components/queries/sorting.component.ts

@ -0,0 +1,65 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { QueryModel, QuerySorting } from '@app/shared/internal';
@Component({
selector: 'sqx-sorting',
template: `
<div class="row">
<div class="col">
<div class="form-inline">
<select class="form-control mr-2" [ngModel]="sorting.path" (ngModelChange)="changePath($event)">
<option *ngFor="let fieldName of model.fields | sqxKeys" [ngValue]="fieldName">{{fieldName}}</option>
</select>
<select class="form-control mr-2" [ngModel]="sorting.order" (ngModelChange)="changeOrder($event)">
<option>ascending</option>
<option>descending</option>
</select>
</div>
</div>
<div class="col-auto pl-2">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()">
<i class="icon-bin2"></i>
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SortingComponent {
@Output()
public change = new EventEmitter();
@Output()
public remove = new EventEmitter();
@Input()
public model: QueryModel;
@Input()
public sorting: QuerySorting;
public changeOrder(order: any) {
this.sorting.order = order;
this.emitChange();
}
public changePath(path: string) {
this.sorting.path = path;
this.emitChange();
}
private emitChange() {
this.change.emit();
}
}

33
src/Squidex/app/features/content/shared/references-dropdown.component.ts → src/Squidex/app/shared/components/references-dropdown.component.ts

@ -21,7 +21,7 @@ import {
SchemasService,
StatefulControlComponent,
Types
} from '@app/shared';
} from '@app/shared/internal';
export const SQX_REFERENCES_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferencesDropdownComponent), multi: true
@ -38,6 +38,8 @@ interface State {
type ContentName = { name: string, id?: string };
const NO_EMIT = { emitEvent: false };
@Component({
selector: 'sqx-references-dropdown',
template: `
@ -52,13 +54,16 @@ type ContentName = { name: string, id?: string };
providers: [SQX_REFERENCES_DROPDOWN_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReferencesDropdownComponent extends StatefulControlComponent<State, string[]> implements OnInit {
export class ReferencesDropdownComponent extends StatefulControlComponent<State, string[] | string> implements OnInit {
private languageField: AppLanguageDto;
private selectedId: string | undefined;
@Input()
public schemaId: string;
@Input()
public mode: 'Array' | 'Single';
@Input()
public set language(value: AppLanguageDto) {
this.languageField = value;
@ -84,10 +89,20 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
.subscribe((value: ContentName) => {
if (value && value.id) {
this.callTouched();
this.callChange([value.id]);
if (this.mode === 'Single') {
this.callChange(value.id);
} else {
this.callChange([value.id]);
}
} else {
this.callTouched();
this.callChange([]);
if (this.mode === 'Single') {
this.callChange(null);
} else {
this.callChange([]);
}
}
}));
}
@ -119,7 +134,11 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
}
public writeValue(obj: any) {
if (Types.isArrayOfString(obj)) {
if (Types.isString(obj)) {
this.selectedId = obj;
this.selectContent();
} if (Types.isArrayOfString(obj)) {
this.selectedId = obj[0];
this.selectContent();
@ -131,11 +150,11 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
}
private selectContent() {
this.selectionControl.setValue(this.snapshot.contentNames.find(x => x.id === this.selectedId), { emitEvent: false });
this.selectionControl.setValue(this.snapshot.contentNames.find(x => x.id === this.selectedId), NO_EMIT);
}
private unselectContent() {
this.selectionControl.setValue(undefined, { emitEvent: false });
this.selectionControl.setValue(undefined, NO_EMIT);
}
private createContentNames(schema: SchemaDetailsDto | undefined | null, contents: ContentDto[]): ContentName[] {

92
src/Squidex/app/shared/components/search-form.component.html

@ -3,72 +3,60 @@
</ng-container>
<form [class]="formClass" (ngSubmit)="search()">
<input class="form-control form-control-expandable" #inputFind [placeholder]="placeholder"
[ngModel]="filter.query | async"
(ngModelChange)="filter.setQuery($event)"
[ngModelOptions]="{ standalone: true }" />
<input class="form-control form-control-expandable" #inputFind
[ngModel]="query?.fullText"
(ngModelChange)="changeQueryFullText($event)"
[ngModelOptions]="standalone"
[placeholder]="placeholder" />
<ng-container *ngIf="expandable">
<a class="expand-search" (click)="searchModal.toggle()" #expand>
<i class="icon-caret-down"></i>
</a>
<sqx-onboarding-tooltip helpId="contentArchive" [for]="expand" position="bottom-right" after="60000">
Click this icon to show the advanced search menu and to show the archive!
</sqx-onboarding-tooltip>
</ng-container>
<div class="buttons">
<ng-container *ngIf="queries">
<ng-container *ngIf="saveKey | async; else notBookmarked; let key">
<a class="save-search" (click)="queries.remove(key)">
<i class="icon-star-full"></i>
</a>
</ng-container>
<ng-template #notBookmarked>
<a class="save-search" (click)="saveQuery()">
<i class="icon-star-empty"></i>
</a>
</ng-template>
</ng-container>
<ng-container *ngIf="queries">
<ng-container *ngIf="saveKey | async; else notBookmarked; let key">
<a class="save-search" (click)="queries.remove(key)">
<i class="icon-star-full"></i>
<ng-container *ngIf="queryModel">
<a class="expand-search" (click)="searchDialog.toggle()" [class.highlighted]="hasFilter" #expand>
<i class="icon-filter-filled"></i>
</a>
</ng-container>
<ng-template #notBookmarked>
<a class="save-search" (click)="saveQuery()" *ngIf="filter.query | async">
<i class="icon-star-empty"></i>
</a>
</ng-template>
</ng-container>
<sqx-onboarding-tooltip helpId="contentArchive" [for]="expand" position="bottom-right" after="60000">
Click this icon to show the advanced search menu!
</sqx-onboarding-tooltip>
</ng-container>
</div>
</form>
<sqx-onboarding-tooltip helpId="contentFind" [for]="inputFind" position="bottom-right" after="120000">
Search for content using full text search over all fields and languages!
</sqx-onboarding-tooltip>
<ng-container *sqxModal="searchModal">
<ng-container *sqxModal="searchDialog;closeAuto:false">
<div class="dropdown-menu" [sqxAnchoredTo]="inputFind" @fade>
<div class="form-horizontal">
<div class="form-group row">
<label class="col-2 col-form-label" for="search">Text</label>
<div class="submit">
<button type="button" class="btn btn-primary mr-2" (click)="search(true)">
Submit
</button>
<div class="col-10">
<input type="text" class="form-control" id="search" placeholder="Fulltext search"
[ngModel]="filter.fullText | async"
(ngModelChange)="filter.setFullText($event)" />
</div>
</div>
<div class="form-group row">
<label class="col-2 col-form-label" for="filter">Filter</label>
<div class="col-10">
<input type="text" class="form-control" id="filter" placeholder="{{fieldExample}} eq [VALUE]"
[ngModel]="filter.filter | async"
(ngModelChange)="filter.setFilter($event)" />
</div>
</div>
<div class="form-group row">
<label class="col-2 col-form-label" for="orderBy">Order</label>
<div class="col-10">
<input type="text" class="form-control" id="orderBy" placeholder="{{fieldExample}} desc"
[ngModel]="filter.order | async"
(ngModelChange)="filter.setOrder($event)" />
</div>
<button type="button" class="btn btn-text-secondary" (click)="searchDialog.hide()">
<i class="icon-close"></i>
</button>
</div>
<sqx-query
[model]="queryModel"
[query]="query"
(queryChange)="changeQuery($event)">
</sqx-query>
<div class="link">
Read more about filtering in the <a href="https://docs.squidex.io/04-guides/02-api.html" sqxExternalLink>Documentation</a>.

36
src/Squidex/app/shared/components/search-form.component.scss

@ -13,14 +13,28 @@
padding-right: 3rem;
}
.dropdown-menu {
max-height: 80%;
overflow-x: hidden;
overflow-y: auto;
}
.form-horizontal {
padding: 1rem 1.5rem;
min-width: 25rem;
max-width: 25rem;
position: relative;
min-width: 60rem;
max-width: 60rem;
}
.submit {
@include absolute(.5rem, 1rem, auto, auto);
}
.buttons {
@include absolute(8px, 8px, auto, auto);
}
.save-search {
@include absolute(8px, 24px, auto, auto);
color: $color-border-dark !important;
font-size: .9rem;
font-weight: normal;
@ -28,11 +42,17 @@
}
.expand-search {
@include absolute(8px, 8px, auto, auto);
color: $color-border-dark !important;
font-size: .9rem;
font-weight: normal;
cursor: pointer !important;
& {
color: $color-border-dark !important;
font-size: .9rem;
font-weight: normal;
cursor: pointer !important;
padding: 0 .5rem;
}
&.highlighted {
color: $color-title !important;
}
}
.link {

69
src/Squidex/app/shared/components/search-form.component.ts

@ -5,16 +5,17 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Observable } from 'rxjs';
import {
DialogModel,
fadeAnimation,
FilterState,
ModalModel,
hasFilter,
Queries,
Query,
QueryModel,
SaveQueryForm
} from '@app/shared/internal';
@ -27,24 +28,23 @@ import {
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchFormComponent implements OnInit {
@Input()
public queries: Queries;
export class SearchFormComponent implements OnChanges {
public readonly standalone = { standalone: true };
@Input()
public placeholder = '';
@Input()
public fieldExample = '[MY_FIELD]';
public queryModel: QueryModel;
@Input()
public expandable = false;
public query: Query;
@Input()
public filter: FilterState;
@Output()
public queryChange = new EventEmitter<Query>();
@Input()
public schemaName = '';
public queries: Queries;
@Input()
public enableShortcut = false;
@ -52,23 +52,34 @@ export class SearchFormComponent implements OnInit {
@Input()
public formClass = 'form-inline search-form';
@Output()
public querySubmit = new EventEmitter();
public saveKey: Observable<string | undefined>;
public saveQueryDialog = new DialogModel();
public saveQueryForm = new SaveQueryForm(this.formBuilder);
public searchModal = new ModalModel();
public searchDialog = new DialogModel();
public hasFilter: boolean;
constructor(
private readonly formBuilder: FormBuilder
) {
}
public ngOnInit() {
if (this.queries) {
this.saveKey = this.queries.getSaveKey(this.filter.query);
public ngOnChanges(changes: SimpleChanges) {
if (changes['query'] || changes['queries']) {
this.updateSaveKey();
this.hasFilter = hasFilter(this.query);
}
}
public search(close = false) {
this.hasFilter = hasFilter(this.query);
this.queryChange.emit(this.query);
if (close) {
this.searchDialog.hide();
}
}
@ -81,8 +92,8 @@ export class SearchFormComponent implements OnInit {
const value = this.saveQueryForm.submit();
if (value) {
if (this.queries) {
this.queries.add(value.name, this.filter.apiFilter!);
if (this.queries && this.query) {
this.queries.add(value.name, this.query);
}
this.saveQueryForm.submitCompleted();
@ -91,7 +102,21 @@ export class SearchFormComponent implements OnInit {
this.saveQueryDialog.hide();
}
public search() {
this.querySubmit.emit();
public changeQueryFullText(fullText: string) {
this.query = { ...this.query, fullText };
this.updateSaveKey();
}
public changeQuery(query: Query) {
this.query = query;
this.updateSaveKey();
}
private updateSaveKey() {
if (this.queries && this.query) {
this.saveKey = this.queries.getSaveKey(this.query);
}
}
}

76
src/Squidex/app/shared/components/table-header.component.ts

@ -5,16 +5,24 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { Sorting } from '@app/shared/internal';
import {
LanguageDto,
Query,
RootFieldDto,
SortMode,
Types
} from '@app/shared/internal';
type Field = string | RootFieldDto;
@Component({
selector: 'sqx-table-header',
template: `
<a *ngIf="sortable; else notSortable" (click)="sort()" class="pointer truncate">
<i *ngIf="sorting === 'Ascending'" class="icon-caret-down"></i>
<i *ngIf="sorting === 'Descending'" class="icon-caret-up"></i>
<i *ngIf="order === 'ascending'" class="icon-caret-down"></i>
<i *ngIf="order === 'descending'" class="icon-caret-up"></i>
{{text}}
</a>
@ -24,28 +32,72 @@ import { Sorting } from '@app/shared/internal';
</ng-template>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TableHeaderComponent {
export class TableHeaderComponent implements OnChanges {
private fieldPath: string;
@Input()
public text: string;
@Input()
public field: Field;
@Input()
public language: LanguageDto;
@Input()
public sortable = false;
@Input()
public sorting: Sorting;
public query: Query;
@Output()
public sortingChange = new EventEmitter<Sorting>();
public queryChange = new EventEmitter<Query>();
public sort() {
public order: SortMode | null;
public ngOnChanges(changes: SimpleChanges) {
if (this.sortable) {
if (!this.sorting || this.sorting !== 'Ascending') {
this.sorting = 'Ascending';
if (changes['language'] || changes['field']) {
this.fieldPath = getFieldPath(this.language, this.field);
}
if (changes['query'] && this.fieldPath) {
this.order = getSortMode(this.query, this.fieldPath);
}
}
}
public sort() {
if (this.sortable && this.fieldPath) {
if (!this.order || this.order !== 'ascending') {
this.order = 'ascending';
} else {
this.sorting = 'Descending';
this.order = 'descending';
}
this.sortingChange.emit(this.sorting);
this.queryChange.emit(this.newQuery());
}
}
private newQuery() {
return {...this.query, sort: [{ path: this.fieldPath, order: this.order! }] };
}
}
function getSortMode(query: Query, path: string) {
if (path && query && query.sort && query.sort.length === 1 && query.sort[0].path === path) {
return query.sort[0].order;
}
return null;
}
function getFieldPath(language: LanguageDto | undefined, field: Field) {
if (Types.isString(field)) {
return field;
} else if (field.isLocalizable && language) {
return `data.${field.name}.${language.iso2Code}`;
} else {
return `data.${field.name}.iv`;
}
}

7
src/Squidex/app/shared/declarations.ts

@ -21,11 +21,18 @@ export * from './components/history-list.component';
export * from './components/language-selector.component';
export * from './components/markdown-editor.component';
export * from './components/pipes';
export * from './components/references-dropdown.component';
export * from './components/rich-editor.component';
export * from './components/schema-category.component';
export * from './components/search-form.component';
export * from './components/table-header.component';
export * from './components/queries/filter-comparison.component';
export * from './components/queries/filter-logical.component';
export * from './components/queries/filter-node.component';
export * from './components/queries/query.component';
export * from './components/queries/sorting.component';
export * from './guards/app-must-exist.guard';
export * from './guards/content-must-exist.guard';
export * from './guards/load-apps.guard';

2
src/Squidex/app/shared/internal.ts

@ -49,13 +49,13 @@ export * from './state/contents.forms';
export * from './state/contents.state';
export * from './state/contributors.forms';
export * from './state/contributors.state';
export * from './state/filter.state';
export * from './state/languages.forms';
export * from './state/languages.state';
export * from './state/patterns.forms';
export * from './state/patterns.state';
export * from './state/plans.state';
export * from './state/queries';
export * from './state/query';
export * from './state/roles.forms';
export * from './state/roles.state';
export * from './state/rule-events.state';

13
src/Squidex/app/shared/module.ts

@ -44,6 +44,9 @@ import {
ContributorsService,
ContributorsState,
FileIconPipe,
FilterComparisonComponent,
FilterLogicalComponent,
FilterNodeComponent,
GeolocationEditorComponent,
GraphQlService,
HelpComponent,
@ -66,6 +69,8 @@ import {
PatternsState,
PlansService,
PlansState,
QueryComponent,
ReferencesDropdownComponent,
RichEditorComponent,
RolesService,
RolesState,
@ -79,6 +84,7 @@ import {
SchemasService,
SchemasState,
SearchFormComponent,
SortingComponent,
TableHeaderComponent,
TranslationsService,
UIService,
@ -116,6 +122,9 @@ import {
CommentComponent,
CommentsComponent,
FileIconPipe,
FilterNodeComponent,
FilterLogicalComponent,
FilterComparisonComponent,
GeolocationEditorComponent,
HelpComponent,
HelpMarkdownPipe,
@ -124,7 +133,10 @@ import {
HistoryMessagePipe,
LanguageSelectorComponent,
MarkdownEditorComponent,
QueryComponent,
ReferencesDropdownComponent,
SchemaCategoryComponent,
SortingComponent,
UserDtoPicture,
UserIdPicturePipe,
UserNamePipe,
@ -155,6 +167,7 @@ import {
HistoryMessagePipe,
LanguageSelectorComponent,
MarkdownEditorComponent,
ReferencesDropdownComponent,
RichEditorComponent,
RouterModule,
SchemaCategoryComponent,

13
src/Squidex/app/shared/services/assets.service.spec.ts

@ -20,6 +20,7 @@ import {
ResourceLinks,
Version
} from '@app/shared/internal';
import { encodeQuery } from '../state/query';
describe('AssetsService', () => {
const version = new Version('1');
@ -75,7 +76,7 @@ describe('AssetsService', () => {
assets = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?$top=17&$skip=13');
const req = httpMock.expectOne(`http://service/p/api/apps/my-app/assets?q=${encodeQuery({ take: 17, skip: 13 })}`);
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -117,9 +118,9 @@ describe('AssetsService', () => {
it('should append query to find by name',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
assetsService.getAssets('my-app', 17, 13, 'my-query').subscribe();
assetsService.getAssets('my-app', 17, 13, { fullText: 'my-query' }).subscribe();
const req = httpMock.expectOne(`http://service/p/api/apps/my-app/assets?$filter=contains(fileName,'my-query')&$top=17&$skip=13`);
const req = httpMock.expectOne(`http://service/p/api/apps/my-app/assets?q=${encodeQuery({ filter: { and: [{ path: 'fileName', op: 'contains', value: 'my-query' }] }, take: 17, skip: 13 })}`);
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -127,12 +128,12 @@ describe('AssetsService', () => {
req.flush({ total: 10, items: [] });
}));
it('should append query to find by name and tag',
it('should append query to find by tag',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
assetsService.getAssets('my-app', 17, 13, 'my-query', ['tag1', 'tag2']).subscribe();
assetsService.getAssets('my-app', 17, 13, undefined, ['tag1']).subscribe();
const req = httpMock.expectOne(`http://service/p/api/apps/my-app/assets?$filter=contains(fileName,'my-query') and tags eq 'tag1' and tags eq 'tag2'&$top=17&$skip=13`);
const req = httpMock.expectOne(`http://service/p/api/apps/my-app/assets?q=${encodeQuery({ filter: { and: [{ path: 'tags', op: 'eq', value: 'tag1' }] }, take: 17, skip: 13 })}`);
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();

27
src/Squidex/app/shared/services/assets.service.ts

@ -27,6 +27,8 @@ import {
Versioned
} from '@app/framework';
import { encodeQuery, Query } from './../state/query';
export class AssetsDto extends ResultSet<AssetDto> {
public get canCreate() {
return hasAnyLink(this._links, 'create');
@ -107,36 +109,41 @@ export class AssetsService {
return this.http.get<{ [name: string]: number }>(url);
}
public getAssets(appName: string, take: number, skip: number, query?: string, tags?: string[], ids?: string[]): Observable<AssetsDto> {
public getAssets(appName: string, take: number, skip: number, query?: Query, tags?: string[], ids?: string[]): Observable<AssetsDto> {
let fullQuery = '';
if (ids) {
fullQuery = `ids=${ids.join(',')}`;
} else {
const queries: string[] = [];
const queryObj: Query = {};
const filters: string[] = [];
const filters: any[] = [];
if (query && query.length > 0) {
filters.push(`contains(fileName,'${encodeURIComponent(query)}')`);
if (query && query.fullText && query.fullText.length > 0) {
filters.push({ path: 'fileName', op: 'contains', value: query.fullText });
}
if (tags) {
for (let tag of tags) {
if (tag && tag.length > 0) {
filters.push(`tags eq '${encodeURIComponent(tag)}'`);
filters.push({ path: 'tags', op: 'eq', value: tag });
}
}
}
if (filters.length > 0) {
queries.push(`$filter=${filters.join(' and ')}`);
queryObj.filter = { and: filters };
}
if (take > 0) {
queryObj.take = take;
}
queries.push(`$top=${take}`);
queries.push(`$skip=${skip}`);
if (skip > 0) {
queryObj.skip = skip;
}
fullQuery = queries.join('&');
fullQuery = `q=${encodeQuery(queryObj)}`;
}
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets?${fullQuery}`);

15
src/Squidex/app/shared/services/contents.service.spec.ts

@ -21,6 +21,7 @@ import {
Version,
Versioned
} from '@app/shared/internal';
import { encodeQuery } from '../state/query';
describe('ContentsService', () => {
const version = new Version('1');
@ -47,11 +48,11 @@ describe('ContentsService', () => {
let contents: ContentsDto;
contentsService.getContents('my-app', 'my-schema', 17, 13, undefined, undefined, ['Draft', 'Published']).subscribe(result => {
contentsService.getContents('my-app', 'my-schema', 17, 13).subscribe(result => {
contents = result;
});
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema?$top=17&$skip=13&status=Draft&status=Published');
const req = httpMock.expectOne(`http://service/p/api/content/my-app/my-schema?q=${encodeQuery({ take: 17, skip: 13 })}`);
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -77,9 +78,9 @@ describe('ContentsService', () => {
it('should append query to get request as search',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getContents('my-app', 'my-schema', 17, 13, 'my-query').subscribe();
contentsService.getContents('my-app', 'my-schema', 17, 13, { fullText: 'my-query' }).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema?$search="my-query"&$top=17&$skip=13');
const req = httpMock.expectOne(`http://service/p/api/content/my-app/my-schema?q=${encodeQuery({ fullText: 'my-query', take: 17, skip: 13 })}`);
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -92,7 +93,7 @@ describe('ContentsService', () => {
contentsService.getContents('my-app', 'my-schema', 17, 13, undefined, ['id1', 'id2']).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema?$top=17&$skip=13&ids=id1,id2');
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema?ids=id1,id2');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -100,10 +101,10 @@ describe('ContentsService', () => {
req.flush({ total: 10, items: [] });
}));
it('should append query to get request as plain query string',
it('should append odata query to get request as plain query string',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getContents('my-app', 'my-schema', 17, 13, '$filter=my-filter').subscribe();
contentsService.getContents('my-app', 'my-schema', 17, 13, { fullText: '$filter=my-filter' }).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema?$filter=my-filter&$top=17&$skip=13');

46
src/Squidex/app/shared/services/contents.service.ts

@ -25,6 +25,8 @@ import {
Versioned
} from '@app/framework';
import { encodeQuery, Query } from './../state/query';
export class ScheduleDto {
constructor(
public readonly status: string,
@ -109,34 +111,34 @@ export class ContentsService {
) {
}
public getContents(appName: string, schemaName: string, take: number, skip: number, query?: string, ids?: string[], status?: string[]): Observable<ContentsDto> {
public getContents(appName: string, schemaName: string, take: number, skip: number, query?: Query, ids?: string[]): Observable<ContentsDto> {
const queryParts: string[] = [];
if (query && query.length > 0) {
if (query.indexOf('$filter') < 0 &&
query.indexOf('$search') < 0 &&
query.indexOf('$orderby') < 0) {
queryParts.push(`$search="${encodeURIComponent(query.trim())}"`);
} else {
queryParts.push(`${query.trim()}`);
}
}
if (ids && ids.length > 0) {
queryParts.push(`ids=${ids.join(',')}`);
} else {
const queryObj: Query = { ...query };
if (take > 0) {
queryParts.push(`$top=${take}`);
}
if (queryObj.fullText && queryObj.fullText.indexOf('$') >= 0) {
queryParts.push(`${queryObj.fullText.trim()}`);
if (skip > 0) {
queryParts.push(`$skip=${skip}`);
}
if (take > 0) {
queryParts.push(`$top=${take}`);
}
if (ids && ids.length > 0) {
queryParts.push(`ids=${ids.join(',')}`);
}
if (skip > 0) {
queryParts.push(`$skip=${skip}`);
}
} else {
if (take > 0) {
queryObj.take = take;
}
if (skip > 0) {
queryObj.skip = skip;
}
if (status) {
for (let s of status) {
queryParts.push(`status=${s}`);
queryParts.push(`q=${encodeQuery(queryObj)}`);
}
}

10
src/Squidex/app/shared/state/assets.state.spec.ts

@ -19,6 +19,7 @@ import {
import { createAsset } from './../services/assets.service.spec';
import { TestValues } from './_test-helpers';
import { encodeQuery } from './query';
describe('AssetsState', () => {
const {
@ -131,12 +132,15 @@ describe('AssetsState', () => {
});
it('should load with query when searching', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', It.isValue([])))
const query = { fullText: 'my-query' };
assetsService.setup(x => x.getAssets(app, 30, 0, query, It.isValue([])))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.search('my-query').subscribe();
assetsState.search(query).subscribe();
expect(assetsState.snapshot.assetsQuery).toEqual('my-query');
expect(assetsState.snapshot.assetsQuery).toEqual(query);
expect(assetsState.isQueryUsed({ name: 'name', query, queryJson: encodeQuery(query) })).toBeTruthy();
});
});

17
src/Squidex/app/shared/state/assets.state.ts

@ -20,6 +20,8 @@ import {
import { AssetDto, AssetsService} from './../services/assets.service';
import { AppsState } from './apps.state';
import { SavedQuery } from './queries';
import { encodeQuery, Query } from './query';
interface Snapshot {
// All assets tags.
@ -35,7 +37,10 @@ interface Snapshot {
assetsPager: Pager;
// The query to filter assets.
assetsQuery?: string;
assetsQuery?: Query;
// The json of the assets query.
assetsQueryJson: string;
// Indicates if the assets are loaded.
isLoaded?: boolean;
@ -75,7 +80,7 @@ export class AssetsState extends State<Snapshot> {
private readonly assetsService: AssetsService,
private readonly dialogs: DialogService
) {
super({ assets: ImmutableArray.empty(), assetsPager: new Pager(0, 0, 30), tags: {}, tagsSelected: {} });
super({ assets: ImmutableArray.empty(), assetsPager: new Pager(0, 0, 30), assetsQueryJson: '', tags: {}, tagsSelected: {} });
}
public load(isReload = false): Observable<any> {
@ -198,8 +203,8 @@ export class AssetsState extends State<Snapshot> {
return this.loadInternal();
}
public search(query?: string): Observable<any> {
this.next(s => ({ ...s, assetsPager: new Pager(0, 0, 30), assetsQuery: query }));
public search(query?: Query): Observable<any> {
this.next(s => ({ ...s, assetsPager: new Pager(0, 0, 30), assetsQuery: query, assetsQueryJson: encodeQuery(query) }));
return this.loadInternal();
}
@ -216,6 +221,10 @@ export class AssetsState extends State<Snapshot> {
return this.loadInternal();
}
public isQueryUsed(saved: SavedQuery) {
return this.snapshot.assetsQueryJson === saved.queryJson;
}
public isTagSelected(tag: string) {
return this.snapshot.tagsSelected[tag];
}

73
src/Squidex/app/shared/state/contents.state.ts

@ -20,12 +20,13 @@ import {
Versioned
} from '@app/framework';
import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service';
import { SchemaDto } from './../services/schemas.service';
import { AppsState } from './apps.state';
import { SavedQuery } from './queries';
import { encodeQuery, Query } from './query';
import { SchemasState } from './schemas.state';
import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service';
interface Snapshot {
// The current comments.
contents: ImmutableArray<ContentDto>;
@ -34,7 +35,10 @@ interface Snapshot {
contentsPager: Pager;
// The query to filter and sort contents.
contentsQuery?: string;
contentsQuery?: Query;
// The raw content query.
contentsQueryJson: string;
// Indicates if the contents are loaded.
isLoaded?: boolean;
@ -81,6 +85,9 @@ export abstract class ContentsStateBase extends State<Snapshot> {
public canCreateAny =
this.project(x => !!x.canCreate || !!x.canCreateAndPublish);
public statuses =
this.project(x => x.statuses);
public statusQueries =
this.project2(x => x.statuses, x => buildQueries(x));
@ -89,7 +96,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
private readonly contentsService: ContentsService,
private readonly dialogs: DialogService
) {
super({ contents: ImmutableArray.of(), contentsPager: new Pager(0) });
super({ contents: ImmutableArray.of(), contentsPager: new Pager(0), contentsQueryJson: '' });
}
public select(id: string | null): Observable<ContentDto | null> {
@ -171,6 +178,11 @@ export abstract class ContentsStateBase extends State<Snapshot> {
}));
}
public loadVersion(content: ContentDto, version: Version): Observable<Versioned<any>> {
return this.contentsService.getVersionData(this.appName, this.schemaName, content.id, version).pipe(
shareSubscribed(this.dialogs));
}
public create(request: any, publish: boolean): Observable<ContentDto> {
return this.contentsService.postContent(this.appName, this.schemaName, request, publish).pipe(
tap(payload => {
@ -282,24 +294,8 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs));
}
private replaceContent(content: ContentDto, oldVersion?: Version) {
if (!oldVersion || !oldVersion.eq(content.version)) {
return this.next(s => {
const contents = s.contents.replaceBy('id', content);
const selectedContent =
s.selectedContent &&
s.selectedContent.id === content.id ?
content :
s.selectedContent;
return { ...s, contents, selectedContent };
});
}
}
public search(contentsQuery?: string): Observable<any> {
this.next(s => ({ ...s, contentsPager: new Pager(0), contentsQuery }));
public search(contentsQuery?: Query): Observable<any> {
this.next(s => ({ ...s, contentsPager: new Pager(0), contentsQuery, contentsQueryJson: encodeQuery(contentsQuery) }));
return this.loadInternal();
}
@ -316,15 +312,30 @@ export abstract class ContentsStateBase extends State<Snapshot> {
return this.loadInternal();
}
public loadVersion(content: ContentDto, version: Version): Observable<Versioned<any>> {
return this.contentsService.getVersionData(this.appName, this.schemaName, content.id, version).pipe(
shareSubscribed(this.dialogs));
public isQueryUsed(saved: SavedQuery) {
return this.snapshot.contentsQueryJson === saved.queryJson;
}
private get appName() {
return this.appsState.appName;
}
private replaceContent(content: ContentDto, oldVersion?: Version) {
if (!oldVersion || !oldVersion.eq(content.version)) {
return this.next(s => {
const contents = s.contents.replaceBy('id', content);
const selectedContent =
s.selectedContent &&
s.selectedContent.id === content.id ?
content :
s.selectedContent;
return { ...s, contents, selectedContent };
});
}
}
protected abstract get schemaName(): string;
}
@ -356,12 +367,20 @@ export class ManualContentsState extends ContentsStateBase {
}
}
export type ContentQuery = { color: string; name: string; filter: string; };
export type ContentQuery = { color: string; } & SavedQuery;
function buildQueries(statuses: StatusInfo[] | undefined): ContentQuery[] {
return statuses ? statuses.map(s => buildQuery(s)) : [];
}
function buildQuery(s: StatusInfo) {
return ({ name: s.status, color: s.color, filter: `$filter=status eq '${s.status}'` });
const query = {
filter: {
and: [
{ path: 'status', op: 'eq', value: s.status }
]
}
};
return ({ name: s.status, color: s.color, query, queryJson: encodeQuery(query) });
}

150
src/Squidex/app/shared/state/filter.state.spec.ts

@ -1,150 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import {
FilterState,
LanguageDto,
Sorting
} from '@app/shared/internal';
describe('FilterState', () => {
let query: string | undefined;
let fullText: string | undefined;
let filterState: FilterState;
let filter: string | undefined;
let order: string | undefined;
beforeEach(() => {
filterState = new FilterState();
filterState.setLanguage(new LanguageDto('de', 'German'));
filterState.query.subscribe(value => {
query = value;
});
filterState.filter.subscribe(value => {
filter = value;
});
filterState.fullText.subscribe(value => {
fullText = value;
});
filterState.order.subscribe(value => {
order = value;
});
});
it('should parse elements from query', () => {
const newQuery = '$filter=MY_FILTER&$orderby=MY_FIELD desc&$search=MY_TEXT';
filterState.setQuery(newQuery);
expect(order).toBe('MY_FIELD desc');
expect(query).toBe(newQuery);
expect(filter).toBe('MY_FILTER');
expect(fullText).toBe('MY_TEXT');
});
it('should set full text and order and calculate query', () => {
filterState.setFullText('Hello World');
filterState.setOrder('data/name/iv asc');
expect(query).toBe('$search=Hello World&$orderby=data/name/iv asc');
expect(fullText).toBe('Hello World');
});
it('should set full text only and calculate query', () => {
filterState.setFullText('Hello World');
expect(query).toBe('Hello World');
expect(fullText).toBe('Hello World');
});
it('should set filter and calculate query', () => {
filterState.setFilter('data/name/iv eq "Squidex"');
expect(query).toBe('$filter=data/name/iv eq "Squidex"');
expect(filter).toBe('data/name/iv eq "Squidex"');
});
it('should set order and calculate query', () => {
filterState.setOrder('data/name/iv asc');
expect(query).toBe('$orderby=data/name/iv asc');
expect(order).toBe('data/name/iv asc');
});
it('should set field name and calculate query', () => {
filterState.setOrderField('field', 'Descending');
expect(query).toBe('$orderby=field desc');
expect(order).toBe('field desc');
});
it('should set field and calculate query', () => {
filterState.setOrderField(<any>{ name: 'first-name', isLocalizable: false }, 'Ascending');
expect(query).toBe('$orderby=data/first_name/iv asc');
expect(order).toBe('data/first_name/iv asc');
});
it('should set localizable field and calculate query', () => {
filterState.setOrderField(<any>{ name: 'first-name', isLocalizable: true }, 'Ascending');
expect(query).toBe('$orderby=data/first_name/de asc');
expect(order).toBe('data/first_name/de asc');
});
it('should update field ordering for path', () => {
let sorting: Sorting;
filterState.sortMode('field').subscribe(value => {
sorting = value;
});
filterState.setQuery('$orderby=field desc');
expect(sorting!).toBe('Descending');
});
it('should update field ordering for field', () => {
let sorting: Sorting;
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: false }).subscribe(value => {
sorting = value;
});
filterState.setQuery('$orderby=data/first_name/iv asc');
expect(sorting!).toBe('Ascending');
});
it('should update field ordering for localizable field', () => {
let sorting: Sorting;
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: true }).subscribe(value => {
sorting = value;
});
filterState.setQuery('$orderby=data/first_name/de asc');
expect(sorting!).toBe('Ascending');
});
it('should update field ordering for localizable field and other language', () => {
let sorting: Sorting;
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: true }).subscribe(value => {
sorting = value;
});
filterState.setQuery('$orderby=data/first_name/en asc');
expect(sorting!).toBe('None');
});
});

215
src/Squidex/app/shared/state/filter.state.ts

@ -1,215 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Observable } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/internal/operators';
import { State, Types } from '@app/framework';
import { LanguageDto } from './../services/languages.service';
import { RootFieldDto } from './../services/schemas.service';
interface Snapshot {
// The current language.
language?: LanguageDto;
// The order statement.
order?: string;
// The order field.
orderField?: string;
// The order direction.
orderDirection?: string;
// The final query.
query?: string;
// The odata filter statement.
filter?: string;
// The odata full text statement.
fullText?: string;
}
export type Sorting = 'Ascending' | 'Descending' | 'None';
type Field = string | RootFieldDto;
export class FilterState extends State<Snapshot> {
private readonly sortModes: { [key: string]: Observable<Sorting> } = {};
public query =
this.project(x => x.query);
public order =
this.project(x => x.order);
public filter =
this.project(x => x.filter);
public fullText =
this.project(x => x.fullText);
public get apiFilter() {
return this.snapshot.query;
}
public constructor() {
super({});
}
public sortMode(field: Field) {
const key = Types.isString(field) ? field : field.fieldId.toString();
let result = this.sortModes[key];
if (!result) {
result = this.project(x => sortMode(x, field)), distinctUntilChanged();
this.sortModes[key] = result;
}
return result;
}
public setQuery(query?: string) {
this.next(s => fromQuery(s, query));
}
public setOrder(order?: string) {
this.next(s => fromProperty(s, { order }));
}
public setFilter(filter?: string) {
this.next(s => fromProperty(s, { filter }));
}
public setFullText(fullText?: string) {
this.next(s => fromProperty(s, { fullText }));
}
public setLanguage(language: LanguageDto) {
this.next(s => ({ ...s, language }));
}
public setOrderField(field: Field, sorting: Sorting) {
this.setOrder(getFieldSorting(this.snapshot, field, sorting));
}
}
function sortMode(snapshot: Snapshot, field: Field): Sorting {
let path = getFieldPath(snapshot, field);
if (snapshot.orderField === path) {
if (snapshot.orderDirection === 'asc') {
return 'Ascending';
} else if (snapshot.orderDirection === 'desc') {
return 'Descending';
}
}
return 'None';
}
function escapeField(value: string) {
return value.replace('-', '_');
}
function getFieldSorting(snapshot: Snapshot, field: Field, sorting: Sorting) {
if (sorting === 'Ascending') {
return `${getFieldPath(snapshot, field)} asc`;
} else {
return `${getFieldPath(snapshot, field)} desc`;
}
}
function getFieldPath(snapshot: Snapshot, field?: Field) {
let path: string | undefined = undefined;
if (field) {
if (Types.isString(field)) {
path = field;
} else if (field.isLocalizable && snapshot.language) {
path = `data/${escapeField(field.name)}/${snapshot.language.iso2Code.replace('-', '_')}`;
} else {
path = `data/${escapeField(field.name)}/iv`;
}
}
return path;
}
function fromQuery(previous: Snapshot, query?: string) {
const snapshot: Snapshot = { language: previous.language, query };
if (query) {
const parts = query.split('&');
if (parts.length === 1 && parts[0][0] !== '$') {
snapshot.fullText = parts[0];
} else {
for (let part of parts) {
const kvp = part.split('=');
if (kvp.length === 2) {
const key = kvp[0].toLowerCase();
if (key === '$filter') {
snapshot.filter = kvp[1];
} else if (key === '$orderby') {
snapshot.order = kvp[1];
} else if (key === '$search') {
snapshot.fullText = kvp[1];
}
}
}
}
}
return enrichOrderField(snapshot);
}
function fromProperty(previous: Snapshot, update: Partial<Snapshot>) {
const snapshot = { ...previous, ...update };
if (snapshot.fullText && !snapshot.order && !snapshot.filter) {
snapshot.query = snapshot.fullText;
} else {
const parts: string[] = [];
if (snapshot.fullText) {
parts.push(`$search=${snapshot.fullText}`);
}
if (snapshot.filter) {
parts.push(`$filter=${snapshot.filter}`);
}
if (snapshot.order) {
parts.push(`$orderby=${snapshot.order}`);
}
snapshot.query = parts.join('&');
}
return enrichOrderField(snapshot);
}
function enrichOrderField(snapshot: Snapshot) {
if (snapshot.order) {
const orderParts = snapshot.order.split(' ');
if (orderParts.length === 2) {
snapshot.orderField = orderParts[0];
snapshot.orderDirection = orderParts[1];
}
}
return snapshot;
}

37
src/Squidex/app/shared/state/queries.spec.ts

@ -5,12 +5,14 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { BehaviorSubject } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import {
encodeQuery,
Queries,
Query,
SavedQuery,
UIState
} from '@app/shared/internal';
@ -18,7 +20,7 @@ describe('Queries', () => {
const prefix = 'schemas.my-schema';
let uiState: IMock<UIState>;
let filter = new BehaviorSubject('');
let queries$ = new BehaviorSubject({});
let queries: Queries;
@ -29,15 +31,16 @@ describe('Queries', () => {
.returns(() => queries$);
queries$.next({
key1: 'query1',
key2: 'query2'
key1: '{ "fullText": "text1" }',
key2: 'text2',
key3: undefined
});
queries = new Queries(uiState.object, prefix);
});
it('should load queries', () => {
let converted: Query[];
let converted: SavedQuery[];
queries.queries.subscribe(x => {
converted = x;
@ -46,12 +49,16 @@ describe('Queries', () => {
expect(converted!).toEqual([
{
name: 'key1',
nameSortable: 'KEY1',
filter: 'query1'
query: { fullText: 'text1' },
queryJson: encodeQuery({ fullText: 'text1' })
}, {
name: 'key2',
nameSortable: 'KEY2',
filter: 'query2'
query: { fullText: 'text2' },
queryJson: encodeQuery({ fullText: 'text2' })
}, {
name: 'key3',
query: undefined,
queryJson: ''
}
]);
});
@ -59,25 +66,23 @@ describe('Queries', () => {
it('should provide key', () => {
let key: string;
queries.getSaveKey(filter).subscribe(x => {
queries.getSaveKey({}).subscribe(x => {
key = x!;
});
filter.next('query2');
expect(key!).toEqual('key2');
expect(key!).toEqual('key3');
});
it('should forward add call to state', () => {
queries.add('key3', 'filter3');
queries.add('key3', { fullText: 'text3' });
expect(true).toBeTruthy();
uiState.verify(x => x.set('schemas.my-schema.queries.key3', 'filter3'), Times.once());
uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}'), Times.once());
});
it('should forward remove call to state', () => {
queries.remove('key3');
queries.remove({ name: 'key3' });
expect(true).toBeTruthy();

95
src/Squidex/app/shared/state/queries.ts

@ -5,74 +5,89 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { combineLatest, Observable } from 'rxjs';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { compareStringsAsc, Types } from '@app/framework';
import { UIState } from './ui.state';
export interface Query {
import { encodeQuery, Query } from './query';
export interface SavedQuery {
// The name of the query.
name: string;
nameSortable?: string;
filter: string;
// The deserialized value.
query?: Query;
// The raw value of the query.
queryJson?: string;
}
const OLDEST_FIRST: Query = {
sort: [
{ path: 'lastModified', order: 'descending' }
]
};
export class Queries {
public queries: Observable<Query[]>;
public queries: Observable<SavedQuery[]>;
public defaultQueries: Query[] = [{
name: 'All (newest first)', filter: ''
}, {
name: 'All (oldest first)', filter: '$orderby=lastModified asc'
}];
public defaultQueries: SavedQuery[] = [
{ name: 'All (newest first)', queryJson: '' },
{ name: 'All (oldest first)', queryJson: encodeQuery(OLDEST_FIRST), query: OLDEST_FIRST }
];
constructor(
private readonly uiState: UIState,
private readonly prefix: string
) {
this.queries = this.uiState.get(`${this.prefix}.queries`, {}).pipe(
map(x => {
let queries: Query[] = Object.keys(x).map(y => ({ name: y, filter: x[y] }));
map(settings => {
let queries = Object.keys(settings).map(name => parseStored(name, settings[name]));
for (let query of queries) {
query.nameSortable = query.name.toUpperCase();
}
queries = queries.sort((a, b) => {
if (a.nameSortable! < b.nameSortable!) {
return -1;
}
if (a.nameSortable! > b.nameSortable!) {
return 1;
}
return 0;
});
return queries;
return queries.sort((a, b) => compareStringsAsc(a.name, b.name));
})
);
}
public add(key: string, filter: string) {
this.uiState.set(`${this.prefix}.queries.${key}`, filter);
public add(key: string, query: Query) {
this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query));
}
public remove(key: string) {
this.uiState.remove(`${this.prefix}.queries.${key}`);
public remove(saved: SavedQuery) {
this.uiState.remove(`${this.prefix}.queries.${saved.name}`);
}
public getSaveKey(filter$: Observable<string | undefined>): Observable<string | undefined> {
return combineLatest(this.queries, filter$).pipe(
map(project => {
const filter = project[1];
public getSaveKey(query: Query): Observable<string | undefined> {
const json = encodeQuery(query);
if (filter) {
for (let query of project[0]) {
if (query.filter === filter) {
return query.name;
}
return this.queries.pipe(
map(queries => {
for (let saved of queries) {
if (saved.queryJson === json) {
return saved.name;
}
}
return undefined;
}));
}
}
export function parseStored(name: string, raw?: string) {
if (Types.isString(raw)) {
let query: Query;
if (raw.indexOf('{') === 0) {
query = JSON.parse(raw);
} else {
query = { fullText: raw };
}
return { name, query, queryJson: encodeQuery(query) };
}
return { name, query: undefined, queryJson: '' };
}

218
src/Squidex/app/shared/state/query.ts

@ -0,0 +1,218 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Types } from '@app/framework';
import { StatusInfo } from './../services/contents.service';
import { LanguageDto } from './../services/languages.service';
import { SchemaDetailsDto } from './../services/schemas.service';
export type QueryValueType =
'boolean' |
'date' |
'datetime' |
'number' |
'reference' |
'status' |
'string';
export interface FilterOperator {
// The optional display value.
name?: string;
// The operator value.
value: string;
// True, when the operator does not require an value.
noValue?: boolean;
}
export interface QueryFieldModel {
// The value type.
type: QueryValueType;
// The allowed operator.
operators: FilterOperator[];
// Extra values.
extra?: any;
}
export interface QueryModel {
// All available fields.
fields: { [name: string]: QueryFieldModel };
}
export type FilterNode = FilterComparison | FilterLogical;
export interface FilterComparison {
// The full path to the property.
path: string;
// The operator.
op: string;
// The value.
value: any;
}
export interface FilterLogical {
// The child filters if the logical filter is a conjunction (AND).
and?: FilterNode[];
// The child filters if the logical filter is a disjunction (OR).
or?: FilterNode[];
}
export interface QuerySorting {
// The full path to the property.
path: string;
// The sort order.
order: SortMode;
}
export type SortMode = 'ascending' | 'descending';
export interface Query {
// The optional filter.
filter?: FilterNode;
// The full text search.
fullText?: string;
// The sorting.
sort?: QuerySorting[];
// The number of items to take.
take?: number;
// The number of items to skip.
skip?: number;
}
export function encodeQuery(query?: Query) {
if (Types.isEmpty(query)) {
return '';
}
query = { ...query };
if (!query.sort) {
query.sort = [];
}
if (!query.filter) {
query.filter = { and: [] };
}
return encodeURIComponent(JSON.stringify(query));
}
export function hasFilter(query?: Query) {
return !!query && !Types.isEmpty(query.filter);
}
const EqualOperators: FilterOperator[] = [
{ name: '==', value: 'eq' },
{ name: '!=', value: 'ne' }
];
const CompareOperator: FilterOperator[] = [
{ name: '<', value: 'lt' },
{ name: '<=', value: 'le' },
{ name: '>', value: 'gt' },
{ name: '>=', value: 'ge' }
];
const StringOperators: FilterOperator[] = [
{ name: 'T*', value: 'startsWith' },
{ name: '*T', value: 'endsWith' },
{ name: '*T*', value: 'contains' }
];
const ArrayOperators: FilterOperator[] = [
{ value: 'empty', noValue: true }
];
const TypeBoolean: QueryFieldModel = {
type: 'boolean',
operators: EqualOperators
};
const TypeDateTime: QueryFieldModel = {
type: 'datetime',
operators: [...EqualOperators, ...CompareOperator]
};
const TypeNumber: QueryFieldModel = {
type: 'number',
operators: [...EqualOperators, ...CompareOperator]
};
const TypeReference: QueryFieldModel = {
type: 'reference',
operators: [...EqualOperators, ...ArrayOperators]
};
const TypeStatus: QueryFieldModel = {
type: 'status',
operators: EqualOperators
};
const TypeString: QueryFieldModel = {
type: 'string',
operators: [...EqualOperators, ...CompareOperator, ...StringOperators, ...ArrayOperators]
};
export function queryModelFromSchema(schema: SchemaDetailsDto, languages: LanguageDto[], statuses: StatusInfo[] | undefined) {
let languagesCodes = languages.map(x => x.iso2Code);
let invariantCodes = ['iv'];
let model: QueryModel = {
fields: {}
};
model.fields['created'] = TypeDateTime;
model.fields['createdBy'] = TypeString;
model.fields['lastModified'] = TypeDateTime;
model.fields['lastModifiedBy'] = TypeString;
model.fields['version'] = TypeNumber;
if (statuses) {
model.fields['status'] = { ...TypeStatus, extra: statuses };
}
for (let field of schema.fields) {
let type: QueryFieldModel | null = null;
if (field.properties.fieldType === 'Boolean') {
type = TypeBoolean;
} else if (field.properties.fieldType === 'Number') {
type = TypeNumber;
} else if (field.properties.fieldType === 'String') {
type = TypeString;
} else if (field.properties.fieldType === 'DateTime') {
type = TypeDateTime;
} else if (field.properties.fieldType === 'References') {
const extra = { schemaId: field.properties['schemaId'] };
type = { ...TypeReference, extra };
}
if (type) {
let codes = field.isLocalizable ? languagesCodes : invariantCodes;
for (let code of codes) {
model.fields[`data.${field.name}.${code}`] = type;
}
}
}
return model;
}

8
src/Squidex/app/theme/icomoon/demo-files/demo.css

@ -147,21 +147,21 @@ p {
font-size: 16px;
}
.fs1 {
font-size: 24px;
font-size: 28px;
}
.fs2 {
font-size: 24px;
}
.fs3 {
font-size: 32px;
font-size: 24px;
}
.fs4 {
font-size: 32px;
}
.fs5 {
font-size: 20px;
font-size: 32px;
}
.fs6 {
font-size: 28px;
font-size: 20px;
}

1820
src/Squidex/app/theme/icomoon/demo.html

File diff suppressed because it is too large

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.eot

Binary file not shown.

1
src/Squidex/app/theme/icomoon/fonts/icomoon.svg

@ -127,6 +127,7 @@
<glyph unicode="&#xe975;" glyph-name="type-UI" d="M512 256h-341.333c-5.845 0-11.349 1.152-16.299 3.2-5.205 2.133-9.899 5.333-13.867 9.301s-7.125 8.661-9.301 13.867c-2.048 4.949-3.2 10.453-3.2 16.299v426.667c0 5.845 1.152 11.349 3.2 16.299 2.133 5.205 5.333 9.899 9.301 13.867s8.661 7.125 13.867 9.301c4.949 2.048 10.453 3.2 16.299 3.2h682.667c5.845 0 11.349-1.152 16.299-3.2 5.205-2.133 9.899-5.333 13.867-9.301s7.125-8.661 9.301-13.867c2.048-4.949 3.2-10.453 3.2-16.299v-426.667c0-5.845-1.152-11.349-3.2-16.299-2.133-5.205-5.333-9.899-9.301-13.867s-8.661-7.125-13.867-9.301c-4.949-2.048-10.453-3.2-16.299-3.2zM469.333 170.667v-85.333h-128c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667h341.333c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667h-128v85.333h298.667c17.28 0 33.835 3.456 48.981 9.728 15.701 6.485 29.781 16 41.557 27.776s21.291 25.856 27.776 41.557c6.229 15.104 9.685 31.659 9.685 48.939v426.667c0 17.28-3.456 33.835-9.728 48.981-6.485 15.701-16 29.781-27.776 41.557s-25.856 21.291-41.557 27.776c-15.104 6.229-31.659 9.685-48.939 9.685h-682.667c-17.28 0-33.835-3.456-48.981-9.728-15.659-6.485-29.739-16-41.515-27.776s-21.291-25.856-27.776-41.515c-6.272-15.147-9.728-31.701-9.728-48.981v-426.667c0-17.28 3.456-33.835 9.728-48.981 6.485-15.701 16-29.781 27.776-41.557s25.856-21.291 41.557-27.776c15.104-6.229 31.659-9.685 48.939-9.685z" />
<glyph unicode="&#xe976;" glyph-name="arrow-right" d="M621.254 82.746l320 320c24.994 24.992 24.994 65.516 0 90.51l-320 320c-24.994 24.992-65.516 24.992-90.51 0-24.994-24.994-24.994-65.516 0-90.51l210.746-210.746h-613.49c-35.346 0-64-28.654-64-64s28.654-64 64-64h613.49l-210.746-210.746c-12.496-12.496-18.744-28.876-18.744-45.254s6.248-32.758 18.744-45.254c24.994-24.994 65.516-24.994 90.51 0z" />
<glyph unicode="&#xe977;" glyph-name="corner-down-right" d="M128 768v-298.667c0-58.88 23.936-112.299 62.464-150.869s91.989-62.464 150.869-62.464h409.003l-140.501-140.501c-16.683-16.683-16.683-43.691 0-60.331s43.691-16.683 60.331 0l213.333 213.333c3.925 3.925 7.083 8.619 9.259 13.824s3.243 10.795 3.243 16.341c0 10.923-4.181 21.845-12.501 30.165l-213.333 213.333c-16.683 16.683-43.691 16.683-60.331 0s-16.683-43.691 0-60.331l140.501-140.501h-409.003c-35.371 0-67.285 14.293-90.496 37.504s-37.504 55.125-37.504 90.496v298.667c0 23.552-19.115 42.667-42.667 42.667s-42.667-19.115-42.667-42.667z" />
<glyph unicode="&#xe978;" glyph-name="filter-filled" horiz-adv-x="805" d="M801.714 782.286c5.714-13.714 2.857-29.714-8-40l-281.714-281.714v-424c0-14.857-9.143-28-22.286-33.714-4.571-1.714-9.714-2.857-14.286-2.857-9.714 0-18.857 3.429-25.714 10.857l-146.286 146.286c-6.857 6.857-10.857 16-10.857 25.714v277.714l-281.714 281.714c-10.857 10.286-13.714 26.286-8 40 5.714 13.143 18.857 22.286 33.714 22.286h731.429c14.857 0 28-9.143 33.714-22.286z" />
<glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" />
<glyph unicode="&#xf00a;" glyph-name="grid" d="M292.571 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857z" />
<glyph unicode="&#xf0c9;" glyph-name="list1" horiz-adv-x="878" d="M877.714 182.857v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 475.428v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 768v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571z" />

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.ttf

Binary file not shown.

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.woff

Binary file not shown.

2
src/Squidex/app/theme/icomoon/selection.json

File diff suppressed because one or more lines are too long

265
src/Squidex/app/theme/icomoon/style.css

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?vuepjz');
src: url('fonts/icomoon.eot?vuepjz#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?vuepjz') format('truetype'),
url('fonts/icomoon.woff?vuepjz') format('woff'),
url('fonts/icomoon.svg?vuepjz#icomoon') format('svg');
src: url('fonts/icomoon.eot?ha0h5n');
src: url('fonts/icomoon.eot?ha0h5n#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?ha0h5n') format('truetype'),
url('fonts/icomoon.woff?ha0h5n') format('woff'),
url('fonts/icomoon.svg?ha0h5n#icomoon') format('svg');
font-weight: normal;
font-style: normal;
}
@ -24,6 +24,102 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-filter-filled:before {
content: "\e978";
}
.icon-clone:before {
content: "\e96a";
}
.icon-control-Tags:before {
content: "\e963";
}
.icon-control-Checkboxes:before {
content: "\e962";
}
.icon-control-Html:before {
content: "\e960";
}
.icon-single-content:before {
content: "\e958";
}
.icon-multiple-content:before {
content: "\e957";
}
.icon-type-Array:before {
content: "\e956";
}
.icon-exclamation:before {
content: "\e955";
}
.icon-orleans:before {
content: "\e94b";
}
.icon-document-lock:before {
content: "\e949";
}
.icon-document-unpublish:before {
content: "\e93f";
}
.icon-angle-down:before {
content: "\e900";
}
.icon-angle-left:before {
content: "\e901";
}
.icon-angle-right:before {
content: "\e931";
}
.icon-angle-up:before {
content: "\e903";
}
.icon-api:before {
content: "\e945";
}
.icon-assets:before {
content: "\e948";
}
.icon-bug:before {
content: "\e93d";
}
.icon-caret-down:before {
content: "\e92c";
}
.icon-caret-left:before {
content: "\e92a";
}
.icon-caret-right:before {
content: "\e929";
}
.icon-caret-up:before {
content: "\e92b";
}
.icon-contents:before {
content: "\e946";
}
.icon-trigger-ContentChanged:before {
content: "\e946";
}
.icon-control-Date:before {
content: "\e936";
}
.icon-control-DateTime:before {
content: "\e937";
}
.icon-control-Markdown:before {
content: "\e938";
}
.icon-grid:before {
content: "\f00a";
}
.icon-list1:before {
content: "\f0c9";
}
.icon-user-o:before {
content: "\e932";
}
.icon-rules:before {
content: "\e947";
}
.icon-corner-down-right:before {
content: "\e977";
}
@ -66,6 +162,72 @@
.icon-download:before {
content: "\e93e";
}
.icon-control-Radio:before {
content: "\e90d";
}
.icon-control-TextArea:before {
content: "\e90e";
}
.icon-control-Toggle:before {
content: "\e90f";
}
.icon-copy:before {
content: "\e910";
}
.icon-dashboard:before {
content: "\e911";
}
.icon-delete:before {
content: "\e912";
}
.icon-bin:before {
content: "\e912";
}
.icon-delete-filled:before {
content: "\e913";
}
.icon-document-delete:before {
content: "\e914";
}
.icon-document-disable:before {
content: "\e915";
}
.icon-document-publish:before {
content: "\e916";
}
.icon-drag:before {
content: "\e917";
}
.icon-filter:before {
content: "\e918";
}
.icon-github:before {
content: "\e941";
}
.icon-help:before {
content: "\e919";
}
.icon-location:before {
content: "\e91b";
}
.icon-control-Map:before {
content: "\e91b";
}
.icon-type-Geolocation:before {
content: "\e91b";
}
.icon-logo:before {
content: "\e91c";
}
.icon-media:before {
content: "\e91d";
}
.icon-type-Assets:before {
content: "\e91d";
}
.icon-trigger-AssetChanged:before {
content: "\e91d";
}
.icon-type-UI:before {
content: "\e975";
}
@ -333,96 +495,3 @@
.icon-info:before {
content: "\e93c";
}
.icon-clone:before {
content: "\e96a";
}
.icon-control-Tags:before {
content: "\e963";
}
.icon-control-Checkboxes:before {
content: "\e962";
}
.icon-control-Html:before {
content: "\e960";
}
.icon-single-content:before {
content: "\e958";
}
.icon-multiple-content:before {
content: "\e957";
}
.icon-type-Array:before {
content: "\e956";
}
.icon-exclamation:before {
content: "\e955";
}
.icon-orleans:before {
content: "\e94b";
}
.icon-document-lock:before {
content: "\e949";
}
.icon-document-unpublish:before {
content: "\e93f";
}
.icon-angle-down:before {
content: "\e900";
}
.icon-angle-left:before {
content: "\e901";
}
.icon-angle-right:before {
content: "\e931";
}
.icon-angle-up:before {
content: "\e903";
}
.icon-api:before {
content: "\e945";
}
.icon-assets:before {
content: "\e948";
}
.icon-bug:before {
content: "\e93d";
}
.icon-caret-down:before {
content: "\e92c";
}
.icon-caret-left:before {
content: "\e92a";
}
.icon-caret-right:before {
content: "\e929";
}
.icon-caret-up:before {
content: "\e92b";
}
.icon-contents:before {
content: "\e946";
}
.icon-trigger-ContentChanged:before {
content: "\e946";
}
.icon-control-Date:before {
content: "\e936";
}
.icon-control-DateTime:before {
content: "\e937";
}
.icon-control-Markdown:before {
content: "\e938";
}
.icon-grid:before {
content: "\f00a";
}
.icon-list1:before {
content: "\f0c9";
}
.icon-user-o:before {
content: "\e932";
}
.icon-rules:before {
content: "\e947";
}

381
tests/Squidex.Infrastructure.Tests/Queries/JsonQueryConversionTests.cs

@ -0,0 +1,381 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using NJsonSchema;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Queries.Json;
using Squidex.Infrastructure.TestHelpers;
using Xunit;
namespace Squidex.Infrastructure.Queries
{
public sealed class JsonQueryConversionTests
{
private readonly List<string> errors = new List<string>();
private readonly JsonSchema schema = new JsonSchema();
public JsonQueryConversionTests()
{
var nested = new JsonSchemaProperty { Title = "nested" };
nested.Properties["property"] = new JsonSchemaProperty
{
Type = JsonObjectType.String
};
schema.Properties["boolean"] = new JsonSchemaProperty
{
Type = JsonObjectType.Boolean
};
schema.Properties["datetime"] = new JsonSchemaProperty
{
Type = JsonObjectType.String, Format = JsonFormatStrings.DateTime
};
schema.Properties["guid"] = new JsonSchemaProperty
{
Type = JsonObjectType.String, Format = JsonFormatStrings.Guid
};
schema.Properties["integer"] = new JsonSchemaProperty
{
Type = JsonObjectType.Integer
};
schema.Properties["number"] = new JsonSchemaProperty
{
Type = JsonObjectType.Number
};
schema.Properties["string"] = new JsonSchemaProperty
{
Type = JsonObjectType.String
};
schema.Properties["stringArray"] = new JsonSchemaProperty
{
Item = new JsonSchema
{
Type = JsonObjectType.String
},
Type = JsonObjectType.Array
};
schema.Properties["object"] = nested;
schema.Properties["reference"] = new JsonSchemaProperty
{
Reference = nested
};
}
[Fact]
public void Should_add_error_if_property_does_not_exist()
{
var json = new { path = "notfound", op = "eq", value = 1 };
AssertErrors(json, "Path 'notfound' does not point to a valid property in the model.");
}
[Fact]
public void Should_add_error_if_nested_property_does_not_exist()
{
var json = new { path = "object.notfound", op = "eq", value = 1 };
AssertErrors(json, "'notfound' is not a property of 'nested'.");
}
[Theory]
[InlineData("contains", "contains(datetime, 2012-11-10T09:08:07Z)")]
[InlineData("empty", "empty(datetime)")]
[InlineData("endswith", "endsWith(datetime, 2012-11-10T09:08:07Z)")]
[InlineData("eq", "datetime == 2012-11-10T09:08:07Z")]
[InlineData("ge", "datetime >= 2012-11-10T09:08:07Z")]
[InlineData("gt", "datetime > 2012-11-10T09:08:07Z")]
[InlineData("le", "datetime <= 2012-11-10T09:08:07Z")]
[InlineData("lt", "datetime < 2012-11-10T09:08:07Z")]
[InlineData("ne", "datetime != 2012-11-10T09:08:07Z")]
[InlineData("startswith", "startsWith(datetime, 2012-11-10T09:08:07Z)")]
public void Should_parse_datetime_string_filter(string op, string expected)
{
var json = new { path = "datetime", op, value = "2012-11-10T09:08:07Z" };
AssertFilter(json, expected);
}
[Fact]
public void Should_parse_date_string_filter()
{
var json = new { path = "datetime", op = "eq", value = "2012-11-10" };
AssertFilter(json, "datetime == 2012-11-10T00:00:00Z");
}
[Fact]
public void Should_add_error_if_datetime_string_property_got_invalid_string_value()
{
var json = new { path = "datetime", op = "eq", value = "invalid" };
AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got invalid String.");
}
[Fact]
public void Should_add_error_if_datetime_string_property_got_invalid_value()
{
var json = new { path = "datetime", op = "eq", value = 1 };
AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got Number.");
}
[Theory]
[InlineData("contains", "contains(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")]
[InlineData("empty", "empty(guid)")]
[InlineData("endswith", "endsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")]
[InlineData("eq", "guid == bf57d32c-d4dd-4217-8c16-6dcb16975cf3")]
[InlineData("ge", "guid >= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")]
[InlineData("gt", "guid > bf57d32c-d4dd-4217-8c16-6dcb16975cf3")]
[InlineData("le", "guid <= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")]
[InlineData("lt", "guid < bf57d32c-d4dd-4217-8c16-6dcb16975cf3")]
[InlineData("ne", "guid != bf57d32c-d4dd-4217-8c16-6dcb16975cf3")]
[InlineData("startswith", "startsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")]
public void Should_parse_guid_string_filter(string op, string expected)
{
var json = new { path = "guid", op, value = "bf57d32c-d4dd-4217-8c16-6dcb16975cf3" };
AssertFilter(json, expected);
}
[Fact]
public void Should_add_error_if_guid_string_property_got_invalid_string_value()
{
var json = new { path = "guid", op = "eq", value = "invalid" };
AssertErrors(json, "Expected Guid String for path 'guid', but got invalid String.");
}
[Fact]
public void Should_add_error_if_guid_string_property_got_invalid_value()
{
var json = new { path = "guid", op = "eq", value = 1 };
AssertErrors(json, "Expected Guid String for path 'guid', but got Number.");
}
[Theory]
[InlineData("contains", "contains(string, 'Hello')")]
[InlineData("empty", "empty(string)")]
[InlineData("endswith", "endsWith(string, 'Hello')")]
[InlineData("eq", "string == 'Hello'")]
[InlineData("ge", "string >= 'Hello'")]
[InlineData("gt", "string > 'Hello'")]
[InlineData("le", "string <= 'Hello'")]
[InlineData("lt", "string < 'Hello'")]
[InlineData("ne", "string != 'Hello'")]
[InlineData("startswith", "startsWith(string, 'Hello')")]
public void Should_parse_string_filter(string op, string expected)
{
var json = new { path = "string", op, value = "Hello" };
AssertFilter(json, expected);
}
[Fact]
public void Should_add_error_if_string_property_got_invalid_value()
{
var json = new { path = "string", op = "eq", value = 1 };
AssertErrors(json, "Expected String for path 'string', but got Number.");
}
[Fact]
public void Should_parse_string_in_filter()
{
var json = new { path = "string", op = "in", value = new[] { "Hello" } };
AssertFilter(json, "string in ['Hello']");
}
[Fact]
public void Should_parse_nested_string_filter()
{
var json = new { path = "object.property", op = "in", value = new[] { "Hello" } };
AssertFilter(json, "object.property in ['Hello']");
}
[Fact]
public void Should_parse_referenced_string_filter()
{
var json = new { path = "reference.property", op = "in", value = new[] { "Hello" } };
AssertFilter(json, "reference.property in ['Hello']");
}
[Theory]
[InlineData("eq", "number == 12")]
[InlineData("ge", "number >= 12")]
[InlineData("gt", "number > 12")]
[InlineData("le", "number <= 12")]
[InlineData("lt", "number < 12")]
[InlineData("ne", "number != 12")]
public void Should_parse_number_filter(string op, string expected)
{
var json = new { path = "number", op, value = 12 };
AssertFilter(json, expected);
}
[Fact]
public void Should_add_error_if_number_property_got_invalid_value()
{
var json = new { path = "number", op = "eq", value = true };
AssertErrors(json, "Expected Number for path 'number', but got Boolean.");
}
[Fact]
public void Should_parse_number_in_filter()
{
var json = new { path = "number", op = "in", value = new[] { 12 } };
AssertFilter(json, "number in [12]");
}
[Theory]
[InlineData("eq", "boolean == True")]
[InlineData("ne", "boolean != True")]
public void Should_parse_boolean_filter(string op, string expected)
{
var json = new { path = "boolean", op, value = true };
AssertFilter(json, expected);
}
[Fact]
public void Should_add_error_if_boolean_property_got_invalid_value()
{
var json = new { path = "boolean", op = "eq", value = 1 };
AssertErrors(json, "Expected Boolean for path 'boolean', but got Number.");
}
[Fact]
public void Should_parse_boolean_in_filter()
{
var json = new { path = "boolean", op = "in", value = new[] { true } };
AssertFilter(json, "boolean in [True]");
}
[Theory]
[InlineData("empty", "empty(stringArray)")]
[InlineData("eq", "stringArray == 'Hello'")]
[InlineData("ne", "stringArray != 'Hello'")]
public void Should_parse_array_filter(string op, string expected)
{
var json = new { path = "stringArray", op, value = "Hello" };
AssertFilter(json, expected);
}
[Fact]
public void Should_parse_array_in_filter()
{
var json = new { path = "stringArray", op = "in", value = new[] { "Hello" } };
AssertFilter(json, "stringArray in ['Hello']");
}
[Fact]
public void Should_add_error_when_using_array_value_for_non_allowed_operator()
{
var json = new { path = "string", op = "eq", value = new[] { "Hello" } };
AssertErrors(json, "Array value is not allowed for 'Equals' operator and path 'string'.");
}
[Fact]
public void Should_parse_query()
{
var json = new { skip = 10, take = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } };
AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20");
}
[Fact]
public void Should_parse_query_with_sorting()
{
var json = new { sort = new[] { new { path = "string", order = "ascending" } } };
AssertQuery(json, "Sort: string Ascending");
}
[Fact]
public void Should_throw_exception_for_invalid_query()
{
var json = new { sort = new[] { new { path = "invalid", order = "ascending" } } };
Assert.Throws<ValidationException>(() => AssertQuery(json, null));
}
[Fact]
public void Should_throw_exception_when_parsing_invalid_json()
{
var json = "invalid";
Assert.Throws<ValidationException>(() => AssertQuery(json, null));
}
private void AssertQuery(object json, string expectedFilter)
{
var filter = ConvertQuery(json);
Assert.Empty(errors);
Assert.Equal(expectedFilter, filter);
}
private void AssertFilter(object json, string expectedFilter)
{
var filter = ConvertFilter(json);
Assert.Empty(errors);
Assert.Equal(expectedFilter, filter);
}
private void AssertErrors(object json, params string[] expectedErrors)
{
var filter = ConvertFilter(json);
Assert.Equal(expectedErrors.ToList(), errors);
Assert.Null(filter);
}
private string ConvertFilter<T>(T value)
{
var json = JsonHelper.DefaultSerializer.Serialize(value, true);
var jsonFilter = JsonHelper.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json);
return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString();
}
private string ConvertQuery<T>(T value)
{
var json = JsonHelper.DefaultSerializer.Serialize(value, true);
var jsonFilter = schema.Parse(json, JsonHelper.DefaultSerializer);
return jsonFilter.ToString();
}
}
}
Loading…
Cancel
Save