Browse Source

Improve queries (#1293)

* Expanded left panel.

* Fix panel label.

* a11y improvements

* Fix test

* Improve queries

* Fix tests

* More powerful queries.

* Query improvements.

* Formatting.

* Add initializer.

* Fix casting.
pull/1296/head
Sebastian Stehle 5 months ago
committed by GitHub
parent
commit
a8f257cb72
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentSqlQueryBuilder.cs
  2. 2
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs
  3. 1
      backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs
  4. 14
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs
  5. 26
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs
  6. 5
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs
  7. 14
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs
  8. 122
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs
  9. 25
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs
  10. 409
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql
  11. 120
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs
  12. 35
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs
  13. 330
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql
  14. 14
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs
  15. 153
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs
  16. 47
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs
  17. 313
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql
  18. 2
      backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs
  19. 8
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  20. 4
      backend/src/Squidex.Infrastructure/Queries/QueryModel.cs
  21. 42
      backend/src/Squidex.Web/Pipeline/SchemaResolver.cs
  22. 1438
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs
  23. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs
  24. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs
  25. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs
  26. 11
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs
  27. 315
      frontend/package-lock.json
  28. 5
      frontend/package.json
  29. 35
      frontend/refactor.js
  30. 4
      frontend/src/app/_theme.html
  31. 2
      frontend/src/app/features/api/pages/graphql/graphql-page.component.html
  32. 3
      frontend/src/app/features/apps/pages/app.component.html
  33. 8
      frontend/src/app/features/apps/pages/onboarding-dialog.component.html
  34. 3
      frontend/src/app/features/apps/pages/team.component.html
  35. 2
      frontend/src/app/features/assets/pages/asset-tag-dialog.component.html
  36. 6
      frontend/src/app/features/assets/pages/asset-tags.component.html
  37. 4
      frontend/src/app/features/content/pages/content/content-event.component.html
  38. 18
      frontend/src/app/features/content/pages/content/content-history-page.component.html
  39. 15
      frontend/src/app/features/content/pages/content/content-page.component.html
  40. 2
      frontend/src/app/features/content/pages/content/editor/content-editor.component.html
  41. 6
      frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.html
  42. 2
      frontend/src/app/features/content/shared/forms/array-editor.component.html
  43. 2
      frontend/src/app/features/content/shared/forms/component.component.html
  44. 2
      frontend/src/app/features/content/shared/forms/field-languages.component.html
  45. 2
      frontend/src/app/features/content/shared/forms/user-info-editor.component.html
  46. 7
      frontend/src/app/features/content/shared/list/content.component.html
  47. 2
      frontend/src/app/features/content/shared/preview-button.component.html
  48. 2
      frontend/src/app/features/content/shared/references/reference-item.component.html
  49. 4
      frontend/src/app/features/content/shared/references/references-tags.component.ts
  50. 7
      frontend/src/app/features/dashboard/pages/dashboard-config.component.html
  51. 3
      frontend/src/app/features/dashboard/pages/dashboard-page.component.html
  52. 2
      frontend/src/app/features/rules/pages/events/rule-event.component.html
  53. 14
      frontend/src/app/features/rules/pages/rule/branch.component.html
  54. 8
      frontend/src/app/features/rules/pages/rule/rule-page.component.html
  55. 18
      frontend/src/app/features/rules/pages/rules/rule.component.html
  56. 2
      frontend/src/app/features/rules/pages/rules/rules-page.component.html
  57. 5
      frontend/src/app/features/rules/pages/simulator/rule-transition.component.html
  58. 2
      frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html
  59. 10
      frontend/src/app/features/rules/shared/state-attempt.component.html
  60. 4
      frontend/src/app/features/rules/shared/state-details.component.html
  61. 4
      frontend/src/app/features/rules/shared/state-step.component.html
  62. 26
      frontend/src/app/features/schemas/pages/schema/common/schema-edit-form.component.html
  63. 10
      frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html
  64. 14
      frontend/src/app/features/schemas/pages/schema/fields/field.component.html
  65. 10
      frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html
  66. 7
      frontend/src/app/features/schemas/pages/schema/fields/types/rich-text-ui.component.html
  67. 5
      frontend/src/app/features/schemas/pages/schema/schema-page.component.html
  68. 2
      frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html
  69. 4
      frontend/src/app/features/schemas/pages/schema/ui/schema-ui-form.component.html
  70. 8
      frontend/src/app/features/schemas/pages/schemas/schema-form.component.html
  71. 2
      frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html
  72. 4
      frontend/src/app/features/settings/pages/clients/client-connect-form.component.html
  73. 2
      frontend/src/app/features/settings/pages/clients/client.component.html
  74. 6
      frontend/src/app/features/settings/pages/more/more-page.component.html
  75. 10
      frontend/src/app/features/settings/pages/plans/plan.component.html
  76. 5
      frontend/src/app/features/settings/pages/workflows/workflow-step.component.html
  77. 6
      frontend/src/app/features/settings/pages/workflows/workflow.component.html
  78. 2
      frontend/src/app/features/teams/pages/contributors/contributor-add-form.component.html
  79. 10
      frontend/src/app/features/teams/pages/plans/plan.component.html
  80. 9
      frontend/src/app/framework/angular/layout.component.html
  81. 6
      frontend/src/app/framework/angular/menu-item.component.html
  82. 8
      frontend/src/app/framework/angular/menu.component.html
  83. 7
      frontend/src/app/framework/angular/modals/dialog-renderer.component.html
  84. 2
      frontend/src/app/framework/angular/modals/modal-dialog.component.html
  85. 13
      frontend/src/app/framework/angular/modals/tour-template.component.html
  86. 4
      frontend/src/app/framework/angular/pager.component.html
  87. 20
      frontend/src/app/shared/components/assets/asset-dialog.component.html
  88. 5
      frontend/src/app/shared/components/assets/asset-folder.component.html
  89. 2
      frontend/src/app/shared/components/assets/asset-path.component.html
  90. 10
      frontend/src/app/shared/components/assets/asset.component.html
  91. 2
      frontend/src/app/shared/components/cards/api-calls-card.component.html
  92. 2
      frontend/src/app/shared/components/contents/content-value.component.html
  93. 2
      frontend/src/app/shared/components/references/content-selector.component.html
  94. 4
      frontend/src/app/shared/components/search/query-list.component.html
  95. 5
      frontend/src/app/shared/components/search/search-form.component.ts
  96. 2
      frontend/src/app/shared/components/table-header.component.html
  97. 5
      frontend/src/app/shared/components/tour-guide.component.html
  98. 1
      frontend/src/app/shared/model/custom.ts
  99. 1
      frontend/src/app/shared/model/generated.ts
  100. 12
      frontend/src/app/shell/pages/internal/profile-menu.component.html

4
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentSqlQueryBuilder.cs

@ -10,12 +10,12 @@ using Squidex.Text;
namespace Squidex.Domain.Apps.Entities.Contents;
public class ContentSqlQueryBuilder(SqlDialect dialect, string table, SqlParams? parameters = null) : SqlQueryBuilder(dialect, table, parameters)
public class ContentSqlQueryBuilder(SqlDialect dialect, string table, SqlParams? parameters = null)
: SqlQueryBuilder(dialect, table, parameters)
{
public override PropertyPath Visit(PropertyPath path)
{
var elements = path.ToList();
elements[0] = elements[0].ToPascalCase();
return new PropertyPath(elements);

2
backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs

@ -6,13 +6,11 @@
// ==========================================================================
using System.Linq.Expressions;
using Google.Protobuf;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using PhenX.EntityFrameworkCore.BulkInsert.Extensions;
using PhenX.EntityFrameworkCore.BulkInsert.Options;
using Squidex.Domain.Apps.Entities;

1
backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs

@ -7,7 +7,6 @@
using System.Collections.Concurrent;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Internal;
#pragma warning disable EF1001 // Internal EF Core API usage.

14
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs

@ -7,6 +7,7 @@
using System.Collections;
using System.Text;
using Microsoft.EntityFrameworkCore;
namespace Squidex.Infrastructure.Queries;
@ -19,6 +20,12 @@ public class SqlDialect
return false;
}
public virtual Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return Task.CompletedTask;
}
public virtual string BuildSelectStatement(SqlQuery request)
{
var sb = new StringBuilder("SELECT");
@ -144,7 +151,7 @@ public class SqlDialect
throw new NotSupportedException();
}
protected virtual string FormatValues(CompareOperator op, ClrValue value, SqlParams queryParameters)
protected virtual string FormatValues(CompareOperator op, ClrValue value, SqlParams queryParameters, bool withoutBraces = false)
{
if (!value.IsList && value.ValueType == ClrValueType.Null)
{
@ -170,6 +177,11 @@ public class SqlDialect
if (op == CompareOperator.In)
{
if (withoutBraces)
{
return string.Join(", ", parameters);
}
return $"({string.Join(", ", parameters)})";
}

26
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Hosting;
namespace Squidex.Infrastructure.Queries;
public sealed class SqlDialectInitializer<TContext>(IDbContextFactory<TContext> dbContextFactory)
: IInitializable where TContext : DbContext
{
public async Task InitializeAsync(CancellationToken ct)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(ct);
if (dbContext is not IDbContextWithDialect withDialect)
{
return;
}
await withDialect.Dialect.InitializeAsync(dbContext, ct);
}
}

5
backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs

@ -0,0 +1,5 @@
namespace Squidex.Providers.MySql.Content;
public static class JsonFunction
{
}

14
backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs

@ -17,7 +17,14 @@ internal static class Extensions
{
sb.Append('`');
sb.Append(path[0]);
sb.Append("`, \'$");
sb.Append("`, ");
sb.AppendJsonPropertyPath(path);
return sb;
}
public static StringBuilder AppendJsonPropertyPath(this StringBuilder sb, PropertyPath path)
{
sb.Append("\'$");
foreach (var property in path.Skip(1))
{
@ -38,6 +45,11 @@ internal static class Extensions
return sb;
}
public static string JsonSubPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonPropertyPath(path).ToString();
}
public static string JsonPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonPath(path).ToString();

122
backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs

@ -0,0 +1,122 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
namespace Squidex.Providers.MySql;
public static class JsonFunction
{
private const int TypeAny = 0;
private const int TypeNull = 1;
private const int TypeText = 2;
private const int TypeBoolean = 3;
private const int TypeNumber = 4;
private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new()
{
[(TypeAny, CompareOperator.Empty)] = "json_empty",
[(TypeAny, CompareOperator.Exists)] = "json_exists",
[(TypeNull, CompareOperator.Equals)] = "json_null_equals",
[(TypeNull, CompareOperator.NotEquals)] = "json_null_notequals",
[(TypeText, CompareOperator.Contains)] = "json_text_contains",
[(TypeText, CompareOperator.EndsWith)] = "json_text_endswith",
[(TypeText, CompareOperator.Equals)] = "json_text_equals",
[(TypeText, CompareOperator.GreaterThan)] = "json_text_greaterthan",
[(TypeText, CompareOperator.GreaterThanOrEqual)] = "json_text_greaterthanorequal",
[(TypeText, CompareOperator.In)] = "json_text_in",
[(TypeText, CompareOperator.LessThan)] = "json_text_lessthan",
[(TypeText, CompareOperator.LessThanOrEqual)] = "json_text_lessthanorequal",
[(TypeText, CompareOperator.Matchs)] = "json_text_matchs",
[(TypeText, CompareOperator.NotEquals)] = "json_text_notequals",
[(TypeText, CompareOperator.StartsWith)] = "json_text_startswith",
[(TypeBoolean, CompareOperator.Equals)] = "json_boolean_equals",
[(TypeBoolean, CompareOperator.In)] = "json_boolean_in",
[(TypeBoolean, CompareOperator.NotEquals)] = "json_boolean_notequals",
[(TypeNumber, CompareOperator.Equals)] = "json_number_equals",
[(TypeNumber, CompareOperator.GreaterThan)] = "json_number_greaterthan",
[(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "json_number_greaterthanorequal",
[(TypeNumber, CompareOperator.In)] = "json_number_in",
[(TypeNumber, CompareOperator.LessThan)] = "json_number_lessthan",
[(TypeNumber, CompareOperator.LessThanOrEqual)] = "json_number_lessthanorequal",
[(TypeNumber, CompareOperator.NotEquals)] = "json_number_notequals",
};
public static async Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
var sqlStream = typeof(MySqlDialect).Assembly.GetManifestResourceStream("Squidex.Providers.MySql.json_function.sql");
var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct);
sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal);
sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal);
var statements = sqlText.Split(";;", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// We want to filter out the drop statements and multiple function creations are not supported.
foreach (var statement in statements)
{
#if RELEASE
if (statement.StartsWith("DROP", StringComparison.Ordinal))
{
continue;
}
#endif
await dbContext.Database.ExecuteSqlRawAsync(statement, ct);
}
}
public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue)
{
var type = -1;
if (op is CompareOperator.Exists or CompareOperator.Empty)
{
type = TypeAny;
}
else
{
switch (value.ValueType)
{
case ClrValueType.Single:
case ClrValueType.Double:
case ClrValueType.Int32:
case ClrValueType.Int64:
type = TypeNumber;
break;
case ClrValueType.Instant:
case ClrValueType.Guid:
case ClrValueType.String:
type = TypeText;
break;
case ClrValueType.Boolean:
type = TypeBoolean;
break;
case ClrValueType.Null:
type = TypeNull;
break;
}
}
if (!Functions.TryGetValue((type, op), out var fn))
{
throw new NotSupportedException($"No jsonb function for type={value.ValueType}, operator={op}.");
}
if (type is TypeNull or TypeAny)
{
return $"{fn}(`{path[0]}`, {path.JsonSubPath()})";
}
var arg = formattedValue;
if (value.IsList)
{
arg = $"JSON_ARRAY({formattedValue})";
}
return $"{fn}(`{path[0]}`, {path.JsonSubPath()}, {arg})";
}
}

