Browse Source

Filter v3 (#1230)

* Started with migration.

* Search form integrated

* Update dependencies.

* Self closing.

* Fix build.

* Fix tests

* Playwright fix.
pull/1232/head
Sebastian Stehle 1 year ago
committed by GitHub
parent
commit
754cae1ac5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_fr.json
  3. 2
      backend/i18n/frontend_it.json
  4. 2
      backend/i18n/frontend_pt.json
  5. 2
      backend/i18n/frontend_zh.json
  6. 2
      backend/i18n/source/frontend_en.json
  7. 1
      backend/i18n/source/frontend_fr.json
  8. 1
      backend/i18n/source/frontend_it.json
  9. 1
      backend/i18n/source/frontend_pt.json
  10. 1
      backend/i18n/source/frontend_zh.json
  11. 14
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  12. 12
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  13. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  14. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  15. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  16. 40
      backend/src/Squidex.Infrastructure/Translations/MissingKeys.cs
  17. 20
      backend/src/Squidex.Infrastructure/Translations/ResourcesLocalizer.cs
  18. 24
      backend/src/Squidex/Squidex.csproj
  19. 13
      backend/src/Squidex/appsettings.json
  20. 3
      frontend/angular.json
  21. 31
      frontend/package-lock.json
  22. 2
      frontend/package.json
  23. 4
      frontend/src/app/app.component.html
  24. 2
      frontend/src/app/features/administration/pages/event-consumers/event-consumers-page.component.html
  25. 2
      frontend/src/app/features/administration/pages/restore/restore-page.component.html
  26. 2
      frontend/src/app/features/administration/pages/users/users-page.component.html
  27. 2
      frontend/src/app/features/api/api-area.component.html
  28. 3
      frontend/src/app/features/assets/pages/assets-page.component.html
  29. 2
      frontend/src/app/features/content/pages/content/content-page.component.html
  30. 4
      frontend/src/app/features/content/pages/content/references/content-references.component.html
  31. 7
      frontend/src/app/features/content/pages/contents/contents-page.component.html
  32. 2
      frontend/src/app/features/content/pages/schemas/schemas-page.component.html
  33. 9
      frontend/src/app/features/rules/pages/rule/rule-page.component.html
  34. 2
      frontend/src/app/features/rules/pages/rules/rules-page.component.html
  35. 4
      frontend/src/app/features/rules/shared/history-step.component.html
  36. 5
      frontend/src/app/features/schemas/pages/schema/schema-page.component.html
  37. 2
      frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html
  38. 2
      frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html
  39. 2
      frontend/src/app/features/settings/pages/clients/clients-page.component.html
  40. 2
      frontend/src/app/features/settings/pages/contributors/contributors-page.component.html
  41. 2
      frontend/src/app/features/settings/pages/jobs/jobs-page.component.html
  42. 2
      frontend/src/app/features/settings/pages/languages/languages-page.component.html
  43. 2
      frontend/src/app/features/settings/pages/more/more-page.component.html
  44. 2
      frontend/src/app/features/settings/pages/plans/plans-page.component.html
  45. 2
      frontend/src/app/features/settings/pages/roles/roles-page.component.html
  46. 2
      frontend/src/app/features/settings/pages/settings/settings-page.component.html
  47. 2
      frontend/src/app/features/settings/pages/templates/templates-page.component.html
  48. 2
      frontend/src/app/features/settings/pages/workflows/workflows-page.component.html
  49. 2
      frontend/src/app/features/settings/settings-area.component.html
  50. 2
      frontend/src/app/features/teams/pages/auth/auth-page.component.html
  51. 2
      frontend/src/app/features/teams/pages/contributors/contributors-page.component.html
  52. 2
      frontend/src/app/features/teams/pages/more/more-page.component.html
  53. 2
      frontend/src/app/features/teams/pages/plans/plans-page.component.html
  54. 2
      frontend/src/app/features/teams/shared/settings-area.component.html
  55. 2
      frontend/src/app/framework/angular/dropdown-menu.component.html
  56. 16
      frontend/src/app/framework/angular/list-view.component.html
  57. 2
      frontend/src/app/framework/angular/modals/dialog-renderer.component.html
  58. 2
      frontend/src/app/framework/angular/modals/root-view.component.html
  59. 5
      frontend/src/app/shared/components/contents/content-list-header.component.ts
  60. 15
      frontend/src/app/shared/components/references/content-selector.component.html
  61. 10
      frontend/src/app/shared/components/references/content-selector.component.ts
  62. 10
      frontend/src/app/shared/components/references/reference-input.component.html
  63. 6
      frontend/src/app/shared/components/references/reference-input.component.scss
  64. 11
      frontend/src/app/shared/components/references/reference-input.component.ts
  65. 112
      frontend/src/app/shared/components/search/queries/filter-comparison.component.html
  66. 18
      frontend/src/app/shared/components/search/queries/filter-comparison.component.scss
  67. 109
      frontend/src/app/shared/components/search/queries/filter-comparison.component.ts
  68. 47
      frontend/src/app/shared/components/search/queries/filter-logical.component.html
  69. 33
      frontend/src/app/shared/components/search/queries/filter-logical.component.scss
  70. 124
      frontend/src/app/shared/components/search/queries/filter-logical.component.ts
  71. 18
      frontend/src/app/shared/components/search/queries/filter-node.component.html
  72. 2
      frontend/src/app/shared/components/search/queries/filter-node.component.scss
  73. 54
      frontend/src/app/shared/components/search/queries/filter-node.component.ts
  74. 21
      frontend/src/app/shared/components/search/queries/query-path.component.html
  75. 2
      frontend/src/app/shared/components/search/queries/query-path.component.scss
  76. 39
      frontend/src/app/shared/components/search/queries/query-path.component.ts
  77. 15
      frontend/src/app/shared/components/search/queries/query.component.html
  78. 2
      frontend/src/app/shared/components/search/queries/query.component.scss
  79. 83
      frontend/src/app/shared/components/search/queries/query.component.ts
  80. 22
      frontend/src/app/shared/components/search/queries/query.pipes.ts
  81. 15
      frontend/src/app/shared/components/search/queries/sorting.component.html
  82. 2
      frontend/src/app/shared/components/search/queries/sorting.component.scss
  83. 52
      frontend/src/app/shared/components/search/queries/sorting.component.ts
  84. 141
      frontend/src/app/shared/components/search/search-form.component.html
  85. 94
      frontend/src/app/shared/components/search/search-form.component.scss
  86. 168
      frontend/src/app/shared/components/search/search-form.component.ts
  87. 7
      frontend/src/app/shared/components/table-header.component.ts
  88. 7
      frontend/src/app/shared/index.ts
  89. 132
      frontend/src/app/shared/services/query.ts
  90. 6
      frontend/src/app/shell/pages/internal/internal-area.component.html
  91. 2
      frontend/src/app/shell/pages/internal/teams-area.component.html
  92. 3
      frontend/src/app/theme/_bootstrap-vars.scss
  93. 8
      frontend/src/app/theme/_bootstrap.scss
  94. 6
      frontend/src/app/theme/_common.scss
  95. 10
      frontend/src/app/theme/_vars.scss
  96. 3
      frontend/src/app/theme/theme.scss
  97. 16
      tools/e2e/tests/given-app/rules.spec.ts
  98. 4
      tools/e2e/tests/given-schema/contents.spec.ts
  99. 10
      tools/e2e/tests/pages/rule.ts

2
backend/i18n/frontend_en.json

@ -1078,7 +1078,7 @@
"search.addFilter": "Add Filter",
"search.addGroup": "Add Group",
"search.addSorting": "Add Sorting",
"search.advancedTour": "Click this icon to show the advanced search menu!",
"search.advancedTour": "Use filters to get exactly the content your are looking for.",
"search.customQuery": "Custom Query",
"search.fullTextTour": "Search for content using full text search over all fields and languages!",
"search.help": "Read more about filtering in the [Documentation](https://docs.squidex.io/04-guides/02-api.html).",

2
backend/i18n/frontend_fr.json

@ -1078,7 +1078,7 @@
"search.addFilter": "Ajouter un filtre",
"search.addGroup": "Ajouter un groupe",
"search.addSorting": "Ajouter un tri",
"search.advancedTour": "Cliquez sur cette icône pour afficher le menu de recherche avancée\u00A0!",
"search.advancedTour": "Use filters to get exactly the content your are looking for.",
"search.customQuery": "Requête personnalisée",
"search.fullTextTour": "Recherchez du contenu à l'aide de la recherche plein texte dans tous les champs et dans toutes les langues\u00A0!",
"search.help": "En savoir plus sur le filtrage dans la [Documentation](https://docs.squidex.io/04-guides/02-api.html).",

2
backend/i18n/frontend_it.json

@ -1078,7 +1078,7 @@
"search.addFilter": "Aggiungi un Filtro",
"search.addGroup": "Aggiungi un Gruppo",
"search.addSorting": "Aggiungi ordinamento",
"search.advancedTour": "Fai clic su questa icona per visualizzare il menu della ricerca avanzata!",
"search.advancedTour": "Use filters to get exactly the content your are looking for.",
"search.customQuery": "Query personalizzata",
"search.fullTextTour": "Cerca contenuti utilizzando la ricerca testuale su tutti i campi e le lingue!",
"search.help": "Ulteriori informazioni sui filtri su [Documentazione](https://docs.squidex.io/04-guides/02-api.html).",

2
backend/i18n/frontend_pt.json

@ -1078,7 +1078,7 @@
"search.addFilter": "Adicionar filtro",
"search.addGroup": "Adicionar Grupo",
"search.addSorting": "Adicionar Classificação",
"search.advancedTour": "Clique neste ícone para mostrar o menu de pesquisa avançado!",
"search.advancedTour": "Use filters to get exactly the content your are looking for.",
"search.customQuery": "Consulta personalizada",
"search.fullTextTour": "Pes para obter conteúdos com textos completos para todos os campos e para os direitos de texto.",
"search.help": "Ler mais sobre filtros em [Documentação](https://docs.squidex.io/04-guides/02-api.html).",

2
backend/i18n/frontend_zh.json

@ -1078,7 +1078,7 @@
"search.addFilter": "添加过滤器",
"search.addGroup": "添加组",
"search.addSorting": "添加排序",
"search.advancedTour": "单击此图标可显示高级搜索菜单!",
"search.advancedTour": "Use filters to get exactly the content your are looking for.",
"search.customQuery": "自定义查询",
"search.fullTextTour": "使用全文搜索在所有领域和语言中搜索内容!",
"search.help": "在 [文档](https://docs.squidex.io/04-guides/02-api.html) 中阅读有关过滤的更多信息。",

2
backend/i18n/source/frontend_en.json

@ -1078,7 +1078,7 @@
"search.addFilter": "Add Filter",
"search.addGroup": "Add Group",
"search.addSorting": "Add Sorting",
"search.advancedTour": "Click this icon to show the advanced search menu!",
"search.advancedTour": "Use filters to get exactly the content your are looking for.",
"search.customQuery": "Custom Query",
"search.fullTextTour": "Search for content using full text search over all fields and languages!",
"search.help": "Read more about filtering in the [Documentation](https://docs.squidex.io/04-guides/02-api.html).",

1
backend/i18n/source/frontend_fr.json

@ -945,7 +945,6 @@
"search.addFilter": "Ajouter un filtre",
"search.addGroup": "Ajouter un groupe",
"search.addSorting": "Ajouter un tri",
"search.advancedTour": "Cliquez sur cette icône pour afficher le menu de recherche avancée\u00A0!",
"search.customQuery": "Requête personnalisée",
"search.fullTextTour": "Recherchez du contenu à l'aide de la recherche plein texte dans tous les champs et dans toutes les langues\u00A0!",
"search.help": "En savoir plus sur le filtrage dans la [Documentation](https://docs.squidex.io/04-guides/02-api.html).",

1
backend/i18n/source/frontend_it.json

@ -751,7 +751,6 @@
"search.addFilter": "Aggiungi un Filtro",
"search.addGroup": "Aggiungi un Gruppo",
"search.addSorting": "Aggiungi ordinamento",
"search.advancedTour": "Fai clic su questa icona per visualizzare il menu della ricerca avanzata!",
"search.customQuery": "Query personalizzata",
"search.fullTextTour": "Cerca contenuti utilizzando la ricerca testuale su tutti i campi e le lingue!",
"search.help": "Ulteriori informazioni sui filtri su [Documentazione](https://docs.squidex.io/04-guides/02-api.html).",

1
backend/i18n/source/frontend_pt.json

@ -927,7 +927,6 @@
"search.addFilter": "Adicionar filtro",
"search.addGroup": "Adicionar Grupo",
"search.addSorting": "Adicionar Classificação",
"search.advancedTour": "Clique neste ícone para mostrar o menu de pesquisa avançado!",
"search.customQuery": "Consulta personalizada",
"search.fullTextTour": "Pes para obter conteúdos com textos completos para todos os campos e para os direitos de texto.",
"search.help": "Ler mais sobre filtros em [Documentação](https://docs.squidex.io/04-guides/02-api.html).",

1
backend/i18n/source/frontend_zh.json

@ -759,7 +759,6 @@
"search.addFilter": "添加过滤器",
"search.addGroup": "添加组",
"search.addSorting": "添加排序",
"search.advancedTour": "单击此图标可显示高级搜索菜单!",
"search.customQuery": "自定义查询",
"search.fullTextTour": "使用全文搜索在所有领域和语言中搜索内容!",
"search.help": "在 [文档](https://docs.squidex.io/04-guides/02-api.html) 中阅读有关过滤的更多信息。",

14
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -40,13 +40,13 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.Json.Microsoft" Version="8.0.3" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.NetTopologySuite" Version="8.0.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.22.0" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.22.0" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.22.0" />
<PackageReference Include="Squidex.Hosting" Version="7.22.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.22.0" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.23.0" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.23.0" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.23.0" />
<PackageReference Include="Squidex.Hosting" Version="7.23.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.23.0" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

12
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -25,12 +25,12 @@
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.30.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.22.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.22.0" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.22.0" />
<PackageReference Include="Squidex.Hosting" Version="7.22.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.22.0" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.23.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.23.0" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.23.0" />
<PackageReference Include="Squidex.Hosting" Version="7.23.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.23.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -20,7 +20,7 @@
<PackageReference Include="NetTopologySuite" Version="2.5.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Flows" Version="7.22.0" />
<PackageReference Include="Squidex.Flows" Version="7.23.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

4
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -29,8 +29,8 @@
<PackageReference Include="NJsonSchema" Version="11.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="7.22.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.22.0" />
<PackageReference Include="Squidex.AI" Version="7.23.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.23.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="7.22.0" />
<PackageReference Include="Squidex.Caching" Version="7.22.0" />
<PackageReference Include="Squidex.Events" Version="7.22.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.22.0" />
<PackageReference Include="Squidex.Log" Version="7.22.0" />
<PackageReference Include="Squidex.Messaging" Version="7.22.0" />
<PackageReference Include="Squidex.Text" Version="7.22.0" />
<PackageReference Include="Squidex.Assets" Version="7.23.0" />
<PackageReference Include="Squidex.Caching" Version="7.23.0" />
<PackageReference Include="Squidex.Events" Version="7.23.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.23.0" />
<PackageReference Include="Squidex.Log" Version="7.23.0" />
<PackageReference Include="Squidex.Messaging" Version="7.23.0" />
<PackageReference Include="Squidex.Text" Version="7.23.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

40
backend/src/Squidex.Infrastructure/Translations/MissingKeys.cs

@ -1,40 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.Translations;
public sealed class MissingKeys
{
private const string MissingFileName = "__missing.txt";
private readonly object lockObject = new object();
private readonly HashSet<string> missingTranslations;
public MissingKeys()
{
if (File.Exists(MissingFileName))
{
var missing = File.ReadAllLines(MissingFileName);
missingTranslations = new HashSet<string>(missing);
}
else
{
missingTranslations = [];
}
}
public void Log(string key)
{
lock (lockObject)
{
if (!missingTranslations.Add(key))
{
File.AppendAllLines(MissingFileName, [key]);
}
}
}
}

20
backend/src/Squidex.Infrastructure/Translations/ResourcesLocalizer.cs

@ -13,11 +13,6 @@ namespace Squidex.Infrastructure.Translations;
public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocalizer
{
#if DEBUG
private static readonly MissingKeys MissingKeys = new MissingKeys();
#endif
public (string Result, bool Found) Get(CultureInfo culture, string key, string fallback, object? args = null)
{
Guard.NotNull(culture);
@ -38,11 +33,9 @@ public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocal
var sb = new StringBuilder(translation.Length);
var span = translation.AsSpan();
while (span.Length > 0)
{
var indexOfStart = span.IndexOf('{');
if (indexOfStart < 0)
{
break;
@ -51,7 +44,6 @@ public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocal
indexOfStart++;
var indexOfEnd = span[indexOfStart..].IndexOf('}');
if (indexOfEnd < 0)
{
break;
@ -62,7 +54,6 @@ public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocal
sb.Append(span[.. (indexOfStart - 1)]);
var variable = span[indexOfStart..indexOfEnd];
var shouldLower = false;
var shouldUpper = false;
@ -83,7 +74,6 @@ public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocal
var variableName = variable.ToString();
var variableValue = variableName;
var property = argsType.GetProperty(variableName);
if (property != null)
@ -129,7 +119,6 @@ public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocal
}
sb.Append(span);
return (sb.ToString(), true);
}
@ -138,13 +127,6 @@ public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocal
private string? GetCore(CultureInfo culture, string key)
{
var translation = resourceManager.GetString(key, culture);
#if DEBUG
if (translation == null)
{
MissingKeys.Log(key);
}
#endif
return translation;
return resourceManager.GetString(key, culture);
}
}

24
backend/src/Squidex/Squidex.csproj

@ -59,17 +59,17 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.4.1" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.S3" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.S3" Version="7.23.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.23.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="21.5.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.22.0" />
<PackageReference Include="Squidex.Hosting" Version="7.22.0" />
<PackageReference Include="Squidex.Messaging.All" Version="7.22.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.22.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.23.0" />
<PackageReference Include="Squidex.Hosting" Version="7.23.0" />
<PackageReference Include="Squidex.Messaging.All" Version="7.23.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.23.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.4.3" />
<PackageReference Include="YDotNet.Native" Version="0.4.3" />
@ -83,11 +83,11 @@
</ItemGroup>
<ItemGroup Condition="'$(IncludeMagick)' == 'true'">
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.22.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.23.0" />
</ItemGroup>
<ItemGroup Condition="'$(IncludeKafka)' == 'true'">
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.22.0" />
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.23.0" />
</ItemGroup>
<PropertyGroup>

13
backend/src/Squidex/appsettings.json

@ -476,7 +476,10 @@
"folder": {
// The relative or absolute path to the folder to store the assets.
"path": "Assets"
"path": "Assets",
// To create the subfolder if it does not exist. Set it to false it the Squidex process has limited permissions.
"createFolder": true
},
"googleCloud": {
// The name of the bucket in google cloud store.
@ -487,7 +490,10 @@
"containerName": "squidex-assets",
// The connection string to the azure storage service.
"connectionString": "UseDevelopmentStorage=true"
"connectionString": "UseDevelopmentStorage=true",
// To create the subfolder if it does not exist. Set it to false it the Squidex process has limited permissions.
"createFolder": true
},
"amazonS3": {
// The url of the S3 API service. Leave it empty if using the one provided by Amazon
@ -539,6 +545,9 @@
//The host of the ftp service
"serverPort": "21",
// To create the subfolder if it does not exist. Set it to false it the Squidex process has limited permissions.
"createFolder": true,
// Credentials.
"username": "",
"password": "",

3
frontend/angular.json

@ -187,7 +187,8 @@
],
"stylePreprocessorOptions": {
"includePaths": [
"./src/app/theme"
"./src/app/theme",
"node_modules"
]
},
"scripts": []

31
frontend/package-lock.json

@ -41,6 +41,8 @@
"mousetrap": "1.6.5",
"ng2-charts": "^8.0.0",
"ngx-doc-viewer": "15.0.1",
"ngx-inline-filter": "^0.2.1",
"ngx-scrollbar": "^18.0.0",
"ngx-ui-tour-core": "14.0.0",
"oidc-client-ts": "^3.2.1",
"progressbar.js": "1.1.1",
@ -19872,6 +19874,35 @@
"@angular/platform-browser": ">= 10.0.0"
}
},
"node_modules/ngx-inline-filter": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/ngx-inline-filter/-/ngx-inline-filter-0.2.1.tgz",
"integrity": "sha512-Szqe/ld24c2bWN2QqVC62u/X2tKRdBiyjvQytsRwRZ9iiLIxbz3X8xjMVgSA2UB0FcLV+rQdgJ7/wnlzTYGYKQ==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": "^19.0.0",
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
"ngx-scrollbar": "^18.0.0"
}
},
"node_modules/ngx-scrollbar": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/ngx-scrollbar/-/ngx-scrollbar-18.0.0.tgz",
"integrity": "sha512-+ykmY491x+nzXvnecJvZHvDz0YWuX1r7SYMxNG4RVHXm5Z68P/8kd/3ryLD6DXdNWmJawd4NGvqq2ZkUKb/g3A==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": ">=19.0.0",
"@angular/common": ">=19.0.0",
"@angular/core": ">=19.0.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/ngx-ui-tour-core": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/ngx-ui-tour-core/-/ngx-ui-tour-core-14.0.0.tgz",

