Browse Source

Improve e2e tests (#1125)

* Improve e2e tests

* Fix stories.

* Page objects.

* Save file.

* Rename screenshots.

* Fix fixture.

* Fix dropdowns.

* Rename

* assets tests
pull/1127/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
1c70f9d215
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      backend/i18n/frontend_en.json
  2. 1
      backend/i18n/frontend_fr.json
  3. 1
      backend/i18n/frontend_it.json
  4. 1
      backend/i18n/frontend_nl.json
  5. 1
      backend/i18n/frontend_pt.json
  6. 1
      backend/i18n/frontend_zh.json
  7. 1
      backend/i18n/source/frontend_en.json
  8. 10
      backend/src/Squidex.Domain.Apps.Core.Model/Assets/Asset.cs
  9. 34
      backend/src/Squidex.Domain.Apps.Entities/Assets/SvgAssetMetadataSource.cs
  10. 17
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Assets/AssetTests.cs
  11. 40
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/SvgAssetMetadataSourceTests.cs
  12. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/TestFiles/SvgValidNonNumberSizes.svg
  13. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  14. 4
      frontend/.storybook/main.js
  15. 3
      frontend/.storybook/tsconfig.json
  16. 2
      frontend/angular.json
  17. 2
      frontend/package.json
  18. 24
      frontend/src/app/features/content/pages/content/content-page.component.ts
  19. 26
      frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html
  20. 80
      frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts
  21. 2
      frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html
  22. 2
      frontend/src/app/framework/angular/forms/editable-title.stories.ts
  23. 7
      frontend/src/app/framework/angular/forms/editors/checkbox-group.stories.ts
  24. 2
      frontend/src/app/framework/angular/forms/editors/code-editor.component.ts
  25. 20
      frontend/src/app/framework/angular/forms/editors/code-editor.stories.ts
  26. 1
      frontend/src/app/framework/angular/forms/editors/dropdown.component.html
  27. 22
      frontend/src/app/framework/angular/forms/editors/dropdown.stories.ts
  28. 7
      frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts
  29. 6
      frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts
  30. 2
      frontend/src/app/framework/angular/modals/modal-dialog.component.html
  31. 2
      frontend/src/app/framework/angular/modals/modal-dialog.component.ts
  32. 5
      frontend/src/app/shared/components/assets/asset.component.html
  33. 20
      frontend/src/app/shared/state/contents.forms-helpers.ts
  34. BIN
      tools/e2e/assets/logo-squared.png
  35. 0
      tools/e2e/snapshots/login-start.spec.ts-snapshots/visual-test-1-logged-out-linux.png
  36. 0
      tools/e2e/snapshots/login-start.spec.ts-snapshots/visual-test-1-logged-out-win32.png
  37. 65
      tools/e2e/tests/_fixture.ts
  38. 15
      tools/e2e/tests/given-app/_setup.ts
  39. 80
      tools/e2e/tests/given-app/assets.spec.ts
  40. 92
      tools/e2e/tests/given-app/rules.spec.ts
  41. 201
      tools/e2e/tests/given-app/schemas.spec.ts
  42. 21
      tools/e2e/tests/given-login/_setup.ts
  43. 4
      tools/e2e/tests/given-login/apps-page.spec.ts
  44. 13
      tools/e2e/tests/given-login/apps.spec.ts
  45. 41
      tools/e2e/tests/given-schema/_setup.ts
  46. 253
      tools/e2e/tests/given-schema/contents.spec.ts
  47. 6
      tools/e2e/tests/login-start.spec.ts
  48. 35
      tools/e2e/tests/login.spec.ts
  49. 38
      tools/e2e/tests/pages/apps.ts
  50. 87
      tools/e2e/tests/pages/assets.ts
  51. 88
      tools/e2e/tests/pages/content.ts
  52. 63
      tools/e2e/tests/pages/contents.ts
  53. 32
      tools/e2e/tests/pages/dropdown.ts
  54. 17
      tools/e2e/tests/pages/index.ts
  55. 47
      tools/e2e/tests/pages/login.ts
  56. 30
      tools/e2e/tests/pages/rule.ts
  57. 59
      tools/e2e/tests/pages/rules.ts
  58. 116
      tools/e2e/tests/pages/schema.ts
  59. 48
      tools/e2e/tests/pages/schemas.ts
  60. 4
      tools/e2e/tests/utils.ts

1
backend/i18n/frontend_en.json

@ -251,6 +251,7 @@
"common.disabled": "Disabled",
"common.displayName": "Display Name",
"common.documentation": "Documentation",
"common.download": "Download",
"common.edit": "Edit",
"common.editing": "Editing",
"common.editInNewTab": "Open in new tab",

1
backend/i18n/frontend_fr.json

@ -251,6 +251,7 @@
"common.disabled": "Désactivé",
"common.displayName": "Afficher un nom",
"common.documentation": "Documentation",
"common.download": "Download",
"common.edit": "Modifier",
"common.editing": "Édition",
"common.editInNewTab": "Open in new tab",

1
backend/i18n/frontend_it.json

@ -251,6 +251,7 @@
"common.disabled": "Disabled",
"common.displayName": "Nome visualizzato",
"common.documentation": "Documentation",
"common.download": "Download",
"common.edit": "Modifica",
"common.editing": "Editing",
"common.editInNewTab": "Open in new tab",

1
backend/i18n/frontend_nl.json

@ -251,6 +251,7 @@
"common.disabled": "Uitgezet",
"common.displayName": "Weergavenaam",
"common.documentation": "Documentation",
"common.download": "Download",
"common.edit": "Bewerken",
"common.editing": "Bewerken",
"common.editInNewTab": "Open in new tab",

1
backend/i18n/frontend_pt.json

@ -251,6 +251,7 @@
"common.disabled": "Deficientes",
"common.displayName": "Nome do visor",
"common.documentation": "Documentação",
"common.download": "Download",
"common.edit": "Editar",
"common.editing": "Edição",
"common.editInNewTab": "Open in new tab",

1
backend/i18n/frontend_zh.json

@ -251,6 +251,7 @@
"common.disabled": "已禁用",
"common.displayName": "显示名称",
"common.documentation": "Documentation",
"common.download": "Download",
"common.edit": "编辑",
"common.editing": "Editing",
"common.editInNewTab": "Open in new tab",

1
backend/i18n/source/frontend_en.json

@ -251,6 +251,7 @@
"common.disabled": "Disabled",
"common.displayName": "Display Name",
"common.documentation": "Documentation",
"common.download": "Download",
"common.edit": "Edit",
"common.editing": "Editing",
"common.editInNewTab": "Open in new tab",

10
backend/src/Squidex.Domain.Apps.Core.Model/Assets/Asset.cs

@ -53,27 +53,27 @@ public record Asset : AssetItem
if (fileName != null && !string.Equals(FileName, fileName, StringComparison.OrdinalIgnoreCase))
{
result = this with { FileName = fileName };
result = result with { FileName = fileName };
}
if (slug != null && !string.Equals(Slug, slug, StringComparison.OrdinalIgnoreCase))
{
result = this with { Slug = slug };
result = result with { Slug = slug };
}
if (isProtected != null && IsProtected != isProtected.Value)
{
result = this with { IsProtected = isProtected.Value };
result = result with { IsProtected = isProtected.Value };
}
if (tags != null && !Tags.SetEquals(tags))
{
result = this with { Tags = tags };
result = result with { Tags = tags };
}
if (metadata != null && !Metadata.EqualsDictionary(metadata))
{
result = this with { Metadata = metadata };
result = result with { Metadata = metadata };
}
return result;

34
backend/src/Squidex.Domain.Apps.Entities/Assets/SvgAssetMetadataSource.cs

@ -52,11 +52,28 @@ public sealed class SvgAssetMetadataSource : IAssetMetadataSource
if (!string.IsNullOrWhiteSpace(width) && !string.IsNullOrWhiteSpace(height))
{
command.Metadata[KnownMetadataKeys.SvgWidth] = width;
command.Metadata[KnownMetadataKeys.SvgHeight] = height;
var hasNumericWidth = TryParseInt(width, out var w);
var hasNumericHeight = TryParseInt(height, out var h);
if (int.TryParse(width, NumberStyles.Integer, CultureInfo.InvariantCulture, out var w) &&
int.TryParse(height, NumberStyles.Integer, CultureInfo.InvariantCulture, out var h))
if (hasNumericWidth)
{
command.Metadata[KnownMetadataKeys.SvgWidth] = w;
}
else
{
command.Metadata[KnownMetadataKeys.SvgWidth] = width;
}
if (hasNumericWidth)
{
command.Metadata[KnownMetadataKeys.SvgHeight] = h;
}
else
{
command.Metadata[KnownMetadataKeys.SvgHeight] = height;
}
if (hasNumericWidth && hasNumericHeight)
{
command.Metadata[KnownMetadataKeys.PixelWidth] = w;
command.Metadata[KnownMetadataKeys.PixelHeight] = h;
@ -79,6 +96,11 @@ public sealed class SvgAssetMetadataSource : IAssetMetadataSource
}
}
private static bool TryParseInt(string value, out int result)
{
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out result);
}
public IEnumerable<string> Format(Asset asset)
{
var isSvg =
@ -90,8 +112,8 @@ public sealed class SvgAssetMetadataSource : IAssetMetadataSource
yield break;
}
if (asset.Metadata.TryGetString(KnownMetadataKeys.SvgWidth, out var w) &&
asset.Metadata.TryGetString(KnownMetadataKeys.SvgHeight, out var h))
if (asset.Metadata.TryGetValue(KnownMetadataKeys.SvgWidth, out var w) &&
asset.Metadata.TryGetValue(KnownMetadataKeys.SvgHeight, out var h))
{
yield return $"{w}x{h}";
}

