Browse Source

Daily test #1 (#1126)

* Daily test #1

* More tests

* More tests

* More tests

* Retry tests.

* Fix rename.

* More tests

* PII permission and frontend fixes.

* Fix pager.

* Fix tests

* Make tests more stable.

* Improve tests.

* Improve tests.

* Tests

* Fix count update.
pull/1128/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
ef2b4e2b95
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/dev.yml
  2. 2
      .github/workflows/release.yml
  3. 3
      backend/i18n/frontend_en.json
  4. 3
      backend/i18n/frontend_fr.json
  5. 3
      backend/i18n/frontend_it.json
  6. 3
      backend/i18n/frontend_nl.json
  7. 3
      backend/i18n/frontend_pt.json
  8. 3
      backend/i18n/frontend_zh.json
  9. 3
      backend/i18n/source/frontend_en.json
  10. 6
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/MongoCountCollection.cs
  11. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  12. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/UserGraphType.cs
  13. 3
      backend/src/Squidex.Shared/PermissionIds.cs
  14. 2
      backend/src/Squidex/appsettings.json
  15. 2
      backend/src/Squidex/wwwroot/editor/squidex-editor.css
  16. 306
      backend/src/Squidex/wwwroot/editor/squidex-editor.js
  17. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppTests.cs
  18. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs
  19. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs
  20. 61
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  21. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLSubscriptionTests.cs
  22. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestAsset.cs
  23. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs
  24. 41
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestQuery.cs
  25. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/Mocks.cs
  26. 1
      frontend/src/app/features/administration/pages/event-consumers/event-consumers-page.component.html
  27. 1
      frontend/src/app/features/administration/pages/restore/restore-page.component.html
  28. 1
      frontend/src/app/features/administration/pages/users/users-page.component.html
  29. 1
      frontend/src/app/features/apps/pages/apps-page.component.html
  30. 4
      frontend/src/app/features/apps/pages/apps-page.component.scss
  31. 2
      frontend/src/app/features/assets/pages/assets-page.component.html
  32. 3
      frontend/src/app/features/content/pages/content/content-page.component.html
  33. 2
      frontend/src/app/features/content/pages/contents/contents-page.component.html
  34. 3
      frontend/src/app/features/rules/pages/rule/rule-page.component.html
  35. 2
      frontend/src/app/features/rules/pages/rules/rules-page.component.html
  36. 2
      frontend/src/app/features/schemas/pages/schema/fields/field.component.scss
  37. 2
      frontend/src/app/features/schemas/pages/schema/schema-page.component.html
  38. 2
      frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html
  39. 27
      frontend/src/app/features/settings/pages/clients/client.component.html
  40. 3
      frontend/src/app/features/settings/pages/clients/client.component.ts
  41. 2
      frontend/src/app/features/settings/pages/clients/clients-page.component.html
  42. 2
      frontend/src/app/features/settings/pages/contributors/contributors-page.component.html
  43. 1
      frontend/src/app/features/settings/pages/jobs/jobs-page.component.html
  44. 2
      frontend/src/app/features/settings/pages/languages/language-add-form.component.html
  45. 11
      frontend/src/app/features/settings/pages/languages/language.component.html
  46. 2
      frontend/src/app/features/settings/pages/languages/languages-page.component.html
  47. 2
      frontend/src/app/features/settings/pages/more/more-page.component.html
  48. 2
      frontend/src/app/features/settings/pages/plans/plans-page.component.html
  49. 2
      frontend/src/app/features/settings/pages/roles/roles-page.component.html
  50. 22
      frontend/src/app/features/settings/pages/settings/settings-page.component.html
  51. 1
      frontend/src/app/features/settings/pages/templates/templates-page.component.html
  52. 2
      frontend/src/app/features/settings/pages/workflows/workflows-page.component.html
  53. 2
      frontend/src/app/features/teams/pages/auth/auth-page.component.html
  54. 2
      frontend/src/app/features/teams/pages/contributors/contributors-page.component.html
  55. 3
      frontend/src/app/features/teams/pages/more/more-page.component.html
  56. 2
      frontend/src/app/features/teams/pages/plans/plans-page.component.html
  57. 6
      frontend/src/app/framework/angular/forms/editable-title.component.html
  58. 2
      frontend/src/app/framework/angular/forms/editors/autocomplete.component.html
  59. 6
      frontend/src/app/framework/angular/forms/editors/autocomplete.component.ts
  60. 4
      frontend/src/app/framework/angular/forms/editors/dropdown.component.html
  61. 13
      frontend/src/app/framework/angular/forms/editors/dropdown.component.scss
  62. 6
      frontend/src/app/framework/angular/forms/editors/dropdown.component.ts
  63. 7
      frontend/src/app/framework/angular/forms/editors/dropdown.stories.ts
  64. 49
      frontend/src/app/framework/angular/modals/modal-placement.directive.ts
  65. 2
      frontend/src/app/framework/angular/pager.component.scss
  66. 3
      tools/e2e/.eslintrc.js
  67. 22
      tools/e2e/package-lock.json
  68. 4
      tools/e2e/package.json
  69. 14
      tools/e2e/tests/_fixture.ts
  70. 9
      tools/e2e/tests/given-app/_setup.ts
  71. 117
      tools/e2e/tests/given-app/assets.spec.ts
  72. 24
      tools/e2e/tests/given-app/rules.spec.ts
  73. 72
      tools/e2e/tests/given-app/schemas.spec.ts
  74. 3
      tools/e2e/tests/given-login/apps.spec.ts
  75. 89
      tools/e2e/tests/given-login/clients.spec.ts
  76. 92
      tools/e2e/tests/given-login/languages.spec.ts
  77. 44
      tools/e2e/tests/given-login/settings.spec.ts
  78. 2
      tools/e2e/tests/given-schema/_setup.ts
  79. 10
      tools/e2e/tests/given-schema/contents.spec.ts
  80. 12
      tools/e2e/tests/pages/apps.ts
  81. 65
      tools/e2e/tests/pages/assets.ts
  82. 60
      tools/e2e/tests/pages/clients.ts
  83. 2
      tools/e2e/tests/pages/contents.ts
  84. 14
      tools/e2e/tests/pages/dropdown.ts
  85. 5
      tools/e2e/tests/pages/index.ts
  86. 64
      tools/e2e/tests/pages/languages.ts
  87. 24
      tools/e2e/tests/pages/rename.ts
  88. 23
      tools/e2e/tests/pages/rules.ts
  89. 56
      tools/e2e/tests/pages/settings.ts
  90. 7
      tools/e2e/tests/utils.ts

2
.github/workflows/dev.yml

@ -96,7 +96,7 @@ jobs:
working-directory: './tools/e2e'
- name: Test - Run Playwright Tests
run: npx playwright test
run: npx playwright test --retries=3
working-directory: './tools/e2e'
env:
BASE__URL: http://localhost:8080

2
.github/workflows/release.yml

@ -91,7 +91,7 @@ jobs:
working-directory: './tools/e2e'
- name: Test - Run Playwright Tests
run: npx playwright test
run: npx playwright test --retries=3
working-directory: './tools/e2e'
env:
BASE__URL: http://localhost:8080

3
backend/i18n/frontend_en.json

@ -180,6 +180,8 @@
"clients.connectWizard.step0Title": "Setup client",
"clients.connectWizard.step1Title": "Choose connection method",
"clients.connectWizard.step2Title": "Connect",
"clients.copyClientId": "Copy Client ID",
"clients.copyClientSecret": "Copy Client Secret",
"clients.deleteConfirmText": "Do you really want to revoke the client?",
"clients.deleteConfirmTitle": "Revoke client",
"clients.empty": "No client created yet.",
@ -194,6 +196,7 @@
"comments.follow": "Follow",
"comments.title": "Comments",
"common.actions": "Actions",
"common.add": "Add",
"common.administration": "Administration",
"common.administrationPageTitle": "Administration",
"common.api": "API",

3
backend/i18n/frontend_fr.json

@ -180,6 +180,8 @@
"clients.connectWizard.step0Title": "Configurer le client",
"clients.connectWizard.step1Title": "Choisissez la méthode de connexion",
"clients.connectWizard.step2Title": "Connecter",
"clients.copyClientId": "Copy Client ID",
"clients.copyClientSecret": "Copy Client Secret",
"clients.deleteConfirmText": "Voulez-vous vraiment révoquer le client\u00A0?",
"clients.deleteConfirmTitle": "Révoquer le client",
"clients.empty": "Aucun client créé pour le moment.",
@ -194,6 +196,7 @@
"comments.follow": "Suivre",
"comments.title": "commentaires",
"common.actions": "Actions",
"common.add": "Add",
"common.administration": "Administration",
"common.administrationPageTitle": "Administration",
"common.api": "API",

3
backend/i18n/frontend_it.json

@ -180,6 +180,8 @@
"clients.connectWizard.step0Title": "Setup client",
"clients.connectWizard.step1Title": "Scegli la tipologia di connessione",
"clients.connectWizard.step2Title": "Collega",
"clients.copyClientId": "Copy Client ID",
"clients.copyClientSecret": "Copy Client Secret",
"clients.deleteConfirmText": "Sei sicuro di voler rimuovere il client?",
"clients.deleteConfirmTitle": "Rimuovere il client",
"clients.empty": "Nessun client ancora creato.",
@ -194,6 +196,7 @@
"comments.follow": "Segui",
"comments.title": "Commenti",
"common.actions": "Azioni",
"common.add": "Add",
"common.administration": "Amministrazione",
"common.administrationPageTitle": "Amministrazione",
"common.api": "API",