2
frontend/package.json

@ -48,6 +48,8 @@
"mousetrap": "1.6.5",
"ng2-charts": "^8.0.0",
"ngx-doc-viewer": "15.0.1",
"ngx-inline-filter": "^0.2.1",
"ngx-scrollbar": "^18.0.0",
"ngx-ui-tour-core": "14.0.0",
"oidc-client-ts": "^3.2.1",
"progressbar.js": "1.1.1",

4
frontend/src/app/app.component.html

@ -8,6 +8,8 @@
</div>
}
</router-outlet>
<sqx-tour-guide /> <sqx-tour-template /> <sqx-dialog-renderer />
<sqx-tour-guide />
<sqx-tour-template />
<sqx-dialog-renderer />
</sqx-root-view>
</main>

2
frontend/src/app/features/administration/pages/event-consumers/event-consumers-page.component.html

@ -48,7 +48,7 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />
<sqx-modal-dialog (dialogClose)="eventConsumerErrorDialog.hide()" *sqxModal="eventConsumerErrorDialog">
<ng-container title> {{ "common.error" | sqxTranslate }} </ng-container>
<ng-container content>

2
frontend/src/app/features/administration/pages/restore/restore-page.component.html

@ -86,4 +86,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/administration/pages/users/users-page.component.html