25
backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using Squidex.Infrastructure.Queries;
@ -18,6 +19,12 @@ public sealed class MySqlDialect : SqlDialect
{
}
public override Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return JsonFunction.InitializeAsync(dbContext, ct);
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is MySqlException ex && ex.Number == 1061;
@ -85,7 +92,13 @@ public sealed class MySqlDialect : SqlDialect
var sqlOrder = FormatOrder(order);
var sqlPath = path.JsonPath();
return $"IF(JSON_TYPE(JSON_EXTRACT({sqlPath})) IN ('INTEGER', 'DOUBLE', 'DECIMAL'), CAST(JSON_VALUE({sqlPath}) AS DOUBLE), NULL) {sqlOrder}, JSON_VALUE({sqlPath}) {sqlOrder}";
return $"""
IF (JSON_TYPE(JSON_EXTRACT({sqlPath})) IN ('INTEGER', 'DOUBLE', 'DECIMAL'),
CAST(JSON_VALUE({sqlPath}) AS DOUBLE),
NULL
) {sqlOrder},
JSON_VALUE({sqlPath}) {sqlOrder}
""";
}
return base.OrderBy(path, order, isJson);
@ -100,15 +113,7 @@ public sealed class MySqlDialect : SqlDialect
{
if (isJson)
{
var isBoolean = value.ValueType is ClrValueType.Boolean;
if (isBoolean)
{
var sqlPath = path.JsonPath();
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
return $"IF(JSON_VALUE({sqlPath}) = 'true', 1, 0) {sqlOp} {sqlRhs}";
}
return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true));
}
return base.Where(path, op, value, queryParameters, isJson);

409
backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql

@ -0,0 +1,409 @@
-- =============================================================================
-- TYPE-AGNOSTIC
-- =============================================================================
DROP FUNCTION IF EXISTS json_empty;;
CREATE FUNCTION json_empty(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF val IS NULL THEN RETURN 1; END IF;
IF JSON_TYPE(val) = 'NULL' THEN RETURN 1; END IF;
IF JSON_TYPE(val) = 'ARRAY' AND JSON_LENGTH(val) = 0 THEN RETURN 1; END IF;
IF JSON_TYPE(val) = 'STRING' AND JSON_UNQUOTE(val) = '' THEN RETURN 1; END IF;
RETURN 0;
END;;
DROP FUNCTION IF EXISTS json_exists;;
CREATE FUNCTION json_exists(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
RETURN NOT json_empty(col, path);
END;;
-- =============================================================================
-- NULL
-- =============================================================================
DROP FUNCTION IF EXISTS json_null_equals;;
CREATE FUNCTION json_null_equals(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE COALESCE(JSON_TYPE(jt.elem), 'NULL') = 'NULL'
);
END IF;
RETURN COALESCE(JSON_TYPE(val), 'NULL') = 'NULL';
END;;
DROP FUNCTION IF EXISTS json_null_notequals;;
CREATE FUNCTION json_null_notequals(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
RETURN NOT json_null_equals(col, path);
END;;
-- =============================================================================
-- TEXT
-- =============================================================================
DROP FUNCTION IF EXISTS json_text_equals;;
CREATE FUNCTION json_text_equals(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) = target
);
END IF;
RETURN JSON_UNQUOTE(val) = target;
END;;
DROP FUNCTION IF EXISTS json_text_notequals;;
CREATE FUNCTION json_text_notequals(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN NOT EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) = target
);
END IF;
RETURN JSON_UNQUOTE(val) != target;
END;;
DROP FUNCTION IF EXISTS json_text_lessthan;;
CREATE FUNCTION json_text_lessthan(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) < target
);
END IF;
RETURN JSON_UNQUOTE(val) < target;
END;;
DROP FUNCTION IF EXISTS json_text_lessthanorequal;;
CREATE FUNCTION json_text_lessthanorequal(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) <= target
);
END IF;
RETURN JSON_UNQUOTE(val) <= target;
END;;
DROP FUNCTION IF EXISTS json_text_greaterthan;;
CREATE FUNCTION json_text_greaterthan(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) > target
);
END IF;
RETURN JSON_UNQUOTE(val) > target;
END;;
DROP FUNCTION IF EXISTS json_text_greaterthanorequal;;
CREATE FUNCTION json_text_greaterthanorequal(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) >= target
);
END IF;
RETURN JSON_UNQUOTE(val) >= target;
END;;
DROP FUNCTION IF EXISTS json_text_contains;;
CREATE FUNCTION json_text_contains(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT('%', target, '%')
);
END IF;
RETURN JSON_UNQUOTE(val) LIKE CONCAT('%', target, '%');
END;;
DROP FUNCTION IF EXISTS json_text_startswith;;
CREATE FUNCTION json_text_startswith(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT(target, '%')
);
END IF;
RETURN JSON_UNQUOTE(val) LIKE CONCAT(target, '%');
END;;
DROP FUNCTION IF EXISTS json_text_endswith;;
CREATE FUNCTION json_text_endswith(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT('%', target)
);
END IF;
RETURN JSON_UNQUOTE(val) LIKE CONCAT('%', target);
END;;
DROP FUNCTION IF EXISTS json_text_matchs;;
CREATE FUNCTION json_text_matchs(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) REGEXP target
);
END IF;
RETURN JSON_UNQUOTE(val) REGEXP target;
END;;
DROP FUNCTION IF EXISTS json_text_in;;
CREATE FUNCTION json_text_in(col JSON, path VARCHAR(500), targets JSON)
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_CONTAINS(targets, jt.elem)
);
END IF;
RETURN JSON_CONTAINS(targets, val);
END;;
-- =============================================================================
-- NUMBER
-- =============================================================================
DROP FUNCTION IF EXISTS json_number_equals;;
CREATE FUNCTION json_number_equals(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END = target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) = target;
END;;
DROP FUNCTION IF EXISTS json_number_notequals;;
CREATE FUNCTION json_number_notequals(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN NOT EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END = target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) != target;
END;;
DROP FUNCTION IF EXISTS json_number_lessthan;;
CREATE FUNCTION json_number_lessthan(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END < target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) < target;
END;;
DROP FUNCTION IF EXISTS json_number_lessthanorequal;;
CREATE FUNCTION json_number_lessthanorequal(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END <= target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) <= target;
END;;
DROP FUNCTION IF EXISTS json_number_greaterthan;;
CREATE FUNCTION json_number_greaterthan(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END > target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) > target;
END;;
DROP FUNCTION IF EXISTS json_number_greaterthanorequal;;
CREATE FUNCTION json_number_greaterthanorequal(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END >= target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) >= target;
END;;
DROP FUNCTION IF EXISTS json_number_in;;
CREATE FUNCTION json_number_in(col JSON, path VARCHAR(500), targets JSON)
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
AND JSON_CONTAINS(targets, jt.elem)
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN JSON_CONTAINS(targets, val);
END;;
-- =============================================================================
-- BOOLEAN
-- =============================================================================
DROP FUNCTION IF EXISTS json_boolean_equals;;
CREATE FUNCTION json_boolean_equals(col JSON, path VARCHAR(500), target TINYINT(1))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
DECLARE target_json JSON;
SET val = JSON_EXTRACT(col, path);
SET target_json = IF(target, CAST('true' AS JSON), CAST('false' AS JSON));
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) = 'BOOLEAN' THEN jt.elem END = target_json
);
END IF;
IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF;
RETURN val = target_json;
END;;
DROP FUNCTION IF EXISTS json_boolean_notequals;;
CREATE FUNCTION json_boolean_notequals(col JSON, path VARCHAR(500), target TINYINT(1))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
DECLARE target_json JSON;
SET val = JSON_EXTRACT(col, path);
SET target_json = IF(target, CAST('true' AS JSON), CAST('false' AS JSON));
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN NOT EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) = 'BOOLEAN' THEN jt.elem END = target_json
);
END IF;
IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF;
RETURN val != target_json;
END;;
DROP FUNCTION IF EXISTS json_boolean_in;;
CREATE FUNCTION json_boolean_in(col JSON, path VARCHAR(500), targets JSON)
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_TYPE(jt.elem) = 'BOOLEAN'
AND EXISTS (
SELECT 1 FROM JSON_TABLE(targets, '$[*]' COLUMNS (t JSON PATH '$')) AS tt
WHERE IF(jt.elem = CAST('true' AS JSON), 1, 0) = CAST(JSON_UNQUOTE(tt.t) AS UNSIGNED)
)
);
END IF;
IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF;
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(targets, '$[*]' COLUMNS (t JSON PATH '$')) AS tt
WHERE IF(val = CAST('true' AS JSON), 1, 0) = CAST(JSON_UNQUOTE(tt.t) AS UNSIGNED)
);
END;;

120
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs

@ -0,0 +1,120 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres.App;
namespace Squidex.Providers.Postgres;
public static class JsonFunction
{
private const int TypeAny = 0;
private const int TypeNull = 1;
private const int TypeText = 2;
private const int TypeBoolean = 3;
private const int TypeNumber = 4;
private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new ()
{
[(TypeAny, CompareOperator.Empty)] = "jsonb_empty",
[(TypeAny, CompareOperator.Exists)] = "jsonb_exists",
[(TypeNull, CompareOperator.Equals)] = "jsonb_null_equals",
[(TypeNull, CompareOperator.NotEquals)] = "jsonb_null_notequals",
[(TypeText, CompareOperator.Contains)] = "jsonb_text_contains",
[(TypeText, CompareOperator.EndsWith)] = "jsonb_text_endswith",
[(TypeText, CompareOperator.Equals)] = "jsonb_text_equals",
[(TypeText, CompareOperator.GreaterThan)] = "jsonb_text_greaterthan",
[(TypeText, CompareOperator.GreaterThanOrEqual)] = "jsonb_text_greaterthanorequal",
[(TypeText, CompareOperator.In)] = "jsonb_text_in",
[(TypeText, CompareOperator.LessThan)] = "jsonb_text_lessthan",
[(TypeText, CompareOperator.LessThanOrEqual)] = "jsonb_text_lessthanorequal",
[(TypeText, CompareOperator.Matchs)] = "jsonb_text_matchs",
[(TypeText, CompareOperator.NotEquals)] = "jsonb_text_notequals",
[(TypeText, CompareOperator.StartsWith)] = "jsonb_text_startswith",
[(TypeBoolean, CompareOperator.Equals)] = "jsonb_boolean_equals",
[(TypeBoolean, CompareOperator.In)] = "jsonb_boolean_in",
[(TypeBoolean, CompareOperator.NotEquals)] = "jsonb_boolean_notequals",
[(TypeNumber, CompareOperator.Equals)] = "jsonb_number_equals",
[(TypeNumber, CompareOperator.GreaterThan)] = "jsonb_number_greaterthan",
[(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "jsonb_number_greaterthanorequal",
[(TypeNumber, CompareOperator.In)] = "jsonb_number_in",
[(TypeNumber, CompareOperator.LessThan)] = "jsonb_number_lessthan",
[(TypeNumber, CompareOperator.LessThanOrEqual)] = "jsonb_number_lessthanorequal",
[(TypeNumber, CompareOperator.NotEquals)] = "jsonb_number_notequals",
};
public static async Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
var sqlStream = typeof(PostgresDialect).Assembly.GetManifestResourceStream("Squidex.Providers.Postgres.json_function.sql");
var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct);
sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal);
sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal);
await dbContext.Database.ExecuteSqlRawAsync(sqlText, ct);
}
public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue)
{
var type = -1;
if (op is CompareOperator.Exists or CompareOperator.Empty)
{
type = TypeAny;
}
else
{
switch (value.ValueType)
{
case ClrValueType.Single:
case ClrValueType.Double:
case ClrValueType.Int32:
case ClrValueType.Int64:
type = TypeNumber;
break;
case ClrValueType.Instant:
case ClrValueType.Guid:
case ClrValueType.String:
type = TypeText;
break;
case ClrValueType.Boolean:
type = TypeBoolean;
break;
case ClrValueType.Null:
type = TypeNull;
break;
}
}
if (!Functions.TryGetValue((type, op), out var fn))
{
throw new NotSupportedException($"No jsonb function for type={value.ValueType}, operator={op}.");
}
if (type is TypeNull or TypeAny)
{
return $"{fn}({path.JsonPath(false)})";
}
var arg = formattedValue;
if (value.IsList && type == TypeNumber)
{
arg = $"ARRAY[{formattedValue}]::numeric[]";
}
else if (value.IsList)
{
arg = $"ARRAY[{formattedValue}]";
}
else if (type == TypeNumber)
{
arg = $"{formattedValue}::numeric";
}
return $"{fn}({path.JsonPath(false)}, {arg})";
}
}

35
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres.App;
@ -19,6 +20,12 @@ public class PostgresDialect : SqlDialect
{
}
public override Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return JsonFunction.InitializeAsync(dbContext, ct);
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is PostgresException ex && ex.SqlState == "42P07";
@ -71,27 +78,7 @@ public class PostgresDialect : SqlDialect
{
if (isJson)
{
var issNumeric = value.ValueType is
ClrValueType.Single or
ClrValueType.Double or
ClrValueType.Int32 or
ClrValueType.Int64;
if (issNumeric)
{
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
return $"(CASE WHEN jsonb_typeof({path.JsonPath(false)}) = 'number' THEN ({path.JsonPath(true)})::numeric {sqlOp} {sqlRhs} ELSE FALSE END)";
}
var isBoolean = value.ValueType is ClrValueType.Boolean;
if (isBoolean)
{
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
return $"(CASE WHEN jsonb_typeof({path.JsonPath(false)}) = 'boolean' THEN ({path.JsonPath(true)})::boolean {sqlOp} {sqlRhs} ELSE FALSE END)";
}
return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true));
}
return base.Where(path, op, value, queryParameters, isJson);
@ -100,12 +87,16 @@ public class PostgresDialect : SqlDialect
protected override string FormatField(PropertyPath path, bool isJson)
{
var baseField = path[0];
if (isJson && path.Count > 1)
{
return path.JsonPath(true);
}
if (baseField == "__element" || baseField.Contains("->", StringComparison.Ordinal))
{
return baseField;
}
return $"\"{baseField}\"";
}
}

330
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql

@ -0,0 +1,330 @@
-- =============================================================================
-- TYPE-AGNOSTIC
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_empty(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN val IS NULL
OR (jsonb_typeof(val) = 'null')
OR (jsonb_typeof(val) = 'array' AND jsonb_array_length(val) = 0)
OR (jsonb_typeof(val) = 'string' AND val #>> '{}' = '');
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_exists(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN NOT jsonb_empty(val);
END;
$$;
-- =============================================================================
-- TEXT
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_text_equals(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' = target
);
END IF;
RETURN val #>> '{}' = target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_notequals(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' = target
);
END IF;
RETURN val #>> '{}' != target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_lessthan(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' < target
);
END IF;
RETURN val #>> '{}' < target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_lessthanorequal(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' <= target
);
END IF;
RETURN val #>> '{}' <= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_greaterthan(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' > target
);
END IF;
RETURN val #>> '{}' > target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_greaterthanorequal(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' >= target
);
END IF;
RETURN val #>> '{}' >= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_contains(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' LIKE '%' || target || '%'
);
END IF;
RETURN val #>> '{}' LIKE '%' || target || '%';
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_startswith(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' LIKE target || '%'
);
END IF;
RETURN val #>> '{}' LIKE target || '%';
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_endswith(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' LIKE '%' || target
);
END IF;
RETURN val #>> '{}' LIKE '%' || target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_matchs(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' ~ target
);
END IF;
RETURN val #>> '{}' ~ target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_in(val jsonb, targets text[])
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' = ANY(targets)
);
END IF;
RETURN val #>> '{}' = ANY(targets);
END;
$$;
-- =============================================================================
-- NUMBER
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_number_equals(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric = target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_notequals(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric != target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_lessthan(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric < target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric < target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_lessthanorequal(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric <= target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric <= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_greaterthan(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric > target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric > target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_greaterthanorequal(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric >= target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric >= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_in(val jsonb, targets numeric[])
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = ANY(targets)
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric = ANY(targets);
END;
$$;
-- =============================================================================
-- NULL
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_null_equals(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'null'
);
END IF;
RETURN val IS NULL OR jsonb_typeof(val) = 'null';
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_null_notequals(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN NOT jsonb_null_equals(val);
END;
$$;
-- =============================================================================
-- BOOLEAN
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_boolean_equals(val jsonb, target boolean)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = target
);
END IF;
IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::boolean = target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_boolean_notequals(val jsonb, target boolean)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = target
);
END IF;
IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::boolean != target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_boolean_in(val jsonb, targets boolean[])
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = ANY(targets)
);
END IF;
IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::boolean = ANY(targets);
END;
$$;

14
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs

@ -17,7 +17,14 @@ internal static class Extensions
{
sb.Append('[');
sb.Append(path[0]);
sb.Append("], \'$");
sb.Append("], ");
sb.AppendJsonSubPath(path);
return sb;
}
public static StringBuilder AppendJsonSubPath(this StringBuilder sb, PropertyPath path)
{
sb.Append("\'$");
foreach (var property in path.Skip(1))
{
@ -38,6 +45,11 @@ internal static class Extensions
return sb;
}
public static string JsonSubPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonSubPath(path).ToString();
}
public static string JsonPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonPath(path).ToString();

153
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs

@ -0,0 +1,153 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
namespace Squidex.Providers.SqlServer;
public static class JsonFunction
{
private const int TypeAny = 0;
private const int TypeNull = 1;
private const int TypeText = 2;
private const int TypeBoolean = 3;
private const int TypeNumber = 4;
private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new ()
{
[(TypeAny, CompareOperator.Empty)] = "dbo.json_empty",
[(TypeAny, CompareOperator.Exists)] = "dbo.json_exists",
[(TypeNull, CompareOperator.Equals)] = "dbo.json_null_equals",
[(TypeNull, CompareOperator.NotEquals)] = "dbo.json_null_notequals",
[(TypeText, CompareOperator.Contains)] = "dbo.json_text_contains",
[(TypeText, CompareOperator.EndsWith)] = "dbo.json_text_endswith",
[(TypeText, CompareOperator.Equals)] = "dbo.json_text_equals",
[(TypeText, CompareOperator.GreaterThan)] = "dbo.json_text_greaterthan",
[(TypeText, CompareOperator.GreaterThanOrEqual)] = "dbo.json_text_greaterthanorequal",
[(TypeText, CompareOperator.LessThan)] = "dbo.json_text_lessthan",
[(TypeText, CompareOperator.LessThanOrEqual)] = "dbo.json_text_lessthanorequal",
[(TypeText, CompareOperator.Matchs)] = "dbo.json_text_matchs",
[(TypeText, CompareOperator.NotEquals)] = "dbo.json_text_notequals",
[(TypeText, CompareOperator.StartsWith)] = "dbo.json_text_startswith",
[(TypeBoolean, CompareOperator.Equals)] = "dbo.json_boolean_equals",
[(TypeBoolean, CompareOperator.NotEquals)] = "dbo.json_boolean_notequals",
[(TypeNumber, CompareOperator.Equals)] = "dbo.json_number_equals",
[(TypeNumber, CompareOperator.GreaterThan)] = "dbo.json_number_greaterthan",
[(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "dbo.json_number_greaterthanorequal",
[(TypeNumber, CompareOperator.LessThan)] = "dbo.json_number_lessthan",
[(TypeNumber, CompareOperator.LessThanOrEqual)] = "dbo.json_number_lessthanorequal",
[(TypeNumber, CompareOperator.NotEquals)] = "dbo.json_number_notequals",
};
public static async Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
var sqlStream = typeof(SqlServerDialect).Assembly.GetManifestResourceStream("Squidex.Providers.SqlServer.json_function.sql");
var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct);
sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal);
sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal);
var statements = sqlText.Split(";;", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// We want to filter out the drop statements and multiple function creations are not supported.
foreach (var statement in statements)
{
#if RELEASE
if (statement.StartsWith("DROP", StringComparison.Ordinal))
{
continue;
}
#endif
await dbContext.Database.ExecuteSqlRawAsync(statement, ct);
}
}
public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue)
{
var type = -1;
if (op is CompareOperator.Exists or CompareOperator.Empty)
{
type = TypeAny;
}
else
{
switch (value.ValueType)
{
case ClrValueType.Single:
case ClrValueType.Double:
case ClrValueType.Int32:
case ClrValueType.Int64:
type = TypeNumber;
break;
case ClrValueType.Instant:
case ClrValueType.Guid:
case ClrValueType.String:
type = TypeText;
break;
case ClrValueType.Boolean:
type = TypeBoolean;
break;
case ClrValueType.Null:
type = TypeNull;
break;
}
}
var jsonPath = path.JsonPath();
// SQL Server scalar functions cannot accept parameterized arrays, embedding
// SQL parameter references (@p0, @p1) inside a JSON string literal
// produces invalid JSON at execution time. IN is therefore handled inline so
// parameters remain proper SQL parameters.
if (value.IsList)
{
return type switch
{
TypeText =>
$"""
(CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL
THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 1 AND j.[value] IN ({formattedValue})) THEN 1 ELSE 0 END
ELSE CASE WHEN JSON_VALUE({jsonPath}) IN ({formattedValue}) THEN 1 ELSE 0 END
END) = 1
""",
TypeNumber =>
$"""
(CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL
THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 2 AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) IN ({formattedValue})) THEN 1 ELSE 0 END
ELSE CASE WHEN TRY_CAST(JSON_VALUE({jsonPath}) AS DECIMAL(38, 10)) IN ({formattedValue}) THEN 1 ELSE 0 END
END) = 1
""",
TypeBoolean =>
$"""
(CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL
THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 3 AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) IN ({formattedValue})) THEN 1 ELSE 0 END
ELSE CASE WHEN IIF(JSON_VALUE({jsonPath}) = 'true', 1, IIF(JSON_VALUE({jsonPath}) = 'false', 0, NULL)) IN ({formattedValue}) THEN 1 ELSE 0 END
END) = 1
""",
_ => throw new NotSupportedException($"No json function for type={type}, operator={op}."),
};
}
if (!Functions.TryGetValue((type, op), out var fn))
{
throw new NotSupportedException($"No json function for type={type}, operator={op}.");
}
if (type is TypeNull or TypeAny)
{
return $"{fn}({jsonPath}) = 1";
}
if (value.IsList)
{
return $"{fn}({jsonPath}, N'[{formattedValue}]') = 1";
}
return $"{fn}({jsonPath}, {formattedValue}) = 1";
}
}

47
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
using SqlException = Microsoft.Data.SqlClient.SqlException;
@ -18,6 +19,12 @@ public sealed class SqlServerDialect : SqlDialect
{
}
public override Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return JsonFunction.InitializeAsync(dbContext, ct);
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is SqlException ex && ex.Number is 1913 or 7642 or 7652;
@ -87,45 +94,7 @@ public sealed class SqlServerDialect : SqlDialect
{
if (isJson)
{
var issNumeric = value.ValueType is
ClrValueType.Single or
ClrValueType.Double or
ClrValueType.Int32 or
ClrValueType.Int64;
if (issNumeric)
{
var sqlPath = path.JsonPath();
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
return $"TRY_CAST(JSON_VALUE({sqlPath}) AS NUMERIC) {sqlOp} {sqlRhs}";
}
var isBoolean = value.ValueType is ClrValueType.Boolean;
if (isBoolean)
{
var sqlPath = path.JsonPath();
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
return $"IIF(JSON_VALUE({sqlPath}) = 'true', 1, 0) {sqlOp} {sqlRhs}";
}
var isNull = value.ValueType is ClrValueType.Null;
if (isNull)
{
var sqlPath = path.JsonPath();
if (op == CompareOperator.Equals)
{
return $"JSON_QUERY({sqlPath}) IS NULL AND JSON_VALUE({sqlPath}) IS NULL";
}
if (op == CompareOperator.NotEquals)
{
return $"JSON_QUERY({sqlPath}) IS NOT NULL OR JSON_VALUE({sqlPath}) IS NOT NULL";
}
}
return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true));
}
return base.Where(path, op, value, queryParameters, isJson);

313
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql

@ -0,0 +1,313 @@
-- =============================================================================
-- TYPE-AGNOSTIC
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_empty;;
CREATE FUNCTION dbo.json_empty(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
DECLARE @val NVARCHAR(MAX) = JSON_VALUE(@col, @path);
IF @query IS NOT NULL AND LEFT(LTRIM(@query), 1) = '['
RETURN CASE WHEN (SELECT COUNT(*) FROM OPENJSON(@query)) = 0 THEN 1 ELSE 0 END;
IF @query IS NOT NULL AND LEFT(LTRIM(@query), 1) = '{'
RETURN 0;
IF @val IS NULL RETURN 1;
IF @val = '' RETURN 1;
RETURN 0;
END;;
DROP FUNCTION IF EXISTS dbo.json_exists;;
CREATE FUNCTION dbo.json_exists(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
RETURN 1 - dbo.json_empty(@col, @path);
END;;
-- =============================================================================
-- NULL
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_null_equals;;
CREATE FUNCTION dbo.json_null_equals(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 0
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) IS NULL THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_null_notequals;;
CREATE FUNCTION dbo.json_null_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
RETURN 1 - dbo.json_null_equals(@col, @path);
END;;
-- =============================================================================
-- TEXT
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_text_equals;;
CREATE FUNCTION dbo.json_text_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) = @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_notequals;;
CREATE FUNCTION dbo.json_text_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN NOT EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) != @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_lessthan;;
CREATE FUNCTION dbo.json_text_lessthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] < @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) < @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_lessthanorequal;;
CREATE FUNCTION dbo.json_text_lessthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] <= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) <= @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_greaterthan;;
CREATE FUNCTION dbo.json_text_greaterthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] > @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) > @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_greaterthanorequal;;
CREATE FUNCTION dbo.json_text_greaterthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] >= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) >= @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_contains;;
CREATE FUNCTION dbo.json_text_contains(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE '%' + @target + '%'
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE '%' + @target + '%' THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_startswith;;
CREATE FUNCTION dbo.json_text_startswith(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE @target + '%'
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE @target + '%' THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_endswith;;
CREATE FUNCTION dbo.json_text_endswith(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE '%' + @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE '%' + @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_matchs;;
CREATE FUNCTION dbo.json_text_matchs(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE @target THEN 1 ELSE 0 END;
END;;
-- =============================================================================
-- NUMBER
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_number_equals;;
CREATE FUNCTION dbo.json_number_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) = @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_notequals;;
CREATE FUNCTION dbo.json_number_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN NOT EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) != @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_lessthan;;
CREATE FUNCTION dbo.json_number_lessthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) < @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) < @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_lessthanorequal;;
CREATE FUNCTION dbo.json_number_lessthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) <= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) <= @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_greaterthan;;
CREATE FUNCTION dbo.json_number_greaterthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) > @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) > @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_greaterthanorequal;;
CREATE FUNCTION dbo.json_number_greaterthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) >= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) >= @target THEN 1 ELSE 0 END;
END;;
-- =============================================================================
-- BOOLEAN
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_boolean_equals;;
CREATE FUNCTION dbo.json_boolean_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target BIT)
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 3
AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN IIF(JSON_VALUE(@col, @path) = 'true', 1, IIF(JSON_VALUE(@col, @path) = 'false', 0, NULL)) = @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_boolean_notequals;;
CREATE FUNCTION dbo.json_boolean_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target BIT)
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN NOT EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 3
AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN IIF(JSON_VALUE(@col, @path) = 'true', 1, IIF(JSON_VALUE(@col, @path) = 'false', 0, NULL)) != @target THEN 1 ELSE 0 END;
END;;

2
backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs

@ -45,6 +45,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.UsageTracking;
using Squidex.Messaging;
@ -260,6 +261,7 @@ public static class ServiceExtensions
.AddEntityFrameworkStore<TContext, CronJobContext>();
services.AddEntityFrameworkAssetKeyValueStore<TContext, TusMetadata>();
services.AddSingletonAs<SqlDialectInitializer<TContext>>();
}
public static void AddSquidexEntityFrameworkEventStore(this IServiceCollection services, IConfiguration config)

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

@ -10,6 +10,9 @@
<DebugType>full</DebugType>
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<None Remove="Providers\SqlServer\json_function.sql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />
<ProjectReference Include="..\Squidex.Domain.Users\Squidex.Domain.Users.csproj" />
@ -55,4 +58,9 @@
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Providers\MySql\json_function.sql" />
<EmbeddedResource Include="Providers\Postgres\json_function.sql" />
<EmbeddedResource Include="Providers\SqlServer\json_function.sql" />
</ItemGroup>
</Project>

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