17
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Assets/AssetTests.cs

@ -143,6 +143,23 @@ public class AssetTests
Assert.Same(asset_1, asset_2);
}
[Fact]
public void Should_annotate_with_multiple_properties()
{
var newSlug = "my-file.png";
var newFile = "My File";
var asset_1 = asset_0.Annotate(fileName: newFile, slug: newSlug);
var asset_2 = asset_1.Annotate(fileName: newFile, slug: newSlug);
Assert.NotSame(asset_0, asset_1);
Assert.Equal(newSlug, asset_1.Slug);
Assert.Equal(newSlug, asset_2.Slug);
Assert.Equal(newFile, asset_1.FileName);
Assert.Equal(newFile, asset_2.FileName);
Assert.Same(asset_1, asset_2);
}
[Fact]
public void Should_deserialize_state()
{

40
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/SvgAssetMetadataSourceTests.cs

@ -66,8 +66,24 @@ public class SvgAssetMetadataSourceTests : GivenContext
Assert.Equal(20, command.Metadata[KnownMetadataKeys.PixelWidth].AsNumber);
Assert.Equal(30, command.Metadata[KnownMetadataKeys.PixelHeight].AsNumber);
Assert.Equal("20", command.Metadata[KnownMetadataKeys.SvgWidth].AsString);
Assert.Equal("30", command.Metadata[KnownMetadataKeys.SvgHeight].AsString);
Assert.Equal(20, command.Metadata[KnownMetadataKeys.SvgWidth].AsNumber);
Assert.Equal(30, command.Metadata[KnownMetadataKeys.SvgHeight].AsNumber);
Assert.Equal("0 0 100 100", command.Metadata[KnownMetadataKeys.SvgViewBox].AsString);
}
[Fact]
public async Task Should_annote_with_non_numeric_dimensions()
{
var command = Command("SvgValidNonNumberSizes.svg");
await sut.EnhanceAsync(command, default);
Assert.DoesNotContain(KnownMetadataKeys.PixelWidth, command.Metadata);
Assert.DoesNotContain(KnownMetadataKeys.PixelHeight, command.Metadata);
Assert.Equal("20%", command.Metadata[KnownMetadataKeys.SvgWidth].AsString);
Assert.Equal("30%", command.Metadata[KnownMetadataKeys.SvgHeight].AsString);
Assert.Equal("0 0 100 100", command.Metadata[KnownMetadataKeys.SvgViewBox].AsString);
}
@ -103,7 +119,25 @@ public class SvgAssetMetadataSourceTests : GivenContext
var formatted = sut.Format(source);
Assert.Equal(new[] { "128x55" }, formatted);
Assert.Equal(["128x55"], formatted);
}
[Fact]
public void Should_describe_metadata_with_number()
{
var source = CreateAsset() with
{
Metadata = new AssetMetadata
{
[KnownMetadataKeys.SvgWidth] = 128,
[KnownMetadataKeys.SvgHeight] = 55
},
MimeType = "image/svg+xml"
};
var formatted = sut.Format(source);
Assert.Equal(["128x55"], formatted);
}
private static UploadAssetCommand Command(string path)

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/TestFiles/SvgValidNonNumberSizes.svg

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="20%" height="30%">
<path d="M30,1h40l29,29v40l-29,29h-40l-29-29v-40z" stroke="#000" fill="none"/>
<path d="M31,3h38l28,28v38l-28,28h-38l-28-28v-38z" fill="#a23"/>
<text x="50" y="68" font-size="48" fill="#FFF" text-anchor="middle"><![CDATA[410]]></text>
<use id="#other"/>
</svg>

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -70,6 +70,9 @@
<None Update="Assets\TestFiles\SvgNonPixelDimensions.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\TestFiles\SvgValidNonNumberSizes.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\TestFiles\SvgValid.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

4
frontend/.storybook/main.js