3
backend/i18n/frontend_nl.json

@ -180,6 +180,8 @@
"clients.connectWizard.step0Title": "Client instellen",
"clients.connectWizard.step1Title": "Kies verbindingsmethode",
"clients.connectWizard.step2Title": "Verbinden",
"clients.copyClientId": "Copy Client ID",
"clients.copyClientSecret": "Copy Client Secret",
"clients.deleteConfirmText": "Wil je de client echt intrekken?",
"clients.deleteConfirmTitle": "Client intrekken",
"clients.empty": "Nog geen client aangemaakt.",
@ -194,6 +196,7 @@
"comments.follow": "Volgen",
"comments.title": "Reacties",
"common.actions": "Acties",
"common.add": "Add",
"common.administration": "Administratie",
"common.administrationPageTitle": "Administratie",
"common.api": "API",

3
backend/i18n/frontend_pt.json

@ -180,6 +180,8 @@
"clients.connectWizard.step0Title": "Cliente de configuração",
"clients.connectWizard.step1Title": "Escolha o método de ligação",
"clients.connectWizard.step2Title": "Ligar",
"clients.copyClientId": "Copy Client ID",
"clients.copyClientSecret": "Copy Client Secret",
"clients.deleteConfirmText": "Quer mesmo revogar o cliente?",
"clients.deleteConfirmTitle": "Revogar cliente",
"clients.empty": "Nenhum cliente foi criado ainda.",
@ -194,6 +196,7 @@
"comments.follow": "Seguir",
"comments.title": "Comentários",
"common.actions": "Ações",
"common.add": "Add",
"common.administration": "Administração",
"common.administrationPageTitle": "Administração",
"common.api": "API",

3
backend/i18n/frontend_zh.json

@ -180,6 +180,8 @@
"clients.connectWizard.step0Title": "设置客户端",
"clients.connectWizard.step1Title": "选择连接方式",
"clients.connectWizard.step2Title": "连接",
"clients.copyClientId": "Copy Client ID",
"clients.copyClientSecret": "Copy Client Secret",
"clients.deleteConfirmText": "你真的要撤销客户端吗?",
"clients.deleteConfirmTitle": "撤销客户端",
"clients.empty": "尚未创建客户端。",
@ -194,6 +196,7 @@
"comments.follow": "关注",
"comments.title": "评论",
"common.actions": "动作",
"common.add": "Add",
"common.administration": "管理",
"common.administrationPageTitle": "管理",
"common.api": "API",

3
backend/i18n/source/frontend_en.json

@ -180,6 +180,8 @@
"clients.connectWizard.step0Title": "Setup client",
"clients.connectWizard.step1Title": "Choose connection method",
"clients.connectWizard.step2Title": "Connect",
"clients.copyClientId": "Copy Client ID",
"clients.copyClientSecret": "Copy Client Secret",
"clients.deleteConfirmText": "Do you really want to revoke the client?",
"clients.deleteConfirmTitle": "Revoke client",
"clients.empty": "No client created yet.",
@ -194,6 +196,7 @@
"comments.follow": "Follow",
"comments.title": "Comments",
"common.actions": "Actions",
"common.add": "Add",
"common.administration": "Administration",
"common.administrationPageTitle": "Administration",
"common.api": "API",

6
backend/src/Squidex.Domain.Apps.Entities.MongoDb/MongoCountCollection.cs

@ -78,9 +78,9 @@ internal sealed class MongoCountCollection : MongoRepositoryBase<MongoCountEntit
await Collection.UpdateOneAsync(x => x.Key == key,
Update
.Set(x => x.Key, key)
.SetOnInsert(x => x.Count, actualCount)
.SetOnInsert(x => x.Created, now),
.SetOnInsert(x => x.Key, key)
.Set(x => x.Count, actualCount)
.Set(x => x.Created, now),
Upsert, ct);
return actualCount;

5
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Cache;
using Squidex.Domain.Apps.Entities.Contents.Queries;
using Squidex.Infrastructure;
using Squidex.Shared;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL;
@ -28,6 +29,8 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
public override Context Context { get; }
public bool CanExposePII { get; }
public GraphQLExecutionContext(
IDataLoaderContextAccessor dataLoaders,
IAssetQueryService assetQuery,
@ -58,6 +61,8 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
{
batchSize = Math.Max(MinBatchSize, Math.Min(MaxBatchSize, batchSize));
}
CanExposePII = Context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppPii, context.App.Name));
}
public async ValueTask<IUser?> FindUserAsync(RefToken refToken,

17
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/UserGraphType.cs

@ -35,7 +35,7 @@ internal sealed class UserGraphType : SharedObjectGraphType<IUser>
AddField(new FieldType
{
Name = "displayName",
Resolver = Resolve(x => x.Claims.DisplayName()),
Resolver = ResolveOrHide(x => x.Claims.DisplayName()),
ResolvedType = Scalars.String,
Description = FieldDescriptions.UserDisplayName
});
@ -43,7 +43,7 @@ internal sealed class UserGraphType : SharedObjectGraphType<IUser>
AddField(new FieldType
{
Name = "email",
Resolver = Resolve(x => x.Email),
Resolver = ResolveOrHide(x => x.Email),
ResolvedType = Scalars.String,
Description = FieldDescriptions.UserEmail
});
@ -55,4 +55,17 @@ internal sealed class UserGraphType : SharedObjectGraphType<IUser>
{
return Resolvers.Sync(resolver);
}
private static IFieldResolver ResolveOrHide(Func<IUser, string?> resolver)
{
return Resolvers.Sync<IUser, string?>((source, _, context) =>
{
if (context.CanExposePII)
{
return resolver(source);
}
return "Hidden";
});
}
}

3
backend/src/Squidex.Shared/PermissionIds.cs

@ -103,6 +103,9 @@ public static class PermissionIds
// App Usage
public const string AppUsage = "squidex.apps.{app}.usage";
// App Expose Users
public const string AppPii = "squidex.apps.{app}.pii";
// App Comments
public const string AppComments = "squidex.apps.{app}.comments";
public const string AppCommentsRead = "squidex.apps.{app}.comments.read";

2
backend/src/Squidex/appsettings.json

@ -168,7 +168,7 @@
"hideDateTimeModeButton": false,
// Show the exposed values as information on the apps overview page.
"showInfo": false,
"showInfo": true,
// The number of content items for dropdown selector.
"referencesDropdownItemCount": 100

2
backend/src/Squidex/wwwroot/editor/squidex-editor.css

File diff suppressed because one or more lines are too long

306
backend/src/Squidex/wwwroot/editor/squidex-editor.js

File diff suppressed because one or more lines are too long

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppTests.cs

@ -163,7 +163,7 @@ public class GuardAppTests : GivenContext, IClassFixture<TranslationsFixture>
[Fact]
public async Task CanTransfer_should_not_throw_exception_if_user_has_transfer_permission()
{
var admin = Mocks.ApiUser(permission: PermissionIds.Transfer);
var admin = Mocks.ApiUser(permissions: [PermissionIds.Transfer]);
var command = new TransferToTeam { TeamId = TeamId, Actor = User, User = admin };

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs

@ -311,10 +311,10 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
[Fact]
public void Should_not_throw_exception_if_content_is_from_another_user_but_user_has_permission()
{
var userPermission = PermissionIds.ForApp(PermissionIds.AppContentsDelete, AppId.Name, SchemaId.Name).Id;
var userObject = Mocks.FrontendUser(permission: userPermission);
var userPermissions = PermissionIds.ForApp(PermissionIds.AppContentsDelete, AppId.Name, SchemaId.Name).Id;
var userPrincipal = Mocks.FrontendUser(permissions: [userPermissions]);
var operation = Operation(CreateContent(Status.Draft) with { CreatedBy = RefToken.User("invalid") }, normalSchema, userObject);
var operation = Operation(CreateContent(Status.Draft) with { CreatedBy = RefToken.User("invalid") }, normalSchema, userPrincipal);
operation.MustHavePermission(PermissionIds.AppContentsDelete);
}

20
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs

@ -104,7 +104,7 @@ public class GraphQLMutationTests : GraphQLTestBase
{
data = TestContent.Input(content),
},
Permission = PermissionIds.AppContentsCreate
Permissions = [PermissionIds.AppContentsCreate]
});
var expected = new
@ -156,7 +156,7 @@ public class GraphQLMutationTests : GraphQLTestBase
longitude = 13
}
},
Permission = PermissionIds.AppContentsCreate
Permissions = [PermissionIds.AppContentsCreate]
});
var expected = new
@ -202,7 +202,7 @@ public class GraphQLMutationTests : GraphQLTestBase
{
data = TestContent.Input(content)
},
Permission = PermissionIds.AppContentsCreate
Permissions = [PermissionIds.AppContentsCreate]
});
var expected = new
@ -306,7 +306,7 @@ public class GraphQLMutationTests : GraphQLTestBase
{
data = TestContent.Input(content)
},
Permission = PermissionIds.AppContentsUpdateOwn
Permissions = [PermissionIds.AppContentsUpdateOwn]
});
var expected = new
@ -405,7 +405,7 @@ public class GraphQLMutationTests : GraphQLTestBase
{
data = TestContent.Input(content)
},
Permission = PermissionIds.AppContentsUpsert
Permissions = [PermissionIds.AppContentsUpsert]
});
var expected = new
@ -509,7 +509,7 @@ public class GraphQLMutationTests : GraphQLTestBase
{
data = TestContent.Input(content)
},
Permission = PermissionIds.AppContentsUpdateOwn
Permissions = [PermissionIds.AppContentsUpdateOwn]
});
var expected = new
@ -610,7 +610,7 @@ public class GraphQLMutationTests : GraphQLTestBase
contentId,
fields = TestContent.AllFields
},
Permission = PermissionIds.AppContentsChangeStatusOwn
Permissions = [PermissionIds.AppContentsChangeStatusOwn]
});
var expected = new
@ -652,7 +652,7 @@ public class GraphQLMutationTests : GraphQLTestBase
contentId,
fields = TestContent.AllFields
},
Permission = PermissionIds.AppContentsChangeStatusOwn
Permissions = [PermissionIds.AppContentsChangeStatusOwn]
});
var expected = new
@ -694,7 +694,7 @@ public class GraphQLMutationTests : GraphQLTestBase
contentId,
fields = TestContent.AllFields
},
Permission = PermissionIds.AppContentsChangeStatusOwn
Permissions = [PermissionIds.AppContentsChangeStatusOwn]
});
var expected = new
@ -790,7 +790,7 @@ public class GraphQLMutationTests : GraphQLTestBase
{
contentId
},
Permission = PermissionIds.AppContentsDeleteOwn
Permissions = [PermissionIds.AppContentsDeleteOwn]
});
var expected = new

