diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 9fb58e65..d4884f29 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -16,9 +16,10 @@ "ui-test": "yarn build && yarn playwright test" }, "dependencies": { - "@eclipse-glsp/glsp-playwright": "2.2.1", + "@eclipse-glsp/glsp-playwright": " 2.3.0-next.6", "@playwright/test": "^1.37.1", - "@theia/playwright": "1.49.1" + "@theia/playwright": "1.49.1", + "ts-dedent": "^2.2.0" }, "devDependencies": { "allure-playwright": "^2.9.2" diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 6536985e..c4d8df6f 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -1,6 +1,8 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import 'reflect-metadata'; + import { defineConfig } from '@playwright/test'; export default defineConfig({ diff --git a/e2e-tests/src/fixtures/crossmodel-fixture.ts b/e2e-tests/src/fixtures/crossmodel-fixture.ts deleted file mode 100644 index a0022efc..00000000 --- a/e2e-tests/src/fixtures/crossmodel-fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 CrossBreeze. - ********************************************************************************/ -import { Page, test } from '@playwright/test'; -import { CrossModelApp } from '../page-objects/crossmodel-app'; -import { CrossModelWorkspace } from '../page-objects/crossmodel-workspace'; - -export let page: Page; -export let app: CrossModelApp; - -test.beforeAll(async ({ browser, playwright }) => { - const ws = new CrossModelWorkspace(['src/resources/sample-workspace']); - app = await CrossModelApp.load({ browser, playwright }, ws); -}); - -export default test; diff --git a/e2e-tests/src/page-objects/cm-app.ts b/e2e-tests/src/page-objects/cm-app.ts new file mode 100644 index 00000000..0990db92 --- /dev/null +++ b/e2e-tests/src/page-objects/cm-app.ts @@ -0,0 +1,91 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import { IntegrationArgs, TheiaGLSPApp } from '@eclipse-glsp/glsp-playwright'; +import { Page } from '@playwright/test'; +import { TheiaEditor, TheiaNotificationIndicator, TheiaNotificationOverlay, TheiaWorkspace } from '@theia/playwright'; +import { CMCompositeEditor, IntegratedEditorType } from './cm-composite-editor'; +import { CMExplorerView } from './cm-explorer-view'; +import { CMTheiaIntegration } from './cm-theia-integration'; + +export interface CMAppArgs extends Omit { + workspaceUrl?: string; + baseUrl?: string; +} +export class CMApp extends TheiaGLSPApp { + public static async load(args: CMAppArgs): Promise { + const integration = new CMTheiaIntegration( + { browser: args.browser, page: {} as any, playwright: args.playwright }, + { + type: 'Theia', + workspace: args.workspaceUrl ?? 'src/resources/sample-workspace', + widgetId: '', + url: args.baseUrl ?? 'http://localhost:3000' + } + ); + await integration.initialize(); + await integration.start(); + await integration.app.notificationOverlay.waitForEntry('Connected to Model Server on port'); + await integration.app.notificationOverlay.waitForEntry('Connected to Graphical Server on port'); + await integration.app.notificationOverlay.clearAllNotifications(); + + return integration.app; + } + + readonly notificationIndicator: TheiaNotificationIndicator; + readonly notificationOverlay: TheiaNotificationOverlay; + + public constructor(page: Page, workspace: TheiaWorkspace, isElectron: boolean) { + super(page, workspace, isElectron); + this.notificationIndicator = this.notificationIndicator = new TheiaNotificationIndicator(this); + this.notificationOverlay = this.notificationOverlay = new TheiaNotificationOverlay(this, this.notificationIndicator); + } + + protected _integration: CMTheiaIntegration; + + set integration(integration: CMTheiaIntegration) { + if (!this._integration) { + this._integration = integration; + } else { + console.warn('Integration already set'); + } + } + + get integration(): CMTheiaIntegration { + return this._integration; + } + + async openExplorerView(): Promise { + const explorer = await this.openView(CMExplorerView); + await explorer.waitForVisibleFileNodes(); + return explorer; + } + + async openCompositeEditor(filePath: string, editorType: T): Promise { + const editor = await this.openEditor(filePath, CMCompositeEditor); + await editor.waitForVisible(); + let integratedEditor: TheiaEditor | undefined = undefined; + if (editorType === 'Code Editor') { + integratedEditor = await editor.switchToCodeEditor(); + } else if (editorType === 'Form Editor') { + integratedEditor = await editor.switchToFormEditor(); + } else if (editorType === 'System Diagram') { + integratedEditor = await editor.switchToSystemDiagram(); + } else if (editorType === 'Mapping Diagram') { + integratedEditor = await editor.switchToMappingDiagram(); + } + if (integratedEditor === undefined) { + throw new Error(`Unknown editor type: ${editorType}`); + } + return integratedEditor as IntegratedEditorType[T]; + } + + override openEditor( + filePath: string, + editorFactory: new (editorFilePath: string, app: CMApp) => T, + editorName?: string | undefined, + expectFileNodes?: boolean | undefined + ): Promise { + return super.openEditor(filePath, editorFactory as new (f: string, a: TheiaGLSPApp) => T, editorName, expectFileNodes); + } +} diff --git a/e2e-tests/src/page-objects/crossmodel-composite-editor.ts b/e2e-tests/src/page-objects/cm-composite-editor.ts similarity index 62% rename from e2e-tests/src/page-objects/crossmodel-composite-editor.ts rename to e2e-tests/src/page-objects/cm-composite-editor.ts index 2687853c..cb1952f8 100644 --- a/e2e-tests/src/page-objects/crossmodel-composite-editor.ts +++ b/e2e-tests/src/page-objects/cm-composite-editor.ts @@ -3,16 +3,26 @@ ********************************************************************************/ import { ElementHandle, Page } from '@playwright/test'; -import { isElementVisible, normalizeId, OSUtil, TheiaApp, TheiaEditor, TheiaTextEditor, urlEncodePath } from '@theia/playwright'; +import { OSUtil, TheiaEditor, isElementVisible, normalizeId, urlEncodePath } from '@theia/playwright'; import { TheiaMonacoEditor } from '@theia/playwright/lib/theia-monaco-editor'; import { join } from 'path'; +import { CMApp } from './cm-app'; +import { IntegratedEditor, IntegratedTextEditor } from './cm-integrated-editor'; +import { IntegratedFormEditor } from './form/integrated-form-editor'; +import { IntegratedSystemDiagramEditor } from './system-diagram/integrated-system-diagram-editor'; + +export type CompositeEditorName = keyof IntegratedEditorType; +export interface IntegratedEditorType { + 'Code Editor': IntegratedCodeEditor; + 'Form Editor': IntegratedFormEditor; + 'System Diagram': IntegratedSystemDiagramEditor; + 'Mapping Diagram': IntegratedMappingDiagramEditor; +} -export type CompositeEditorName = 'Code Editor' | 'Form Editor' | 'System Diagram' | 'Mapping Diagram'; - -export class CrossModelCompositeEditor extends TheiaEditor { +export class CMCompositeEditor extends TheiaEditor { constructor( protected filePath: string, - app: TheiaApp + public override app: CMApp ) { // shell-tab-code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 // code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 @@ -52,92 +62,60 @@ export class CrossModelCompositeEditor extends TheiaEditor { async switchToCodeEditor(): Promise { await this.switchToEditor('Code Editor'); - const textEditor = new IntegratedCodeEditor(this.filePath, this.app, this.editorTabSelector('Code Editor')); - await textEditor.waitForVisible(); + const textEditor = new IntegratedCodeEditor(this.filePath, this, this.editorTabSelector('Code Editor')); + await textEditor.activate(); return textEditor; } async switchToFormEditor(): Promise { await this.switchToEditor('Form Editor'); - const formEditor = new IntegratedFormEditor(this.filePath, this.app, this.editorTabSelector('Form Editor')); - await formEditor.waitForVisible(); + const formEditor = new IntegratedFormEditor(this.filePath, this, this.editorTabSelector('Form Editor')); + await formEditor.activate(); + return formEditor; } async switchToSystemDiagram(): Promise { await this.switchToEditor('System Diagram'); - const diagramEditor = new IntegratedSystemDiagramEditor(this.filePath, this.app, this.editorTabSelector('System Diagram')); + const diagramEditor = new IntegratedSystemDiagramEditor(this.filePath, this, this.editorTabSelector('System Diagram')); await diagramEditor.waitForVisible(); + await diagramEditor.activate(); + return diagramEditor; } async switchToMappingDiagram(): Promise { await this.switchToEditor('Mapping Diagram'); - const diagramEditor = new IntegratedMappingDiagramEditor(this.filePath, this.app, this.editorTabSelector('Mapping Diagram')); + const diagramEditor = new IntegratedMappingDiagramEditor(this.filePath, this, this.editorTabSelector('Mapping Diagram')); await diagramEditor.waitForVisible(); + await diagramEditor.activate(); return diagramEditor; } } -export class IntegratedCodeEditor extends TheiaTextEditor { - constructor(filePath: string, app: TheiaApp, tabSelector: string) { +export class IntegratedCodeEditor extends IntegratedTextEditor { + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { // shell-tab-code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 // code-editor-opener:file:///c%3A/Users/user/AppData/Local/Temp/cloud-ws-JBUhb6/sample.txt:1 - super(filePath, app); + super(filePath, parent); this.data.viewSelector = normalizeId( - `#code-editor-opener:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + `#code-editor-opener:file://${urlEncodePath(join(this.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` ); this.data.tabSelector = tabSelector; - this.monacoEditor = new TheiaMonacoEditor(this.viewSelector, app); + this.monacoEditor = new TheiaMonacoEditor(this.viewSelector, parent.app); } } -export class IntegratedFormEditor extends TheiaEditor { - constructor(filePath: string, app: TheiaApp, tabSelector: string) { +export class IntegratedMappingDiagramEditor extends IntegratedEditor { + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { super( { tabSelector, viewSelector: normalizeId( - `#form-editor-opener:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + `#mapping-diagram:file://${urlEncodePath(join(parent.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` ) }, - app - ); - } - - async hasError(errorMessage: string): Promise { - return hasViewError(this.page, this.viewSelector, errorMessage); - } -} - -export class IntegratedSystemDiagramEditor extends TheiaEditor { - constructor(filePath: string, app: TheiaApp, tabSelector: string) { - super( - { - tabSelector, - viewSelector: normalizeId( - `#system-diagram:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` - ) - }, - app - ); - } - - async hasError(errorMessage: string): Promise { - return hasViewError(this.page, this.viewSelector, errorMessage); - } -} - -export class IntegratedMappingDiagramEditor extends TheiaEditor { - constructor(filePath: string, app: TheiaApp, tabSelector: string) { - super( - { - tabSelector, - viewSelector: normalizeId( - `#mapping-diagram:file://${urlEncodePath(join(app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` - ) - }, - app + parent ); } diff --git a/e2e-tests/src/page-objects/cm-explorer-view.ts b/e2e-tests/src/page-objects/cm-explorer-view.ts new file mode 100644 index 00000000..64523548 --- /dev/null +++ b/e2e-tests/src/page-objects/cm-explorer-view.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import { TheiaApp, TheiaExplorerFileStatNode, TheiaExplorerView } from '@theia/playwright'; +import { CMMTabBarToolbar } from './cm-tab-bar-toolbar'; + +export class CMExplorerView extends TheiaExplorerView { + public readonly tabBarToolbar: CMMTabBarToolbar; + + constructor(app: TheiaApp) { + super(app); + this.tabBarToolbar = new CMMTabBarToolbar(this); + } + + /** + * The `existsFileNode` method implementation of the `TheiaExplorerView` PO don't + * behave as expected. If a node does not exist they will throw an errors instead of + * returning `false`. + * This method is a workaround and allows us to quickly check if a file node is visible + */ + async findTreeNode(path: string): Promise { + const fullPathSelector = this.treeNodeSelector(path); + const treeNodeElement = await this.page.$(fullPathSelector); + if (treeNodeElement) { + return new TheiaExplorerFileStatNode(treeNodeElement, this); + } + return undefined; + } + + /** + * Override the `deleteNode` method to wait for the file nodes to decrease after a node is deleted + */ + override async deleteNode(path: string, confirm?: boolean | undefined, nodeSegmentLabel?: string | undefined): Promise { + const fileStatElements = await this.visibleFileStatNodes(); + await super.deleteNode(path, confirm, nodeSegmentLabel); + await this.waitForFileNodesToDecrease(fileStatElements.length); + } +} diff --git a/e2e-tests/src/page-objects/cm-integrated-editor.ts b/e2e-tests/src/page-objects/cm-integrated-editor.ts new file mode 100644 index 00000000..3eff6ea6 --- /dev/null +++ b/e2e-tests/src/page-objects/cm-integrated-editor.ts @@ -0,0 +1,140 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { waitForFunction } from '@eclipse-glsp/glsp-playwright'; +import { TheiaEditor, TheiaTextEditor, TheiaViewData } from '@theia/playwright'; +import { CMApp } from './cm-app'; +import { CMCompositeEditor } from './cm-composite-editor'; + +export abstract class IntegratedEditor extends TheiaEditor { + override app: CMApp; + constructor( + data: TheiaViewData, + readonly parent: CMCompositeEditor + ) { + super(data, parent.app); + this.app = parent.app; + } + + override async activate(): Promise { + await this.parent.activate(); + return super.activate(); + } + + override close(waitForClosed?: boolean | undefined): Promise { + return this.parent.close(waitForClosed); + } + + override closeWithoutSave(): Promise { + return this.parent.closeWithoutSave(); + } + + override async focus(): Promise { + await this.parent.focus(); + return super.focus(); + } + + override async save(): Promise { + await this.parent.save(); + } + + override async saveAndClose(): Promise { + await this.parent.saveAndClose(); + } + + override async undo(times?: number | undefined): Promise { + await this.parent.undo(times); + } + + override async redo(times?: number | undefined): Promise { + await this.parent.redo(times); + } + + override async isDirty(): Promise { + return this.parent.isDirty(); + } + + override async waitForVisible(): Promise { + await this.parent.waitForVisible(); + return super.waitForVisible(); + } + + override isClosable(): Promise { + return this.parent.isClosable(); + } + + override title(): Promise { + return this.parent.title(); + } + + async waitForDirty(): Promise { + await waitForFunction(async () => this.isDirty()); + } +} + +export abstract class IntegratedTextEditor extends TheiaTextEditor { + override app: CMApp; + constructor( + filePath: string, + readonly parent: CMCompositeEditor + ) { + super(filePath, parent.app); + this.app = parent.app; + } + + override async activate(): Promise { + await this.parent.activate(); + return super.activate(); + } + + override close(waitForClosed?: boolean | undefined): Promise { + return this.parent.close(waitForClosed); + } + + override closeWithoutSave(): Promise { + return this.parent.closeWithoutSave(); + } + + override async focus(): Promise { + await this.parent.focus(); + return super.focus(); + } + + override async save(): Promise { + await this.parent.save(); + } + + override async saveAndClose(): Promise { + await this.parent.saveAndClose(); + } + + override async undo(times?: number | undefined): Promise { + await this.parent.undo(times); + } + + override async redo(times?: number | undefined): Promise { + await this.parent.redo(times); + } + + override async isDirty(): Promise { + return this.parent.isDirty(); + } + + override async waitForVisible(): Promise { + await this.parent.waitForVisible(); + return super.waitForVisible(); + } + + override isClosable(): Promise { + return this.parent.isClosable(); + } + + override title(): Promise { + return this.parent.title(); + } + + async waitForDirty(): Promise { + await waitForFunction(async () => this.isDirty()); + } +} diff --git a/e2e-tests/src/page-objects/cm-properties-view.ts b/e2e-tests/src/page-objects/cm-properties-view.ts new file mode 100644 index 00000000..92fa2f6d --- /dev/null +++ b/e2e-tests/src/page-objects/cm-properties-view.ts @@ -0,0 +1,53 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { ElementHandle } from '@playwright/test'; +import { TheiaApp, TheiaEditor, isElementVisible } from '@theia/playwright'; +import { CMForm } from './form/cm-form'; +import { EntityForm } from './form/entiy-form'; +import { RelationshipForm } from './form/relationship-form'; + +const CMPropertiesViewData = { + tabSelector: '#shell-tab-property-view', + viewSelector: '#property-view', + viewName: 'Properties' +}; + +export abstract class CMPropertiesView extends TheiaEditor { + protected modelRootSelector = '#model-property-view'; + + abstract form(): Promise; + + constructor(app: TheiaApp) { + super(CMPropertiesViewData, app); + } + + protected async modelPropertyElement(): Promise | null> { + return this.page.$(this.viewSelector + ' ' + this.modelRootSelector); + } + + isModelPropertyElement(): Promise { + return isElementVisible(this.modelPropertyElement()); + } + + override async isDirty(): Promise { + const form = await this.form(); + return form.isDirty(); + } +} + +export class EntityPropertiesView extends CMPropertiesView { + async form(): Promise { + const entityForm = new EntityForm(this, this.modelRootSelector); + await entityForm.waitForVisible(); + return entityForm; + } +} + +export class RelationshipPropertiesView extends CMPropertiesView { + async form(): Promise { + const relationshipForm = new RelationshipForm(this, this.modelRootSelector); + await relationshipForm.waitForVisible(); + return relationshipForm; + } +} diff --git a/e2e-tests/src/page-objects/cm-tab-bar-toolbar.ts b/e2e-tests/src/page-objects/cm-tab-bar-toolbar.ts new file mode 100644 index 00000000..e0b7409e --- /dev/null +++ b/e2e-tests/src/page-objects/cm-tab-bar-toolbar.ts @@ -0,0 +1,7 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { TheiaTabBarToolbar } from './theia-tabbar-toolbar'; + +export class CMMTabBarToolbar extends TheiaTabBarToolbar {} diff --git a/e2e-tests/src/page-objects/cm-theia-integration.ts b/e2e-tests/src/page-objects/cm-theia-integration.ts new file mode 100644 index 00000000..417487c2 --- /dev/null +++ b/e2e-tests/src/page-objects/cm-theia-integration.ts @@ -0,0 +1,39 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { ContextMenuIntegration, Integration, IntegrationArgs, TheiaIntegrationOptions } from '@eclipse-glsp/glsp-playwright'; +import { Locator, Page } from '@playwright/test'; +import { TheiaAppFactory, TheiaAppLoader } from '@theia/playwright'; +import { CMApp } from './cm-app'; +import { CMWorkspace } from './cm-workspace'; + +export class CMTheiaIntegration extends Integration implements ContextMenuIntegration { + protected theiaApp: CMApp; + + override get page(): Page { + return this.theiaApp.page; + } + + get app(): CMApp { + return this.theiaApp; + } + + get contextMenuLocator(): Locator { + return this.page.locator('body > .p-Widget.p-Menu'); + } + + constructor( + args: IntegrationArgs, + protected readonly options: TheiaIntegrationOptions + ) { + super(args, 'Theia'); + } + + protected override async launch(): Promise { + const ws = new CMWorkspace(this.options.workspace ? [this.options.workspace] : undefined); + this.theiaApp = await TheiaAppLoader.load(this.args, ws, CMApp as TheiaAppFactory); + this.theiaApp.integration = this; + this.theiaApp.initialize(this.options); + } +} diff --git a/e2e-tests/src/page-objects/crossmodel-workspace.ts b/e2e-tests/src/page-objects/cm-workspace.ts similarity index 81% rename from e2e-tests/src/page-objects/crossmodel-workspace.ts rename to e2e-tests/src/page-objects/cm-workspace.ts index faaa1155..9e3bb7e8 100644 --- a/e2e-tests/src/page-objects/crossmodel-workspace.ts +++ b/e2e-tests/src/page-objects/cm-workspace.ts @@ -3,4 +3,4 @@ ********************************************************************************/ import { TheiaWorkspace } from '@theia/playwright'; -export class CrossModelWorkspace extends TheiaWorkspace {} +export class CMWorkspace extends TheiaWorkspace {} diff --git a/e2e-tests/src/page-objects/crossmodel-app.ts b/e2e-tests/src/page-objects/crossmodel-app.ts deleted file mode 100644 index 11d903c8..00000000 --- a/e2e-tests/src/page-objects/crossmodel-app.ts +++ /dev/null @@ -1,15 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 CrossBreeze. - ********************************************************************************/ -import { PlaywrightWorkerArgs } from '@playwright/test'; -import { TheiaApp, TheiaAppFactory, TheiaAppLoader, TheiaPlaywrightTestConfig } from '@theia/playwright'; -import { CrossModelWorkspace } from './crossmodel-workspace'; - -export class CrossModelApp extends TheiaApp { - public static async load( - args: TheiaPlaywrightTestConfig & PlaywrightWorkerArgs, - workspace: CrossModelWorkspace - ): Promise { - return TheiaAppLoader.load(args, workspace, CrossModelApp as TheiaAppFactory); - } -} diff --git a/e2e-tests/src/page-objects/crossmodel-explorer-view.ts b/e2e-tests/src/page-objects/crossmodel-explorer-view.ts deleted file mode 100644 index 21a5832f..00000000 --- a/e2e-tests/src/page-objects/crossmodel-explorer-view.ts +++ /dev/null @@ -1,14 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 CrossBreeze. - ********************************************************************************/ -import { TheiaApp, TheiaExplorerView } from '@theia/playwright'; -import { TheiaTabBarToolbar } from './theia-tabbar-toolbar'; - -export class CrossModelExplorerView extends TheiaExplorerView { - public readonly tabBarToolbar: TheiaTabBarToolbar; - - constructor(app: TheiaApp) { - super(app); - this.tabBarToolbar = new TheiaTabBarToolbar(this); - } -} diff --git a/e2e-tests/src/page-objects/form/cm-form.ts b/e2e-tests/src/page-objects/form/cm-form.ts new file mode 100644 index 00000000..c128010f --- /dev/null +++ b/e2e-tests/src/page-objects/form/cm-form.ts @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { waitForFunction } from '@eclipse-glsp/glsp-playwright'; +import { ElementHandle, Locator } from '@playwright/test'; +import { TheiaPageObject, TheiaView } from '@theia/playwright'; +import { TheiaViewObject } from '../theia-view-object'; + +export const FormIcons = { + Entity: 'codicon-git-commit', + Relationship: 'codicon-git-compare', + SystemDiagram: 'codicon-type-hierarchy-sub', + Mapping: 'codicon-group-by-ref-type' +}; + +export type FormType = keyof typeof FormIcons; + +export abstract class CMForm extends TheiaViewObject { + protected abstract iconClass: string; + protected typeSelector: string; + + readonly locator: Locator; + constructor(view: TheiaView, relativeSelector: string, type: FormType) { + super(view, relativeSelector); + this.typeSelector = `${this.selector} span.${FormIcons[type]}`; + this.locator = view.page.locator(this.selector); + } + + protected typeElementHandle(): Promise | null> { + return this.page.$(this.typeSelector); + } + + override async waitForVisible(): Promise { + await this.page.waitForSelector(this.typeSelector, { state: 'visible' }); + } + + override async isVisible(): Promise { + const viewObject = await this.typeElementHandle(); + return !!viewObject && viewObject.isVisible(); + } + + async isDirty(): Promise { + const title = await this.page.$(this.selector + ' .form-title:not(.p-mod-hidden)'); + const text = await title?.textContent(); + return text?.endsWith('*') ?? false; + } + + async waitForDirty(): Promise { + await waitForFunction(async () => this.isDirty()); + } +} + +export abstract class FormSection extends TheiaPageObject { + readonly locator: Locator; + + constructor( + readonly form: CMForm, + sectionName: string + ) { + super(form.app); + this.locator = form.locator.locator(`div.MuiAccordion-root:has(h6:has-text("${sectionName}"))`); + } +} diff --git a/e2e-tests/src/page-objects/form/entiy-form.ts b/e2e-tests/src/page-objects/form/entiy-form.ts new file mode 100644 index 00000000..398ddf0c --- /dev/null +++ b/e2e-tests/src/page-objects/form/entiy-form.ts @@ -0,0 +1,190 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { defined } from '@eclipse-glsp/glsp-playwright'; +import { Locator } from '@playwright/test'; +import { TheiaPageObject } from '@theia/playwright'; +import { TheiaView } from '@theia/playwright/lib/theia-view'; +import { CMForm, FormIcons, FormSection } from './cm-form'; + +export class EntityForm extends CMForm { + protected override iconClass = FormIcons.Entity; + + readonly generalSection: EntityGeneralSection; + readonly attributesSection: EntityAttributesSection; + + constructor(view: TheiaView, relativeSelector: string) { + super(view, relativeSelector, 'Entity'); + this.generalSection = new EntityGeneralSection(this); + this.attributesSection = new EntityAttributesSection(this); + } +} + +export class EntityGeneralSection extends FormSection { + constructor(form: EntityForm) { + super(form, 'General'); + } + + async getName(): Promise { + return this.locator.getByLabel('Name').inputValue(); + } + + async setName(name: string): Promise { + await this.locator.getByLabel('Name').fill(name); + return this.page.waitForTimeout(250); + } + + async getDescription(): Promise { + return this.locator.getByLabel('Description').inputValue(); + } + + async setDescription(description: string): Promise { + await this.locator.getByLabel('Description').fill(description); + return this.page.waitForTimeout(250); + } +} + +export class EntityAttributesSection extends FormSection { + readonly addButtonLocator: Locator; + constructor(form: EntityForm) { + super(form, 'Attributes'); + this.addButtonLocator = this.locator.locator('button:has-text("Add Attribute")'); + } + + async addAttribute(): Promise { + await this.addButtonLocator.click(); + await this.page.keyboard.press('Enter'); + const lastAttribute = this.locator.locator('div[data-rowindex]').last(); + await this.page.waitForTimeout(150); + return new EntityAttribute(lastAttribute, this); + } + + async getAllAttributes(): Promise { + const attributeLocators = await this.locator.locator('div[data-rowindex]').all(); + return attributeLocators.map(locator => new EntityAttribute(locator, this)); + } + + async getAttribute(name: string): Promise { + return defined(await this.findAttribute(name)); + } + + async findAttribute(name: string): Promise { + const attributeLocators = await this.locator.locator('div[data-rowindex]').all(); + for (const locator of attributeLocators) { + const attribute = new EntityAttribute(locator, this); + if ((await attribute.getName()) === name) { + return attribute; + } + } + return undefined; + } + + async deleteAttribute(name: string): Promise { + const attribute = await this.findAttribute(name); + if (attribute) { + await attribute.delete(); + } + } +} + +export interface EntityAttributeProperties { + name: string; + datatype: string; + identifier: boolean; + description: string; +} + +export const EntityDatatype = { + Integer: 'Integer', + Float: 'Float', + Char: 'Char', + Varchar: 'Varchar', + Bool: 'Bool', + Text: 'Text' +} as const; + +export type EntityDatatype = keyof typeof EntityDatatype; + +export class EntityAttribute extends TheiaPageObject { + constructor( + readonly locator: Locator, + section: EntityAttributesSection + ) { + super(section.app); + } + + protected get nameLocator(): Locator { + return this.locator.locator('[data-field="name"]'); + } + + protected get dataType(): Locator { + return this.locator.locator('[data-field="datatype"]'); + } + + protected get identifierLocator(): Locator { + return this.locator.locator('[data-field="identifier"]'); + } + + protected get descriptionLocator(): Locator { + return this.locator.locator('[data-field="description"]'); + } + + protected get actionsLocator(): Locator { + return this.locator.locator('div[data-field="actions"]'); + } + + async getProperties(): Promise { + return { + name: await this.getName(), + datatype: await this.getDatatype(), + identifier: await this.isIdentifier(), + description: await this.getDescription() + }; + } + + async getName(): Promise { + return (await this.nameLocator.textContent()) ?? ''; + } + + async setName(name: string): Promise { + await this.nameLocator.press('Enter'); + await this.nameLocator.locator('input').fill(name); + await this.nameLocator.press('Enter'); + } + + async getDatatype(): Promise { + return defined(await this.dataType.textContent()) as EntityDatatype; + } + + async setDatatype(datatype: EntityDatatype): Promise { + await this.dataType.press('Enter'); + await this.dataType.getByRole('combobox').click(); + const selectionOverlay = this.page.locator('div[role="presentation"][id="menu-"]'); + await selectionOverlay.locator(`li[data-value="${datatype}"]`).press('Enter'); + } + + async isIdentifier(): Promise { + return (await this.identifierLocator.locator('svg[data-testid="CheckBoxOutlinedIcon"]').count()) === 1; + } + + async toggleIdentifier(): Promise { + await this.identifierLocator.click({ clickCount: 2 }); + await this.identifierLocator.click(); + } + + async getDescription(): Promise { + return (await this.descriptionLocator.textContent()) ?? ''; + } + + async setDescription(description: string): Promise { + await this.descriptionLocator.click({ clickCount: 2 }); + await this.descriptionLocator.locator('input').fill(description); + await this.descriptionLocator.press('Enter'); + } + + async delete(): Promise { + const deleteButton = this.actionsLocator.locator('button[aria-label="Delete"]'); + await deleteButton.click(); + } +} diff --git a/e2e-tests/src/page-objects/form/integrated-form-editor.ts b/e2e-tests/src/page-objects/form/integrated-form-editor.ts new file mode 100644 index 00000000..7d701e5b --- /dev/null +++ b/e2e-tests/src/page-objects/form/integrated-form-editor.ts @@ -0,0 +1,42 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { OSUtil, normalizeId, urlEncodePath } from '@theia/playwright'; +import { join } from 'path'; +import { CMCompositeEditor, hasViewError } from '../cm-composite-editor'; +import { IntegratedEditor } from '../cm-integrated-editor'; +import { CMForm } from './cm-form'; +import { EntityForm } from './entiy-form'; +import { RelationshipForm } from './relationship-form'; +export class IntegratedFormEditor extends IntegratedEditor { + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { + super( + { + tabSelector, + viewSelector: normalizeId( + `#form-editor-opener:file://${urlEncodePath(join(parent.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + ) + }, + parent + ); + } + + async hasError(errorMessage: string): Promise { + return hasViewError(this.page, this.viewSelector, errorMessage); + } + + async formFor(entity: 'entity'): Promise; + async formFor(relationship: 'relationship'): Promise; + async formFor(string: 'entity' | 'relationship'): Promise { + if (string === 'entity') { + const form = new EntityForm(this, ''); + await form.waitForVisible(); + return form; + } else { + const form = new RelationshipForm(this, ''); + await form.waitForVisible(); + return form; + } + } +} diff --git a/e2e-tests/src/page-objects/form/relationship-form.ts b/e2e-tests/src/page-objects/form/relationship-form.ts new file mode 100644 index 00000000..f7307263 --- /dev/null +++ b/e2e-tests/src/page-objects/form/relationship-form.ts @@ -0,0 +1,41 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { TheiaView } from '@theia/playwright'; +import { CMForm, FormIcons, FormSection } from './cm-form'; + +export class RelationshipForm extends CMForm { + protected override iconClass = FormIcons.Relationship; + + readonly generalSection: RelationshipGeneralSection; + + constructor(view: TheiaView, relativeSelector: string) { + super(view, relativeSelector, 'Relationship'); + this.generalSection = new RelationshipGeneralSection(this); + } +} + +export class RelationshipGeneralSection extends FormSection { + constructor(form: RelationshipForm) { + super(form, 'General'); + } + + async getName(): Promise { + return this.locator.getByLabel('Name').inputValue(); + } + + async setName(name: string): Promise { + await this.locator.getByLabel('Name').fill(name); + return this.page.waitForTimeout(250); + } + + async getDescription(): Promise { + return this.locator.getByLabel('Description').inputValue(); + } + + async setDescription(description: string): Promise { + await this.locator.getByLabel('Description').fill(description); + return this.page.waitForTimeout(250); + } +} diff --git a/e2e-tests/src/page-objects/system-diagram/diagram-elements.ts b/e2e-tests/src/page-objects/system-diagram/diagram-elements.ts new file mode 100644 index 00000000..5b794f06 --- /dev/null +++ b/e2e-tests/src/page-objects/system-diagram/diagram-elements.ts @@ -0,0 +1,90 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { + ChildrenAccessor, + EdgeMetadata, + Mix, + ModelElementMetadata, + NodeMetadata, + PEdge, + PLabel, + PModelElement, + PNode, + SVGMetadataUtils, + defined, + useClickableFlow, + useCommandPaletteCapability, + useDeletableFlow, + useDraggableFlow, + useHoverableFlow, + usePopupCapability, + useRenameableFlow, + useResizeHandleCapability, + useRoutingPointCapability, + useSelectableFlow +} from '@eclipse-glsp/glsp-playwright/'; + +const LabelHeaderMixin = Mix(PLabel).flow(useClickableFlow).flow(useRenameableFlow).build(); + +@ModelElementMetadata({ + type: 'label:entity' +}) +export class LabelEntity extends LabelHeaderMixin {} + +const EntityMixin = Mix(PNode) + .flow(useClickableFlow) + .flow(useHoverableFlow) + .flow(useDeletableFlow) + .flow(useDraggableFlow) + .flow(useRenameableFlow) + .flow(useSelectableFlow) + .capability(useResizeHandleCapability) + .capability(usePopupCapability) + .capability(useCommandPaletteCapability) + .build(); + +@NodeMetadata({ + type: 'node:entity' +}) +export class Entity extends EntityMixin { + override readonly children = new EntityChildren(this); + + get label(): Promise { + return this.children.label().then(label => label.textContent()); + } +} + +export class EntityChildren extends ChildrenAccessor { + async label(): Promise { + return this.ofType(LabelEntity, { selector: SVGMetadataUtils.typeAttrOf(LabelEntity) }); + } + + async attributes(): Promise { + return this.allOfType(Attribute); + } +} + +@ModelElementMetadata({ + type: 'comp:attribute' +}) +export class Attribute extends PModelElement { + async name(): Promise { + return defined(await this.locate().locator('.attribute').textContent()); + } + + async datatype(): Promise { + return defined(await this.locate().locator('.datatype').textContent()); + } +} + +const RelationshipMixin = Mix(PEdge) + .flow(useClickableFlow) + .flow(useSelectableFlow) + .flow(useDeletableFlow) + .capability(useRoutingPointCapability) + .build(); +@EdgeMetadata({ + type: 'edge:relationship' +}) +export class Relationship extends RelationshipMixin {} diff --git a/e2e-tests/src/page-objects/system-diagram/integrated-system-diagram-editor.ts b/e2e-tests/src/page-objects/system-diagram/integrated-system-diagram-editor.ts new file mode 100644 index 00000000..cc8dc3a0 --- /dev/null +++ b/e2e-tests/src/page-objects/system-diagram/integrated-system-diagram-editor.ts @@ -0,0 +1,123 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { GLSPBaseCommandPalette, InteractablePosition, PModelElement, PModelElementConstructor } from '@eclipse-glsp/glsp-playwright'; +import { OSUtil, normalizeId, urlEncodePath } from '@theia/playwright'; +import { join } from 'path'; +import { CMCompositeEditor, hasViewError } from '../cm-composite-editor'; +import { IntegratedEditor } from '../cm-integrated-editor'; +import { EntityPropertiesView } from '../cm-properties-view'; +import { CMTheiaIntegration } from '../cm-theia-integration'; +import { Entity } from './diagram-elements'; +import { SystemDiagram, WaitForModelUpdateOptions } from './system-diagram'; +import { SystemTools } from './system-tool-box'; + +export class IntegratedSystemDiagramEditor extends IntegratedEditor { + readonly diagram: SystemDiagram; + constructor(filePath: string, parent: CMCompositeEditor, tabSelector: string) { + super( + { + tabSelector, + viewSelector: normalizeId( + `#system-diagram:file://${urlEncodePath(join(parent.app.workspace.escapedPath, OSUtil.fileSeparator, filePath))}` + ) + }, + parent + ); + this.diagram = this.createSystemDiagram(parent.app.integration); + } + + get globalCommandPalette(): GLSPBaseCommandPalette { + return this.diagram.globalCommandPalette; + } + + override waitForVisible(): Promise { + return this.diagram.graph.waitForVisible(); + } + + protected createSystemDiagram(integration: CMTheiaIntegration): SystemDiagram { + return new SystemDiagram({ type: 'integration', integration }); + } + + async hasError(errorMessage: string): Promise { + return hasViewError(this.page, this.viewSelector, errorMessage); + } + + async enableTool(tool: SystemTools['default']): Promise { + const paletteItem = await this.diagram.toolPalette.content.toolElement('default', tool); + return paletteItem.click(); + } + + async getEntity(entityLabel: string): Promise { + return this.diagram.graph.getNodeByLabel(entityLabel, Entity); + } + + async getEntities(entityLabel: string): Promise { + return this.diagram.graph.getNodesByLabel(entityLabel, Entity); + } + + async findEntity(entityLabel: string): Promise { + const entities = await this.diagram.graph.getNodesByLabel(entityLabel, Entity); + return entities.length > 0 ? entities[0] : undefined; + } + + async selectEntityAndOpenProperties(entityLabel: string): Promise { + const entity = await this.diagram.graph.getNodeByLabel(entityLabel, Entity); + await entity.select(); + const view = new EntityPropertiesView(this.app); + if (!(await view.isTabVisible())) { + await this.page.keyboard.press('Alt+Shift+P'); + } + await view.activate(); + return view; + } + + /** + * Invoke the 'Show Entity` tool at the given position. + * i.e. select the tool and click at the given position. + */ + async invokeShowEntityToolAtPosition(position: InteractablePosition): Promise { + await this.enableTool('Show Entity'); + // Wait for the insert-indicator to appear + await this.page.waitForSelector('.insert-indicator', { state: 'attached' }); + await position.move(); + // Wait for the insert-indicator to be moved to the correct position + await this.page.waitForFunction( + ({ expectedPosition, tolerance }) => { + const insertIndicator = document.querySelector('.insert-indicator'); + const boundingBox = insertIndicator?.getBoundingClientRect(); + if (!boundingBox) { + return false; + } + const { x, y } = boundingBox; + return Math.abs(x - expectedPosition.x) <= tolerance && Math.abs(y - expectedPosition.y) <= tolerance; + }, + { expectedPosition: position.data, tolerance: 20 } + ); + await position.click(); + } + + waitForModelUpdate(executor: () => Promise, options?: WaitForModelUpdateOptions): Promise { + return this.diagram.graph.waitForModelUpdate(executor, options); + } + + waitForCreationOfType( + constructor: PModelElementConstructor, + creator: () => Promise + ): Promise { + return this.diagram.graph.waitForCreationOfType(constructor, creator); + } + + override isDirty(): Promise { + return this.parent.isDirty(); + } + + override isClosable(): Promise { + return this.parent.isClosable(); + } + + override closeWithoutSave(): Promise { + return this.parent.closeWithoutSave(); + } +} diff --git a/e2e-tests/src/page-objects/system-diagram/system-diagram.ts b/e2e-tests/src/page-objects/system-diagram/system-diagram.ts new file mode 100644 index 00000000..62402db4 --- /dev/null +++ b/e2e-tests/src/page-objects/system-diagram/system-diagram.ts @@ -0,0 +1,204 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { + asLocator, + definedAttr, + EdgeSearchOptions, + GLSPAppOptions, + GLSPIntegrationOptions, + GLSPSemanticApp, + GLSPSemanticGraph, + GraphConstructorOptions, + isEqualLocatorType, + isPEdgeConstructor, + isPNodeConstructor, + PEdge, + PEdgeConstructor, + PMetadata, + PModelElement, + PModelElementConstructor, + PNode, + PNodeConstructor, + SVGMetadata, + SVGMetadataUtils, + TypedEdge, + waitForFunction +} from '@eclipse-glsp/glsp-playwright'; +import { Locator } from '@playwright/test'; +import { SystemToolBox } from './system-tool-box'; + +export class SystemDiagram extends GLSPSemanticApp { + override readonly toolPalette: SystemToolBox; + override readonly graph: SystemDiagramGraph; + + constructor(options: GLSPIntegrationOptions) { + super(options); + this.toolPalette = this.createToolPalette(); + this.graph = this.createGraph(options); + } + + protected override createGraph(_options: GLSPAppOptions): SystemDiagramGraph { + return new SystemDiagramGraph({ locator: SystemDiagramGraph.locate(this) }); + } + + protected override createToolPalette(): SystemToolBox { + return new SystemToolBox({ locator: SystemToolBox.locate(this) }); + } +} +export interface WaitForModelUpdateOptions { + increments: number; + match: 'exact' | 'atLeast'; +} +export class SystemDiagramGraph extends GLSPSemanticGraph { + async waitForModelUpdate( + executor: () => Promise, + options: WaitForModelUpdateOptions = { increments: 1, match: 'atLeast' } + ): Promise { + const currentRevision = await this.getRevision(); + const { increments, match } = options; + const expectedRevision = currentRevision + increments; + await executor(); + await waitForFunction(async () => { + const revision = await this.getRevision(); + return match === 'exact' ? revision === expectedRevision : expectedRevision <= revision; + }); + } + + async getRevision(): Promise { + const revision = await this.locate().getAttribute(`${SVGMetadata.prefix}-revision`); + try { + return Number.parseInt(revision ?? '0', 10); + } catch (err) { + return 0; + } + } + + // Temporary fix. The base getNodes methods does not account for "." in ids. The will be falsy treated as class selectors. + override async getEdgesOfType( + constructor: PEdgeConstructor, + options?: TOptions + ): Promise[]> { + const elements: TypedEdge[] = []; + + let query = SVGMetadataUtils.typeAttrOf(constructor); + if (options?.sourceId) { + query += `[${SVGMetadata.Edge.sourceId}="${options.sourceId}"]`; + } else if (options?.targetId) { + query += `[${SVGMetadata.Edge.targetId}="${options.targetId}"]`; + } + + for await (const locator of await this.locate().locator(query).all()) { + const id = await locator.getAttribute('id'); + // eslint-disable-next-line no-null/no-null + if (id !== null && (await isEqualLocatorType(locator, constructor))) { + const element = await this.getEdge(`id=${id}`, constructor, options); + const sourceChecks = []; + const targetChecks = []; + + if (options?.sourceConstructor) { + const sourceId = await element.sourceId(); + sourceChecks.push( + (await this.locate() + .locator(`[id$="${sourceId}"]${SVGMetadataUtils.typeAttrOf(options.sourceConstructor)}`) + .count()) > 0 + ); + } + + if (options?.targetConstructor) { + const targetId = await element.targetId(); + targetChecks.push( + (await this.locate() + .locator(`[id$="${targetId}"]${SVGMetadataUtils.typeAttrOf(options.targetConstructor)}`) + .count()) > 0 + ); + } + + if (options?.sourceSelectorOrLocator) { + const sourceLocator = asLocator(options.sourceSelectorOrLocator, selector => this.locate().locator(selector)); + const sourceId = await element.sourceId(); + const expectedId = await definedAttr(sourceLocator, 'id'); + sourceChecks.push(expectedId.includes(sourceId)); + } + + if (options?.targetSelectorOrLocator) { + const targetLocator = asLocator(options.targetSelectorOrLocator, selector => this.locate().locator(selector)); + const targetId = await element.targetId(); + const expectedId = await definedAttr(targetLocator, 'id'); + sourceChecks.push(expectedId.includes(targetId)); + } + + if (sourceChecks.every(c => c) && targetChecks.every(c => c)) { + elements.push(element); + } + } + } + + return elements; + } + + // Temporary fix. The base getNodes methods does not account for "." in ids. The will be falsy treated as class selectors. + override async getNodes( + selectorOrLocator: string | Locator, + constructor: PNodeConstructor, + options?: GraphConstructorOptions + ): Promise { + const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const elements: TElement[] = []; + + for await (const childLocator of await locator.all()) { + if ((await childLocator.count()) > 0) { + const id = await childLocator.getAttribute('id'); + // eslint-disable-next-line no-null/no-null + if (id !== null && (await isEqualLocatorType(childLocator, constructor))) { + elements.push(await this.getNode(`id=${id}`, constructor, options)); + } + } + } + + return elements; + } + + // Temporary fix. The base getNodes methods does not account for "." in ids. The will be falsy treated as class selectors. + override async waitForCreationOfType( + constructor: PModelElementConstructor, + creator: () => Promise + ): Promise { + const elementType = PMetadata.getType(constructor); + + const ids = await this.waitForCreation(elementType, creator); + + let retriever = this.getModelElement.bind(this); + if (isPNodeConstructor(constructor)) { + retriever = this.getNode.bind(this) as any; + } else if (isPEdgeConstructor(constructor)) { + retriever = this.getEdge.bind(this) as any; + } + + return Promise.all(ids.map(id => retriever(`id=${id}`, constructor))); + } + + // Temporary fix. The base getNodes methods does not account for "." in ids. The will be falsy treated as class selectors. + override async getModelElements( + selectorOrLocator: string | Locator, + constructor: PModelElementConstructor, + options?: GraphConstructorOptions + ): Promise { + super.getModelElements; + const locator = asLocator(selectorOrLocator, selector => this.locator.child(selector).locate()); + const elements: TElement[] = []; + + for await (const childLocator of await locator.all()) { + if ((await childLocator.count()) > 0) { + const id = await childLocator.getAttribute('id'); + // eslint-disable-next-line no-null/no-null + if (id !== null && (await isEqualLocatorType(childLocator, constructor))) { + elements.push(await this.getModelElement(`id=${id}`, constructor, options)); + } + } + } + + return elements; + } +} diff --git a/e2e-tests/src/page-objects/system-diagram/system-tool-box.ts b/e2e-tests/src/page-objects/system-diagram/system-tool-box.ts new file mode 100644 index 00000000..223ccb98 --- /dev/null +++ b/e2e-tests/src/page-objects/system-diagram/system-tool-box.ts @@ -0,0 +1,46 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { + GLSPToolPalette, + GLSPToolPaletteContent, + GLSPToolPaletteOptions, + ToolPaletteContentGroup, + ToolPaletteContentItem +} from '@eclipse-glsp/glsp-playwright'; + +export class SystemToolBox extends GLSPToolPalette { + override readonly content: SystemToolBoxContent; + + constructor(options: GLSPToolPaletteOptions) { + super(options); + this.content = new SystemToolBoxContent(this); + } +} + +export interface SystemTools { + default: 'Select & Move' | 'Hide' | 'Show Entity' | 'Create Entity' | 'Create 1:1 Relationship'; +} + +export class SystemToolBoxContent extends GLSPToolPaletteContent { + async toolGroups(): Promise { + return super.groupsOfType(ToolPaletteContentGroup); + } + + async toolGroupByHeaderText(headerText: TToolGroupKey): Promise { + return super.groupByHeaderText(headerText, ToolPaletteContentGroup); + } + + async toolElement( + groupHeader: TToolGroupKey, + elementText: SystemTools[TToolGroupKey] + ): Promise> { + return super.itemBy({ + groupHeaderText: groupHeader, + groupConstructor: ToolPaletteContentGroup, + elementText, + elementConstructor: ToolPaletteContentItem + }); + } +} diff --git a/e2e-tests/src/resources/sample-workspace/.theia/settings.json b/e2e-tests/src/resources/sample-workspace/.theia/settings.json new file mode 100644 index 00000000..114bcaa8 --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/.theia/settings.json @@ -0,0 +1,3 @@ +{ + "files.enableTrash": false +} \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/diagrams/CRM.system-diagram.cm b/e2e-tests/src/resources/sample-workspace/ExampleCRM/diagrams/CRM.system-diagram.cm new file mode 100644 index 00000000..6f1de9cb --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/diagrams/CRM.system-diagram.cm @@ -0,0 +1,16 @@ +systemDiagram: + id: CRM + name: "CRM" + nodes: + - id: OrderNode + entity: Order + x: 1034 + y: 253 + width: 163.921875 + height: 136 + - id: CustomerNode + entity: Customer + x: 693 + y: 231 + width: 157.5 + height: 176 \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/diagrams/EMPTY.system-diagram.cm b/e2e-tests/src/resources/sample-workspace/ExampleCRM/diagrams/EMPTY.system-diagram.cm new file mode 100644 index 00000000..26fb652d --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/diagrams/EMPTY.system-diagram.cm @@ -0,0 +1,15 @@ +systemDiagram: + id: EMPTY + name: "EMPTY" + description: "Diagram with empty entity" + nodes: + - id: EmptyEntityNode + entity: EmptyEntity + x: 396 + y: 429 + width: 10 + height: 10 + customProperties: + - name: Author + value: "CrossBreeze" + - name: EmptyDiagram \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Address.entity.cm b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Address.entity.cm new file mode 100644 index 00000000..7175b8fa --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Address.entity.cm @@ -0,0 +1,23 @@ +entity: + id: Address + name: "Address" + description: "The address of a customer." + attributes: + - id: CustomerID + name: "CustomerID" + datatype: "Integer" + identifier: true + - id: Street + name: "Street" + datatype: "Text" + - id: CountryCode + name: "CountryCode" + datatype: "Text" + customProperties: + - name: Author + value: "CrossBreeze" + - name: ExampleEntityAttribute + customProperties: + - name: Author + value: "CrossBreeze" + - name: ExampleEntity \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Customer.entity.cm b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Customer.entity.cm new file mode 100644 index 00000000..d3ef8026 --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Customer.entity.cm @@ -0,0 +1,26 @@ +entity: + id: Customer + name: "Customer" + attributes: + - id: Id + name: "Id" + datatype: "Integer" + identifier: true + - id: FirstName + name: "FirstName" + datatype: "Text" + - id: LastName + name: "LastName" + datatype: "Text" + - id: City + name: "City" + datatype: "Text" + - id: Country + name: "Country" + datatype: "Text" + - id: Phone + name: "Phone" + datatype: "Text" + - id: BirthDate + name: "BirthDate" + datatype: "DateTime" \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/EmptyEntity.entity.cm b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/EmptyEntity.entity.cm new file mode 100644 index 00000000..ff232e11 --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/EmptyEntity.entity.cm @@ -0,0 +1,3 @@ +entity: + id: EmptyEntity + name: "EmptyEntity" \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Order.entity.cm b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Order.entity.cm new file mode 100644 index 00000000..62184e8a --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/entities/Order.entity.cm @@ -0,0 +1,21 @@ +entity: + id: Order + name: "Order" + description: "Order placed by a customer in the Customer table." + attributes: + - id: Id + name: "Id" + datatype: "Integer" + identifier: true + - id: OrderDate + name: "OrderDate" + datatype: "Integer" + - id: OrderNumber + name: "OrderNumber" + datatype: "Text" + - id: CustomerId + name: "CustomerId" + datatype: "Integer" + - id: TotalAmount + name: "TotalAmount" + datatype: "Decimal" \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/package.json b/e2e-tests/src/resources/sample-workspace/ExampleCRM/package.json new file mode 100644 index 00000000..455092d0 --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/package.json @@ -0,0 +1,4 @@ +{ + "name": "ExampleCRM", + "version": "1.0.0" +} \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/ExampleCRM/relationships/Test.relationship.cm b/e2e-tests/src/resources/sample-workspace/ExampleCRM/relationships/Test.relationship.cm new file mode 100644 index 00000000..5d2d14ec --- /dev/null +++ b/e2e-tests/src/resources/sample-workspace/ExampleCRM/relationships/Test.relationship.cm @@ -0,0 +1,5 @@ +relationship: + id: TestRelationship + parent: Customer + child: Order + type: "1:1" \ No newline at end of file diff --git a/e2e-tests/src/resources/sample-workspace/testFolder/.gitkeep b/e2e-tests/src/resources/sample-workspace/testFolder/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/e2e-tests/src/tests/crossmodel-app.spec.ts b/e2e-tests/src/tests/cm-app.spec.ts similarity index 61% rename from e2e-tests/src/tests/crossmodel-app.spec.ts rename to e2e-tests/src/tests/cm-app.spec.ts index cd2b0efa..04da3fb9 100644 --- a/e2e-tests/src/tests/crossmodel-app.spec.ts +++ b/e2e-tests/src/tests/cm-app.spec.ts @@ -1,10 +1,16 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { expect } from '@playwright/test'; -import test, { app } from '../fixtures/crossmodel-fixture'; +import { expect, test } from '@playwright/test'; +import { CMApp } from '../page-objects/cm-app'; test.describe('CrossModel App', () => { + let app: CMApp; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); + test('main content panel visible', async () => { expect(await app.isMainContentPanelVisible()).toBe(true); }); diff --git a/e2e-tests/src/tests/crossmodel-error-view.spec.ts b/e2e-tests/src/tests/cm-error-view.spec.ts similarity index 82% rename from e2e-tests/src/tests/crossmodel-error-view.spec.ts rename to e2e-tests/src/tests/cm-error-view.spec.ts index 3b57943a..5fbf3e83 100644 --- a/e2e-tests/src/tests/crossmodel-error-view.spec.ts +++ b/e2e-tests/src/tests/cm-error-view.spec.ts @@ -1,13 +1,18 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { expect } from '@playwright/test'; -import test, { app } from '../fixtures/crossmodel-fixture'; -import { CrossModelCompositeEditor } from '../page-objects/crossmodel-composite-editor'; +import { expect, test } from '@playwright/test'; +import { CMApp } from '../page-objects/cm-app'; +import { CMCompositeEditor } from '../page-objects/cm-composite-editor'; test.describe('CrossModel Error Views', () => { + let app: CMApp; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); test('Form Editor should show error if model code is broken', async () => { - const editor = await app.openEditor('example-entity.entity.cm', CrossModelCompositeEditor); + const editor = await app.openEditor('example-entity.entity.cm', CMCompositeEditor); expect(editor).toBeDefined(); const codeEditor = await editor.switchToCodeEditor(); @@ -24,11 +29,10 @@ test.describe('CrossModel Error Views', () => { }); test('System Diagram Editor should show error if model code is broken', async () => { - const editor = await app.openEditor('example-diagram.diagram.cm', CrossModelCompositeEditor); + const editor = await app.openEditor('example-diagram.diagram.cm', CMCompositeEditor); expect(editor).toBeDefined(); const codeEditor = await editor.switchToCodeEditor(); - expect(codeEditor).toBeDefined(); await codeEditor.addTextToNewLineAfterLineByLineNumber(2, 'break-model'); const diagramEditor = await editor.switchToSystemDiagram(); diff --git a/e2e-tests/src/tests/crossmodel-explorer-view.spec.ts b/e2e-tests/src/tests/cm-explorer-view.spec.ts similarity index 88% rename from e2e-tests/src/tests/crossmodel-explorer-view.spec.ts rename to e2e-tests/src/tests/cm-explorer-view.spec.ts index 1462331f..afe1caa6 100644 --- a/e2e-tests/src/tests/crossmodel-explorer-view.spec.ts +++ b/e2e-tests/src/tests/cm-explorer-view.spec.ts @@ -1,11 +1,9 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { expect, Page } from '@playwright/test'; -import test, { app } from '../fixtures/crossmodel-fixture'; -import { CrossModelExplorerView } from '../page-objects/crossmodel-explorer-view'; - -let explorer: CrossModelExplorerView; +import { expect, Page, test } from '@playwright/test'; +import { CMApp } from '../page-objects/cm-app'; +import { CMExplorerView } from '../page-objects/cm-explorer-view'; async function checkOpenWithItem(page: Page, text: string): Promise { // Locate all elements matching the selector @@ -24,9 +22,12 @@ async function checkOpenWithItem(page: Page, text: string): Promise { } test.describe('CrossModel Explorer View', () => { - test.beforeAll(async ({ browser }) => { - explorer = await app.openView(CrossModelExplorerView); - await explorer.waitForVisibleFileNodes(); + let app: CMApp; + let explorer: CMExplorerView; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + explorer = await app.openExplorerView(); }); test('code and form editor options available in the context menu on an entity', async () => { diff --git a/e2e-tests/src/tests/crossmodel-tabbar-toolbar.spec.ts b/e2e-tests/src/tests/cm-tabbar-toolbar.spec.ts similarity index 80% rename from e2e-tests/src/tests/crossmodel-tabbar-toolbar.spec.ts rename to e2e-tests/src/tests/cm-tabbar-toolbar.spec.ts index 32a9576e..c75b3c2c 100644 --- a/e2e-tests/src/tests/crossmodel-tabbar-toolbar.spec.ts +++ b/e2e-tests/src/tests/cm-tabbar-toolbar.spec.ts @@ -1,30 +1,27 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { expect } from '@playwright/test'; -import test, { app } from '../fixtures/crossmodel-fixture'; -import { CrossModelExplorerView } from '../page-objects/crossmodel-explorer-view'; +import { expect, test } from '@playwright/test'; +import { CMApp } from '../page-objects/cm-app'; +import { CMExplorerView } from '../page-objects/cm-explorer-view'; import { TheiaSingleInputDialog } from '../page-objects/theia-single-input-dialog'; -import { TheiaTabBarToolbar } from '../page-objects/theia-tabbar-toolbar'; - -let explorer: CrossModelExplorerView; -let tabBarToolbar: TheiaTabBarToolbar; test.describe('CrossModel TabBar Toolbar', () => { - test.beforeAll(async ({ browser }) => { - explorer = await app.openView(CrossModelExplorerView); - await explorer.waitForVisibleFileNodes(); - tabBarToolbar = explorer.tabBarToolbar; - }); + let app: CMApp; + let explorer: CMExplorerView; + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + explorer = await app.openExplorerView(); + }); test.beforeEach(async () => { await explorer.focus(); - await tabBarToolbar.waitForVisible(); + await explorer.tabBarToolbar.waitForVisible(); }); test('create new entity from tabbar toolbar', async () => { // Get the new-entity toolbar item. - const tabBarToolbarNewEntity = await tabBarToolbar.toolBarItem('crossbreeze.new.entity.toolbar'); + const tabBarToolbarNewEntity = await explorer.tabBarToolbar.toolBarItem('crossbreeze.new.entity.toolbar'); expect(tabBarToolbarNewEntity).toBeDefined(); if (tabBarToolbarNewEntity) { // expect(await tabBarToolbarNewEntity.isEnabled()).toBe(true); @@ -45,7 +42,7 @@ test.describe('CrossModel TabBar Toolbar', () => { // Wait until the dialog is closed. await newEntityDialog.waitForClosed(); - explorer = await app.openView(CrossModelExplorerView); + explorer = await app.openView(CMExplorerView); const file = await explorer.getFileStatNodeByLabel('entity-created-from-tabbar-toolbar.entity.cm'); expect(file).toBeDefined(); expect(await file.label()).toBe('entity-created-from-tabbar-toolbar.entity.cm'); @@ -54,7 +51,7 @@ test.describe('CrossModel TabBar Toolbar', () => { test('create new relationship from tabbar toolbar', async () => { // Get the new-entity toolbar item. - const tabBarToolbarNewEntity = await tabBarToolbar.toolBarItem('crossbreeze.new.relationship.toolbar'); + const tabBarToolbarNewEntity = await explorer.tabBarToolbar.toolBarItem('crossbreeze.new.relationship.toolbar'); expect(tabBarToolbarNewEntity).toBeDefined(); if (tabBarToolbarNewEntity) { // expect(await tabBarToolbarNewEntity.isEnabled()).toBe(true); @@ -75,7 +72,7 @@ test.describe('CrossModel TabBar Toolbar', () => { // Wait until the dialog is closed. await newRelationshipDialog.waitForClosed(); - explorer = await app.openView(CrossModelExplorerView); + explorer = await app.openView(CMExplorerView); const file = await explorer.getFileStatNodeByLabel('relationship-created-from-tabbar-toolbar.relationship.cm'); expect(file).toBeDefined(); expect(await file.label()).toBe('relationship-created-from-tabbar-toolbar.relationship.cm'); @@ -84,7 +81,7 @@ test.describe('CrossModel TabBar Toolbar', () => { test('create new diagram from tabbar toolbar', async () => { // Get the new-entity toolbar item. - const tabBarToolbarNewEntity = await tabBarToolbar.toolBarItem('crossbreeze.new.system-diagram.toolbar'); + const tabBarToolbarNewEntity = await explorer.tabBarToolbar.toolBarItem('crossbreeze.new.system-diagram.toolbar'); expect(tabBarToolbarNewEntity).toBeDefined(); if (tabBarToolbarNewEntity) { // expect(await tabBarToolbarNewEntity.isEnabled()).toBe(true); @@ -105,7 +102,7 @@ test.describe('CrossModel TabBar Toolbar', () => { // Wait until the dialog is closed. await newDiagramDialog.waitForClosed(); - explorer = await app.openView(CrossModelExplorerView); + explorer = await app.openView(CMExplorerView); const file = await explorer.getFileStatNodeByLabel('diagram-created-from-tabbar-toolbar.system-diagram.cm'); expect(file).toBeDefined(); expect(await file.label()).toBe('diagram-created-from-tabbar-toolbar.system-diagram.cm'); diff --git a/e2e-tests/src/tests/diagram/system/add-edit-delete-attributes.spec.ts b/e2e-tests/src/tests/diagram/system/add-edit-delete-attributes.spec.ts new file mode 100644 index 00000000..26befa4c --- /dev/null +++ b/e2e-tests/src/tests/diagram/system/add-edit-delete-attributes.spec.ts @@ -0,0 +1,107 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { expect } from '@eclipse-glsp/glsp-playwright'; +import { test } from '@playwright/test'; +import { CMApp } from '../../../page-objects/cm-app'; +import { Attribute } from '../../../page-objects/system-diagram/diagram-elements'; + +test.describe.serial('Add/Edit/Delete attributes to/from an entity in a diagram', () => { + let app: CMApp; + const SYSTEM_DIAGRAM_PATH = 'ExampleCRM/diagrams/EMPTY.system-diagram.cm'; + const ENTITY_PATH = 'ExampleCRM/entities/EmptyEntity.entity.cm'; + const ENTITY_ID = 'EmptyEntity'; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); + test.afterAll(async () => { + await app.page.close(); + }); + + test('Add attribute via properties view', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + await diagramEditor.waitForCreationOfType(Attribute, async () => { + const propertyView = await diagramEditor.selectEntityAndOpenProperties(ENTITY_ID); + const form = await propertyView.form(); + const attribute = await form.attributesSection.addAttribute(); + await form.waitForDirty(); + + // Verify that the attribute was added as expected to the properties view + const properties = await attribute.getProperties(); + expect(properties).toMatchObject({ name: 'New Attribute', datatype: 'Varchar', identifier: false, description: '' }); + await propertyView.saveAndClose(); + }); + + // Verify that the attribute was added as expected to the diagram + const entity = await diagramEditor.getEntity(ENTITY_ID); + const attributeNodes = await entity.children.attributes(); + expect(attributeNodes).toHaveLength(1); + const attributeNode = attributeNodes[0]; + expect(await attributeNode.datatype()).toEqual('Varchar'); + expect(await attributeNode.name()).toEqual('New Attribute'); + await diagramEditor.saveAndClose(); + + // Verify that the attribute was added as expected to the entity; + const entityCodeEditor = await app.openCompositeEditor(ENTITY_PATH, 'Code Editor'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(4)).toMatch('attributes:'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(5)).toMatch('- id: New_Attribute'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(6)).toMatch('name: "New Attribute"'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(7)).toMatch('datatype: "Varchar"'); + + await entityCodeEditor.saveAndClose(); + }); + + test('Edit attribute via properties view', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const propertyView = await diagramEditor.selectEntityAndOpenProperties(ENTITY_ID); + const form = await propertyView.form(); + const attribute = await form.attributesSection.getAttribute('New Attribute'); + + await attribute.setName('Renamed Attribute'); + await attribute.setDatatype('Bool'); + await attribute.toggleIdentifier(); + await attribute.setDescription('New Description'); + await form.waitForDirty(); + + // Verify that the attribute was changed as expected in the properties view + const properties = await attribute.getProperties(); + expect(properties).toMatchObject({ name: 'Renamed Attribute', datatype: 'Bool', identifier: true, description: 'New Description' }); + await propertyView.saveAndClose(); + + // Verify that the attribute was added as expected to the entity; + const entityCodeEditor = await app.openCompositeEditor(ENTITY_PATH, 'Code Editor'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(6)).toMatch('name: "Renamed Attribute"'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(7)).toMatch('datatype: "Bool"'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(8)).toMatch('identifier: true'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(9)).toMatch('description: "New Description"'); + await entityCodeEditor.saveAndClose(); + }); + + test('Delete the attribute via properties view', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const propertyView = await diagramEditor.selectEntityAndOpenProperties(ENTITY_ID); + const form = await propertyView.form(); + await diagramEditor.waitForModelUpdate(async () => { + await form.attributesSection.deleteAttribute('Renamed Attribute'); + await form.waitForDirty(); + }); + + // Verify that the attribute was deleted as expected from the properties view + const attribute = await form.attributesSection.findAttribute('Renamed Attribute'); + expect(attribute).toBeUndefined(); + await propertyView.saveAndClose(); + + // Verify that the attribute was deleted as expected from the diagram + await diagramEditor.activate(); + const entity = await diagramEditor.getEntity(ENTITY_ID); + const attributeNodes = await entity.children.attributes(); + expect(attributeNodes).toHaveLength(0); + await diagramEditor.saveAndClose(); + + // Verify that the attribute was deleted as expected from the entity; + const entityCodeEditor = await app.openCompositeEditor(ENTITY_PATH, 'Code Editor'); + expect(await entityCodeEditor.numberOfLines()).toBe(3); + await entityCodeEditor.saveAndClose(); + }); +}); diff --git a/e2e-tests/src/tests/diagram/system/add-edit-delete-entity.spec.ts b/e2e-tests/src/tests/diagram/system/add-edit-delete-entity.spec.ts new file mode 100644 index 00000000..10f18cc0 --- /dev/null +++ b/e2e-tests/src/tests/diagram/system/add-edit-delete-entity.spec.ts @@ -0,0 +1,110 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { expect } from '@eclipse-glsp/glsp-playwright'; +import { test } from '@playwright/test'; +import { CMApp } from '../../../page-objects/cm-app'; +import { Entity } from '../../../page-objects/system-diagram/diagram-elements'; + +test.describe.serial('Add/Edit/Delete entity in a diagram ', () => { + let app: CMApp; + const SYSTEM_DIAGRAM_PATH = 'ExampleCRM/diagrams/EMPTY.system-diagram.cm'; + const NEW_ENTITY_PATH = 'ExampleCRM/entities/NewEntity.entity.cm'; + const NEW_ENTITY_LABEL = 'NewEntity'; + const RENAMED_ENTITY_LABEL = 'NewEntityRenamed'; + const RENAMED_ENTITY_DESCRIPTION = 'NewEntityDescription'; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); + test.afterAll(async () => { + await app.page.close(); + }); + + test('create new entity via toolbox', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + // Create new entity + await diagramEditor.waitForCreationOfType(Entity, async () => { + const existingEntity = await diagramEditor.getEntity('EmptyEntity'); + await diagramEditor.enableTool('Create Entity'); + const taskBounds = await existingEntity.bounds(); + await taskBounds.position('top_center').moveRelative(0, -100).click(); + }); + + // Verify that the entity node was created as expected in the diagram + const newEntity = await diagramEditor.getEntity(NEW_ENTITY_LABEL); + expect(newEntity).toBeDefined(); + + const diagramCodeEditor = await diagramEditor.parent.switchToCodeEditor(); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(12)).toMatch('- id: NewEntityNode'); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(13)).toMatch(`entity: ${NEW_ENTITY_LABEL}`); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(14)).toMatch(/x:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(15)).toMatch(/y:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(16)).toMatch(/width:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(17)).toMatch(/height:\s*\d+/); + await diagramCodeEditor.saveAndClose(); + + // Verify that the entity was created as expected + const explorer = await app.openExplorerView(); + expect(await explorer.existsFileNode(NEW_ENTITY_PATH)).toBeTruthy(); + + const entityCodeEditor = await app.openCompositeEditor(NEW_ENTITY_PATH, 'Code Editor'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(1)).toBe('entity:'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(2)).toMatch(`id: ${NEW_ENTITY_LABEL}`); + expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch(`name: "${NEW_ENTITY_LABEL}"`); + await entityCodeEditor.saveAndClose(); + }); + + test('Edit entity name & description via properties', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const properties = await diagramEditor.selectEntityAndOpenProperties(NEW_ENTITY_LABEL); + const form = await properties.form(); + await form.generalSection.setName(RENAMED_ENTITY_LABEL); + await form.generalSection.setDescription(RENAMED_ENTITY_DESCRIPTION); + await form.waitForDirty(); + // Verify that the entity was renamed as expected + expect(await form.generalSection.getName()).toBe(RENAMED_ENTITY_LABEL); + expect(await form.generalSection.getDescription()).toBe(RENAMED_ENTITY_DESCRIPTION); + await properties.saveAndClose(); + await diagramEditor.activate(); + await diagramEditor.saveAndClose(); + + const entityCodeEditor = await app.openCompositeEditor(NEW_ENTITY_PATH, 'Code Editor'); + + expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch(`name: "${RENAMED_ENTITY_LABEL}"`); + expect(await entityCodeEditor.textContentOfLineByLineNumber(4)).toMatch(`description: "${RENAMED_ENTITY_DESCRIPTION}"`); + await entityCodeEditor.saveAndClose(); + }); + + test('Hide new entity', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + await diagramEditor.activate(); + const renamedEntity = await diagramEditor.getEntity(RENAMED_ENTITY_LABEL); + // Hide entity + await diagramEditor.waitForModelUpdate(async () => { + await diagramEditor.enableTool('Hide'); + await renamedEntity.click(); + }); + + // Check if entity is actually just hidden, i.e. can be shown again + const position = (await diagramEditor.diagram.graph.bounds()).position('middle_center'); + await diagramEditor.invokeShowEntityToolAtPosition(position); + const entitySuggestions = await diagramEditor.diagram.globalCommandPalette.suggestions(); + expect(entitySuggestions).toContain(NEW_ENTITY_LABEL); + + await diagramEditor.saveAndClose(); + }); + + test('Delete new entity', async () => { + const explorer = await app.openExplorerView(); + await explorer.deleteNode(NEW_ENTITY_PATH, true); + expect(await explorer.findTreeNode(NEW_ENTITY_PATH)).toBeUndefined(); + // Check if entity is actually deleted, i.e. can not be shown (using keyboard shortcut) + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const position = (await diagramEditor.diagram.graph.bounds()).position('middle_center'); + await diagramEditor.invokeShowEntityToolAtPosition(position); + const entitySuggestions = await diagramEditor.diagram.globalCommandPalette.suggestions(); + expect(entitySuggestions).not.toContain(NEW_ENTITY_LABEL); + await diagramEditor.close(); + }); +}); diff --git a/e2e-tests/src/tests/diagram/system/add-edit-delete-relationship.spec.ts b/e2e-tests/src/tests/diagram/system/add-edit-delete-relationship.spec.ts new file mode 100644 index 00000000..36f02c8d --- /dev/null +++ b/e2e-tests/src/tests/diagram/system/add-edit-delete-relationship.spec.ts @@ -0,0 +1,95 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { expect, test } from '@playwright/test'; +import { CMApp } from '../../../page-objects/cm-app'; +import { RelationshipPropertiesView } from '../../../page-objects/cm-properties-view'; +import { Relationship } from '../../../page-objects/system-diagram/diagram-elements'; +import { IntegratedSystemDiagramEditor } from '../../../page-objects/system-diagram/integrated-system-diagram-editor'; + +test.describe.serial('Add/Edit/Delete relationship in a diagram ', () => { + let app: CMApp; + const SYSTEM_DIAGRAM_PATH = 'ExampleCRM/diagrams/CRM.system-diagram.cm'; + const CUSTOMER_ID = 'Customer'; + const ORDER_ID = 'Order'; + const NEW_RELATIONSHIP_PATH = 'ExampleCRM/relationships/CustomerToOrder.relationship.cm'; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); + test.afterAll(async () => { + await app.page.close(); + }); + + async function getNewRelationship(diagramEditor: IntegratedSystemDiagramEditor): Promise { + const sourceEntity = await diagramEditor.getEntity(CUSTOMER_ID); + const targetEntity = await diagramEditor.getEntity(ORDER_ID); + return diagramEditor.diagram.graph.getEdgeBetween(Relationship, { sourceNode: sourceEntity, targetNode: targetEntity }); + } + + test('create new relationship via toolbox', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const sourceEntity = await diagramEditor.getEntity(CUSTOMER_ID); + const targetEntity = await diagramEditor.getEntity(ORDER_ID); + + await diagramEditor.waitForCreationOfType(Relationship, async () => { + await diagramEditor.enableTool('Create 1:1 Relationship'); + const targetPosition = (await targetEntity.bounds()).position('middle_center'); + await sourceEntity.dragToAbsolutePosition(targetPosition.data); + }); + // Verify that the entity node was created as expected in the diagram + const diagramCodeEditor = await diagramEditor.parent.switchToCodeEditor(); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(17)).toMatch('edges:'); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(18)).toMatch('- id: CustomerToOrderEdge'); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(19)).toMatch('relationship: ExampleCRM.CustomerToOrder'); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(20)).toMatch('sourceNode: CustomerNode'); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(21)).toMatch('targetNode: OrderNode'); + await diagramCodeEditor.saveAndClose(); + + // Verify that the relationship was created as expected + const explorer = await app.openExplorerView(); + expect(await explorer.existsFileNode(NEW_RELATIONSHIP_PATH)).toBeTruthy(); + + const entityCodeEditor = await app.openCompositeEditor(NEW_RELATIONSHIP_PATH, 'Code Editor'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(1)).toBe('relationship:'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(2)).toMatch('id: CustomerToOrder'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch('parent: Customer'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(4)).toMatch('child: Order'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(5)).toMatch('type: "1:1"'); + await entityCodeEditor.saveAndClose(); + }); + + test('Edit relationship name & description via properties', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const relationship = await getNewRelationship(diagramEditor); + await relationship.select(); + const properties = await app.openView(RelationshipPropertiesView); + const form = await properties.form(); + await form.generalSection.setName('RenamedRelationship'); + await form.generalSection.setDescription('RenamedRelationshipDescription'); + await form.waitForDirty(); + + // Verify that the entity was renamed as expected + expect(await form.generalSection.getName()).toBe('RenamedRelationship'); + expect(await form.generalSection.getDescription()).toBe('RenamedRelationshipDescription'); + await properties.saveAndClose(); + + const entityCodeEditor = await app.openCompositeEditor(NEW_RELATIONSHIP_PATH, 'Code Editor'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(3)).toMatch('name: "RenamedRelationship'); + expect(await entityCodeEditor.textContentOfLineByLineNumber(4)).toMatch(' description: "RenamedRelationshipDescription"'); + await entityCodeEditor.saveAndClose(); + }); + + // Skipped for now. Deleting a relationship in the diagram does currently not remove the relationship from the file system. + test.skip('Delete new relationship', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const relationship = await getNewRelationship(diagramEditor); + await relationship.delete(); + const diagramCodeEditor = await diagramEditor.parent.switchToCodeEditor(); + expect(await diagramCodeEditor.numberOfLines()).toBe(11); + + const explorer = await app.openExplorerView(); + expect(await explorer.findTreeNode(NEW_RELATIONSHIP_PATH)).toBeUndefined(); + }); +}); diff --git a/e2e-tests/src/tests/diagram/system/add-existing-entity.spec.ts b/e2e-tests/src/tests/diagram/system/add-existing-entity.spec.ts new file mode 100644 index 00000000..836106f9 --- /dev/null +++ b/e2e-tests/src/tests/diagram/system/add-existing-entity.spec.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { expect } from '@eclipse-glsp/glsp-playwright'; +import { test } from '@playwright/test'; +import { CMApp } from '../../../page-objects/cm-app'; +import { Entity } from '../../../page-objects/system-diagram/diagram-elements'; + +test.describe.serial('Add existing entity to a diagram', () => { + let app: CMApp; + const SYSTEM_DIAGRAM_PATH = 'ExampleCRM/diagrams/EMPTY.system-diagram.cm'; + const CUSTOMER_ID = 'Customer'; + + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); + test.afterAll(async () => { + await app.page.close(); + }); + + test('Add existing entity via toolbox', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const diagram = diagramEditor.diagram; + // Create new entity + await diagram.graph.waitForCreationOfType(Entity, async () => { + const position = (await diagram.graph.bounds()).position('middle_center'); + await diagramEditor.invokeShowEntityToolAtPosition(position); + await diagram.globalCommandPalette.search(CUSTOMER_ID, { confirm: true }); + }); + + // Verify that the entity node was created as expected + const customer = await diagramEditor.getEntity(CUSTOMER_ID); + expect(customer).toBeDefined(); + + const diagramCodeEditor = await diagramEditor.parent.switchToCodeEditor(); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(12)).toMatch('- id: CustomerNode'); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(13)).toMatch(`entity: ${CUSTOMER_ID}`); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(14)).toMatch(/x:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(15)).toMatch(/y:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(16)).toMatch(/width:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(17)).toMatch(/height:\s*\d+/); + await diagramCodeEditor.saveAndClose(); + }); + + test('Add existing entity via keyboard shortcut', async () => { + const diagramEditor = await app.openCompositeEditor(SYSTEM_DIAGRAM_PATH, 'System Diagram'); + const diagram = diagramEditor.diagram; + // Create new entity + await diagram.graph.waitForCreationOfType(Entity, async () => { + await diagram.globalCommandPalette.open(); + await diagram.globalCommandPalette.search(CUSTOMER_ID, { confirm: true }); + }); + + // Verify that the entity node was created as expected + const customers = await diagramEditor.getEntities(CUSTOMER_ID); + expect(customers).toHaveLength(2); + + const diagramCodeEditor = await diagramEditor.parent.switchToCodeEditor(); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(18)).toMatch('- id: CustomerNode1'); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(19)).toMatch(`entity: ${CUSTOMER_ID}`); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(20)).toMatch(/x:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(21)).toMatch(/y:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(22)).toMatch(/width:\s*\d+/); + expect(await diagramCodeEditor.textContentOfLineByLineNumber(23)).toMatch(/height:\s*\d+/); + await diagramCodeEditor.saveAndClose(); + }); +}); diff --git a/e2e-tests/src/tests/form/add-edit-delete-entity.spec.ts b/e2e-tests/src/tests/form/add-edit-delete-entity.spec.ts new file mode 100644 index 00000000..604ff4b9 --- /dev/null +++ b/e2e-tests/src/tests/form/add-edit-delete-entity.spec.ts @@ -0,0 +1,87 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { expect, test } from '@playwright/test'; +import { CMApp } from '../../page-objects/cm-app'; +import { TheiaSingleInputDialog } from '../../page-objects/theia-single-input-dialog'; + +async function confirmCreationDialog(app: CMApp, entityName: string): Promise { + const newEntityDialog = new TheiaSingleInputDialog(app); + newEntityDialog.waitForVisible(); + expect(await newEntityDialog.title()).toBe('New Entity...'); + await newEntityDialog.enterSingleInput(entityName); + await newEntityDialog.waitUntilMainButtonIsEnabled(); + await newEntityDialog.confirm(); + await newEntityDialog.waitForClosed(); +} + +test.describe('Add/Edit/Delete entity from explorer', () => { + let app: CMApp; + const NEW_ENTITY_PATH = 'ExampleCRM/entities/NewEntity.entity.cm'; + const NEW_ENTITY2_PATH = 'testFolder/NewEntity2.entity.cm'; + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); + test.afterAll(async () => { + await app.page.close(); + }); + + test('Create entity via explorer tabbar', async () => { + const explorer = await app.openExplorerView(); + await explorer.getFileStatNodeByLabel('ExampleCRM/entities'); + await explorer.selectTreeNode('ExampleCRM/entities'); + + const tabBarToolbarNewEntity = await explorer.tabBarToolbar.toolBarItem('crossbreeze.new.entity.toolbar'); + expect(tabBarToolbarNewEntity).toBeDefined(); + if (!tabBarToolbarNewEntity) { + return; + } + await tabBarToolbarNewEntity.trigger(); + await confirmCreationDialog(app, 'NewEntity'); + + // Verify that the entity was created as expected + explorer.activate(); + expect(await explorer.existsFileNode(NEW_ENTITY_PATH)).toBeTruthy(); + + const editor = await app.openCompositeEditor(NEW_ENTITY_PATH, 'Code Editor'); + expect(await editor.textContentOfLineByLineNumber(1)).toBe('entity:'); + expect(await editor.textContentOfLineByLineNumber(2)).toMatch('id: NewEntity'); + expect(await editor.textContentOfLineByLineNumber(3)).toMatch('name: "NewEntity"'); + await editor.saveAndClose(); + }); + + test('Edit entity name & description using form editor ', async () => { + const formEditor = await app.openCompositeEditor(NEW_ENTITY_PATH, 'Form Editor'); + const form = await formEditor.formFor('entity'); + const general = await form.generalSection; + await general.setName('NewEntityRenamed'); + await general.setDescription('NewEntityDescription'); + await formEditor.waitForDirty(); + await formEditor.saveAndClose(); + + // Verify that the entity was changed as expected + const editor = await app.openCompositeEditor(NEW_ENTITY_PATH, 'Code Editor'); + expect(await editor.textContentOfLineByLineNumber(3)).toMatch('name: "NewEntityRenamed"'); + expect(await editor.textContentOfLineByLineNumber(4)).toMatch('description: "NewEntityDescription"'); + await editor.saveAndClose(); + }); + + test('Create & delete entity via context menu', async () => { + const explorer = await app.openExplorerView(); + // Create node + const folderNode = await explorer.getFileStatNodeByLabel('testFolder'); + const contextMenu = await folderNode.openContextMenu(); + const menuItem = await contextMenu.menuItemByNamePath('New Element', 'Entity...'); + expect(menuItem).toBeDefined(); + await menuItem?.click(); + await confirmCreationDialog(app, 'NewEntity2'); + explorer.activate(); + + // Verify that the entity was created as expected + expect(await explorer.existsFileNode(NEW_ENTITY2_PATH)).toBeTruthy(); + + // Delete node + await explorer.deleteNode(NEW_ENTITY2_PATH); + expect(await explorer.findTreeNode(NEW_ENTITY2_PATH)).toBeUndefined(); + }); +}); diff --git a/e2e-tests/src/tests/form/add-edit-delete-relationship.spec.ts b/e2e-tests/src/tests/form/add-edit-delete-relationship.spec.ts new file mode 100644 index 00000000..b3ebb9c3 --- /dev/null +++ b/e2e-tests/src/tests/form/add-edit-delete-relationship.spec.ts @@ -0,0 +1,77 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { expect, test } from '@playwright/test'; +import { CMApp } from '../../page-objects/cm-app'; +import { TheiaSingleInputDialog } from '../../page-objects/theia-single-input-dialog'; + +async function confirmCreationDialog(app: CMApp, entityName: string): Promise { + const dialog = new TheiaSingleInputDialog(app); + dialog.waitForVisible(); + expect(await dialog.title()).toBe('New Relationship...'); + await dialog.enterSingleInput(entityName); + await dialog.waitUntilMainButtonIsEnabled(); + await dialog.confirm(); + await dialog.waitForClosed(); +} + +test.describe('Add/Edit/Delete relationship from explorer', () => { + let app: CMApp; + const NEW_RELATIONSHIP_PATH = 'ExampleCRM/relationships/NewRelationship.relationship.cm'; + const TEST_RELATIONSHIP_PATH = 'ExampleCRM/relationships/Test.relationship.cm'; + test.beforeAll(async ({ browser, playwright }) => { + app = await CMApp.load({ browser, playwright }); + }); + test.afterAll(async () => { + await app.page.close(); + }); + + test('Create relationship via explorer tabbar', async () => { + const explorer = await app.openExplorerView(); + await explorer.getFileStatNodeByLabel('ExampleCRM/relationships'); + await explorer.selectTreeNode('ExampleCRM/relationships'); + + const tabBarToolbarNewEntity = await explorer.tabBarToolbar.toolBarItem('crossbreeze.new.relationship.toolbar'); + expect(tabBarToolbarNewEntity).toBeDefined(); + if (!tabBarToolbarNewEntity) { + return; + } + await tabBarToolbarNewEntity.trigger(); + await confirmCreationDialog(app, 'NewRelationship'); + + // Verify that the entity was created as expected + explorer.activate(); + expect(await explorer.existsFileNode(NEW_RELATIONSHIP_PATH)).toBeTruthy(); + + const editor = await app.openCompositeEditor(NEW_RELATIONSHIP_PATH, 'Code Editor'); + expect(await editor.textContentOfLineByLineNumber(1)).toBe('relationship:'); + expect(await editor.textContentOfLineByLineNumber(2)).toMatch('id: NewRelationship'); + expect(await editor.textContentOfLineByLineNumber(3)).toMatch('parent:'); + expect(await editor.textContentOfLineByLineNumber(4)).toMatch('child:'); + expect(await editor.textContentOfLineByLineNumber(5)).toMatch('type: "1:1"'); + await editor.close(); + }); + + test('Edit relationship name & description using form editor ', async () => { + // Workaround: New relationship created in the previous test is incomplete, so we use an existing one instead + const formEditor = await app.openCompositeEditor(TEST_RELATIONSHIP_PATH, 'Form Editor'); + const form = await formEditor.formFor('relationship'); + const general = await form.generalSection; + await general.setName('NewRelationshipRenamed'); + await general.setDescription('NewRelationshipRenamed'); + await formEditor.waitForDirty(); + await formEditor.saveAndClose(); + + // Verify that the entity was changed as expected + const editor = await app.openCompositeEditor(TEST_RELATIONSHIP_PATH, 'Code Editor'); + expect(await editor.textContentOfLineByLineNumber(3)).toMatch('name: "NewRelationshipRenamed"'); + expect(await editor.textContentOfLineByLineNumber(4)).toMatch('description: "NewRelationshipRenamed"'); + await editor.saveAndClose(); + }); + + test('Delete relationship via context menu', async () => { + const explorer = await app.openExplorerView(); + await explorer.deleteNode(NEW_RELATIONSHIP_PATH); + expect(await explorer.findTreeNode(NEW_RELATIONSHIP_PATH)).toBeUndefined(); + }); +}); diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/tool-palette/mapping-tool-palette-provider.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/tool-palette/mapping-tool-palette-provider.ts index 602a8aac..ab8f54c3 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/tool-palette/mapping-tool-palette-provider.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/tool-palette/mapping-tool-palette-provider.ts @@ -10,32 +10,40 @@ export class MappingToolPaletteProvider extends ToolPaletteItemProvider { override getItems(_args?: Args | undefined): MaybePromise { return [ { - id: 'default-tool', - sortString: '1', - label: 'Select', - icon: 'inspect', - actions: [activateDefaultToolsAction()] - }, - { - id: 'delete-tool', - sortString: '2', - label: 'Delete', - icon: 'chrome-close', - actions: [activateDeleteToolAction()] - }, - { - id: 'source-object-create-tool', - sortString: '3', - label: 'Create Source Object', - icon: 'empty-window', - actions: [EnableToolsAction.create(['source-object-creation-tool'])] - }, - { - id: 'mapping-create-tool', - sortString: '4', - label: 'Create Mapping', - icon: 'git-compare', - actions: [EnableToolsAction.create(['mapping-edge-creation-tool'])] + id: 'default', + actions: [], + label: 'default', + sortString: 'A', + children: [ + { + id: 'default-tool', + sortString: '1', + label: 'Select', + icon: 'inspect', + actions: [activateDefaultToolsAction()] + }, + { + id: 'delete-tool', + sortString: '2', + label: 'Delete', + icon: 'chrome-close', + actions: [activateDeleteToolAction()] + }, + { + id: 'source-object-create-tool', + sortString: '3', + label: 'Create Source Object', + icon: 'empty-window', + actions: [EnableToolsAction.create(['source-object-creation-tool'])] + }, + { + id: 'mapping-create-tool', + sortString: '4', + label: 'Create Mapping', + icon: 'git-compare', + actions: [EnableToolsAction.create(['mapping-edge-creation-tool'])] + } + ] } ]; } diff --git a/extensions/crossmodel-lang/src/glsp-server/system-diagram/tool-palette/system-tool-palette-provider.ts b/extensions/crossmodel-lang/src/glsp-server/system-diagram/tool-palette/system-tool-palette-provider.ts index 30df2b8b..8cd10fd2 100644 --- a/extensions/crossmodel-lang/src/glsp-server/system-diagram/tool-palette/system-tool-palette-provider.ts +++ b/extensions/crossmodel-lang/src/glsp-server/system-diagram/tool-palette/system-tool-palette-provider.ts @@ -23,39 +23,47 @@ export class SystemToolPaletteProvider extends ToolPaletteItemProvider { override getItems(_args?: Args | undefined): MaybePromise { return [ { - id: 'default-tool', - sortString: '1', - label: 'Select & Move', - icon: 'inspect', - actions: [activateDefaultToolsAction()] - }, - { - id: 'hide-tool', - sortString: '2', - label: 'Hide', - icon: 'eye-closed', - actions: [activateDeleteToolAction()] - }, - { - id: 'entity-show-tool', - sortString: '3', - label: 'Show Entity', - icon: 'eye', - actions: [TriggerNodeCreationAction.create(ENTITY_NODE_TYPE, { args: { type: 'show' } })] - }, - { - id: 'entity-create-tool', - sortString: '4', - label: 'Create Entity', - icon: ModelStructure.Entity.ICON, - actions: [TriggerNodeCreationAction.create(ENTITY_NODE_TYPE, { args: { type: 'create' } })] - }, - { - id: 'relationship-create-tool', - sortString: '5', - label: 'Create 1:1 Relationship', - icon: ModelStructure.Relationship.ICON, - actions: [TriggerEdgeCreationAction.create(RELATIONSHIP_EDGE_TYPE)] + id: 'default', + actions: [], + label: 'default', + sortString: 'A', + children: [ + { + id: 'default-tool', + sortString: '1', + label: 'Select & Move', + icon: 'inspect', + actions: [activateDefaultToolsAction()] + }, + { + id: 'hide-tool', + sortString: '2', + label: 'Hide', + icon: 'eye-closed', + actions: [activateDeleteToolAction()] + }, + { + id: 'entity-show-tool', + sortString: '3', + label: 'Show Entity', + icon: 'eye', + actions: [TriggerNodeCreationAction.create(ENTITY_NODE_TYPE, { args: { type: 'show' } })] + }, + { + id: 'entity-create-tool', + sortString: '4', + label: 'Create Entity', + icon: ModelStructure.Entity.ICON, + actions: [TriggerNodeCreationAction.create(ENTITY_NODE_TYPE, { args: { type: 'create' } })] + }, + { + id: 'relationship-create-tool', + sortString: '5', + label: 'Create 1:1 Relationship', + icon: ModelStructure.Relationship.ICON, + actions: [TriggerEdgeCreationAction.create(RELATIONSHIP_EDGE_TYPE)] + } + ] } ]; } diff --git a/packages/glsp-client/src/browser/cm-metadata-placer.ts b/packages/glsp-client/src/browser/cm-metadata-placer.ts new file mode 100644 index 00000000..695d62b7 --- /dev/null +++ b/packages/glsp-client/src/browser/cm-metadata-placer.ts @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { GChildElement, GEdge, GModelElement, GModelRoot, MetadataPlacer, setAttr } from '@eclipse-glsp/client'; +import { injectable } from '@theia/core/shared/inversify'; +import { VNode } from 'snabbdom'; + +@injectable() +export class CmMetadataPlacer extends MetadataPlacer { + override decorate(vnode: VNode, element: GModelElement): VNode { + if (element instanceof GModelRoot) { + setAttr(vnode, 'data-svg-metadata-api', true); + setAttr(vnode, 'data-svg-metadata-revision', element.revision ?? 0); + } + + setAttr(vnode, 'data-svg-metadata-type', element.type); + + if (element instanceof GChildElement) { + setAttr(vnode, 'data-svg-metadata-parent-id', this.domHelper.createUniqueDOMElementId(element.parent)); + } + if (element instanceof GEdge) { + if (element.source !== undefined) { + setAttr(vnode, 'data-svg-metadata-edge-source-id', this.domHelper.createUniqueDOMElementId(element.source)); + } + if (element.target !== undefined) { + setAttr(vnode, 'data-svg-metadata-edge-target-id', this.domHelper.createUniqueDOMElementId(element.target)); + } + } + return vnode; + } +} diff --git a/packages/glsp-client/src/browser/crossmodel-diagram-module.ts b/packages/glsp-client/src/browser/crossmodel-diagram-module.ts index e9aa07e1..c9be476b 100644 --- a/packages/glsp-client/src/browser/crossmodel-diagram-module.ts +++ b/packages/glsp-client/src/browser/crossmodel-diagram-module.ts @@ -8,6 +8,7 @@ import { GLSPMousePositionTracker, GlspCommandPalette, LogLevel, + MetadataPlacer, MouseDeleteTool, StatusOverlay, TYPES, @@ -18,6 +19,7 @@ import { } from '@eclipse-glsp/client'; import { GlspSelectionDataService } from '@eclipse-glsp/theia-integration'; import { ContainerModule, injectable, interfaces } from '@theia/core/shared/inversify'; +import { CmMetadataPlacer } from './cm-metadata-placer'; import { CrossModelCommandPalette, CrossModelMousePositionTracker } from './cross-model-command-palette'; import { CrossModelMouseDeleteTool } from './cross-model-delete-tool'; import { CrossModelDiagramStartup } from './cross-model-diagram-startup'; @@ -51,6 +53,7 @@ export function createCrossModelDiagramModule(registry: interfaces.ContainerModu bindOrRebind(context, TYPES.IToolManager).toService(CrossModelToolManager); bindAsService(bind, TYPES.IUIExtension, CrossModelErrorExtension); + rebind(MetadataPlacer).to(CmMetadataPlacer).inSingletonScope(); }); } diff --git a/packages/glsp-client/style/diagram.css b/packages/glsp-client/style/diagram.css index 5d17fc70..7d7ae2c8 100644 --- a/packages/glsp-client/style/diagram.css +++ b/packages/glsp-client/style/diagram.css @@ -137,6 +137,9 @@ color: var(--theia-activityBarBadge-foreground); } */ +.tool-palette .group-header { + display: none; +} .command-palette { animation: none; } diff --git a/packages/react-model-ui/src/views/form/Header.tsx b/packages/react-model-ui/src/views/form/Header.tsx index ad00fb87..6a07117e 100644 --- a/packages/react-model-ui/src/views/form/Header.tsx +++ b/packages/react-model-ui/src/views/form/Header.tsx @@ -27,7 +27,7 @@ export function Header({ name, id, iconClass }: HeaderProps): React.ReactElement {iconClass && } - + {name} {saveModel && dirty ? '*' : ''} diff --git a/yarn.lock b/yarn.lock index 20dbb6f0..d39d0074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1082,10 +1082,10 @@ snabbdom "~3.5.1" vscode-jsonrpc "8.2.0" -"@eclipse-glsp/glsp-playwright@2.2.1": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/glsp-playwright/-/glsp-playwright-2.2.1.tgz#2a66256f28d1165632f68d4bfbb74dd238ea1ecd" - integrity sha512-xtrdOxE965Nnq0fB9ciVK4kcZyOWLkGnfQ57CypCzUfbmZL0S6emlRMbx+27s5ShO+vBjDgWysJQzcVFdFIG2w== +"@eclipse-glsp/glsp-playwright@ 2.3.0-next.6": + version "2.3.0-next.6" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/glsp-playwright/-/glsp-playwright-2.3.0-next.6.tgz#3b555b5bf123888723b09b40e449275fe34df81f" + integrity sha512-JYoEj8Gx2svu350s3kGFNFLHPmeZkdEcqRzLykAef4Vb4Nx2gPeW2PpdBcE2pL1Wt7pc58mhDiZ5PR57+/OROw== dependencies: "@vscode/test-electron" "^2.3.2" uuid "^9.0.0" @@ -14568,6 +14568,11 @@ ts-api-utils@^1.0.1: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331" integrity sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg== +ts-dedent@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" + integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== + ts-jest@^29.1.1: version "29.1.1" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.1.tgz#f58fe62c63caf7bfcc5cc6472082f79180f0815b"