@ -75,4 +75,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/api/api-area.component.html

@ -20,4 +20,4 @@
</ul>
</ng-container>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

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

@ -101,7 +101,8 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet> <sqx-asset-folder-dialog (dialogClose)="addAssetFolderDialog.hide()" *sqxModal="addAssetFolderDialog" />
<router-outlet />
<sqx-asset-folder-dialog (dialogClose)="addAssetFolderDialog.hide()" *sqxModal="addAssetFolderDialog" />
<sqx-asset-dialog
[asset]="editAsset!"
(assetReplaced)="replaceAsset($event)"

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

@ -253,4 +253,4 @@
</ng-template>
</sqx-layout>
</form>
<router-outlet></router-outlet>
<router-outlet />

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

@ -31,5 +31,7 @@
</table>
}
</ng-container>
<ng-container footer> <sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)" /> </ng-container>
<ng-container footer>
<sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)" />
</ng-container>
</sqx-list-view>

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

@ -1,11 +1,11 @@
<sqx-title [message]="schema.displayName" />
<sqx-layout layout="main" titleIcon="contents" titleText="i18n:common.contents">
<sqx-layout layout="main">
<ng-container menu>
<div class="row flex-nowrap flex-grow-1 gx-2">
<div class="col-auto ms-8">
<sqx-notifo position="bottom-left" topic="apps/{{ contentsState.appId }}/schemas/{{ schema.id }}/contents" />
<button class="btn btn-text-secondary" (click)="reload()" shortcut="CTRL + B" title="i18n:contents.refreshTooltip" type="button">
<i class="icon-reset"></i> {{ "common.refresh" | sqxTranslate }}
<i class="icon-reset"></i>
</button>
</div>
@ -184,4 +184,5 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet> <sqx-due-time-selector #dueTimeSelector [disabled]="disableScheduler" />
<router-outlet />
<sqx-due-time-selector #dueTimeSelector [disabled]="disableScheduler" />

2
frontend/src/app/features/content/pages/schemas/schemas-page.component.html

@ -21,4 +21,4 @@
</ng-container>
</sqx-layout>
}
<router-outlet></router-outlet>
<router-outlet />

9
frontend/src/app/features/rules/pages/rule/rule-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="i18n:rules.listPageTitle" [url]="['..']" /> <sqx-title message="i18n:rules.itemPageTitle" />
<sqx-title message="i18n:rules.listPageTitle" [url]="['..']" />
<sqx-layout layout="main" overflow="true" padding="true" white="true">
<ng-container title>
<div class="d-flex align-items-center">
@ -31,11 +31,10 @@
<div class="btn btn-outline-secondary btn-enabled">
@if (rule.isEnabled) {
<span class="me-2"> {{ "common.enabled" | sqxTranslate }} </span>
}
@if (!rule.isEnabled) {
} @else {
<span class="me-2"> {{ "common.disabled" | sqxTranslate }} </span>
}
<sqx-toggle [ngModel]="rule.isEnabled" (ngModelChange)="changeEnabled($event)" />
</div>
<input
@ -148,4 +147,4 @@
[scriptCompletions]="scriptCompletions | async"
*sqxModal="stepDialog"
[stepDefinition]="stepToUpsert?.step" />
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/rules/pages/rules/rules-page.component.html

@ -72,4 +72,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

4
frontend/src/app/features/rules/shared/history-step.component.html

@ -33,4 +33,6 @@
</div>
</div>
<ng-template #detailsTemplate> <ng-content select="[details]"></ng-content> </ng-template>
<ng-template #detailsTemplate>
<ng-content select="[details]" />
</ng-template>

5
frontend/src/app/features/schemas/pages/schema/schema-page.component.html

@ -116,7 +116,8 @@
@if (schema.type !== "Component") {
<sqx-schema-preview-urls-form [schema]="schema" />
}
<sqx-schema-field-rules-form [schema]="schema" /> <sqx-schema-edit-form [schema]="schema" />
<sqx-schema-field-rules-form [schema]="schema" />
<sqx-schema-edit-form [schema]="schema" />
</div>
</sqx-list-view>
}
@ -158,4 +159,4 @@
</ng-template>
</sqx-layout>
}
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html

@ -42,4 +42,4 @@
</ng-container>
</sqx-layout>
<sqx-schema-form (create)="redirectSchema($event)" (dialogClose)="addSchemaDialog.hide()" [import]="import" *sqxModal="addSchemaDialog" />
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html

@ -60,4 +60,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/clients/clients-page.component.html

@ -53,4 +53,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/contributors/contributors-page.component.html

@ -82,4 +82,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/jobs/jobs-page.component.html

@ -44,4 +44,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/languages/languages-page.component.html

@ -51,4 +51,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/more/more-page.component.html

@ -164,4 +164,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/plans/plans-page.component.html

@ -83,4 +83,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/roles/roles-page.component.html

@ -67,4 +67,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/settings/settings-page.component.html

@ -180,4 +180,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/templates/templates-page.component.html

@ -34,4 +34,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/pages/workflows/workflows-page.component.html

@ -69,4 +69,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/settings/settings-area.component.html

@ -6,4 +6,4 @@
}
</ng-container>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/teams/pages/auth/auth-page.component.html

@ -142,4 +142,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/teams/pages/contributors/contributors-page.component.html

@ -70,4 +70,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/teams/pages/more/more-page.component.html

@ -78,4 +78,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/teams/pages/plans/plans-page.component.html

@ -74,4 +74,4 @@
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/features/teams/shared/settings-area.component.html

