diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js index 479553f12e..adf217b4e4 100644 --- a/frontend/.storybook/main.js +++ b/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: { diff --git a/frontend/.storybook/tsconfig.json b/frontend/.storybook/tsconfig.json index b17c798fe4..737aed5245 100644 --- a/frontend/.storybook/tsconfig.json +++ b/frontend/.storybook/tsconfig.json @@ -1,9 +1,6 @@ { "extends": "../tsconfig.app.json", "compilerOptions": { - "types": [ - "node" - ], "allowSyntheticDefaultImports": true }, "exclude": [ diff --git a/frontend/angular.json b/frontend/angular.json index 45ff39f660..78335cae9e 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -127,7 +127,7 @@ "inlineCritical": false }, "fonts": true - }, + } }, "development": { "extractLicenses": false, diff --git a/frontend/package.json b/frontend/package.json index 481c61477b..8481e96ced 100644 --- a/frontend/package.json +++ b/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", diff --git a/frontend/src/app/features/content/pages/content/content-page.component.ts b/frontend/src/app/features/content/pages/content/content-page.component.ts index 0c03b2752e..54632ee5f4 100644 --- a/frontend/src/app/features/content/pages/content/content-page.component.ts +++ b/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 => { diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html b/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html index a83928b9a3..1526d47a4a 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html @@ -9,7 +9,7 @@ @if (editForm) { -
+
} @else { -
+
@@ -88,8 +88,8 @@ @if (!editForm) {
-
-
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts b/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts index c3b7e27680..bc97bcfd4b 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts +++ b/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') }; \ No newline at end of file diff --git a/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html b/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html index cc195a29b1..9eff5a55e1 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html +++ b/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) {
imple } public async ngAfterViewInit() { - this.valueChanged.pipe(debounceTime(500)) + this.valueChanged.pipe(debounceTime(100)) .subscribe(() => { this.changeValue(); }); diff --git a/frontend/src/app/framework/angular/forms/editors/code-editor.stories.ts b/frontend/src/app/framework/angular/forms/editors/code-editor.stories.ts index 7815986faf..aed4dd1b29 100644 --- a/frontend/src/app/framework/angular/forms/editors/code-editor.stories.ts +++ b/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, + }, + }, + args: { + ngModelChange: action('ngModelChange'), }, render: args => ({ props: args, @@ -28,6 +36,8 @@ export default { [disabled]="disabled" [height]="height" [maxLines]="maxLines" + (ngModelChange)="ngModelChange" + [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; 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, }; \ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts b/frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts index dfeaca55d1..42fbf1d435 100644 --- a/frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts +++ b/frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts @@ -36,8 +36,7 @@ export default { }), decorators: [ moduleMetadata({ - imports: [ - ], + imports: [], }), ], } as Meta; diff --git a/frontend/src/app/shared/state/contents.forms-helpers.ts b/frontend/src/app/shared/state/contents.forms-helpers.ts index af10a5ea79..ee0f66ffc6 100644 --- a/frontend/src/app/shared/state/contents.forms-helpers.ts +++ b/frontend/src/app/shared/state/contents.forms-helpers.ts @@ -99,7 +99,7 @@ export abstract class Hidden { } } -export type FieldGroup = { separator?: T; fields: T[] }; +export type FieldGroup = { separator?: T; fields: T[]; id: string }; export function groupFields(fields: ReadonlyArray, keepEmpty = false): FieldGroup[] { const result: FieldGroup[] = []; @@ -107,23 +107,25 @@ export function groupFields(fields: ReadonlyArray, 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; } diff --git a/tools/e2e/tests/given-app/schemas.spec.ts b/tools/e2e/tests/given-app/schemas.spec.ts index 038e8c9b11..34dd03ab74 100644 --- a/tools/e2e/tests/given-app/schemas.spec.ts +++ b/tools/e2e/tests/given-app/schemas.spec.ts @@ -6,7 +6,7 @@ */ import { Page } from '@playwright/test'; -import { escapeRegex, getRandomId } from '../utils'; +import { createField, createNestedField, createSchema, escapeRegex, FieldSaveMode, getRandomId, saveField } from '../utils'; import { expect, test } from './_fixture'; test.beforeEach(async ({ page, appName }) => { @@ -14,6 +14,7 @@ test.beforeEach(async ({ page, appName }) => { }); test('create schema', async ({ page }) => { + // Add schema. const schemaName = await createRandomSchema(page); const schemaLink = page.locator('a.nav-link', { hasText: escapeRegex(schemaName) }); @@ -21,9 +22,11 @@ test('create schema', async ({ page }) => { }); test('delete schema', async ({ dropdown, page }) => { + // Add schema. const schemaName = await createRandomSchema(page); const schemaLink = page.locator('a.nav-link', { hasText: escapeRegex(schemaName) }); + // Delete schema. await page.getByLabel('Options').click(); await dropdown.delete(); @@ -31,58 +34,151 @@ test('delete schema', async ({ dropdown, page }) => { }); test('publish schema', async ({ page }) => { + // Add schema. await createRandomSchema(page); + // Publish schema. await page.getByRole('button', { name: 'Published', exact: true }).click(); await expect(page.getByRole('button', { name: 'Published', exact: true })).toBeDisabled(); }); test('add field', async ({ page }) => { + // Add schema. await createRandomSchema(page); - const fieldName = await createRandomField(page); + const fieldName = await createRandomField(page, 'CreateAndClose'); const fieldRow = page.getByText(fieldName); await expect(fieldRow).toBeVisible(); }); +test('add field and edit', async ({ page }) => { + const fieldLabel = `field-${getRandomId()}`; + + // Add schema. + await createRandomSchema(page); + + // Add field. + await createRandomField(page, 'CreateAndEdit'); + + // Edit field. + await page.getByLabel('Label').fill(fieldLabel); + await saveField(page, 'SaveAndClose'); + + const fieldRow = page.getByText(fieldLabel); + + await expect(fieldRow).toBeVisible(); +}); + +test('add field and add another', async ({ page }) => { + // Add schema. + await createRandomSchema(page); + + // Add field. + const fieldName1 = await createRandomField(page, 'CreateAndAdd'); + const fieldName2 = `field-${getRandomId()}`; + + // Add another field. + await page.getByPlaceholder('Enter field name').fill(fieldName2); + await saveField(page, 'CreateAndClose'); + + const fieldRow1 = page.getByText(fieldName1); + const fieldRow2 = page.getByText(fieldName2); + + await expect(fieldRow1).toBeVisible(); + await expect(fieldRow2).toBeVisible(); +}); + +test('add field to array', async ({ page }) => { + // Add schema. + await createRandomSchema(page); + + // Add array array. + await createRandomField(page, 'CreateAndClose', 'Array'); + + // Add field to array. + const fieldName = await createRandomNestedField(page, 'CreateAndClose'); + const fieldRow = page.getByText(fieldName); + + await expect(fieldRow).toBeVisible(); +}); + +test('add field to array and ed', async ({ page }) => { + const fieldLabel = `field-${getRandomId()}`; + + // Add schema. + await createRandomSchema(page); + + // Add array array. + await createRandomField(page, 'CreateAndClose', 'Array'); + + // Add field to array. + await createRandomNestedField(page, 'CreateAndEdit'); + + // Edit field. + await page.getByLabel('Label').fill(fieldLabel); + await saveField(page, 'SaveAndClose'); + + const fieldRow = page.getByText(fieldLabel); + + await expect(fieldRow).toBeVisible(); +}); + +test('add field to array and another', async ({ page }) => { + // Add schema. + await createRandomSchema(page); + + // Add array array. + await createRandomField(page, 'CreateAndClose', 'Array'); + + // Add field to array. + const fieldName1 = await createRandomNestedField(page, 'CreateAndAdd'); + const fieldName2 = `field-${getRandomId()}`; + + // Add another field. + await page.getByPlaceholder('Enter field name').fill(fieldName2); + await saveField(page, 'CreateAndClose'); + + const fieldRow1 = page.getByText(fieldName1); + const fieldRow2 = page.getByText(fieldName2); + + await expect(fieldRow1).toBeVisible(); + await expect(fieldRow2).toBeVisible(); +}); + test('delete field', async ({ dropdown, page }) => { + // Add schema. await createRandomSchema(page); - const fieldName = await createRandomField(page); + // Add field. + const fieldName = await createRandomField(page, 'CreateAndClose'); const fieldRow = page.locator('div.table-items-row-summary', { hasText: escapeRegex(fieldName) }); + // Delete field. await fieldRow.getByLabel('Options').click(); await dropdown.delete(); await expect(fieldRow).not.toBeVisible(); }); -async function createRandomField(page: Page) { - const fieldName = `field-${getRandomId()}`; +async function createRandomField(page: Page, mode: FieldSaveMode, type = 'String') { + const name = `field-${getRandomId()}`; - await page.locator('button').filter({ hasText: /^Add Field$/ }).click(); - - // Define field name. - await page.getByPlaceholder('Enter field name').fill(fieldName); + await createField(page, { name, mode, type }); + return name; +} - // Save field. - await page.getByTestId('dialog').getByRole('button', { name: 'Create' }).click(); +async function createRandomNestedField(page: Page, mode: FieldSaveMode, type = 'String') { + const name = `field-${getRandomId()}`; - return fieldName; + await createNestedField(page, { name, mode, type }); + return name; } async function createRandomSchema(page: Page) { - const schemaName = `schema-${getRandomId()}`; - - await page.getByLabel('Create Schema').click(); - - // Define schema name. - await page.getByLabel('Name (required)').fill(schemaName); - - // Save schema. - await page.getByRole('button', { name: 'Create', exact: true }).click(); + const name = `schema-${getRandomId()}`; - return schemaName; + await createSchema(page, { name }); + return name; } \ No newline at end of file diff --git a/tools/e2e/tests/given-schema/_setup.ts b/tools/e2e/tests/given-schema/_setup.ts index 69d41ebb3c..5925eb01a9 100644 --- a/tools/e2e/tests/given-schema/_setup.ts +++ b/tools/e2e/tests/given-schema/_setup.ts @@ -6,7 +6,7 @@ */ import { expect, test as setup } from '../given-app/_fixture'; -import { getRandomId, writeJsonAsync } from '../utils'; +import { createField, createSchema, getRandomId, writeJsonAsync } from '../utils'; setup('prepare schema', async ({ page, appName }) => { const schemaName = `my-schema-${getRandomId()}`; @@ -17,22 +17,12 @@ setup('prepare schema', async ({ page, appName }) => { await page.goto(`/app/${appName}/schemas`); - await page.getByLabel('Create Schema').click(); - - // Define schema name. - await page.getByLabel('Name (required)').fill(schemaName); - - // Save schema. - await page.getByRole('button', { name: 'Create', exact: true }).click(); + // Add schema. + await createSchema(page, { name: schemaName }); + // Add fields. 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(); + await createField(page, { name: field.name }); } // Publish schema. diff --git a/tools/e2e/tests/utils.ts b/tools/e2e/tests/utils.ts index 2a9a6a84e4..36a7f2daaf 100644 --- a/tools/e2e/tests/utils.ts +++ b/tools/e2e/tests/utils.ts @@ -7,10 +7,13 @@ import fs from 'fs/promises'; import path from 'path'; +import { Page } from '@playwright/test'; 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; } @@ -45,4 +48,72 @@ async function getPath(name: string) { await fs.mkdir(TEMPORARY_PATH, { recursive: true }); return fullPath; +} + +export type FieldSaveMode = + 'CreateAndAdd' | + 'CreateAndClose' | + 'CreateAndEdit' | + 'SaveAndAdd' | + 'SaveAndClose'; + +export async function createSchema(page: Page, args: { name: string; mode?: FieldSaveMode }) { + const { name } = args; + + await page.getByLabel('Create Schema').click(); + + // Define schema name. + await page.getByLabel('Name (required)').fill(name); + + // Save schema. + await page.getByRole('button', { name: 'Create', exact: true }).click(); +} + +export async function createField(page: Page, args: { name: string; type?: string; mode?: FieldSaveMode }) { + const { name, type, mode } = args; + + await page.locator('button').filter({ hasText: /^Add Field$/ }).click(); + + // Define field type and name. + await page.getByText(type || 'String', { exact: true }).click(); + await page.getByPlaceholder('Enter field name').fill(name); + + // Save schema. + await saveField(page, mode || 'CreateAndClose'); +} + +export async function createNestedField(page: Page, args: { name: string; type?: string; mode?: FieldSaveMode }) { + const { name, type, mode } = args; + + await page.locator('button').filter({ hasText: /Add Nested Field/ }).click(); + + // Define field type and name. + await page.getByText(type || 'String', { exact: true }).click(); + await page.getByPlaceholder('Enter field name').fill(name); + + // Save schema. + await saveField(page, mode || 'CreateAndClose'); +} + +export async function saveField(page: Page, mode: FieldSaveMode) { + switch (mode) { + case 'CreateAndClose': + await page.getByTestId('dialog').getByRole('button', { name: 'Create' }).click(); + break; + case 'CreateAndAdd': + await page.getByTestId('dialog').getByLabel('Add field').getByLabel('More').click(); + await page.getByText('Create & add another').click(); + break; + case 'CreateAndEdit': + await page.getByTestId('dialog').getByLabel('Add field').getByLabel('More').click(); + await page.getByText('Create & edit properties').click(); + break; + case 'SaveAndClose': + await page.getByTestId('dialog').getByRole('button', { name: 'Save and close' }).click(); + break; + case 'SaveAndAdd': + await page.getByTestId('dialog').getByLabel('Save field').getByLabel('More').click(); + await page.getByText('Save and add field').click(); + break; + } } \ No newline at end of file