@ -11,7 +11,7 @@ public sealed class QueryModel
{
public static readonly IReadOnlyDictionary<FilterSchemaType, IReadOnlyList<CompareOperator>> DefaultOperators = new Dictionary<FilterSchemaType, IReadOnlyList<CompareOperator>>
{
[FilterSchemaType.Any] = Enum.GetValues(typeof(CompareOperator)).OfType<CompareOperator>().ToList(),
[FilterSchemaType.Any] = Enum.GetValues<CompareOperator>().ToList(),
[FilterSchemaType.Boolean] =
[
CompareOperator.Equals,
@ -117,14 +117,12 @@ public sealed class QueryModel
public QueryModel Flatten(int maxDepth = 7, bool onlyWithOperators = true)
{
var predicate = (Predicate<FilterSchema>?)null;
if (onlyWithOperators)
{
predicate = x => Operators.TryGetValue(x.Type, out var operators) && operators.Count > 0;
}
var flatten = Schema.Flatten(maxDepth, predicate);
if (ReferenceEquals(flatten, Schema))
{
return this;

42
backend/src/Squidex.Web/Pipeline/SchemaResolver.cs

@ -31,35 +31,29 @@ public sealed class SchemaResolver : IAsyncActionFilter
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var app = context.HttpContext.Features.Get<App>();
if (app != null)
if (app != null && context.RouteData.Values.TryGetValue("schema", out var schemaValue))
{
if (context.RouteData.Values.TryGetValue("schema", out var schemaValue))
var schemaIdOrName = schemaValue?.ToString();
if (string.IsNullOrWhiteSpace(schemaIdOrName))
{
var schemaIdOrName = schemaValue?.ToString();
if (string.IsNullOrWhiteSpace(schemaIdOrName))
{
context.Result = new NotFoundResult();
return;
}
var schema = await GetSchemaAsync(app.Id, schemaIdOrName, context.HttpContext.User);
if (schema == null)
{
context.Result = new NotFoundResult();
return;
}
context.Result = new NotFoundResult();
return;
}
if (context.ActionDescriptor.EndpointMetadata.Any(x => x is SchemaMustBePublishedAttribute) && !schema.IsPublished)
{
context.Result = new NotFoundResult();
return;
}
var schema = await GetSchemaAsync(app.Id, schemaIdOrName, context.HttpContext.User);
if (schema == null)
{
context.Result = new NotFoundResult();
return;
}
context.HttpContext.Features.Set(schema);
if (context.ActionDescriptor.EndpointMetadata.Any(x => x is SchemaMustBePublishedAttribute) && !schema.IsPublished)
{
context.Result = new NotFoundResult();
return;
}
context.HttpContext.Features.Set(schema);
}
await next();

1438
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs

File diff suppressed because it is too large

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.MySql;
using Squidex.Providers.MySql.Content;
using Testcontainers.MySql;
@ -63,6 +64,7 @@ public class MySqlFixture(string? reuseId = null) : IAsyncLifetime, ISqlContentF
.AddSingleton<ConnectionStringParser, MySqlConnectionStringParser>()
.AddSingletonAs<DatabaseCreator<TestDbContextMySql>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<IInitializable, SqlDialectInitializer<TestDbContextMySql>>()
.BuildServiceProvider();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres;
using Squidex.Providers.Postgres.Content;
using Testcontainers.PostgreSql;
@ -59,6 +60,7 @@ public class PostgresFixture(string? reuseId) : IAsyncLifetime, ISqlContentFixtu
.AddSingleton<ConnectionStringParser, PostgresConnectionStringParser>()
.AddSingletonAs<DatabaseCreator<TestDbContextPostgres>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<IInitializable, SqlDialectInitializer<TestDbContextPostgres>>()
.BuildServiceProvider();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs

@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.SqlServer;
using Squidex.Providers.SqlServer.Content;
using Testcontainers.MsSql;
@ -61,6 +62,7 @@ public class SqlServerFixture(string? reuseId = null) : IAsyncLifetime, ISqlCont
.AddSingleton<ConnectionStringParser, SqlServerConnectionStringParser>()
.AddSingletonAs<DatabaseCreator<TestDbContextSqlServer>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<IInitializable, SqlDialectInitializer<TestDbContextSqlServer>>()
.BuildServiceProvider();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())

11
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NetTopologySuite.Geometries;
#pragma warning disable MA0048 // File name must match type name
@ -40,13 +41,21 @@ public class TestJson
public long? NumberOrNull { get; set; }
public long[] NumberArray { get; set; }
public string Text { get; set; }
public string? TextOrNull { get; set; }
public string[] TextArray { get; set; }
public bool Boolean { get; set; }
public bool? BooleanOrNull { get; set; }
public bool[] BooleanArray { get; set; }
public object? Mixed { get; set; }
public long[] Array { get; set; }
public object?[] MixedArray { get; set; }
}

315
frontend/package-lock.json

File diff suppressed because it is too large

5
frontend/package.json

@ -12,7 +12,8 @@
"watch": "ng build --watch --configuration development",
"storybook": "ng run squidex:storybook",
"storybook-build": "ng run squidex:build-storybook",
"generate": "dotnet run --project generator/Generator/Generator.csproj"
"generate": "dotnet run --project generator/Generator/Generator.csproj",
"format": "prettier --write **/*.html"
},
"private": true,
"dependencies": {
@ -49,7 +50,7 @@
"mousetrap": "1.6.5",
"ng2-charts": "^8.0.0",
"ngx-doc-viewer": "15.0.1",
"ngx-inline-filter": "^0.2.5",
"ngx-inline-filter": "^0.3.0",
"ngx-scrollbar": "^19.1.4",
"ngx-ui-tour-core": "16.0.0",
"oidc-client-ts": "^3.4.1",

35
frontend/refactor.js

@ -0,0 +1,35 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT_DIR = ".";
const SKIP_DIRS = ["node_modules", ".git", "dist", "build", ".cache"];
const BUTTON_TAG_RE = /<button(\s[^>]*?)?>/gi;
function fixButtons(html) {
return html.replace(BUTTON_TAG_RE, (match, attrs) => {
if (attrs && /\btype\s*=/i.test(attrs)) return match;
return attrs ? `<button type="button"${attrs}>` : `<button type="button">`;
});
}
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.includes(entry.name)) walk(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".component.html")) {
const original = fs.readFileSync(fullPath, "utf8");
const fixed = fixButtons(original);
if (fixed !== original) {
fs.writeFileSync(fullPath, fixed, "utf8");
console.log(`Fixed: ${fullPath}`);
}
}
}
}
walk(path.resolve(ROOT_DIR));

4
frontend/src/app/_theme.html

@ -1071,8 +1071,8 @@
<div class="bs-component" style="margin-bottom: 40px">
<span class="badge badge-primary">Primary</span> <span class="badge badge-secondary">Secondary</span>
<span class="badge badge-success">Success</span> <span class="badge badge-danger">Danger</span>
<span class="badge badge-warning">Warning</span> <span class="badge badge-info">Info</span>
<span class="badge badge-light">Light</span> <span class="badge badge-dark">Dark</span>
<span class="badge badge-warning">Warning</span> <span class="badge badge-info">Info</span> <span class="badge badge-light">Light</span>
<span class="badge badge-dark">Dark</span>
</div>
<div class="bs-component">

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

@ -3,7 +3,7 @@
<div #graphiQLContainer inner sqxTourStep="graphQLExplorer"></div>
@if (clientsReadable) {
<button class="btn btn-simple btn-options" (click)="clientsDialog.show()"><i class="icon-clients"></i></button>
<button class="btn btn-simple btn-options" (click)="clientsDialog.show()" type="button"><i class="icon-clients"></i></button>
}
</sqx-layout>
<sqx-modal-dialog (dialogClose)="clientsDialog.hide()" *sqxModal="clientsDialog">

3
frontend/src/app/features/apps/pages/app.component.html

@ -51,7 +51,8 @@
confirmRememberKey="leaveApp"
confirmText="i18n:apps.leaveConfirmText"
confirmTitle="i18n:apps.leaveConfirmTitle"
(sqxConfirmClick)="leave.emit(app)">
(sqxConfirmClick)="leave.emit(app)"
type="button">
{{ "apps.leave" | sqxTranslate }}
</button>
</sqx-dropdown-menu>

8
frontend/src/app/features/apps/pages/onboarding-dialog.component.html