@ -6,4 +6,4 @@
}
</ng-container>
</sqx-layout>
<router-outlet></router-outlet>
<router-outlet />

2
frontend/src/app/framework/angular/dropdown-menu.component.html

@ -1 +1 @@
<ng-content></ng-content>
<ng-content />

16
frontend/src/app/framework/angular/list-view.component.html

@ -38,10 +38,18 @@
<div class="loader text-center"><sqx-loader /></div>
}
<ng-template #contentTemplate> <ng-content></ng-content> </ng-template>
<ng-template #contentTemplate>
<ng-content />
</ng-template>
<ng-template #topHeaderTemplate> <ng-content select="[topHeader]"></ng-content> </ng-template>
<ng-template #topHeaderTemplate>
<ng-content select="[topHeader]" />
</ng-template>
<ng-template #headerTemplate> <ng-content select="[header]"></ng-content> </ng-template>
<ng-template #headerTemplate>
<ng-content select="[header]" />
</ng-template>
<ng-template #footerTemplate> <ng-content select="[footer]"></ng-content> </ng-template>
<ng-template #footerTemplate>
<ng-content select="[footer]" />
</ng-template>

2
frontend/src/app/framework/angular/modals/dialog-renderer.component.html

@ -1,4 +1,4 @@
<ng-content></ng-content>
<ng-content />
<ng-container *sqxModal="dialogView">
@if (snapshot.dialogRequest; as request) {
<sqx-modal-dialog (dialogClose)="cancel()" [showClose]="false">

2
frontend/src/app/framework/angular/modals/root-view.component.html

@ -1,2 +1,2 @@
<div #element></div>
<ng-content></ng-content>
<ng-content />

5
frontend/src/app/shared/components/contents/content-list-header.component.ts

@ -6,7 +6,8 @@
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { AppLanguageDto, META_FIELDS, Query, SortMode, TableField } from '@app/shared/internal';
import { SortOrder } from 'ngx-inline-filter';
import { AppLanguageDto, META_FIELDS, Query, TableField } from '@app/shared/internal';
import { TableHeaderComponent } from '../table-header.component';
@Component({
@ -34,7 +35,7 @@ export class ContentListHeaderComponent {
public language!: AppLanguageDto;
public sortPath?: string;
public sortDefault?: SortMode;
public sortDefault?: SortOrder;
public ngOnChanges() {
const { field, language } = this;

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

@ -106,8 +106,17 @@
</ng-container>
<ng-container footer>
<button class="btn btn-text-secondary" (click)="emitClose()" type="button">{{ "common.cancel" | sqxTranslate }}</button>
<button class="btn btn-success" (click)="emitSelect()" [disabled]="selectionCount === 0 || selectionCount > maxItems" type="submit">
{{ "contents.referencesLink" | sqxTranslate: { count: selectionCount } }}
</button>
<div>
@if (canClear) {
<button class="btn me-2" (click)="emitClear()" type="button">
{{ "common.clear" | sqxTranslate: { count: 0 } }}
</button>
}
<button class="btn btn-success" (click)="emitSelect()" [disabled]="selectionCount === 0 || selectionCount > maxItems" type="submit">
{{ "contents.referencesLink" | sqxTranslate: { count: selectionCount } }}
</button>
</div>
</ng-container>
</sqx-modal-dialog>

10
frontend/src/app/shared/components/references/content-selector.component.ts

@ -50,6 +50,9 @@ export class ContentSelectorComponent implements OnInit {
@Output()
public contentSelect = new EventEmitter<ReadonlyArray<ContentDto>>();
@Output()
public contentClear = new EventEmitter();
@Input({ transform: numberAttribute })
public maxItems = Number.MAX_VALUE;
@ -59,6 +62,9 @@ export class ContentSelectorComponent implements OnInit {
@Input()
public schemaIdentifiers?: ReadonlyArray<string>;
@Input({ transform: booleanAttribute })
public canClear = false;
@Input()
public query?: string;
@ -160,6 +166,10 @@ export class ContentSelectorComponent implements OnInit {
this.contentSelect.emit([]);
}
public emitClear() {
this.contentClear.emit();
}
public emitSelect() {
this.contentSelect.emit(Object.values(this.selectedItems));
}

10
frontend/src/app/shared/components/references/reference-input.component.html

@ -1,10 +1,8 @@
<div class="input-group">
<input class="form-control" (click)="openDialog()" [ngModel]="snapshot.selectedName" readonly />
<button class="btn btn-outline-secondary" (click)="selectContent()" [disabled]="snapshot.isDisabled" sqxStopClick type="button">
<i class="icon-close"></i>
</button>
</div>
<input class="form-control" [class.form-control-sm]="small" (click)="openDialog()" [ngModel]="snapshot.selectedName" readonly />
<sqx-content-selector
canClear="true"
(contentClear)="select([], true)"
(contentSelect)="select($event)"
[language]="language"
[languages]="languages"

6
frontend/src/app/shared/components/references/reference-input.component.scss

@ -1,2 +1,6 @@
@import 'mixins';
@import 'vars';
@import 'vars';
input {
cursor: default;
}

11
frontend/src/app/shared/components/references/reference-input.component.ts

@ -7,7 +7,7 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ModalDirective, StopClickDirective } from '@app/framework';
import { ModalDirective } from '@app/framework';
import { AppLanguageDto, AppsState, ContentDto, ContentsService, DialogModel, getContentValue, LocalizerService, StatefulControlComponent, TypedSimpleChanges, Types } from '@app/shared/internal';
import { ContentSelectorComponent } from './content-selector.component';
@ -35,7 +35,6 @@ interface State {
ContentSelectorComponent,
FormsModule,
ModalDirective,
StopClickDirective,
],
})
export class ReferenceInputComponent extends StatefulControlComponent<State, ReadonlyArray<string> | string> {
@ -54,6 +53,9 @@ export class ReferenceInputComponent extends StatefulControlComponent<State, Rea
@Input({ required: true })
public mode: 'Array' | 'Single' = 'Single';
@Input()
public small = false;
@Input({ transform: booleanAttribute })
public set disabled(value: boolean | undefined | null) {
this.setDisabledState(value === true);
@ -105,8 +107,8 @@ export class ReferenceInputComponent extends StatefulControlComponent<State, Rea
this.contentSelectorDialog.show();
}
public select(contents: ReadonlyArray<ContentDto>) {
if (contents.length > 0) {
public select(contents: ReadonlyArray<ContentDto>, clear = false) {
if (contents.length > 0 || clear) {
this.selectContent(contents[0]);
}
@ -133,7 +135,6 @@ export class ReferenceInputComponent extends StatefulControlComponent<State, Rea
}
this.callTouched();
this.updateContent(selectedContent);
}

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

@ -1,112 +0,0 @@
@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]="actualComparison.path" (pathChange)="changePath($event)" />
</div>
<div class="col-auto operator">
<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>
}
</select>
</div>
<div class="col align-items-center">
@switch (fieldUI) {
@case ("Boolean") {
<input class="form-check-input" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" type="checkbox" />
}
@case ("Date") {
<sqx-date-time-editor hideDateButtons="true" mode="Date" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" />
}
@case ("DateTime") {
<sqx-date-time-editor hideDateButtons="true" mode="DateTime" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" />
}
@case ("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]="actualComparison.value"
(ngModelChange)="changeValue($event)"
[query]="undefined"
[schemaIds]="field.schema.extra?.schemaIds" />
}
@case ("Select") {
<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>
}
</select>
}
@case ("Status") {
<sqx-dropdown
canSearch="false"
[items]="model.statuses"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"
valueProperty="status">
<ng-template let-status="$implicit"> <i class="icon-circle" [style.color]="status.color"></i> {{ status.status }} </ng-template>
</sqx-dropdown>
}
@case ("String") {
@if (!field.schema.extra) {
<input class="form-control" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" />
}
}
@case ("User") {
@if (contributorsState.isLoaded | async) {
<sqx-dropdown
[items]="contributorsState.contributors | async"
[ngModel]="actualComparison.value"
(ngModelChange)="changeValue($event)"
searchProperty="contributorName"
valueProperty="token">
<ng-template let-context="context" let-user="$implicit">
<span class="dropdown-user">
<img class="user-picture" [src]="user | sqxUserDtoPicture" />
<span class="user-name" [innerHTML]="user.contributorName | sqxHighlight: context"></span>
</span>
</ng-template>
<ng-template let-user="$implicit">
<span class="user-name">{{ user.contributorName }}</span>
</ng-template>
</sqx-dropdown>
} @else {
<input class="form-control" [ngModel]="actualComparison.value" (ngModelChange)="changeValue($event)" />
}
}
@case ("Unsupported") {
{{ "common.notSupported" | sqxTranslate }}
}
}
</div>
<div class="col-auto ps-2">
<button class="btn btn-text-danger" (click)="remove.emit()" type="button"><i class="icon-bin2"></i></button>
</div>
</div>
}

18
frontend/src/app/shared/components/search/queries/filter-comparison.component.scss

@ -1,18 +0,0 @@
@import 'mixins';
@import 'vars';
.path {
width: 10rem;
}
.operator {
width: 10rem;
}
.dropdown-user {
@include truncate;
.user-name {
margin-left: .25rem;
}
}

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

@ -1,109 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
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 { AppLanguageDto, ContributorsState, FilterableField, FilterComparison, FilterFieldUI, FilterNegation, getFilterUI, isNegation, QueryModel } from '@app/shared/internal';
import { UserDtoPicture } from '../../pipes';
import { ReferenceInputComponent } from '../../references/reference-input.component';
import { QueryPathComponent } from './query-path.component';
import { FilterOperatorPipe } from './query.pipes';
@Component({
selector: 'sqx-filter-comparison',
styleUrls: ['./filter-comparison.component.scss'],
templateUrl: './filter-comparison.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
DateTimeEditorComponent,
DropdownComponent,
FilterOperatorPipe,
FormsModule,
HighlightPipe,
QueryPathComponent,
ReferenceInputComponent,
TranslatePipe,
UserDtoPicture,
],
})
export class FilterComparisonComponent {
@Output()
public filterChange = new EventEmitter<FilterComparison | FilterNegation>();
@Output()
public remove = new EventEmitter();
@Input({ required: true })
public language!: AppLanguageDto;
@Input({ required: true })
public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ required: true })
public model!: QueryModel;
@Input({ required: true })
public filter!: FilterComparison | FilterNegation;
public actualComparison!: FilterComparison;
public actualNegated = false;
public field?: FilterableField;
public fieldUI?: FilterFieldUI;
public operators: ReadonlyArray<string> = [];
constructor(
public readonly contributorsState: ContributorsState,
) {
}
public ngOnChanges() {
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.change({ value });
}
public changeOp(op: string) {
this.change({ op });
}
public changePath(path: string) {
this.change({ path, value: null });
}
private change(update: Partial<FilterComparison>) {
this.emitChange({ ...this.actualComparison, ...update }, this.actualNegated);
}
public toggleNot() {
this.emitChange(this.actualComparison, !this.actualNegated);
}
private emitChange(filter: FilterComparison, not: boolean) {
this.filterChange.emit(not ? { not: filter } : filter);
}
}

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