@ -24,9 +24,11 @@ module.exports = {
*/
config.plugins.push(new CopyPlugin({
patterns: [
{ from: './node_modules/ace-builds/src-min/', to: 'dependencies/ace/' },
{ from: './node_modules/ace-builds/src-min/', to: './dependencies/ace/' },
]
}));
config.resolve?.extensions?.push('.d.ts');
return config;
},
docs: {

3
frontend/.storybook/tsconfig.json

@ -1,9 +1,6 @@
{
"extends": "../tsconfig.app.json",
"compilerOptions": {
"types": [
"node"
],
"allowSyntheticDefaultImports": true
},
"exclude": [

2
frontend/angular.json

@ -127,7 +127,7 @@
"inlineCritical": false
},
"fonts": true
},
}
},
"development": {
"extractLicenses": false,

2
frontend/package.json

@ -33,7 +33,6 @@
"@lithiumjs/angular": "^8.0.0",
"@lithiumjs/ngx-virtual-scroll": "^0.3.2",
"@marker.io/browser": "^0.19.0",
"@types/ace": "^0.0.52",
"ace-builds": "^1.34.2",
"angular-gridster2": "18.0.1",
"angular-mentions": "1.5.0",
@ -95,6 +94,7 @@
"@storybook/addon-links": "^8.1.5",
"@storybook/angular": "^8.1.5",
"@storybook/testing-library": "^0.2.2",
"@types/ace": "^0.0.52",
"@types/codemirror": "5.60.15",
"@types/core-js": "2.5.8",
"@types/jasmine": "5.1.4",

24
frontend/src/app/features/content/pages/content/content-page.component.ts

@ -280,17 +280,23 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
this.contentsState.create(value, publish, this.contentId)
.subscribe({
next: content => {
if (navigationMode == 'Edit') {
this.contentForm.submitCompleted({ noReset: true });
this.contentForm.load(content.data, true);
switch (navigationMode) {
case 'Add':
this.contentForm = new EditContentForm(this.languages, this.schema, this.schemasState.schemaMap, this.formContext);
break;
this.router.navigate([content.id, 'history'], { relativeTo: this.route.parent! });
} else if (navigationMode === 'Close') {
this.autoSaveIgnore = true;
case 'Edit':
this.contentForm.submitCompleted({ noReset: true });
this.contentForm.load(content.data, true);
this.router.navigate(['./'], { relativeTo: this.route.parent! });
} else {
this.contentForm = new EditContentForm(this.languages, this.schema, this.schemasState.schemaMap, this.formContext);
this.router.navigate([content.id, 'history'], { relativeTo: this.route.parent! });
break;
case 'Close':
this.autoSaveIgnore = true;
this.router.navigate(['./'], { relativeTo: this.route.parent! });
break;
}
},
error: error => {

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

@ -9,7 +9,7 @@
<ng-container content>
@if (editForm) {
<form class="edit-form" [formGroup]="editForm.form" (ngSubmit)="save()">
<form class="edit-form" [formGroup]="editForm.form" (ngSubmit)="save('Close')">
<sqx-field-form
[field]="field"
[fieldForm]="editForm.form"
@ -20,7 +20,7 @@
[settings]="settings"></sqx-field-form>
</form>
} @else {
<form [formGroup]="addFieldForm.form" (ngSubmit)="addField(false)">
<form [formGroup]="addFieldForm.form" (ngSubmit)="addField('Close')">
<sqx-form-error [error]="addFieldForm.error | async"></sqx-form-error>
<div class="form-group">
<div class="row">
@ -88,8 +88,8 @@
@if (!editForm) {
<div>
<div class="btn-group ms-2" attr.aria-label="{{ 'schemas.addFieldAndClose' | sqxTranslate }}" role="group">
<button class="btn btn-success" (click)="addField(false)" shortcut="CTRL + SHIFT + S">
<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">
{{ "schemas.addFieldAndClose" | sqxTranslate }}
</button>
<button
@ -106,10 +106,10 @@
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="addFieldModal; closeAlways: true">
<a class="dropdown-item" (click)="addField(true)">
<a class="dropdown-item" (click)="addField('Add')">
{{ "schemas.addFieldAndCreate" | sqxTranslate }}
</a>
<a class="dropdown-item" (click)="addField(false, true)">
<a class="dropdown-item" (click)="addField('Edit')">
{{ "schemas.addFieldAndEdit" | sqxTranslate }}
</a>
</sqx-dropdown-menu>
@ -119,13 +119,9 @@
@if (editForm) {
<div>
<div
class="btn-group ms-2"
attr.aria-label="{{ 'schemas.addFieldAndClose' | sqxTranslate }}"
position="top-end"
role="group">
<button class="btn btn-primary" (click)="save(true)" shortcut="CTRL + SHIFT + S">
{{ "schemas.saveFieldAndNew" | sqxTranslate }}
<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">
{{ "schemas.saveFieldAndClose" | sqxTranslate }}
</button>
<button
class="btn btn-primary"
@ -141,8 +137,8 @@
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="addFieldModal; closeAlways: true">
<a class="dropdown-item" (click)="save()">
{{ "schemas.saveFieldAndClose" | sqxTranslate }}
<a class="dropdown-item" (click)="save('Add')">
{{ "schemas.saveFieldAndNew" | sqxTranslate }}
</a>
</sqx-dropdown-menu>
</div>

80
frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts

@ -11,7 +11,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AddFieldForm, AppSettingsDto, ControlErrorsComponent, createProperties, DropdownMenuComponent, EditFieldForm, FieldDto, fieldTypes, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, LanguagesState, ModalDialogComponent, ModalDirective, ModalModel, ModalPlacementDirective, RootFieldDto, SchemaDto, SchemasState, TooltipDirective, TranslatePipe, Types } from '@app/shared';
import { FieldFormComponent } from './forms/field-form.component';
const DEFAULT_FIELD = { name: '', partitioning: 'invariant', properties: createProperties('String') };
type SaveNavigationMode = 'Close' | 'Add' | 'Edit';
@Component({
standalone: true,
@ -80,60 +81,73 @@ export class FieldWizardComponent implements OnInit {
this.dialogClose.emit();
}
public addField(addNew: boolean, edit = false) {
public addField(navigationMode: SaveNavigationMode) {
const value = this.addFieldForm.submit();
if (value) {
this.schemasState.addField(this.schema, value, this.parent)
.subscribe({
next: dto => {
this.field = dto;
if (!value) {
return;
}
this.addFieldForm.submitCompleted({ newValue: { ...DEFAULT_FIELD } });
this.schemasState.addField(this.schema, value, this.parent)
.subscribe({
next: dto => {
switch (navigationMode) {
case 'Add':
this.addFieldForm.submitCompleted({ newValue: { ...DEFAULT_FIELD } });
if (addNew) {
if (Types.isFunction(this.nameInput.nativeElement.focus)) {
this.nameInput.nativeElement.focus();
}
} else if (edit) {
break;
case 'Edit':
this.field = dto;
this.editForm = new EditFieldForm(this.field.properties);
this.editForm.load(this.field.properties);
} else {
break;
case 'Close':
this.emitClose();
}
},
error: error => {
this.addFieldForm.submitFailed(error);
},
});
}
}
},
error: error => {
this.addFieldForm.submitFailed(error);
},
});
}
public save(addNew = false) {
public save(navigationMode: SaveNavigationMode) {
if (!this.editForm) {
return;
}
const value = this.editForm.submit();
if (value) {
const properties = createProperties(this.field.properties.fieldType, value);
if (!value) {
return;
}
this.schemasState.updateField(this.schema, this.field as RootFieldDto, { properties })
.subscribe({
next: () => {
this.editForm!.submitCompleted();
const properties = createProperties(this.field.properties.fieldType, value);
if (addNew) {
this.schemasState.updateField(this.schema, this.field as RootFieldDto, { properties })
.subscribe({
next: () => {
switch (navigationMode) {
case 'Add':
this.editForm = undefined;
} else {
break;
case 'Close': {
this.emitClose();
}
},
error: error => {
this.editForm!.submitFailed(error);
},
});
}
}
},
error: error => {
this.editForm!.submitFailed(error);
},
});
}
}
const DEFAULT_FIELD = { name: '', partitioning: 'invariant', properties: createProperties('String') };

2
frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html

@ -4,7 +4,7 @@
[cdkDropListDisabled]="!sortable"
(cdkDropListDropped)="sortGroups($event)"
cdkDropListGroup>
@for (group of fieldGroups; track group) {
@for (group of fieldGroups; track group.id) {
<div class="table-drag" cdkDrag [cdkDragDisabled]="!group.separator" cdkDragLockAxis="y">
<sqx-field-group
[fieldGroup]="group"

2
frontend/src/app/framework/angular/forms/editable-title.stories.ts

@ -14,7 +14,7 @@ export default {
component: EditableTitleComponent,
argTypes: {
inputTitle: {
control: 'inputTitle',
control: 'text',
},
closeButton: {
control: 'boolean',

7
frontend/src/app/framework/angular/forms/editors/checkbox-group.stories.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { FormsModule } from '@angular/forms';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { CheckboxGroupComponent } from '@app/framework';
@ -18,6 +19,9 @@ export default {
unsorted: {
control: 'boolean',
},
change: {
action:'ngModelChange',
},
},
render: args => ({
props: args,
@ -26,7 +30,7 @@ export default {
<sqx-checkbox-group
[disabled]="disabled"
[layout]="layout"
(ngModelChange)="ngModelChange"
(ngModelChange)="change($event)"
[ngModel]="model"
[unsorted]="unsorted"
[values]="values">
@ -37,6 +41,7 @@ export default {
decorators: [
moduleMetadata({
imports: [
FormsModule,
],
}),
],

2
frontend/src/app/framework/angular/forms/editors/code-editor.component.ts

@ -143,7 +143,7 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, any> imple
}
public async ngAfterViewInit() {
this.valueChanged.pipe(debounceTime(500))
this.valueChanged.pipe(debounceTime(100))
.subscribe(() => {
this.changeValue();
});

20
frontend/src/app/framework/angular/forms/editors/code-editor.stories.ts

@ -5,6 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { FormsModule } from '@angular/forms';
import { action } from '@storybook/addon-actions';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { CodeEditorComponent, ScriptCompletions } from '@app/framework';
@ -18,6 +20,12 @@ export default {
dropdownFullWidth: {
control: 'boolean',
},
singleLine: {
control: false,
},
change: {
action: 'ngModelChange',
},
},
render: args => ({
props: args,
@ -28,6 +36,8 @@ export default {
[disabled]="disabled"
[height]="height"
[maxLines]="maxLines"
(ngModelChange)="change($event)"
[ngModel]="ngModel"
[singleLine]="singleLine"
[valueFile]="valueFile"
[valueMode]="valueMode"
@ -39,6 +49,7 @@ export default {
decorators: [
moduleMetadata({
imports: [
FormsModule,
],
}),
],
@ -63,11 +74,13 @@ type Story = StoryObj<CodeEditorComponent>;
export const Default: Story = {
args: {
height: 'auto',
},
ngModel: 'Value',
} as any,
};
export const Completions: Story = {
args: {
height: 200,
completion: COMPLETIONS,
},
};
@ -84,6 +97,8 @@ export const SingleLine: Story = {
[disabled]="disabled"
[height]="height"
[maxLines]="maxLines"
(ngModelChange)="ngModelChange"
[ngModel]="ngModel"
[singleLine]="singleLine"
[valueFile]="valueFile"
[valueMode]="valueMode"
@ -98,5 +113,6 @@ export const SingleLine: Story = {
}),
args: {
singleLine: true,
},
ngModelChange: action('ngModelChange'),
} as any,
};

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

@ -29,7 +29,6 @@
adjustHeight="false"
[adjustWidth]="dropdownFullWidth"
[position]="dropdownPosition"
r
scrollX="false"
scrollY="true"
[sqxAnchoredTo]="input"

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

@ -6,6 +6,8 @@
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { DropdownComponent, LocalizerService, RootViewComponent } from '@app/framework';
@ -52,6 +54,19 @@ export default {
dropdownFullWidth: {
control: 'boolean',
},
change: {
action:'ngModelChange',
},
position: {
control: 'radio',
options: [
'bottom-start',
'bottom-end',
],
},
},
args: {
position: 'bottom-start',
},
render: args => ({
props: args,
@ -59,10 +74,11 @@ export default {
<sqx-root-view>
<sqx-dropdown
[disabled]="disabled"
[dropdownPosition]="'bottom-left'"
[dropdownPosition]="position"
[dropdownFullWidth]="dropdownFullWidth"
[items]="items"
[itemsLoading]="itemsLoading"
(ngModelChange)="change($event)"
[ngModel]="model">
</sqx-dropdown>
</sqx-root-view>
@ -74,6 +90,8 @@ export default {
TestComponent,
],
imports: [
BrowserAnimationsModule,
FormsModule,
RootViewComponent,
],
providers: [
@ -146,7 +164,7 @@ export const ComplexValues: Story = {
<sqx-root-view>
<sqx-dropdown
[disabled]="disabled"
[dropdownPosition]="'bottom-left'"
[dropdownPosition]="'bottom-start'"
[dropdownFullWidth]="dropdownFullWidth"
[items]="items"
[itemsLoading]="itemsLoading"

7
frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { FormsModule } from '@angular/forms';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { RadioGroupComponent } from '@app/framework';
@ -18,6 +19,9 @@ export default {
unsorted: {
control: 'boolean',
},
change: {
action:'ngModelChange',
},
},
render: args => ({
props: args,
@ -26,7 +30,7 @@ export default {
<sqx-radio-group
[disabled]="disabled"
[layout]="layout"
(ngModelChange)="ngModelChange"
(ngModelChange)="change($event)"
[ngModel]="model"
[unsorted]="unsorted"
[values]="values">
@ -37,6 +41,7 @@ export default {
decorators: [
moduleMetadata({
imports: [
FormsModule,
],
}),
],

6
frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts

@ -6,6 +6,7 @@
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { LocalizerService, RootViewComponent, TagEditorComponent } from '@app/framework';
@ -52,6 +53,9 @@ export default {
disabled: {
control: 'boolean',
},
change: {
action:'ngModelChange',
},
},
render: args => ({
props: args,
@ -62,6 +66,7 @@ export default {
[disabled]="disabled"
[itemsSource]="itemsSource"
[itemsSourceLoading]="itemsSourceLoading"
(ngModelChange)="change($event)"
[ngModel]="model"
[styleScrollable]="styleScrollable"
[styleBlank]="styleBlank"
@ -76,6 +81,7 @@ export default {
TestComponent,
],
imports: [
FormsModule,
RootViewComponent,
],
providers: [

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

@ -7,7 +7,7 @@
<ng-content select="[title]"></ng-content>
</h4>
<ng-content select="[plainTitle]"></ng-content>
<button class="btn btn-close" (click)="dialogClose.emit()" type="button"></button>
<button class="btn btn-close" attr.aria-label="{{ 'common.close' | sqxTranslate }}" (click)="dialogClose.emit()" type="button"></button>
</div>
}

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

@ -8,6 +8,7 @@
import { AfterViewInit, booleanAttribute, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, Renderer2, ViewChild } from '@angular/core';
import { fadeAnimation } from '@app/framework/internal';
import { TranslatePipe } from '../pipes/translate.pipe';
import { ShortcutComponent } from '../shortcut.component';
import { TourStepDirective } from './tour-step.directive';
@ -22,6 +23,7 @@ import { TourStepDirective } from './tour-step.directive';
changeDetection: ChangeDetectionStrategy.Default,
imports: [
ShortcutComponent,
TranslatePipe,
TourStepDirective,
],
})

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

@ -31,13 +31,14 @@
<div class="overlay-menu">
@if (!isDisabled) {
<a class="file-edit ms-2" (click)="emitEdit()">
<a class="file-edit ms-2" attr.aria-label="{{ 'common.edit' | sqxTranslate }}" (click)="emitEdit()">
<i class="icon-pencil"></i>
</a>
}
<a
class="file-download ms-2"
attr.aria-label="{{ 'common.download' | sqxTranslate }}"
[href]="asset | sqxAssetUrl: asset.version : false"
sqxExternalLink="noicon"
sqxStopClick>
@ -53,6 +54,7 @@
@if (!isDisabled && !removeMode && asset.canDelete) {
<a
class="file-delete ms-2"
attr.aria-label="{{ 'common.delete' | sqxTranslate }}"
confirmRememberKey="deleteAsset"
confirmText="i18n:assets.deleteConfirmText"
confirmTitle="i18n:assets.deleteConfirmTitle"
@ -64,6 +66,7 @@
@if (!isDisabled && removeMode) {
<a
class="file-delete ms-2"
attr.aria-label="{{ 'common.remove' | sqxTranslate }}"
confirmRememberKey="removeAsset"
confirmText="i18n:assets.removeConfirmText"
confirmTitle="i18n:assets.removeConfirmTitle"

20
frontend/src/app/shared/state/contents.forms-helpers.ts

@ -99,7 +99,7 @@ export abstract class Hidden {
}
}
export type FieldGroup<T = FieldDto> = { separator?: T; fields: T[] };
export type FieldGroup<T = FieldDto> = { separator?: T; fields: T[]; id: string };
export function groupFields<T extends FieldDto>(fields: ReadonlyArray<T>, keepEmpty = false): FieldGroup<T>[] {
const result: FieldGroup<T>[] = [];
@ -107,23 +107,25 @@ export function groupFields<T extends FieldDto>(fields: ReadonlyArray<T>, keepEm
let currentSeparator: T | undefined;
let currentFields: T[] = [];
const addGroup = () => {
if (currentFields.length > 0 || keepEmpty) {
const id = currentSeparator?.fieldId.toString() || 'DEFAULT';
result.push({ separator: currentSeparator, fields: currentFields, id });
}
};
for (const field of fields) {
if (field.properties.isContentField) {
currentFields.push(field);
} else {
if (currentFields.length > 0 || keepEmpty) {
result.push({ separator: currentSeparator, fields: currentFields });
}
addGroup();
currentFields = [];
currentSeparator = field;
}
}
if (currentFields.length > 0 || keepEmpty) {
result.push({ separator: currentSeparator, fields: currentFields });
}
addGroup();
return result;
}

BIN
tools/e2e/assets/logo-squared.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

0
tools/e2e/snapshots/start-page.spec.ts-snapshots/visual-test-1-logged-out-linux.png → tools/e2e/snapshots/login-start.spec.ts-snapshots/visual-test-1-logged-out-linux.png

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

0
tools/e2e/snapshots/start-page.spec.ts-snapshots/visual-test-1-logged-out-win32.png → tools/e2e/snapshots/login-start.spec.ts-snapshots/visual-test-1-logged-out-win32.png

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

65
tools/e2e/tests/_fixture.ts

@ -5,34 +5,49 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { test as base, Page } from '@playwright/test';
type BaseFixture = {
dropdown: Dropdown;
import { test as base } from '@playwright/test';
import { AssetsPage, ContentPage, ContentsPage, LoginPage, RulePage, RulesPage, SchemaPage, SchemasPage } from './pages';
import { AppsPage } from './pages/apps';
export type BaseFixture = {
appsPage: AppsPage;
assetsPage: AssetsPage;
contentPage: ContentPage;
contentsPage: ContentsPage;
loginPage: LoginPage;
rulePage: RulePage;
rulesPage: RulesPage;
schemaPage: SchemaPage;
schemasPage: SchemasPage;
};
class Dropdown {
constructor(
private readonly page: Page,
) {
}
public async delete() {
await this.page.getByText('Delete').click();
await this.page.getByRole('button', { name: /Yes/ }).click();
}
public async action(name: string) {
await this.page.getByText(name).click();
await this.page.locator('sqx-dropdown-menu').waitFor({ state: 'hidden' });
}
}
export const test = base.extend<BaseFixture>({
dropdown: async ({ page }, use) => {
const dropdown = new Dropdown(page);
await use(dropdown);
appsPage: async ({ page }, use) => {
await use(new AppsPage(page));
},
assetsPage: async ({ page }, use) => {
await use(new AssetsPage(page));
},
contentPage: async ({ page }, use) => {
await use(new ContentPage(page));
},
contentsPage: async ({ page }, use) => {
await use(new ContentsPage(page));
},
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
rulePage: async ({ page }, use) => {
await use(new RulePage(page));
},
rulesPage: async ({ page }, use) => {
await use(new RulesPage(page));
},
schemaPage: async ({ page }, use) => {
await use(new SchemaPage(page));
},
schemasPage: async ({ page }, use) => {
await use(new SchemasPage(page));
},
});

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

@ -5,20 +5,19 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { test as setup } from '@playwright/test';
import { getRandomId, writeJsonAsync } from '../utils';
import { test as setup } from './../given-login/_fixture';
setup('prepare app', async ({ page }) => {
setup('prepare app', async ({ appsPage }) => {
const appName = `my-app-${getRandomId()}`;
await page.goto('/app');
await appsPage.goto();
await page.getByTestId('new-app').click();
const dialog = await appsPage.openAppDialog();
await dialog.enterName(appName);
await dialog.save();
await page.locator('#name').fill(appName);
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('heading', { name: appName }).click();
await appsPage.gotoApp(appName);
await writeJsonAsync('app', { appName });
});

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

@ -0,0 +1,80 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { readFile } from 'fs/promises';
import path from 'path';
import { AssetsPage } from '../pages';
import { getRandomId } from '../utils';
import { expect, test } from './_fixture';
test.beforeEach(async ({ appName, assetsPage }) => {
await assetsPage.goto(appName);
});
test('should upload asset', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
expect(assetCard.root).toBeVisible();
});
test('should delete asset', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
await assetCard.delete();
expect(assetCard.root).not.toBeVisible();
});
test('should not delete asset if cancelled', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
await assetCard.delete(true);
expect(assetCard.root).toBeVisible();
});
test('should edit asset name', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
const newName = `file-${getRandomId()}`;
const assetDialog = await assetCard.edit();
await assetDialog.enterName(newName);
await assetDialog.save();
const newCard = await assetsPage.getAssetCard(newName);
expect(newCard.root).toBeVisible();
});
test('should edit asset metadata', async ({ assetsPage }) => {
const assetName = await uploadRandomAsset(assetsPage);
const assetCard = await assetsPage.getAssetCard(assetName);
const w = '42';
const h = '13';
const assetDialog = await assetCard.edit();
await assetDialog.enterMetadata('pixelWidth', w);
await assetDialog.enterMetadata('pixelHeight', h);
await assetDialog.save();
const newCard = await assetsPage.getAssetCard(`${w}x${h}px`);
expect(newCard.root).toBeVisible();
});
async function uploadRandomAsset(assetsPage: AssetsPage) {
const fileName = `file-${getRandomId()}`;
const fileBuffer = await readFile(path.join(__dirname, '../../assets/logo-squared.png'));
await assetsPage.uploadFile({ name: fileName, mimeType: 'image/png', buffer: fileBuffer });
return fileName;
}

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

@ -1,90 +1,80 @@
import { expect, Page } from '@playwright/test';
import { escapeRegex, getRandomId } from '../utils';
import { expect } from '@playwright/test';
import { RulePage, RulesPage } from '../pages';
import { getRandomId } from '../utils';
import { test } from './_fixture';
// We have no easy way to identity rules. Therefore run them sequentially.
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page, appName }) => {
await page.goto(`/app/${appName}/rules`);
test.beforeEach(async ({ appName, rulesPage }) => {
await rulesPage.goto(appName);
});
test('create rule', async ({ page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) });
test('create rule', async ({ rulesPage, rulePage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
await expect(ruleCard).toBeVisible();
await expect(ruleCard.root).toBeVisible();
});
test('delete rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) });
test('delete rule', async ({ rulesPage, rulePage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
await ruleCard.getByLabel('Options').click();
const dropdown = await ruleCard.openOptionsDropdown();
await dropdown.delete();
await expect(ruleCard).not.toBeVisible();
await expect(ruleCard.root).not.toBeVisible();
});
test('disable rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) });
test('disable rule', async ({ rulePage, rulesPage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
await ruleCard.getByLabel('Options').click();
const dropdown = await ruleCard.openOptionsDropdown();
await dropdown.action('Disable');
await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'unchecked');
await expect(ruleCard.root.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'unchecked');
});
test('enable rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) });
test('enable rule', async ({ rulePage, rulesPage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
await ruleCard.getByLabel('Options').click();
await dropdown.action('Disable');
const dropdown1 = await ruleCard.openOptionsDropdown();
await dropdown1.action('Disable');
await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'unchecked');
await expect(ruleCard.root.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'unchecked');
await ruleCard.getByLabel('Options').click();
await dropdown.action('Enable');
const dropdown2 = await ruleCard.openOptionsDropdown();
await dropdown2.action('Enable');
await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'checked');
await expect(ruleCard.root.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'checked');
});
test('edit rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) });
test('edit rule', async ({ page, rulePage, rulesPage }) => {
const ruleName = await createRandomRule(rulesPage, rulePage);
const ruleCard = await rulesPage.getRule(ruleName);
await ruleCard.getByLabel('Options').click();
const dropdown = await ruleCard.openOptionsDropdown();
await dropdown.action('Edit');
await expect(page.getByText('Enabled')).toBeVisible();
});
async function createRandomRule(page: Page) {
async function createRandomRule(rulesPage: RulesPage, rulePage: RulePage) {
const ruleName = `rule-${getRandomId()}`;
await page.getByRole('link', { name: /New Rule/ }).click();
// Define rule action
await page.getByText('Content changed').click();
// Define rule trigger
await page.getByText('Webhook').click();
// This is the only required field, so we have to enter some text.
await page.locator('sqx-formattable-input').first().getByRole('textbox').fill('https:/squidex.io');
await page.getByRole('button', { name: 'Save' }).click();
await page.getByText('Enabled').waitFor({ state: 'visible' });
await rulesPage.addRule();
// Go back
await page.getByLabel('Back').click();
await rulePage.selectContentChangedTrigger();
await rulePage.selectWebhookAction();
await rulePage.save();
await rulePage.back();
// Define rule name.
await page.locator('div.card', { hasText: /Unnamed Rule/ }).getByRole('heading').first().dblclick();
await page.locator('form').getByRole('textbox').fill(ruleName);
await page.locator('form').getByLabel('Save').click();
const rename = await rulesPage.renameRule(/Unnamed Rule/);
await rename.enterName(ruleName);
await rename.save();
return ruleName;
}

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

@ -5,84 +5,193 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
import { escapeRegex, getRandomId } from '../utils';
import { SchemaPage, SchemasPage } from '../pages';
import { getRandomId } from '../utils';
import { expect, test } from './_fixture';
test.beforeEach(async ({ page, appName }) => {
await page.goto(`/app/${appName}/schemas`);
test.beforeEach(async ({ appName, schemasPage }) => {
await schemasPage.goto(appName);
});
test('create schema', async ({ page }) => {
const schemaName = await createRandomSchema(page);
const schemaLink = page.locator('a.nav-link', { hasText: escapeRegex(schemaName) });
test('create schema', async ({ schemasPage }) => {
const schemaName = await createRandomSchema(schemasPage);
const schemaLink = await schemasPage.getSchemaLink(schemaName);
await expect(schemaLink).toBeVisible();
await expect(schemaLink.root).toBeVisible();
});
test('delete schema', async ({ dropdown, page }) => {
const schemaName = await createRandomSchema(page);
const schemaLink = page.locator('a.nav-link', { hasText: escapeRegex(schemaName) });
test('delete schema', async ({ schemasPage, schemaPage }) => {
const schemaName = await createRandomSchema(schemasPage);
const schemaLink = await schemasPage.getSchemaLink(schemaName);
await page.getByLabel('Options').click();
const dropdown = await schemaPage.openOptionsDropdown();
await dropdown.delete();
await expect(schemaLink).not.toBeVisible();
await expect(schemaLink.root).not.toBeVisible();
});
test('publish schema', async ({ page }) => {
await createRandomSchema(page);
test('publish schema', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
await page.getByRole('button', { name: 'Published', exact: true }).click();
await schemaPage.publish();
});
test('unpublish schema', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
await expect(page.getByRole('button', { name: 'Published', exact: true })).toBeDisabled();
await schemaPage.publish();
await schemaPage.unpublish();
});
test('add field', async ({ page }) => {
await createRandomSchema(page);
test('add field', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const fieldName = await createRandomField(page);
const fieldRow = page.getByText(fieldName);
const fieldName = await createRandomField(schemaPage);
const fieldRow = await schemaPage.getFieldRow(fieldName);
await expect(fieldRow).toBeVisible();
await expect(fieldRow.root).toBeVisible();
});
test('delete field', async ({ dropdown, page }) => {
await createRandomSchema(page);
test('add field and edit', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const fieldName = `field-${getRandomId()}`;
const fieldLabel = `field-${getRandomId()}`;
const fieldName = await createRandomField(page);
const fieldRow = page.locator('div.table-items-row-summary', { hasText: escapeRegex(fieldName) });
const fieldDialog = await schemaPage.openFieldWizard();
await fieldDialog.enterName(fieldName);
await fieldDialog.createAndEdit();
await fieldRow.getByLabel('Options').click();
await dropdown.delete();
await fieldDialog.enterLabel(fieldLabel);
await fieldDialog.saveAndClose();
const fieldRow = await schemaPage.getFieldRow(fieldLabel);
await expect(fieldRow).not.toBeVisible();
await expect(fieldRow.root).toBeVisible();
});
async function createRandomField(page: Page) {
const fieldName = `field-${getRandomId()}`;
test('add field and add another', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
await page.locator('button').filter({ hasText: /^Add Field$/ }).click();
const fieldName1 = `field-${getRandomId()}`;
const fieldName2 = `field-${getRandomId()}`;
// Define field name.
await page.getByPlaceholder('Enter field name').fill(fieldName);
const fieldDialog = await schemaPage.openFieldWizard();
await fieldDialog.enterName(fieldName1);
await fieldDialog.createAndAdd();
// Save field.
await page.getByTestId('dialog').getByRole('button', { name: 'Create' }).click();
await fieldDialog.enterName(fieldName2);
await fieldDialog.createAndAdd();
return fieldName;
}
const fieldRow1 = await schemaPage.getFieldRow(fieldName1);
const fieldRow2 = await schemaPage.getFieldRow(fieldName2);
await expect(fieldRow1.root).toBeVisible();
await expect(fieldRow2.root).toBeVisible();
});
test('add field to array', async ({ schemasPage, schemaPage }) => {
await createRandomSchema( schemasPage);
const rootFieldName = `field-${getRandomId()}`;
const rootDialog = await schemaPage.openFieldWizard();
await rootDialog.enterName(rootFieldName);
await rootDialog.enterType('Array');
await rootDialog.createAndClose();
const nestedFieldName = `field-${getRandomId()}`;
const nestedDialog = await schemaPage.openNestedFieldWizard();
await nestedDialog.enterName(nestedFieldName);
await nestedDialog.createAndClose();
const fieldRow = await schemaPage.getFieldRow(nestedFieldName);
await expect(fieldRow.root).toBeVisible();
});
test('add field to array and ed', async ({ schemasPage, schemaPage }) => {
await createRandomSchema(schemasPage);
const rootFieldName = `field-${getRandomId()}`;
const rootDialog = await schemaPage.openFieldWizard();
await rootDialog.enterName(rootFieldName);
await rootDialog.enterType('Array');
await rootDialog.createAndClose();
const nestedFieldName = `field-${getRandomId()}`;
const nestedFieldLabel = `field-${getRandomId()}`;
async function createRandomSchema(page: Page) {
const schemaName = `schema-${getRandomId()}`;
const nestedDialog = await schemaPage.openNestedFieldWizard();
await nestedDialog.enterName(nestedFieldName);
await nestedDialog.createAndEdit();
await page.getByLabel('Create Schema').click();
await nestedDialog.enterLabel(nestedFieldLabel);
await nestedDialog.saveAndClose();
const fieldRow = await schemaPage.getFieldRow(nestedFieldLabel);
await expect(fieldRow.root).toBeVisible();
});
test('add field to array and another', async ({ schemasPage, schemaPage }) => {
await createRandomSchema(schemasPage);
const rootFieldName = `field-${getRandomId()}`;
const rootDialog = await schemaPage.openFieldWizard();
await rootDialog.enterName(rootFieldName);
await rootDialog.enterType('Array');
await rootDialog.createAndClose();
const nestedFieldName1 = `field-${getRandomId()}`;
const nestedFieldName2 = `field-${getRandomId()}`;
const nestedDialog = await schemaPage.openNestedFieldWizard();
await nestedDialog.enterName(nestedFieldName1);
await nestedDialog.createAndAdd();
await nestedDialog.enterName(nestedFieldName2);
await nestedDialog.createAndClose();
const fieldRow1 = await schemaPage.getFieldRow(nestedFieldName1);
const fieldRow2 = await schemaPage.getFieldRow(nestedFieldName2);
await expect(fieldRow1.root).toBeVisible();
await expect(fieldRow2.root).toBeVisible();
});
test('delete 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.delete();
await expect(fieldRow.root).not.toBeVisible();
});
async function createRandomField(schemaPage: SchemaPage) {
const name = `field-${getRandomId()}`;
const fieldDialog = await schemaPage.openFieldWizard();
await fieldDialog.enterName(name);
await fieldDialog.enterType('String');
await fieldDialog.createAndClose();
return name;
}
// Define schema name.
await page.getByLabel('Name (required)').fill(schemaName);
async function createRandomSchema(schemasPage: SchemasPage) {
const name = `schema-${getRandomId()}`;
// Save schema.
await page.getByRole('button', { name: 'Create', exact: true }).click();
const schemaDialog = await schemasPage.openSchemaDialog();
await schemaDialog.enterName(name);
await schemaDialog.save();
return schemaName;
return name;
}

21
tools/e2e/tests/given-login/_setup.ts

@ -8,22 +8,13 @@
import { STORAGE_STATE } from '../../playwright.config';
import { test as setup } from './_fixture';
setup('prepare login', async ({ page, userEmail, userPassword }) => {
await page.goto('/');
setup('prepare login', async ({ page, userEmail, userPassword, loginPage }) => {
await loginPage.goto();
// Start waiting for popup before clicking.
const popupPromise = page.waitForEvent('popup');
await page.getByTestId('login').click();
const popup = await popupPromise;
await popup.waitForLoadState();
await popup.getByTestId('login-button').waitFor();
await popup.getByPlaceholder('Enter Email').fill(userEmail);
await popup.getByPlaceholder('Enter Password').fill(userPassword);
await popup.getByTestId('login-button').click();
const popup = await loginPage.openPopup();
await popup.enterEmail(userEmail);
await popup.enterPassword(userPassword),
await popup.login();
await page.waitForURL(/app/);

4
tools/e2e/tests/given-login/apps-page.spec.ts

@ -7,8 +7,8 @@
import { expect, test } from './_fixture';
test.beforeEach(async ({ page }) => {
await page.goto('/app');
test.beforeEach(async ({ appsPage }) => {
await appsPage.goto();
});
test('has title', async ({ page }) => {

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

@ -8,17 +8,16 @@
import { getRandomId } from '../utils';
import { expect, test } from './_fixture';
test.beforeEach(async ({ page }) => {
await page.goto('/app');
test.beforeEach(async ({ appsPage }) => {
await appsPage.goto();
});
test('create app', async ({ page }) => {
test('create app', async ({ page, appsPage }) => {
const appName = `my-app-${getRandomId()}`;
await page.getByTestId('new-app').click();
await page.locator('#name').fill(appName);
await page.getByRole('button', { name: 'Create' }).click();
const appDialog = await appsPage.openAppDialog();
await appDialog.enterName(appName);
await appDialog.save();
const newApp = page.getByRole('heading', { name: appName });

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

@ -5,42 +5,31 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test as setup } from '../given-app/_fixture';
import { test as setup } from '../given-app/_fixture';
import { getRandomId, writeJsonAsync } from '../utils';
setup('prepare schema', async ({ page, appName }) => {
const schemaName = `my-schema-${getRandomId()}`;
const fields = [{
name: 'my-string',
}];
const fields = [{
name: 'my-string',
}];
await page.goto(`/app/${appName}/schemas`);
setup('prepare schema', async ({ appName, schemasPage, schemaPage }) => {
const schemaName = `my-schema-${getRandomId()}`;
await page.getByLabel('Create Schema').click();
await schemasPage.goto(appName);
// Define schema name.
await page.getByLabel('Name (required)').fill(schemaName);
const createDialog = await schemasPage.openSchemaDialog();
await createDialog.enterName(schemaName);
await createDialog.save();
// Save schema.
await page.getByRole('button', { name: 'Create', exact: true }).click();
await schemaPage.publish();
for (const field of fields) {
await page.locator('button').filter({ hasText: /^Add Field$/ }).click();
// Define field name.
await page.getByPlaceholder('Enter field name').fill(field.name);
// Save field.
await page.getByTestId('dialog').getByRole('button', { name: 'Create' }).click();
const fieldDialog = await schemaPage.openFieldWizard();
await fieldDialog.enterName(field.name);
await fieldDialog.enterType('String');
await fieldDialog.createAndClose();
}
// Publish schema.
await page.getByRole('button', { name: 'Published', exact: true }).click();
// Just wait for the publish operation to complete.
await expect(page.getByRole('button', { name: 'Published', exact: true })).toBeDisabled();
await writeJsonAsync('schema', { schemaName });
await writeJsonAsync('fields', { fields });
});

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

@ -5,206 +5,199 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
import { escapeRegex, getRandomId } from '../utils';
import { ContentPage, ContentsPage } from '../pages';
import { getRandomId } from '../utils';
import { expect, test } from './_fixture';
test.beforeEach(async ({ page, appName, schemaName }) => {
await page.goto(`/app/${appName}/content/${schemaName}`);
await page.getByRole('combobox').selectOption('3: 50');
test.beforeEach(async ({ appName, schemaName, contentsPage }) => {
await contentsPage.goto(appName, schemaName);
await contentsPage.increasePageSize();
});
test('create content and close', async ({ page }) => {
const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
test('create content and close', async ({ contentsPage, contentPage }) => {
await contentsPage.addContent();
await expect(contentRow).toBeVisible();
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
await contentPage.saveAndClose();
const contentRow = await contentsPage.getContentRow(contentText);
await expect(contentRow.root.getByLabel('Draft')).toBeVisible();
});
test('create content and edit', async ({ page }) => {
await createRandomContent(page, 'SaveAndEdit');
test('create content and edit', async ({ page, contentsPage, contentPage }) => {
await contentsPage.addContent();
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
await contentPage.saveAndEdit();
await expect(page.getByRole('button', { name: /Draft/ })).toBeVisible();
await expect(page.getByLabel('Identity')).toBeVisible();
});
test('create content and add another', async ({ page }) => {
await createRandomContent(page, 'SaveAndAdd');
test('create content and add another', async ({ page, contentsPage, contentPage }) => {
await contentsPage.addContent();
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
await contentPage.saveAndAdd();
await expect(page.locator('sqx-field-editor').getByRole('textbox')).toBeEmpty();
});
test('create content as published and edit', async ({ page }) => {
await createRandomContent(page, 'SavePublishAndEdit');
test('create content as published and edit', async ({ page, contentsPage, contentPage }) => {
await contentsPage.addContent();
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
await contentPage.savePublishAndEdit();
await expect(page.getByRole('button', { name: /Published/ })).toBeVisible();
await expect(page.getByLabel('Identity')).toBeVisible();
});
test('create content as published and close', async ({ page }) => {
const contentText = await createRandomContent(page, 'SavePublishAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
test('create content as published and close', async ({ contentsPage, contentPage }) => {
await contentsPage.addContent();
await expect(contentRow.getByLabel('Published')).toBeVisible();
});
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
await contentPage.savePublishAndClose();
test('update content', async ({ page }) => {
const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
const contentRow = await contentsPage.getContentRow(contentText);
await contentRow.click();
await expect(contentRow.root.getByLabel('Published')).toBeVisible();
});
const contentUpdate = `content-${getRandomId()}`;
test('update content', async ({ page, contentsPage, contentPage }) => {
const contentText = await createRandomContent(contentsPage, contentPage);
const contentRow = await contentsPage.getContentRow(contentText);
await contentRow.edit();
// Define content value.
await page.locator('sqx-field-editor').getByRole('textbox').fill(contentUpdate);
await saveContent(page, 'Save');
const contentUpdate = `content-${getRandomId()}`;
await page.getByRole('button', { name: 'Save', exact: true }).click();
await contentPage.enterField(contentUpdate);
await contentPage.save();
// Wait for update of the version
await page.getByText('Version: 1').waitFor({ state: 'visible' });
await contentPage.back();
// Go back
await page.getByLabel('Back').click();
const updateRow = page.locator('tr', { hasText: escapeRegex(contentUpdate) });
const updateRow = await contentsPage.getContentRow(contentUpdate);
await expect(updateRow).toBeVisible();
await expect(updateRow.root).toBeVisible();
});
const states = [{
state: 'Archived',
currentState: 'Draft',
initialPublished: false,
status: 'Archived',
currentStatus: 'Draft',
}, {
state: 'Draft',
currentState: 'Published',
initialPublished: true,
status: 'Draft',
currentStatus: 'Published',
}, {
state: 'Published',
currentState: 'Draft',
initialPublished: false,
status: 'Published',
currentStatus: 'Draft',
}];
states.forEach(({ state, currentState, initialPublished }) => {
const mode: SaveMode = initialPublished ? 'SavePublishAndClose' : 'SaveAndClose';
states.forEach(({ status, currentStatus }) => {
test(`change content from <${currentStatus}> to <${status}>`, async ({ contentsPage, contentPage }) => {
await contentsPage.addContent();
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
test(`change content to ${state}`, async ({ dropdown, page }) => {
const contentText = await createRandomContent(page, mode);
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
if (currentStatus === 'Published') {
await contentPage.savePublishAndClose();
} else {
await contentPage.saveAndClose();
}
await contentRow.getByLabel('Options').click();
await dropdown.action(`Change to ${state}`);
await page.getByRole('button', { name: 'Confirm' }).click();
const contentRow = await contentsPage.getContentRow(contentText);
const dropdown = await contentRow.openOptionsDropdown();
await dropdown.actionConfirm(`Change to ${status}`);
await expect(contentRow.getByLabel(state)).toBeVisible();
await expect(contentRow.root.getByLabel(status)).toBeVisible();
});
test(`change content to ${state} by checkbox`, async ({ page }) => {
const contentText = await createRandomContent(page, mode);
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
test(`change content from <${currentStatus}> to <${status}> by checkbox`, async ({ contentsPage, contentPage }) => {
await contentsPage.addContent();
await contentRow.getByRole('checkbox').click();
await page.getByRole('button', { name: state }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
await expect(contentRow.getByLabel(state)).toBeVisible();
if (currentStatus === 'Published') {
await contentPage.savePublishAndClose();
} else {
await contentPage.saveAndClose();
}
const contentRow = await contentsPage.getContentRow(contentText);
await contentRow.select();
await contentsPage.changeSelectedStatus(status);
await expect(contentRow.root.getByLabel(status)).toBeVisible();
});
test(`change content to ${state} by detail page`, async ({ page }) => {
const contentText = await createRandomContent(page, mode);
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
test(`change content from <${currentStatus}> to <${status}> by detail page`, async ({ page, contentsPage, contentPage }) => {
await contentsPage.addContent();
const contentText = `content-${getRandomId()}`;
await contentPage.enterField(contentText);
await contentRow.click();
await page.getByRole('button', { name: currentState }).click();
await page.getByText(`Change to ${state}`).click();
await page.getByRole('button', { name: 'Confirm' }).click();
if (currentStatus === 'Published') {
await contentPage.savePublishAndEdit();
} else {
await contentPage.saveAndEdit();
}
await expect(page.getByRole('button', { name: state })).toBeVisible();
const dropdown = await contentPage.openStatusDropdown(currentStatus);
await dropdown.actionConfirm(`Change to ${status}`);
await expect(page.getByRole('button', { name: status })).toBeVisible();
});
});
test('delete content', async ({ dropdown, page }) => {
await createRandomContent(page, 'SaveAndEdit');
test('delete content by details', async ({ page, contentsPage, contentPage }) => {
const contentText = `field-${getRandomId()}`;
await contentsPage.addContent();
await contentPage.enterField(contentText);
await contentPage.saveAndEdit();
await page.getByLabel('Options').click();
const dropdown = await contentPage.openOptionsDropdown();
await dropdown.delete();
await expect(page.getByLabel('Identity')).not.toBeVisible();
});
test('delete content by options', async ({ dropdown, page }) => {
const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
test('delete content by options', async ({ contentsPage, contentPage }) => {
const contentText = await createRandomContent(contentsPage, contentPage);
const contentRow = await contentsPage.getContentRow(contentText);
await contentRow.getByLabel('Options').click();
const dropdown = await contentRow.openOptionsDropdown();
await dropdown.delete();
await expect(contentRow).not.toBeVisible();
await expect(contentRow.root).not.toBeVisible();
});
test('delete content by checkbox', async ({ page }) => {
const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
test('delete content by checkbox', async ({ contentsPage, contentPage }) => {
const contentText = await createRandomContent(contentsPage, contentPage);
const contentRow = await contentsPage.getContentRow(contentText);
await contentRow.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await contentRow.select();
await contentsPage.deleteSelected();
await expect(contentRow).not.toBeVisible();
await expect(contentRow.root).not.toBeVisible();
});
async function createRandomContent(page: Page, mode: SaveMode) {
async function createRandomContent(contentsPage: ContentsPage, contentPage: ContentPage) {
const contentText = `content-${getRandomId()}`;
await page.getByRole('button', { name: /New/ }).click();
await contentsPage.addContent();
// Define content value.
await page.locator('sqx-field-editor').getByRole('textbox').fill(contentText);
await saveContent(page, mode);
// Wait for the success alert.
await page.getByRole('alert').getByText('Content created successfully.').waitFor({ state: 'visible' });
await contentPage.enterField(contentText);
await contentPage.saveAndClose();
return contentText;
}
type SaveMode =
'Save' |
'SaveAndAdd' |
'SaveAndClose' |
'SaveAndEdit' |
'SavePublishAndAdd' |
'SavePublishAndClose' |
'SavePublishAndEdit';
async function saveContent(page: Page, mode: SaveMode) {
switch (mode) {
case 'SaveAndAdd':
await page.getByLabel('Save', { exact: true }).getByLabel('More').click();
await page.getByText('Save & add another').click();
break;
case 'SaveAndClose':
await page.getByLabel('Save', { exact: true }).getByLabel('More').click();
await page.getByText('Save & close').click();
break;
case 'SaveAndEdit':
await page.getByRole('button', { name: 'Save', exact: true }).click();
break;
case 'SavePublishAndAdd':
await page.getByLabel('Save and Publish').getByLabel('More').click();
await page.getByText('Save and Publish & add another').click();
break;
case 'SavePublishAndClose':
await page.getByLabel('Save and Publish').getByLabel('More').click();
await page.getByText('Save and Publish & close').click();
break;
case 'SavePublishAndEdit':
await page.getByRole('button', { name: 'Save and Publish' }).click();
break;
case 'Save':
await page.getByRole('button', { name: 'Save', exact: true }).click();
break;
}
}

6
tools/e2e/tests/start-page.spec.ts → tools/e2e/tests/login-start.spec.ts

@ -5,10 +5,10 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '@playwright/test';
import { expect, test } from './_fixture';
test.beforeEach(async ({ page }) => {
await page.goto('/');
test.beforeEach(async ({ loginPage }) => {
await loginPage.goto();
});
test('has title', async ({ page }) => {

35
tools/e2e/tests/login.spec.ts

@ -5,38 +5,25 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '@playwright/test';
import { expect, test } from './_fixture';
test.beforeEach(async ({ page }) => {
await page.goto('/');
test.beforeEach(async ({ loginPage }) => {
await loginPage.goto();
});
test('login', async ({ page }) => {
// Start waiting for popup before clicking.
const popupPromise = page.waitForEvent('popup');
await page.getByTestId('login').click();
const popup = await popupPromise;
await popup.waitForLoadState();
await popup.getByTestId('login-button').waitFor();
await popup.getByPlaceholder('Enter Email').fill('hello@squidex.io');
await popup.getByPlaceholder('Enter Password').fill('1q2w3e$R');
await popup.getByTestId('login-button').click();
test('login', async ({ page, loginPage }) => {
const popup = await loginPage.openPopup();
await popup.enterEmail('hello@squidex.io');
await popup.enterPassword('1q2w3e$R'),
await popup.login();
await page.waitForURL(/app/);
await expect(page).toHaveTitle(/Apps/);
});
test('visual test', async ({ page }) => {
// Start waiting for popup before clicking.
const popupPromise = page.waitForEvent('popup');
await page.getByTestId('login').click();
test('visual test', async ({ loginPage }) => {
const popup = await loginPage.openPopup();
const popup = await popupPromise;
await expect(popup).toHaveScreenshot({ fullPage: true });
await expect(popup.root).toHaveScreenshot({ fullPage: true });
});

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

@ -0,0 +1,38 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
export class AppsPage {
constructor(private readonly page: Page) {}
public async goto() {
await this.page.goto('/app');
}
public async gotoApp(name: string) {
await this.page.getByRole('heading', { name }).click();
}
public async openAppDialog() {
await this.page.getByTestId('new-app').click();
return new AppDialog(this.page);
}
}
class AppDialog {
constructor(private readonly page: Page) {}
public async enterName(name: string) {
await this.page.locator('#name').fill(name);
}
public async save() {
await this.page.getByRole('button', { name: 'Create' }).click();
}
}

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

@ -0,0 +1,87 @@
/*
* 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 AssetsPage {
constructor(private readonly page: Page) {}
public async goto(appName: string) {
await this.page.goto(`/app/${appName}/assets`);
}
public async uploadFile(file: { name: string; mimeType: string; buffer: Buffer }) {
const fileChooserPromise = this.page.waitForEvent('filechooser');
await this.page.getByText(/Drop files here to upload/).locator('..').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(file);
}
public async getAssetCard(name: string) {
const locator = this.page.locator('sqx-asset', { hasText: escapeRegex(name) });
return new AssetCard(this.page, locator);
}
}
export class AssetCard {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async delete(cancel = false) {
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();
}
}
public async edit() {
await this.root.getByLabel('Edit').click();
return new AssetDialog(this.page, this.page.getByTestId('dialog'));
}
}
export class AssetDialog {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async enterName(name: string) {
await this.root.getByLabel('Name').fill(name);
}
public async enterMetadata(name: string, value: string) {
const rows = this.root.getByPlaceholder('Name');
await rows.first().waitFor({ state: 'attached' });
for (const row of await rows.all()) {
if (await row.inputValue() === name) {
await row.locator('../..').locator('input').nth(1).fill(value);
return;
}
}
throw new Error(`Cannot find row with name '${name}'`);
}
public async save() {
await this.root.getByRole('button', { name: 'Save' }).click();
await this.page.getByText('Asset has been updated.').waitFor({ state: 'visible' });
await this.root.getByLabel('Close').click();
}
}

88
tools/e2e/tests/pages/content.ts

@ -0,0 +1,88 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
import { Dropdown } from './dropdown';
export class ContentPage {
constructor(private readonly page: Page) {}
public async back() {
await this.page.getByLabel('Back').click();
}
public async enterField(value: string) {
await this.page.locator('sqx-field-editor').getByRole('textbox').fill(value);
}
public async saveAndAdd() {
await this.page.getByLabel('Save', { exact: true }).getByLabel('More').click();
const dropdown = new Dropdown(this.page);
await dropdown.action('Save & add another');
await this.waitForCreation();
}
public async saveAndClose() {
await this.page.getByLabel('Save', { exact: true }).getByLabel('More').click();
const dropdown = new Dropdown(this.page);
await dropdown.action('Save & close');
await this.waitForCreation();
}
public async saveAndEdit() {
await this.page.getByRole('button', { name: 'Save', exact: true }).click();
await this.waitForCreation();
}
public async savePublishAndAdd() {
await this.page.getByLabel('Save and Publish').getByLabel('More').click();
const dropdown = new Dropdown(this.page);
await dropdown.action('Save and Publish & add another');
await this.waitForCreation();
}
public async savePublishAndClose() {
await this.page.getByLabel('Save and Publish').getByLabel('More').click();
const dropdown = new Dropdown(this.page);
await dropdown.action('Save and Publish & close');
await this.waitForCreation();
}
public async savePublishAndEdit() {
await this.page.getByRole('button', { name: 'Save and Publish', exact: true }).click();
await this.waitForCreation();
}
public async save() {
await this.page.getByRole('button', { name: 'Save', exact: true }).click();
await this.waitForCreation();
}
public async openStatusDropdown(status: string) {
await this.page.getByRole('button', { name: status }).click();
return new Dropdown(this.page);
}
public async openOptionsDropdown() {
await this.page.getByLabel('Options').click();
return new Dropdown(this.page);
}
private async waitForCreation() {
await this.page.getByRole('alert').getByText('Content created successfully.').waitFor({ state: 'visible' });
}
}

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

@ -0,0 +1,63 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Locator, Page } from '@playwright/test';
import { escapeRegex } from '../utils';
import { Dropdown } from './dropdown';
export class ContentsPage {
constructor(private readonly page: Page) {}
public async goto(appName: string, schemaName: string) {
await this.page.goto(`/app/${appName}/content/${schemaName}`);
}
public async increasePageSize() {
await this.page.getByRole('combobox').selectOption('3: 50');
}
public async addContent() {
await this.page.getByRole('button', { name: /New/ }).click();
}
public async changeSelectedStatus(status: string) {
await this.page.getByRole('button', { name: status }).click();
await this.page.getByRole('button', { name: 'Confirm' }).click();
}
public async deleteSelected() {
await this.page.getByRole('button', { name: 'Delete' }).click();
await this.page.getByRole('button', { name: 'Yes' }).click();
}
public async getContentRow(text: string) {
const locator = this.page.locator('tr', { hasText: escapeRegex(text) });
return new ContentRow(this.page, locator);
}
}
export class ContentRow {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async edit() {
await this.root.click();
}
public async select() {
await this.root.getByRole('checkbox').click();
}
public async openOptionsDropdown() {
await this.root.getByLabel('Options').click();
return new Dropdown(this.page);
}
}

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

@ -0,0 +1,32 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
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 action(name: string) {
await this.page.getByText(name).click();
await this.page.locator('sqx-dropdown-menu').waitFor({ state: 'hidden' });
}
public async actionConfirm(name: string) {
await this.action(name);
await this.page.getByRole('button', { name: 'Confirm' }).click();
}
}

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

@ -0,0 +1,17 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
export * from './apps';
export * from './assets';
export * from './content';
export * from './contents';
export * from './login';
export * from './rule';
export * from './rule';
export * from './rules';
export * from './schema';
export * from './schemas';

47
tools/e2e/tests/pages/login.ts

@ -0,0 +1,47 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private readonly page: Page) {}
public async goto() {
await this.page.goto('/');
}
public async openPopup() {
const popupPromise = this.page.waitForEvent('popup');
await this.page.getByTestId('login').click();
const popup = await popupPromise;
await popup.waitForLoadState();
await popup.getByTestId('login-button').waitFor();
return new LoginPopup(popup);
}
}
export class LoginPopup {
constructor(
public readonly root: Page,
) {
}
public async enterEmail(email: string) {
await this.root.getByPlaceholder('Enter Email').fill(email);
}
public async enterPassword(password: string) {
await this.root.getByPlaceholder('Enter Password').fill(password);
}
public async login() {
await this.root.getByTestId('login-button').click();
}
}

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

@ -0,0 +1,30 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
export class RulePage {
constructor(private readonly page: Page) {}
public async selectContentChangedTrigger() {
await this.page.getByText('Content changed').click();
}
public async selectWebhookAction() {
await this.page.getByText('Webhook').click();
await this.page.locator('sqx-formattable-input').first().getByRole('textbox').fill('https:/squidex.io');
}
public async save() {
await this.page.getByRole('button', { name: 'Save' }).click();
await this.page.getByText('Enabled').waitFor({ state: 'visible' });
}
public async back() {
await this.page.getByLabel('Back').click();
}
}

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

@ -0,0 +1,59 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Locator, Page } from '@playwright/test';
import { escapeRegex } from '../utils';
import { Dropdown } from './dropdown';
export class RulesPage {
constructor(private readonly page: Page) {}
public async goto(appName: string) {
await this.page.goto(`/app/${appName}/rules`);
}
public async addRule() {
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) {
const locator = this.page.locator('div.card', { hasText: escapeRegex(name) });
return new RuleCard(this.page, locator);
}
}
export class RuleCard {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async openOptionsDropdown() {
await this.root.getByLabel('Options').click();
return new Dropdown(this.page);
}
}
export class RenameDialog {
constructor(private readonly page: Page) {}
public async enterName(name: string) {
await this.page.locator('form').getByRole('textbox').fill(name);
}
public async save() {
await this.page.locator('form').getByLabel('Save').click();
}
}

116
tools/e2e/tests/pages/schema.ts

@ -0,0 +1,116 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, Locator, Page } from '@playwright/test';
import { escapeRegex } from '../utils';
import { Dropdown } from './dropdown';
export class SchemaPage {
constructor(private readonly page: Page) {}
public async goto(appName: string, schemaName: string) {
await this.page.goto(`/app/${appName}/schemas/${schemaName}`);
}
public async openFieldWizard() {
await this.page.locator('button').filter({ hasText: /^Add Field$/ }).click();
return new FieldDialog(this.page, this.page.getByTestId('dialog'));
}
public async openNestedFieldWizard() {
await this.page.locator('button').filter({ hasText: /Add Nested Field/ }).click();
return new FieldDialog(this.page, this.page.getByTestId('dialog'));
}
public async openOptionsDropdown() {
await this.page.getByLabel('Options').click();
return new Dropdown(this.page);
}
public async getFieldRow(fieldName: string) {
const locator = this.page.locator('div.table-items-row-summary', { hasText: escapeRegex(fieldName) });
return new FieldRow(this.page, locator);
}
public async publish() {
const button = this.page.getByRole('button', { name: 'Published', exact: true });
await button.click();
await expect(button).toBeDisabled();
}
public async unpublish() {
const button = this.page.getByRole('button', { name: 'Unpublished', exact: true });
await button.click();
await expect(button).toBeDisabled();
}
}
export class FieldRow {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async openOptionsDropdown() {
await this.root.getByLabel('Options').click();
return new Dropdown(this.page);
}
}
export class FieldDialog {
constructor(private readonly page: Page,
public readonly root: Locator,
) {}
public async enterName(name: string) {
await this.root.getByPlaceholder('Enter field name').fill(name);
}
public async enterType(type: string) {
await this.root.getByText(type, { exact: true }).click();
}
public async enterLabel(label: string) {
await this.root.getByLabel('Label').fill(label);
}
public async createAndClose() {
await this.root.getByRole('button', { name: 'Create' }).click();
}
public async createAndAdd() {
await this.root.getByLabel('Add field').getByLabel('More').click();
const dropdown = new Dropdown(this.page);
await dropdown.action('Create & add another');
}
public async createAndEdit() {
await this.root.getByLabel('Add field').getByLabel('More').click();
const dropdown = new Dropdown(this.page);
await dropdown.action('Create & edit properties');
}
public async saveAndClose() {
await this.root.getByRole('button', { name: 'Save and close' }).click();
}
public async saveAndAdd() {
await this.root.getByLabel('Save field').getByLabel('More').click();
const dropdown = new Dropdown(this.page);
await dropdown.action('Save and add field');
}
}

48
tools/e2e/tests/pages/schemas.ts

@ -0,0 +1,48 @@
/*
* 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 SchemasPage {
constructor(private readonly page: Page) {}
public async goto(appName: string) {
await this.page.goto(`/app/${appName}/schemas`);
}
public async getSchemaLink(schemaName: string) {
const locator = this.page.locator('a.nav-link', { hasText: escapeRegex(schemaName) });
return new SchemaLink(this.page, locator);
}
public async openSchemaDialog() {
await this.page.getByLabel('Create Schema').click();
return new CreateDialog(this.page);
}
}
export class SchemaLink {
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
}
export class CreateDialog {
constructor(private readonly page: Page) {}
public async enterName(name: string) {
await this.page.getByLabel('Name (required)').fill(name);
}
public async save() {
await this.page.getByRole('button', { name: 'Create', exact: true }).click();
}
}

4
tools/e2e/tests/utils.ts

@ -9,8 +9,10 @@ import fs from 'fs/promises';
import path from 'path';
import { TEMPORARY_PATH } from '../playwright.config';
let COUNTER = 0;
export function getRandomId() {
const result = new Date().getTime().toString();
const result = `${new Date().getTime()}-${COUNTER++}`;
return result;
}

Loading…
Cancel
Save