@ -3,7 +3,7 @@
<div class="squid d-flex align-items-center justify-content-center"><img src="./images/squid.svg" /></div>
</ng-container>
<ng-container content>
<button class="header-right modal-close btn-inline-link" (click)="cancel()">{{ "tour.skip" | sqxTranslate }}</button>
<button class="header-right modal-close btn-inline-link" (click)="cancel()" type="button">{{ "tour.skip" | sqxTranslate }}</button>
@if (step === 0) {
<div class="onboarding-step">
<img class="header-left" animate.enter="fade-in" animate.leave="fade-out" src="./images/logo-white-small.png" />
@ -15,7 +15,7 @@
<div inline="false" [sqxMarkdown]="'tour.stepIntroText' | sqxTranslate"></div>
<div class="mt-4">
<button class="btn btn-success" (click)="next()">{{ "tour.stepIntroNext" | sqxTranslate }}</button>
<button class="btn btn-success" (click)="next()" type="button">{{ "tour.stepIntroNext" | sqxTranslate }}</button>
</div>
</div>
</div>
@ -78,8 +78,8 @@
<div [sqxMarkdown]="'tour.stepTourText' | sqxTranslate"></div>
<div class="mt-4">
<button class="btn btn-success" (click)="start()">{{ "tour.startYes" | sqxTranslate }}</button>
<button class="btn btn-outline-secondary ms-2" (click)="cancel()">
<button class="btn btn-success" (click)="start()" type="button">{{ "tour.startYes" | sqxTranslate }}</button>
<button class="btn btn-outline-secondary ms-2" (click)="cancel()" type="button">
{{ "tour.startNo" | sqxTranslate }}
</button>
</div>

3
frontend/src/app/features/apps/pages/team.component.html

@ -24,7 +24,8 @@
confirmRememberKey="leaveApp"
confirmText="i18n:teams.leaveConfirmText"
confirmTitle="i18n:teams.leaveConfirmTitle"
(sqxConfirmClick)="leave.emit(team)">
(sqxConfirmClick)="leave.emit(team)"
type="button">
{{ "teams.leave" | sqxTranslate }}
</button>
</sqx-dropdown-menu>

2
frontend/src/app/features/assets/pages/asset-tag-dialog.component.html

@ -3,7 +3,7 @@
<ng-container title> {{ "common.renameTag" | sqxTranslate }} </ng-container>
<ng-container content>
<sqx-form-error [error]="editForm.error | async" />
<sqx-form-row for="tagName" label="common.name" vertical required>
<sqx-form-row for="tagName" label="common.name" required vertical>
<input class="form-control" id="tagName" autocomplete="off" formControlName="tagName" sqxFocusOnInit />
</sqx-form-row>
</ng-container>

6
frontend/src/app/features/assets/pages/asset-tags.component.html

@ -1,11 +1,11 @@
<div class="nav nav-light flex-column">
<div class="nav-item">
<button class="nav-link" [class.active]="isEmpty()" (click)="tagsReset.emit()">{{ "common.tagsAll" | sqxTranslate }}</button>
<button class="nav-link" [class.active]="isEmpty()" (click)="tagsReset.emit()" type="button">{{ "common.tagsAll" | sqxTranslate }}</button>
</div>
@for (tag of tags; track tag.name) {
<div class="nav-item">
<button class="nav-link" [class.active]="isSelected(tag)" (click)="toggle.emit(tag.name)">
<button class="nav-link" [class.active]="isSelected(tag)" (click)="toggle.emit(tag.name)" type="button">
<div class="row g-0">
<div class="col">
<span class="truncate">{{ tag.name }}</span>
@ -15,7 +15,7 @@
<div class="badge badge-secondary rounded-pill">{{ tag.count }}</div>
@if (canRename) {
<button class="btn-sm btn-text-secondary btn-rename" (click)="renameTag(tag)" sqxStopClick>
<button class="btn-sm btn-text-secondary btn-rename" (click)="renameTag(tag)" sqxStopClick type="button">
<i class="icon-pencil"></i>
</button>
}

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

@ -14,8 +14,8 @@
<div class="event-created">{{ event.created | sqxFromNow }}</div>
@if (canLoadOrCompare) {
<button class="event-load btn-inline-link" (click)="dataLoad.emit()">{{ "contents.loadContent" | sqxTranslate }}</button> &middot;
<button class="event-load btn-inline-link" (click)="dataCompare.emit()">{{ "contents.versionCompare" | sqxTranslate }}</button>
<button class="event-load btn-inline-link" (click)="dataLoad.emit()" type="button">{{ "contents.loadContent" | sqxTranslate }}</button> &middot;
<button class="event-load btn-inline-link" (click)="dataCompare.emit()" type="button">{{ "contents.versionCompare" | sqxTranslate }}</button>
}
</div>
</div>

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

@ -18,7 +18,7 @@
@if (content.canDraftCreate || content.canDraftDelete) {
<div class="section mb-4">
@if (!content.newStatus) {
<button class="btn btn-success btn-block" (click)="createDraft()">{{ "contents.draftNew" | sqxTranslate }}</button>
<button class="btn btn-success btn-block" (click)="createDraft()" type="button">{{ "contents.draftNew" | sqxTranslate }}</button>
} @else {
<label>{{ "contents.draftStatus" | sqxTranslate }}</label>
<button
@ -36,7 +36,7 @@
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdownNew; closeAlways: true">
@if (content.statusUpdates.length > 0) {
@for (info of content.statusUpdates; track info) {
<button class="dropdown-item" (click)="changeStatus(info.status)">
<button class="dropdown-item" (click)="changeStatus(info.status)" type="button">
{{ "common.statusChangeTo" | sqxTranslate }}
<i class="icon-circle icon-sm" [style.color]="info.color"></i> {{ info.status }}
</button>
@ -50,7 +50,8 @@
confirmRememberKey="deleteDraft"
confirmText="i18n:contents.deleteVersionConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="deleteDraft()">
(sqxConfirmClick)="deleteDraft()"
type="button">
{{ "contents.versionDelete" | sqxTranslate }}
</button>
<div class="dropdown-divider"></div>
@ -60,7 +61,8 @@
confirmRememberKey="deleteContent"
confirmText="i18n:contents.deleteConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="delete()">
(sqxConfirmClick)="delete()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
</sqx-dropdown-menu>
@ -88,7 +90,7 @@
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">
@if (content.statusUpdates.length > 0) {
@for (info of content.statusUpdates; track info) {
<button class="dropdown-item" (click)="changeStatus(info.status)">
<button class="dropdown-item" (click)="changeStatus(info.status)" type="button">
{{ "common.statusChangeTo" | sqxTranslate }}
<sqx-content-status layout="text" small="true" [status]="info.status" [statusColor]="info.color" />
</button>
@ -102,7 +104,8 @@
confirmRememberKey="cancelStatus"
confirmText="i18n:contents.cancelStatusConfirmText"
confirmTitle="i18n:contents.cancelStatusConfirmTitle"
(sqxConfirmClick)="cancelStatus()">
(sqxConfirmClick)="cancelStatus()"
type="button">
{{ "contents.cancelStatus" | sqxTranslate }}
</button>
<div class="dropdown-divider"></div>
@ -112,7 +115,8 @@
confirmRememberKey="deleteContent"
confirmText="i18n:contents.deleteConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="delete()">
(sqxConfirmClick)="delete()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
</sqx-dropdown-menu>

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

@ -4,7 +4,7 @@
<ng-container title>
<div class="d-flex align-items-center">
@if (schema.type !== "Singleton") {
<button class="btn btn-text-secondary" aria-labelledby="content-back" (click)="back()">
<button class="btn btn-text-secondary" aria-labelledby="content-back" (click)="back()" type="button">
<span id="content-back" hidden>{{ "common.back" | sqxTranslate }}</span> <i class="icon-angle-left"></i>
</button>
}
@ -75,7 +75,7 @@
</button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">
@if (content.canClone) {
<button class="dropdown-item" (click)="clone()">
<button class="dropdown-item" (click)="clone()" type="button">
{{ "common.clone" | sqxTranslate }}
</button>
}
@ -86,7 +86,8 @@
confirmRememberKey="deleteContent"
confirmText="i18n:contents.deleteConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="delete()">
(sqxConfirmClick)="delete()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
}
@ -133,8 +134,8 @@
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="saveOnlyDropdown; closeAlways: true">
<button class="dropdown-item" (click)="saveAsDraft('Add')">{{ "common.saveAdd" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="saveAsDraft('Close')">{{ "common.saveClose" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="saveAsDraft('Add')" type="button">{{ "common.saveAdd" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="saveAsDraft('Close')" type="button">{{ "common.saveClose" | sqxTranslate }}</button>
</sqx-dropdown-menu>
</div>
}
@ -157,10 +158,10 @@
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="savePublishDropdown; closeAlways: true">
<button class="dropdown-item" (click)="saveAndPublish('Add')">
<button class="dropdown-item" (click)="saveAndPublish('Add')" type="button">
{{ "contents.saveAndPublishAdd" | sqxTranslate }}
</button>
<button class="dropdown-item" (click)="saveAndPublish('Close')">
<button class="dropdown-item" (click)="saveAndPublish('Close')" type="button">
{{ "contents.saveAndPublishClose" | sqxTranslate }}
</button>
</sqx-dropdown-menu>

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

@ -8,7 +8,7 @@
@if (contentVersion) {
<div class="alert alert-danger">
<div class="float-end">
<button (click)="loadLatest.emit()">{{ "contents.viewLatest" | sqxTranslate }}</button>
<button (click)="loadLatest.emit()" type="button">{{ "contents.viewLatest" | sqxTranslate }}</button>
</div>
@if (isDeleted) {

6
frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.html

@ -3,19 +3,19 @@
@if (mode | async; as currentMode) {
<ul class="nav nav-tabs2">
<li class="nav-item">
<button class="nav-link" [class.active]="currentMode === 'Content'" (click)="setMode('Content')">
<button class="nav-link" [class.active]="currentMode === 'Content'" (click)="setMode('Content')" type="button">
{{ "contents.inspectContent" | sqxTranslate }}
</button>
</li>
<li class="nav-item">
<button class="nav-link" [class.active]="currentMode === 'Data'" (click)="setMode('Data')">
<button class="nav-link" [class.active]="currentMode === 'Data'" (click)="setMode('Data')" type="button">
{{ "contents.inspectData" | sqxTranslate }}
</button>
</li>
<li class="nav-item">
<button class="nav-link" [class.active]="currentMode === 'FlatData'" (click)="setMode('FlatData')">
<button class="nav-link" [class.active]="currentMode === 'FlatData'" (click)="setMode('FlatData')" type="button">
{{ "contents.inspectFlatData" | sqxTranslate }}
</button>
</li>

2
frontend/src/app/features/content/shared/forms/array-editor.component.html

@ -90,7 +90,7 @@
</button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonSelect" *sqxModal="schemasDropdown; closeAlways: true">
@for (schema of schemasList; track schema) {
<button class="dropdown-item" (click)="addComponent(schema)">{{ schema.displayName }}</button>
<button class="dropdown-item" (click)="addComponent(schema)" type="button">{{ schema.displayName }}</button>
}
</sqx-dropdown-menu>
}

2
frontend/src/app/features/content/shared/forms/component.component.html

@ -28,7 +28,7 @@
</button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonSelect" *sqxModal="schemasDropdown; closeAlways: true">
@for (schema of schemasList; track schema) {
<button class="dropdown-item" (click)="setSchema(schema)">{{ schema.displayName }}</button>
<button class="dropdown-item" (click)="setSchema(schema)" type="button">{{ schema.displayName }}</button>
}
</sqx-dropdown-menu>
}

2
frontend/src/app/features/content/shared/forms/field-languages.component.html

@ -9,7 +9,7 @@
}
</button>
}
@if (formModel.field.properties.isComplexUI || !showAllControls) {
<sqx-language-selector
[exists]="formModel.translationStatus | async"

2
frontend/src/app/features/content/shared/forms/user-info-editor.component.html

@ -22,7 +22,7 @@
<div ngPreserveWhitespaces>Authorization: ApiKey {{ rolesState.appName }}:{{ apiKey | async }}</div>
</sqx-code>
<sqx-form-hint>
{{ 'common.or' | sqxTranslate }}
{{ "common.or" | sqxTranslate }}
</sqx-form-hint>
<sqx-code>
<div ngPreserveWhitespaces>ApiKey: {{ rolesState.appName }}:{{ apiKey | async }}</div>

7
frontend/src/app/features/content/shared/list/content.component.html

@ -22,14 +22,14 @@
<sqx-dropdown-menu position="bottom-start" scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">
<a class="dropdown-item" [routerLink]="link" sqxExternalLink target="_blank"> {{ "common.editInNewTab" | sqxTranslate }} </a>
@for (info of content.statusUpdates; track info) {
<button class="dropdown-item" (click)="statusChange.emit(info.status)">
<button class="dropdown-item" (click)="statusChange.emit(info.status)" type="button">
{{ "common.statusChangeTo" | sqxTranslate }}
<sqx-content-status layout="text" small="true" [status]="info.status" [statusColor]="info.color" />
</button>
}
@if (cloneable) {
<button class="dropdown-item" (click)="clone.emit(); dropdown.hide()">{{ "common.clone" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="clone.emit(); dropdown.hide()" type="button">{{ "common.clone" | sqxTranslate }}</button>
}
<div class="dropdown-divider"></div>
@ -39,7 +39,8 @@
confirmRememberKey="deleteContent"
confirmText="i18n:contents.deleteConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="delete.emit()">
(sqxConfirmClick)="delete.emit()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
</sqx-dropdown-menu>

2
frontend/src/app/features/content/shared/preview-button.component.html

@ -9,7 +9,7 @@
<button class="btn btn-secondary dropdown-toggle" (click)="dropdown.toggle()" type="button"></button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonGroup" *sqxModal="dropdown; closeAlways: true">
@for (name of snapshot.previewNamesMore; track name) {
<button class="dropdown-item" (click)="follow(name)">{{ name }}</button>
<button class="dropdown-item" (click)="follow(name)" type="button">{{ name }}</button>
}
</sqx-dropdown-menu>
</div>

2
frontend/src/app/features/content/shared/references/reference-item.component.html

@ -44,7 +44,7 @@
<i class="icon-dots"></i>
</button>
<div class="reference-menu">
<button class="btn btn-text-secondary" (click)="clone.emit()" title="{{ 'common.clone' | sqxTranslate }}">
<button class="btn btn-text-secondary" (click)="clone.emit()" title="{{ 'common.clone' | sqxTranslate }}" type="button">
<i class="icon-copy"></i>
</button>
@if (canRemove) {

4
frontend/src/app/features/content/shared/references/references-tags.component.ts

@ -144,7 +144,6 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
for (const content of items) {
const index = this.contents.findIndex(x => x.id === content.id);
if (index >= 0) {
this.contents[index] = content;
} else {
@ -153,12 +152,10 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
}
this.isLoadingFailed = false;
this.resetConverterState(true);
},
error: () => {
this.isLoadingFailed = true;
this.resetConverterState(false);
},
});
@ -171,7 +168,6 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
if (rebuild) {
const converter = new ReferencesTagsConverter(this.language, this.contents, this.localizer);
this.next({ converter });
}

7
frontend/src/app/features/dashboard/pages/dashboard-config.component.html

@ -24,8 +24,8 @@
}
<div class="dropdown-divider"></div>
<button class="dropdown-item" (click)="startExpertMode()"> {{ "common.expertMode" | sqxTranslate }} </button>
<button class="dropdown-item" (click)="saveConfig()"> {{ "common.save" | sqxTranslate }} </button>
<button class="dropdown-item" (click)="startExpertMode()" type="button">{{ "common.expertMode" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="saveConfig()" type="button">{{ "common.save" | sqxTranslate }}</button>
<div class="dropdown-divider"></div>
<button
class="dropdown-item dropdown-item-delete"
@ -33,7 +33,8 @@
confirmRememberKey="resetConfig"
confirmText="i18n:dashboard.resetConfigConfirmText"
confirmTitle="i18n:dashboard.resetConfigConfirmTitle"
(sqxConfirmClick)="resetConfig()">
(sqxConfirmClick)="resetConfig()"
type="button">
{{ "common.reset" | sqxTranslate }}
</button>
</sqx-dropdown-menu>

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

@ -24,7 +24,8 @@
<sqx-github-card />
}
@case ("api-calls") {animate.enter="fade-in" animate.leave="fade-out"
@case ("api-calls") {
animate.enter="fade-in" animate.leave="fade-out"
<sqx-api-calls-card [app]="app" [usage]="callsUsage" />
}

2
frontend/src/app/features/rules/pages/events/rule-event.component.html

@ -80,7 +80,7 @@
</div>
<div class="event-section">
<sqx-state-details [availableSteps]="availableSteps" [state]="event.flowState" [branchItems]="branchItems" />
<sqx-state-details [availableSteps]="availableSteps" [branchItems]="branchItems" [state]="event.flowState" />
</div>
</td>
</tr>

14
frontend/src/app/features/rules/pages/rule/branch.component.html

@ -14,7 +14,11 @@
@if (branchItems.length > 0) {
@for (item of branchItems; track item.id; let last = $last; let i = $index) {
<div class="centered">
<button class="btn btn-outline-secondary btn-circle-sm" attr.aria-label="{{ 'rules.addStep' | sqxTranslate }}" (click)="add(i - 1)">
<button
class="btn btn-outline-secondary btn-circle-sm"
attr.aria-label="{{ 'rules.addStep' | sqxTranslate }}"
(click)="add(i - 1)"
type="button">
<i class="icon-plus"></i>
</button>
</div>
@ -60,7 +64,11 @@
@if (last && item.step.step.stepType !== "If") {
<div class="centered">
<button class="btn btn-outline-secondary btn-circle-sm" attr.aria-label="{{ 'rules.addStep' | sqxTranslate }}" (click)="add(i)">
<button
class="btn btn-outline-secondary btn-circle-sm"
attr.aria-label="{{ 'rules.addStep' | sqxTranslate }}"
(click)="add(i)"
type="button">
<i class="icon-plus"></i>
</button>
</div>
@ -68,7 +76,7 @@
}
} @else {
<div class="centered">
<button class="btn btn-outline-secondary btn-circle" attr.aria-label="{{ 'rules.addStep' | sqxTranslate }}" (click)="add()">
<button class="btn btn-outline-secondary btn-circle" attr.aria-label="{{ 'rules.addStep' | sqxTranslate }}" (click)="add()" type="button">
<i class="icon-plus"></i>
</button>
</div>

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

@ -2,7 +2,7 @@
<sqx-layout layout="main" overflow="true" padding="true" white="true">
<ng-container title>
<div class="d-flex align-items-center">
<button class="btn btn-text-secondary" attr.aria-label="{{ 'common.back' | sqxTranslate }}" (click)="back()">
<button class="btn btn-text-secondary" attr.aria-label="{{ 'common.back' | sqxTranslate }}" (click)="back()" type="button">
<i class="icon-angle-left"></i>
</button>
<h3 class="title">{{ "common.rule" | sqxTranslate }}</h3>
@ -18,7 +18,8 @@
confirmText="i18n:rules.triggerConfirmText"
confirmTitle="i18n:rules.triggerConfirmTitle"
[disabled]="!rule?.canTrigger"
(sqxConfirmClick)="trigger()">
(sqxConfirmClick)="trigger()"
type="button">
<i class="icon-play-line"></i>
</button>
}
@ -67,7 +68,8 @@
<button
class="btn btn-outline-secondary btn-circle"
attr.aria-label="{{ 'rules.addTrigger' | sqxTranslate }}"
(click)="triggerDialog.show()">
(click)="triggerDialog.show()"
type="button">
<i class="icon-plus"></i>
</button>
}

18
frontend/src/app/features/rules/pages/rules/rule.component.html

@ -22,15 +22,15 @@
</button>
<sqx-dropdown-menu position="bottom-end" scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">
@if (rule.canUpdate) {
<button class="dropdown-item" [routerLink]="rule.id">{{ "common.edit" | sqxTranslate }}</button>
<button class="dropdown-item" [routerLink]="rule.id" type="button">{{ "common.edit" | sqxTranslate }}</button>
}
@if (rule.canEnable) {
<button class="dropdown-item" (click)="enable()">{{ "common.enable" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="enable()" type="button">{{ "common.enable" | sqxTranslate }}</button>
}
@if (rule.canDisable) {
<button class="dropdown-item" (click)="disable()">{{ "common.disable" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="disable()" type="button">{{ "common.disable" | sqxTranslate }}</button>
}
<div class="dropdown-divider"></div>
@ -42,7 +42,8 @@
confirmRememberKey="runRule"
confirmText="i18n:rules.runRuleConfirmText"
confirmTitle="i18n:rules.runRuleConfirmTitle"
(sqxConfirmClick)="run()">
(sqxConfirmClick)="run()"
type="button">
{{ "rules.run" | sqxTranslate }}
</button>
}
@ -53,7 +54,8 @@
confirmRememberKey="runRuleFromSnapshots"
confirmText="i18n:rules.runRuleConfirmText"
confirmTitle="i18n:rules.runRuleConfirmTitle"
(sqxConfirmClick)="runFromSnapshots()">
(sqxConfirmClick)="runFromSnapshots()"
type="button">
{{ "rules.runFromSnapshots" | sqxTranslate }}
</button>
}
@ -67,7 +69,8 @@
confirmRememberKey="deleteRule"
confirmText="i18n:rules.deleteConfirmText"
confirmTitle="i18n:rules.deleteConfirmTitle"
(sqxConfirmClick)="delete()">
(sqxConfirmClick)="delete()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
}
@ -118,7 +121,8 @@
confirmText="i18n:rules.triggerConfirmText"
confirmTitle="i18n:rules.triggerConfirmTitle"
[disabled]="!rule.canTrigger"
(sqxConfirmClick)="trigger()">
(sqxConfirmClick)="trigger()"
type="button">
<i class="icon-play-line"></i>
</button>
} @else {

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

@ -15,7 +15,7 @@
@if (rulesState.runningRule | async; as runningRule) {
<div class="alert alert-danger d-flex justify-content-between align-items-center p-2 ps-4 mb-4">
{{ "rules.runningRule" | sqxTranslate: { name: runningRule.name || "Unnamed Rule" } }}
<button class="btn" (click)="cancelRun()">{{ "common.cancel" | sqxTranslate }}</button>
<button class="btn" (click)="cancelRun()" type="button">{{ "common.cancel" | sqxTranslate }}</button>
</div>
}

5
frontend/src/app/features/rules/pages/simulator/rule-transition.component.html

@ -1,6 +1,5 @@
@if (text) {
<sqx-history-step small="true" [isLast]="isLast && filteredErrors.length === 0">
<sqx-history-step [isLast]="isLast && filteredErrors.length === 0" small="true">
<ng-container summary>
<div class="col text-sm truncate">
{{ text | sqxTranslate }}
@ -10,7 +9,7 @@
}
@for (error of filteredErrors; track error; let last = $last) {
<sqx-history-step small="true" [isLast]="isLast && last">
<sqx-history-step [isLast]="isLast && last" small="true">
<ng-container summary>
<div class="col text-sm text-danger truncate">
{{ error | sqxTranslate }}

2
frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html

@ -71,7 +71,7 @@
text="i18n:rules.simulation.eventConditionEvaluated" />
@if (event.flowState) {
<sqx-state-details [availableSteps]="availableSteps" showEvent="false" [state]="event.flowState" [branchItems]="branchItems" />
<sqx-state-details [availableSteps]="availableSteps" [branchItems]="branchItems" showEvent="false" [state]="event.flowState" />
}
}
}

10
frontend/src/app/features/rules/shared/state-attempt.component.html

@ -2,19 +2,19 @@
@if (attempt) {
<div class="row g-0">
<div class="col">
<label>{{ "common.started" | sqxTranslate }}:&nbsp;</label> <span class="text-sm">{{ attempt.started.toISOString() || '-' }}</span>
<label>{{ "common.started" | sqxTranslate }}:&nbsp;</label> <span class="text-sm">{{ attempt.started.toISOString() || "-" }}</span>
</div>
<div class="col">
<label>{{ "common.completed" | sqxTranslate }}:&nbsp;</label> <span class="text-sm">{{ attempt.completed.toISOString() || '-' }}</span>
<label>{{ "common.completed" | sqxTranslate }}:&nbsp;</label> <span class="text-sm">{{ attempt.completed.toISOString() || "-" }}</span>
</div>
</div>
@if (attempt.error) {
<label class="mt-3">{{ "common.error" | sqxTranslate }}</label>
<sqx-code-editor disabled="true" height="auto" mode="ace/mode/text" [ngModel]="attempt.error" wordWrap="true" />
}
@if (output) {
<label class="mt-3">{{ "common.output" | sqxTranslate }}</label>
<sqx-code-editor disabled="true" height="auto" mode="ace/mode/text" [ngModel]="output" wordWrap="true" />

4
frontend/src/app/features/rules/shared/state-details.component.html

@ -19,6 +19,6 @@
[isLast]="$last"
[isNext]="state.nextStepId === item.id"
[state]="state.steps[item.id]"
[stepInfo]="availableSteps[item.step.step.stepType]"
[stepDefinition]="state.definition.steps[item.id].step" />
[stepDefinition]="state.definition.steps[item.id].step"
[stepInfo]="availableSteps[item.step.step.stepType]" />
}

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

@ -21,7 +21,7 @@
<ul class="nav nav-tabs2 mt-2">
@if (stepInfo.properties.length > 0) {
<li class="nav-item">
<button class="nav-link" [class.active]="attemptIndex === -1" (click)="selectAttempt(-1)">
<button class="nav-link" [class.active]="attemptIndex === -1" (click)="selectAttempt(-1)" type="button">
{{ "common.properties" | sqxTranslate }}
</button>
</li>
@ -29,7 +29,7 @@
@if (state) {
@for (attempt of state.attempts; let i = $index; track i) {
<li class="nav-item">
<button class="nav-link" [class.active]="attemptIndex === i" (click)="selectAttempt(i)">
<button class="nav-link" [class.active]="attemptIndex === i" (click)="selectAttempt(i)" type="button">
@if ($first) {
{{ "rules.ruleEvents.attempt" | sqxTranslate }}&nbsp;
}

26
frontend/src/app/features/schemas/pages/schema/common/schema-edit-form.component.html

@ -6,37 +6,43 @@
<sqx-form-row for="name" label="common.name" vertical>
<input class="form-control" id="name" [ngModel]="schema.name" [ngModelOptions]="{ standalone: true }" readonly />
</sqx-form-row>
<sqx-form-row for="label" label="common.label" vertical hint="schemas.schemaLabelHint">
<sqx-form-row for="label" hint="schemas.schemaLabelHint" label="common.label" vertical>
<input class="form-control" id="label" formControlName="label" />
</sqx-form-row>
<sqx-form-row for="hints" label="common.hints" vertical hint="schemas.schemaHintsHint">
<sqx-form-row for="hints" hint="schemas.schemaHintsHint" label="common.hints" vertical>
<textarea class="form-control" id="hints" formControlName="hints" rows="4"></textarea>
</sqx-form-row>
<sqx-form-row for="contentsSidebarUrl" label="schemas.contentsSidebarUrl" vertical hint="schemas.contentsSidebarUrlHint">
<sqx-form-row for="contentsSidebarUrl" hint="schemas.contentsSidebarUrlHint" label="schemas.contentsSidebarUrl" vertical>
<input class="form-control" id="contentsSidebarUrl" formControlName="contentsSidebarUrl" type="url" />
</sqx-form-row>
<sqx-form-row for="contentSidebarUrl" label="schemas.contentSidebarUrl" vertical hint="schemas.contentSidebarUrlHint">
<sqx-form-row for="contentSidebarUrl" hint="schemas.contentSidebarUrlHint" label="schemas.contentSidebarUrl" vertical>
<input class="form-control" id="contentSidebarUrl" formControlName="contentSidebarUrl" type="url" />
</sqx-form-row>
<sqx-form-row for="contentEditorUrl" label="schemas.contentEditorUrl" vertical hint="schemas.contentEditorUrlHint">
<sqx-form-row for="contentEditorUrl" hint="schemas.contentEditorUrlHint" label="schemas.contentEditorUrl" vertical>
<input class="form-control" id="contentEditorUrl" formControlName="contentEditorUrl" type="url" />
</sqx-form-row>
<sqx-form-row for="contentsListUrl" label="schemas.contentsListUrl" vertical hint="schemas.contentsListUrlHint">
<sqx-form-row for="contentsListUrl" hint="schemas.contentsListUrlHint" label="schemas.contentsListUrl" vertical>
<input class="form-control" id="contentsListUrl" formControlName="contentsListUrl" type="url" />
</sqx-form-row>
<sqx-form-row for="tags" label="common.tags" vertical hint="schemas.schemaTagsHint">
<sqx-form-row for="tags" hint="schemas.schemaTagsHint" label="common.tags" vertical>
<sqx-tag-editor id="tags" formControlName="tags" />
</sqx-form-row>
<sqx-form-row for="validateOnPublish" label="schemas.validateOnPublish" check vertical hint="schemas.validateOnPublishHint" alert="schemas.validateOnPublishHint">
<input class="form-check-input" id="validateOnPublish" formControlName="validateOnPublish" type="checkbox" />
<sqx-form-row
alert="schemas.validateOnPublishHint"
check
for="validateOnPublish"
hint="schemas.validateOnPublishHint"
label="schemas.validateOnPublish"
vertical>
<input class="form-check-input" id="validateOnPublish" formControlName="validateOnPublish" type="checkbox" />
</sqx-form-row>
</div>

10
frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html

@ -74,7 +74,7 @@
@if (!editForm) {
<div>
<div class="btn-group ms-2" attr.aria-label="{{ 'schemas.addField' | sqxTranslate }}" role="group">
<button class="btn btn-success" (click)="addField('Close')" shortcut="CTRL + SHIFT + S">
<button class="btn btn-success" (click)="addField('Close')" shortcut="CTRL + SHIFT + S" type="button">
{{ "schemas.addFieldAndClose" | sqxTranslate }}
</button>
<button
@ -86,8 +86,8 @@
<i class="icon-angle-down"></i>
</button>
<sqx-dropdown-menu position="top-end" scrollY="true" [sqxAnchoredTo]="buttonSave" *sqxModal="addFieldModal; closeAlways: true">
<button class="dropdown-item" (click)="addField('Add')">{{ "schemas.addFieldAndCreate" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="addField('Edit')">{{ "schemas.addFieldAndEdit" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="addField('Add')" type="button">{{ "schemas.addFieldAndCreate" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="addField('Edit')" type="button">{{ "schemas.addFieldAndEdit" | sqxTranslate }}</button>
</sqx-dropdown-menu>
</div>
</div>
@ -96,7 +96,7 @@
@if (editForm) {
<div>
<div class="btn-group ms-2" attr.aria-label="{{ 'schemas.saveField' | sqxTranslate }}" role="group">
<button class="btn btn-primary" (click)="save('Close')" shortcut="CTRL + SHIFT + S">
<button class="btn btn-primary" (click)="save('Close')" shortcut="CTRL + SHIFT + S" type="button">
{{ "schemas.saveFieldAndClose" | sqxTranslate }}
</button>
<button
@ -108,7 +108,7 @@
<i class="icon-angle-down"></i>
</button>
<sqx-dropdown-menu position="bottom-end" scrollY="true" [sqxAnchoredTo]="buttonSave" *sqxModal="addFieldModal; closeAlways: true">
<button class="dropdown-item" (click)="save('Add')">{{ "schemas.saveFieldAndNew" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="save('Add')" type="button">{{ "schemas.saveFieldAndNew" | sqxTranslate }}</button>
</sqx-dropdown-menu>
</div>
</div>

14
frontend/src/app/features/schemas/pages/schema/fields/field.component.html

@ -56,19 +56,19 @@
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">
@if (field.properties.isContentField) {
@if (field.canEnable) {
<button class="dropdown-item" (click)="enableField()">{{ "schemas.field.enable" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="enableField()" type="button">{{ "schemas.field.enable" | sqxTranslate }}</button>
}
@if (field.canDisable) {
<button class="dropdown-item" (click)="disableField()">{{ "schemas.field.disable" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="disableField()" type="button">{{ "schemas.field.disable" | sqxTranslate }}</button>
}
@if (field.canHide) {
<button class="dropdown-item" (click)="hideField()">{{ "schemas.field.hide" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="hideField()" type="button">{{ "schemas.field.hide" | sqxTranslate }}</button>
}
@if (field.canShow) {
<button class="dropdown-item" (click)="showField()">{{ "schemas.field.show" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="showField()" type="button">{{ "schemas.field.show" | sqxTranslate }}</button>
}
}
@ -79,7 +79,8 @@
confirmRememberKey="lockField"
confirmText="i18n:schemas.field.lockConfirmText"
confirmTitle="i18n:schemas.field.lockConfirmTitle"
(sqxConfirmClick)="lockField()">
(sqxConfirmClick)="lockField()"
type="button">
{{ "schemas.field.lock" | sqxTranslate }}
</button>
}
@ -91,7 +92,8 @@
confirmRememberKey="deleteField"
confirmText="i18n:schemas.field.deleteConfirmText"
confirmTitle="i18n:schemas.field.deleteConfirmTitle"
(sqxConfirmClick)="deleteField()">
(sqxConfirmClick)="deleteField()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
</ng-container>

10
frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html

@ -1,25 +1,27 @@
<div class="table-items-row-details-tabs clearfix">
<ul class="nav nav-tabs2">
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)" type="button">
{{ "schemas.field.tabCommon" | sqxTranslate }}
</button>
</li>
<li class="nav-item" [class.hidden]="!field.properties.isContentField">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)" type="button">
{{ "schemas.field.tabValidation" | sqxTranslate }}
</button>
</li>
<li class="nav-item" [class.hidden]="!field.properties.isContentField">
<button class="nav-link" [class.active]="selectedTab === 2" (click)="selectTab(2)">
<button class="nav-link" [class.active]="selectedTab === 2" (click)="selectTab(2)" type="button">
{{ "schemas.field.tabEditing" | sqxTranslate }}
</button>
</li>
<li class="nav-item" [class.hidden]="field.properties.fieldType !== 'Json'">
<button class="nav-link" [class.active]="selectedTab === 3" (click)="selectTab(3)">{{ "schemas.field.tabMore" | sqxTranslate }}</button>
<button class="nav-link" [class.active]="selectedTab === 3" (click)="selectTab(3)" type="button">
{{ "schemas.field.tabMore" | sqxTranslate }}
</button>
</li>
</ul>

7
frontend/src/app/features/schemas/pages/schema/fields/types/rich-text-ui.component.html

@ -1,5 +1,10 @@
<div [formGroup]="fieldForm">
<sqx-form-row #classNamesRow for="classNames" hint="schemas.fieldTypes.string.classNamesHint" label="schemas.fieldTypes.string.classNames" [prefix]="field.fieldId">
<sqx-form-row
#classNamesRow
for="classNames"
hint="schemas.fieldTypes.string.classNamesHint"
label="schemas.fieldTypes.string.classNames"
[prefix]="field.fieldId">
<sqx-tag-editor [id]="classNamesRow.fieldName" formControlName="classNames" />
</sqx-form-row>

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

@ -76,7 +76,7 @@
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="editOptionsDropdown; closeAlways: true">
@if (schemasState.canCreate) {
<div class="dropdown-divider"></div>
<button class="dropdown-item" (click)="cloneSchema()">{{ "common.clone" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="cloneSchema()" type="button">{{ "common.clone" | sqxTranslate }}</button>
}
<ng-container>
<div class="dropdown-divider"></div>
@ -86,7 +86,8 @@
confirmRememberKey="deleteSchema"
confirmText="i18n:schemas.deleteConfirmText"
confirmTitle="i18n:schemas.deleteConfirmTitle"
(sqxConfirmClick)="deleteSchema()">
(sqxConfirmClick)="deleteSchema()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
</ng-container>

2
frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html

@ -3,7 +3,7 @@
<ul class="nav nav-tabs2">
@for (script of editForm.form.controls | sqxKeys; track script) {
<li class="nav-item">
<button class="nav-link" [class.active]="schemaScript === script" (click)="selectField(script)">
<button class="nav-link" [class.active]="schemaScript === script" (click)="selectField(script)" type="button">
{{ script | sqxScriptName }}
</button>
</li>

4
frontend/src/app/features/schemas/pages/schema/ui/schema-ui-form.component.html

@ -2,13 +2,13 @@
<div class="inner-header">
<ul class="nav nav-tabs2">
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)" type="button">
{{ "schemas.listFields" | sqxTranslate }} <small>({{ fieldsInLists.length }})</small>
</button>
</li>
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)" type="button">
{{ "schemas.referenceFields" | sqxTranslate }} <small>({{ fieldsInReferences.length }})</small>
</button>
</li>

8
frontend/src/app/features/schemas/pages/schemas/schema-form.component.html

@ -12,20 +12,20 @@
<div class="col">
<ul class="nav nav-tabs2">
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)" type="button">
{{ "schemas.createCustom" | sqxTranslate }}
</button>
</li>
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)" type="button">
{{ "schemas.createFromJson" | sqxTranslate }}
</button>
</li>
@if (hasChatBot) {
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 2" (click)="selectTab(2)">
<button class="nav-link" [class.active]="selectedTab === 2" (click)="selectTab(2)" type="button">
{{ "schemas.createAI" | sqxTranslate }}
<span class="badge rounded-pill badge-primary">New</span>
</button>
@ -143,7 +143,7 @@
</div>
</div>
</sqx-form-row>
<sqx-form-alert> {{ "schemas.promptExample" | sqxTranslate }} </sqx-form-alert>
<sqx-code-editor disabled="true" [height]="1000" [ngModel]="generateLog" [ngModelOptions]="{ standalone: true }" />

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

@ -11,7 +11,7 @@
<ul class="nav nav-tabs2">
@for (script of editForm.form.controls | sqxKeys; track script) {
<li class="nav-item">
<button class="nav-link" [class.active]="assetScript === script" (click)="selectField(script)">
<button class="nav-link" [class.active]="assetScript === script" (click)="selectField(script)" type="button">
{{ script | sqxScriptName }}
</button>
</li>

4
frontend/src/app/features/settings/pages/clients/client-connect-form.component.html

@ -11,7 +11,7 @@
</li>
} @else {
<li class="breadcrumb-item done">
<button class="btn-inline-link" (click)="go('Start')">
<button class="btn-inline-link" (click)="go('Start')" type="button">
<i class="icon-checkmark"></i> {{ "clients.connectWizard.step1Title" | sqxTranslate }}
</button>
</li>
@ -157,7 +157,7 @@
}
</ng-container>
<ng-container footer>
<button class="btn btn-text-secondary" (click)="go('Start')" [disabled]="step === 'Start'">
<button class="btn btn-text-secondary" (click)="go('Start')" [disabled]="step === 'Start'" type="button">
{{ "common.back" | sqxTranslate }}
</button>
</ng-container>

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

@ -93,7 +93,7 @@
@if (client.canUpdate) {
<div class="col-auto">
<button class="btn btn-outline-secondary" (click)="updateApiCallsLimit(apiCallsLimit)">
<button class="btn btn-outline-secondary" (click)="updateApiCallsLimit(apiCallsLimit)" type="button">
{{ "common.save" | sqxTranslate }}
</button>
</div>

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

@ -48,7 +48,11 @@
<div>
<sqx-avatar [identifier]="app.name" [image]="app.image" size="150" />
@if (isEditableImage && app.image) {
<button class="btn btn-danger btn-sm app-image-remove" (click)="removeImage()" title="i18n:apps.removeImage">
<button
class="btn btn-danger btn-sm app-image-remove"
(click)="removeImage()"
title="i18n:apps.removeImage"
type="button">
<i class="icon-bin2"></i>
</button>
}

10
frontend/src/app/features/settings/pages/plans/plan.component.html

@ -22,7 +22,7 @@
</div>
@if (planInfo.isSelected) {
<button class="btn btn-block btn-text-success plan-selected">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
<button class="btn btn-block btn-text-success plan-selected" type="button">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
}
@if (!planInfo.isSelected) {
@ -33,7 +33,8 @@
[confirmText]="planInfo.plan.confirmText"
confirmTitle="i18n:plans.changeConfirmTitle"
[disabled]="(plansState.locked | async) !== 'None'"
(sqxConfirmClick)="changeMonthly()">
(sqxConfirmClick)="changeMonthly()"
type="button">
{{ "plans.change" | sqxTranslate }}
</button>
}
@ -47,7 +48,7 @@
</div>
@if (planInfo.isYearlySelected) {
<button class="btn btn-block btn-text-success plan-selected">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
<button class="btn btn-block btn-text-success plan-selected" type="button">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
}
@if (!planInfo.isYearlySelected) {
@ -57,7 +58,8 @@
[confirmText]="planInfo.plan.yearlyConfirmText!"
confirmTitle="i18n:plans.changeConfirmTitle"
[disabled]="(plansState.locked | async) !== 'None'"
(sqxConfirmClick)="changeYearly()">
(sqxConfirmClick)="changeYearly()"
type="button">
{{ "plans.change" | sqxTranslate }}
</button>
}

5
frontend/src/app/features/settings/pages/workflows/workflow-step.component.html

@ -6,7 +6,8 @@
[class.active]="step.name === workflow.dto.initial"
[class.enabled]="step.name !== workflow.dto.initial && !step.isLocked"
(click)="makeInitial()"
[disabled]="step.name === workflow.dto.initial || step.isLocked || disabled">
[disabled]="step.name === workflow.dto.initial || step.isLocked || disabled"
type="button">
@if (!step.isLocked) {
<i class="icon-arrow-right text-decent"></i>
}
@ -69,7 +70,7 @@
</div>
<div class="col ps-2">
<button class="btn btn-outline-secondary" (click)="addTransition(openStep.name)"><i class="icon-plus"></i></button>
<button class="btn btn-outline-secondary" (click)="addTransition(openStep.name)" type="button"><i class="icon-plus"></i></button>
</div>
</div>
}

6
frontend/src/app/features/settings/pages/workflows/workflow.component.html

@ -37,13 +37,13 @@
<div class="table-items-row-details-tabs clearfix">
<ul class="nav nav-tabs2">
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)" type="button">
{{ "workflows.tabEdit" | sqxTranslate }}
</button>
</li>
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)" type="button">
{{ "workflows.tabVisualize" | sqxTranslate }}
</button>
</li>
@ -94,7 +94,7 @@
}
@if (workflowView.dto.canUpdate) {
<button class="btn btn-success" (click)="addStep()">{{ "workflows.addStep" | sqxTranslate }}</button>
<button class="btn btn-success" (click)="addStep()" type="button">{{ "workflows.addStep" | sqxTranslate }}</button>
}
} @else {
<sqx-workflow-diagram [workflow]="workflowView" />

2
frontend/src/app/features/teams/pages/contributors/contributor-add-form.component.html

@ -30,7 +30,7 @@
<div class="import-hint">
<sqx-form-hint>
{{ "contributors.importHint" | sqxTranslate }}
<button class="btn-inline-link" (click)="importDialog.show()">{{ "contributors.importButton" | sqxTranslate }}</button>
<button class="btn-inline-link" (click)="importDialog.show()" type="button">{{ "contributors.importButton" | sqxTranslate }}</button>
</sqx-form-hint>
</div>
</div>

10
frontend/src/app/features/teams/pages/plans/plan.component.html

@ -22,7 +22,7 @@
</div>
@if (planInfo.isSelected) {
<button class="btn btn-block btn-text-success plan-selected">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
<button class="btn btn-block btn-text-success plan-selected" type="button">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
}
@if (!planInfo.isSelected) {
@ -33,7 +33,8 @@
[confirmText]="planInfo.plan.confirmText"
confirmTitle="i18n:plans.changeConfirmTitle"
[disabled]="(plansState.locked | async) !== 'None'"
(sqxConfirmClick)="changeMonthly()">
(sqxConfirmClick)="changeMonthly()"
type="button">
{{ "plans.change" | sqxTranslate }}
</button>
}
@ -47,7 +48,7 @@
</div>
@if (planInfo.isYearlySelected) {
<button class="btn btn-block btn-text-success plan-selected">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
<button class="btn btn-block btn-text-success plan-selected" type="button">&#10003; {{ "plans.selected" | sqxTranslate }}</button>
}
@if (!planInfo.isYearlySelected) {
@ -57,7 +58,8 @@
[confirmText]="planInfo.plan.yearlyConfirmText!"
confirmTitle="i18n:plans.changeConfirmTitle"
[disabled]="(plansState.locked | async) !== 'None'"
(sqxConfirmClick)="changeYearly()">
(sqxConfirmClick)="changeYearly()"
type="button">
{{ "plans.change" | sqxTranslate }}
</button>
}

9
frontend/src/app/framework/angular/layout.component.html

@ -25,7 +25,7 @@
<i class="icon-close"></i>
</a>
} @else {
<button class="btn panel2-collapse"><i class="icon-close"></i></button>
<button class="btn panel2-collapse" type="button"><i class="icon-close"></i></button>
}
</div>
}
@ -63,7 +63,12 @@
<ng-container *ngTemplateOutlet="menuTemplate"></ng-container>
</div>
</div>
<button class="btn panel2-collapse" attr.aria-label="{{ 'common.togglePanel' | sqxTranslate }}" (click)="toggle()" sqxStopClick>
<button
class="btn panel2-collapse"
attr.aria-label="{{ 'common.togglePanel' | sqxTranslate }}"
(click)="toggle()"
sqxStopClick
type="button">
@if (displayMode === "Collapsed" && expandedWidth > 0) {
<i class="icon-angle-right"></i>
} @else if (displayMode === "Collapsed") {

6
frontend/src/app/framework/angular/menu-item.component.html

@ -7,7 +7,8 @@
[confirmText]="confirmText | sqxTranslate"
[confirmTitle]="confirmTitle | sqxTranslate"
(sqxConfirmClick)="action.emit()"
[title]="tooltip | sqxTranslate">
[title]="tooltip | sqxTranslate"
type="button">
{{ menuLabel || label | sqxTranslate }}
</button>
</ng-template>
@ -21,7 +22,8 @@
[disabled]="disabled"
(sqxConfirmClick)="action.emit()"
[tabIndex]="tabIndex"
[title]="tooltip | sqxTranslate">
[title]="tooltip | sqxTranslate"
type="button">
@if (icon) {
<i class="icon-{{ icon }}"></i>
}

8
frontend/src/app/framework/angular/menu.component.html

@ -23,7 +23,13 @@
</sqx-dropdown-menu>
}
<div class="d-flex flex-nowrap" #menu [class.gap-1]="small" [class.gap-2]="!small" [class.overflown-right]="!!overflowMenuItems && isRightAligned" [class.overflown-left]="!!overflowMenuItems && !isRightAligned">
<div
class="d-flex flex-nowrap"
#menu
[class.gap-1]="small"
[class.gap-2]="!small"
[class.overflown-left]="!!overflowMenuItems && !isRightAligned"
[class.overflown-right]="!!overflowMenuItems && isRightAligned">
<ng-content />
</div>
</div>

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

@ -22,7 +22,12 @@
<div class="notification-container notification-container-bottom-right">
@for (notification of snapshot.notifications; track notification) {
<div class="alert alert-light alert-dismissible border shadow-sm timed" animate.enter="fade-in" animate.leave="fade-out" (click)="close(notification)" role="alert">
<div
class="alert alert-light alert-dismissible border shadow-sm timed"
animate.enter="fade-in"
animate.leave="fade-out"
(click)="close(notification)"
role="alert">
<button class="btn-sm btn-close" data-dismiss="alert" (dialogClose)="close(notification)" type="button"></button>
<div class="timer timer-{{ notification.messageType }}"></div>
<div [sqxMarkdown]="notification.message | sqxTranslate"></div>

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

@ -1,4 +1,4 @@
<div class="modal" animate.enter="fade-in" animate.leave="fade-out" data-testid="dialog" cdkTrapFocus>
<div class="modal" animate.enter="fade-in" animate.leave="fade-out" cdkTrapFocus data-testid="dialog">
<div class="modal-dialog modal-{{ size }}" [class.modal-fh]="fullHeight">
<div class="modal-content" [sqxTourStep]="tourId">
@if (showHeader) {

13
frontend/src/app/framework/angular/modals/tour-template.component.html

@ -1,6 +1,7 @@
<div
class="tour-step"
animate.enter="fade-in" animate.leave="fade-out"
animate.enter="fade-in"
animate.leave="fade-out"
[class.d-none]="!isVisible"
offset="4"
[position]="currentStep.position || 'bottom-start'"
@ -31,13 +32,13 @@
<div class="d-flex justify-content-between">
<div class="d-flex pe-4">
@if (hasPrev) {
<button class="btn btn-outline-secondary btn-sm me-2" (click)="tourService.prev()">
<button class="btn btn-outline-secondary btn-sm me-2" (click)="tourService.prev()" type="button">
{{ "common.prev" | sqxTranslate }}
</button>
}
@if (currentStep.hideAll) {
<button class="btn btn-outline-danger btn-sm" (click)="currentStep.hideAll()">
<button class="btn btn-outline-danger btn-sm" (click)="currentStep.hideAll()" type="button">
{{ "tour.tooltipStop" | sqxTranslate }}
</button>
}
@ -45,19 +46,19 @@
<div class="d-flex ps-4">
@if (hasNext) {
<button class="btn btn-outline-secondary btn-sm" (click)="tourService.next()">
<button class="btn btn-outline-secondary btn-sm" (click)="tourService.next()" type="button">
{{ "common.next" | sqxTranslate }}
</button>
}
@if (hasFinish) {
<button class="btn btn-outline-success btn-sm" (click)="tourService.end()">
<button class="btn btn-outline-success btn-sm" (click)="tourService.end()" type="button">
{{ "common.finish" | sqxTranslate }}
</button>
}
@if (currentStep.hideThis) {
<button class="btn btn-outline-success btn-sm" (click)="currentStep.hideThis()">
<button class="btn btn-outline-success btn-sm" (click)="currentStep.hideThis()" type="button">
{{ "tour.tooltipConfirm" | sqxTranslate }}
</button>
}

4
frontend/src/app/framework/angular/pager.component.html

@ -14,11 +14,11 @@
<span class="page-info d-flex align-items-center justify-content-end">
<span class="btn deactivated">&nbsp;</span> &nbsp;
@if (paging.count > 0 && paging.total > 0) {
<button class="btn deactivated">{{ "common.pagerInfo" | sqxTranslate: translationInfo }}</button>
<button class="btn deactivated" type="button">{{ "common.pagerInfo" | sqxTranslate: translationInfo }}</button>
}
@if (paging.count > 0 && paging.total <= 0) {
<button class="btn" (click)="loadTotal.emit()" title="{{ 'common.pagerReload' | sqxTranslate }}">
<button class="btn" (click)="loadTotal.emit()" title="{{ 'common.pagerReload' | sqxTranslate }}" type="button">
{{ "common.pagerInfoNoTotal" | sqxTranslate: translationInfo }}
</button>
}

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

@ -6,14 +6,14 @@
<div class="col">
<ul class="nav nav-tabs2">
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)">
<button class="nav-link" [class.active]="selectedTab === 0" (click)="selectTab(0)" type="button">
{{ "assets.tabMetadata" | sqxTranslate }}
</button>
</li>
<li class="nav-item">
@if (isImage) {
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)">
<button class="nav-link" [class.active]="selectedTab === 1" (click)="selectTab(1)" type="button">
{{ "assets.tabImage" | sqxTranslate }}
</button>
}
@ -21,7 +21,7 @@
<li class="nav-item">
@if (isImage) {
<button class="nav-link" [class.active]="selectedTab === 2" (click)="selectTab(2)">
<button class="nav-link" [class.active]="selectedTab === 2" (click)="selectTab(2)" type="button">
{{ "assets.tabFocusPoint" | sqxTranslate }}
</button>
}
@ -29,7 +29,7 @@
<li class="nav-item">
@if (isDocumentLikely) {
<button class="nav-link" [class.active]="selectedTab === 3" (click)="selectTab(3)">
<button class="nav-link" [class.active]="selectedTab === 3" (click)="selectTab(3)" type="button">
{{ "assets.tabTextEditor" | sqxTranslate }}
</button>
}
@ -37,14 +37,14 @@
<li class="nav-item">
@if (isVideo || (asset | sqxPreviewable)) {
<button class="nav-link" [class.active]="selectedTab === 4" (click)="selectTab(4)">
<button class="nav-link" [class.active]="selectedTab === 4" (click)="selectTab(4)" type="button">
{{ "assets.tabPreview" | sqxTranslate }}
</button>
}
</li>
<li class="nav-item">
<button class="nav-link" [class.active]="selectedTab === 5" (click)="selectTab(5)">
<button class="nav-link" [class.active]="selectedTab === 5" (click)="selectTab(5)" type="button">
{{ "assets.tabHistory" | sqxTranslate }}
</button>
</li>
@ -60,7 +60,13 @@
}
@case (1) {
<button class="btn btn-primary" [class.invisible]="!isUploadable" (click)="cropImage()" [disabled]="progress > 0" type="button">
<button
class="btn btn-primary"
[class.invisible]="!isUploadable"
(click)="cropImage()"
[disabled]="progress > 0"
type="button"
type="button">
{{ "common.upload" | sqxTranslate }}
</button>
}

5
frontend/src/app/shared/components/assets/asset-folder.component.html

@ -17,7 +17,7 @@
</button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="editDropdown; closeAlways: true">
@if (canUpdate) {
<button class="dropdown-item" (click)="editDialog.show()">{{ "common.rename" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="editDialog.show()" type="button">{{ "common.rename" | sqxTranslate }}</button>
<div class="dropdown-divider"></div>
}
<button
@ -26,7 +26,8 @@
confirmRememberKey="deleteAssetFolder"
confirmText="i18n:assets.deleteFolderConfirmText"
confirmTitle="i18n:assets.deleteFolderConfirmTitle"
(sqxConfirmClick)="emitDelete()">
(sqxConfirmClick)="emitDelete()"
type="button">
{{ "common.delete" | sqxTranslate }}
</button>
</sqx-dropdown-menu>

2
frontend/src/app/shared/components/assets/asset-path.component.html

@ -3,7 +3,7 @@
@if (i > 0) {
<i class="icon-angle-right"></i>
}
<button class="btn" [class.first]="i === 0" [class.force]="i < path.length - 1 || all" (click)="navigate.emit(item)">
<button class="btn" [class.first]="i === 0" [class.force]="i < path.length - 1 || all" (click)="navigate.emit(item)" type="button">
{{ item.folderName | sqxTranslate }}
</button>
} @empty {

10
frontend/src/app/shared/components/assets/asset.component.html

@ -25,7 +25,7 @@
<div class="overlay-menu">
@if (!isDisabled) {
<button class="file-edit ms-2" attr.aria-label="{{ 'common.edit' | sqxTranslate }}" (click)="emitEdit()">
<button class="file-edit ms-2" attr.aria-label="{{ 'common.edit' | sqxTranslate }}" (click)="emitEdit()" type="button">
<i class="icon-pencil"></i>
</button>
}
@ -38,7 +38,7 @@
<i class="icon-download"></i>
</a>
@if (folderIcon) {
<button class="file-folder ms-2" (click)="selectFolder.emit(asset.parentId)"><i class="icon-folder"></i></button>
<button class="file-folder ms-2" (click)="selectFolder.emit(asset.parentId)" type="button"><i class="icon-folder"></i></button>
}
@if (!isDisabled && !removeMode && asset.canDelete) {
@ -48,7 +48,8 @@
confirmRememberKey="deleteAsset"
confirmText="i18n:assets.deleteConfirmText"
confirmTitle="i18n:assets.deleteConfirmTitle"
(sqxConfirmClick)="delete.emit()">
(sqxConfirmClick)="delete.emit()"
type="button">
<i class="icon-delete"></i>
</button>
}
@ -60,7 +61,8 @@
confirmRememberKey="removeAsset"
confirmText="i18n:assets.removeConfirmText"
confirmTitle="i18n:assets.removeConfirmTitle"
(sqxConfirmClick)="remove.emit()">
(sqxConfirmClick)="remove.emit()"
type="button">
<i class="icon-close"></i>
</button>
}

2
frontend/src/app/shared/components/cards/api-calls-card.component.html

@ -3,7 +3,7 @@
{{ "dashboard.apiCallsCard" | sqxTranslate }}
@if (app) {
<div class="float-end">
<button class="btn-inline-link" (click)="downloadLog(app)">
<button class="btn-inline-link" (click)="downloadLog(app)" type="button">
<small>{{ "dashboard.downloadLog" | sqxTranslate }}</small>
</button>
</div>

2
frontend/src/app/shared/components/contents/content-value.component.html

@ -4,7 +4,7 @@
</div>
@if (isString && fields) {
<button class="btn btn-icon" (click)="toggle()" sqxStopClick><i class="icon-wrap_text"></i></button>
<button class="btn btn-icon" (click)="toggle()" sqxStopClick type="button"><i class="icon-wrap_text"></i></button>
}
} @else {
<div

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

@ -114,7 +114,7 @@
</button>
}
<button class="btn btn-success" (click)="emitSelect()" [disabled]="selectionCount === 0 || selectionCount > maxItems" type="submit">
<button class="btn btn-success" (click)="emitSelect()" [disabled]="selectionCount === 0 || selectionCount > maxItems" type="button" type="submit">
{{ "contents.referencesLink" | sqxTranslate: { count: selectionCount } }}
</button>
</div>

4
frontend/src/app/shared/components/search/query-list.component.html

@ -2,7 +2,7 @@
<ul class="nav nav-light flex-column">
@for (saved of queries; track saved.name) {
<li class="nav-item">
<button class="nav-link" [class.active]="isSelectedQuery(saved)" (click)="search.emit(saved.query)">
<button class="nav-link" [class.active]="isSelectedQuery(saved)" (click)="search.emit(saved.query)" type="button">
<div class="row g-0">
<div class="col truncate">
<i class="icon-circle" [class.hidden]="!saved.color" [style.color]="saved.color"></i>
@ -11,7 +11,7 @@
<div class="col-auto">
@if (canRemove) {
<button class="btn-sm btn-text-danger btn-remove" (click)="remove.emit(saved)" sqxStopClick>
<button class="btn-sm btn-text-danger btn-remove" (click)="remove.emit(saved)" sqxStopClick type="button">
<i class="icon-close"></i>
</button>
}

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

@ -157,6 +157,8 @@ export class SearchFormComponent {
let args;
let component: Type<FieldComponent<any>> | undefined = undefined;
let operators = [...model.operators[field.schema.type]];
const { type, extra } = field.schema;
if (field.schema.type === 'Boolean') {
component = BooleanValue;
@ -176,6 +178,7 @@ export class SearchFormComponent {
} else if (type === 'String' && !extra) {
component = StringValue;
} else if (type === 'StringArray' && extra?.schemaIds) {
operators = ['eq'];
args = { editor: 'Reference', schemaIds: extra.schemaIds };
} else if (type === 'StringArray') {
component = StringValue;
@ -189,7 +192,7 @@ export class SearchFormComponent {
defaultValue: null,
description: field.description,
label: field.path,
operators: model.operators[field.schema.type] as any,
operators,
path: field.path,
};

2
frontend/src/app/shared/components/table-header.component.html

@ -1,5 +1,5 @@
@if (sortable) {
<button class="btn-inline-link truncate" (click)="sort()">
<button class="btn-inline-link truncate" (click)="sort()" type="button">
<span class="truncate">
@if (order === "ascending") {
<i class="icon-caret-up"></i>

5
frontend/src/app/shared/components/tour-guide.component.html

@ -7,7 +7,7 @@
<div class="col">{{ "tour.guide-title" | sqxTranslate }}</div>
<div class="col-auto">
<button class="btn btn-simple btn-sm" (click)="toggle()"><i class="icon-angle-down"></i></button>
<button class="btn btn-simple btn-sm" (click)="toggle()" type="button"><i class="icon-angle-down"></i></button>
</div>
</div>
</div>
@ -36,7 +36,8 @@
class="btn btn-outline-secondary btn-block mt-2"
(click)="complete()"
[sqxScrollActive]="(tourState.pendingTasks | async) === 0"
[sqxScrollContainer]="body">
[sqxScrollContainer]="body"
type="button">
{{ "tour.complete" | sqxTranslate }}
</button>
</div>

1
frontend/src/app/shared/model/custom.ts

@ -586,6 +586,7 @@ export class SchemaDto extends generated.SchemaDto {
public export(): any {
const fieldKeys = [
'fieldId',
'cachedValues',
'parentId',
'parentFieldId',
'_links',

1
frontend/src/app/shared/model/generated.ts

@ -2383,6 +2383,7 @@ export class SchemaDto extends ResourceDto implements ISchemaDto {
public export(): any {
const fieldKeys = [
'fieldId',
'cachedValues',
'parentId',
'parentFieldId',
'_links',

12
frontend/src/app/shell/pages/internal/profile-menu.component.html

@ -13,14 +13,18 @@
<div class="dropdown-divider"></div>
@if (uiState.canUseAdminResource | async) {
<button class="dropdown-item" routerLink="/app/administration">{{ "common.administration" | sqxTranslate }}</button>
<button class="dropdown-item" routerLink="/app/administration" type="button">{{ "common.administration" | sqxTranslate }}</button>
}
<a class="dropdown-item" href="{{ snapshot.profileUrl }}" sqxExternalLink="noicon"> {{ "profile.title" | sqxTranslate }} </a>
<div class="dropdown-submenu">
<button class="dropdown-item dropdown-toggle" (click)="toggleSubmenu()" sqxStopClick>{{ "common.language" | sqxTranslate }}</button>
<button class="dropdown-item dropdown-toggle" (click)="toggleSubmenu()" sqxStopClick type="button">{{ "common.language" | sqxTranslate }}</button>
<div class="dropdown-menu" [class.open]="snapshot.showSubmenu">
@for (availableLanguage of languages; track availableLanguage) {
<button class="dropdown-item" [class.active]="availableLanguage.iso2Code === language" (click)="changeLanguage(availableLanguage.iso2Code)">
<button
class="dropdown-item"
[class.active]="availableLanguage.iso2Code === language"
(click)="changeLanguage(availableLanguage.iso2Code)"
type="button">
{{ availableLanguage.localName }}
</button>
}
@ -28,5 +32,5 @@
</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item" (click)="logout()" sqxExternalLink>{{ "common.logout" | sqxTranslate }}</button>
<button class="dropdown-item" (click)="logout()" sqxExternalLink type="button">{{ "common.logout" | sqxTranslate }}</button>
</sqx-dropdown-menu>

Loading…
Cancel
Save