@ -1,47 +0,0 @@
<div class="group">
<div class="row g-0">
<div class="col">
<div class="btn-group">
<button class="btn btn-secondary btn-toggle btn-code" [class.btn-primary]="isAnd" (click)="toggleType()" [disabled]="isAnd" type="button">
AND
</button>
<button class="btn btn-secondary btn-toggle btn-code" [class.btn-primary]="isOr" (click)="toggleType()" [disabled]="isOr" type="button">
OR
</button>
</div>
</div>
@if (!isRoot) {
<div class="col-auto ps-2">
<button class="btn btn-text-danger" (click)="remove.emit()" type="button"><i class="icon-bin2"></i></button>
</div>
}
</div>
<div class="filters">
<span class="filter-line-v"></span>
@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)="replaceNode(i, $event)"
[language]="language"
[languages]="languages"
[level]="level + 1"
[model]="model"
(remove)="removeNode(i)" />
</div>
}
<div class="filter filter-add mt-3">
<span class="filter-line-h"></span>
<button class="btn btn-outline-success btn-sm me-2" (click)="addComparison()">
{{ "search.addFilter" | sqxTranslate }}
</button>
@if (level < 1) {
<button class="btn btn-outline-success btn-sm" (click)="addLogical()">{{ "search.addGroup" | sqxTranslate }}</button>
}
</div>
</div>
</div>

33
frontend/src/app/shared/components/search/queries/filter-logical.component.scss

@ -1,33 +0,0 @@
@import 'mixins';
@import 'vars';
$field-line: #e1e8ef;
.filters {
padding-left: 3rem;
position: relative;
}
.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;
}
}

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

@ -1,124 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output } from '@angular/core';
import { TranslatePipe } from '@app/framework';
import { AppLanguageDto, FilterLogical, FilterNode, isLogicalAnd, isLogicalOr, QueryModel } from '@app/shared/internal';
import { FilterNodeComponent } from './filter-node.component';
@Component({
selector: 'sqx-filter-logical',
styleUrls: ['./filter-logical.component.scss'],
templateUrl: './filter-logical.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FilterNodeComponent,
TranslatePipe,
],
})
export class FilterLogicalComponent {
@Output()
public filterChange = new EventEmitter<FilterLogical>();
@Output()
public remove = new EventEmitter();
@Input({ required: true })
public language!: AppLanguageDto;
@Input({ required: true })
public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: numberAttribute })
public level = 0;
@Input({ transform: booleanAttribute })
public isRoot?: boolean | null;
@Input({ required: true })
public model!: QueryModel;
@Input({ required: true })
public filter!: FilterLogical;
public get filters() {
return isLogicalAnd(this.filter) ? this.filter.and : this.filter.or;
}
public get isAnd() {
return isLogicalAnd(this.filter);
}
public get isOr() {
return isLogicalOr(this.filter);
}
public addComparison() {
this.addNode({ path: this.model.schema.fields[0].path } as any);
}
public addLogical() {
this.addNode({ and: [] });
}
public addNode(node: FilterNode) {
const filter = this.filter;
let change: FilterLogical;
if (isLogicalAnd(filter)) {
change = { and: [...filter.and, node] };
} else {
change = { or: [...filter.or, node] };
}
this.emitChange(change);
}
public removeNode(index: number) {
const filter = this.filter;
let change: FilterLogical;
if (isLogicalAnd(filter)) {
change = { and: filter.and.filter((_, i) => i !== index) };
} else {
change = { or: filter.or.filter((_, i) => i !== index) };
}
this.emitChange(change);
}
public replaceNode(index: number, node: FilterNode) {
const filter = this.filter;
let change: FilterLogical;
if (isLogicalAnd(filter)) {
change = { and: filter.and.map((x, i) => i === index ? node : x) };
} else {
change = { or: filter.or.map((x, i) => i === index ? node : x) };
}
this.emitChange(change);
}
public toggleType() {
const filter = this.filter;
let change: FilterLogical;
if (isLogicalAnd(filter)) {
change = { or: filter.and };
} else {
change = { and: filter.or };
}
this.emitChange(change);
}
private emitChange(filter: FilterLogical) {
this.filterChange.emit(filter);
}
}

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

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

2
frontend/src/app/shared/components/search/queries/filter-node.component.scss

@ -1,2 +0,0 @@
@import 'mixins';
@import 'vars';

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

@ -1,54 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, forwardRef, Input, numberAttribute, Output } from '@angular/core';
import { AppLanguageDto, FilterComparison, FilterLogical, FilterNegation, FilterNode, isLogical, QueryModel } from '@app/shared/internal';
import { FilterComparisonComponent } from './filter-comparison.component';
import { FilterLogicalComponent } from './filter-logical.component';
@Component({
selector: 'sqx-filter-node',
styleUrls: ['./filter-node.component.scss'],
templateUrl: './filter-node.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
forwardRef(() => FilterComparisonComponent),
forwardRef(() => FilterLogicalComponent),
],
})
export class FilterNodeComponent {
@Output()
public filterChange = new EventEmitter();
@Output()
public remove = new EventEmitter();
@Input({ required: true })
public language!: AppLanguageDto;
@Input({ required: true })
public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: numberAttribute })
public level = 0;
@Input({ required: true })
public model!: QueryModel;
@Input()
public set filter(value: FilterNode) {
if (isLogical(value)) {
this.actualLogical = value;
} else {
this.actualComparison = value;
}
}
public actualComparison?: FilterComparison | FilterNegation;
public actualLogical?: FilterLogical;
}

21
frontend/src/app/shared/components/search/queries/query-path.component.html

@ -1,21 +0,0 @@
<sqx-dropdown
[disabled]="model.schema.fields.length === 0"
[dropdownStyles]="{ maxWidth: '40rem' }"
[items]="model.schema.fields"
itemSeparator="true"
[ngModel]="field"
(ngModelChange)="pathChange.emit($event?.path)"
searchProperty="path">
<ng-template let-field="$implicit">
<div class="row">
<div class="col-auto">
<div class="badge rounded-pill badge-primary">{{ field.path }}</div>
</div>
<div class="col text-end">
<sqx-form-hint>{{ field.description }}</sqx-form-hint>
</div>
</div>
</ng-template>
<ng-template let-field="$implicit"> {{ field.path }} </ng-template>
</sqx-dropdown>

2
frontend/src/app/shared/components/search/queries/query-path.component.scss

@ -1,2 +0,0 @@
@import 'mixins';
@import 'vars';

39
frontend/src/app/shared/components/search/queries/query-path.component.ts

@ -1,39 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DropdownComponent, FormHintComponent } from '@app/framework';
import { FilterableField, QueryModel } from '@app/shared/internal';
@Component({
selector: 'sqx-query-path',
styleUrls: ['./query-path.component.scss'],
templateUrl: './query-path.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DropdownComponent,
FormHintComponent,
FormsModule,
],
})
export class QueryPathComponent {
@Output()
public pathChange = new EventEmitter<string>();
@Input()
public path = '';
@Input({ required: true })
public model!: QueryModel;
public field?: FilterableField;
public ngOnChanges() {
this.field = this.model.schema.fields.find(x => x.path === this.path);
}
}

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

@ -1,15 +0,0 @@
<sqx-filter-logical
[filter]="actualQuery.filter"
(filterChange)="changeFilter($event)"
isRoot="true"
[language]="language"
[languages]="languages"
[model]="model" />
<h4 class="mt-4">{{ "search.sorting" | sqxTranslate }}</h4>
@for (sorting of actualQuery.sort; track sorting; let i = $index) {
<div class="mb-2">
<sqx-sorting [model]="model" (remove)="removeSorting(i)" [sorting]="sorting" (sortingChange)="replaceSorting(i, $event)" />
</div>
}
<button class="btn btn-outline-success btn-sm me-2" (click)="addSorting()">{{ "search.addSorting" | sqxTranslate }}</button>