61
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL;
@ -1973,4 +1974,64 @@ public class GraphQLQueriesTests : GraphQLTestBase
A.CallTo(() => userResolver.FindByIdAsync(A<string>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_expose_user_information_if_user_has_permission()
{
var assetId = DomainId.NewGuid();
var asset = TestAsset.Create(assetId);
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null,
A<Q>.That.HasIdsWithoutTotal(assetId),
A<CancellationToken>._))
.Returns(ResultList.CreateFrom(1, asset));
var actual = await ExecuteAsync(new TestQuery
{
Query = @"
query {
findAsset(id: '{assetId}') {
createdByUser {
id,
email,
displayName
},
lastModifiedByUser {
id,
email,
displayName
}
}
}",
Args = new
{
assetId
},
Permissions = [PermissionIds.AppPii]
});
var expected = new
{
data = new
{
findAsset = new
{
createdByUser = new
{
id = asset.CreatedBy.Identifier,
email = $"{asset.CreatedBy.Identifier}@email.com",
displayName = $"{asset.CreatedBy.Identifier}name"
},
lastModifiedByUser = new
{
id = asset.LastModifiedBy.Identifier,
email = $"{asset.LastModifiedBy}",
displayName = asset.LastModifiedBy.Identifier
}
}
}
};
AssertResult(expected, actual);
}
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLSubscriptionTests.cs

@ -43,7 +43,7 @@ public class GraphQLSubscriptionTests : GraphQLTestBase
fileSize
}
}",
Permission = PermissionIds.AppAssetsRead
Permissions = [PermissionIds.AppAssetsRead]
});
var expected = new
@ -140,7 +140,7 @@ public class GraphQLSubscriptionTests : GraphQLTestBase
data
}
}",
Permission = PermissionIds.AppContentsRead
Permissions = [PermissionIds.AppContentsRead]
});
var expected = new

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestAsset.cs

@ -102,8 +102,8 @@ public static class TestAsset
createdByUser = new
{
id = asset.CreatedBy.Identifier,
email = $"{asset.CreatedBy.Identifier}@email.com",
displayName = $"{asset.CreatedBy.Identifier}name"
email = "Hidden",
displayName = "Hidden"
},
editToken = $"token_{asset.Id}",
lastModified = asset.LastModified,
@ -111,8 +111,8 @@ public static class TestAsset
lastModifiedByUser = new
{
id = asset.LastModifiedBy.Identifier,
email = $"{asset.LastModifiedBy}",
displayName = asset.LastModifiedBy.Identifier
email = "Hidden",
displayName = "Hidden"
},
url = $"assets/{asset.AppId.Name}/{asset.Id}",
thumbnailUrl = $"assets/{asset.AppId.Name}/{asset.Id}?width=100",

16
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs

@ -405,8 +405,8 @@ public static class TestContent
createdByUser = new
{
id = content.CreatedBy.Identifier,
email = $"{content.CreatedBy.Identifier}@email.com",
displayName = $"{content.CreatedBy.Identifier}name"
email = "Hidden",
displayName = "Hidden"
},
editToken = $"token_{content.Id}",
lastModified = content.LastModified,
@ -414,8 +414,8 @@ public static class TestContent
lastModifiedByUser = new
{
id = content.LastModifiedBy.Identifier,
email = $"{content.LastModifiedBy}",
displayName = content.LastModifiedBy.Identifier
email = "Hidden",
displayName = "Hidden"
},
status = "DRAFT",
statusColor = "red",
@ -437,8 +437,8 @@ public static class TestContent
createdByUser = new
{
id = content.CreatedBy.Identifier,
email = $"{content.CreatedBy.Identifier}@email.com",
displayName = $"{content.CreatedBy.Identifier}name"
email = "Hidden",
displayName = "Hidden",
},
editToken = $"token_{content.Id}",
lastModified = content.LastModified,
@ -446,8 +446,8 @@ public static class TestContent
lastModifiedByUser = new
{
id = content.LastModifiedBy.Identifier,
email = $"{content.LastModifiedBy}",
displayName = content.LastModifiedBy.Identifier
email = "Hidden",
displayName = "Hidden",
},
status = "DRAFT",
statusColor = "red",

41
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestQuery.cs

@ -26,7 +26,7 @@ public sealed class TestQuery
public object? Args { get; set; }
public string? Permission { get; set; }
public string[] Permissions { get; set; } = [];
public string? OperationName { get; set; }
@ -42,16 +42,21 @@ public sealed class TestQuery
}
}
query = query.Replace('\'', '\"');
var userPermissions = Permissions.Select(p => PermissionIds.ForApp(p, TestApp.Default.Name, TestSchemas.Default.Name).Id);
var userPrincipal = Mocks.FrontendUser(null, userPermissions.ToArray());
var context = new Context(userPrincipal, TestApp.Default);
var options = new ExecutionOptions
{
Query = query.Replace('\'', '\"')
Query = query,
User = userPrincipal,
UserContext = ActivatorUtilities.CreateInstance<GraphQLExecutionContext>(services, context),
OperationName = OperationName,
};
if (OperationName != null)
{
options.OperationName = OperationName;
}
if (Variables != null)
{
options.Variables = Serializer.ReadNode<Inputs>(Serialize(Variables))!;
@ -62,25 +67,11 @@ public sealed class TestQuery
options.Listeners.Add(listener);
}
options.UserContext = ActivatorUtilities.CreateInstance<GraphQLExecutionContext>(services, BuildContext(Permission));
return options;
}
static Context BuildContext(string? permissionId)
{
if (permissionId == null)
{
return new Context(Mocks.FrontendUser(), TestApp.Default);
}
var permission = PermissionIds.ForApp(permissionId, TestApp.Default.Name, TestSchemas.Default.Name).Id;
return new Context(Mocks.FrontendUser(permission: permission), TestApp.Default);
}
static JsonElement Serialize(object value)
{
return JsonSerializer.SerializeToElement(value, TestUtils.DefaultOptions());
}
private static JsonElement Serialize(object value)
{
return JsonSerializer.SerializeToElement(value, TestUtils.DefaultOptions());
}
}

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/Mocks.cs

@ -19,21 +19,11 @@ public static class Mocks
return CreateUser(false, role, permissions);
}
public static ClaimsPrincipal ApiUser(string? role = null, string? permission = null)
{
return CreateUser(false, role, permission);
}
public static ClaimsPrincipal FrontendUser(string? role = null, params string[] permissions)
{
return CreateUser(true, role, permissions);
}
public static ClaimsPrincipal FrontendUser(string? role = null, string? permission = null)
{
return CreateUser(true, role, permission);
}
public static ClaimsPrincipal CreateUser(bool isFrontend, string? role, params string?[] permissions)
{
var claimsIdentity = new ClaimsIdentity();

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

@ -50,6 +50,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -83,6 +83,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -67,6 +67,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

1
frontend/src/app/features/apps/pages/apps-page.component.html

@ -62,6 +62,7 @@
}
</div>
}
@if (info) {
<div class="apps-section">
<small class="info">{{ info }}</small>

4
frontend/src/app/features/apps/pages/apps-page.component.scss

@ -104,8 +104,4 @@
padding-top: 1rem;
}
}
}
.info {
color: $color-border-dark;
}

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

@ -62,6 +62,7 @@
<div class="col-auto">
<button
class="btn btn-success"
attr.aria-label="{{ 'i18n:assets.createFolder' | sqxTranslate }}"
(click)="addAssetFolderDialog.show()"
shortcut="CTRL + U"
title="i18n:assets.createFolderTooltip"
@ -102,6 +103,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.filters' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="filters"

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

