Skip to content

Commit

Permalink
Introduce form to edit Relationship data and attributes (#54)
Browse files Browse the repository at this point in the history
* Introduce form to edit Relationship data and attributes

Introduce reference resolution and completion mechanism for elements
- Ensure we send the global id as part of each identifiable element
- Attach reference data to GLSP elements for property view
- Replace specific 'requestDiagramNodeEntityModel' method
- Replace specific 'findRootReferenceName' method

Create Form for Relationship data
- Unify form for property view and editor (also for Entity)
- Use MUI components consistently and add theming for them
- Remove custom stylesheets
- Remove dependency to 'react-tabs' as it is no longer used

Refactorings:
- Rename 'ExternalId' to 'GlobalId'
- Ensure IDs do not contain any invalid characters
- Fix issue with attribute serialization for relationships

* Allow more Node memory usage

* Moved node memory setting to crossmodel-app prepare (since issue occurs there).

* Changed export env to use cross-env.

* Updated node options settings.

* Added back to memory setting in the workflow, since it's not being picked up on mac.

* Added name to ExampleCRM relationship.
Added example theia settings file to disable autosave in this workspace.

* PR Feedback

- Remove @mui/lab
- Introduce dirty state into the Header part for properties
- Disable attribute move up/down when appropriate
- Warn user before unsaved changes are lost
- Further unify editor and property widget for save mechanism
- Overwrite Theia property widget to delegate Save command (Ctrl+S)

* PR Feedback: Adapt for undefined handling in MUI

---------

Co-authored-by: Harmen Wessels <97173058+harmen-xb@users.noreply.github.com>
  • Loading branch information
martin-fleck-at and harmen-xb committed Apr 18, 2024
1 parent d6e9ef8 commit f0b6dc8
Show file tree
Hide file tree
Showing 75 changed files with 2,226 additions and 1,263 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/build-and-test.yml
Expand Up @@ -11,6 +11,8 @@ defaults:

jobs:
build-and-test:
env:
NODE_OPTIONS: --max_old_space_size=8192
name: ${{ matrix.os }}

strategy:
Expand Down
2 changes: 1 addition & 1 deletion applications/electron-app/package.json
Expand Up @@ -25,7 +25,7 @@
"package:post": "rimraf plugins/crossmodel-lang* && yarn --cwd ../../extensions/crossmodel-lang symlink",
"package:pre": "rimraf dist && rimraf plugins/crossmodel-lang && yarn package:extensions",
"package:preview": "yarn package:pre && electron-builder -c.mac.identity=null --dir && yarn package:post",
"prepare": "theia build --mode development && yarn download:plugins",
"prepare": "cross-env NODE_OPTIONS=--max-old-space-size=8192 theia build --mode development && yarn download:plugins",
"rebuild": "theia rebuild:electron --cacheRoot ../..",
"start": "cross-env NODE_ENV=development theia start --plugins=local-dir:plugins",
"test": "jest --passWithNoTests",
Expand Down
3 changes: 3 additions & 0 deletions examples/mapping-example/.theia/settings.json
@@ -0,0 +1,3 @@
{
"files.autoSave": "off"
}
@@ -1,8 +1,9 @@
relationship:
id: Address_Customer
name: "Address - Customer"
parent: Customer
child: ExampleCRM.Address
type: "1:1"
attributes:
- parent: Customer.Id
child: ExampleCRM.Address.CustomerID
- parent: Customer.Id
child: ExampleCRM.Address.CustomerID
@@ -1,8 +1,9 @@
relationship:
id: Order_Customer
name: "Order - Customer"
parent: Customer
child: Order
type: "1:1"
type: "1:n"
attributes:
- parent: Customer.Id
child: Order.CustomerId
Expand Up @@ -5,45 +5,24 @@ import { AddSourceObjectOperation } from '@crossbreeze/protocol';
import { LabeledAction } from '@eclipse-glsp/protocol';
import { Args, CommandPaletteActionProvider, GModelElement, Point } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { AstNodeDescription } from 'langium';
import { codiconCSSString } from 'sprotty';
import { PackageExternalAstNodeDescription, isExternalDescriptionForLocalPackage } from '../../../language-server/cross-model-scope.js';
import { createSourceObjectReference } from '../../../language-server/util/ast-util.js';
import { SourceObject } from '../../../language-server/generated/ast.js';
import { MappingModelState } from '../model/mapping-model-state.js';

@injectable()
export class MappingDiagramCommandPaletteActionProvider extends CommandPaletteActionProvider {
@inject(MappingModelState) protected state!: MappingModelState;

getPaletteActions(_selectedElementIds: string[], _selectedElements: GModelElement[], position: Point, args?: Args): LabeledAction[] {
const scopeProvider = this.state.services.language.references.ScopeProvider;
const refInfo = createSourceObjectReference(this.state.mapping);
const actions: LabeledAction[] = [];
const scope = scopeProvider.getScope(refInfo);
const duplicateStore = new Set<string>();

const externalTargetId = this.state.idProvider.getExternalId(this.state.mapping.target.entity.ref);
const localTargetId = this.state.idProvider.getLocalId(this.state.mapping.target.entity.ref);
scope.getAllElements().forEach(description => {
if (
!duplicateStore.has(description.name) &&
!isExternalDescriptionForLocalPackage(description, this.state.packageId) &&
!this.isTargetDescription(description, localTargetId, externalTargetId)
) {
actions.push({
label: description.name,
actions: [AddSourceObjectOperation.create(description.name, position || Point.ORIGIN)],
icon: codiconCSSString('inspect')
});
duplicateStore.add(description.name);
}
const completionItems = this.state.services.language.references.ScopeProvider.complete({
container: { globalId: this.state.mapping.id! },
syntheticElements: [{ property: 'sources', type: SourceObject }],
property: 'entity'
});
return actions;
}

protected isTargetDescription(description: AstNodeDescription, localTargetName?: string, externalTargetName?: string): boolean {
return description instanceof PackageExternalAstNodeDescription
? !!externalTargetName && description.name === externalTargetName
: !!localTargetName && description.name === localTargetName;
return completionItems.map<LabeledAction>(item => ({
label: item.label,
actions: [AddSourceObjectOperation.create(item.label, position || Point.ORIGIN)],
icon: codiconCSSString('inspect')
}));
}
}
Expand Up @@ -5,7 +5,8 @@
import { AddSourceObjectOperation } from '@crossbreeze/protocol';
import { Command, JsonOperationHandler, ModelState } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { createSourceObject, createSourceObjectReference } from '../../../language-server/util/ast-util.js';
import { SourceObject } from '../../../language-server/generated/ast.js';
import { createSourceObject } from '../../../language-server/util/ast-util.js';
import { CrossModelCommand } from '../../common/cross-model-command.js';
import { MappingModelState } from '../model/mapping-model-state.js';

Expand All @@ -23,10 +24,12 @@ export class MappingDiagramAddSourceObjectOperationHandler extends JsonOperation

protected async addSourceObject(operation: AddSourceObjectOperation): Promise<void> {
const container = this.modelState.mapping;
const refInfo = createSourceObjectReference(container);
const scope = this.modelState.services.language.references.ScopeProvider.getScope(refInfo);
const entityDescription = scope.getElement(operation.entityName);

const scope = this.modelState.services.language.references.ScopeProvider.getCompletionScope({
container: { globalId: this.modelState.mapping.id! },
syntheticElements: [{ property: 'sources', type: SourceObject }],
property: 'entity'
});
const entityDescription = scope.elementScope.getElement(operation.entityName);
if (entityDescription) {
const sourceObject = createSourceObject(entityDescription, container, this.modelState.idProvider);
container.sources.push(sourceObject);
Expand Down
Expand Up @@ -112,7 +112,7 @@ export class GTargetObjectNodeBuilder extends GNodeBuilder<GTargetObjectNode> {
this.addCssClasses('diagram-node', 'target-node');

// Add the label/name of the node
this.add(createHeader(node.entity?.ref?.name || 'unresolved', this.proxy.id));
this.add(createHeader(node.entity?.ref?.name || node.entity?.ref?.id || 'unresolved', this.proxy.id));

// Add the children of the node
const attributes = getAttributes(node);
Expand Down
Expand Up @@ -6,8 +6,7 @@ import { EditorContext, LabeledAction } from '@eclipse-glsp/protocol';
import { ContextActionsProvider, ModelState, Point } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { codiconCSSString } from 'sprotty';
import { isExternalDescriptionForLocalPackage } from '../../../language-server/cross-model-scope.js';
import { createEntityNodeReference } from '../../../language-server/util/ast-util.js';
import { EntityNode } from '../../../language-server/generated/ast.js';
import { SystemModelState } from '../model/system-model-state.js';

/**
Expand All @@ -21,23 +20,15 @@ export class SystemDiagramAddEntityActionProvider implements ContextActionsProvi
@inject(ModelState) protected state!: SystemModelState;

async getActions(editorContext: EditorContext): Promise<LabeledAction[]> {
const scopeProvider = this.state.services.language.references.ScopeProvider;
const refInfo = createEntityNodeReference(this.state.systemDiagram);
const actions: LabeledAction[] = [];
const scope = scopeProvider.getScope(refInfo);
const duplicateStore = new Set<string>();

scope.getAllElements().forEach(description => {
if (!duplicateStore.has(description.name) && !isExternalDescriptionForLocalPackage(description, this.state.packageId)) {
actions.push({
label: description.name,
actions: [AddEntityOperation.create(description.name, editorContext.lastMousePosition || Point.ORIGIN)],
icon: codiconCSSString('inspect')
});
duplicateStore.add(description.name);
}
const completionItems = this.state.services.language.references.ScopeProvider.complete({
container: { globalId: this.state.systemDiagram.id! },
syntheticElements: [{ property: 'nodes', type: EntityNode }],
property: 'entity'
});

return actions;
return completionItems.map<LabeledAction>(item => ({
label: item.label,
actions: [AddEntityOperation.create(item.label, editorContext.lastMousePosition || Point.ORIGIN)],
icon: codiconCSSString('inspect')
}));
}
}
Expand Up @@ -6,7 +6,6 @@ import { AddEntityOperation } from '@crossbreeze/protocol';
import { Command, JsonOperationHandler, ModelState } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { Entity, EntityNode } from '../../../language-server/generated/ast.js';
import { createEntityNodeReference } from '../../../language-server/util/ast-util.js';
import { CrossModelCommand } from '../../common/cross-model-command.js';
import { SystemModelState } from '../model/system-model-state.js';

Expand All @@ -23,10 +22,14 @@ export class SystemDiagramAddEntityOperationHandler extends JsonOperationHandler
}

protected async createEntityNode(operation: AddEntityOperation): Promise<void> {
const scope = this.modelState.services.language.references.ScopeProvider.getCompletionScope({
container: { globalId: this.modelState.systemDiagram.id! },
syntheticElements: [{ property: 'nodes', type: EntityNode }],
property: 'entity'
});

const container = this.modelState.systemDiagram;
const refInfo = createEntityNodeReference(container);
const scope = this.modelState.services.language.references.ScopeProvider.getScope(refInfo);
const entityDescription = scope.getElement(operation.entityName);
const entityDescription = scope.elementScope.getElement(operation.entityName);

if (entityDescription) {
const node: EntityNode = {
Expand Down
Expand Up @@ -44,7 +44,7 @@ export class SystemDiagramCreateEdgeOperationHandler extends JsonCreateEdgeOpera
id: this.modelState.idProvider.findNextId(RelationshipEdge, relationship.id, this.modelState.systemDiagram),
relationship: {
ref: relationship,
$refText: this.modelState.idProvider.getExternalId(relationship) || relationship.id || ''
$refText: this.modelState.idProvider.getGlobalId(relationship) || relationship.id || ''
},
sourceNode: {
ref: sourceNode,
Expand Down
Expand Up @@ -38,7 +38,7 @@ export class SystemDiagramDropEntityOperationHandler extends JsonOperationHandle
$container: container,
id: this.modelState.idProvider.findNextId(EntityNode, root.entity.id + 'Node', this.modelState.systemDiagram),
entity: {
$refText: this.modelState.idProvider.getExternalId(root.entity) || root.entity.id || '',
$refText: this.modelState.idProvider.getGlobalId(root.entity) || root.entity.id || '',
ref: root.entity
},
x: (x += 10),
Expand Down
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/

import { RELATIONSHIP_EDGE_TYPE } from '@crossbreeze/protocol';
import { REFERENCE_CONTAINER_TYPE, REFERENCE_PROPERTY, REFERENCE_VALUE, RELATIONSHIP_EDGE_TYPE } from '@crossbreeze/protocol';
import { GEdge, GEdgeBuilder } from '@eclipse-glsp/server';
import { RelationshipEdge } from '../../../language-server/generated/ast.js';
import { SystemModelIndex } from './system-model-index.js';
Expand All @@ -20,6 +20,9 @@ export class GRelationshipEdgeBuilder extends GEdgeBuilder<GRelationshipEdge> {
this.id(index.createId(edge));
this.addCssClasses('diagram-edge', 'relationship');
this.addArg('edgePadding', 5);
this.addArg(REFERENCE_CONTAINER_TYPE, RelationshipEdge);
this.addArg(REFERENCE_PROPERTY, 'relationship');
this.addArg(REFERENCE_VALUE, edge.relationship.$refText);

const sourceId = index.createId(edge.sourceNode?.ref);
const targetId = index.createId(edge.targetNode?.ref);
Expand Down
@@ -1,7 +1,7 @@
/********************************************************************************
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/
import { ENTITY_NODE_TYPE } from '@crossbreeze/protocol';
import { ENTITY_NODE_TYPE, REFERENCE_CONTAINER_TYPE, REFERENCE_PROPERTY, REFERENCE_VALUE } from '@crossbreeze/protocol';
import { ArgsUtil, GNode, GNodeBuilder } from '@eclipse-glsp/server';
import { EntityNode } from '../../../language-server/generated/ast.js';
import { getAttributes } from '../../../language-server/util/ast-util.js';
Expand All @@ -25,9 +25,12 @@ export class GEntityNodeBuilder extends GNodeBuilder<GEntityNode> {

// Options which are the same for every node
this.addCssClasses('diagram-node', 'entity');
this.addArg(REFERENCE_CONTAINER_TYPE, EntityNode);
this.addArg(REFERENCE_PROPERTY, 'entity');
this.addArg(REFERENCE_VALUE, node.entity.$refText);

// Add the label/name of the node
this.add(createHeader(entityRef?.name || 'unresolved', this.proxy.id));
this.add(createHeader(entityRef?.name || entityRef?.id || 'unresolved', this.proxy.id));

// Add the children of the node
const attributes = getAttributes(node);
Expand Down
Expand Up @@ -17,8 +17,7 @@ import { v4 as uuid } from 'uuid';
import { CompletionItemKind, InsertTextFormat } from 'vscode-languageserver-protocol';
import type { Range } from 'vscode-languageserver-types';
import { CrossModelServices } from './cross-model-module.js';
import { isExternalDescriptionForLocalPackage } from './cross-model-scope.js';
import { RelationshipAttribute, isRelationshipAttribute } from './generated/ast.js';
import { RelationshipAttribute } from './generated/ast.js';
import { fixDocument } from './util/ast-util.js';

/**
Expand All @@ -32,7 +31,7 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
};

constructor(
services: CrossModelServices,
protected services: CrossModelServices,
protected packageManager = services.shared.workspace.PackageManager
) {
super(services);
Expand Down Expand Up @@ -195,15 +194,12 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
}

protected override filterCrossReference(context: CompletionContext, description: AstNodeDescription): boolean {
if (isRelationshipAttribute(context.node)) {
return this.filterRelationshipAttribute(context.node, context, description);
}
if (isExternalDescriptionForLocalPackage(description, this.packageId)) {
// we want to keep fully qualified names in the scope so we can do proper linking
// but want to hide it from the user for local options, i.e., if we are in the same project we can skip the project name
return false;
}
return super.filterCrossReference(context, description);
return this.services.references.ScopeProvider.filterCompletion(
description,
this.packageId!,
context.node,
context.features[context.features.length - 1].property
);
}

protected filterRelationshipAttribute(node: RelationshipAttribute, context: CompletionContext, desc: AstNodeDescription): boolean {
Expand Down
@@ -0,0 +1,35 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/
import { AstNode, AstNodeDescription, DefaultIndexManager, URI } from 'langium';
import { CrossModelSharedServices } from './cross-model-module.js';
import { SemanticRoot, findSemanticRoot } from './util/ast-util.js';

export class CrossModelIndexManager extends DefaultIndexManager {
constructor(protected services: CrossModelSharedServices) {
super(services);
}

getElementById(globalId: string, type?: string): AstNodeDescription | undefined {
return this.allElements().find(desc => desc.name === globalId && (!type || desc.type === type));
}

resolveElement(description?: AstNodeDescription): AstNode | undefined {
if (!description) {
return undefined;
}
const document = this.services.workspace.LangiumDocuments.getDocument(description.documentUri);
return document
? this.serviceRegistry.getServices(document.uri).workspace.AstNodeLocator.getAstNode(document.parseResult.value, description.path)
: undefined;
}

resolveElementById(globalId: string, type?: string): AstNode | undefined {
return this.resolveElement(this.getElementById(globalId, type));
}

resolveSemanticElement(uri: URI): SemanticRoot | undefined {
const document = this.services.workspace.LangiumDocuments.getDocument(uri);
return document ? findSemanticRoot(document) : undefined;
}
}

0 comments on commit f0b6dc8

Please sign in to comment.