2
frontend/src/app/shared/components/search/queries/query.component.scss

@ -1,2 +0,0 @@
@import 'mixins';
@import 'vars';

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

@ -1,83 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '@app/framework';
import { AppLanguageDto, FilterLogical, Query, QueryModel, QuerySorting } from '@app/shared/internal';
import { FilterLogicalComponent } from './filter-logical.component';
import { SortingComponent } from './sorting.component';
@Component({
selector: 'sqx-query',
styleUrls: ['./query.component.scss'],
templateUrl: './query.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FilterLogicalComponent,
SortingComponent,
TranslatePipe,
],
})
export class QueryComponent {
@Output()
public queryChange = new EventEmitter<Query>();
@Input({ required: true })
public language!: AppLanguageDto;
@Input({ required: true })
public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ required: true })
public model!: QueryModel;
@Input()
public set query(query: Query | undefined | null) {
if (!query) {
query = {};
}
if (!query.filter) {
query = { ...query, filter: { and: [] } };
}
if (!query.sort) {
query = { ...query, sort: [] };
}
this.actualQuery = query as any;
}
public actualQuery!: RequireKeys<Query, 'sort' | 'filter'>;
public addSorting() {
const path = Object.keys(this.model.schema.fields)[0];
this.change({ sort: [...this.actualQuery.sort, { path, order: 'ascending' }] });
}
public replaceSorting(index: number, sorting: QuerySorting) {
this.change({ sort: this.actualQuery.sort.map((x, i) => i === index ? sorting : x) });
}
public removeSorting(index: number) {
this.change({ sort: this.actualQuery.sort.filter((_, i) => i !== index) });
}
public changeFilter(filter: FilterLogical ) {
this.change({ filter });
}
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>;

22
frontend/src/app/shared/components/search/queries/query.pipes.ts

@ -1,22 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'sqxFilterOperator',
pure: true,
})
export class FilterOperatorPipe implements PipeTransform {
public transform(value: any) {
if (!value) {
return null;
}
return `common.queryOperators.${value}`;
}
}

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

@ -1,15 +0,0 @@
<div class="row g-1">
<div class="col"><sqx-query-path [model]="model" [path]="sorting.path" (pathChange)="changePath($event)" /></div>
<div class="col-auto">
<select class="form-select ms-2" [ngModel]="sorting.order" (ngModelChange)="changeOrder($event)">
@for (mode of modes; track mode) {
<option [ngValue]="mode">{{ mode }}</option>
}
</select>
</div>
<div class="col-auto">
<button class="btn btn-text-danger" (click)="remove.emit()" type="button"><i class="icon-bin2"></i></button>
</div>
</div>

2
frontend/src/app/shared/components/search/queries/sorting.component.scss

@ -1,2 +0,0 @@
@import 'mixins';
@import 'vars';

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

@ -1,52 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { QueryModel, QuerySorting, SORT_MODES } from '@app/shared/internal';
import { QueryPathComponent } from './query-path.component';
@Component({
selector: 'sqx-sorting',
styleUrls: ['./sorting.component.scss'],
templateUrl: './sorting.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
QueryPathComponent,
],
})
export class SortingComponent {
public readonly modes = SORT_MODES;
@Output()
public sortingChange = new EventEmitter<QuerySorting>();
@Output()
public remove = new EventEmitter();
@Input({ required: true })
public model!: QueryModel;
@Input({ required: true })
public sorting!: QuerySorting;
public changeOrder(order: any) {
this.change({ order });
}
public changePath(path: string) {
this.change({ path });
}
private change(update: Partial<QuerySorting>) {
this.sorting = { ...this.sorting, ...update };
this.sortingChange.emit(this.sorting);
}
}

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

