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) 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) 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); 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<PluginLoader> pluginLoaders = [];
private readonly HashSet<IPlugin> loadedPlugins = []; 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(); 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) 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) 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>> public static readonly IReadOnlyDictionary<FilterSchemaType, IReadOnlyList<CompareOperator>> DefaultOperators = new Dictionary<FilterSchemaType, IReadOnlyList<CompareOperator>>
{ {
[FilterSchemaType.Any] = Enum.GetValues(typeof(CompareOperator)).OfType<CompareOperator>().ToList(), [FilterSchemaType.Any] = Enum.GetValues(typeof(CompareOperator)).OfType<CompareOperator>().ToList(),
[FilterSchemaType.Boolean] = new List<CompareOperator> [FilterSchemaType.Boolean] =
{ [
CompareOperator.Equals, CompareOperator.Equals,
CompareOperator.Exists, CompareOperator.Exists,
CompareOperator.In, CompareOperator.In,
CompareOperator.NotEquals CompareOperator.NotEquals
}, ],
[FilterSchemaType.DateTime] = new List<CompareOperator> [FilterSchemaType.DateTime] =
{ [
CompareOperator.Contains, CompareOperator.Contains,
CompareOperator.Empty, CompareOperator.Empty,
CompareOperator.Exists, CompareOperator.Exists,
@ -34,14 +34,14 @@ public sealed class QueryModel
CompareOperator.Matchs, CompareOperator.Matchs,
CompareOperator.NotEquals, CompareOperator.NotEquals,
CompareOperator.StartsWith CompareOperator.StartsWith
}, ],
[FilterSchemaType.GeoObject] = new List<CompareOperator> [FilterSchemaType.GeoObject] =
{ [
CompareOperator.LessThan, CompareOperator.LessThan,
CompareOperator.Exists CompareOperator.Exists
}, ],
[FilterSchemaType.Guid] = new List<CompareOperator> [FilterSchemaType.Guid] =
{ [
CompareOperator.Contains, CompareOperator.Contains,
CompareOperator.Empty, CompareOperator.Empty,
CompareOperator.Exists, CompareOperator.Exists,
@ -55,18 +55,18 @@ public sealed class QueryModel
CompareOperator.Matchs, CompareOperator.Matchs,
CompareOperator.NotEquals, CompareOperator.NotEquals,
CompareOperator.StartsWith CompareOperator.StartsWith
}, ],
[FilterSchemaType.Object] = new List<CompareOperator>(), [FilterSchemaType.Object] = [],
[FilterSchemaType.ObjectArray] = new List<CompareOperator> [FilterSchemaType.ObjectArray] =
{ [
CompareOperator.Empty, CompareOperator.Empty,
CompareOperator.Exists, CompareOperator.Exists,
CompareOperator.Equals, CompareOperator.Equals,
CompareOperator.In, CompareOperator.In,
CompareOperator.NotEquals CompareOperator.NotEquals
}, ],
[FilterSchemaType.Number] = new List<CompareOperator> [FilterSchemaType.Number] =
{ [
CompareOperator.Equals, CompareOperator.Equals,
CompareOperator.Exists, CompareOperator.Exists,
CompareOperator.LessThan, CompareOperator.LessThan,
@ -75,9 +75,9 @@ public sealed class QueryModel
CompareOperator.GreaterThanOrEqual, CompareOperator.GreaterThanOrEqual,
CompareOperator.In, CompareOperator.In,
CompareOperator.NotEquals CompareOperator.NotEquals
}, ],
[FilterSchemaType.String] = new List<CompareOperator> [FilterSchemaType.String] =
{ [
CompareOperator.Contains, CompareOperator.Contains,
CompareOperator.Empty, CompareOperator.Empty,
CompareOperator.Exists, CompareOperator.Exists,
@ -91,9 +91,9 @@ public sealed class QueryModel
CompareOperator.Matchs, CompareOperator.Matchs,
CompareOperator.NotEquals, CompareOperator.NotEquals,
CompareOperator.StartsWith CompareOperator.StartsWith
}, ],
[FilterSchemaType.StringArray] = new List<CompareOperator> [FilterSchemaType.StringArray] =
{ [
CompareOperator.Contains, CompareOperator.Contains,
CompareOperator.Empty, CompareOperator.Empty,
CompareOperator.Exists, CompareOperator.Exists,
@ -107,7 +107,7 @@ public sealed class QueryModel
CompareOperator.Matchs, CompareOperator.Matchs,
CompareOperator.NotEquals, CompareOperator.NotEquals,
CompareOperator.StartsWith CompareOperator.StartsWith
} ]
}; };
public FilterSchema Schema { get; init; } = FilterSchema.Any; 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 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<string, Type>? mapByName;
private Dictionary<Type, string>? mapByType; 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) 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) if (schema != null)
{ {
await result.AssignStatusesAsync(workflow, schema); await result.AssignStatusesAsync(workflow, schema);
await result.CreateLinksAsync(resources, 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.Schemas;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
@ -29,11 +30,13 @@ namespace Squidex.Areas.Api.Controllers.Schemas;
public sealed class SchemasController : ApiController public sealed class SchemasController : ApiController
{ {
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IContentWorkflow workflow;
public SchemasController(ICommandBus commandBus, IAppProvider appProvider) public SchemasController(ICommandBus commandBus, IAppProvider appProvider, IContentWorkflow workflow)
: base(commandBus) : base(commandBus)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.workflow = workflow;
} }
/// <summary> /// <summary>
@ -369,9 +372,10 @@ public sealed class SchemasController : ApiController
{ {
var components = await appProvider.GetComponentsAsync(Schema, HttpContext.RequestAborted); 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() 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; using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Areas.Api.Controllers.Contents.Models; namespace Squidex.Areas.Api.Controllers;
public sealed class StatusInfoDto public sealed class StatusInfoDto
{ {

2
backend/src/Squidex/Squidex.csproj

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

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

@ -255,6 +255,30 @@ public class MongoQueryTests
AssertQuery("{ 'Text' : { '$exists' : true, '$ne' : null } }", filter); 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] [Fact]
public void Should_make_query_with_full_text() public void Should_make_query_with_full_text()
{ {

10
frontend/package-lock.json

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

2
frontend/package.json

@ -37,7 +37,7 @@
"ace-builds": "^1.34.2", "ace-builds": "^1.34.2",
"angular-gridster2": "18.0.1", "angular-gridster2": "18.0.1",
"angular-mentions": "1.5.0", "angular-mentions": "1.5.0",
"bootstrap": "5.3.3", "bootstrap": "5.2.3",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^12.0.2",
"core-js": "3.37.1", "core-js": "3.37.1",
"cropperjs": "2.0.0-alpha.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" [queriesTypes]="'common.contents' | sqxTranslate"
[query]="contentsState.query | async" [query]="contentsState.query | async"
(queryChange)="search($event)" (queryChange)="search($event)"
[queryModel]="queryModel | async" [queryModel]="queryModel | async"></sqx-search-form>
[statuses]="contentsState.statuses | async"></sqx-search-form>
</div> </div>
@if (languages.length > 1) { @if (languages.length > 1) {
<div class="col-auto"> <div class="col-auto">

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

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

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

@ -1,10 +1,25 @@
@if (field) { @if (field) {
<div class="row gx-2 mb-1 align-items-center"> <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"> <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>
<div class="col-auto operator"> <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) { @for (operator of operators; track operator) {
<option [ngValue]="operator">{{ operator | sqxFilterOperator | sqxTranslate }}</option> <option [ngValue]="operator">{{ operator | sqxFilterOperator | sqxTranslate }}</option>
} }
@ -13,37 +28,41 @@
<div class="col align-items-center"> <div class="col align-items-center">
@switch (fieldUI) { @switch (fieldUI) {
@case ("Boolean") { @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") { @case ("Date") {
<sqx-date-time-editor <sqx-date-time-editor
hideDateButtons="true" hideDateButtons="true"
mode="Date" mode="Date"
[ngModel]="filter.value" [ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"></sqx-date-time-editor> (ngModelChange)="changeValue($event)"></sqx-date-time-editor>
} }
@case ("DateTime") { @case ("DateTime") {
<sqx-date-time-editor <sqx-date-time-editor
hideDateButtons="true" hideDateButtons="true"
mode="DateTime" mode="DateTime"
[ngModel]="filter.value" [ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"></sqx-date-time-editor> (ngModelChange)="changeValue($event)"></sqx-date-time-editor>
} }
@case ("Number") { @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") { @case ("Reference") {
<sqx-reference-input <sqx-reference-input
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
mode="Single" mode="Single"
[ngModel]="filter.value" [ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)" (ngModelChange)="changeValue($event)"
[query]="undefined" [query]="undefined"
[schemaIds]="field.schema.extra?.schemaIds"></sqx-reference-input> [schemaIds]="field.schema.extra?.schemaIds"></sqx-reference-input>
} }
@case ("Select") { @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> <option [ngValue]="null"></option>
@for (value of field.schema.extra?.options; track value) { @for (value of field.schema.extra?.options; track value) {
<option [ngValue]="value">{{ value }}</option> <option [ngValue]="value">{{ value }}</option>
@ -53,8 +72,8 @@
@case ("Status") { @case ("Status") {
<sqx-dropdown <sqx-dropdown
canSearch="false" canSearch="false"
[items]="statuses" [items]="model.statuses"
[ngModel]="filter.value" [ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)" (ngModelChange)="changeValue($event)"
valueProperty="status"> valueProperty="status">
<ng-template let-status="$implicit"> <ng-template let-status="$implicit">
@ -65,14 +84,14 @@
} }
@case ("String") { @case ("String") {
@if (!field.schema.extra) { @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") { @case ("User") {
@if (contributorsState.isLoaded | async) { @if (contributorsState.isLoaded | async) {
<sqx-dropdown <sqx-dropdown
[items]="contributorsState.contributors | async" [items]="contributorsState.contributors | async"
[ngModel]="filter.value" [ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)" (ngModelChange)="changeValue($event)"
searchProperty="contributorName" searchProperty="contributorName"
valueProperty="token"> valueProperty="token">
@ -87,7 +106,7 @@
</ng-template> </ng-template>
</sqx-dropdown> </sqx-dropdown>
} @else { } @else {
<input class="form-control" [ngModel]="filter.value" (ngModelChange)="changeValue($event)" /> <input class="form-control" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" />
} }
} }
@case ("Unsupported") { @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 { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateTimeEditorComponent, DropdownComponent, HighlightPipe, TranslatePipe } from '@app/framework'; 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 { UserDtoPicture } from '../../pipes';
import { ReferenceInputComponent } from '../../references/reference-input.component'; import { ReferenceInputComponent } from '../../references/reference-input.component';
import { QueryPathComponent } from './query-path.component'; import { QueryPathComponent } from './query-path.component';
@ -36,7 +36,7 @@ import { FilterOperatorPipe } from './query.pipes';
}) })
export class FilterComparisonComponent { export class FilterComparisonComponent {
@Output() @Output()
public filterChange = new EventEmitter(); public filterChange = new EventEmitter<FilterComparison | FilterNegation>();
@Output() @Output()
public remove = new EventEmitter(); public remove = new EventEmitter();
@ -47,14 +47,14 @@ export class FilterComparisonComponent {
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ required: true }) @Input({ required: true })
public model!: QueryModel; public model!: QueryModel;
@Input({ required: true }) @Input({ required: true })
public filter!: FilterComparison; public filter!: FilterComparison | FilterNegation;
public actualComparison!: FilterComparison;
public actualNegated = false;
public field?: FilterableField; public field?: FilterableField;
public fieldUI?: FilterFieldUI; public fieldUI?: FilterFieldUI;
@ -66,48 +66,45 @@ export class FilterComparisonComponent {
} }
public ngOnChanges() { 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) { public changeValue(value: any) {
this.filter.value = value; this.change({ value });
this.emitChange();
} }
public changeOp(op: string) { public changeOp(op: string) {
this.filter.op = op; this.change({ op });
this.updatePath(false);
this.emitChange();
} }
public changePath(path: string) { public changePath(path: string) {
this.filter.path = path; this.change({ path, value: null });
this.updatePath(true);
this.emitChange();
} }
private updatePath(updateValue: boolean) { private change(update: Partial<FilterComparison>) {
this.field = this.model.schema.fields.find(x => x.path === this.filter.path); this.emitChange({ ...this.actualComparison, ...update }, this.actualNegated);
}
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;
}
this.fieldUI = getFilterUI(this.filter, this.field!); public toggleNot() {
this.emitChange(this.actualComparison, !this.actualNegated);
} }
public emitChange() { private emitChange(filter: FilterComparison, not: boolean) {
this.filterChange.emit(); 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="col">
<div class="btn-group"> <div class="btn-group">
<button <button
class="btn btn-secondary btn-toggle" class="btn btn-secondary btn-toggle btn-code"
[class.btn-primary]="isAnd" [class.btn-primary]="isAnd"
(click)="toggleType()" (click)="toggleType()"
[disabled]="isAnd" [disabled]="isAnd"
@ -11,7 +11,7 @@
AND AND
</button> </button>
<button <button
class="btn btn-secondary btn-toggle" class="btn btn-secondary btn-toggle btn-code"
[class.btn-primary]="isOr" [class.btn-primary]="isOr"
(click)="toggleType()" (click)="toggleType()"
[disabled]="isOr" [disabled]="isOr"
@ -33,18 +33,17 @@
<div class="filters"> <div class="filters">
<span class="filter-line-v"></span> <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"> <div class="filter mt-3">
<span class="filter-line-h"></span> <span class="filter-line-h"></span>
<sqx-filter-node <sqx-filter-node
[filter]="filter" [filter]="filter"
(filterChange)="emitChange()" (filterChange)="replaceNode(i, $event)"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[level]="level + 1" [level]="level + 1"
[model]="model" [model]="model"
(remove)="removeFilter(i)" (remove)="removeNode(i)"></sqx-filter-node>
[statuses]="statuses"></sqx-filter-node>
</div> </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 { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output } from '@angular/core';
import { TranslatePipe } from '@app/framework'; 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'; import { FilterNodeComponent } from './filter-node.component';
@Component({ @Component({
@ -23,10 +23,8 @@ import { FilterNodeComponent } from './filter-node.component';
], ],
}) })
export class FilterLogicalComponent { export class FilterLogicalComponent {
private filterValue!: FilterLogical;
@Output() @Output()
public filterChange = new EventEmitter(); public filterChange = new EventEmitter<FilterLogical>();
@Output() @Output()
public remove = new EventEmitter(); public remove = new EventEmitter();
@ -37,9 +35,6 @@ export class FilterLogicalComponent {
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ transform: numberAttribute }) @Input({ transform: numberAttribute })
public level = 0; public level = 0;
@ -50,67 +45,69 @@ export class FilterLogicalComponent {
public model!: QueryModel; public model!: QueryModel;
@Input({ required: true }) @Input({ required: true })
public set filter(filter: FilterLogical | undefined | null) { public filter!: FilterLogical;
this.filterValue = filter || {};
this.updateFilters(this.filterValue); public get filters() {
} return isLogicalAnd(this.filter) ? this.filter.and : this.filter.or;
public filters: FilterNode[] = [];
public get filter() {
return this.filterValue;
} }
public get isAnd() { public get isAnd() {
return !!this.filterValue.and; return isLogicalAnd(this.filter);
} }
public get isOr() { public get isOr() {
return !!this.filterValue.or; return isLogicalOr(this.filter);
} }
public addComparison() { public addComparison() {
this.filters.push(<any>{ path: this.model.schema.fields[0].path }); this.addNode({ path: this.model.schema.fields[0].path } as any);
this.emitChange();
} }
public addLogical() { public addLogical() {
this.filters.push({ and: [] }); this.addNode({ and: [] });
this.emitChange();
} }
public removeFilter(index: number) { public addNode(node: FilterNode) {
this.filters.splice(index, 1); const filter = this.filter;
this.emitChange(); if (isLogicalAnd(filter)) {
this.emitChange({ and: [...filter.and, node] });
} else {
this.emitChange({ or: [...filter.or, node] });
}
} }
public toggleType() { public removeNode(index: number) {
if (this.filterValue.and) { const filter = this.filter;
this.filterValue.or = this.filterValue.and;
delete this.filterValue.and; if (isLogicalAnd(filter)) {
this.filter = { and: filter.and.filter((_, i) => i !== index) };
} else { } else {
this.filterValue.and = this.filterValue.or; this.filter = { or: filter.or.filter((_, i) => i !== index) };
delete this.filterValue.or;
} }
}
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) { public toggleType() {
if (filter) { const filter = this.filter;
this.filters = filter.and || filter.or || [];
if (isLogicalAnd(filter)) {
this.emitChange({ or: filter.and });
} else { } else {
this.filters = []; this.emitChange({ and: filter.or });
} }
} }
public emitChange() { private emitChange(filter: FilterLogical) {
this.filterChange.emit(); 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 <sqx-filter-logical
[filter]="logical" [filter]="actualLogical"
(filterChange)="filterChange.emit()" (filterChange)="filterChange.emit($event)"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[level]="level" [level]="level"
[model]="model" [model]="model"
(remove)="remove.emit()" (remove)="remove.emit()"></sqx-filter-logical>
[statuses]="statuses"></sqx-filter-logical> } @else if (actualComparison) {
}
@if (comparison) {
<sqx-filter-comparison <sqx-filter-comparison
[filter]="comparison" [filter]="actualComparison"
(filterChange)="filterChange.emit()" (filterChange)="filterChange.emit($event)"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[model]="model" [model]="model"
(remove)="remove.emit()" (remove)="remove.emit()"></sqx-filter-comparison>
[statuses]="statuses"></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 { 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 { FilterComparisonComponent } from './filter-comparison.component';
import { FilterLogicalComponent } from './filter-logical.component'; import { FilterLogicalComponent } from './filter-logical.component';
@ -23,8 +23,6 @@ import { FilterLogicalComponent } from './filter-logical.component';
], ],
}) })
export class FilterNodeComponent { export class FilterNodeComponent {
public comparison?: FilterComparison;
@Output() @Output()
public filterChange = new EventEmitter(); public filterChange = new EventEmitter();
@ -37,9 +35,6 @@ export class FilterNodeComponent {
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ transform: numberAttribute }) @Input({ transform: numberAttribute })
public level = 0; public level = 0;
@ -48,12 +43,13 @@ export class FilterNodeComponent {
@Input() @Input()
public set filter(value: FilterNode) { public set filter(value: FilterNode) {
if ((value as any)['and'] || (value as any)['or']) { if (isLogical(value)) {
this.logical = <FilterLogical>value; this.actualLogical = value;
} else { } 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 <sqx-filter-logical
[filter]="queryValue.filter" [filter]="actualQuery.filter"
(filterChange)="emitQueryChange()" (filterChange)="changeFilter($event)"
isRoot="true" isRoot="true"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[model]="model" [model]="model"></sqx-filter-logical>
[statuses]="statuses"></sqx-filter-logical>
<h4 class="mt-4">{{ "search.sorting" | sqxTranslate }}</h4> <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"> <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> </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 { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '@app/framework'; 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 { FilterLogicalComponent } from './filter-logical.component';
import { SortingComponent } from './sorting.component'; import { SortingComponent } from './sorting.component';
@ -34,9 +34,6 @@ export class QueryComponent {
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<LanguageDto>;
@Input({ required: true })
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input({ required: true }) @Input({ required: true })
public model!: QueryModel; public model!: QueryModel;
@ -47,37 +44,41 @@ export class QueryComponent {
} }
if (!query.filter) { if (!query.filter) {
query.filter = { and: [] }; query = { ...query, filter: { and: [] } };
} }
if (!query.sort) { 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() { public addSorting() {
const path = Object.keys(this.model.schema.fields)[0]; const path = Object.keys(this.model.schema.fields)[0];
if (this.queryValue.sort) { this.change({ sort: [...this.actualQuery.sort, { path, order: 'ascending' }] });
this.queryValue.sort.push({ 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) { public removeSorting(index: number) {
if (this.queryValue.sort) { this.change({ sort: this.actualQuery.sort.filter((_, i) => i !== index) });
this.queryValue.sort.splice(index, 1); }
}
this.emitQueryChange(); public changeFilter(filter: FilterLogical ) {
this.change({ filter });
} }
public emitQueryChange() { private change(update: Partial<Query>) {
this.queryChange.emit(this.queryValue); 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 { export class SortingComponent {
public readonly modes = SORT_MODES;
@Output() @Output()
public sortingChange = new EventEmitter(); public sortingChange = new EventEmitter<QuerySorting>();
@Output() @Output()
public remove = new EventEmitter(); public remove = new EventEmitter();
@ -35,21 +37,17 @@ export class SortingComponent {
@Input({ required: true }) @Input({ required: true })
public sorting!: QuerySorting; public sorting!: QuerySorting;
public modes = SORT_MODES;
public changeOrder(order: any) { public changeOrder(order: any) {
this.sorting.order = order; this.change({ order });
this.emitChange();
} }
public changePath(path: string) { public changePath(path: string) {
this.sorting.path = path; this.change({ path });
this.emitChange();
} }
private emitChange() { private change(update: Partial<QuerySorting>) {
this.sortingChange.emit(); 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"> <div class="btn-group ms-2">
<button <button
class="btn btn-sm btn-secondary btn-toggle" class="btn btn-sm btn-secondary btn-toggle"
[class.btn-primary]="!showQueries" [class.btn-primary]="showQueries"
(click)="changeView(false)" (click)="changeView(true)"
[disabled]="!showQueries" [disabled]="showQueries"
type="button"> type="button">
{{ "common.designer" | sqxTranslate }} {{ "common.designer" | sqxTranslate }}
</button> </button>
<button <button
class="btn btn-sm btn-secondary btn-toggle" class="btn btn-sm btn-secondary btn-toggle"
[class.btn-primary]="showQueries" [class.btn-primary]="!showQueries"
(click)="changeView(true)" (click)="changeView(false)"
[disabled]="showQueries" [disabled]="!showQueries"
type="button"> type="button">
{{ "common.bookmarks" | sqxTranslate }} {{ "common.bookmarks" | sqxTranslate }}
</button> </button>
@ -84,15 +84,14 @@
</div> </div>
@if (showQueries) { @if (showQueries) {
@if (queryModel && statuses) { @if (queryModel) {
<div class="form-horizontal"> <div class="form-horizontal">
<sqx-query <sqx-query
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[model]="queryModel" [model]="queryModel"
[query]="query" [query]="query"
(queryChange)="changeQuery($event)" (queryChange)="changeQuery($event)"></sqx-query>
[statuses]="statuses"></sqx-query>
<div class="link" [innerHTML]="'search.help' | sqxTranslate | sqxMarkdown | sqxSafeHtml"></div> <div class="link" [innerHTML]="'search.help' | sqxTranslate | sqxMarkdown | sqxSafeHtml"></div>
</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 { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ControlErrorsComponent, FocusOnInitDirective, MarkdownPipe, ModalDialogComponent, ModalDirective, SafeHtmlPipe, ShortcutComponent, ShortcutDirective, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/framework'; 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 { TourHintDirective } from '../tour-hint.directive';
import { QueryComponent } from './queries/query.component'; import { QueryComponent } from './queries/query.component';
import { SavedQueriesComponent } from './shared-queries.component'; import { SavedQueriesComponent } from './shared-queries.component';
@ -56,9 +56,6 @@ export class SearchFormComponent {
@Input() @Input()
public languages: ReadonlyArray<LanguageDto> = []; public languages: ReadonlyArray<LanguageDto> = [];
@Input()
public statuses?: ReadonlyArray<StatusInfo> | null;
@Input() @Input()
public queryModel?: QueryModel | null; public queryModel?: QueryModel | null;
@ -77,7 +74,7 @@ export class SearchFormComponent {
@Input() @Input()
public formClass = 'form-inline search-form'; public formClass = 'form-inline search-form';
public showQueries = false; public showQueries = true;
public saveKey!: Observable<string | undefined>; public saveKey!: Observable<string | undefined>;
public saveQueryDialog = new DialogModel(); public saveQueryDialog = new DialogModel();
@ -93,19 +90,17 @@ export class SearchFormComponent {
} }
if (changes.query) { if (changes.query) {
this.previousQuery = Types.clone(this.query); this.previousQuery = this.query;
this.hasFilter = hasFilter(this.query);
} }
this.hasFilter = hasFilter(this.query);
} }
public search(close = false) { public search(close = false) {
this.hasFilter = hasFilter(this.query); this.hasFilter = hasFilter(this.query);
if (this.query && !equalsQuery(this.query, this.previousQuery)) { if (this.query && !equalsQuery(this.query, this.previousQuery)) {
const clone = Types.clone(this.query); this.queryChange.emit(this.query);
this.queryChange.emit(clone);
this.previousQuery = 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 { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiUrlConfig, DateTime, ErrorDto, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, StringHelper, Version, Versioned } from '@app/framework'; 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 { Query, sanitize } from './query';
import { parseField, RootFieldDto } from './schemas.service'; import { parseField, RootFieldDto } from './schemas.service';
export type StatusInfo = Readonly<{ status: string; color: string }>;
export class ScheduleDto { export class ScheduleDto {
constructor( constructor(
public readonly status: string, public readonly status: string,

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

@ -6,6 +6,7 @@
*/ */
import { QueryParams, RouteSynchronizer, Types } from '@app/framework'; import { QueryParams, RouteSynchronizer, Types } from '@app/framework';
import { StatusInfo } from './contents.service';
export type FilterSchemaType = export type FilterSchemaType =
'Any' | 'Any' |
@ -92,13 +93,17 @@ export interface QueryModel {
// All available fields. // All available fields.
readonly schema: FilterSchema; readonly schema: FilterSchema;
// All available statuses.
readonly statuses: ReadonlyArray<StatusInfo>;
// The allowed operators. // The allowed operators.
readonly operators: Readonly<{ [type: string]: ReadonlyArray<string> }>; 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. // The full path to the property.
path: string; path: string;
@ -107,14 +112,41 @@ export interface FilterComparison {
// The value. // The value.
value: any; value: any;
} }>;
export interface FilterLogical { export type FilterNegation = Readonly<{
// The child filters if the logical filter is a conjunction (AND). // The negated filter.
and?: FilterNode[]; not: FilterComparison;
}>;
export type FilterAnd = Readonly<{
// The child filters if the logical filter is a conjunction (AND). // 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 { 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 { EMPTY, Observable, of } from 'rxjs';
import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators'; import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators';
import { debug, DialogService, ErrorDto, getPagingInfo, ListState, shareSubscribed, State, Types, Version, Versioned } from '@app/framework'; 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 { Query } from '../services/query';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { SavedQuery } from './queries'; import { SavedQuery } from './queries';
@ -17,9 +17,6 @@ import { SchemasState } from './schemas.state';
/* eslint-disable @typescript-eslint/no-throw-literal */ /* eslint-disable @typescript-eslint/no-throw-literal */
export type StatusInfo =
Readonly<{ status: string; color: string }>;
interface Snapshot extends ListState<Query> { interface Snapshot extends ListState<Query> {
// The current contents. // The current contents.
contents: ReadonlyArray<ContentDto>; contents: ReadonlyArray<ContentDto>;

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

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

Loading…
Cancel
Save