@ -238,6 +238,7 @@
<a
class="panel-link"
#linkHistory
attr.aria-label="{{ 'common.workflow' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -250,6 +251,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.comments' | sqxTranslate }}"
hintAfter="120000"
hintText="i18n:common.sidebarTour"
queryParamsHandling="preserve"
@ -265,6 +267,7 @@
@if (schema.properties.contentSidebarUrl) {
<a
class="panel-link"
attr.aria-label="{{ 'common.sidebar' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="sidebar"

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

@ -178,6 +178,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.filters' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="filters"
@ -191,6 +192,7 @@
@if (schema.properties.contentsSidebarUrl) {
<a
class="panel-link"
attr.aria-label="{{ 'common.sidebar' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="sidebar"

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

@ -189,6 +189,7 @@
@if (rule && (rulesState.canReadEvents | async)) {
<a
class="panel-link panel-link-gray"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
[queryParams]="{ ruleId: rule.id }"
routerLink="events"
routerLinkActive="active"
@ -199,6 +200,7 @@
</a>
<a
class="panel-link panel-link-gray"
attr.aria-label="{{ 'rules.simulator' | sqxTranslate }}"
[queryParams]="{ ruleId: rule.id }"
routerLink="simulator"
routerLinkActive="active"
@ -212,6 +214,7 @@
<a
class="panel-link"
#helpLink
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
hintAfter="180000"
hintText="i18n:common.helpTour"
queryParamsHandling="preserve"

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

@ -52,6 +52,7 @@
@if (rulesState.canReadEvents | async) {
<a
class="panel-link panel-link-gray"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
routerLink="events"
routerLinkActive="active"
sqxTourStep="history"
@ -64,6 +65,7 @@
<a
class="panel-link"
#helpLink
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
hintAfter="180000"
hintText="i18n:common.helpTour"
queryParamsHandling="preserve"

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

@ -90,7 +90,7 @@ $padding: 1rem;
}
&-hidden {
color: $color-text-decent;
color: lighten($color-text-decent, 40%);
}
&-partitioning {

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

@ -118,6 +118,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -129,6 +130,7 @@
</a>
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -43,6 +43,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -55,6 +56,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -30,14 +30,24 @@
<div class="card-body">
<div class="container">
<div class="form-group row">
<label class="col-3 col-form-label">
<label class="col-3 col-form-label" for="{{ client.id }}_clientId">
{{ "common.clientId" | sqxTranslate }}
</label>
<div class="col">
<div class="input-group">
<input class="form-control" #clientId readonly value="{{ appsState.appName }}:{{ client.id }}" />
<input
class="form-control"
id="{{ client.id }}_clientId"
#clientId
readonly
value="{{ appsState.appName }}:{{ client.id }}" />
<button class="btn btn-outline-secondary" [sqxCopy]="clientId" type="button">
<button
class="btn btn-outline-secondary"
attr.aria-label="{{ 'i18n:clients.copyClientId' | sqxTranslate }}"
[sqxCopy]="clientId"
title="i18n:clients.copyClientId"
type="button">
<i class="icon-copy"></i>
</button>
</div>
@ -45,14 +55,19 @@
</div>
<div class="form-group row">
<label class="col-3 col-form-label">
<label class="col-3 col-form-label" for="{{ client.id }}_clientSecret">
{{ "common.clientSecret" | sqxTranslate }}
</label>
<div class="col">
<div class="input-group">
<input class="form-control" #inputSecret readonly [value]="client.secret" />
<input class="form-control" id="{{ client.id }}_clientSecret" #inputSecret readonly [value]="client.secret" />
<button class="btn btn-outline-secondary" [sqxCopy]="inputSecret" type="button">
<button
class="btn btn-outline-secondary"
attr.aria-label="{{ 'i18n:clients.copyClientSecret' | sqxTranslate }}"
[sqxCopy]="inputSecret"
title="i18n:clients.copyClientSecret"
type="button">
<i class="icon-copy"></i>
</button>
</div>

3
frontend/src/app/features/settings/pages/clients/client.component.ts

@ -8,7 +8,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AppsState, ClientDto, ClientsState, ConfirmClickDirective, CopyDirective, DialogModel, EditableTitleComponent, FormHintComponent, ModalDirective, RoleDto, TourStepDirective, TranslatePipe, TypedSimpleChanges } from '@app/shared';
import { AppsState, ClientDto, ClientsState, ConfirmClickDirective, CopyDirective, DialogModel, EditableTitleComponent, FormHintComponent, ModalDirective, RoleDto, TooltipDirective, TourStepDirective, TranslatePipe, TypedSimpleChanges } from '@app/shared';
import { ClientConnectFormComponent } from './client-connect-form.component';
@Component({
@ -25,6 +25,7 @@ import { ClientConnectFormComponent } from './client-connect-form.component';
FormHintComponent,
FormsModule,
ModalDirective,
TooltipDirective,
TourStepDirective,
TranslatePipe,
],

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

@ -31,6 +31,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -43,6 +44,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -70,6 +70,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -82,6 +83,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -39,6 +39,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

2
frontend/src/app/features/settings/pages/languages/language-add-form.component.html

@ -7,6 +7,8 @@
<sqx-autocomplete
displayProperty="iso2Code"
formControlName="language"
formId="language"
formName="{{ 'common.language' | sqxTranslate }}"
[itemsSource]="addLanguagesSource"
valueProperty="iso2Code">
<ng-template let-language="$implicit">{{ language.iso2Code }} ({{ language.englishName }})</ng-template>

11
frontend/src/app/features/settings/pages/languages/language.component.html

@ -1,16 +1,17 @@
<div class="table-items-row table-items-row-expandable language">
<div class="table-items-row-summary row gx-2 align-items-center">
<div class="col-2" [class.language-master]="language.isMaster" [class.language-optional]="language.isOptional">
{{ language.iso2Code }}
<span class="truncate">{{ language.iso2Code }}</span>
</div>
<div class="col" [class.language-master]="language.isMaster" [class.language-optional]="language.isOptional">
{{ language.englishName }}
<span class="truncate">{{ language.englishName }}</span>
</div>
<div class="col-auto">
<div class="float-end">
@if (!language.isMaster) {
<button
class="btn btn-outline-secondary btn-expand me-1"
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
[class.expanded]="isEditing"
(click)="toggleEditing()"
type="button">
@ -21,6 +22,7 @@
<button
class="btn btn-text-danger"
attr.aria-label="{{ 'common.delete' | sqxTranslate }}"
confirmRememberKey="removeLanguage"
confirmText="i18n:languages.deleteConfirmText"
confirmTitle="i18n:languages.deleteConfirmTitle"
@ -51,7 +53,9 @@
<div class="table-items-row-details-tab">
@if (isEditable || fallbackLanguages.length > 0) {
<div class="form-group row">
<label class="col-3 col-form-label fallback-label">{{ "common.fallback" | sqxTranslate }}</label>
<label class="col-3 col-form-label fallback-label" for="{{ language.iso2Code }}_fallback">{{
"common.fallback" | sqxTranslate
}}</label>
<div class="col-9">
@if (fallbackLanguages.length > 0) {
<div
@ -92,6 +96,7 @@
<div class="col">
<select
class="form-select fallback-select"
id="{{ language.iso2Code }}_fallback"
name="otherLanguage"
[(ngModel)]="otherLanguage">
@for (otherLanguage of fallbackLanguagesNew; track language.iso2Code) {

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

@ -30,6 +30,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -41,6 +42,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -154,6 +154,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -166,6 +167,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -64,6 +64,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -76,6 +77,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -51,6 +51,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -63,6 +64,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -25,7 +25,7 @@
{{ "appSettings.patterns.description" | sqxTranslate }}
</sqx-form-hint>
<div class="card card-body mb-4">
<div class="card card-body mb-4" data-testid="patterns">
<div class="content">
@if (!isEditable && editForm.patternsControls.length === 0) {
<div class="mt-4">
@ -34,7 +34,7 @@
}
@for (form of editForm.patternsControls; track form; let i = $index) {
<div class="form-group row gx-2" [formGroup]="form">
<div class="form-group row gx-2" attr.data-testid="pattern_{{ form.get('name')?.value }}" [formGroup]="form">
<div class="col-3">
<sqx-control-errors for="name"></sqx-control-errors>
<input
@ -62,6 +62,7 @@
<div class="col-auto">
<button
class="btn btn-text-danger"
attr.aria-label="{{ 'common.delete' | sqxTranslate }}"
confirmRememberKey="deletePattern"
confirmText="i18n:appSettings.patterns.deleteConfirmText"
confirmTitle="i18n:appSettings.patterns.deleteConfirmTitle"
@ -86,7 +87,11 @@
<div class="form-control preview">{{ "common.message" | sqxTranslate }}</div>
</div>
<div class="col-auto">
<button class="btn btn-success" (click)="editForm.patterns.add()" type="button">
<button
class="btn btn-success"
attr.aria-label="{{ 'common.add' | sqxTranslate }}"
(click)="editForm.patterns.add()"
type="button">
<i class="icon-add"></i>
</button>
</div>
@ -102,7 +107,7 @@
</sqx-form-hint>
<div class="card card-body mb-4">
<div class="content">
<div class="content" data-testid="pattern">
@if (!isEditable && editForm.editorsControls.length === 0) {
<div class="mt-4">
{{ "appSettings.editors.empty" | sqxTranslate }}
@ -130,6 +135,7 @@
<div class="col-auto">
<button
class="btn btn-text-danger"
attr.aria-label="{{ 'common.delete' | sqxTranslate }}"
confirmRememberKey="deleteEditor"
confirmText="i18n:appSettings.editors.deleteConfirmText"
confirmTitle="i18n:appSettings.editors.deleteConfirmTitle"
@ -151,7 +157,11 @@
<div class="form-control preview">{{ "common.url" | sqxTranslate }}</div>
</div>
<div class="col-auto">
<button class="btn btn-success" (click)="editForm.editors.add()" type="button">
<button
class="btn btn-success"
attr.aria-label="{{ 'common.add' | sqxTranslate }}"
(click)="editForm.editors.add()"
type="button">
<i class="icon-add"></i>
</button>
</div>
@ -180,6 +190,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -192,6 +203,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -31,6 +31,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -48,6 +48,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -60,6 +61,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -126,6 +126,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -137,6 +138,7 @@
</a>
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -56,6 +56,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -68,6 +69,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -62,6 +62,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -71,8 +72,10 @@
titlePosition="left">
<i class="icon-time"></i>
</a>
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

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

@ -56,6 +56,7 @@
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.history' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="history"
@ -68,6 +69,7 @@
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"

6
frontend/src/app/framework/angular/forms/editable-title.component.html

@ -44,7 +44,11 @@
</div>
@if (!disabled) {
<div class="col-auto title-edit">
<button class="btn btn-text-secondary btn-{{ size }}" (click)="toggleRename()" type="button">
<button
class="btn btn-text-secondary btn-{{ size }}"
attr.aria-label="{{ 'common.rename' | sqxTranslate }}"
(click)="toggleRename()"
type="button">
<i class="icon-pencil text-decent"></i>
</button>
</div>

2
frontend/src/app/framework/angular/forms/editors/autocomplete.component.html

@ -1,6 +1,7 @@
<div class="control-container">
<input
class="form-control"
[id]="formId"
#input
autocapitalize="off"
autocomplete="off"
@ -11,6 +12,7 @@
[class.form-underlined]="inputStyle === 'underlined'"
[formControl]="queryInput"
(keydown)="onKeyDown($event)"
[name]="formName"
[placeholder]="placeholder"
[sqxFocusOnInit]="autoFocus" />

6
frontend/src/app/framework/angular/forms/editors/autocomplete.component.ts

@ -80,6 +80,12 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
@Input({ transform: booleanAttribute })
public allowOpen?: boolean | null = false;
@Input()
public formId?: string;
@Input()
public formName?: string;
@Input()
public displayProperty = '';

4
frontend/src/app/framework/angular/forms/editors/dropdown.component.html

@ -1,6 +1,7 @@
<div class="selection">
<input
class="form-select"
[id]="formId"
#input
autocapitalize="off"
autocomplete="off"
@ -8,6 +9,7 @@
(click)="openModal()"
[disabled]="snapshot.isDisabled"
(keydown)="onKeyDown($event)"
[name]="formName"
readonly />
@if (snapshot.selectedItem; as selectedItem) {
@ -30,7 +32,7 @@
[adjustWidth]="dropdownFullWidth"
[position]="dropdownPosition"
scrollX="false"
scrollY="true"
scrollY="hidden"
[sqxAnchoredTo]="input"
*sqxModal="dropdown"
[style]="dropdownStyles">

13
frontend/src/app/framework/angular/forms/editors/dropdown.component.scss

@ -27,6 +27,11 @@ $color-input-disabled: #eef1f4;
padding: .5rem;
}
.items-container {
display: flex;
flex-direction: column;
}
.control-dropdown {
max-height: none;
max-width: 40rem;
@ -37,6 +42,7 @@ $color-input-disabled: #eef1f4;
max-height: 15rem;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: .5rem;
}
.selection {
@ -65,4 +71,11 @@ $color-input-disabled: #eef1f4;
sqx-loader {
margin-top: .25rem;
}
sqx-dropdown-menu {
display: flex !important;
flex-direction: column;
flex-grow: 0;
padding-bottom: 0;
}

6
frontend/src/app/framework/angular/forms/editors/dropdown.component.ts

@ -81,6 +81,12 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
@Input({ transform: booleanAttribute })
public itemSeparator?: boolean | null;
@Input()
public formId?: string;
@Input()
public formName?: string;
@Input()
public searchProperty = 'name';

7
frontend/src/app/framework/angular/forms/editors/dropdown.stories.ts

@ -113,6 +113,13 @@ export const Default: Story = {
},
};
export const Long: Story = {
args: {
items: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'],
model: 'B',
},
};
export const WrongModel: Story = {
args: {
items: ['A', 'B', 'C'],

49
frontend/src/app/framework/angular/modals/modal-placement.directive.ts

@ -20,11 +20,11 @@ export class ModalPlacementDirective implements AfterViewInit, OnDestroy {
@Input('sqxAnchoredTo')
public target?: Element;
@Input({ transform: booleanAttribute })
public scrollX = false;
@Input()
public scrollX?: Scroll;
@Input({ transform: booleanAttribute })
public scrollY = false;
@Input()
public scrollY?: Scroll;
@Input({ transform: numberAttribute })
public scrollMargin = 10;
@ -131,21 +131,24 @@ export class ModalPlacementDirective implements AfterViewInit, OnDestroy {
middleware.push(size({
apply: ({ availableWidth, availableHeight, rects }) => {
if (this.scrollX) {
const maxWidth = availableWidth > 0 ? `${availableWidth - this.scrollMargin}px` : 'none';
const overflowX = parseScroll(this.scrollX);
const overflowY = parseScroll(this.scrollY);
this.renderer.setStyle(modalRef, 'overflow-x', 'auto');
this.renderer.setStyle(modalRef, 'overflow-y', 'none');
this.renderer.setStyle(modalRef, 'max-width', maxWidth);
}
const maxWidth =
overflowX !== 'none'
? availableWidth > 0 ? `${availableWidth - this.scrollMargin}px` : 'none'
: undefined;
if (this.scrollY) {
const maxHeight = availableHeight > 0 ? `${availableHeight - this.scrollMargin}px` : 'none';
const maxHeight =
overflowY !== 'none'
? availableHeight > 0 ? `${availableHeight - this.scrollMargin}px` : 'none'
: undefined;
this.renderer.setStyle(modalRef, 'overflow-x', 'none');
this.renderer.setStyle(modalRef, 'overflow-y', 'auto');
this.renderer.setStyle(modalRef, 'max-height', maxHeight);
}
this.renderer.setStyle(modalRef, 'overflow-x', overflowX);
this.renderer.setStyle(modalRef, 'overflow-y', overflowY);
this.renderer.setStyle(modalRef, 'max-width', maxWidth);
this.renderer.setStyle(modalRef, 'max-height', maxHeight);
if (this.adjustWidth) {
const width = rects.reference.width + 2 * this.spaceX;
@ -169,3 +172,17 @@ export class ModalPlacementDirective implements AfterViewInit, OnDestroy {
this.renderer.setStyle(modalRef, 'visibility', 'visible');
}
}
type Scroll = 'hidden' | 'scroll' | 'auto' | 'true' | 'false' | true | undefined;
function parseScroll(source: Scroll): string {
if (source === 'true' || source === 'auto' || source === true) {
return 'auto';
}
if (!source || source === 'false') {
return 'none';
}
return source;
}

2
frontend/src/app/framework/angular/pager.component.scss

@ -15,6 +15,6 @@
width: 14rem;
}
.deactived {
.deactivated {
pointer-events: none;
}

3
tools/e2e/.eslintrc.js

@ -54,11 +54,12 @@ module.exports = {
],
}
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-shadow": "off",
"@typescript-eslint/no-this-alias": "error",
"@typescript-eslint/no-unnecessary-boolean-literal-compare": "error",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-shadow": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{

22
tools/e2e/package-lock.json

@ -8,9 +8,13 @@
"name": "e2e",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"uuid": "^10.0.0"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.9.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"eslint": "^8.54.0",
@ -194,6 +198,12 @@
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"dev": true
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz",
@ -2839,6 +2849,18 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

4
tools/e2e/package.json

@ -14,11 +14,15 @@
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.9.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"eslint": "^8.54.0",
"eslint-config-airbnb-typescript": "17.1.0",
"eslint-plugin-deprecation": "^2.0.0",
"eslint-plugin-import": "2.29.0"
},
"dependencies": {
"uuid": "^10.0.0"
}
}

14
tools/e2e/tests/_fixture.ts

@ -6,19 +6,22 @@
*/
import { test as base } from '@playwright/test';
import { AssetsPage, ContentPage, ContentsPage, LoginPage, RulePage, RulesPage, SchemaPage, SchemasPage } from './pages';
import { AssetsPage, ClientsPage, ContentPage, ContentsPage, LanguagesPage, LoginPage, RulePage, RulesPage, SchemaPage, SchemasPage, SettingsPage } from './pages';
import { AppsPage } from './pages/apps';
export type BaseFixture = {
appsPage: AppsPage;
assetsPage: AssetsPage;
clientsPage: ClientsPage;
contentPage: ContentPage;
contentsPage: ContentsPage;
languagesPage: LanguagesPage;
loginPage: LoginPage;
rulePage: RulePage;
rulesPage: RulesPage;
schemaPage: SchemaPage;
schemasPage: SchemasPage;
settingsPage: SettingsPage;
};
export const test = base.extend<BaseFixture>({
@ -28,12 +31,18 @@ export const test = base.extend<BaseFixture>({
assetsPage: async ({ page }, use) => {
await use(new AssetsPage(page));
},
clientsPage: async ({ page }, use) => {
await use(new ClientsPage(page));
},
contentPage: async ({ page }, use) => {
await use(new ContentPage(page));
},
contentsPage: async ({ page }, use) => {
await use(new ContentsPage(page));
},
languagesPage: async ({ page }, use) => {
await use(new LanguagesPage(page));
},
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
@ -49,6 +58,9 @@ export const test = base.extend<BaseFixture>({
schemasPage: async ({ page }, use) => {
await use(new SchemasPage(page));
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
export { expect } from '@playwright/test';

9
tools/e2e/tests/given-app/_setup.ts

@ -9,13 +9,8 @@ import { getRandomId, writeJsonAsync } from '../utils';
import { test as setup } from './../given-login/_fixture';
setup('prepare app', async ({ appsPage }) => {
const appName = `my-app-${getRandomId()}`;
await appsPage.goto();
const dialog = await appsPage.openAppDialog();
await dialog.enterName(appName);
await dialog.save();
const appName = `app-${getRandomId()}`;
await appsPage.createNewApp(appName);
await appsPage.gotoApp(appName);

117
tools/e2e/tests/given-app/assets.spec.ts

@ -15,31 +15,36 @@ test.beforeEach(async ({ appName, assetsPage }) => {
await assetsPage.goto(appName);
});
test('should upload asset', async ({ assetsPage }) => {
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: /Assets/ });
await expect(header).toBeVisible();
});
test('upload asset', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
expect(assetCard.root).toBeVisible();
await expect(assetCard.root).toBeVisible();
});
test('should delete asset', async ({ assetsPage }) => {
test('delete asset', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
await assetCard.delete();
expect(assetCard.root).not.toBeVisible();
await expect(assetCard.root).not.toBeVisible();
});
test('should not delete asset if cancelled', async ({ assetsPage }) => {
test('not delete asset if cancelled', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
await assetCard.delete(/No/);
await assetCard.delete(true);
expect(assetCard.root).toBeVisible();
await expect(assetCard.root).toBeVisible();
});
test('should edit asset name', async ({ assetsPage }) => {
test('edit asset name', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
@ -50,10 +55,10 @@ test('should edit asset name', async ({ assetsPage }) => {
const newCard = await assetsPage.getAssetCard(newName);
expect(newCard.root).toBeVisible();
await expect(newCard.root).toBeVisible();
});
test('should edit asset metadata', async ({ assetsPage }) => {
test('edit asset metadata', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
@ -67,7 +72,95 @@ test('should edit asset metadata', async ({ assetsPage }) => {
const newCard = await assetsPage.getAssetCard(`${w}x${h}px`);
expect(newCard.root).toBeVisible();
await expect(newCard.root).toBeVisible();
});
test('add asset folder', async ({ assetsPage }) => {
const folderName = `folder-${getRandomId()}`;
const folderDialog = await assetsPage.openAssetFolderDialog();
await folderDialog.enterName(folderName);
await folderDialog.save();
const folderCard = await assetsPage.getAssetFolderCard(folderName);
await expect(folderCard.root).toBeVisible();
});
test('open asset folder', async ({ assetsPage }) => {
const folderName = `folder-${getRandomId()}`;
const folderDialog = await assetsPage.openAssetFolderDialog();
await folderDialog.enterName(folderName);
await folderDialog.save();
const folderCard = await assetsPage.getAssetFolderCard(folderName);
await folderCard.open();
const moveUpCard = await assetsPage.getAssetFolderCard('<Parent>');
await expect(moveUpCard.root).toBeVisible();
});
test('rename asset folder', async ({ assetsPage }) => {
const folderName = `folder-${getRandomId()}`;
const folderDialog = await assetsPage.openAssetFolderDialog();
await folderDialog.enterName(folderName);
await folderDialog.save();
const folderCard = await assetsPage.getAssetFolderCard(folderName);
const newName = `folder-${getRandomId()}`;
const renameDialog = await folderCard.rename();
await renameDialog.enterName(newName);
await renameDialog.rename();
const renamedCard = await assetsPage.getAssetFolderCard(newName);
await expect(renamedCard.root).toBeVisible();
});
test('delete asset folder', async ({ assetsPage }) => {
const folderName = `folder-${getRandomId()}`;
const folderDialog = await assetsPage.openAssetFolderDialog();
await folderDialog.enterName(folderName);
await folderDialog.save();
const folderCard = await assetsPage.getAssetFolderCard(folderName);
const dropdown = await folderCard.openOptionsDropdown();
await dropdown.delete();
await expect(folderCard.root).not.toBeVisible();
});
test('add asset folder to parent', async ({ assetsPage }) => {
const parentName = `folder-${getRandomId()}`;
const parentDialog = await assetsPage.openAssetFolderDialog();
await parentDialog.enterName(parentName);
await parentDialog.save();
const parentcard = await assetsPage.getAssetFolderCard(parentName);
await parentcard.open();
const childName = `folder-${getRandomId()}`;
const childDialog = await assetsPage.openAssetFolderDialog();
await childDialog.enterName(childName);
await childDialog.save();
const childCard = await assetsPage.getAssetFolderCard(childName);
const moveUpCard = await assetsPage.getAssetFolderCard('<Parent>');
await moveUpCard.open();
await expect(childCard.root).not.toBeVisible();
await parentcard.open();
await expect(childCard.root).toBeVisible();
});
async function uploadRandomAsset(assetsPage: AssetsPage) {

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

@ -10,16 +10,22 @@ test.beforeEach(async ({ appName, rulesPage }) => {
await rulesPage.goto(appName);
});
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: /Rules/ });
await expect(header).toBeVisible();
});
test('create rule', async ({ rulesPage, rulePage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
const ruleCard = await rulesPage.getRuleCard(ruleName);
await expect(ruleCard.root).toBeVisible();
});
test('delete rule', async ({ rulesPage, rulePage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
const ruleCard = await rulesPage.getRuleCard(ruleName);
const dropdown = await ruleCard.openOptionsDropdown();
await dropdown.delete();
@ -29,7 +35,7 @@ test('delete rule', async ({ rulesPage, rulePage }) => {
test('disable rule', async ({ rulePage, rulesPage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
const ruleCard = await rulesPage.getRuleCard(ruleName);
const dropdown = await ruleCard.openOptionsDropdown();
await dropdown.action('Disable');
@ -39,7 +45,7 @@ test('disable rule', async ({ rulePage, rulesPage }) => {
test('enable rule', async ({ rulePage, rulesPage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
const ruleCard = await rulesPage.getRuleCard(ruleName);
const dropdown1 = await ruleCard.openOptionsDropdown();
await dropdown1.action('Disable');
@ -54,7 +60,7 @@ test('enable rule', async ({ rulePage, rulesPage }) => {
test('edit rule', async ({ page, rulePage, rulesPage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
const ruleCard = await rulesPage.getRuleCard(ruleName);
const dropdown = await ruleCard.openOptionsDropdown();
await dropdown.action('Edit');
@ -72,9 +78,11 @@ async function createRandomRule(rulesPage: RulesPage, rulePage: RulePage) {
await rulePage.save();
await rulePage.back();
const rename = await rulesPage.renameRule(/Unnamed Rule/);
await rename.enterName(ruleName);
await rename.save();
const ruleCard = await rulesPage.getRuleCard('Unnamed Rule');
const renameDialog = await ruleCard.startRenameDblClick();
await renameDialog.enterName(ruleName);
await renameDialog.save();
return ruleName;
}

72
tools/e2e/tests/given-app/schemas.spec.ts

@ -13,6 +13,12 @@ test.beforeEach(async ({ appName, schemasPage }) => {
await schemasPage.goto(appName);
});
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: /Schemas/ });
await expect(header).toBeVisible();
});
test('create schema', async ({ schemasPage }) => {
const schemaName = await createRandomSchema(schemasPage);
const schemaLink = await schemasPage.getSchemaLink(schemaName);
@ -175,6 +181,72 @@ test('delete field', async ({ schemasPage, schemaPage }) => {
await expect(fieldRow.root).not.toBeVisible();
});
test('disable field', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const fieldName = await createRandomField(schemaPage);
const fieldRow = await schemaPage.getFieldRow(fieldName);
const dropdown = await fieldRow.openOptionsDropdown();
await dropdown.action('Disable in UI');
await expect(fieldRow.root.getByText('Disabled')).toBeVisible();
});
test('enable field', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const fieldName = await createRandomField(schemaPage);
const fieldRow = await schemaPage.getFieldRow(fieldName);
const dropdown1 = await fieldRow.openOptionsDropdown();
await dropdown1.action('Disable in UI');
const dropdown2 = await fieldRow.openOptionsDropdown();
await dropdown2.action('Enable in UI');
await expect(fieldRow.root.getByText('Enabled')).toBeVisible();
});
test('hide field', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const fieldName = await createRandomField(schemaPage);
const fieldRow = await schemaPage.getFieldRow(fieldName);
const dropdown = await fieldRow.openOptionsDropdown();
await dropdown.action('Hide in API');
await expect(fieldRow.root.getByText(fieldName)).toHaveCSS('color', 'rgb(166, 170, 179)');
});
test('show field', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const fieldName = await createRandomField(schemaPage);
const fieldRow = await schemaPage.getFieldRow(fieldName);
const dropdown1 = await fieldRow.openOptionsDropdown();
await dropdown1.action('Hide in API');
const dropdown2 = await fieldRow.openOptionsDropdown();
await dropdown2.action('Show in API');
await expect(fieldRow.root.getByText(fieldName)).not.toHaveCSS('color', 'rgb(166, 170, 179)');
});
test('lock field', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const fieldName = await createRandomField(schemaPage);
const fieldRow = await schemaPage.getFieldRow(fieldName);
const dropdown = await fieldRow.openOptionsDropdown();
await dropdown.actionAndConfirm('Lock and prevent changes');
await expect(fieldRow.root.getByText('Locked')).toBeVisible();
});
async function createRandomField(schemaPage: SchemaPage) {
const name = `field-${getRandomId()}`;

3
tools/e2e/tests/given-login/apps.spec.ts

@ -13,13 +13,12 @@ test.beforeEach(async ({ appsPage }) => {
});
test('create app', async ({ page, appsPage }) => {
const appName = `my-app-${getRandomId()}`;
const appName = `app-${getRandomId()}`;
const appDialog = await appsPage.openAppDialog();
await appDialog.enterName(appName);
await appDialog.save();
const newApp = page.getByRole('heading', { name: appName });
await expect(newApp).toBeVisible();
});

89
tools/e2e/tests/given-login/clients.spec.ts

@ -0,0 +1,89 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '../_fixture';
import { ClientsPage } from '../pages';
import { getRandomId } from '../utils';
test.beforeEach(async ({ context, appsPage, clientsPage }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const appName = `app-${getRandomId()}`;
await appsPage.createNewApp(appName);
await clientsPage.goto(appName);
});
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: /Clients/ });
await expect(header).toBeVisible();
});
test('add client', async ({ clientsPage }) => {
const clientId = await createRandomClient(clientsPage);
const clientCard = await clientsPage.getClientCard(clientId);
await expect(clientCard.root).toBeVisible();
});
test('copy client ID', async ({ page, clientsPage }) => {
const clientCard = await clientsPage.getClientCard('default');
await clientCard.copyClientId();
const handle = await page.evaluateHandle(() => navigator.clipboard.readText());
const clipboardContent = await handle.jsonValue();
await expect(clipboardContent).toContain(':default');
});
test('copy client Secret', async ({ page, clientsPage }) => {
const clientCard = await clientsPage.getClientCard('default');
await clientCard.copyClientSecret();
const handle = await page.evaluateHandle(() => navigator.clipboard.readText());
const clipboardContent = await handle.jsonValue();
await expect(clipboardContent.length).toBeGreaterThan(40);
});
test('rename rule with dbclick', async ({ clientsPage }) => {
const clientId = await createRandomClient(clientsPage);
const clientCard = await clientsPage.getClientCard(clientId);
const newName = `client-${getRandomId()}`;
const renameDialog = await clientCard.startRenameDblClick();
await renameDialog.enterName(newName);
await renameDialog.save();
const renamedCard = await clientsPage.getClientCard(newName);
await expect(renamedCard.root).toBeVisible();
});
test('rename rule with button', async ({ clientsPage }) => {
const clientId = await createRandomClient(clientsPage);
const clientCard = await clientsPage.getClientCard(clientId);
const newName = `client-${getRandomId()}`;
const renameDialog = await clientCard.startRenameButton();
await renameDialog.enterName(newName);
await renameDialog.save();
const renamedCard = await clientsPage.getClientCard(newName);
await expect(renamedCard.root).toBeVisible();
});
async function createRandomClient(clientsPage: ClientsPage) {
const clientId = `client-${getRandomId()}`;
await clientsPage.enterClientId(clientId);
await clientsPage.save();
return clientId;
}

92
tools/e2e/tests/given-login/languages.spec.ts

@ -5,51 +5,89 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
import { getRandomId } from '../utils';
import { expect, test } from './_fixture';
test.beforeEach(async ({ page }) => {
await page.goto('/app');
test.beforeEach(async ({ languagesPage, appsPage }) => {
const appName = `app-${getRandomId()}`;
await appsPage.createNewApp(appName);
await languagesPage.goto(appName);
});
test('show proper English frontend', async ({ page }) => {
await changeLanguage(page, 'English');
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: /Languages/ });
await expect(page.getByText('Welcome to Squidex')).toBeVisible();
await expect(header).toBeVisible();
});
test('show proper French frontend', async ({ page }) => {
await changeLanguage(page, 'Français');
test('add random language', async ({ languagesPage }) => {
const randomName = `language-${getRandomId()}`;
await expect(page.getByText('Bienvenue sur Squidex.')).toBeVisible();
});
await languagesPage.enterLanguage(randomName);
await languagesPage.save();
test('show proper Dutch frontend', async ({ page }) => {
await changeLanguage(page, 'Nederlands');
const languagesCard = await languagesPage.getLanguageCard(randomName);
await expect(page.getByText('Welkom bij Squidex.')).toBeVisible();
await expect(languagesCard.root).toBeVisible();
});
test('show proper Italian frontend', async ({ page }) => {
await changeLanguage(page, 'Italiano');
test('add language with dropdown', async ({ languagesPage }) => {
await languagesPage.enterLanguage('de-');
await languagesPage.selectLanguage('de-DE (German (Germany))');
await languagesPage.save();
const languagesCard = await languagesPage.getLanguageCard('German (Germany)');
await expect(page.getByText('Benvenuto su Squidex.')).toBeVisible();
await expect(languagesCard.root).toBeVisible();
});
test('show proper Portugese frontend', async ({ page }) => {
await changeLanguage(page, 'Portuguese');
test('delete language', async ({ languagesPage }) => {
const randomName = `language-${getRandomId()}`;
await languagesPage.enterLanguage(randomName);
await languagesPage.save();
const languagesCard = await languagesPage.getLanguageCard(randomName);
await expect(languagesCard.root).toBeVisible();
await expect(page.getByText('Bem-vindo a Squidex.')).toBeVisible();
await languagesCard.delete();
await expect(languagesCard.root).not.toBeVisible();
});
test('show proper Chinese frontend', async ({ page }) => {
await changeLanguage(page, '简体中文');
test('add default language', async ({ languagesPage }) => {
const randomName1 = `language-${getRandomId()}`;
const randomName2 = `language-${getRandomId()}`;
await expect(page.getByText('欢迎来到 Squidex。')).toBeVisible();
await languagesPage.enterLanguage(randomName1);
await languagesPage.save();
const languagesCard1 = await languagesPage.getLanguageCard(randomName1);
await expect(languagesCard1.root).toBeVisible();
await languagesPage.enterLanguage(randomName2);
await languagesPage.save();
const languagesCard2 = await languagesPage.getLanguageCard(randomName2);
await languagesCard2.toggle();
await languagesCard2.addFallbackLanguage(randomName1);
await languagesCard2.save();
await expect(languagesCard2.root.getByText(randomName2)).toBeVisible();
});
async function changeLanguage(page: Page, name: string) {
await page.locator('sqx-profile-menu span').first().click();
await page.getByText('Language').click();
await page.getByText(name).click();
}
test('make master', async ({ languagesPage }) => {
const randomName = `language-${getRandomId()}`;
await languagesPage.enterLanguage(randomName);
await languagesPage.save();
const languagesCard = await languagesPage.getLanguageCard(randomName);
await languagesCard.toggle();
await languagesCard.makeMaster();
await languagesCard.save();
await expect(languagesCard.root.getByLabel('Delete')).toBeDisabled();
});

44
tools/e2e/tests/given-login/settings.spec.ts

@ -0,0 +1,44 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '../_fixture';
import { getRandomId } from '../utils';
test.beforeEach(async ({ appsPage, settingsPage }) => {
const appName = `app-${getRandomId()}`;
await appsPage.createNewApp(appName);
await settingsPage.goto(appName);
});
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: /UI Settings/ });
await expect(header).toBeVisible();
});
test('add pattern', async ({ settingsPage })=> {
const patternName = `name-${getRandomId()}`;
const patternRegex = `regex-${getRandomId()}`;
const newRow = await settingsPage.getPatternNewRow();
await newRow.enterName(patternName);
await newRow.enterRegex(patternRegex);
await settingsPage.save();
const patternRow = await settingsPage.getPatternRow(patternName);
await expect(patternRow.root).toBeVisible();
});
test('delete pattern', async ({ settingsPage })=> {
const patternRow = await settingsPage.getPatternRow('Email');
await patternRow.delete();
await settingsPage.save();
await expect(patternRow.root).not.toBeVisible();
});

2
tools/e2e/tests/given-schema/_setup.ts

@ -13,7 +13,7 @@ const fields = [{
}];
setup('prepare schema', async ({ appName, schemasPage, schemaPage }) => {
const schemaName = `my-schema-${getRandomId()}`;
const schemaName = `schema-${getRandomId()}`;
await schemasPage.goto(appName);

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

@ -14,6 +14,12 @@ test.beforeEach(async ({ appName, schemaName, contentsPage }) => {
await contentsPage.increasePageSize();
});
test('has header', async ({ page }) => {
const header = page.getByRole('heading', { name: 'Contents' });
await expect(header).toBeVisible();
});
test('create content and close', async ({ contentsPage, contentPage }) => {
await contentsPage.addContent();
@ -114,7 +120,7 @@ states.forEach(({ status, currentStatus }) => {
const contentRow = await contentsPage.getContentRow(contentText);
const dropdown = await contentRow.openOptionsDropdown();
await dropdown.actionConfirm(`Change to ${status}`);
await dropdown.actionAndConfirm(`Change to ${status}`, /Confirm/);
await expect(contentRow.root.getByLabel(status)).toBeVisible();
});
@ -151,7 +157,7 @@ states.forEach(({ status, currentStatus }) => {
}
const dropdown = await contentPage.openStatusDropdown(currentStatus);
await dropdown.actionConfirm(`Change to ${status}`);
await dropdown.actionAndConfirm(`Change to ${status}`, /Confirm/);
await expect(page.getByRole('button', { name: status })).toBeVisible();
});

12
tools/e2e/tests/pages/apps.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
import { expect, Page } from '@playwright/test';
export class AppsPage {
constructor(private readonly page: Page) {}
@ -23,6 +23,16 @@ export class AppsPage {
return new AppDialog(this.page);
}
public async createNewApp(appName: string) {
await this.goto();
const appDialog = await this.openAppDialog();
await appDialog.enterName(appName);
await appDialog.save();
const newApp = this.page.getByRole('heading', { name: appName });
await expect(newApp).toBeVisible();
}
}
class AppDialog {

65
tools/e2e/tests/pages/assets.ts

@ -7,6 +7,7 @@
import { Locator, Page } from '@playwright/test';
import { escapeRegex } from '../utils';
import { Dropdown } from './dropdown';
export class AssetsPage {
constructor(private readonly page: Page) {}
@ -24,11 +25,23 @@ export class AssetsPage {
await fileChooser.setFiles(file);
}
public async openAssetFolderDialog() {
await this.page.getByLabel('Create Folder').click();
return new AssetFolderDialog(this.page, this.page.getByTestId('dialog'));
}
public async getAssetCard(name: string) {
const locator = this.page.locator('sqx-asset', { hasText: escapeRegex(name) });
return new AssetCard(this.page, locator);
}
public async getAssetFolderCard(name: string) {
const locator = this.page.locator('sqx-asset-folder', { hasText: escapeRegex(name) });
return new AssetFolderCard(this.page, locator);
}
}
export class AssetCard {
@ -37,14 +50,9 @@ export class AssetCard {
) {
}
public async delete(cancel = false) {
public async delete(button = /Yes/) {
await this.root.getByLabel('Delete').click();
if (cancel) {
await this.page.getByRole('button', { name: /No/ }).click();
} else {
await this.page.getByRole('button', { name: /Yes/ }).click();
}
await this.page.getByRole('button', { name: button }).click();
}
public async edit() {
@ -54,6 +62,30 @@ export class AssetCard {
}
}
export class AssetFolderCard {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async open() {
await this.root.dblclick();
}
public async rename() {
const dropdown = await this.openOptionsDropdown();
await dropdown.action('Rename');
return new AssetFolderDialog(this.page, this.page.getByTestId('dialog'));
}
public async openOptionsDropdown() {
await this.root.getByLabel('Options').click();
return new Dropdown(this.page);
}
}
export class AssetDialog {
constructor(private readonly page: Page,
public readonly root: Locator,
@ -84,4 +116,23 @@ export class AssetDialog {
await this.root.getByLabel('Close').click();
}
}
export class AssetFolderDialog {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async enterName(name: string) {
await this.root.getByLabel('Folder Name (required)').fill(name);
}
public async save() {
await this.root.getByRole('button', { name: 'Create' }).click();
}
public async rename() {
await this.root.getByRole('button', { name: 'Rename' }).click();
}
}

60
tools/e2e/tests/pages/clients.ts

@ -0,0 +1,60 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Locator, Page } from '@playwright/test';
import { escapeRegex } from '../utils';
import { RenameDialog } from './rename';
export class ClientsPage {
constructor(private readonly page: Page) {}
public async goto(appName: string) {
await this.page.goto(`/app/${appName}/settings/clients`);
}
public async enterClientId(input: string) {
await this.page.getByPlaceholder('Enter client name').fill(input);
}
public async save() {
await this.page.getByRole('button', { name: 'Add Client' }).click();
}
public async getClientCard(name: string) {
const locator = this.page.locator('sqx-client', { hasText: escapeRegex(name) });
return new ClientCard(this.page, locator);
}
}
class ClientCard {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async copyClientId() {
await this.root.getByLabel('Copy Client ID').click();
}
public async copyClientSecret() {
await this.root.getByLabel('Copy Client Secret').click();
}
public async startRenameDblClick() {
await this.root.getByRole('heading').first().dblclick();
return new RenameDialog(this.page);
}
public async startRenameButton() {
await this.root.getByRole('heading').hover();
await this.root.getByLabel('Rename').click();
return new RenameDialog(this.page);
}
}

2
tools/e2e/tests/pages/contents.ts

@ -17,7 +17,7 @@ export class ContentsPage {
}
public async increasePageSize() {
await this.page.getByRole('combobox').selectOption('3: 50');
await this.page.getByLabel('Page Size').selectOption({ label: '50' });
}
public async addContent() {

14
tools/e2e/tests/pages/dropdown.ts

@ -10,14 +10,8 @@ import { Page } from '@playwright/test';
export class Dropdown {
constructor(private readonly page: Page) {}
public async delete(cancel = false) {
await this.page.getByText('Delete').click();
if (cancel) {
await this.page.getByRole('button', { name: /No/ }).click();
} else {
await this.page.getByRole('button', { name: /Yes/ }).click();
}
public async delete(button = /Yes/) {
await this.actionAndConfirm('Delete', button);
}
public async action(name: string) {
@ -25,8 +19,8 @@ export class Dropdown {
await this.page.locator('sqx-dropdown-menu').waitFor({ state: 'hidden' });
}
public async actionConfirm(name: string) {
public async actionAndConfirm(name: string, button = /Yes/) {
await this.action(name);
await this.page.getByRole('button', { name: 'Confirm' }).click();
await this.page.getByRole('button', { name: button }).click();
}
}

5
tools/e2e/tests/pages/index.ts

@ -7,11 +7,14 @@
export * from './apps';
export * from './assets';
export * from './clients';
export * from './content';
export * from './contents';
export * from './languages';
export * from './login';
export * from './rule';
export * from './rule';
export * from './rules';
export * from './schema';
export * from './schemas';
export * from './schemas';
export * from './settings';

64
tools/e2e/tests/pages/languages.ts

@ -0,0 +1,64 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Locator, Page } from '@playwright/test';
import { escapeRegex } from '../utils';
export class LanguagesPage {
constructor(private readonly page: Page) {}
public async goto(appName: string) {
await this.page.goto(`/app/${appName}/settings/languages`);
}
public async enterLanguage(input: string) {
await this.page.locator('#language').fill(input);
}
public async selectLanguage(selection: string) {
await this.page.getByText(selection).click();
}
public async save() {
await this.page.getByRole('button', { name: 'Add Language' }).click();
}
public async getLanguageCard(name: string) {
const locator = this.page.locator('sqx-language', { hasText: escapeRegex(name) });
return new LanguageCard(this.page, locator);
}
}
export class LanguageCard {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async delete(button = /Yes/) {
await this.root.getByLabel('Delete').click();
await this.page.getByRole('button', { name: button }).click();
}
public async toggle() {
await this.root.getByLabel('Options').click();
}
public async save() {
await this.root.getByRole('button', { name: 'Save' }).click();
}
public async makeMaster() {
await this.root.getByLabel('Is Master').check();
}
public async addFallbackLanguage(language: string) {
await this.root.getByLabel('Fallback').selectOption({ label: language });
await this.root.getByRole('button', { name: 'Add Language' }).click();
}
}

24
tools/e2e/tests/pages/rename.ts

@ -0,0 +1,24 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Locator, Page } from '@playwright/test';
export class RenameDialog {
public root: Locator;
constructor(private readonly page: Page) {
this.root = this.page.locator('sqx-editable-title');
}
public async enterName(name: string) {
await this.root.locator('form').getByRole('textbox').fill(name);
}
public async save() {
await this.root.locator('form').getByLabel('Save').click();
}
}

23
tools/e2e/tests/pages/rules.ts

@ -8,6 +8,7 @@
import { Locator, Page } from '@playwright/test';
import { escapeRegex } from '../utils';
import { Dropdown } from './dropdown';
import { RenameDialog } from './rename';
export class RulesPage {
constructor(private readonly page: Page) {}
@ -20,13 +21,7 @@ export class RulesPage {
await this.page.getByRole('link', { name: /New Rule/ }).click();
}
public async renameRule(name: RegExp) {
await this.page.locator('div.card', { hasText: name }).getByRole('heading').first().dblclick();
return new RenameDialog(this.page);
}
public async getRule(name: string) {
public async getRuleCard(name: string) {
const locator = this.page.locator('div.card', { hasText: escapeRegex(name) });
return new RuleCard(this.page, locator);
@ -44,16 +39,16 @@ export class RuleCard {
return new Dropdown(this.page);
}
}
export class RenameDialog {
constructor(private readonly page: Page) {}
public async startRenameDblClick() {
await this.root.getByRole('heading').first().dblclick();
public async enterName(name: string) {
await this.page.locator('form').getByRole('textbox').fill(name);
return new RenameDialog(this.page);
}
public async save() {
await this.page.locator('form').getByLabel('Save').click();
public async startRenameButton() {
await this.root.getByLabel('Rename').click();
return new RenameDialog(this.page);
}
}

56
tools/e2e/tests/pages/settings.ts

@ -0,0 +1,56 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Locator, Page } from '@playwright/test';
export class SettingsPage {
constructor(private readonly page: Page) {}
public async goto(appName: string) {
await this.page.goto(`/app/${appName}/settings/settings`);
}
public async save() {
await this.page.getByRole('button', { name: 'Save' }).click();
}
public async getPatternRow(name: string) {
const locator = this.page.getByTestId(`pattern_${name}`);
return new PatternRow(this.page, locator);
}
public async getPatternNewRow() {
const locator = this.page.getByTestId(/pattern_/).last();
return new PatternRow(this.page, locator);
}
public async addPattern() {
await this.page.getByTestId('patterns').getByRole('button', { name: 'Add' }).click();
}
}
export class PatternRow {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async enterName(value: string) {
await this.root.getByPlaceholder('Name').fill(value);
}
public async enterRegex(value: string) {
await this.root.getByPlaceholder('Pattern').fill(value);
}
public async delete() {
await this.root.getByRole('button', { name: 'Delete' }).click();
await this.page.getByRole('button', { name: 'Yes' }).click();
}
}

7
tools/e2e/tests/utils.ts

@ -7,14 +7,11 @@
import fs from 'fs/promises';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { TEMPORARY_PATH } from '../playwright.config';
let COUNTER = 0;
export function getRandomId() {
const result = `${new Date().getTime()}-${COUNTER++}`;
return result;
return uuidv4().replace(/-/g, '');
}
export function escapeRegex(string: string) {

Loading…
Cancel
Save