@ -2,94 +2,71 @@
<sqx-shortcut keys="CTRL + I" (trigger)="inputFind.focus()" />
}
<form [class]="formClass" (ngSubmit)="search()">
<div class="input-group">
<input
class="form-control"
#inputFind
hintAfter="120000"
hintPosition="bottom-end"
hintText="i18n:search.fullTextTour"
[ngModel]="query?.fullText"
(ngModelChange)="changeQueryFullText($event)"
[ngModelOptions]="{ standalone: true }"
[placeholder]="placeholder"
shortcut="CTRL + I"
shortcutAction="none"
sqxTourStep="search" />
@if (queries) {
@if (saveKey | async; as key) {
<button class="btn btn-outline-secondary" (click)="queries.remove(key)" type="button">
<i class="icon-star-full"></i>
</button>
} @else {
<button class="btn btn-outline-secondary" (click)="saveQuery()" type="button"><i class="icon-star-empty"></i></button>
<div hintAfter="60000" hintPosition="bottom-end" hintText="i18n:search.advancedTour" sqxTourStep="search">
<filter-input
#inputFind
[isBookmarked]="!!(saveKey | async)"
(isBookmarkedChange)="bookmark($event)"
[model]="filterModel"
[options]="options"
[query]="cleanedQuery"
(queryChange)="cleanedQuery = $event"
(search)="queryChange.emit($event)"
[valueTemplate]="valueTemplate" />
<ng-template #valueTemplate let-field="field" let-grid="grid" let-onChange="onChange" let-value="value">
@switch (field.args.editor) {
@case ("Date") {
<sqx-date-time-editor hideDateButtons="true" mode="Date" [ngModel]="value" (ngModelChange)="onChange($event)" />
}
}
@if (queryModel) {
<button
class="btn btn-outline-secondary"
(click)="searchDialog.toggle()"
hintAfter="60000"
hintPosition="bottom-end"
hintText="i18n:search.advancedTour"
sqxTourStep="search"
type="button">
<i [class.icon-filter]="!hasFilter" [class.icon-filter-filled]="hasFilter"></i>
</button>
}
</div>
</form>
<sqx-modal-dialog (dialogClose)="searchDialog.hide()" size="lg" *sqxModal="searchDialog">
<ng-container title> {{ "search.customQuery" | sqxTranslate }} </ng-container>
<ng-container content>
@if (queries && queriesTypes) {
<div class="buttons2">
@if (saveKey | async; as key) {
<a class="btn" (click)="queries.remove(key)"> <i class="icon-star-full"></i> </a>
} @else {
<a class="btn" (click)="saveQuery()"> <i class="icon-star-empty"></i> </a>
}
@case ("DateTime") {
<sqx-date-time-editor hideDateButtons="true" mode="DateTime" [ngModel]="value" (ngModelChange)="onChange($event)" />
}
<div class="btn-group ms-2">
<button
class="btn btn-sm btn-secondary btn-toggle"
[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(false)"
[disabled]="!showQueries"
type="button">
{{ "common.bookmarks" | sqxTranslate }}
</button>
</div>
</div>
}
@case ("Reference") {
<sqx-reference-input
[language]="language"
[languages]="languages"
mode="Single"
[ngModel]="value"
(ngModelChange)="onChange($event)"
[query]="undefined"
[schemaIds]="field.args?.schemaIds" />
}
@if (showQueries) {
@if (queryModel) {
<div class="form-horizontal">
<sqx-query [language]="language" [languages]="languages" [model]="queryModel" [query]="query" (queryChange)="changeQuery($event)" />
<div class="link" [sqxMarkdown]="'search.help' | sqxTranslate"></div>
</div>
@case ("Status") {
<sqx-dropdown canSearch="false" [items]="field.args.statuses" [ngModel]="value" (ngModelChange)="onChange($event)" valueProperty="status">
<ng-template let-status="$implicit"> <i class="icon-circle" [style.color]="status.color"></i> {{ status.status }} </ng-template>
</sqx-dropdown>
}
@case ("User") {
@if (contributorsState.isLoaded | async) {
<sqx-dropdown
[items]="contributorsState.contributors | async"
[ngModel]="value"
(ngModelChange)="onChange($event)"
searchProperty="contributorName"
valueProperty="token">
<ng-template let-context="context" let-user="$implicit">
<span class="dropdown-user">
<img class="user-picture" [src]="user | sqxUserDtoPicture" />
<span class="user-name" [innerHTML]="user.contributorName | sqxHighlight: context"></span>
</span>
</ng-template>
<ng-template let-user="$implicit">
<span class="user-name">{{ user.contributorName }}</span>
</ng-template>
</sqx-dropdown>
} @else {
<input class="form-control" [ngModel]="value" (ngModelChange)="onChange($event)" />
}
}
} @else if (queries && queriesTypes) {
<div class="mt-4">
<sqx-shared-queries [queries]="queries" [queryUsed]="query" (search)="changeQuery($event)" [types]="queriesTypes" />
</div>
}
</ng-container>
<ng-container footer>
<span></span> <button class="btn btn-primary" (click)="search(true)" type="button">{{ "common.submit" | sqxTranslate }}</button>
</ng-container>
</sqx-modal-dialog>
</ng-template>
</div>
<ng-container *sqxModal="saveQueryDialog">
<form [formGroup]="saveQueryForm.form" (ngSubmit)="saveQueryComplete()">
<sqx-modal-dialog (dialogClose)="saveQueryDialog.hide()">

94
frontend/src/app/shared/components/search/search-form.component.scss

@ -1,32 +1,70 @@
@import 'mixins';
@import 'vars';
.search-form {
display: inline-block;
}
.form-inline {
position: relative;
}
.form-horizontal {
min-width: 35rem;
}
.buttons {
@include absolute(8px, 8px);
}
.buttons2 {
@include absolute(.75rem, 1.75rem);
}
.link {
font-size: $font-small;
font-weight: normal;
margin-top: 1.5rem;
}
.col-form-label {
text-align: left;
::ng-deep {
.nf-details {
background-color: $color-white;
input, select, button {
height: auto;
}
}
.nf {
svg {
fill: $color-text-decent;
}
.btn-success {
svg {
fill: $color-white;
}
}
}
.nf-dropdown {
.nf-dropdown-description {
color: $color-text-decent
}
.control-dropdown-item.active {
.nf-dropdown-description {
color: $color-white;
}
}
}
.nf-button {
&.btn-sm {
padding-top: .25rem;
padding-bottom: .25rem;
}
}
.nf-comparison-inline {
font-size: $font-small;
* {
font-size: .9rem;
}
input {
box-shadow: none;
}
.btn,
.form-control,
.form-select {
background: none !important;
border: 0;
border-radius: 0;
height: calc(1.75rem - 2px);
padding-top: 0.1875rem;
padding-bottom: .25rem;
}
}
.dropdown-user {
margin-right: .5rem;
}
}

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

@ -6,14 +6,15 @@
*/
import { AsyncPipe } from '@angular/common';
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, Output, Type } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BooleanValue, BootstrapClasses, EMPTY_FILTER_MODEL, FieldComponent, FilterField, Input as FilterInput, FilterModel, FilterOptions, NumberValue, StringValue } from 'ngx-inline-filter';
import { Observable } from 'rxjs';
import { ControlErrorsComponent, FocusOnInitDirective, MarkdownDirective, ModalDialogComponent, ModalDirective, ShortcutComponent, ShortcutDirective, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/framework';
import { AppLanguageDto, DialogModel, equalsQuery, hasFilter, Queries, Query, QueryModel, SaveQueryForm, TypedSimpleChanges } from '@app/shared/internal';
import { ControlErrorsComponent, DateTimeEditorComponent, DropdownComponent, FocusOnInitDirective, HighlightPipe, LocalizerService, ModalDialogComponent, ModalDirective, ShortcutComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/framework';
import { AppLanguageDto, ContributorsState, DialogModel, Queries, Query, QueryModel, sanitize, SaveQueryForm, TypedSimpleChanges } from '@app/shared/internal';
import { UserDtoPicture } from '../pipes';
import { ReferenceInputComponent } from '../references/reference-input.component';
import { TourHintDirective } from '../tour-hint.directive';
import { QueryComponent } from './queries/query.component';
import { SavedQueriesComponent } from './shared-queries.component';
@Component({
selector: 'sqx-search-form',
@ -23,25 +24,25 @@ import { SavedQueriesComponent } from './shared-queries.component';
imports: [
AsyncPipe,
ControlErrorsComponent,
DateTimeEditorComponent,
DropdownComponent,
FilterInput,
FocusOnInitDirective,
FormsModule,
MarkdownDirective,
HighlightPipe,
ModalDialogComponent,
ModalDirective,
QueryComponent,
ReferenceInputComponent,
ReactiveFormsModule,
SavedQueriesComponent,
ShortcutComponent,
ShortcutDirective,
TooltipDirective,
TourHintDirective,
TourStepDirective,
TranslatePipe,
UserDtoPicture,
],
})
export class SearchFormComponent {
private previousQuery?: Query | null;
@Output()
public queryChange = new EventEmitter<Query>();
@ -69,42 +70,135 @@ export class SearchFormComponent {
@Input({ transform: booleanAttribute })
public enableShortcut?: boolean | null;
@Input()
public formClass = 'form-inline search-form';
public showQueries = true;
public filterModel = EMPTY_FILTER_MODEL;
public saveKey!: Observable<string | undefined>;
public saveQueryDialog = new DialogModel();
public saveQueryForm = new SaveQueryForm();
public searchDialog = new DialogModel(false);
public hasFilter = false;
public cleanedQuery: Query = sanitize();
public readonly options: FilterOptions;
constructor(
public readonly contributorsState: ContributorsState,
private readonly localizer: LocalizerService,
) {
this.options = {
cssClasses: {
...BootstrapClasses,
buttonAdd: 'btn btn-success',
buttonAddOutline: 'btn btn-outline-success btn-sm',
buttonDefault: () => 'btn',
buttonLogical: active => `btn btn-sm btn-secondary btn-toggle ${active ? 'btn-primary' : ''}`,
dropdown: 'bg-white',
dropdownItem: active => `control-dropdown-item control-dropdown-item-selectable separated ${active ? 'active' : ''}`,
dropdownSearch: 'p-2',
},
texts: {
addComparison: localizer.getOrKey('search.addFilter'),
addGroup: localizer.getOrKey('search.addGroup'),
addSorting: localizer.getOrKey('search.addSorting'),
and: 'AND',
not: 'NOT',
noResults: ' - ',
or: 'OR',
save: localizer.getOrKey('common.save'),
searchPlaceholder: localizer.getOrKey('common.search'),
searchShortcut: 'CTRL + I',
sortAsc: 'ascending',
sortDesc: 'descending',
sorting: localizer.getOrKey('search.sorting'),
},
};
}
public ngOnChanges(changes: TypedSimpleChanges<this>) {
if (changes.query) {
this.cleanedQuery = sanitize(this.query);
}
if (changes.query || changes.queries) {
this.updateSaveKey();
}
if (changes.query) {
this.previousQuery = this.query;
if (changes.queryModel) {
this.updateFilterModel();
}
this.hasFilter = hasFilter(this.query);
}
public search(close = false) {
this.hasFilter = hasFilter(this.query);
private updateFilterModel() {
const model = this.queryModel;
if (!model) {
this.filterModel = EMPTY_FILTER_MODEL;
return;
}
const filterModel: FilterModel = { fields: [], operators: [] };
const uniqueOperators = new Set<string>();
for (var operators of Object.values(model.operators)) {
for (const operator of operators) {
uniqueOperators.add(operator);
}
}
for (const operator of uniqueOperators) {
const isEmpty =
operator === 'empty' ||
operator === 'exists';
const label = this.localizer.get(`common.queryOperators.${operator}`) || operator;
filterModel.operators.push({ value: operator, label, isEmpty });
}
if (this.query && !equalsQuery(this.query, this.previousQuery)) {
this.queryChange.emit(this.query);
for (const field of model.schema.fields) {
let args;
let component: Type<FieldComponent<any>> | undefined = undefined;
const { type, extra } = field.schema;
if (field.schema.type === 'Boolean') {
component = BooleanValue;
} else if (type === 'DateTime' && extra?.editor === 'Date') {
args = { editor: 'Date' };
} else if (type === 'DateTime') {
args = { editor: 'DateTime' };
} else if (type === 'Number') {
component = NumberValue;
} else if (type === 'String' && extra?.editor === 'Status') {
args = { editor: 'Status', statuses: model.statuses };
} else if (type === 'String' && extra?.editor === 'User') {
args = { editor: 'User' };
} else if (type === 'String' && !extra) {
component = StringValue;
} else if (type === 'StringArray' && extra?.schemaIds) {
args = { editor: 'Reference', schemaIds: extra.schemaIds };
} else if (type === 'StringArray') {
component = StringValue;
} else {
continue;
}
this.previousQuery = this.query;
const filterField: FilterField = {
args,
component,
defaultValue: null,
description: field.description,
label: field.path,
operators: model.operators[field.schema.type] as any,
path: field.path,
};
filterModel.fields.push(filterField);
}
if (close) {
this.searchDialog.hide();
this.filterModel = filterModel;
}
public bookmark(value: boolean) {
if (value) {
this.saveQueryDialog.show();
}
}
@ -131,22 +225,6 @@ export class SearchFormComponent {
this.saveQueryDialog.hide();
}
public changeQueryFullText(fullText: string) {
this.query = { ...this.query, fullText };
this.updateSaveKey();
}
public changeQuery(query: Query) {
this.query = query;
this.updateSaveKey();
}
public changeView(value: boolean) {
this.showQueries = value;
}
private updateSaveKey() {
if (this.queries && this.query) {
this.saveKey = this.queries.getSaveKey(this.query);

7
frontend/src/app/shared/components/table-header.component.ts

@ -7,8 +7,9 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { SortOrder } from 'ngx-inline-filter';
import { TranslatePipe } from '@app/framework';
import { AppLanguageDto, Query, SortMode, Types } from '@app/shared/internal';
import { AppLanguageDto, Query, Types } from '@app/shared/internal';
@Component({
selector: 'sqx-table-header',
@ -39,9 +40,9 @@ export class TableHeaderComponent {
public sortable?: boolean | null;
@Input()
public sortDefault: SortMode | undefined | null;
public sortDefault: SortOrder | undefined | null;
public order: SortMode | undefined | null;
public order: SortOrder | undefined | null;
public ngOnChanges() {
const { query, sortDefault, sortable, sortPath } = this;

7
frontend/src/app/shared/index.ts

@ -59,13 +59,6 @@ export * from './components/references/content-selector-item.component';
export * from './components/references/content-selector.component';
export * from './components/references/reference-input.component';
export * from './components/schema-category.component';
export * from './components/search/queries/filter-comparison.component';
export * from './components/search/queries/filter-logical.component';
export * from './components/search/queries/filter-node.component';
export * from './components/search/queries/query-path.component';
export * from './components/search/queries/query.component';
export * from './components/search/queries/query.pipes';
export * from './components/search/queries/sorting.component';
export * from './components/search/query-list.component';
export * from './components/search/search-form.component';
export * from './components/search/shared-queries.component';

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

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ComplexQuery } from 'ngx-inline-filter';
import { QueryParams, RouteSynchronizer, Types } from '@app/framework';
import { StatusInfo } from './contents.service';
@ -20,53 +21,6 @@ export type FilterSchemaType =
'String' |
'StringArray';
export type FilterFieldUI =
'Boolean' |
'Date' |
'DateTime' |
'Reference' |
'None' |
'Number' |
'Select' |
'String' |
'Status' |
'Unsupported' |
'User';
export function getFilterUI(comparison: FilterComparison, field: FilterableField): FilterFieldUI {
if (!field || !comparison) {
return 'None';
}
const { type, extra } = field.schema;
if (comparison.op === 'empty' || comparison.op === 'exists') {
return 'None';
} else if (type === 'Boolean') {
return 'Boolean';
} else if (type === 'DateTime' && extra?.editor === 'Date') {
return 'Date';
} else if (type === 'DateTime') {
return 'DateTime';
} else if (type === 'Number') {
return 'Number';
} else if (type === 'String' && extra?.editor === 'Status') {
return 'Status';
} else if (type === 'String' && extra?.editor === 'User') {
return 'User';
} else if (type === 'String' && extra?.options) {
return 'Select';
} else if (type === 'String') {
return 'String';
} else if (type === 'StringArray' && extra?.schemaIds) {
return 'Reference';
} else if (type === 'StringArray') {
return 'String';
} else {
return 'Unsupported';
}
}
export interface FilterSchema {
// The value type.
readonly type: FilterSchemaType;
@ -100,80 +54,7 @@ export interface QueryModel {
readonly operators: Readonly<{ [type: string]: ReadonlyArray<string> }>;
}
export type FilterNode = FilterComparison | FilterLogical | FilterNegation;
export type FilterLogical = FilterAnd | FilterOr;
export type FilterComparison = Readonly<{
// The full path to the property.
path: string;
// The operator.
op: string;
// The value.
value: any;
}>;
export type FilterNegation = Readonly<{
// The negated filter.
not: FilterComparison;
}>;
export type FilterAnd = Readonly<{
// The child filters if the logical filter is a conjunction (AND).
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 {
// The full path to the property.
path: string;
// The sort order.
order: SortMode;
}
export const SORT_MODES: ReadonlyArray<SortMode> = [
'ascending',
'descending',
];
export type SortMode = 'ascending' | 'descending';
export interface Query {
// The optional filter.
filter?: FilterLogical;
// The full text search.
fullText?: string;
// The sorting.
sort?: QuerySorting[];
export interface Query extends ComplexQuery {
// The number of items to take.
take?: number;
@ -181,13 +62,6 @@ export interface Query {
skip?: number;
}
const DEFAULT_QUERY = {
filter: {
and: [],
},
sort: [],
};
export class QueryFullTextSynchronizer implements RouteSynchronizer {
public static readonly INSTANCE = new QueryFullTextSynchronizer();
@ -242,7 +116,7 @@ export class QuerySynchronizer implements RouteSynchronizer {
export function sanitize(query?: Query | null) {
if (!query) {
return DEFAULT_QUERY;
return { filter: { and: [] }, sort: [] };
}
if (!query.sort) {

6
frontend/src/app/shell/pages/internal/internal-area.component.html

@ -6,7 +6,11 @@
<div class="navbar-nav"><sqx-apps-menu /></div>
<div class="navbar-nav align-items-center ms-auto">
<sqx-search-menu /> <sqx-chat-menu /> <sqx-asset-uploader /> <sqx-notifications-menu /> <sqx-profile-menu />
<sqx-search-menu />
<sqx-chat-menu />
<sqx-asset-uploader />
<sqx-notifications-menu />
<sqx-profile-menu />
</div>
</nav>
}

2
frontend/src/app/shell/pages/internal/teams-area.component.html

@ -1 +1 @@
<router-outlet></router-outlet>
<router-outlet />

3
frontend/src/app/theme/_bootstrap-vars.scss

@ -57,9 +57,6 @@ $btn-border-width: 1px;
$btn-white-space: nowrap;
// Alerts
$alert-color-scale: -100%;
$alert-bg-scale: 0%;
$alert-border-scale: 0%;
$alert-link-font-weight: normal;
// Labels

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

@ -460,10 +460,6 @@ a {
&.btn-warning {
@include build-toggle-button($color-theme-warning, $color-badge-warning-background);
}
&.btn-secondary {
color: $color-text-decent;
}
}
.btn + .btn-toggle {
@ -722,10 +718,10 @@ $icon-size: 4.5rem;
.form-control {
&[readonly] {
color: $input-disabled-color;
background-color: $input-disabled-bg;
border-color: $input-disabled-border-color;
border-radius: inherit;
border-radius: $border-radius;
color: $input-disabled-color;
opacity: 1;
}
}

6
frontend/src/app/theme/_common.scss

@ -119,6 +119,12 @@ hr {
font-weight: bold;
}
.dropdown-user {
.user-name {
margin-left: .5rem;
}
}
//
// Help texts
//

10
frontend/src/app/theme/_vars.scss

@ -89,6 +89,8 @@ $btn-disabled-color: inherit;
$btn-disabled-bg: inherit;
$btn-disabled-border-color: inherit;
$dropdown-bg: $color-white;
$modal-content-bg: $color-white;
$history-dot-size: 10px;
@ -103,4 +105,10 @@ $asset-image: 12rem;
$asset-footer: 7rem;
$asset-background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAIAAAC1nk4lAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjEuMWMqnEsAAACbSURBVGhD7c6hDQAxAMPA33+m7vYlJh7AoFKOBMbfyfyZRRsPgUUbD4FFGw+BRRsPgUUbD4FFGw+BRRsPgUUbD4FFGw+BRRsPgUUbD4FFGw+BRRsPgUUbD4E3o9kA7YFFGw+BRRsPgUUbD4FFGw+BRRsPgUUbD4FFGw+BRRsPgUUbD4FFGw+BRRsPgUUbD4FFGw+BRRsPgQejz7nPYYKl8IqSfgAAAABJRU5ErkJggg==');
$gutter-y: 0;
$gutter-y: 0;
$nf-color-border: $color-border;
$nf-color-add-background: $color-theme-success;
$nf-color-primary: $color-theme-brand;
$nf-color-text: $color-text;
$nf-color-text-muted: $color-text-decent;

3
frontend/src/app/theme/theme.scss

@ -12,6 +12,9 @@
// GraphiQL
@import 'graphiql/graphiql.css';
// Filter
@import 'ngx-inline-filter/styles/layout';
// Custom files
@import 'common';
@import 'panels2';

16
tools/e2e/tests/given-app/rules.spec.ts

@ -14,12 +14,19 @@ test('has header', async ({ page }) => {
});
test('create rule', async ({ rulesPage, rulePage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleName = await createRandomRule(rulesPage, rulePage, true);
const ruleCard = await rulesPage.getRuleCard(ruleName);
await expect(ruleCard.root).toBeVisible();
});
test('create disabled rule', async ({ rulesPage, rulePage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage, true);
const ruleCard = await rulesPage.getRuleCard(ruleName);
await expect(ruleCard.root.locator('sqx-toggle div').first()).toHaveAttribute('data-state', 'unchecked');
});
test('delete rule', async ({ rulesPage, rulePage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRuleCard(ruleName);
@ -79,7 +86,7 @@ test('rename rule', async ({ rulePage, rulesPage }) => {
await expect(newCard.root).toBeVisible();
});
async function createRandomRule(rulesPage: RulesPage, rulePage: RulePage) {
async function createRandomRule(rulesPage: RulesPage, rulePage: RulePage, disabled = false) {
const ruleName = `rule-${getRandomId()}`;
await rulesPage.addRule();
@ -93,6 +100,11 @@ async function createRandomRule(rulesPage: RulesPage, rulePage: RulePage) {
await stepDialog.add();
await rulePage.enterName(ruleName);
if (disabled) {
await rulePage.toggleEnabled();
}
await rulePage.save();
await rulePage.back();

4
tools/e2e/tests/given-schema/contents.spec.ts

@ -14,8 +14,8 @@ test.beforeEach(async ({ appName, schemaName, contentsPage }) => {
await contentsPage.increasePageSize();
});
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: 'Contents' });
test('has filter', async ({ page }) => {
const header = page.getByRole('button', { name: 'AND' });
await expect(header).toBeVisible();
});

10
tools/e2e/tests/pages/rule.ts

@ -26,9 +26,13 @@ export class RulePage {
return new StepDialog(this.page, this.page.getByTestId('dialog'));
}
public async toggleEnabled() {
await this.page.locator('sqx-toggle div').first().click();
}
public async save() {
await this.page.getByRole('button', { name: 'Save' }).click();
await this.page.getByText('Enabled').waitFor({ state: 'visible' });
await this.page.getByRole('alert').waitFor({ state: 'visible' });
}
public async back() {
@ -48,10 +52,12 @@ export class TriggerDialog {
public async add() {
await this.root.getByRole('button', { name: 'Add' }).click();
await this.root.waitFor({ state: 'detached' });
}
public async save() {
await this.root.getByRole('button', { name: 'Save' }).click();
await this.root.waitFor({ state: 'detached' });
}
}
@ -68,9 +74,11 @@ export class StepDialog {
public async add() {
await this.root.getByRole('button', { name: 'Add' }).click();
await this.root.waitFor({ state: 'detached' });
}
public async save() {
await this.root.getByRole('button', { name: 'Save' }).click();
await this.root.waitFor({ state: 'detached' });
}
}
Loading…
Cancel
Save