diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3c79464..00f85c4 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -11,6 +11,8 @@ defaults: jobs: build-and-test: + env: + NODE_OPTIONS: --max_old_space_size=8192 name: ${{ matrix.os }} strategy: diff --git a/applications/electron-app/package.json b/applications/electron-app/package.json index 3fa25fa..72ecd7f 100644 --- a/applications/electron-app/package.json +++ b/applications/electron-app/package.json @@ -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", diff --git a/examples/mapping-example/.theia/settings.json b/examples/mapping-example/.theia/settings.json new file mode 100644 index 0000000..7952073 --- /dev/null +++ b/examples/mapping-example/.theia/settings.json @@ -0,0 +1,3 @@ +{ + "files.autoSave": "off" +} \ No newline at end of file diff --git a/examples/mapping-example/Sources/ExampleCRM/relationships/Address_Customer.relationship.cm b/examples/mapping-example/Sources/ExampleCRM/relationships/Address_Customer.relationship.cm index a0f8227..683fdc9 100644 --- a/examples/mapping-example/Sources/ExampleCRM/relationships/Address_Customer.relationship.cm +++ b/examples/mapping-example/Sources/ExampleCRM/relationships/Address_Customer.relationship.cm @@ -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 \ No newline at end of file + - parent: Customer.Id + child: ExampleCRM.Address.CustomerID \ No newline at end of file diff --git a/examples/mapping-example/Sources/ExampleCRM/relationships/Order_Customer.relationship.cm b/examples/mapping-example/Sources/ExampleCRM/relationships/Order_Customer.relationship.cm index 65e20a8..70f955d 100644 --- a/examples/mapping-example/Sources/ExampleCRM/relationships/Order_Customer.relationship.cm +++ b/examples/mapping-example/Sources/ExampleCRM/relationships/Order_Customer.relationship.cm @@ -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 \ No newline at end of file diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/command-palette/add-source-object-action-provider.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/command-palette/add-source-object-action-provider.ts index 14daec2..e3b6fa2 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/command-palette/add-source-object-action-provider.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/command-palette/add-source-object-action-provider.ts @@ -5,10 +5,8 @@ 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() @@ -16,34 +14,15 @@ export class MappingDiagramCommandPaletteActionProvider extends CommandPaletteAc @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(); - - 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(item => ({ + label: item.label, + actions: [AddSourceObjectOperation.create(item.label, position || Point.ORIGIN)], + icon: codiconCSSString('inspect') + })); } } diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/add-source-object-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/add-source-object-operation-handler.ts index 55f27b4..af99a72 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/add-source-object-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/add-source-object-operation-handler.ts @@ -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'; @@ -23,10 +24,12 @@ export class MappingDiagramAddSourceObjectOperationHandler extends JsonOperation protected async addSourceObject(operation: AddSourceObjectOperation): Promise { 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); diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/nodes.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/nodes.ts index 27b4af1..58df0eb 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/nodes.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/nodes.ts @@ -112,7 +112,7 @@ export class GTargetObjectNodeBuilder extends GNodeBuilder { 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); diff --git a/extensions/crossmodel-lang/src/glsp-server/system-diagram/command-palette/add-entity-action-provider.ts b/extensions/crossmodel-lang/src/glsp-server/system-diagram/command-palette/add-entity-action-provider.ts index a93cc94..3ec89c8 100644 --- a/extensions/crossmodel-lang/src/glsp-server/system-diagram/command-palette/add-entity-action-provider.ts +++ b/extensions/crossmodel-lang/src/glsp-server/system-diagram/command-palette/add-entity-action-provider.ts @@ -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'; /** @@ -21,23 +20,15 @@ export class SystemDiagramAddEntityActionProvider implements ContextActionsProvi @inject(ModelState) protected state!: SystemModelState; async getActions(editorContext: EditorContext): Promise { - 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(); - - 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(item => ({ + label: item.label, + actions: [AddEntityOperation.create(item.label, editorContext.lastMousePosition || Point.ORIGIN)], + icon: codiconCSSString('inspect') + })); } } diff --git a/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/add-entity-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/add-entity-operation-handler.ts index 77509b8..be968ad 100644 --- a/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/add-entity-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/add-entity-operation-handler.ts @@ -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'; @@ -23,10 +22,14 @@ export class SystemDiagramAddEntityOperationHandler extends JsonOperationHandler } protected async createEntityNode(operation: AddEntityOperation): Promise { + 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 = { diff --git a/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/create-edge-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/create-edge-operation-handler.ts index 2150476..f5e73e7 100644 --- a/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/create-edge-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/create-edge-operation-handler.ts @@ -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, diff --git a/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/drop-entity-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/drop-entity-operation-handler.ts index 43cb8e1..7656151 100644 --- a/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/drop-entity-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/system-diagram/handler/drop-entity-operation-handler.ts @@ -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), diff --git a/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/edges.ts b/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/edges.ts index c9f78ea..5278dd6 100644 --- a/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/edges.ts +++ b/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/edges.ts @@ -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'; @@ -20,6 +20,9 @@ export class GRelationshipEdgeBuilder extends GEdgeBuilder { 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); diff --git a/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/nodes.ts b/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/nodes.ts index 6188e63..0a507c5 100644 --- a/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/nodes.ts +++ b/extensions/crossmodel-lang/src/glsp-server/system-diagram/model/nodes.ts @@ -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'; @@ -25,9 +25,12 @@ export class GEntityNodeBuilder extends GNodeBuilder { // 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); diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-completion-provider.ts b/extensions/crossmodel-lang/src/language-server/cross-model-completion-provider.ts index e18b567..e17044d 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-completion-provider.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-completion-provider.ts @@ -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'; /** @@ -32,7 +31,7 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider { }; constructor( - services: CrossModelServices, + protected services: CrossModelServices, protected packageManager = services.shared.workspace.PackageManager ) { super(services); @@ -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 { diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-index-manager.ts b/extensions/crossmodel-lang/src/language-server/cross-model-index-manager.ts new file mode 100644 index 0000000..86f51c8 --- /dev/null +++ b/extensions/crossmodel-lang/src/language-server/cross-model-index-manager.ts @@ -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; + } +} diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-langium-documents.ts b/extensions/crossmodel-lang/src/language-server/cross-model-langium-documents.ts index 708b5b9..80818d8 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-langium-documents.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-langium-documents.ts @@ -1,15 +1,51 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { DefaultLangiumDocuments } from 'langium'; +import { DefaultLangiumDocuments, DocumentState, LangiumDocument } from 'langium'; +import { TextDocument } from 'vscode-languageserver-textdocument'; import { URI } from 'vscode-uri'; import { isPackageUri } from './cross-model-package-manager.js'; +import { CrossModelRoot } from './generated/ast.js'; +import { fixDocument } from './util/ast-util.js'; import { Utils } from './util/uri-util.js'; export class CrossModelLangiumDocuments extends DefaultLangiumDocuments { override getOrCreateDocument(uri: URI): any { - // only create documents for actual language files but not for package.json - const realUri = isPackageUri(uri) ? undefined : Utils.toRealURIorUndefined(uri); - return realUri ? super.getOrCreateDocument(realUri) : undefined; + const document = this.getDocument(uri); + if (document) { + return document; + } + const documentUri = this.getDocumentUri(uri); + if (documentUri) { + return super.getOrCreateDocument(documentUri); + } + return this.createEmptyDocument(uri); + } + + protected getDocumentUri(uri: URI): URI | undefined { + // we register for package.json files because our package scoping mechanism depends on it + // but we do not want actually want to parse them + if (isPackageUri(uri)) { + return undefined; + } + // we want to resolve existing URIs to properly deal with linked files and folders and not create duplicates for them + return Utils.toRealURIorUndefined(uri); + } + + createEmptyDocument(uri: URI, rootType = CrossModelRoot): LangiumDocument { + const document: LangiumDocument = { + uri, + parseResult: { lexerErrors: [], parserErrors: [], value: { $type: rootType } }, + references: [], + state: DocumentState.Validated, + textDocument: TextDocument.create(uri.toString(), '', 1, ''), + diagnostics: [] + }; + fixDocument(document.parseResult.value, document); + return document; + } + + getDocument(uri: URI): LangiumDocument | undefined { + return this.documentMap.get(uri.toString()) as LangiumDocument | undefined; } } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-module.ts b/extensions/crossmodel-lang/src/language-server/cross-model-module.ts index a6893b1..de2cc6f 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-module.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-module.ts @@ -27,6 +27,7 @@ import { ClientLogger } from './cross-model-client-logger.js'; import { CrossModelCompletionProvider } from './cross-model-completion-provider.js'; import { CrossModelDocumentBuilder } from './cross-model-document-builder.js'; import { CrossModelModelFormatter } from './cross-model-formatter.js'; +import { CrossModelIndexManager } from './cross-model-index-manager.js'; import { CrossModelLangiumDocuments } from './cross-model-langium-documents.js'; import { CrossModelLanguageServer } from './cross-model-language-server.js'; import { DefaultIdProvider } from './cross-model-naming.js'; @@ -77,6 +78,8 @@ export interface CrossModelAddedSharedServices { workspace: { /* override */ WorkspaceManager: CrossModelWorkspaceManager; PackageManager: CrossModelPackageManager; + LangiumDocuments: CrossModelLangiumDocuments; + IndexManager: CrossModelIndexManager; }; logger: { ClientLogger: ClientLogger; @@ -89,7 +92,9 @@ export interface CrossModelAddedSharedServices { export const CrossModelSharedServices = Symbol('CrossModelSharedServices'); export type CrossModelSharedServices = Omit & CrossModelAddedSharedServices & - AddedSharedModelServices; + AddedSharedModelServices & { + CrossModel: CrossModelServices; + }; export const CrossModelSharedModule: Module< CrossModelSharedServices, @@ -102,7 +107,8 @@ export const CrossModelSharedModule: Module< LangiumDocuments: services => new CrossModelLangiumDocuments(services), TextDocuments: services => new OpenableTextDocuments(TextDocument, services), TextDocumentManager: services => new OpenTextDocumentManager(services), - DocumentBuilder: services => new CrossModelDocumentBuilder(services) + DocumentBuilder: services => new CrossModelDocumentBuilder(services), + IndexManager: services => new CrossModelIndexManager(services) }, logger: { ClientLogger: services => new ClientLogger(services) @@ -130,6 +136,7 @@ export interface CrossModelAddedServices { references: { IdProvider: DefaultIdProvider; Linker: CrossModelLinker; + ScopeProvider: CrossModelScopeProvider; }; validation: { CrossModelValidator: CrossModelValidator; @@ -204,6 +211,7 @@ export function createCrossModelServices(context: DefaultSharedModuleContext): { const shared = inject(createDefaultSharedModule(context), CrossModelGeneratedSharedModule, CrossModelSharedModule); const CrossModel = inject(createDefaultModule({ shared }), CrossModelGeneratedModule, createCrossModelModule({ shared })); shared.ServiceRegistry.register(CrossModel); + shared.CrossModel = CrossModel; registerValidationChecks(CrossModel); return { shared, CrossModel }; } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-naming.ts b/extensions/crossmodel-lang/src/language-server/cross-model-naming.ts index e5c8c80..1628a10 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-naming.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-naming.ts @@ -28,7 +28,7 @@ export function getId(node?: AstNode): string | undefined { export interface IdProvider extends NameProvider { getNodeId(node?: AstNode): string | undefined; getLocalId(node?: AstNode): string | undefined; - getExternalId(node?: AstNode): string | undefined; + getGlobalId(node?: AstNode): string | undefined; findNextId(type: string, proposal: string | undefined): string; findNextId(type: string, proposal: string | undefined, container: AstNode): string; @@ -95,7 +95,7 @@ export class DefaultIdProvider implements NameProvider, IdProvider { * @param packageName package name * @returns fully qualified, package-local name */ - getExternalId(node?: AstNode, packageName = this.getPackageName(node)): string | undefined { + getGlobalId(node?: AstNode, packageName = this.getPackageName(node)): string | undefined { const localId = this.getLocalId(node); if (!localId) { return undefined; @@ -110,7 +110,7 @@ export class DefaultIdProvider implements NameProvider, IdProvider { } getName(node?: AstNode): string | undefined { - return node ? this.getExternalId(node) : undefined; + return node ? this.getGlobalId(node) : undefined; } getNameNode(node: AstNode): CstNode | undefined { @@ -121,9 +121,9 @@ export class DefaultIdProvider implements NameProvider, IdProvider { findNextId(type: string, proposal: string | undefined, container: AstNode): string; findNextId(type: string, proposal: string | undefined, container?: AstNode): string { if (isAstNode(container)) { - return this.findNextIdInContainer(type, proposal ?? 'Element', container); + return this.findNextIdInContainer(type, proposal?.replaceAll('.', '_') ?? 'Element', container); } - return this.findNextIdInIndex(type, proposal ?? 'Element'); + return this.findNextIdInIndex(type, proposal?.replaceAll('.', '_') ?? 'Element'); } protected getParent(node: AstNode): AstNode | undefined { diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-scope-provider.ts b/extensions/crossmodel-lang/src/language-server/cross-model-scope-provider.ts index aa702c3..7ad83e7 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-scope-provider.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-scope-provider.ts @@ -1,10 +1,29 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { AstNodeDescription, DefaultScopeProvider, EMPTY_SCOPE, getDocument, ReferenceInfo, Scope, StreamScope } from 'langium'; +import { + CrossReference, + CrossReferenceContainer, + CrossReferenceContext, + ReferenceableElement, + isGlobalElementReference, + isRootElementReference, + isSyntheticDocument +} from '@crossbreeze/protocol'; +import { + AstNode, + AstNodeDescription, + DefaultScopeProvider, + EMPTY_SCOPE, + ReferenceInfo, + Scope, + StreamScope, + URI, + getDocument +} from 'langium'; import { CrossModelServices } from './cross-model-module.js'; -import { PackageAstNodeDescription, PackageExternalAstNodeDescription } from './cross-model-scope.js'; -import { isAttributeMapping } from './generated/ast.js'; +import { GlobalAstNodeDescription, PackageAstNodeDescription, isGlobalDescriptionForLocalPackage } from './cross-model-scope.js'; +import { isAttributeMapping, isRelationshipAttribute, isSourceObject } from './generated/ast.js'; import { fixDocument } from './util/ast-util.js'; /** @@ -14,7 +33,8 @@ import { fixDocument } from './util/ast-util.js'; export class PackageScopeProvider extends DefaultScopeProvider { constructor( protected services: CrossModelServices, - protected packageManager = services.shared.workspace.PackageManager + protected packageManager = services.shared.workspace.PackageManager, + protected idProvider = services.references.IdProvider ) { super(services); } @@ -50,7 +70,7 @@ export class PackageScopeProvider extends DefaultScopeProvider { .getAllElements() .filter( description => - description instanceof PackageExternalAstNodeDescription && + description instanceof GlobalAstNodeDescription && this.packageManager.isVisible(sourcePackage, this.getPackageId(description)) ) ); @@ -67,6 +87,47 @@ export class PackageScopeProvider extends DefaultScopeProvider { } export class CrossModelScopeProvider extends PackageScopeProvider { + protected resolveCrossReferenceContainer(container: CrossReferenceContainer): AstNode | undefined { + if (isSyntheticDocument(container)) { + const document = this.services.shared.workspace.LangiumDocuments.createEmptyDocument(URI.parse(container.uri)); + return { $type: container.type, $container: document.parseResult.value }; + } + if (isRootElementReference(container)) { + return this.services.shared.workspace.IndexManager.resolveSemanticElement(URI.parse(container.uri)); + } + if (isGlobalElementReference(container)) { + return this.services.shared.workspace.IndexManager.resolveElementById(container.globalId, container.type); + } + return undefined; + } + + referenceContextToInfo(ctx: CrossReferenceContext): ReferenceInfo { + let container = this.resolveCrossReferenceContainer(ctx.container); + if (!container) { + throw Error('Invalid CrossReference Container'); + } + for (const segment of ctx.syntheticElements ?? []) { + container = { + $container: container, + $containerProperty: segment.property, + $type: segment.type + }; + } + const referenceInfo: ReferenceInfo = { + reference: { $refText: '' }, + container: container, + property: ctx.property + }; + return referenceInfo; + } + + resolveCrossReference(reference: CrossReference): AstNode | undefined { + const description = this.getScope(this.referenceContextToInfo(reference)) + .getAllElements() + .find(desc => desc.name === reference.value); + return this.services.shared.workspace.IndexManager.resolveElement(description); + } + override getScope(context: ReferenceInfo): Scope { try { return super.getScope(this.fixContext(context)); @@ -80,4 +141,56 @@ export class CrossModelScopeProvider extends PackageScopeProvider { fixDocument(context.container, context.container.$cstNode?.root.astNode.$document); return context; } + + getCompletionScope(ctx: CrossReferenceContext): CompletionScope { + const referenceInfo = this.referenceContextToInfo(ctx); + const packageId = this.packageManager.getPackageIdByDocument(getDocument(referenceInfo.container)); + const filteredDescriptions = this.getScope(referenceInfo) + .getAllElements() + .filter(description => this.filterCompletion(description, packageId, referenceInfo.container, referenceInfo.property)) + .distinct(description => description.name); + const elementScope = this.createScope(filteredDescriptions); + return { elementScope, source: referenceInfo }; + } + + complete(ctx: CrossReferenceContext): ReferenceableElement[] { + return this.getCompletionScope(ctx) + .elementScope.getAllElements() + .map(description => ({ + uri: description.documentUri.toString(), + type: description.type, + label: description.name + })) + .toArray(); + } + + filterCompletion(description: AstNodeDescription, packageId: string, container?: AstNode, property?: string): boolean { + if (isRelationshipAttribute(container)) { + // only show relevant attributes depending on the parent or child context + if (property === 'child') { + return description.name.startsWith(container.$container.child?.$refText + '.'); + } + if (property === 'parent') { + return description.name.startsWith(container.$container.parent?.$refText + '.'); + } + } + if (isSourceObject(container) && property === 'entity' && container.$container.target.entity.ref) { + const targetEntity = container.$container.target.entity.ref; + if (description instanceof GlobalAstNodeDescription) { + return description.name !== this.idProvider.getGlobalId(targetEntity); + } + return description.name !== this.idProvider.getLocalId(targetEntity); + } + if (isGlobalDescriptionForLocalPackage(description, 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 true; + } +} + +export interface CompletionScope { + source: ReferenceInfo; + elementScope: Scope; } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-scope.ts b/extensions/crossmodel-lang/src/language-server/cross-model-scope.ts index 9e3a7b5..1bb6242 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-scope.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-scope.ts @@ -49,20 +49,18 @@ export class PackageLocalAstNodeDescription extends PackageAstNodeDescription { /** * Custom class to represent package-external descriptions with the package name so we can do easier instanceof checks. */ -export class PackageExternalAstNodeDescription extends PackageAstNodeDescription { +export class GlobalAstNodeDescription extends PackageAstNodeDescription { constructor(packageName: string, name: string, delegate: AstNodeDescription) { super(packageName, name, delegate); } } -export function isExternalDescriptionForLocalPackage(description: AstNodeDescription, packageId?: string): boolean { - return packageId !== undefined && description instanceof PackageExternalAstNodeDescription && description.packageId === packageId; +export function isGlobalDescriptionForLocalPackage(description: AstNodeDescription, packageId?: string): boolean { + return packageId !== undefined && description instanceof GlobalAstNodeDescription && description.packageId === packageId; } export function getLocalName(description: AstNodeDescription): string { - return description instanceof PackageExternalAstNodeDescription - ? getLocalName(description.delegate) ?? description.name - : description.name; + return description instanceof GlobalAstNodeDescription ? getLocalName(description.delegate) ?? description.name : description.name; } /** @@ -107,9 +105,9 @@ export class CrossModelScopeComputation extends DefaultScopeComputation { exports.push(new PackageLocalAstNodeDescription(packageId, localId, description)); } - const externalId = this.idProvider.getExternalId(node, packageName); - if (externalId && description) { - exports.push(new PackageExternalAstNodeDescription(packageId, externalId, description)); + const globalId = this.idProvider.getGlobalId(node, packageName); + if (globalId && description) { + exports.push(new GlobalAstNodeDescription(packageId, globalId, description)); } } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts b/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts index e3d6b2e..b07161d 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts @@ -29,10 +29,10 @@ const PROPERTY_ORDER = [ 'datatype', 'description', 'entity', - 'attributes', 'parent', 'child', 'type', + 'attributes', 'nodes', 'edges', 'x', diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts b/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts index cc2b14f..3ce41ed 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts @@ -40,27 +40,27 @@ export class CrossModelValidator { constructor(protected services: CrossModelServices) {} checkNode(node: AstNode, accept: ValidationAcceptor): void { - this.checkUniqueExternalId(node, accept); + this.checkUniqueGlobalId(node, accept); this.checkUniqueNodeId(node, accept); } - protected checkUniqueExternalId(node: AstNode, accept: ValidationAcceptor): void { - if (!this.isExported(node)) { + protected checkUniqueGlobalId(node: AstNode, accept: ValidationAcceptor): void { + if (!this.isExportedGlobally(node)) { return; } - const externalId = this.services.references.IdProvider.getExternalId(node); - if (!externalId) { + const globalId = this.services.references.IdProvider.getGlobalId(node); + if (!globalId) { accept('error', 'Missing required id field', { node, property: ID_PROPERTY }); return; } const allElements = Array.from(this.services.shared.workspace.IndexManager.allElements()); - const duplicates = allElements.filter(description => description.name === externalId); + const duplicates = allElements.filter(description => description.name === globalId); if (duplicates.length > 1) { accept('error', 'Must provide a unique id.', { node, property: ID_PROPERTY }); } } - protected isExported(node: AstNode): boolean { + protected isExportedGlobally(node: AstNode): boolean { // we export anything with an id from entities and relationships and all root nodes, see CrossModelScopeComputation return isEntity(node) || isEntityAttribute(node) || isRelationship(node) || isSystemDiagram(node) || isMapping(node); } diff --git a/extensions/crossmodel-lang/src/language-server/util/ast-util.ts b/extensions/crossmodel-lang/src/language-server/util/ast-util.ts index 0a3e812..46bd5b0 100644 --- a/extensions/crossmodel-lang/src/language-server/util/ast-util.ts +++ b/extensions/crossmodel-lang/src/language-server/util/ast-util.ts @@ -2,16 +2,7 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { unquote } from '@crossbreeze/protocol'; -import { - AstNode, - AstNodeDescription, - LangiumDocument, - Reference, - ReferenceInfo, - findRootNode, - isAstNode, - isAstNodeDescription -} from 'langium'; +import { AstNode, AstNodeDescription, LangiumDocument, findRootNode, isAstNode, isAstNodeDescription } from 'langium'; import { ID_PROPERTY, IdProvider } from '../cross-model-naming.js'; import { getLocalName } from '../cross-model-scope.js'; import { @@ -98,36 +89,12 @@ export function isImplicitProperty(prop: string, obj: any): boolean { ); } -export function createEntityNodeReference(root: SystemDiagram): ReferenceInfo { - return { - reference: {} as Reference, - container: { - $type: EntityNode, - $container: root, - $containerProperty: 'nodes' - }, - property: 'entity' - }; -} - -export function createSourceObjectReference(root: Mapping): ReferenceInfo { - return { - reference: {} as Reference, - container: { - $type: SourceObject, - $container: root, - $containerProperty: 'sources' - }, - property: 'entity' - }; -} - export function createSourceObject(entity: Entity | AstNodeDescription, container: Mapping, idProvider: IdProvider): SourceObject { const entityId = isAstNodeDescription(entity) ? getLocalName(entity) : entity.id ?? idProvider.getLocalId(entity) ?? entity.name ?? 'unknown'; const ref = isAstNodeDescription(entity) ? undefined : entity; - const $refText = isAstNodeDescription(entity) ? entity.name : idProvider.getExternalId(entity) || entity.id || ''; + const $refText = isAstNodeDescription(entity) ? entity.name : idProvider.getGlobalId(entity) || entity.id || ''; return { $type: SourceObject, $container: container, @@ -218,7 +185,7 @@ export type TypeGuard = (item: unknown) => item is T; export function findSemanticRoot(input: DocumentContent): SemanticRoot | undefined; export function findSemanticRoot(input: DocumentContent, guard: TypeGuard): T | undefined; export function findSemanticRoot(input: DocumentContent, guard?: TypeGuard): SemanticRoot | T | undefined { - const root = isAstNode(input) ? input.$document?.parseResult.value ?? findRootNode(input) : input.parseResult.value; + const root = isAstNode(input) ? input.$document?.parseResult?.value ?? findRootNode(input) : input.parseResult?.value; const semanticRoot = isCrossModelRoot(root) ? root.entity ?? root.mapping ?? root.relationship ?? root.systemDiagram : undefined; return !semanticRoot ? undefined : !guard ? semanticRoot : guard(semanticRoot) ? semanticRoot : undefined; } diff --git a/extensions/crossmodel-lang/src/model-server/model-server.ts b/extensions/crossmodel-lang/src/model-server/model-server.ts index dbb3152..0e7405a 100644 --- a/extensions/crossmodel-lang/src/model-server/model-server.ts +++ b/extensions/crossmodel-lang/src/model-server/model-server.ts @@ -6,23 +6,26 @@ import { CloseModel, CloseModelArgs, CrossModelRoot, - FindRootReferenceName, - FindRootReferenceNameArgs, + CrossReference, + CrossReferenceContext, + FindReferenceableElements, OnSave, OnUpdated, OpenModel, OpenModelArgs, + ReferenceableElement, RequestModel, - RequestModelDiagramNode, + ResolveReference, + ResolvedElement, SaveModel, SaveModelArgs, UpdateModel, UpdateModelArgs } from '@crossbreeze/protocol'; -import { URI, isReference } from 'langium'; +import { AstNode, findRootNode, getDocument, isReference } from 'langium'; import { Disposable } from 'vscode-jsonrpc'; import * as rpc from 'vscode-jsonrpc/node.js'; -import { CrossModelRoot as CrossModelRootAst, DiagramNode, Entity, isCrossModelRoot } from '../language-server/generated/ast.js'; +import { isCrossModelRoot } from '../language-server/generated/ast.js'; import { ModelService } from './model-service.js'; @@ -45,55 +48,24 @@ export class ModelServer implements Disposable { this.toDispose.push(connection.onRequest(OpenModel, args => this.openModel(args))); this.toDispose.push(connection.onRequest(CloseModel, args => this.closeModel(args))); this.toDispose.push(connection.onRequest(RequestModel, uri => this.requestModel(uri))); - this.toDispose.push(connection.onRequest(RequestModelDiagramNode, (uri, id) => this.requestModelDiagramNode(uri, id))); - this.toDispose.push(connection.onRequest(FindRootReferenceName, args => this.findReferenceName(args))); + this.toDispose.push(connection.onRequest(FindReferenceableElements, args => this.complete(args))); + this.toDispose.push(connection.onRequest(ResolveReference, args => this.resolve(args))); this.toDispose.push(connection.onRequest(UpdateModel, args => this.updateModel(args))); this.toDispose.push(connection.onRequest(SaveModel, args => this.saveModel(args))); } - /** - * Returns the entity model of the selected node in the diagram. - * - * @param uri The uri of the opened diagram - * @param id The id of the selected node - * @returns { - * uri: of the entity model - * entity: model of the entity - * } - */ - async requestModelDiagramNode(uri: string, id: string): Promise { - const root = (await this.modelService.request(uri)) as CrossModelRootAst; - let diagramNode: DiagramNode | undefined; - - if (!root || !root.systemDiagram) { - throw new Error('Something went wrong loading the diagram'); - } - - for (const node of root.systemDiagram.nodes) { - if (this.modelService.getId(node, URI.parse(uri)) === id) { - diagramNode = node; - } - } - - const entity: Entity | undefined = diagramNode?.entity?.ref; - - if (!entity?.$container.$document) { - throw new Error('No node found with the given id: ' + id + ' (in ' + uri + ')'); - } - - const serializedEntity = toSerializable({ - $type: 'CrossModelRoot', - entity: entity - }) as CrossModelRoot; - - return { - uri: entity.$container.$document.uri.toString(), - model: serializedEntity - }; + protected complete(args: CrossReferenceContext): Promise { + return this.modelService.findReferenceableElements(args); } - protected async findReferenceName(args: FindRootReferenceNameArgs): Promise { - return this.modelService.findRootReferenceName(args); + protected async resolve(args: CrossReference): Promise { + const node = await this.modelService.resolveCrossReference(args); + if (!node) { + return undefined; + } + const uri = getDocument(node).uri.toString(); + const model = this.toSerializable(findRootNode(node)) as CrossModelRoot; + return { uri, model }; } protected async openModel(args: OpenModelArgs): Promise { @@ -111,14 +83,14 @@ export class ModelServer implements Disposable { this.modelService.onSave(args.uri, event => this.connection.sendNotification(OnSave, { uri: args.uri, - model: toSerializable(event.model) as CrossModelRoot, + model: this.toSerializable(event.model) as CrossModelRoot, sourceClientId: event.sourceClientId }) ), this.modelService.onUpdate(args.uri, event => this.connection.sendNotification(OnUpdated, { uri: args.uri, - model: toSerializable(event.model) as CrossModelRoot, + model: this.toSerializable(event.model) as CrossModelRoot, sourceClientId: event.sourceClientId, reason: event.reason }) @@ -139,12 +111,12 @@ export class ModelServer implements Disposable { protected async requestModel(uri: string): Promise { const root = await this.modelService.request(uri, isCrossModelRoot); - return toSerializable(root) as CrossModelRoot; + return this.toSerializable(root) as CrossModelRoot; } protected async updateModel(args: UpdateModelArgs): Promise { const updated = await this.modelService.update(args); - return toSerializable(updated) as CrossModelRoot; + return this.toSerializable(updated) as CrossModelRoot; } protected async saveModel(args: SaveModelArgs): Promise { @@ -154,50 +126,45 @@ export class ModelServer implements Disposable { dispose(): void { this.toDispose.forEach(disposable => disposable.dispose()); } -} -/** - * Cleans the semantic object of any property that cannot be serialized as a String and thus cannot be sent to the client - * over the RPC connection. - * - * @param obj semantic object - * @returns serializable semantic object - */ -export function toSerializable(obj?: T): T | undefined { - if (!obj) { - return; - } - // We remove all $ from the semantic object with the exception of type - // they are added by Langium but have no additional value on the client side - // Furthermore we ensure that for references we use their string representation ($refText) - // instead of their real value to avoid sending whole serialized object graphs - return Object.entries(obj) - .filter(([key, value]) => !key.startsWith('$') || key === '$type') - .reduce((acc, [key, value]) => ({ ...acc, [key]: cleanValue(value) }), {}); -} - -function cleanValue(value: any): any { - if (Array.isArray(value)) { - return value.map(cleanValue); - } else if (isContainedObject(value)) { - return toSerializable(value); - } else { - return resolvedValue(value); + /** + * Cleans the semantic object of any property that cannot be serialized as a String and thus cannot be sent to the client + * over the RPC connection. + * + * @param obj semantic object + * @returns serializable semantic object + */ + protected toSerializable(obj?: T): O | undefined { + if (!obj) { + return; + } + // We remove all $ from the semantic object with the exception of type + // they are added by Langium but have no additional value on the client side + // Furthermore we ensure that for references we use their string representation ($refText) + // instead of their real value to avoid sending whole serialized object graphs + return Object.entries(obj) + .filter(([key, value]) => !key.startsWith('$') || key === '$type') + .reduce((acc, [key, value]) => ({ ...acc, [key]: this.cleanValue(value) }), { $globalId: this.modelService.getGlobalId(obj) }); } -} -function isContainedObject(value: any): boolean { - return value === Object(value) && !isReference(value); -} + protected cleanValue(value: any): any { + if (Array.isArray(value)) { + return value.map(val => this.cleanValue(val)); + } else if (this.isContainedObject(value)) { + return this.toSerializable(value); + } else { + return this.resolvedValue(value); + } + } -function resolvedValue(value: any): any { - if (isReference(value)) { - return value.$refText; + protected isContainedObject(value: any): boolean { + return value === Object(value) && !isReference(value); } - return value; -} -interface DiagramNodeEntity { - uri: string; - model: CrossModelRoot | undefined; + protected resolvedValue(value: any): any { + if (isReference(value)) { + return value.$refText; + } + return value; + } } diff --git a/extensions/crossmodel-lang/src/model-server/model-service.ts b/extensions/crossmodel-lang/src/model-server/model-service.ts index 1d2430b..79738b6 100644 --- a/extensions/crossmodel-lang/src/model-server/model-service.ts +++ b/extensions/crossmodel-lang/src/model-server/model-service.ts @@ -4,19 +4,20 @@ import { CloseModelArgs, - FindRootReferenceNameArgs, + CrossReference, + CrossReferenceContext, ModelSavedEvent, ModelUpdatedEvent, OpenModelArgs, + ReferenceableElement, SaveModelArgs, UpdateModelArgs } from '@crossbreeze/protocol'; -import { AstNode, Deferred, DocumentState, LangiumDocument, isAstNode } from 'langium'; +import { AstNode, Deferred, DocumentState, isAstNode } from 'langium'; import { Disposable, OptionalVersionedTextDocumentIdentifier, Range, TextDocumentEdit, TextEdit, uinteger } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import { CrossModelServices, CrossModelSharedServices } from '../language-server/cross-model-module.js'; -import { CrossModelRoot } from '../language-server/generated/ast.js'; -import { findSemanticRoot } from '../language-server/util/ast-util.js'; +import { findDocument } from '../language-server/util/ast-util.js'; import { LANGUAGE_CLIENT_ID } from './openable-text-documents.js'; /** @@ -75,6 +76,10 @@ export class ModelService { * @param uri document URI */ async close(args: CloseModelArgs): Promise { + if (this.documentManager.isOnlyOpenInClient(args.uri, args.clientId)) { + // we need to restore the original state without any unsaved changes + await this.update({ ...args, model: this.documentManager.readFile(args.uri) }); + } return this.documentManager.close(args); } @@ -178,7 +183,7 @@ export class ModelService { return serializer.serialize(model); } - getId(node: AstNode, uri = node.$document?.uri): string | undefined { + getId(node: AstNode, uri = findDocument(node)?.uri): string | undefined { if (uri) { const services = this.shared.ServiceRegistry.getServices(uri) as CrossModelServices; return services.references.IdProvider.getLocalId(node); @@ -186,19 +191,19 @@ export class ModelService { return undefined; } - async findRootReferenceName(args: FindRootReferenceNameArgs): Promise { - const targetUri = URI.parse(args.target); - if (!this.documents.hasDocument(targetUri)) { - return; + getGlobalId(node: AstNode, uri = findDocument(node)?.uri): string | undefined { + if (uri) { + const services = this.shared.ServiceRegistry.getServices(uri) as CrossModelServices; + return services.references.IdProvider.getGlobalId(node); } - const targetDoc = this.documents.getOrCreateDocument(targetUri) as LangiumDocument; - const targetRoot = findSemanticRoot(targetDoc); - - // decide which name to use based on how the documents relate to each other - const sourceUri = URI.parse(args.source); - const packageManager = this.shared.workspace.PackageManager; - const services = this.shared.ServiceRegistry.getServices(sourceUri) as CrossModelServices; - const useExternal = packageManager.getPackageIdByUri(sourceUri) === this.shared.workspace.PackageManager.getPackageIdByUri(targetUri); - return useExternal ? services.references.IdProvider.getExternalId(targetRoot) : services.references.IdProvider.getLocalId(targetRoot); + return undefined; + } + + async findReferenceableElements(args: CrossReferenceContext): Promise { + return this.shared.CrossModel.references.ScopeProvider.complete(args); + } + + async resolveCrossReference(args: CrossReference): Promise { + return this.shared.CrossModel.references.ScopeProvider.resolveCrossReference(args); } } diff --git a/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts b/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts index 39579c6..c5f343a 100644 --- a/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts +++ b/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts @@ -150,6 +150,10 @@ export class OpenTextDocumentManager { return this.textDocuments.isOpenInLanguageClient(this.normalizedUri(uri)); } + isOnlyOpenInClient(uri: string, client: string): boolean { + return this.textDocuments.isOnlyOpenInClient(this.normalizedUri(uri), client); + } + protected createDummyDocument(uri: string): TextDocumentItem { return TextDocumentItem.create(this.normalizedUri(uri), CrossModelLanguageMetaData.languageId, 0, ''); } @@ -158,9 +162,11 @@ export class OpenTextDocumentManager { uri: string, languageId: string = CrossModelLanguageMetaData.languageId ): Promise { - const vscUri = URI.parse(uri); - const content = this.fileSystemProvider.readFileSync(vscUri); - return TextDocumentItem.create(vscUri.toString(), languageId, 0, content.toString()); + return TextDocumentItem.create(uri, languageId, 0, this.readFile(uri)); + } + + readFile(uri: string): string { + return this.fileSystemProvider.readFileSync(URI.parse(uri)); } protected normalizedUri(uri: string): string { diff --git a/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts b/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts index 6ae3e18..a157d3f 100644 --- a/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts +++ b/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts @@ -250,6 +250,10 @@ export class OpenableTextDocuments extends TextDocuments return this.isOpenInClient(uri, LANGUAGE_CLIENT_ID); } + isOnlyOpenInClient(uri: string, client: string): boolean { + return this.__clientDocuments.get(uri)?.size === 1 && this.isOpenInClient(uri, client); + } + protected log(uri: string, message: string): void { const full = URI.parse(uri); this.logger.info(`[Documents][${basename(full.fsPath)}] ${message}`); diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/utils.ts b/extensions/crossmodel-lang/test/language-server/test-utils/utils.ts index 74615a3..c65506d 100644 --- a/extensions/crossmodel-lang/test/language-server/test-utils/utils.ts +++ b/extensions/crossmodel-lang/test/language-server/test-utils/utils.ts @@ -2,8 +2,9 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { DefaultLangiumDocuments, EmptyFileSystem, LangiumDocument, LangiumServices } from 'langium'; +import { EmptyFileSystem, LangiumDocument, LangiumServices } from 'langium'; import { ParseHelperOptions, parseDocument as langiumParseDocument } from 'langium/test'; +import { CrossModelLangiumDocuments } from '../../../src/language-server/cross-model-langium-documents.js'; import { CrossModelServices, createCrossModelServices } from '../../../src/language-server/cross-model-module.js'; import { CrossModelRoot, @@ -20,7 +21,7 @@ import { SemanticRoot, TypeGuard, WithDocument, findSemanticRoot } from '../../. export function createCrossModelTestServices(): CrossModelServices { const services = createCrossModelServices({ ...EmptyFileSystem }).CrossModel; - services.shared.workspace.LangiumDocuments = new DefaultLangiumDocuments(services.shared); + services.shared.workspace.LangiumDocuments = new CrossModelLangiumDocuments(services.shared); return services; } diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts new file mode 100644 index 0000000..cb8b4f9 --- /dev/null +++ b/packages/core/src/browser/index.ts @@ -0,0 +1,8 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +export * from './cm-file-navigator-tree-widget'; +export * from './core-frontend-module'; +export * from './model-widget'; +export * from './new-element-contribution'; +export * from './preferences-monaco-contribution'; diff --git a/packages/core/src/browser/model-widget.tsx b/packages/core/src/browser/model-widget.tsx new file mode 100644 index 0000000..ea7e437 --- /dev/null +++ b/packages/core/src/browser/model-widget.tsx @@ -0,0 +1,247 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { ModelService, ModelServiceClient } from '@crossbreeze/model-service/lib/common'; +import { CrossModelRoot } from '@crossbreeze/protocol'; +import { + EntityComponent, + ErrorView, + ModelProviderProps, + OpenCallback, + RelationshipComponent, + SaveCallback +} from '@crossbreeze/react-model-ui'; +import { Emitter, Event } from '@theia/core'; +import { LabelProvider, Message, OpenerService, ReactWidget, Saveable, open } from '@theia/core/lib/browser'; +import { ThemeService } from '@theia/core/lib/browser/theming'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { EditorPreferences } from '@theia/editor/lib/browser'; +import * as deepEqual from 'fast-deep-equal'; +import * as debounce from 'p-debounce'; + +export const CrossModelWidgetOptions = Symbol('FormEditorWidgetOptions'); +export interface CrossModelWidgetOptions { + clientId: string; + widgetId: string; + uri?: string; +} + +interface Model { + uri: URI; + root: CrossModelRoot; +} + +@injectable() +export class CrossModelWidget extends ReactWidget implements Saveable { + @inject(CrossModelWidgetOptions) protected options: CrossModelWidgetOptions; + @inject(LabelProvider) protected labelProvider: LabelProvider; + @inject(ModelService) protected readonly modelService: ModelService; + @inject(ModelServiceClient) protected serviceClient: ModelServiceClient; + @inject(ThemeService) protected readonly themeService: ThemeService; + @inject(EditorPreferences) protected readonly editorPreferences: EditorPreferences; + @inject(OpenerService) protected readonly openerService: OpenerService; + + protected readonly onDirtyChangedEmitter = new Emitter(); + onDirtyChanged: Event = this.onDirtyChangedEmitter.event; + dirty = false; + autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange'; + autoSaveDelay: number; + + protected model?: Model; + protected error: string | undefined; + + @postConstruct() + init(): void { + this.id = this.options.widgetId; + this.title.closable = true; + + this.setModel(this.options.uri); + + this.autoSave = this.editorPreferences.get('files.autoSave'); + this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay'); + + this.toDispose.pushAll([ + this.serviceClient.onUpdate(event => { + if (event.sourceClientId !== this.options.clientId && event.uri === this.model?.uri.toString()) { + this.handleExternalUpdate(event.model); + } + }), + this.editorPreferences.onPreferenceChanged(event => { + if (event.preferenceName === 'files.autoSave') { + this.autoSave = this.editorPreferences.get('files.autoSave'); + } + if (event.preferenceName === 'files.autoSaveDelay') { + this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay'); + } + }), + this.themeService.onDidColorThemeChange(() => this.update()) + ]); + } + + protected async setModel(uri?: string): Promise { + if (this.model?.uri) { + await this.closeModel(this.model.uri.toString()); + } + this.model = uri ? await this.openModel(uri) : undefined; + this.updateTitle(this.model?.uri); + this.setDirty(false); + this.update(); + this.focusInput(); + } + + private updateTitle(uri?: URI): void { + if (uri) { + this.title.label = this.labelProvider.getName(uri); + this.title.iconClass = this.labelProvider.getIcon(uri); + } else { + this.title.label = 'Model Widget'; + this.title.iconClass = 'no-icon'; + } + } + + protected async closeModel(uri: string): Promise { + this.model = undefined; + await this.modelService.close({ clientId: this.options.clientId, uri }); + } + + protected async openModel(uri: string): Promise { + try { + const model = await this.modelService.open({ clientId: this.options.clientId, uri }); + if (model) { + return { root: model, uri: new URI(uri) }; + } + return undefined; + } catch (error: any) { + this.error = error; + return undefined; + } + } + + setDirty(dirty: boolean): void { + if (dirty === this.dirty) { + return; + } + + this.dirty = dirty; + this.onDirtyChangedEmitter.fire(); + this.update(); + } + + async save(): Promise { + return this.saveModel(); + } + + protected async handleExternalUpdate(root: CrossModelRoot): Promise { + if (this.model && !deepEqual(this.model.root, root)) { + this.model.root = root; + this.update(); + } + } + + protected async updateModel(root: CrossModelRoot): Promise { + if (this.model && !deepEqual(this.model.root, root)) { + this.model.root = root; + this.setDirty(true); + await this.modelService.update({ uri: this.model.uri.toString(), model: root, clientId: this.options.clientId }); + if (this.autoSave !== 'off' && this.dirty) { + const saveTimeout = setTimeout(() => { + this.save(); + clearTimeout(saveTimeout); + }, this.autoSaveDelay); + } + } + } + + protected async saveModel(model = this.model): Promise { + if (model === undefined) { + throw new Error('Cannot save undefined model'); + } + + this.setDirty(false); + await this.modelService.save({ uri: model.uri.toString(), model: model.root, clientId: this.options.clientId }); + } + + protected async openModelInEditor(): Promise { + if (this.model?.uri === undefined) { + throw new Error('Cannot open undefined model'); + } + open(this.openerService, this.model.uri); + } + + protected getModelProviderProps(model: CrossModelRoot): ModelProviderProps { + return { + model, + dirty: this.dirty, + onModelUpdate: this.handleUpdateRequest, + onModelSave: this.handleSaveRequest, + onModelOpen: this.handleOpenRequest, + modelQueryApi: this.modelService + }; + } + + protected handleUpdateRequest = debounce(async (root: CrossModelRoot): Promise => { + this.updateModel(root); + }, 200); + + protected handleSaveRequest?: SaveCallback = () => this.save(); + + protected handleOpenRequest?: OpenCallback = () => this.openModelInEditor(); + + override close(): void { + if (this.model) { + this.closeModel(this.model.uri.toString()); + } + super.close(); + } + + render(): React.ReactNode { + if (this.model?.root?.entity) { + return ( + + ); + } + if (this.model?.root?.relationship) { + return ( + + ); + } + if (this.error) { + return ; + } + return
No properties available.
; + } + + protected focusInput(): void { + setTimeout(() => { + document.activeElement; + const inputs = this.node.getElementsByTagName('input'); + if (inputs.length > 0) { + inputs[0].focus(); + } + }, 50); + } + + protected override onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this.focusInput(); + } +} diff --git a/packages/core/src/browser/new-element-contribution.ts b/packages/core/src/browser/new-element-contribution.ts index 6cad163..99989cd 100644 --- a/packages/core/src/browser/new-element-contribution.ts +++ b/packages/core/src/browser/new-element-contribution.ts @@ -2,7 +2,7 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { ModelService } from '@crossbreeze/model-service/lib/common'; -import { ModelFileExtensions, quote, toId } from '@crossbreeze/protocol'; +import { MappingType, ModelFileExtensions, TargetObjectType, quote, toId } from '@crossbreeze/protocol'; import { Command, CommandContribution, CommandRegistry, MaybePromise, MenuContribution, MenuModelRegistry, URI, nls } from '@theia/core'; import { CommonMenus, DialogError, codicon, open } from '@theia/core/lib/browser'; import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; @@ -141,8 +141,8 @@ export class CrossModelWorkspaceContribution extends WorkspaceCommandContributio registry.registerMenuAction(EditorContextMenu.COMMANDS, { commandId: DERIVE_MAPPING_FROM_ENTITY.id }); } - protected async deriveNewMappingFile(uri: URI): Promise { - const parent = await this.getDirectory(uri); + protected async deriveNewMappingFile(entityUri: URI): Promise { + const parent = await this.getDirectory(entityUri); if (parent) { const parentUri = parent.resource; const dialog = new WorkspaceInputDialog( @@ -159,11 +159,16 @@ export class CrossModelWorkspaceContribution extends WorkspaceCommandContributio if (selectedSource) { const fileName = this.applyFileExtension(selectedSource, ModelFileExtensions.Mapping); const baseFileName = this.removeFileExtension(selectedSource, ModelFileExtensions.Mapping); - const fileUri = parentUri.resolve(fileName); + const mappingUri = parentUri.resolve(fileName); - const targetRef = await this.modelService.findRootReferenceName({ source: fileUri.path.fsPath(), target: uri.path.fsPath() }); - if (!targetRef) { - this.messageService.error('Could not detect target element at ' + uri.path.fsPath()); + const elements = await this.modelService.findReferenceableElements({ + container: { uri: mappingUri.path.fsPath(), type: MappingType }, + syntheticElements: [{ property: 'target', type: TargetObjectType }], + property: 'entity' + }); + const entityElement = elements.find(element => element.uri === entityUri.toString()); + if (!entityElement) { + this.messageService.error('Could not detect target element at ' + entityUri.path.fsPath()); return; } @@ -171,10 +176,10 @@ export class CrossModelWorkspaceContribution extends WorkspaceCommandContributio const content = `mapping: id: ${mappingName} target: - entity: ${targetRef}`; - await this.fileService.create(fileUri, content); - this.fireCreateNewFile({ parent: parentUri, uri: fileUri }); - open(this.openerService, fileUri); + entity: ${entityElement.label}`; + await this.fileService.create(mappingUri, content); + this.fireCreateNewFile({ parent: parentUri, uri: mappingUri }); + open(this.openerService, mappingUri); } } } diff --git a/packages/form-client/package.json b/packages/form-client/package.json index 1af85ea..85c720f 100644 --- a/packages/form-client/package.json +++ b/packages/form-client/package.json @@ -29,6 +29,7 @@ "watch": "tsc -w" }, "dependencies": { + "@crossbreeze/core": "0.0.0", "@crossbreeze/model-service": "^1.0.0", "@crossbreeze/protocol": "0.0.0", "@crossbreeze/react-model-ui": "0.0.0", diff --git a/packages/form-client/src/browser/form-client-frontend-module.ts b/packages/form-client/src/browser/form-client-frontend-module.ts index 487d2ed..cc1de9e 100644 --- a/packages/form-client/src/browser/form-client-frontend-module.ts +++ b/packages/form-client/src/browser/form-client-frontend-module.ts @@ -2,22 +2,26 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { URI } from '@theia/core'; import { NavigatableWidgetOptions, OpenHandler, WidgetFactory } from '@theia/core/lib/browser'; import { ContainerModule } from '@theia/core/shared/inversify'; import { FormEditorOpenHandler, createFormEditorId } from './form-editor-open-handler'; import { FormEditorWidget, FormEditorWidgetOptions } from './form-editor-widget'; +import { CrossModelWidgetOptions } from '@crossbreeze/core/lib/browser'; export default new ContainerModule(bind => { bind(OpenHandler).to(FormEditorOpenHandler).inSingletonScope(); bind(WidgetFactory).toDynamicValue(context => ({ id: FormEditorOpenHandler.ID, // must match the id in the open handler - createWidget: (options: NavigatableWidgetOptions) => { + createWidget: (navigatableOptions: NavigatableWidgetOptions) => { // create a child container so we can bind unique form editor widget options for each widget const container = context.container.createChild(); - const uri = new URI(options.uri); - const id = createFormEditorId(uri, options.counter); - container.bind(FormEditorWidgetOptions).toConstantValue({ ...options, id }); + const widgetId = createFormEditorId(navigatableOptions.uri, navigatableOptions.counter); + const options: FormEditorWidgetOptions = { + ...navigatableOptions, + widgetId, + clientId: 'form-editor' + }; + container.bind(CrossModelWidgetOptions).toConstantValue(options); container.bind(FormEditorWidget).toSelf(); return container.get(FormEditorWidget); } diff --git a/packages/form-client/src/browser/form-editor-open-handler.ts b/packages/form-client/src/browser/form-editor-open-handler.ts index ce4399b..cecc4e1 100644 --- a/packages/form-client/src/browser/form-editor-open-handler.ts +++ b/packages/form-client/src/browser/form-editor-open-handler.ts @@ -20,7 +20,7 @@ export class FormEditorOpenHandler extends NavigatableWidgetOpenHandler(); - onDirtyChanged: Event = this.onDirtyChangedEmitter.event; - - @inject(FormEditorWidgetOptions) protected options: FormEditorWidgetOptions; - @inject(LabelProvider) protected labelProvider: LabelProvider; - @inject(ModelService) private readonly modelService: ModelService; - @inject(CommandService) protected commandService: CommandService; - @inject(ModelServiceClient) protected formClient: ModelServiceClient; - - protected syncedModel: CrossModelRoot | undefined; - protected error: string | undefined; - - @postConstruct() - init(): void { - // Widget options - this.id = this.options.id; - this.title.label = this.labelProvider.getName(this.getResourceUri()); - this.title.iconClass = this.labelProvider.getIcon(this.getResourceUri()); - this.title.closable = true; - - this.updateModel = this.updateModel.bind(this); - this.getResourceUri = this.getResourceUri.bind(this); - this.loadModel(); - - this.toDispose.push( - this.formClient.onUpdate(event => { - if (event.sourceClientId !== FORM_CLIENT_ID && event.uri === this.getResourceUri().toString()) { - this.modelUpdated(event.model); - } - }) - ); - } - - protected async loadModel(): Promise { - try { - const uri = this.getResourceUri().toString(); - const model = await this.modelService.open({ uri, clientId: FORM_CLIENT_ID }); - if (model) { - this.syncedModel = model; - } - } catch (error: any) { - this.error = error; - } finally { - this.update(); - } - } - - async save(options?: SaveOptions | undefined): Promise { - if (this.syncedModel === undefined) { - throw new Error('Cannot save undefined model'); - } - - this.setDirty(false); - await this.modelService.save({ uri: this.getResourceUri().toString(), model: this.syncedModel, clientId: FORM_CLIENT_ID }); - } +export class FormEditorWidget extends CrossModelWidget implements NavigatableWidget { + @inject(CrossModelWidgetOptions) protected override options: FormEditorWidgetOptions; - protected updateModel = debounce((model: CrossModelRoot) => { - if (!deepEqual(this.syncedModel, model)) { - this.syncedModel = model; - this.modelService.update({ uri: this.getResourceUri().toString(), model, clientId: FORM_CLIENT_ID }); - } - }, 200); - - protected modelUpdated(model: CrossModelRoot): void { - if (!deepEqual(this.syncedModel, model)) { - this.syncedModel = model; - this.update(); - } - } - - override close(): void { - this.modelService.close({ uri: this.getResourceUri().toString(), clientId: FORM_CLIENT_ID }); - super.close(); - } - - setDirty(dirty: boolean): void { - if (dirty === this.dirty) { - return; - } - - this.dirty = dirty; - this.onDirtyChangedEmitter.fire(); - } - - render(): React.ReactNode { - const FormComponent = withModelProvider(EntityForm, { - model: this.syncedModel, - onModelUpdate: this.updateModel - }); - return ; - } - - protected override onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - const focusInput = (): boolean => { - const inputs = this.node.getElementsByTagName('input'); - if (inputs.length > 0) { - inputs[0].focus(); - return true; - } - return false; - }; - if (!focusInput()) { - setTimeout(focusInput, 500); - } - } + protected override handleOpenRequest = undefined; // we do not need to support opening in editor, we are the editor + protected override handleSaveRequest = undefined; // we do not need to support saving through the widget itself, we are a Theia editor getResourceUri(): URI { return new URI(this.options.uri); diff --git a/packages/glsp-client/src/browser/crossmodel-selection-data-service.ts b/packages/glsp-client/src/browser/crossmodel-selection-data-service.ts index df44b7a..0894398 100644 --- a/packages/glsp-client/src/browser/crossmodel-selection-data-service.ts +++ b/packages/glsp-client/src/browser/crossmodel-selection-data-service.ts @@ -1,7 +1,8 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { GModelElement, GModelRoot } from '@eclipse-glsp/client'; +import { CrossReference, REFERENCE_CONTAINER_TYPE, REFERENCE_PROPERTY, REFERENCE_VALUE } from '@crossbreeze/protocol'; +import { GModelElement, GModelRoot, hasArgs } from '@eclipse-glsp/client'; import { GlspSelectionData } from '@eclipse-glsp/theia-integration'; import { isDefined } from '@theia/core'; import { injectable } from '@theia/core/shared/inversify'; @@ -14,8 +15,34 @@ export class CrossModelGLSPSelectionDataService extends CrossModelSelectionDataS } } -export function getSelectionDataFor(selection: GModelElement[]): GlspSelectionData { - const selectionDataMap = new Map(); - selection.forEach(element => selectionDataMap.set(element.id, element.type)); +export interface GModelElementInfo { + type: string; + reference?: CrossReference; +} + +export interface CrossModelSelectionData { + selectionDataMap: Map; +} + +export function getSelectionDataFor(selection: GModelElement[]): CrossModelSelectionData { + const selectionDataMap = new Map(); + selection.forEach(element => selectionDataMap.set(element.id, getElementInfo(element))); return { selectionDataMap }; } + +export function getElementInfo(element: GModelElement): GModelElementInfo { + if (hasArgs(element)) { + const referenceProperty = element.args[REFERENCE_PROPERTY]; + const referenceContainerType = element.args[REFERENCE_CONTAINER_TYPE]; + const referenceValue = element.args[REFERENCE_VALUE]; + return { + type: element.type, + reference: { + container: { globalId: element.id, type: referenceContainerType.toString() }, + property: referenceProperty.toString(), + value: referenceValue.toString() + } + }; + } + return { type: element.type }; +} diff --git a/packages/glsp-client/src/browser/system-diagram/select-tool/select-tool-module.ts b/packages/glsp-client/src/browser/system-diagram/select-tool/select-tool-module.ts index 043ea6f..8cb7cda 100644 --- a/packages/glsp-client/src/browser/system-diagram/select-tool/select-tool-module.ts +++ b/packages/glsp-client/src/browser/system-diagram/select-tool/select-tool-module.ts @@ -9,15 +9,19 @@ import { SelectFeedbackCommand, TYPES, bindAsService, - configureCommand + configureCommand, + selectModule } from '@eclipse-glsp/client'; import { SystemSelectTool } from './select-tool'; -export const systemSelectModule = new FeatureModule((bind, _unbind, isBound) => { - const context = { bind, isBound }; - configureCommand(context, SelectCommand); - configureCommand(context, SelectAllCommand); - configureCommand(context, SelectFeedbackCommand); - bindAsService(context, TYPES.IDefaultTool, SystemSelectTool); - bind(RankedSelectMouseListener).toSelf().inSingletonScope(); -}); +export const systemSelectModule = new FeatureModule( + (bind, _unbind, isBound) => { + const context = { bind, isBound }; + configureCommand(context, SelectCommand); + configureCommand(context, SelectAllCommand); + configureCommand(context, SelectFeedbackCommand); + bindAsService(context, TYPES.IDefaultTool, SystemSelectTool); + bind(RankedSelectMouseListener).toSelf().inSingletonScope(); + }, + { featureId: selectModule.featureId } +); diff --git a/packages/glsp-client/src/browser/system-diagram/system-diagram-configuration.ts b/packages/glsp-client/src/browser/system-diagram/system-diagram-configuration.ts index 4edc4be..966d4ab 100644 --- a/packages/glsp-client/src/browser/system-diagram/system-diagram-configuration.ts +++ b/packages/glsp-client/src/browser/system-diagram/system-diagram-configuration.ts @@ -19,10 +19,16 @@ export class SystemDiagramConfiguration extends GLSPDiagramConfiguration { diagramType: string = SystemDiagramLanguage.diagramType; configureContainer(container: Container, ...containerConfiguration: ContainerConfiguration): Container { - return initializeDiagramContainer(container, ...containerConfiguration, systemDiagramModule, systemEdgeCreationToolModule, { - add: systemSelectModule, - remove: selectModule - }); + return initializeDiagramContainer( + container, + { + add: systemSelectModule, + remove: selectModule + }, + ...containerConfiguration, + systemDiagramModule, + systemEdgeCreationToolModule + ); } } diff --git a/packages/model-service/src/common/model-service-rpc.ts b/packages/model-service/src/common/model-service-rpc.ts index 07ad2f7..92d044d 100644 --- a/packages/model-service/src/common/model-service-rpc.ts +++ b/packages/model-service/src/common/model-service-rpc.ts @@ -5,10 +5,12 @@ import { CloseModelArgs, CrossModelRoot, - DiagramNodeEntity, - FindRootReferenceNameArgs, + CrossReference, + CrossReferenceContext, ModelUpdatedEvent, OpenModelArgs, + ReferenceableElement, + ResolvedElement, SaveModelArgs, UpdateModelArgs } from '@crossbreeze/protocol'; @@ -25,8 +27,8 @@ export interface ModelService extends RpcServer { open(args: OpenModelArgs): Promise; close(args: CloseModelArgs): Promise; request(uri: string): Promise; - requestDiagramNodeEntityModel(uri: string, id: string): Promise; - findRootReferenceName(args: FindRootReferenceNameArgs): Promise; + findReferenceableElements(args: CrossReferenceContext): Promise; + resolveReference(reference: CrossReference): Promise; update(args: UpdateModelArgs): Promise; save(args: SaveModelArgs): Promise; } diff --git a/packages/model-service/src/node/model-service.ts b/packages/model-service/src/node/model-service.ts index 7b8abc7..dd86182 100644 --- a/packages/model-service/src/node/model-service.ts +++ b/packages/model-service/src/node/model-service.ts @@ -5,16 +5,18 @@ import { CloseModel, CloseModelArgs, CrossModelRoot, - DiagramNodeEntity, - FindRootReferenceName, - FindRootReferenceNameArgs, + CrossReference, + CrossReferenceContext, + FindReferenceableElements, MODELSERVER_PORT_COMMAND, OnSave, OnUpdated, OpenModel, OpenModelArgs, + ReferenceableElement, RequestModel, - RequestModelDiagramNode, + ResolveReference, + ResolvedElement, SaveModel, SaveModelArgs, UpdateModel, @@ -155,14 +157,14 @@ export class ModelServiceImpl implements ModelService { } } - async requestDiagramNodeEntityModel(uri: string, id: string): Promise { + async findReferenceableElements(args: CrossReferenceContext): Promise { await this.initializeServerConnection(); - return this.connection.sendRequest(RequestModelDiagramNode, uri, id); + return this.connection.sendRequest(FindReferenceableElements, args); } - async findRootReferenceName(args: FindRootReferenceNameArgs): Promise { + async resolveReference(reference: CrossReference): Promise { await this.initializeServerConnection(); - return this.connection.sendRequest(FindRootReferenceName, args); + return this.connection.sendRequest(ResolveReference, reference); } protected setUpListeners(): void { diff --git a/packages/property-view/src/browser/model-data-service.ts b/packages/property-view/src/browser/model-data-service.ts index 15b2053..c084a95 100644 --- a/packages/property-view/src/browser/model-data-service.ts +++ b/packages/property-view/src/browser/model-data-service.ts @@ -2,8 +2,9 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CrossModelSelectionData } from '@crossbreeze/glsp-client/lib/browser/crossmodel-selection-data-service'; import { ModelService } from '@crossbreeze/model-service/lib/common'; -import { DiagramNodeEntity, ENTITY_NODE_TYPE } from '@crossbreeze/protocol'; +import { ResolvedElement } from '@crossbreeze/protocol'; import { GlspSelection } from '@eclipse-glsp/theia-integration'; import { inject, injectable } from '@theia/core/shared/inversify'; import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; @@ -21,19 +22,21 @@ export class ModelDataService implements PropertyDataService { return GlspSelection.is(selection) ? 1 : 0; } - protected async getSelectedEntity(selection: GlspSelection | undefined): Promise { + protected async getSelectedEntity(selection: GlspSelection | undefined): Promise { if (!selection || !GlspSelection.is(selection) || !selection.sourceUri || selection.selectedElementsIDs.length === 0) { return undefined; } + const dataMap = selection.additionalSelectionData as CrossModelSelectionData; for (const selectedElementId of selection.selectedElementsIDs) { - if (selection.additionalSelectionData?.selectionDataMap.get(selectedElementId) === ENTITY_NODE_TYPE) { - return this.modelService.requestDiagramNodeEntityModel(selection.sourceUri, selectedElementId); + const info = dataMap?.selectionDataMap.get(selectedElementId); + if (info?.reference) { + return this.modelService.resolveReference(info?.reference); } } return undefined; } - async providePropertyData(selection: GlspSelection | undefined): Promise { + async providePropertyData(selection: GlspSelection | undefined): Promise { return this.getSelectedEntity(selection); } } diff --git a/packages/property-view/src/browser/model-property-widget-provider.ts b/packages/property-view/src/browser/model-property-widget-provider.ts index ff3a4aa..9cdd65f 100644 --- a/packages/property-view/src/browser/model-property-widget-provider.ts +++ b/packages/property-view/src/browser/model-property-widget-provider.ts @@ -11,8 +11,6 @@ import { ModelPropertyWidget } from './model-property-widget'; export class ModelPropertyWidgetProvider extends DefaultPropertyViewWidgetProvider { override readonly id = 'model-property-widget-provider'; override readonly label = 'Model Property Widget Provider'; - currentUri = ''; - currentNode = ''; @inject(ModelPropertyWidget) protected modelPropertyWidget: ModelPropertyWidget; diff --git a/packages/property-view/src/browser/model-property-widget.tsx b/packages/property-view/src/browser/model-property-widget.tsx index bbd321a..334888c 100644 --- a/packages/property-view/src/browser/model-property-widget.tsx +++ b/packages/property-view/src/browser/model-property-widget.tsx @@ -2,91 +2,52 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { Message, ReactWidget } from '@theia/core/lib/browser'; -import * as React from '@theia/core/shared/react'; +import { ApplicationShell, ShouldSaveDialog } from '@theia/core/lib/browser'; import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; import { PropertyViewContentWidget } from '@theia/property-view/lib/browser/property-view-content-widget'; -import { ModelService } from '@crossbreeze/model-service/lib/common'; -import { CrossModelRoot, isDiagramNodeEntity } from '@crossbreeze/protocol'; -import { EntityPropertyView, withModelProvider } from '@crossbreeze/react-model-ui'; +import { CrossModelWidget } from '@crossbreeze/core/lib/browser'; +import { ResolvedElement } from '@crossbreeze/protocol'; import { GLSPDiagramWidget, GlspSelection, getDiagramWidget } from '@eclipse-glsp/theia-integration'; -import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { PROPERTY_CLIENT_ID } from './model-data-service'; @injectable() -export class ModelPropertyWidget extends ReactWidget implements PropertyViewContentWidget { - static readonly ID = 'attribute-property-view'; - static readonly LABEL = 'Model property widget'; - - @inject(ModelService) protected modelService: ModelService; +export class ModelPropertyWidget extends CrossModelWidget implements PropertyViewContentWidget { @inject(ApplicationShell) protected shell: ApplicationShell; - protected model: CrossModelRoot | undefined; - protected uri: string; - constructor() { super(); - this.id = ModelPropertyWidget.ID; - this.title.label = ModelPropertyWidget.LABEL; - this.title.caption = ModelPropertyWidget.LABEL; - this.title.closable = false; this.node.tabIndex = 0; - - this.saveModel = this.saveModel.bind(this); - this.updateModel = this.updateModel.bind(this); + this.node.style.height = '100%'; } async updatePropertyViewContent(propertyDataService?: PropertyDataService, selection?: GlspSelection | undefined): Promise { const activeWidget = getDiagramWidget(this.shell); - if (activeWidget?.options.uri === this.uri && this.uri !== selection?.sourceUri) { + if (activeWidget?.options.uri === this.model?.uri.toString() && this.model?.uri.toString() !== selection?.sourceUri) { // only react to selection of active widget return; } - this.model = undefined; - if (propertyDataService) { - try { - const selectionData = await propertyDataService.providePropertyData(selection); - if (isDiagramNodeEntity(selectionData)) { - this.model = selectionData.model; - this.uri = selectionData.uri; - } - } catch (error) { - this.model = undefined; + const selectionData = (await propertyDataService.providePropertyData(selection)) as ResolvedElement | undefined; + if (this.model?.uri.toString() === selectionData?.uri) { + return; } + this.setModel(selectionData?.uri); + } else { + this.setModel(); } - - this.update(); - } - - async saveModel(): Promise { - if (this.model === undefined || this.uri === undefined) { - throw new Error('Cannot save undefined model'); - } - this.modelService.update({ uri: this.uri, model: this.model, clientId: PROPERTY_CLIENT_ID }); } - protected async updateModel(model: CrossModelRoot): Promise { - this.model = model; - } - - protected render(): React.ReactNode { - if (!this.model) { - return <>; + protected override async closeModel(uri: string): Promise { + if (this.model && this.dirty) { + const toSave = this.model; + this.model = undefined; + const shouldSave = await new ShouldSaveDialog(this).open(); + if (shouldSave) { + await this.saveModel(toSave); + } } - const PropertyComponent = withModelProvider(EntityPropertyView, { - model: this.model, - onModelUpdate: this.updateModel, - onModelSave: this.saveModel - }); - return ; - } - - protected override onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - this.node.focus(); + super.closeModel(uri); } protected getDiagramWidget(): GLSPDiagramWidget | undefined { @@ -97,4 +58,8 @@ export class ModelPropertyWidget extends ReactWidget implements PropertyViewCont } return undefined; } + + protected override focusInput(): void { + // do nothing, we properties are based on selection so we do not want to steal focus + } } diff --git a/packages/property-view/src/browser/property-view-frontend-module.ts b/packages/property-view/src/browser/property-view-frontend-module.ts index 92d1e27..0be3021 100644 --- a/packages/property-view/src/browser/property-view-frontend-module.ts +++ b/packages/property-view/src/browser/property-view-frontend-module.ts @@ -2,18 +2,23 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CrossModelWidgetOptions } from '@crossbreeze/core/lib/browser'; import { ContainerModule } from '@theia/core/shared/inversify'; import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; +import { PropertyViewWidget } from '@theia/property-view/lib/browser/property-view-widget'; import { PropertyViewWidgetProvider } from '@theia/property-view/lib/browser/property-view-widget-provider'; import '../../style/property-view.css'; import { ModelDataService } from './model-data-service'; import { ModelPropertyWidget } from './model-property-widget'; import { ModelPropertyWidgetProvider } from './model-property-widget-provider'; +import { SaveablePropertyViewWidget } from './saveable-property-view-widget'; export default new ContainerModule((bind, _unbind, _isBound, rebind) => { // To make the property widget working + bind(CrossModelWidgetOptions).toConstantValue({ clientId: 'model-property-view', widgetId: 'model-property-view' }); bind(ModelPropertyWidget).toSelf(); bind(ModelDataService).toSelf().inSingletonScope(); bind(PropertyDataService).toService(ModelDataService); bind(PropertyViewWidgetProvider).to(ModelPropertyWidgetProvider).inSingletonScope(); + rebind(PropertyViewWidget).to(SaveablePropertyViewWidget); }); diff --git a/packages/property-view/src/browser/saveable-property-view-widget.tsx b/packages/property-view/src/browser/saveable-property-view-widget.tsx new file mode 100644 index 0000000..d31d7f5 --- /dev/null +++ b/packages/property-view/src/browser/saveable-property-view-widget.tsx @@ -0,0 +1,18 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { DelegatingSaveable, Saveable, SaveableSource } from '@theia/core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { PropertyViewContentWidget } from '@theia/property-view/lib/browser/property-view-content-widget'; +import { PropertyViewWidget } from '@theia/property-view/lib/browser/property-view-widget'; + +@injectable() +export class SaveablePropertyViewWidget extends PropertyViewWidget implements SaveableSource { + saveable: Saveable = new DelegatingSaveable(); + + protected override attachContentWidget(newContentWidget: PropertyViewContentWidget): void { + super.attachContentWidget(newContentWidget); + this.saveable = Saveable.get(newContentWidget) ?? new DelegatingSaveable(); + } +} diff --git a/packages/protocol/src/glsp/types.ts b/packages/protocol/src/glsp/types.ts index dca28f3..8a6628c 100644 --- a/packages/protocol/src/glsp/types.ts +++ b/packages/protocol/src/glsp/types.ts @@ -15,3 +15,8 @@ export const SOURCE_STRING_NODE_TYPE = DefaultTypes.NODE + ':source-string'; export const TARGET_OBJECT_NODE_TYPE = DefaultTypes.NODE + ':target-object'; export const TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE = DefaultTypes.EDGE + ':target-attribute-mapping'; export const ATTRIBUTE_COMPARTMENT_TYPE = DefaultTypes.COMPARTMENT + ':attribute'; + +// Args +export const REFERENCE_CONTAINER_TYPE = 'reference-container-type'; +export const REFERENCE_PROPERTY = 'reference-property'; +export const REFERENCE_VALUE = 'reference-value'; diff --git a/packages/protocol/src/model-service/protocol.ts b/packages/protocol/src/model-service/protocol.ts index c6e2783..b2c229d 100644 --- a/packages/protocol/src/model-service/protocol.ts +++ b/packages/protocol/src/model-service/protocol.ts @@ -14,7 +14,16 @@ export const CrossModelRegex = { * Serialized version of the semantic model generated by Langium. */ -export interface CrossModelRoot { +export interface CrossModelElement { + readonly $type: string; +} + +export interface Identifiable { + id: string; + $globalId: string; +} + +export interface CrossModelRoot extends CrossModelElement { readonly $type: 'CrossModelRoot'; entity?: Entity; relationship?: Relationship; @@ -24,40 +33,42 @@ export function isCrossModelRoot(model?: any): model is CrossModelRoot { return !!model && model.$type === 'CrossModelRoot'; } -export interface Entity { - readonly $type: 'Entity'; +export const EntityType = 'Entity'; +export interface Entity extends CrossModelElement, Identifiable { + readonly $type: typeof EntityType; attributes: Array; description?: string; - id?: string; name?: string; } -export interface EntityAttribute { - readonly $type: 'EntityAttribute'; +export const EntityAttributeType = 'EntityAttribute'; +export interface EntityAttribute extends CrossModelElement, Identifiable { + readonly $type: typeof EntityAttributeType; datatype?: string; description?: string; - id?: string; name?: string; } -export interface Relationship { - readonly $type: 'Relationship'; +export const RelationshipType = 'Relationship'; +export interface Relationship extends CrossModelElement, Identifiable { + readonly $type: typeof RelationshipType; + attributes: Array; child?: string; description?: string; - id?: string; name?: string; parent?: string; type?: string; } -export interface DiagramNodeEntity { - uri: string; - model: CrossModelRoot; +export const RelationshipAttributeType = 'RelationshipAttribute'; +export interface RelationshipAttribute extends CrossModelElement { + readonly $type: typeof RelationshipAttributeType; + parent?: string; + child?: string; } -export function isDiagramNodeEntity(model?: any): model is DiagramNodeEntity { - return !!model && model.uri && model.model && isCrossModelRoot(model.model); -} +export const MappingType = 'Mapping'; +export const TargetObjectType = 'TargetObject'; export interface ClientModelArgs { uri: string; @@ -91,18 +102,102 @@ export interface ModelSavedEvent { sourceClientId: string; } -export interface FindRootReferenceNameArgs { - target: string; - source: string; +/** + * A context to describe a cross reference to retrieve reachable elements. + */ +export interface CrossReferenceContext { + /** + * The container from which we want to query the reachable elements. + */ + container: CrossReferenceContainer; + /** + * Synthetic elements starting from the container to further narrow down the cross reference. + * This is useful for elements that are being created or if the element cannot be identified. + */ + syntheticElements?: SyntheticElement[]; + /** + * The property of the element referenced through the source container and the optional synthetic + * elements for which we should retrieve the reachable elements. + */ + property: string; +} +export interface RootElementReference { + uri: string; +} +export function isRootElementReference(object: unknown): object is RootElementReference { + return !!object && typeof object === 'object' && 'uri' in object && typeof object.uri === 'string'; +} +export interface GlobalElementReference { + globalId: string; + type?: string; +} +export function isGlobalElementReference(object: unknown): object is GlobalElementReference { + return !!object && typeof object === 'object' && 'globalId' in object && typeof object.globalId === 'string'; +} +export interface SyntheticDocument { + uri: string; + type: string; +} +export function isSyntheticDocument(object: unknown): object is SyntheticDocument { + return ( + !!object && + typeof object === 'object' && + 'uri' in object && + typeof object.uri === 'string' && + 'type' in object && + typeof object.type === 'string' + ); +} +export type CrossReferenceContainer = RootElementReference | GlobalElementReference | SyntheticDocument; + +export interface SyntheticElement { + type: string; + property: string; +} +export function isSyntheticElement(object: unknown): object is SyntheticElement { + return ( + !!object && + typeof object === 'object' && + 'type' in object && + typeof object.type === 'string' && + 'property' in object && + typeof object.property === 'string' + ); +} +export interface ReferenceableElement { + uri: string; + type: string; + label: string; +} + +export interface CrossReference { + /** + * The container from which we want to resolve the reference. + */ + container: CrossReferenceContainer; + /** + * The property for which we want to resolve the reference. + */ + property: string; + /** + * The textual value of the reference we are resolving. + */ + value: string; +} + +export interface ResolvedElement { + uri: string; + model: CrossModelRoot; + match?: T; } export const OpenModel = new rpc.RequestType1('server/open'); export const CloseModel = new rpc.RequestType1('server/close'); export const RequestModel = new rpc.RequestType1('server/request'); -export const RequestModelDiagramNode = new rpc.RequestType2( - 'server/requestModelDiagramNode' -); -export const FindRootReferenceName = new rpc.RequestType1('server/reference'); +export const RequestModelDiagramNode = new rpc.RequestType2('server/requestModelDiagramNode'); + +export const FindReferenceableElements = new rpc.RequestType1('server/complete'); +export const ResolveReference = new rpc.RequestType1('server/resolve'); export const UpdateModel = new rpc.RequestType1, CrossModelRoot, void>('server/update'); export const SaveModel = new rpc.RequestType1, void, void>('server/save'); diff --git a/packages/react-model-ui/package.json b/packages/react-model-ui/package.json index 44a9efc..05973ff 100644 --- a/packages/react-model-ui/package.json +++ b/packages/react-model-ui/package.json @@ -35,12 +35,11 @@ "@crossbreeze/protocol": "0.0.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", - "@mui/icons-material": "^5.11.16", - "@mui/material": "^5.13.5", - "@mui/x-data-grid": "^6.2.1", + "@mui/icons-material": "^5.15.15", + "@mui/material": "^5.15.15", + "@mui/x-data-grid": "^7.1.1", "immer": "^10.0.3", "react": "18.2.0", - "react-tabs": "^6.0.1", "use-immer": "^0.9.0" } } diff --git a/packages/react-model-ui/src/ModelContext.tsx b/packages/react-model-ui/src/ModelContext.tsx index 8003e7b..26e5345 100644 --- a/packages/react-model-ui/src/ModelContext.tsx +++ b/packages/react-model-ui/src/ModelContext.tsx @@ -1,19 +1,34 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossModelRoot } from '@crossbreeze/protocol'; +import { CrossModelRoot, CrossReferenceContext, Entity, ReferenceableElement, Relationship } from '@crossbreeze/protocol'; import * as React from 'react'; import { DispatchAction, ModelReducer } from './ModelReducer'; export type SaveCallback = () => void; +export type OpenCallback = () => void; -export const defaultSaveCallback = (): void => { - console.log('Saving this model is not supported.'); -}; +export interface ModelQueryApi { + findReferenceableElements(args: CrossReferenceContext): Promise; +} + +const DEFAULT_MODEL_ROOT: CrossModelRoot = { $type: 'CrossModelRoot' }; +export const ModelContext = React.createContext(DEFAULT_MODEL_ROOT); + +export type ActionDispatcher = React.Dispatch>; +export const DEFAULT_MODEL_REDUCER: ActionDispatcher = x => x; +export const ModelDispatchContext = React.createContext(DEFAULT_MODEL_REDUCER); + +export const DEFAULT_OPEN_CALLBACK = (): void => console.log('Opening this model is not supported.'); +export const OpenModelContext = React.createContext(undefined); -export const ModelContext = React.createContext({ $type: 'CrossModelRoot' }); -export const ModelDispatchContext = React.createContext>>(x => x); -export const SaveModelContext = React.createContext(defaultSaveCallback); +export const DEFAULT_SAVE_CALLBACK = (): void => console.log('Saving this model is not supported.'); +export const SaveModelContext = React.createContext(undefined); + +export const DEFAULT_QUERY_API: ModelQueryApi = { findReferenceableElements: async () => [] }; +export const ModelQueryApiContext = React.createContext(DEFAULT_QUERY_API); + +export const ModelDirtyContext = React.createContext(false); export function useModel(): CrossModelRoot { return React.useContext(ModelContext); @@ -23,6 +38,26 @@ export function useModelDispatch(): React.Dispatch { return React.useContext(ModelDispatchContext); } -export function useModelSave(): SaveCallback { +export function useModelSave(): SaveCallback | undefined { return React.useContext(SaveModelContext); } + +export function useModelOpen(): OpenCallback | undefined { + return React.useContext(OpenModelContext); +} + +export function useModelQueryApi(): ModelQueryApi { + return React.useContext(ModelQueryApiContext); +} + +export function useDirty(): boolean { + return React.useContext(ModelDirtyContext); +} + +export function useEntity(): Entity { + return useModel().entity!; +} + +export function useRelationship(): Relationship { + return useModel().relationship!; +} diff --git a/packages/react-model-ui/src/ModelProvider.tsx b/packages/react-model-ui/src/ModelProvider.tsx index 73fbcff..4476e27 100644 --- a/packages/react-model-ui/src/ModelProvider.tsx +++ b/packages/react-model-ui/src/ModelProvider.tsx @@ -5,29 +5,24 @@ import { CrossModelRoot } from '@crossbreeze/protocol'; import * as React from 'react'; import { useImmerReducer } from 'use-immer'; -import { ModelContext, ModelDispatchContext, SaveCallback, SaveModelContext, defaultSaveCallback } from './ModelContext'; +import { + ModelContext, + ModelDirtyContext, + ModelDispatchContext, + ModelQueryApiContext, + OpenModelContext, + SaveModelContext +} from './ModelContext'; import { DispatchAction, ModelReducer, ModelState } from './ModelReducer'; +import { ModelProviderProps } from './ModelViewer'; export type UpdateCallback = (model: CrossModelRoot) => void; /** * Represents the properties required by the ModelProvider component. */ -export interface ModelProviderProps extends React.PropsWithChildren { - /** - * The model object that will be provided to the child components. - */ +export interface InternalModelProviderProps extends React.PropsWithChildren, ModelProviderProps { model: CrossModelRoot; - - /** - * A callback that will be triggered when the model is updated by this component. - */ - onModelUpdate: UpdateCallback; - - /** - * A callback that is triggered when this components want to save it's model - */ - onModelSave?: SaveCallback; } /** @@ -40,19 +35,22 @@ export interface ModelProviderProps extends React.PropsWithChildren { */ export function ModelProvider({ model, - onModelSave = defaultSaveCallback, + dirty, + onModelOpen, + onModelSave, onModelUpdate, + modelQueryApi, children -}: ModelProviderProps): React.ReactElement { +}: InternalModelProviderProps): React.ReactElement { const [appState, dispatch] = useImmerReducer(ModelReducer, { model, reason: 'model:initial' }); React.useEffect(() => { // triggered when a new model is passed from the outside via props -> update internal state dispatch({ type: 'model:update', model }); - }, [model, dispatch]); + }, [dispatch, model]); React.useEffect(() => { - if (appState.reason !== 'model:update') { + if (appState.reason !== 'model:initial' && appState.reason !== 'model:update') { // triggered when the internal model is updated, pass update to callback onModelUpdate(appState.model); } @@ -60,9 +58,15 @@ export function ModelProvider({ return ( - - {children} - + + + + + {children} + + + + ); } diff --git a/packages/react-model-ui/src/ModelReducer.tsx b/packages/react-model-ui/src/ModelReducer.tsx index 2d29e02..5e4315b 100644 --- a/packages/react-model-ui/src/ModelReducer.tsx +++ b/packages/react-model-ui/src/ModelReducer.tsx @@ -1,7 +1,13 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossModelRoot, EntityAttribute } from '@crossbreeze/protocol'; +import { + CrossModelRoot, + EntityAttribute, + EntityAttributeType, + RelationshipAttribute, + RelationshipAttributeType +} from '@crossbreeze/protocol'; export interface ModelAction { type: string; @@ -28,40 +34,77 @@ export interface EntityAttributeUpdateAction extends ModelAction { attribute: EntityAttribute; } -export interface EntityAttributeChangeNameAction extends ModelAction { - type: 'entity:attribute:change-name'; +export interface EntityAttributeAddEmptyAction extends ModelAction { + type: 'entity:attribute:add-empty'; +} + +export interface EntityAttributeMoveUpAction extends ModelAction { + type: 'entity:attribute:move-attribute-up'; attributeIdx: number; - name: string; } -export interface EntityAttributeChangeDatatypeAction extends ModelAction { - type: 'entity:attribute:change-datatype'; +export interface EntityAttributeMoveDownAction extends ModelAction { + type: 'entity:attribute:move-attribute-down'; attributeIdx: number; - datatype: string; } -export interface EntityAttributeChangeDescriptionAction extends ModelAction { - type: 'entity:attribute:change-description'; +export interface EntityAttributeDeleteAction extends ModelAction { + type: 'entity:attribute:delete-attribute'; attributeIdx: number; +} + +export interface RelationshipUpdateAction extends ModelAction { + type: 'relationship:update'; + name: string; +} + +export interface RelationshipChangeNameAction extends ModelAction { + type: 'relationship:change-name'; + name: string; +} + +export interface RelationshipChangeDescriptionAction extends ModelAction { + type: 'relationship:change-description'; description: string; } -export interface EntityAttributeAddEmptyAction extends ModelAction { - type: 'entity:attribute:add-empty'; +export interface RelationshipChangeTypeAction extends ModelAction { + type: 'relationship:change-type'; + newType: string; } -export interface EntityAttributeMoveUpAction extends ModelAction { - type: 'entity:attribute:move-attribute-up'; +export interface RelationshipChangeParentAction extends ModelAction { + type: 'relationship:change-parent'; + parent?: string; +} + +export interface RelationshipChangeChildAction extends ModelAction { + type: 'relationship:change-child'; + child?: string; +} + +export interface RelationshipAttributeUpdateAction extends ModelAction { + type: 'relationship:attribute:update'; attributeIdx: number; + attribute: RelationshipAttribute; } -export interface EntityAttributeMoveDownAction extends ModelAction { - type: 'entity:attribute:move-attribute-down'; +export interface RelationshipAttributeAddEmptyAction extends ModelAction { + type: 'relationship:attribute:add-empty'; +} + +export interface RelationshipAttributeMoveUpAction extends ModelAction { + type: 'relationship:attribute:move-attribute-up'; attributeIdx: number; } -export interface EntityAttributeDeleteAction extends ModelAction { - type: 'entity:attribute:delete-attribute'; +export interface RelationshipAttributeMoveDownAction extends ModelAction { + type: 'relationship:attribute:move-attribute-down'; + attributeIdx: number; +} + +export interface RelationshipAttributeDeleteAction extends ModelAction { + type: 'relationship:attribute:delete-attribute'; attributeIdx: number; } @@ -70,13 +113,21 @@ export type DispatchAction = | EntityChangeNameAction | EntityChangeDescriptionAction | EntityAttributeUpdateAction - | EntityAttributeChangeNameAction - | EntityAttributeChangeDatatypeAction - | EntityAttributeChangeDescriptionAction | EntityAttributeAddEmptyAction | EntityAttributeMoveUpAction | EntityAttributeMoveDownAction - | EntityAttributeDeleteAction; + | EntityAttributeDeleteAction + | RelationshipUpdateAction + | RelationshipChangeNameAction + | RelationshipChangeDescriptionAction + | RelationshipChangeTypeAction + | RelationshipChangeParentAction + | RelationshipChangeChildAction + | RelationshipAttributeUpdateAction + | RelationshipAttributeAddEmptyAction + | RelationshipAttributeMoveUpAction + | RelationshipAttributeMoveDownAction + | RelationshipAttributeDeleteAction; export type ModelStateReason = DispatchAction['type'] | 'model:initial'; @@ -102,33 +153,27 @@ export function ModelReducer(state: ModelState, action: DispatchAction): ModelSt break; case 'entity:change-name': - state.model.entity!.name = action.name; + state.model.entity!.name = undefinedIfEmpty(action.name); break; case 'entity:change-description': - state.model.entity!.description = action.description; + state.model.entity!.description = undefinedIfEmpty(action.description); break; case 'entity:attribute:update': - state.model.entity!.attributes[action.attributeIdx] = action.attribute; - break; - - case 'entity:attribute:change-datatype': - state.model.entity!.attributes[action.attributeIdx].datatype = action.datatype; - break; - - case 'entity:attribute:change-description': - state.model.entity!.attributes[action.attributeIdx].description = action.description; - break; - - case 'entity:attribute:change-name': - state.model.entity!.attributes[action.attributeIdx].name = action.name; + state.model.entity!.attributes[action.attributeIdx] = { + ...action.attribute, + name: undefinedIfEmpty(action.attribute.name), + description: undefinedIfEmpty(action.attribute.description) + }; break; case 'entity:attribute:add-empty': state.model.entity!.attributes.push({ - $type: 'EntityAttribute', - name: `empty_attribute${state.model.entity!.attributes.length}`, + $type: EntityAttributeType, + id: findName('Attribute', state.model.entity!.attributes, attr => attr.id!), + $globalId: 'toBeAssigned', + name: findName('New Attribute', state.model.entity!.attributes, attr => attr.name!), datatype: 'Varchar' }); break; @@ -152,9 +197,78 @@ export function ModelReducer(state: ModelState, action: DispatchAction): ModelSt case 'entity:attribute:delete-attribute': state.model.entity!.attributes.splice(action.attributeIdx, 1); break; + + case 'relationship:change-name': + state.model.relationship!.name = undefinedIfEmpty(action.name); + break; + + case 'relationship:change-description': + state.model.relationship!.description = undefinedIfEmpty(action.description); + break; + + case 'relationship:change-type': + state.model.relationship!.type = action.newType; + break; + + case 'relationship:change-parent': + state.model.relationship!.parent = action.parent; + break; + + case 'relationship:change-child': + state.model.relationship!.child = action.child; + break; + + case 'relationship:attribute:update': + state.model.relationship!.attributes[action.attributeIdx] = action.attribute; + break; + + case 'relationship:attribute:add-empty': + state.model.relationship!.attributes.push({ + $type: RelationshipAttributeType + }); + break; + + case 'relationship:attribute:move-attribute-up': + if (action.attributeIdx > 0) { + const temp = state.model.relationship!.attributes[action.attributeIdx - 1]; + state.model.relationship!.attributes[action.attributeIdx - 1] = state.model.relationship!.attributes[action.attributeIdx]; + state.model.relationship!.attributes[action.attributeIdx] = temp; + } + break; + + case 'relationship:attribute:move-attribute-down': + if (action.attributeIdx < state.model.relationship!.attributes.length - 1) { + const temp = state.model.relationship!.attributes[action.attributeIdx + 1]; + state.model.relationship!.attributes[action.attributeIdx + 1] = state.model.relationship!.attributes[action.attributeIdx]; + state.model.relationship!.attributes[action.attributeIdx] = temp; + } + break; + + case 'relationship:attribute:delete-attribute': + state.model.relationship!.attributes.splice(action.attributeIdx, 1); + break; + default: { throw Error('Unknown ModelReducer action'); } } return state; } + +function findName(suggestion: string, data: T[], nameGetter: (element: T) => string): string { + const names = data.map(nameGetter); + let name = suggestion; + let index = 1; + while (names.includes(name)) { + name = name + index++; + } + return name; +} + +export function undefinedIfEmpty(string?: string): string | undefined { + return valueIfEmpty(string, undefined); +} + +export function valueIfEmpty(value: V | undefined, defaultValue: T): V | T { + return !value ? defaultValue : value; +} diff --git a/packages/react-model-ui/src/ModelViewer.tsx b/packages/react-model-ui/src/ModelViewer.tsx index 4ee8f8d..5022cfe 100644 --- a/packages/react-model-ui/src/ModelViewer.tsx +++ b/packages/react-model-ui/src/ModelViewer.tsx @@ -4,27 +4,49 @@ import { CrossModelRoot } from '@crossbreeze/protocol'; import * as React from 'react'; -import { SaveCallback } from './ModelContext'; +import { ModelQueryApi, OpenCallback, SaveCallback } from './ModelContext'; import { ModelProvider, UpdateCallback } from './ModelProvider'; import { ErrorView } from './views/ErrorView'; -export interface ModelViewerProps { - model: CrossModelRoot | undefined; +export interface ModelProviderProps { + /** + * The model object that will be provided to the child components. + */ + model?: CrossModelRoot; + + dirty: boolean; + + /** + * A callback that will be triggered when the model is updated by this component. + */ onModelUpdate: UpdateCallback; + + /** + * A callback that is triggered when this components want to save it's model + */ onModelSave?: SaveCallback; + + /** + * A callback that is triggered when this components want to save it's model + */ + onModelOpen?: OpenCallback; + + /** + * An API to query additional Model data. + */ + modelQueryApi: ModelQueryApi; } -export function withModelProvider

( - WrappedComponent: React.ComponentType

, - { model, onModelUpdate, onModelSave }: MVP -): (props: P) => React.ReactElement { - const ModelViewerReadyComponent = (componentProps: P): React.ReactElement => { - if (!model) { +export function modelComponent( + WrappedComponent: React.ComponentType

+): React.ComponentType

{ + const ModelViewerReadyComponent = (props: P & MVP & React.JSX.IntrinsicAttributes): React.ReactElement => { + if (!props.model) { return ; } return ( - - + + ); }; diff --git a/packages/react-model-ui/src/ThemedViewer.tsx b/packages/react-model-ui/src/ThemedViewer.tsx new file mode 100644 index 0000000..f00a738 --- /dev/null +++ b/packages/react-model-ui/src/ThemedViewer.tsx @@ -0,0 +1,172 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import { CssBaseline, Theme, ThemeOptions, ThemeProvider, createTheme } from '@mui/material'; +import React = require('react'); + +const sharedThemeOptions: ThemeOptions = { + components: { + MuiButton: { + defaultProps: { + size: 'small' + } + }, + MuiButtonGroup: { + defaultProps: { + size: 'small' + } + }, + MuiCheckbox: { + defaultProps: { + size: 'small' + } + }, + MuiFab: { + defaultProps: { + size: 'small' + } + }, + MuiFormControl: { + defaultProps: { + size: 'small', + margin: 'normal' + } + }, + MuiFormHelperText: { + defaultProps: { + margin: 'dense' + } + }, + MuiIconButton: { + defaultProps: { + size: 'small' + } + }, + MuiInputBase: { + defaultProps: { + fullWidth: true + } + }, + MuiInputLabel: { + defaultProps: {} + }, + MuiRadio: { + defaultProps: { + size: 'small' + } + }, + MuiSwitch: { + defaultProps: { + size: 'small' + } + }, + MuiTextField: { + defaultProps: { + size: 'small', + fullWidth: true, + sx: { margin: '8px 0' } + } + }, + MuiAutocomplete: { + defaultProps: { + size: 'small' + } + }, + MuiAccordion: { + defaultProps: { + disableGutters: true, + elevation: 0, + square: true, + defaultExpanded: true + } + }, + MuiAccordionSummary: { + defaultProps: { + expandIcon: , + sx: { minHeight: 'auto' } + } + } + }, + shape: { + borderRadius: 2 + }, + spacing: 4, + typography: { + fontSize: 12, + fontWeightBold: 400 + } +}; + +const lightTheme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#007acc' /* --theia-button-background */, + contrastText: '#ffffff' /* --theia-button-foreground */, + dark: '#0062a3' /* --theia-button-hoverBackground */, + light: '#1a85ff' /* --theia-editorInfo-foreground */ + }, + secondary: { + main: '#5f6a79' /* --theia-button-secondaryBackground */, + contrastText: '#ffffff' /* --theia-button-secondaryForeground */, + dark: '#4c5561' /* --theia-button-secondaryHoverBackground */, + light: '#6c6c6c' /* --theia-editorHint-foreground */ + }, + background: { + default: '#ffffff' /* --theia-editor-background */, + paper: '#ffffff' /* --theia-editor-background */ + }, + text: { + primary: '#000000' /* --theia-editor-foreground */ + } + }, + ...sharedThemeOptions +}); + +// Define dark theme settings +const darkTheme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: '#0e639c' /* --theia-button-background */, + contrastText: '#ffffff' /* --theia-button-foreground */, + dark: '#1177bb' /* --theia-button-hoverBackground */, + light: '#3794ff' /* --theia-editorInfo-foreground */ + }, + secondary: { + main: '#3a3d41' /* --theia-button-secondaryBackground */, + contrastText: '#ffffff' /* --theia-button-secondaryForeground */, + dark: '#45494e' /* --theia-button-secondaryHoverBackground */, + light: 'rgba(238, 238, 238, 0.7)' /* --theia-editorHint-foreground */ + }, + background: { + default: '#1e1e1e' /* --theia-editor-background */, + paper: '#1e1e1e' /* --theia-editor-background */ + }, + text: { + primary: '#d4d4d4' /* --theia-editor-foreground */ + } + }, + ...sharedThemeOptions +}); + +export type ThemeType = 'light' | 'dark' | 'hc' | 'hcLight'; + +const getTheme = (type: ThemeType): Theme => (type === 'dark' ? darkTheme : lightTheme); + +export interface ThemingProps { + theme: ThemeType; +} + +export function themed( + WrappedComponent: React.ComponentType

+): React.ComponentType

{ + const ThemedComponent = (props: P & TP & React.JSX.IntrinsicAttributes): React.ReactElement => ( + + + + + ); + return ThemedComponent; +} diff --git a/packages/react-model-ui/src/index.ts b/packages/react-model-ui/src/index.ts index ec61c82..f3eb014 100644 --- a/packages/react-model-ui/src/index.ts +++ b/packages/react-model-ui/src/index.ts @@ -5,4 +5,5 @@ export * from './ModelContext'; export * from './ModelProvider'; export * from './ModelReducer'; export * from './ModelViewer'; +export * from './ThemedViewer'; export * from './views'; diff --git a/packages/react-model-ui/src/views/FormSection.tsx b/packages/react-model-ui/src/views/FormSection.tsx new file mode 100644 index 0000000..4720f25 --- /dev/null +++ b/packages/react-model-ui/src/views/FormSection.tsx @@ -0,0 +1,27 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ + +import { Accordion, AccordionDetails, AccordionSummary, Typography } from '@mui/material'; +import * as React from 'react'; + +export interface FormSectionProps extends React.PropsWithChildren { + label: string; +} + +export function FormSection({ label, children }: FormSectionProps): React.ReactElement { + return ( + + + {label} + + {children} + + ); +} diff --git a/packages/react-model-ui/src/views/common/AsyncAutoComplete.tsx b/packages/react-model-ui/src/views/common/AsyncAutoComplete.tsx new file mode 100644 index 0000000..003c0ea --- /dev/null +++ b/packages/react-model-ui/src/views/common/AsyncAutoComplete.tsx @@ -0,0 +1,77 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +/* eslint-disable no-null/no-null */ +import { Autocomplete, AutocompleteProps, CircularProgress, TextField, TextFieldProps } from '@mui/material'; +import React = require('react'); + +export interface AsyncAutoCompleteProps extends Omit, 'renderInput' | 'options'> { + label: string; + optionLoader: () => Promise; + textFieldProps?: TextFieldProps; +} + +// Based on https://mui.com/material-ui/react-autocomplete/ +export default function AsyncAutoComplete({ + label, + optionLoader, + textFieldProps, + ...props +}: AsyncAutoCompleteProps): React.ReactElement { + const [open, setOpen] = React.useState(false); + const [options, setOptions] = React.useState([]); + const loading = open && options.length === 0; + + React.useEffect(() => { + let active = true; + if (!loading) { + return undefined; + } + const loadOperation = async (): Promise => { + const loadedOptions = await optionLoader(); + if (active) { + setOptions([...loadedOptions]); + } + }; + loadOperation(); + + return () => { + active = false; + }; + }, [loading, optionLoader]); + + React.useEffect(() => { + if (!open) { + setOptions([]); + } + }, [open]); + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + options={options} + loading={loading} + disableClearable={true} + handleHomeEndKeys={true} + {...props} + renderInput={params => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + /> + ); +} diff --git a/packages/react-model-ui/src/views/common/AttributeGrid.tsx b/packages/react-model-ui/src/views/common/AttributeGrid.tsx new file mode 100644 index 0000000..9f471ec --- /dev/null +++ b/packages/react-model-ui/src/views/common/AttributeGrid.tsx @@ -0,0 +1,208 @@ +/******************************************************************************** + * Copyright (c) 2024 CrossBreeze. + ********************************************************************************/ +import { DeleteOutlined } from '@mui/icons-material'; +import AddIcon from '@mui/icons-material/Add'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import Button from '@mui/material/Button'; +import { + DataGrid, + DataGridProps, + GridActionsCellItem, + GridColDef, + GridEditCellProps, + GridPreProcessEditCellProps, + GridRowModes, + GridRowModesModel, + GridToolbarContainer, + GridValidRowModel +} from '@mui/x-data-grid'; +import * as React from 'react'; + +export type AttributeRow = T & + GridValidRowModel & { + idx: number; + }; + +export type ValidationFunction =

(field: P, value: V) => string | undefined; + +export interface AttributeGridProps extends Omit, 'rows' | 'columns' | 'processRowUpdate'> { + attributes: T[]; + attributeColumns: GridColDef[]; + onNewAttribute: () => void; + onUpdate: (toUpdate: AttributeRow) => void; + onDelete: (toDelete: AttributeRow) => void; + onMoveUp: (toMoveUp: AttributeRow) => void; + onMoveDown: (toMoveDown: AttributeRow) => void; + validateAttribute?: ValidationFunction; +} + +export default function AttributeGrid({ + attributes, + attributeColumns, + onNewAttribute, + onUpdate, + onDelete, + onMoveUp, + onMoveDown, + validateAttribute, + ...props +}: AttributeGridProps): React.ReactElement { + const [rowModesModel, setRowModesModel] = React.useState({}); + const [columns, setColumns] = React.useState>[]>([]); + const [rows, setRows] = React.useState[]>([]); + + const validateRow = React.useCallback( + (params: GridPreProcessEditCellProps, column: GridColDef): GridEditCellProps => { + const error = validateAttribute?.(column.field, params.props.value); + return { ...params.props, error }; + }, + [validateAttribute] + ); + + const handleRowUpdate = React.useCallback( + (newRow: AttributeRow, oldRow: AttributeRow): AttributeRow => { + const updatedRow = mergeRightToLeft(oldRow, newRow); + onUpdate(updatedRow); + return updatedRow; + }, + [onUpdate] + ); + + const handleRowModesModelChange = React.useCallback((newRowModesModel: GridRowModesModel): void => { + setRowModesModel(newRowModesModel); + }, []); + + const getRowId = React.useCallback((attribute: AttributeRow): number => attribute.idx, []); + + React.useEffect(() => { + if (rows.length - attributes.length === -1) { + setRowModesModel(oldModel => ({ ...oldModel, [rows.length]: { mode: GridRowModes.Edit } })); + } + }, [attributes.length, rows]); + + React.useEffect(() => { + setRows(attributes.map((data, idx) => ({ ...data, idx, isNew: false }))); + }, [attributes]); + + React.useEffect(() => { + const gridColumns = attributeColumns.map(column => ({ + preProcessEditCellProps: params => validateRow(params, column), + ...column + })) as GridColDef>[]; + gridColumns.push({ + field: 'actions', + type: 'actions', + cellClassName: 'actions', + getActions: params => [ + } + label='Delete' + onClick={() => onDelete(params.row)} + color='inherit' + />, + } + label='Move Up' + onClick={() => onMoveUp(params.row)} + color='inherit' + disabled={params.row.idx === 0} + />, + } + label='Move Down' + onClick={() => onMoveDown(params.row)} + color='inherit' + disabled={params.row.idx === rows.length - 1} + /> + ] + }); + setColumns(gridColumns); + }, [attributeColumns, onDelete, onMoveDown, onMoveUp, rows.length, validateRow]); + + const EditToolbar = React.useMemo( + () => ( + + + + ), + [onNewAttribute] + ); + + return ( + > + rows={rows} + getRowId={getRowId} + columns={columns} + editMode='row' + rowModesModel={rowModesModel} + rowSelection={true} + onRowModesModelChange={handleRowModesModelChange} + processRowUpdate={handleRowUpdate} + hideFooter={true} + density='compact' + disableColumnFilter={true} + disableColumnSelector={true} + disableColumnSorting={true} + disableMultipleRowSelection={true} + disableColumnMenu={true} + disableDensitySelector={true} + slots={{ toolbar: () => EditToolbar }} + sx={{ + fontSize: '1em', + width: '100%', + '&.MuiDataGrid-root': { + width: '100%' + }, + '& .actions': { + color: 'text.secondary' + }, + '& .textPrimary': { + color: 'text.primary' + }, + '& :focus': { + outline: 'none !important' + }, + '& .MuiOutlinedInput-notchedOutline': { + borderWidth: 0 + }, + '& .Mui-focused .MuiOutlinedInput-notchedOutline': { + borderWidth: '0 !important' + }, + '& .MuiDataGrid-row--editing .MuiDataGrid-cell': { + backgroundColor: 'transparent !important' + }, + '& .MuiInputBase-input': { + padding: '0 9px', + fontSize: '13px' + }, + '& .MuiAutocomplete-input, & .MuiAutocomplete-input': { + padding: '2px 3px !important', + fontSize: '13px' + }, + '& .MuiSelect-select': { + paddingTop: '1px' + }, + '& .Mui-error': { + backgroundColor: 'var(--theia-inputValidation-errorBackground)', + color: 'var(--theia-inputValidation-errorBorder)' + } + }} + {...props} + /> + ); +} + +function mergeRightToLeft>(one: T, ...more: T[]): T { + let result = { ...one }; + more.forEach( + right => (result = Object.entries(right).reduce((acc, [key, value]) => ({ ...acc, [key]: value ?? acc[key] }), { ...result })) + ); + return result; +} diff --git a/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx b/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx index db32e71..62575be 100644 --- a/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx +++ b/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx @@ -1,290 +1,118 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { EntityAttribute } from '@crossbreeze/protocol'; -import AddIcon from '@mui/icons-material/Add'; -import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { Box, FormControl, MenuItem, Select, SelectChangeEvent, Tooltip, TooltipProps, styled, tooltipClasses } from '@mui/material'; -import Button from '@mui/material/Button'; -import { - DataGrid, - GridActionsCellItem, - GridCellEditStartParams, - GridCellModes, - GridCellParams, - GridColDef, - GridEditCellProps, - GridEditInputCell, - GridPreProcessEditCellProps, - GridRenderEditCellParams, - GridRowId, - GridRowModel, - GridRowsProp, - GridToolbarContainer, - MuiEvent, - useGridApiRef -} from '@mui/x-data-grid'; +import { EntityAttribute, EntityAttributeType } from '@crossbreeze/protocol'; +import { GridColDef, GridRowModel } from '@mui/x-data-grid'; import * as React from 'react'; -import { useModel, useModelDispatch } from '../../ModelContext'; +import { useEntity, useModelDispatch } from '../../ModelContext'; +import { ErrorView } from '../ErrorView'; +import AttributeGrid, { AttributeRow, ValidationFunction } from './AttributeGrid'; export function EntityAttributesDataGrid(): React.ReactElement { - // Context variables to handle model state. - const model = useModel(); - const gridApiRef = useGridApiRef(); + const entity = useEntity(); const dispatch = useModelDispatch(); - const [currentEdit, setCurrentEdit] = React.useState({} as CurrentEdit); // Callback for when the user stops editing a cell. - const handleRowUpdate = (updatedRow: GridRowModel, originalRow: GridRowModel): GridRowModel => { - // Handle change of name property. - dispatch({ - type: 'entity:attribute:update', - attributeIdx: updatedRow.id, - attribute: { - $type: 'EntityAttribute', - id: updatedRow.attributeId, - name: updatedRow.name, - datatype: updatedRow.datatype, - description: updatedRow.description - } - }); - return updatedRow; - }; - - // Cell edit handler to block editing of any other row, when a row is erroneous. - const handleOnCellEditStart = (params: GridCellEditStartParams, event: MuiEvent): void => { - if (currentEdit.row_id && currentEdit.field) { - if (gridApiRef.current.getCellMode(currentEdit.row_id, currentEdit.field) === GridCellModes.Edit) { - gridApiRef.current.stopCellEditMode({ - id: currentEdit.row_id, - field: currentEdit.field, - ignoreModifications: true // will also discard the changes made - }); - } - } - - setCurrentEdit({ - row_id: params.id as number, - field: params.field - }); - }; - - const handleAddAttribute = (): void => { - dispatch({ - type: 'entity:attribute:add-empty' - }); - }; - - // Callback for when the user selects a new datatype in the table - function dataTypeChangedDispatch(id: number, newVal: string): void { - dispatch({ - type: 'entity:attribute:change-datatype', - attributeIdx: id, - datatype: newVal - }); - } - - const handleAttributeUpward = (id: GridRowId) => () => { - dispatch({ - type: 'entity:attribute:move-attribute-up', - attributeIdx: Number(id) - }); - }; - - const handleAttributeDownward = (id: GridRowId) => () => { - dispatch({ - type: 'entity:attribute:move-attribute-down', - attributeIdx: Number(id) - }); - }; - - const handleAttributeDelete = (id: GridRowId) => () => { - dispatch({ - type: 'entity:attribute:delete-attribute', - attributeIdx: Number(id) - }); - }; - - const handleRowUpdateError = (error: Error) => () => { - console.log(error.message); - }; - - // Check if model initialized. Has to be here otherwise the compiler complains. - if (model.entity === undefined) { - return <>; - } + const handleRowUpdate = React.useCallback( + (attribute: AttributeRow): GridRowModel => { + // Handle change of name property. + dispatch({ + type: 'entity:attribute:update', + attributeIdx: attribute.idx, + attribute: { + $type: EntityAttributeType, + $globalId: attribute.id, + id: attribute.id, + name: attribute.name, + datatype: attribute.datatype, + description: attribute.description + } + }); + return attribute; + }, + [dispatch] + ); - // Pre-process function for mandatory cells. This will show an error message on the cell of the field is empty. - const preProcessMandatoryCellProps = (params: GridPreProcessEditCellProps): GridEditCellProps => { - const error = params.props.value!.toString().length === 0; - const errormessage = error ? 'This field is mandatory!' : undefined; - return { ...params.props, error: error, errormessage: errormessage }; - }; + const handleAddAttribute = React.useCallback((): void => { + dispatch({ type: 'entity:attribute:add-empty' }); + }, [dispatch]); - // Cols and rows for the data grid - const rows = createRows(model.entity.attributes); - const columns: GridColDef[] = [ - // { field: 'id', headerName: 'Id', width: 40 }, - { - field: 'name', - headerName: 'Name', - minWidth: 200, - editable: true, - preProcessEditCellProps: preProcessMandatoryCellProps, - renderEditCell: renderValidateableCell - }, - { - field: 'datatype', - headerName: 'Data type', - minWidth: 120, - renderCell: (params: GridCellParams) => + const handleAttributeUpward = React.useCallback( + (attribute: AttributeRow): void => { + dispatch({ + type: 'entity:attribute:move-attribute-up', + attributeIdx: attribute.idx + }); }, - { field: 'description', headerName: 'Description', editable: true, minWidth: 200 }, - { - field: 'actions', - type: 'actions', - width: 80, - getActions: params => [ - } - label='Move up' - onClick={handleAttributeUpward(params.id)} - showInMenu - />, - } - label='Move down' - onClick={handleAttributeDownward(params.id)} - showInMenu - />, - } - label='Delete' - onClick={handleAttributeDelete(params.id)} - showInMenu - /> - ] - } - ]; - - return ( - + [dispatch] ); -} - -// Style tooltip element, to show the error message of a validation. -const StyledTooltip = styled(({ className, ...props }: TooltipProps) => )( - ({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: theme.palette.error.main, - color: theme.palette.error.contrastText - } - }) -); -// Custom edit cell element, which can show an error in a tooltip. -function ValidateableEditInputCell(props: GridRenderEditCellParams): React.JSX.Element { - return ( -

-
- -
- - -
-
-
-
+ const handleAttributeDownward = React.useCallback( + (attribute: AttributeRow): void => { + dispatch({ + type: 'entity:attribute:move-attribute-down', + attributeIdx: attribute.idx + }); + }, + [dispatch] ); -} -// Render function for rendering the validateable cell. -function renderValidateableCell(params: GridRenderEditCellParams): React.JSX.Element { - return ; -} + const handleAttributeDelete = React.useCallback( + (attribute: AttributeRow): void => { + dispatch({ + type: 'entity:attribute:delete-attribute', + attributeIdx: attribute.idx + }); + }, + [dispatch] + ); -// Edit toolbar with the button to add an attribute. -function EditToolbar(props: any): React.ReactElement { - return ( - - - + const validateAttribute = React.useCallback>( +

(field: P, value: V): string | undefined => { + if (field === 'name' && !value) { + return 'Invalid Name'; + } + return undefined; + }, + [] ); -} -// Data Type selection (drop-down) element. -function DataTypeSelect(props: any): React.ReactElement { - const { id, value, onChange } = props; - const options = ['Integer', 'Float', 'Char', 'Varchar', 'Bool']; + const columns = React.useMemo( + () => [ + { + field: 'name', + headerName: 'Name', + flex: 200, + editable: true, + type: 'string' + }, + { + field: 'datatype', + headerName: 'Data type', + editable: true, + type: 'singleSelect', + valueOptions: ['Integer', 'Float', 'Char', 'Varchar', 'Bool'] + }, + { field: 'description', headerName: 'Description', editable: true, flex: 200 } + ], + [] + ); - // When the custom value does not exist yet in the options, we add it to the list - if (props.datatype !== undefined && options.findIndex((item: string) => item.toLowerCase() === props.datatype.toLowerCase()) === -1) { - options.push(props.datatype); + // Check if model initialized. Has to be here otherwise the compiler complains. + if (entity === undefined) { + return ; } - return ( - - - + ); } - -// Function to construct an error of rows. -function createRows(attributes: Array): GridRowsProp { - const rows = attributes.map((attribute, index) => ({ - id: index, - attributeId: attribute.id, - name: attribute.name, - datatype: attribute.datatype, - description: attribute.description - })); - - return rows; -} - -// Interface for storing the currently being edited row and field. -interface CurrentEdit { - row_id?: number; - field?: string; -} diff --git a/packages/react-model-ui/src/views/common/EntityGeneralForm.tsx b/packages/react-model-ui/src/views/common/EntityGeneralForm.tsx deleted file mode 100644 index 48c4a20..0000000 --- a/packages/react-model-ui/src/views/common/EntityGeneralForm.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 CrossBreeze. - ********************************************************************************/ -import * as React from 'react'; -import '../../../style/entity-general-form.css'; -import { useModel, useModelDispatch } from '../../ModelContext'; -import { ErrorView } from '../ErrorView'; - -interface EntityGeneralProps extends React.HTMLProps {} - -export function EntityGeneralForm(_props: EntityGeneralProps): React.ReactElement { - // Context variables to handle model state. - const model = useModel(); - const dispatch = useModelDispatch(); - - // Check if model initialized. Has to be here otherwise the compiler complains. - if (model.entity === undefined) { - return ; - } - - return ( -

-
- - ) => { - dispatch({ type: 'entity:change-name', name: e.target.value ? e.target.value : '' }); - }} - /> -
- -
- -