Browse Source

Not filter in UI. (#1101)

pull/1104/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
2996e31ca2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      backend/src/Squidex.Infrastructure/EventSourcing/EventCommit.cs
  2. 2
      backend/src/Squidex.Infrastructure/Plugins/PluginManager.cs
  3. 2
      backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs
  4. 50
      backend/src/Squidex.Infrastructure/Queries/QueryModel.cs
  5. 2
      backend/src/Squidex.Infrastructure/Reflection/TypeConfig.cs
  6. 2
      backend/src/Squidex.Infrastructure/Validation/ValidationException.cs
  7. 1
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  8. 42
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/QueryModelDto.cs
  9. 10
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  10. 2
      backend/src/Squidex/Areas/Api/Controllers/StatusInfoDto.cs
  11. 2
      backend/src/Squidex/Squidex.csproj
  12. 24
      backend/tests/Squidex.Infrastructure.Tests/MongoDb/MongoQueryTests.cs
  13. 10
      frontend/package-lock.json
  14. 2
      frontend/package.json
  15. 3
      frontend/src/app/features/content/pages/contents/contents-page.component.html
  16. 3
      frontend/src/app/shared/components/references/content-selector.component.html
  17. 45
      frontend/src/app/shared/components/search/queries/filter-comparison.component.html
  18. 67
      frontend/src/app/shared/components/search/queries/filter-comparison.component.ts
  19. 11
      frontend/src/app/shared/components/search/queries/filter-logical.component.html
  20. 79
      frontend/src/app/shared/components/search/queries/filter-logical.component.ts
  21. 20
      frontend/src/app/shared/components/search/queries/filter-node.component.html
  22. 16
      frontend/src/app/shared/components/search/queries/filter-node.component.ts
  23. 15
      frontend/src/app/shared/components/search/queries/query.component.html
  24. 37
      frontend/src/app/shared/components/search/queries/query.component.ts
  25. 20
      frontend/src/app/shared/components/search/queries/sorting.component.ts
  26. 17
      frontend/src/app/shared/components/search/search-form.component.html
  27. 17
      frontend/src/app/shared/components/search/search-form.component.ts
  28. 3
      frontend/src/app/shared/services/contents.service.ts
  29. 46
      frontend/src/app/shared/services/query.ts
  30. 5
      frontend/src/app/shared/state/contents.state.ts
  31. 4
      frontend/src/app/theme/_bootstrap.scss

4
backend/src/Squidex.Infrastructure/EventSourcing/EventCommit.cs

@ -13,7 +13,7 @@ public sealed record EventCommit(Guid Id, string StreamName, long Offset, IColle
{
public static EventCommit Create(Guid id, string streamName, long offset, EventData @event)
{
return new EventCommit(id, streamName, offset, new List<EventData> { @event });
return new EventCommit(id, streamName, offset, [@event]);
}
public static EventCommit Create(string streamName, long offset, Envelope<IEvent> envelope, IEventFormatter eventFormatter)
@ -22,6 +22,6 @@ public sealed record EventCommit(Guid Id, string StreamName, long Offset, IColle
var eventData = eventFormatter.ToEventData(envelope, id);
return new EventCommit(id, streamName, offset, new List<EventData> { eventData });
return new EventCommit(id, streamName, offset, [eventData]);
}
}

2
backend/src/Squidex.Infrastructure/Plugins/PluginManager.cs

@ -17,7 +17,7 @@ public sealed class PluginManager : DisposableObjectBase
{
private readonly HashSet<PluginLoader> pluginLoaders = [];
private readonly HashSet<IPlugin> loadedPlugins = [];
private readonly List<(string Plugin, string Action, Exception Exception)> exceptions = new List<(string, string, Exception)>();
private readonly List<(string Plugin, string Action, Exception Exception)> exceptions = [];
public static readonly PluginManager Instance = new PluginManager();

2
backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs

@ -40,7 +40,7 @@ public sealed class JsonFilterVisitor : FilterNodeVisitor<FilterNode<ClrValue>,
public override FilterNode<ClrValue> Visit(NegateFilter<JsonValue> nodeIn, Args args)
{
return new NegateFilter<ClrValue>(nodeIn.Accept(this, args));
return new NegateFilter<ClrValue>(nodeIn.Filter.Accept(this, args));
}
public override FilterNode<ClrValue> Visit(LogicalFilter<JsonValue> nodeIn, Args args)

50
backend/src/Squidex.Infrastructure/Queries/QueryModel.cs

@ -12,15 +12,15 @@ public sealed class QueryModel
public static readonly IReadOnlyDictionary<FilterSchemaType, IReadOnlyList<CompareOperator>> DefaultOperators = new Dictionary<FilterSchemaType, IReadOnlyList<CompareOperator>>
{
[FilterSchemaType.Any] = Enum.GetValues(typeof(CompareOperator)).OfType<CompareOperator>().ToList(),
[FilterSchemaType.Boolean] = new List<CompareOperator>
{
[FilterSchemaType.Boolean] =
[
CompareOperator.Equals,
CompareOperator.Exists,
CompareOperator.In,
CompareOperator.NotEquals
},
[FilterSchemaType.DateTime] = new List<CompareOperator>
{
],
[FilterSchemaType.DateTime] =
[
CompareOperator.Contains,
CompareOperator.Empty,
CompareOperator.Exists,
@ -34,14 +34,14 @@ public sealed class QueryModel
CompareOperator.Matchs,
CompareOperator.NotEquals,
CompareOperator.StartsWith
},
[FilterSchemaType.GeoObject] = new List<CompareOperator>
{
],
[FilterSchemaType.GeoObject] =
[
CompareOperator.LessThan,
CompareOperator.Exists
},
[FilterSchemaType.Guid] = new List<CompareOperator>
{
],
[FilterSchemaType.Guid] =
[
CompareOperator.Contains,
CompareOperator.Empty,
CompareOperator.Exists,
@ -55,18 +55,18 @@ public sealed class QueryModel
CompareOperator.Matchs,
CompareOperator.NotEquals,
CompareOperator.StartsWith
},
[FilterSchemaType.Object] = new List<CompareOperator>(),
[FilterSchemaType.ObjectArray] = new List<CompareOperator>
{
],
[FilterSchemaType.Object] = [],
[FilterSchemaType.ObjectArray] =
[
CompareOperator.Empty,
CompareOperator.Exists,
CompareOperator.Equals,
CompareOperator.In,
CompareOperator.NotEquals
},
[FilterSchemaType.Number] = new List<CompareOperator>
{
],
[FilterSchemaType.Number] =
[
CompareOperator.Equals,
CompareOperator.Exists,
CompareOperator.LessThan,
@ -75,9 +75,9 @@ public sealed class QueryModel
CompareOperator.GreaterThanOrEqual,
CompareOperator.In,
CompareOperator.NotEquals
},
[FilterSchemaType.String] = new List<CompareOperator>
{
],
[FilterSchemaType.String] =
[
CompareOperator.Contains,
CompareOperator.Empty,
CompareOperator.Exists,
@ -91,9 +91,9 @@ public sealed class QueryModel
CompareOperator.Matchs,
CompareOperator.NotEquals,
CompareOperator.StartsWith
},
[FilterSchemaType.StringArray] = new List<CompareOperator>
{
],
[FilterSchemaType.StringArray] =
[
CompareOperator.Contains,
CompareOperator.Empty,
CompareOperator.Exists,
@ -107,7 +107,7 @@ public sealed class QueryModel
CompareOperator.Matchs,
CompareOperator.NotEquals,
CompareOperator.StartsWith
}
]
};
public FilterSchema Schema { get; init; } = FilterSchema.Any;

2
backend/src/Squidex.Infrastructure/Reflection/TypeConfig.cs

@ -11,7 +11,7 @@ namespace Squidex.Infrastructure.Reflection;
public sealed class TypeConfig
{
private readonly List<(Type DerivedType, string TypeName)> derivedTypes = new List<(Type DervicedType, string TypeName)>();
private readonly List<(Type DerivedType, string TypeName)> derivedTypes = [];
private Dictionary<string, Type>? mapByName;
private Dictionary<Type, string>? mapByType;

2
backend/src/Squidex.Infrastructure/Validation/ValidationException.cs

@ -22,7 +22,7 @@ public class ValidationException : DomainException
}
public ValidationException(ValidationError error, Exception? inner = null)
: this(new List<ValidationError> { error }, inner)
: this([error], inner)
{
}

1
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -41,7 +41,6 @@ public sealed class ContentsDto : Resource
if (schema != null)
{
await result.AssignStatusesAsync(workflow, schema);
await result.CreateLinksAsync(resources, workflow, schema);
}

42
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/QueryModelDto.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Schemas.Models;
public sealed class QueryModelDto
{
public FilterSchema Schema { get; init; } = FilterSchema.Any;
public IReadOnlyDictionary<FilterSchemaType, IReadOnlyList<CompareOperator>> Operators { get; init; }
public StatusInfoDto[] Statuses { get; set; }
public static async Task<QueryModelDto> FromModelAsync(QueryModel model, Schema? schema, IContentWorkflow workflow)
{
var result = SimpleMapper.Map(model, new QueryModelDto());
if (schema != null)
{
await result.AssignStatusesAsync(workflow, schema);
}
return result;
}
private async Task AssignStatusesAsync(IContentWorkflow workflow, Schema schema)
{
var allStatuses = await workflow.GetAllAsync(schema);
Statuses = allStatuses.Select(StatusInfoDto.FromDomain).ToArray();
}
}

10
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core.GenerateFilters;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
@ -29,11 +30,13 @@ namespace Squidex.Areas.Api.Controllers.Schemas;
public sealed class SchemasController : ApiController
{
private readonly IAppProvider appProvider;
private readonly IContentWorkflow workflow;
public SchemasController(ICommandBus commandBus, IAppProvider appProvider)
public SchemasController(ICommandBus commandBus, IAppProvider appProvider, IContentWorkflow workflow)
: base(commandBus)
{
this.appProvider = appProvider;
this.workflow = workflow;
}
/// <summary>
@ -369,9 +372,10 @@ public sealed class SchemasController : ApiController
{
var components = await appProvider.GetComponentsAsync(Schema, HttpContext.RequestAborted);
var filters = ContentQueryModel.Build(Schema, App.PartitionResolver(), components).Flatten();
var result = ContentQueryModel.Build(Schema, App.PartitionResolver(), components).Flatten();
var response = await QueryModelDto.FromModelAsync(result, Schema, workflow);
return Ok(filters);
return Ok(response);
}
private async Task<FilterSchema> BuildModel()

2
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs → backend/src/Squidex/Areas/Api/Controllers/StatusInfoDto.cs

@ -7,7 +7,7 @@
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Areas.Api.Controllers.Contents.Models;
namespace Squidex.Areas.Api.Controllers;
public sealed class StatusInfoDto
{

2
backend/src/Squidex/Squidex.csproj

@ -127,7 +127,7 @@
<Content Remove="Assets\**" />
<Content Remove="wwwroot\build\**" />
</ItemGroup>
<ItemGroup>
<Folder Include="Areas\Frontend\Resources\" />
</ItemGroup>

24
backend/tests/Squidex.Infrastructure.Tests/MongoDb/MongoQueryTests.cs

@ -255,6 +255,30 @@ public class MongoQueryTests
AssertQuery("{ 'Text' : { '$exists' : true, '$ne' : null } }", filter);
}
[Fact]
public void Should_make_query_with_and()
{
var filter = ClrFilter.And(ClrFilter.Eq("A", 1), ClrFilter.Eq("B", 2));
AssertQuery("{ 'A' : 1, 'B' : 2 }", filter);
}
[Fact]
public void Should_make_query_with_or()
{
var filter = ClrFilter.Or(ClrFilter.Eq("A", 1), ClrFilter.Eq("B", 2));
AssertQuery("{ '$or' : [{ 'A' : 1 }, { 'B' : 2 }] }", filter);
}
[Fact]
public void Should_make_query_with_not()
{
var filter = ClrFilter.Not(ClrFilter.Lt("A", 1));
AssertQuery("{ 'A' : { '$not' : { '$lt' : 1 } } }", filter);
}
[Fact]
public void Should_make_query_with_full_text()
{

10
frontend/package-lock.json

@ -30,7 +30,7 @@
"ace-builds": "^1.34.2",
"angular-gridster2": "18.0.1",
"angular-mentions": "1.5.0",
"bootstrap": "5.3.3",
"bootstrap": "5.2.3",
"copy-webpack-plugin": "^12.0.2",
"core-js": "3.37.1",
"cropperjs": "2.0.0-alpha.1",
@ -12378,9 +12378,9 @@
"license": "ISC"
},
"node_modules/bootstrap": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
"funding": [
{
"type": "github",
@ -12392,7 +12392,7 @@
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.8"
"@popperjs/core": "^2.11.6"
}
},
"node_modules/bootstrap.native": {

2
frontend/package.json

@ -37,7 +37,7 @@
"ace-builds": "^1.34.2",
"angular-gridster2": "18.0.1",
"angular-mentions": "1.5.0",
"bootstrap": "5.3.3",
"bootstrap": "5.2.3",
"copy-webpack-plugin": "^12.0.2",
"core-js": "3.37.1",
"cropperjs": "2.0.0-alpha.1",

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

@ -27,8 +27,7 @@
[queriesTypes]="'common.contents' | sqxTranslate"
[query]="contentsState.query | async"
(queryChange)="search($event)"
[queryModel]="queryModel | async"
[statuses]="contentsState.statuses | async"></sqx-search-form>
[queryModel]="queryModel | async"></sqx-search-form>
</div>
@if (languages.length > 1) {
<div class="col-auto">

3
frontend/src/app/shared/components/references/content-selector.component.html

@ -43,8 +43,7 @@
placeholder="{{ 'contents.searchPlaceholder' | sqxTranslate }}"
[query]="contentsState.query | async"
(queryChange)="search($event)"
[queryModel]="queryModel | async"
[statuses]="contentsState.statuses | async"></sqx-search-form>
[queryModel]="queryModel | async"></sqx-search-form>
</div>
@if (languages.length > 1) {
<div class="col-auto">

45
frontend/src/app/shared/components/search/queries/filter-comparison.component.html

@ -1,10 +1,25 @@
@if (field) {
<div class="row gx-2 mb-1 align-items-center">
<div class="col-auto">
<div class="btn-group">
<button
class="btn btn-secondary btn-toggle btn-code text-sm"
[class.btn-primary]="actualNegated"
(click)="toggleNot()"
type="button">
NOT
</button>
</div>
</div>
<div class="col-auto path">
<sqx-query-path [model]="model" [path]="filter.path" (pathChange)="changePath($event)"></sqx-query-path>
<sqx-query-path [model]="model" [path]="actualComparison.path" (pathChange)="changePath($event)"></sqx-query-path>
</div>
<div class="col-auto operator">
<select class="form-select" [disabled]="operators.length === 0" [ngModel]="filter.op" (ngModelChange)="changeOp($event)">
<select
class="form-select"
[disabled]="operators.length === 0"
[ngModel]="actualComparison.op"
(ngModelChange)="changeOp($event)">
@for (operator of operators; track operator) {
<option [ngValue]="operator">{{ operator | sqxFilterOperator | sqxTranslate }}</option>
}
@ -13,37 +28,41 @@
<div class="col align-items-center">
@switch (fieldUI) {
@case ("Boolean") {
<input class="form-check-input" [ngModel]="filter.value" (ngModelChange)="changeValue($event)" type="checkbox" />
<input
class="form-check-input"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"
type="checkbox" />
}
@case ("Date") {
<sqx-date-time-editor
hideDateButtons="true"
mode="Date"
[ngModel]="filter.value"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"></sqx-date-time-editor>
}
@case ("DateTime") {
<sqx-date-time-editor
hideDateButtons="true"
mode="DateTime"
[ngModel]="filter.value"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"></sqx-date-time-editor>
}
@case ("Number") {
<input class="form-control" [ngModel]="filter.value" (ngModelChange)="changeValue($event)" type="number" />
<input class="form-control" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" type="number" />
}
@case ("Reference") {
<sqx-reference-input
[language]="language"
[languages]="languages"
mode="Single"
[ngModel]="filter.value"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"
[query]="undefined"
[schemaIds]="field.schema.extra?.schemaIds"></sqx-reference-input>
}
@case ("Select") {
<select class="form-select" [ngModel]="filter.value" (ngModelChange)="changeValue($event)">
<select class="form-select" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)">
<option [ngValue]="null"></option>
@for (value of field.schema.extra?.options; track value) {
<option [ngValue]="value">{{ value }}</option>
@ -53,8 +72,8 @@
@case ("Status") {
<sqx-dropdown
canSearch="false"
[items]="statuses"
[ngModel]="filter.value"
[items]="model.statuses"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"
valueProperty="status">
<ng-template let-status="$implicit">
@ -65,14 +84,14 @@
}
@case ("String") {
@if (!field.schema.extra) {
<input class="form-control" [ngModel]="filter.value" (ngModelChange)="changeValue($event)" />
<input class="form-control" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" />
}
}
@case ("User") {
@if (contributorsState.isLoaded | async) {
<sqx-dropdown
[items]="contributorsState.contributors | async"
[ngModel]="filter.value"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"
searchProperty="contributorName"
valueProperty="token">
@ -87,7 +106,7 @@
</ng-template>
</sqx-dropdown>
} @else {
<input class="form-control" [ngModel]="filter.value" (ngModelChange)="changeValue($event)" />
<input class="form-control" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" />
}
}
@case ("Unsupported") {

67
frontend/src/app/shared/components/search/queries/filter-comparison.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DateTimeEditorComponent, DropdownComponent, HighlightPipe, TranslatePipe } from '@app/framework';
import { ContributorsState, FilterableField, FilterComparison, FilterFieldUI, getFilterUI, LanguageDto, QueryModel, StatusInfo } from '@app/shared/internal';
import { ContributorsState, FilterableField, FilterComparison, FilterFieldUI, FilterNegation, getFilterUI, isNegation, LanguageDto, QueryModel } from '@app/shared/internal';
import { UserDtoPicture } from '../../pipes';
import { ReferenceInputComponent } from '../../references/reference-input.component';
import { QueryPathComponent } from './query-path.component';
@ -36,7 +36,7 @@ import { FilterOperatorPipe } from './query.pipes';
})
export class FilterComparisonComponent {
@Output()
public filterChange = new EventEmitter();
public filterChange = new EventEmitter<FilterComparison | FilterNegation>();
@Output()
public remove = new EventEmitter();
@ -47,14 +47,14 @@ export class FilterComparisonComponent {
@Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ required: true })
public model!: QueryModel;
@Input({ required: true })
public filter!: FilterComparison;
public filter!: FilterComparison | FilterNegation;
public actualComparison!: FilterComparison;
public actualNegated = false;
public field?: FilterableField;
public fieldUI?: FilterFieldUI;
@ -66,48 +66,45 @@ export class FilterComparisonComponent {
}
public ngOnChanges() {
this.updatePath(false);
if (isNegation(this.filter)) {
this.actualComparison = this.filter.not;
this.actualNegated = true;
} else {
this.actualComparison = this.filter;
this.actualNegated = false;
}
this.field = this.model.schema.fields.find(x => x.path === this.actualComparison.path);
this.fieldUI = getFilterUI(this.actualComparison, this.field!);
this.operators = this.model.operators[this.field?.schema.type!] || [];
if (!this.operators.includes(this.actualComparison.op)) {
this.actualComparison = { ...this.actualComparison, op: this.operators[0] };
}
}
public changeValue(value: any) {
this.filter.value = value;
this.emitChange();
this.change({ value });
}
public changeOp(op: string) {
this.filter.op = op;
this.updatePath(false);
this.emitChange();
this.change({ op });
}
public changePath(path: string) {
this.filter.path = path;
this.updatePath(true);
this.emitChange();
this.change({ path, value: null });
}
private updatePath(updateValue: boolean) {
this.field = this.model.schema.fields.find(x => x.path === this.filter.path);
this.operators = this.model.operators[this.field?.schema.type!] || [];
if (!this.operators.includes(this.filter.op)) {
this.filter.op = this.operators[0];
}
if (updateValue) {
this.filter.value = null;
}
private change(update: Partial<FilterComparison>) {
this.emitChange({ ...this.actualComparison, ...update }, this.actualNegated);
}
this.fieldUI = getFilterUI(this.filter, this.field!);
public toggleNot() {
this.emitChange(this.actualComparison, !this.actualNegated);
}
public emitChange() {
this.filterChange.emit();
private emitChange(filter: FilterComparison, not: boolean) {
this.filterChange.emit(not ? { not: filter } : filter);
}
}

11
frontend/src/app/shared/components/search/queries/filter-logical.component.html

@ -3,7 +3,7 @@
<div class="col">
<div class="btn-group">
<button
class="btn btn-secondary btn-toggle"
class="btn btn-secondary btn-toggle btn-code"
[class.btn-primary]="isAnd"
(click)="toggleType()"
[disabled]="isAnd"
@ -11,7 +11,7 @@
AND
</button>
<button
class="btn btn-secondary btn-toggle"
class="btn btn-secondary btn-toggle btn-code"
[class.btn-primary]="isOr"
(click)="toggleType()"
[disabled]="isOr"
@ -33,18 +33,17 @@
<div class="filters">
<span class="filter-line-v"></span>
@for (filter of filters; track filter; let i = $index) {
@for (filter of filters; track $index; let i = $index) {
<div class="filter mt-3">
<span class="filter-line-h"></span>
<sqx-filter-node
[filter]="filter"
(filterChange)="emitChange()"
(filterChange)="replaceNode(i, $event)"
[language]="language"
[languages]="languages"
[level]="level + 1"
[model]="model"
(remove)="removeFilter(i)"
[statuses]="statuses"></sqx-filter-node>
(remove)="removeNode(i)"></sqx-filter-node>
</div>
}

79
frontend/src/app/shared/components/search/queries/filter-logical.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output } from '@angular/core';
import { TranslatePipe } from '@app/framework';
import { FilterLogical, FilterNode, LanguageDto, QueryModel, StatusInfo } from '@app/shared/internal';
import { FilterLogical, FilterNode, isLogicalAnd, isLogicalOr, LanguageDto, QueryModel } from '@app/shared/internal';
import { FilterNodeComponent } from './filter-node.component';
@Component({
@ -23,10 +23,8 @@ import { FilterNodeComponent } from './filter-node.component';
],
})
export class FilterLogicalComponent {
private filterValue!: FilterLogical;
@Output()
public filterChange = new EventEmitter();
public filterChange = new EventEmitter<FilterLogical>();
@Output()
public remove = new EventEmitter();
@ -37,9 +35,6 @@ export class FilterLogicalComponent {
@Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ transform: numberAttribute })
public level = 0;
@ -50,67 +45,69 @@ export class FilterLogicalComponent {
public model!: QueryModel;
@Input({ required: true })
public set filter(filter: FilterLogical | undefined | null) {
this.filterValue = filter || {};
public filter!: FilterLogical;
this.updateFilters(this.filterValue);
}
public filters: FilterNode[] = [];
public get filter() {
return this.filterValue;
public get filters() {
return isLogicalAnd(this.filter) ? this.filter.and : this.filter.or;
}
public get isAnd() {
return !!this.filterValue.and;
return isLogicalAnd(this.filter);
}
public get isOr() {
return !!this.filterValue.or;
return isLogicalOr(this.filter);
}
public addComparison() {
this.filters.push(<any>{ path: this.model.schema.fields[0].path });
this.emitChange();
this.addNode({ path: this.model.schema.fields[0].path } as any);
}
public addLogical() {
this.filters.push({ and: [] });
this.emitChange();
this.addNode({ and: [] });
}
public removeFilter(index: number) {
this.filters.splice(index, 1);
public addNode(node: FilterNode) {
const filter = this.filter;
this.emitChange();
if (isLogicalAnd(filter)) {
this.emitChange({ and: [...filter.and, node] });
} else {
this.emitChange({ or: [...filter.or, node] });
}
}
public toggleType() {
if (this.filterValue.and) {
this.filterValue.or = this.filterValue.and;
public removeNode(index: number) {
const filter = this.filter;
delete this.filterValue.and;
if (isLogicalAnd(filter)) {
this.filter = { and: filter.and.filter((_, i) => i !== index) };
} else {
this.filterValue.and = this.filterValue.or;
delete this.filterValue.or;
this.filter = { or: filter.or.filter((_, i) => i !== index) };
}
}
public replaceNode(index: number, node: FilterNode) {
const filter = this.filter;
this.emitChange();
if (isLogicalAnd(filter)) {
this.emitChange({ and: filter.and.map((x, i) => i === index ? node : x) });
} else {
this.emitChange({ or: filter.or.map((x, i) => i === index ? node : x) });
}
}
private updateFilters(filter: FilterLogical) {
if (filter) {
this.filters = filter.and || filter.or || [];
public toggleType() {
const filter = this.filter;
if (isLogicalAnd(filter)) {
this.emitChange({ or: filter.and });
} else {
this.filters = [];
this.emitChange({ and: filter.or });
}
}
public emitChange() {
this.filterChange.emit();
private emitChange(filter: FilterLogical) {
this.filterChange.emit(filter);
}
}

20
frontend/src/app/shared/components/search/queries/filter-node.component.html

@ -1,22 +1,18 @@
@if (logical) {
@if (actualLogical) {
<sqx-filter-logical
[filter]="logical"
(filterChange)="filterChange.emit()"
[filter]="actualLogical"
(filterChange)="filterChange.emit($event)"
[language]="language"
[languages]="languages"
[level]="level"
[model]="model"
(remove)="remove.emit()"
[statuses]="statuses"></sqx-filter-logical>
}
@if (comparison) {
(remove)="remove.emit()"></sqx-filter-logical>
} @else if (actualComparison) {
<sqx-filter-comparison
[filter]="comparison"
(filterChange)="filterChange.emit()"
[filter]="actualComparison"
(filterChange)="filterChange.emit($event)"
[language]="language"
[languages]="languages"
[model]="model"
(remove)="remove.emit()"
[statuses]="statuses"></sqx-filter-comparison>
(remove)="remove.emit()"></sqx-filter-comparison>
}

16
frontend/src/app/shared/components/search/queries/filter-node.component.ts

@ -7,7 +7,7 @@
import { ChangeDetectionStrategy, Component, EventEmitter, forwardRef, Input, numberAttribute, Output } from '@angular/core';
import { FilterComparison, FilterLogical, FilterNode, LanguageDto, QueryModel, StatusInfo } from '@app/shared/internal';
import { FilterComparison, FilterLogical, FilterNegation, FilterNode, isLogical, LanguageDto, QueryModel } from '@app/shared/internal';
import { FilterComparisonComponent } from './filter-comparison.component';
import { FilterLogicalComponent } from './filter-logical.component';
@ -23,8 +23,6 @@ import { FilterLogicalComponent } from './filter-logical.component';
],
})
export class FilterNodeComponent {
public comparison?: FilterComparison;
@Output()
public filterChange = new EventEmitter();
@ -37,9 +35,6 @@ export class FilterNodeComponent {
@Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ transform: numberAttribute })
public level = 0;
@ -48,12 +43,13 @@ export class FilterNodeComponent {
@Input()
public set filter(value: FilterNode) {
if ((value as any)['and'] || (value as any)['or']) {
this.logical = <FilterLogical>value;
if (isLogical(value)) {
this.actualLogical = value;
} else {
this.comparison = <FilterComparison>value;
this.actualComparison = value;
}
}
public logical?: FilterLogical;
public actualComparison?: FilterComparison | FilterNegation;
public actualLogical?: FilterLogical;
}

15
frontend/src/app/shared/components/search/queries/query.component.html

@ -1,17 +1,20 @@
<sqx-filter-logical
[filter]="queryValue.filter"
(filterChange)="emitQueryChange()"
[filter]="actualQuery.filter"
(filterChange)="changeFilter($event)"
isRoot="true"
[language]="language"
[languages]="languages"
[model]="model"
[statuses]="statuses"></sqx-filter-logical>
[model]="model"></sqx-filter-logical>
<h4 class="mt-4">{{ "search.sorting" | sqxTranslate }}</h4>
@for (sorting of queryValue.sort; track sorting; let i = $index) {
@for (sorting of actualQuery.sort; track sorting; let i = $index) {
<div class="mb-2">
<sqx-sorting [model]="model" (remove)="removeSorting(i)" [sorting]="sorting" (sortingChange)="emitQueryChange()"></sqx-sorting>
<sqx-sorting
[model]="model"
(remove)="removeSorting(i)"
[sorting]="sorting"
(sortingChange)="replaceSorting(i, $event)"></sqx-sorting>
</div>
}

37
frontend/src/app/shared/components/search/queries/query.component.ts

@ -8,7 +8,7 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '@app/framework';
import { LanguageDto, Query, QueryModel, StatusInfo } from '@app/shared/internal';
import { FilterLogical, LanguageDto, Query, QueryModel, QuerySorting } from '@app/shared/internal';
import { FilterLogicalComponent } from './filter-logical.component';
import { SortingComponent } from './sorting.component';
@ -34,9 +34,6 @@ export class QueryComponent {
@Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ required: true })
public model!: QueryModel;
@ -47,37 +44,41 @@ export class QueryComponent {
}
if (!query.filter) {
query.filter = { and: [] };
query = { ...query, filter: { and: [] } };
}
if (!query.sort) {
query.sort = [];
query = { ...query, sort: [] };
}
this.queryValue = query;
this.actualQuery = query as any;
}
public queryValue: Query = {};
public actualQuery!: RequireKeys<Query, 'sort' | 'filter'>;
public addSorting() {
const path = Object.keys(this.model.schema.fields)[0];
if (this.queryValue.sort) {
this.queryValue.sort.push({ path, order: 'ascending' });
}
this.change({ sort: [...this.actualQuery.sort, { path, order: 'ascending' }] });
}
this.emitQueryChange();
public replaceSorting(index: number, sorting: QuerySorting) {
this.change({ sort: this.actualQuery.sort.map((x, i) => i === index ? sorting : x) });
}
public removeSorting(index: number) {
if (this.queryValue.sort) {
this.queryValue.sort.splice(index, 1);
}
this.change({ sort: this.actualQuery.sort.filter((_, i) => i !== index) });
}
this.emitQueryChange();
public changeFilter(filter: FilterLogical ) {
this.change({ filter });
}
public emitQueryChange() {
this.queryChange.emit(this.queryValue);
private change(update: Partial<Query>) {
this.actualQuery = { ...this.actualQuery, ...update };
this.queryChange.emit(this.actualQuery);
}
}
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;

20
frontend/src/app/shared/components/search/queries/sorting.component.ts

@ -23,8 +23,10 @@ import { QueryPathComponent } from './query-path.component';
],
})
export class SortingComponent {
public readonly modes = SORT_MODES;
@Output()
public sortingChange = new EventEmitter();
public sortingChange = new EventEmitter<QuerySorting>();
@Output()
public remove = new EventEmitter();
@ -35,21 +37,17 @@ export class SortingComponent {
@Input({ required: true })
public sorting!: QuerySorting;
public modes = SORT_MODES;
public changeOrder(order: any) {
this.sorting.order = order;
this.emitChange();
this.change({ order });
}
public changePath(path: string) {
this.sorting.path = path;
this.emitChange();
this.change({ path });
}
private emitChange() {
this.sortingChange.emit();
private change(update: Partial<QuerySorting>) {
this.sorting = { ...this.sorting, ...update };
this.sortingChange.emit(this.sorting);
}
}

17
frontend/src/app/shared/components/search/search-form.component.html

@ -66,17 +66,17 @@
<div class="btn-group ms-2">
<button
class="btn btn-sm btn-secondary btn-toggle"
[class.btn-primary]="!showQueries"
(click)="changeView(false)"
[disabled]="!showQueries"
[class.btn-primary]="showQueries"
(click)="changeView(true)"
[disabled]="showQueries"
type="button">
{{ "common.designer" | sqxTranslate }}
</button>
<button
class="btn btn-sm btn-secondary btn-toggle"
[class.btn-primary]="showQueries"
(click)="changeView(true)"
[disabled]="showQueries"
[class.btn-primary]="!showQueries"
(click)="changeView(false)"
[disabled]="!showQueries"
type="button">
{{ "common.bookmarks" | sqxTranslate }}
</button>
@ -84,15 +84,14 @@
</div>
@if (showQueries) {
@if (queryModel && statuses) {
@if (queryModel) {
<div class="form-horizontal">
<sqx-query
[language]="language"
[languages]="languages"
[model]="queryModel"
[query]="query"
(queryChange)="changeQuery($event)"
[statuses]="statuses"></sqx-query>
(queryChange)="changeQuery($event)"></sqx-query>
<div class="link" [innerHTML]="'search.help' | sqxTranslate | sqxMarkdown | sqxSafeHtml"></div>
</div>

17
frontend/src/app/shared/components/search/search-form.component.ts

@ -10,7 +10,7 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Inp
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { ControlErrorsComponent, FocusOnInitDirective, MarkdownPipe, ModalDialogComponent, ModalDirective, SafeHtmlPipe, ShortcutComponent, ShortcutDirective, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/framework';
import { DialogModel, equalsQuery, hasFilter, LanguageDto, Queries, Query, QueryModel, SaveQueryForm, StatusInfo, TypedSimpleChanges, Types } from '@app/shared/internal';
import { DialogModel, equalsQuery, hasFilter, LanguageDto, Queries, Query, QueryModel, SaveQueryForm, TypedSimpleChanges } from '@app/shared/internal';
import { TourHintDirective } from '../tour-hint.directive';
import { QueryComponent } from './queries/query.component';
import { SavedQueriesComponent } from './shared-queries.component';
@ -56,9 +56,6 @@ export class SearchFormComponent {
@Input()
public languages: ReadonlyArray<LanguageDto> = [];
@Input()
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input()
public queryModel?: QueryModel | null;
@ -77,7 +74,7 @@ export class SearchFormComponent {
@Input()
public formClass = 'form-inline search-form';
public showQueries = false;
public showQueries = true;
public saveKey!: Observable<string | undefined>;
public saveQueryDialog = new DialogModel();
@ -93,19 +90,17 @@ export class SearchFormComponent {
}
if (changes.query) {
this.previousQuery = Types.clone(this.query);
this.hasFilter = hasFilter(this.query);
this.previousQuery = this.query;
}
this.hasFilter = hasFilter(this.query);
}
public search(close = false) {
this.hasFilter = hasFilter(this.query);
if (this.query && !equalsQuery(this.query, this.previousQuery)) {
const clone = Types.clone(this.query);
this.queryChange.emit(clone);
this.queryChange.emit(this.query);
this.previousQuery = this.query;
}

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

@ -10,10 +10,11 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiUrlConfig, DateTime, ErrorDto, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, StringHelper, Version, Versioned } from '@app/framework';
import { StatusInfo } from '../state/contents.state';
import { Query, sanitize } from './query';
import { parseField, RootFieldDto } from './schemas.service';
export type StatusInfo = Readonly<{ status: string; color: string }>;
export class ScheduleDto {
constructor(
public readonly status: string,

46
frontend/src/app/shared/services/query.ts

@ -6,6 +6,7 @@
*/
import { QueryParams, RouteSynchronizer, Types } from '@app/framework';
import { StatusInfo } from './contents.service';
export type FilterSchemaType =
'Any' |
@ -92,13 +93,17 @@ export interface QueryModel {
// All available fields.
readonly schema: FilterSchema;
// All available statuses.
readonly statuses: ReadonlyArray<StatusInfo>;
// The allowed operators.
readonly operators: Readonly<{ [type: string]: ReadonlyArray<string> }>;
}
export type FilterNode = FilterComparison | FilterLogical;
export type FilterNode = FilterComparison | FilterLogical | FilterNegation;
export type FilterLogical = FilterAnd | FilterOr;
export interface FilterComparison {
export type FilterComparison = Readonly<{
// The full path to the property.
path: string;
@ -107,14 +112,41 @@ export interface FilterComparison {
// The value.
value: any;
}
}>;
export interface FilterLogical {
// The child filters if the logical filter is a conjunction (AND).
and?: FilterNode[];
export type FilterNegation = Readonly<{
// The negated filter.
not: FilterComparison;
}>;
export type FilterAnd = Readonly<{
// The child filters if the logical filter is a conjunction (AND).
or?: FilterNode[];
and: FilterNode[];
}>;
export type FilterOr = Readonly<{
// The child filters if the logical filter is a disjunction (OR).
or: FilterNode[];
}>;
export function isNegation(input: FilterNode): input is FilterNegation {
return !!(input as any)['not'];
}
export function isLogicalOr(input: FilterNode): input is FilterOr {
return !!(input as any)['or'];
}
export function isLogicalAnd(input: FilterNode): input is FilterAnd {
return !!(input as any)['and'];
}
export function isLogical(input: FilterNode): input is FilterLogical {
return isLogicalAnd(input) || isLogicalOr(input);
}
export function isComparison(input: FilterNode): input is FilterComparison {
return !isNegation(input) &&! isComparison(input);
}
export interface QuerySorting {

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

@ -9,7 +9,7 @@ import { Injectable } from '@angular/core';
import { EMPTY, Observable, of } from 'rxjs';
import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators';
import { debug, DialogService, ErrorDto, getPagingInfo, ListState, shareSubscribed, State, Types, Version, Versioned } from '@app/framework';
import { BulkResultDto, BulkUpdateJobDto, ContentDto, ContentsDto, ContentsService } from '../services/contents.service';
import { BulkResultDto, BulkUpdateJobDto, ContentDto, ContentsDto, ContentsService, StatusInfo } from '../services/contents.service';
import { Query } from '../services/query';
import { AppsState } from './apps.state';
import { SavedQuery } from './queries';
@ -17,9 +17,6 @@ import { SchemasState } from './schemas.state';
/* eslint-disable @typescript-eslint/no-throw-literal */
export type StatusInfo =
Readonly<{ status: string; color: string }>;
interface Snapshot extends ListState<Query> {
// The current contents.
contents: ReadonlyArray<ContentDto>;

4
frontend/src/app/theme/_bootstrap.scss

@ -335,6 +335,10 @@ a {
box-shadow: none;
}
&-code {
@include text-code;
}
&-outline-secondary {
color: $color-text-decent;

Loading…
Cancel
Save