diff --git a/examples/mapping-example/ExampleDWH/CompleteCustomerMapping.mapping.cm b/examples/mapping-example/ExampleDWH/CompleteCustomerMapping.mapping.cm index f01b84f..54d6f92 100644 --- a/examples/mapping-example/ExampleDWH/CompleteCustomerMapping.mapping.cm +++ b/examples/mapping-example/ExampleDWH/CompleteCustomerMapping.mapping.cm @@ -18,11 +18,9 @@ mapping: sources: - CustomerSourceObject.Country - attribute: FixedNumber - sources: - - 1 + expression: "1" - attribute: FixedString - sources: - - "Hoppa" + expression: "Hoppa" - attribute: Name sources: - CustomerSourceObject.FirstName diff --git a/examples/mapping-example/ExampleDWH/DWH.mapping.cm b/examples/mapping-example/ExampleDWH/DWH.mapping.cm index 72945ec..ab8bf1c 100644 --- a/examples/mapping-example/ExampleDWH/DWH.mapping.cm +++ b/examples/mapping-example/ExampleDWH/DWH.mapping.cm @@ -40,8 +40,6 @@ mapping: sources: - CalcAgeSourceObject.Age - attribute: FixedNumber - sources: - - 1337 + expression: "1337" - attribute: FixedString - sources: - - "Fixed String" \ No newline at end of file + expression: "Fixed String" \ No newline at end of file diff --git a/extensions/crossmodel-lang/src/glsp-server/common/nodes.ts b/extensions/crossmodel-lang/src/glsp-server/common/nodes.ts index 9b9f008..131c427 100644 --- a/extensions/crossmodel-lang/src/glsp-server/common/nodes.ts +++ b/extensions/crossmodel-lang/src/glsp-server/common/nodes.ts @@ -3,7 +3,7 @@ ********************************************************************************/ import { ATTRIBUTE_COMPARTMENT_TYPE, createLeftPortId, createRightPortId } from '@crossbreeze/protocol'; -import { GCompartment, GLabel, GPort } from '@eclipse-glsp/server'; +import { GCompartment, GCompartmentBuilder, GLabel, GPort } from '@eclipse-glsp/server'; import { Attribute } from '../../language-server/generated/ast.js'; import { CrossModelIndex } from './cross-model-index.js'; @@ -19,50 +19,75 @@ export function createHeader(text: string, containerId: string): GCompartment { .build(); } -export function createAttributesCompartment(attributes: Attribute[], containerId: string, index: CrossModelIndex): GCompartment { - const attributesContainer = GCompartment.builder() - .id(`${containerId}_attributes`) - .addCssClass('attributes-compartment') - .layout('vbox') - .addLayoutOption('hAlign', 'left') - .addLayoutOption('paddingBottom', 0); +export type MarkerFunction = (attribute: T, id: string) => GLabel | undefined; - // Add the attributes of the entity. +export function createAttributesCompartment( + attributes: T[], + containerId: string, + index: CrossModelIndex, + markerFn?: MarkerFunction +): GCompartment { + const attributesContainer = new AttributesCompartmentBuilder().set(containerId); for (const attribute of attributes) { - attributesContainer.add(createAttributeCompartment(attribute, index)); + attributesContainer.add(AttributeCompartment.builder().set(attribute, index, markerFn).build()); } return attributesContainer.build(); } -export function createAttributeCompartment(attribute: Attribute, index: CrossModelIndex): GCompartment { - const attributeId = index.createId(attribute); - const attributeCompartment = GCompartment.builder() - .id(attributeId) - .type(ATTRIBUTE_COMPARTMENT_TYPE) - .addCssClass('attribute-compartment') - .layout('hbox') - .addLayoutOption('paddingBottom', 3) - .addLayoutOption('paddingTop', 3) - .addLayoutOption('hGap', 3); +export class AttributesCompartmentBuilder extends GCompartmentBuilder { + constructor() { + super(GCompartment); + } - const leftPortId = createLeftPortId(attributeId); - index.indexSemanticElement(leftPortId, attribute); - attributeCompartment.add(GPort.builder().id(leftPortId).build()); + set(containerId: string): this { + this.id(`${containerId}_attributes`) + .addCssClass('attributes-compartment') + .layout('vbox') + .addLayoutOption('hAlign', 'left') + .addLayoutOption('paddingBottom', 0); + return this; + } +} - attributeCompartment.add( - GLabel.builder() - .id(`${attributeId}_attribute_name`) - .text(attribute.name || '') - .addCssClass('attribute') - .build() - ); - attributeCompartment.add(GLabel.builder().text(':').id(`${attributeId}_attribute_del`).build()); - attributeCompartment.add( - GLabel.builder().id(`${attributeId}_attribute_datatype`).text(attribute.datatype).addCssClass('datatype').build() - ); - const rightPortId = createRightPortId(attributeId); - index.indexSemanticElement(rightPortId, attribute); - attributeCompartment.add(GPort.builder().id(rightPortId).build()); +export class AttributeCompartment extends GCompartment { + override type = ATTRIBUTE_COMPARTMENT_TYPE; - return attributeCompartment.build(); + static override builder(): AttributeCompartmentBuilder { + return new AttributeCompartmentBuilder(AttributeCompartment).type(ATTRIBUTE_COMPARTMENT_TYPE); + } +} + +export class AttributeCompartmentBuilder extends GCompartmentBuilder { + set(attribute: T, index: CrossModelIndex, markerFn?: MarkerFunction): this { + const attributeId = index.createId(attribute); + this.id(attributeId) + .type(ATTRIBUTE_COMPARTMENT_TYPE) + .addCssClass('attribute-compartment') + .layout('hbox') + .addLayoutOption('paddingBottom', 3) + .addLayoutOption('paddingTop', 3) + .addLayoutOption('hGap', 3); + + const leftPortId = createLeftPortId(attributeId); + index.indexSemanticElement(leftPortId, attribute); + this.add(GPort.builder().id(leftPortId).build()); + + this.add( + GLabel.builder() + .id(`${attributeId}_attribute_name`) + .text(attribute.name || '') + .addCssClass('attribute') + .build() + ); + this.add(GLabel.builder().text(':').id(`${attributeId}_attribute_del`).build()); + this.add(GLabel.builder().id(`${attributeId}_attribute_datatype`).text(attribute.datatype).addCssClass('datatype').build()); + const marker = markerFn?.(attribute, attributeId); + if (marker) { + this.add(marker); + } + const rightPortId = createRightPortId(attributeId); + index.indexSemanticElement(rightPortId, attribute); + this.add(GPort.builder().id(rightPortId).build()); + return this; + } } diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/create-edge-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/create-edge-operation-handler.ts index 8f62d2f..6d501bf 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/create-edge-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/create-edge-operation-handler.ts @@ -13,7 +13,7 @@ import { import { inject, injectable } from 'inversify'; import { combineIds } from '../../../language-server/cross-model-naming.js'; import { isSourceObjectAttribute, isTargetObjectAttribute } from '../../../language-server/generated/ast.js'; -import { createAttributeMapping, getOwner } from '../../../language-server/util/ast-util.js'; +import { createAttributeMapping, createAttributeMappingSource, getOwner } from '../../../language-server/util/ast-util.js'; import { CrossModelCommand } from '../../common/cross-model-command.js'; import { MappingModelState } from '../model/mapping-model-state.js'; @@ -36,22 +36,19 @@ export class MappingEdgeCreationOperationHandler extends JsonCreateEdgeOperation protected createEdge(operation: CreateEdgeOperation): void { const sourceElementId = isLeftPortId(operation.targetElementId) ? operation.sourceElementId : operation.targetElementId; const targetElementId = isLeftPortId(operation.targetElementId) ? operation.targetElementId : operation.sourceElementId; - if (operation.args?.isLiteral === true) { + if (isPortId(sourceElementId)) { const container = this.modelState.mapping.target; + const sourceElement = this.modelState.index.findSemanticElement(sourceElementId, isSourceObjectAttribute); const targetElement = this.modelState.index.findSemanticElement(targetElementId, isTargetObjectAttribute); - if (!targetElement) { + if (!targetElement || !sourceElement) { return; } - // interpret sourceElementId as literal - const source = Number.parseFloat(sourceElementId); - const mapping = createAttributeMapping(container, isNaN(source) ? sourceElementId : source, targetElement.id, true); - container.mappings.push(mapping); - } else if (isPortId(sourceElementId)) { - const container = this.modelState.mapping.target; - const sourceElement = this.modelState.index.findSemanticElement(sourceElementId, isSourceObjectAttribute); - const targetElement = this.modelState.index.findSemanticElement(targetElementId, isTargetObjectAttribute); - if (sourceElement && targetElement) { - const mapping = createAttributeMapping(container, combineIds(getOwner(sourceElement).id, sourceElement.id), targetElement.id); + const sourceAttributeReference = combineIds(getOwner(sourceElement).id, sourceElement.id); + const existingMapping = container.mappings.find(mapping => mapping.attribute.value.ref === targetElement); + if (existingMapping) { + existingMapping.sources.push(createAttributeMappingSource(existingMapping, sourceAttributeReference)); + } else { + const mapping = createAttributeMapping(container, sourceAttributeReference, targetElement.id); container.mappings.push(mapping); } } diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/delete-element-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/delete-element-operation-handler.ts index 655e182..2ccf87c 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/delete-element-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/handler/delete-element-operation-handler.ts @@ -5,15 +5,10 @@ import { Command, DeleteElementOperation, GEdge, GNode, JsonOperationHandler, Mo import { inject, injectable } from 'inversify'; import { AttributeMappingSource, - NumberLiteral, SourceObject, SourceObjectRelations, - StringLiteral, isAttributeMappingSource, - isNumberLiteral, - isReferenceSource, - isSourceObject, - isStringLiteral + isSourceObject } from '../../../language-server/generated/ast.js'; import { getOwner } from '../../../language-server/util/ast-util.js'; import { CrossModelCommand } from '../../common/cross-model-command.js'; @@ -64,8 +59,6 @@ export class MappingDiagramDeleteElementOperationHandler extends JsonOperationHa const astNode = this.modelState.index.findSemanticElement(node.id); if (isSourceObject(astNode)) { this.deleteSourceObject(node, astNode, deleteInfo); - } else if (isStringLiteral(astNode) || isNumberLiteral(astNode)) { - this.deleteLiteralObject(node, astNode, deleteInfo); } } @@ -76,19 +69,11 @@ export class MappingDiagramDeleteElementOperationHandler extends JsonOperationHa deleteInfo.relations.push(...mapping.sources.flatMap(src => src.relations).filter(relation => relation.source.ref === source)); deleteInfo.attributeSources.push( ...mapping.target.mappings.flatMap(attrMapping => - attrMapping.sources.filter( - attrSource => isReferenceSource(attrSource) && attrSource.value.ref && getOwner(attrSource.value?.ref) === source - ) + attrMapping.sources.filter(attrSource => attrSource.value.ref && getOwner(attrSource.value?.ref) === source) ) ); } - protected deleteLiteralObject(node: GNode, literal: StringLiteral | NumberLiteral, deleteInfo: DeleteInfo): void { - // Literal nodes are contained by the corresponding targetAttributeMapping => we only have to delete - // the mapping that correlates to the outgoing edge of the literal node - this.modelState.index.getOutgoingEdges(node).forEach(edge => this.deleteEdge(edge, deleteInfo)); - } - protected deleteEdge(edge: GEdge, deleteInfo: DeleteInfo): void { // the edge has the source as semantic element with an additional UUID cause the user may use the same source multiple times // see the generation of the edge id in edges.ts diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/layout-engine.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/layout-engine.ts index 37e33c3..0bf1f56 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/layout-engine.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/layout-engine.ts @@ -4,7 +4,6 @@ import { SOURCE_NUMBER_NODE_TYPE, SOURCE_STRING_NODE_TYPE, TARGET_OBJECT_NODE_TYPE, isLeftPortId } from '@crossbreeze/protocol'; import { GCompartment, GModelRoot, GNode, GPort, LayoutEngine, MaybePromise, findParentByClass } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { isReferenceSource } from '../../language-server/generated/ast.js'; import { getOwner } from '../../language-server/util/ast-util.js'; import { MappingModelState } from './model/mapping-model-state.js'; import { GTargetObjectNode } from './model/nodes.js'; @@ -24,7 +23,7 @@ export class MappingDiagramLayoutEngine implements LayoutEngine { const index = this.modelState.index; - // position source nodes (references and literals) in correct order + // position source nodes in correct order let offset = 0; let maxSourceWidth = 0; const marginBetweenSourceNodes = 20; @@ -62,7 +61,7 @@ export class MappingDiagramLayoutEngine implements LayoutEngine { const idx = this.modelState.index; const sourceNodeOrder = [...target.mappings] .sort((left, right) => (left.attribute.value.ref?.$containerIndex ?? 0) - (right.attribute.value.ref?.$containerIndex ?? 0)) - .flatMap(mapping => mapping.sources.map(source => idx.createId(isReferenceSource(source) ? getOwner(source.value.ref) : source))); + .flatMap(mapping => mapping.sources.map(source => idx.createId(getOwner(source.value.ref)))); return (left: GNode, right: GNode): number => { if (!sourceNodeOrder.includes(left.id)) { return 1; diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/edges.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/edges.ts index 2ae76fe..646a0ce 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/edges.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/edges.ts @@ -2,9 +2,9 @@ * Copyright (c) 2024 CrossBreeze. ********************************************************************************/ -import { TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE, createLeftPortId, createRightPortId } from '@crossbreeze/protocol'; +import { RenderProps, TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE, createLeftPortId, createRightPortId } from '@crossbreeze/protocol'; import { GEdge, GEdgeBuilder } from '@eclipse-glsp/server'; -import { AttributeMappingSource, isReferenceSource } from '../../../language-server/generated/ast.js'; +import { AttributeMappingSource } from '../../../language-server/generated/ast.js'; import { MappingModelIndex } from './mapping-model-index.js'; export class GTargetObjectEdge extends GEdge { @@ -18,7 +18,7 @@ export class GTargetObjectEdge extends GEdge { export class GTargetMappingSourceEdgeBuilder extends GEdgeBuilder { set(source: AttributeMappingSource, index: MappingModelIndex): this { const mapping = source.$container; - const sourceId = isReferenceSource(source) ? createRightPortId(index.createId(source.value.ref)) : index.createId(source); + const sourceId = createRightPortId(index.createId(source.value.ref)); const targetId = createLeftPortId(index.createId(mapping.attribute)); const id = 'edge_' + index.createId(source); @@ -27,6 +27,10 @@ export class GTargetMappingSourceEdgeBuilder extends GEdgeBuilder this.createSourceObjectNode(sourceObject)).forEach(node => graphBuilder.add(node)); - // literals that serve as source - mappingRoot.target.mappings - .flatMap(mapping => mapping.sources) - .map(source => this.createSourceLiteralNode(source)) - .filter(node => node !== undefined) - .forEach(node => graphBuilder.add(node!)); - // target node graphBuilder.add(this.createTargetNode(mappingRoot.target)); @@ -64,14 +50,4 @@ export class MappingDiagramGModelFactory implements GModelFactory { protected createTargetNode(target: TargetObject): GNode { return GTargetObjectNode.builder().set(target, this.modelState.index).build(); } - - protected createSourceLiteralNode(node: AttributeMappingSource): GNode | undefined { - if (isStringLiteral(node)) { - return GStringLiteralNode.builder().set(node, this.modelState.index).build(); - } - if (isNumberLiteral(node)) { - return GNumberLiteralNode.builder().set(node, this.modelState.index).build(); - } - return undefined; - } } diff --git a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/mapping-model-index.ts b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/mapping-model-index.ts index 9566201..e42b170 100644 --- a/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/mapping-model-index.ts +++ b/extensions/crossmodel-lang/src/glsp-server/mapping-diagram/model/mapping-model-index.ts @@ -7,14 +7,10 @@ import { AttributeMapping, AttributeMappingSource, AttributeMappingTarget, - NumberLiteral, - ReferenceSource, SourceObject, - StringLiteral, isAttributeMapping, isAttributeMappingSource, isAttributeMappingTarget, - isReferenceSource, isSourceObject, isTargetObject } from '../../../language-server/generated/ast.js'; @@ -67,14 +63,6 @@ export class MappingModelIndex extends CrossModelIndex { } protected createAttributeMappingSourceId(source: AttributeMappingSource): string | undefined { - return isReferenceSource(source) ? this.createReferenceSourceId(source) : this.createLiteralId(source); - } - - protected createLiteralId(literal: NumberLiteral | StringLiteral): string { - return `mapping_${literal.$container?.$containerIndex ?? 0}_source_${literal.$containerIndex ?? 0}_${literal.value}`; - } - - protected createReferenceSourceId(source: ReferenceSource): string | undefined { return `mapping_${source.$container?.$containerIndex ?? 0}_source_${source.$containerIndex ?? 0}_${this.createId(source.value.ref)}`; } 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 58df0eb..76e0377 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 @@ -1,17 +1,11 @@ /******************************************************************************** * Copyright (c) 2024 CrossBreeze. ********************************************************************************/ -import { - SOURCE_NUMBER_NODE_TYPE, - SOURCE_OBJECT_NODE_TYPE, - SOURCE_STRING_NODE_TYPE, - TARGET_OBJECT_NODE_TYPE, - quote -} from '@crossbreeze/protocol'; -import { ArgsUtil, GNode, GNodeBuilder } from '@eclipse-glsp/server'; -import { NumberLiteral, SourceObject, StringLiteral, TargetObject } from '../../../language-server/generated/ast.js'; +import { RenderProps, SOURCE_OBJECT_NODE_TYPE, TARGET_OBJECT_NODE_TYPE } from '@crossbreeze/protocol'; +import { ArgsUtil, GLabel, GNode, GNodeBuilder } from '@eclipse-glsp/server'; +import { SourceObject, TargetObject, TargetObjectAttribute } from '../../../language-server/generated/ast.js'; import { getAttributes } from '../../../language-server/util/ast-util.js'; -import { createAttributesCompartment, createHeader } from '../../common/nodes.js'; +import { AttributeCompartment, AttributesCompartmentBuilder, createAttributesCompartment, createHeader } from '../../common/nodes.js'; import { MappingModelIndex } from './mapping-model-index.js'; export class GSourceObjectNode extends GNode { @@ -44,58 +38,6 @@ export class GSourceObjectNodeBuilder extends GNodeBuilder { } } -export class GNumberLiteralNode extends GNode { - override type = SOURCE_NUMBER_NODE_TYPE; - - static override builder(): GNumberLiteralNodeBuilder { - return new GNumberLiteralNodeBuilder(GNumberLiteralNode).type(SOURCE_NUMBER_NODE_TYPE); - } -} - -export class GNumberLiteralNodeBuilder extends GNodeBuilder { - set(node: NumberLiteral, index: MappingModelIndex): this { - this.id(index.createId(node)); - - this.addCssClasses('diagram-node', 'source-object', 'number-literal'); - - this.add(createHeader(node.value + '', this.proxy.id)); - - this.layout('vbox') - .addArgs(ArgsUtil.cornerRadius(3)) - .addLayoutOption('prefWidth', 20) - .addLayoutOption('prefHeight', 20) - .position(100, 100); - - return this; - } -} - -export class GStringLiteralNode extends GNode { - override type = SOURCE_STRING_NODE_TYPE; - - static override builder(): GStringLiteralNodeBuilder { - return new GStringLiteralNodeBuilder(GStringLiteralNode).type(SOURCE_STRING_NODE_TYPE); - } -} - -export class GStringLiteralNodeBuilder extends GNodeBuilder { - set(node: StringLiteral, index: MappingModelIndex): this { - this.id(index.createId(node)); - - this.addCssClasses('diagram-node', 'source-object', 'string-literal'); - - this.add(createHeader(quote(node.value), this.proxy.id)); - - this.layout('vbox') - .addArgs(ArgsUtil.cornerRadius(3)) - .addLayoutOption('prefWidth', 20) - .addLayoutOption('prefHeight', 20) - .position(100, 100); - - return this; - } -} - export class GTargetObjectNode extends GNode { override type = TARGET_OBJECT_NODE_TYPE; @@ -106,19 +48,37 @@ export class GTargetObjectNode extends GNode { export class GTargetObjectNodeBuilder extends GNodeBuilder { set(node: TargetObject, index: MappingModelIndex): this { - this.id(index.createId(node)); + const id = index.createId(node); + this.id(id); // Options which are the same for every node this.addCssClasses('diagram-node', 'target-node'); // Add the label/name of the node - this.add(createHeader(node.entity?.ref?.name || node.entity?.ref?.id || 'unresolved', this.proxy.id)); + this.add(createHeader(node.entity?.ref?.name || node.entity?.ref?.id || 'unresolved', id)); // Add the children of the node const attributes = getAttributes(node); - this.add(createAttributesCompartment(attributes, this.proxy.id, index)); + node.$container.sources.find; + + const attributesContainer = new AttributesCompartmentBuilder().set(id); + for (const attribute of attributes) { + const attrComp = AttributeCompartment.builder().set(attribute, index, (attr, attrId) => this.markExpression(node, attr, attrId)); + const mappingIdx = node.mappings.findIndex(mapping => mapping.attribute.value.ref === attribute); + if (mappingIdx >= 0) { + attrComp.addArg(RenderProps.TARGET_ATTRIBUTE_MAPPING_IDX, mappingIdx); + } + attributesContainer.add(attrComp.build()); + } + this.add(attributesContainer.build()); this.layout('vbox').addArgs(ArgsUtil.cornerRadius(3)).addLayoutOption('prefWidth', 100).addLayoutOption('prefHeight', 100); return this; } + + protected markExpression(node: TargetObject, attribute: TargetObjectAttribute, id: string): GLabel | undefined { + return node.mappings.some(mapping => mapping.attribute.value.ref === attribute && !!mapping.expression) + ? GLabel.builder().id(`${id}_attribute_expression_marker`).text('𝑓ᵪ').addCssClasses('attribute_expression_marker').build() + : undefined; + } } 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 252729a..9ad8953 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,7 +17,7 @@ import { v4 as uuid } from 'uuid'; import { CompletionItemKind, InsertTextFormat, TextEdit } from 'vscode-languageserver-protocol'; import type { Range } from 'vscode-languageserver-types'; import { CrossModelServices } from './cross-model-module.js'; -import { AttributeMapping, RelationshipAttribute, isAttributeMapping, isReferenceSource } from './generated/ast.js'; +import { AttributeMapping, RelationshipAttribute, isAttributeMapping } from './generated/ast.js'; import { fixDocument } from './util/ast-util.js'; /** @@ -105,7 +105,6 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider { const end = context.textDocument.positionAt(context.offset); const reference = context.textDocument.getText({ start, end }).trim(); mapping.sources - .filter(isReferenceSource) .filter(source => reference.length === 0 || source.value.$refText.startsWith(reference)) .forEach(source => { acceptor(context, { 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 9f1b6f5..e5a1c12 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts @@ -2,7 +2,6 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { quote } from '@crossbreeze/protocol'; import { isReference } from 'langium'; import { Serializer } from '../model-server/serializer.js'; import { @@ -16,10 +15,7 @@ import { SystemDiagram, isAttributeMappingSource, isAttributeMappingTarget, - isJoinExpression, - isNumberLiteral, - isReferenceSource, - isStringLiteral + isJoinExpression } from './generated/ast.js'; import { isImplicitProperty } from './util/ast-util.js'; @@ -202,16 +198,7 @@ export class CrossModelSerializer implements Serializer { } private serializeAttributeMappingSource(obj: AttributeMappingSource): any { - if (isReferenceSource(obj)) { - return this.resolvedValue(obj.value); - } - if (isNumberLiteral(obj)) { - return obj.value; - } - if (isStringLiteral(obj)) { - return quote(obj.value); - } - return ''; + return this.resolvedValue(obj.value); } private serializeAttributeMappingTarget(obj: AttributeMappingTarget): any { 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 798da8a..1dbd125 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts @@ -15,7 +15,6 @@ import { isEntity, isEntityAttribute, isMapping, - isReferenceSource, isRelationship, isSystemDiagram } from './generated/ast.js'; @@ -139,7 +138,7 @@ export class CrossModelValidator { } const mappingExpressionRange = mappingExpression.range; const expressions = findAllExpressions(mapping.expression); - const sources = mapping.sources.filter(isReferenceSource).map(source => source.value.$refText); + const sources = mapping.sources.map(source => source.value.$refText); for (const expression of expressions) { const completeExpression = getExpression(expression); const expressionPosition = getExpressionPosition(expression); diff --git a/extensions/crossmodel-lang/src/language-server/generated/ast.ts b/extensions/crossmodel-lang/src/language-server/generated/ast.ts index 54a7432..1f49d4e 100644 --- a/extensions/crossmodel-lang/src/language-server/generated/ast.ts +++ b/extensions/crossmodel-lang/src/language-server/generated/ast.ts @@ -18,14 +18,6 @@ export const CrossModelTerminals = { ID: /[_a-zA-Z][\w_\-~$#@/\d]*/, }; -export type AttributeMappingSource = NumberLiteral | ReferenceSource | StringLiteral; - -export const AttributeMappingSource = 'AttributeMappingSource'; - -export function isAttributeMappingSource(item: unknown): item is AttributeMappingSource { - return reflection.isInstance(item, AttributeMappingSource); -} - export type IDReference = string; export function isIDReference(item: unknown): item is IDReference { @@ -74,6 +66,18 @@ export function isAttributeMapping(item: unknown): item is AttributeMapping { return reflection.isInstance(item, AttributeMapping); } +export interface AttributeMappingSource extends AstNode { + readonly $container: AttributeMapping; + readonly $type: 'AttributeMappingSource'; + value: Reference +} + +export const AttributeMappingSource = 'AttributeMappingSource'; + +export function isAttributeMappingSource(item: unknown): item is AttributeMappingSource { + return reflection.isInstance(item, AttributeMappingSource); +} + export interface AttributeMappingTarget extends AstNode { readonly $container: AttributeMapping; readonly $type: 'AttributeMappingTarget'; @@ -174,30 +178,6 @@ export function isMapping(item: unknown): item is Mapping { return reflection.isInstance(item, Mapping); } -export interface NumberLiteral extends AstNode { - readonly $container: AttributeMapping; - readonly $type: 'NumberLiteral'; - value: number -} - -export const NumberLiteral = 'NumberLiteral'; - -export function isNumberLiteral(item: unknown): item is NumberLiteral { - return reflection.isInstance(item, NumberLiteral); -} - -export interface ReferenceSource extends AstNode { - readonly $container: AttributeMapping; - readonly $type: 'ReferenceSource'; - value: Reference -} - -export const ReferenceSource = 'ReferenceSource'; - -export function isReferenceSource(item: unknown): item is ReferenceSource { - return reflection.isInstance(item, ReferenceSource); -} - export interface Relationship extends AstNode { readonly $container: CrossModelRoot; readonly $type: 'Relationship'; @@ -284,18 +264,6 @@ export function isSourceObjectRelations(item: unknown): item is SourceObjectRela return reflection.isInstance(item, SourceObjectRelations); } -export interface StringLiteral extends AstNode { - readonly $container: AttributeMapping; - readonly $type: 'StringLiteral'; - value: string -} - -export const StringLiteral = 'StringLiteral'; - -export function isStringLiteral(item: unknown): item is StringLiteral { - return reflection.isInstance(item, StringLiteral); -} - export interface SystemDiagram extends AstNode { readonly $container: CrossModelRoot; readonly $type: 'SystemDiagram'; @@ -378,8 +346,6 @@ export type CrossModelAstType = { JoinCondition: JoinCondition JoinExpression: JoinExpression Mapping: Mapping - NumberLiteral: NumberLiteral - ReferenceSource: ReferenceSource Relationship: Relationship RelationshipAttribute: RelationshipAttribute RelationshipCondition: RelationshipCondition @@ -388,7 +354,6 @@ export type CrossModelAstType = { SourceObjectAttribute: SourceObjectAttribute SourceObjectCondition: SourceObjectCondition SourceObjectRelations: SourceObjectRelations - StringLiteral: StringLiteral SystemDiagram: SystemDiagram TargetObject: TargetObject TargetObjectAttribute: TargetObjectAttribute @@ -397,7 +362,7 @@ export type CrossModelAstType = { export class CrossModelAstReflection extends AbstractAstReflection { getAllTypes(): string[] { - return ['Attribute', 'AttributeMapping', 'AttributeMappingSource', 'AttributeMappingTarget', 'CrossModelRoot', 'Entity', 'EntityAttribute', 'EntityNode', 'EntityNodeAttribute', 'JoinCondition', 'JoinExpression', 'Mapping', 'NumberLiteral', 'ReferenceSource', 'Relationship', 'RelationshipAttribute', 'RelationshipCondition', 'RelationshipEdge', 'SourceObject', 'SourceObjectAttribute', 'SourceObjectCondition', 'SourceObjectRelations', 'StringLiteral', 'SystemDiagram', 'TargetObject', 'TargetObjectAttribute']; + return ['Attribute', 'AttributeMapping', 'AttributeMappingSource', 'AttributeMappingTarget', 'CrossModelRoot', 'Entity', 'EntityAttribute', 'EntityNode', 'EntityNodeAttribute', 'JoinCondition', 'JoinExpression', 'Mapping', 'Relationship', 'RelationshipAttribute', 'RelationshipCondition', 'RelationshipEdge', 'SourceObject', 'SourceObjectAttribute', 'SourceObjectCondition', 'SourceObjectRelations', 'SystemDiagram', 'TargetObject', 'TargetObjectAttribute']; } protected override computeIsSubtype(subtype: string, supertype: string): boolean { @@ -414,11 +379,6 @@ export class CrossModelAstReflection extends AbstractAstReflection { case RelationshipCondition: { return this.isSubtype(SourceObjectCondition, supertype); } - case NumberLiteral: - case ReferenceSource: - case StringLiteral: { - return this.isSubtype(AttributeMappingSource, supertype); - } default: { return false; } @@ -428,6 +388,11 @@ export class CrossModelAstReflection extends AbstractAstReflection { getReferenceType(refInfo: ReferenceInfo): string { const referenceId = `${refInfo.container.$type}:${refInfo.property}`; switch (referenceId) { + case 'AttributeMappingSource:value': + case 'JoinExpression:source': + case 'JoinExpression:target': { + return SourceObjectAttribute; + } case 'AttributeMappingTarget:value': { return TargetObjectAttribute; } @@ -438,11 +403,6 @@ export class CrossModelAstReflection extends AbstractAstReflection { case 'TargetObject:entity': { return Entity; } - case 'JoinExpression:source': - case 'JoinExpression:target': - case 'ReferenceSource:value': { - return SourceObjectAttribute; - } case 'RelationshipAttribute:child': case 'RelationshipAttribute:parent': { return Attribute; diff --git a/extensions/crossmodel-lang/src/language-server/generated/grammar.ts b/extensions/crossmodel-lang/src/language-server/generated/grammar.ts index e62f078..b10d07c 100644 --- a/extensions/crossmodel-lang/src/language-server/generated/grammar.ts +++ b/extensions/crossmodel-lang/src/language-server/generated/grammar.ts @@ -2111,88 +2111,23 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load "$type": "ParserRule", "name": "AttributeMappingSource", "definition": { - "$type": "Alternatives", - "elements": [ - { - "$type": "Group", - "elements": [ - { - "$type": "Action", - "inferredType": { - "$type": "InferredType", - "name": "ReferenceSource" - } - }, - { - "$type": "Assignment", - "feature": "value", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/interfaces@3" - }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@3" - }, - "arguments": [] - }, - "deprecatedSyntax": false - } - } - ] + "$type": "Assignment", + "feature": "value", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/interfaces@3" }, - { - "$type": "Group", - "elements": [ - { - "$type": "Action", - "inferredType": { - "$type": "InferredType", - "name": "NumberLiteral" - } - }, - { - "$type": "Assignment", - "feature": "value", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@5" - }, - "arguments": [] - } - } - ] + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@3" + }, + "arguments": [] }, - { - "$type": "Group", - "elements": [ - { - "$type": "Action", - "inferredType": { - "$type": "InferredType", - "name": "StringLiteral" - } - }, - { - "$type": "Assignment", - "feature": "value", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@4" - }, - "arguments": [] - } - } - ] - } - ] + "deprecatedSyntax": false + } }, "definesHiddenTokens": false, "entry": false, diff --git a/extensions/crossmodel-lang/src/language-server/mapping.langium b/extensions/crossmodel-lang/src/language-server/mapping.langium index ebadec3..3c13ae1 100644 --- a/extensions/crossmodel-lang/src/language-server/mapping.langium +++ b/extensions/crossmodel-lang/src/language-server/mapping.langium @@ -78,10 +78,9 @@ AttributeMapping: ; AttributeMappingTarget: - value=[TargetObjectAttribute:IDReference]; + value=[TargetObjectAttribute:IDReference] +; AttributeMappingSource: - {infer ReferenceSource} value=[SourceObjectAttribute:IDReference] | - {infer NumberLiteral} value=NUMBER | - {infer StringLiteral} value=STRING + value=[SourceObjectAttribute:IDReference] ; 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 d2a005a..b2ea82c 100644 --- a/extensions/crossmodel-lang/src/language-server/util/ast-util.ts +++ b/extensions/crossmodel-lang/src/language-server/util/ast-util.ts @@ -1,25 +1,23 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { unquote } from '@crossbreeze/protocol'; +import { AttributeMappingSourceType } from '@crossbreeze/protocol'; 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 { Attribute, AttributeMapping, + AttributeMappingSource, AttributeMappingTarget, CrossModelRoot, Entity, EntityNode, EntityNodeAttribute, Mapping, - NumberLiteral, - ReferenceSource, Relationship, SourceObject, SourceObjectAttribute, - StringLiteral, SystemDiagram, TargetObject, TargetObjectAttribute, @@ -105,43 +103,20 @@ export function createSourceObject(entity: Entity | AstNodeDescription, containe }; } -export function createAttributeMapping( - container: TargetObject, - source: string | number, - targetId: string, - asLiteral = false -): AttributeMapping { +export function createAttributeMapping(container: TargetObject, source: string, targetId: string): AttributeMapping { const mapping = { $type: AttributeMapping, $container: container } as AttributeMapping; - mapping.sources = asLiteral || typeof source === 'number' ? [createLiteral(mapping, source)] : [createReferenceSource(mapping, source)]; + mapping.sources = [createAttributeMappingSource(mapping, source)]; mapping.attribute = createAttributeMappingTarget(mapping, targetId); return mapping; } -export function createLiteral(container: AttributeMapping, value: string): StringLiteral; -export function createLiteral(container: AttributeMapping, value: number): NumberLiteral; -export function createLiteral(container: AttributeMapping, value: string | number): NumberLiteral | StringLiteral; -export function createLiteral(container: AttributeMapping, value: string | number): NumberLiteral | StringLiteral { - if (typeof value === 'string') { - return { - $type: StringLiteral, - $container: container, - value: unquote(value) - }; - } - return { - $type: NumberLiteral, - $container: container, - value - }; -} - -export function createReferenceSource(container: AttributeMapping, sourceId: string): ReferenceSource { +export function createAttributeMappingSource(container: AttributeMapping, sourceId: string): AttributeMappingSource { return { $container: container, - $type: ReferenceSource, + $type: AttributeMappingSourceType, value: { $refText: sourceId } }; } diff --git a/extensions/crossmodel-lang/src/model-server/model-server.ts b/extensions/crossmodel-lang/src/model-server/model-server.ts index 0e7405a..cd76b5a 100644 --- a/extensions/crossmodel-lang/src/model-server/model-server.ts +++ b/extensions/crossmodel-lang/src/model-server/model-server.ts @@ -28,6 +28,7 @@ import * as rpc from 'vscode-jsonrpc/node.js'; import { isCrossModelRoot } from '../language-server/generated/ast.js'; import { ModelService } from './model-service.js'; +import { IMPLICIT_ID_PROPERTY } from '../language-server/util/ast-util.js'; /** * The model server handles request messages on the RPC connection and ensures that any return value @@ -143,7 +144,7 @@ export class ModelServer implements Disposable { // 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') + .filter(([key, value]) => !key.startsWith('$') || key === '$type' || key === IMPLICIT_ID_PROPERTY) .reduce((acc, [key, value]) => ({ ...acc, [key]: this.cleanValue(value) }), { $globalId: this.modelService.getGlobalId(obj) }); } diff --git a/packages/core/src/browser/model-widget.tsx b/packages/core/src/browser/model-widget.tsx index ea7e437..8fe9661 100644 --- a/packages/core/src/browser/model-widget.tsx +++ b/packages/core/src/browser/model-widget.tsx @@ -3,10 +3,12 @@ ********************************************************************************/ import { ModelService, ModelServiceClient } from '@crossbreeze/model-service/lib/common'; -import { CrossModelRoot } from '@crossbreeze/protocol'; +import { CrossModelRoot, RenderProps } from '@crossbreeze/protocol'; import { EntityComponent, ErrorView, + MappingComponent, + MappingRenderProps, ModelProviderProps, OpenCallback, RelationshipComponent, @@ -208,6 +210,7 @@ export class CrossModelWidget extends ReactWidget implements Saveable { onModelOpen={this.handleOpenRequest} modelQueryApi={this.modelService} theme={this.themeService.getCurrentTheme().type} + {...this.getRenderProperties(this.model.root)} /> ); } @@ -221,15 +224,37 @@ export class CrossModelWidget extends ReactWidget implements Saveable { onModelOpen={this.handleOpenRequest} modelQueryApi={this.modelService} theme={this.themeService.getCurrentTheme().type} + {...this.getRenderProperties(this.model.root)} /> ); } + if (this.model?.root?.mapping) { + const renderProps = this.getRenderProperties(this.model.root) as unknown as MappingRenderProps; + if (renderProps?.mappingIndex >= 0) { + return ( + + ); + } + } if (this.error) { return ; } return
No properties available.
; } + protected getRenderProperties(root: CrossModelRoot): RenderProps { + return {}; + } + protected focusInput(): void { setTimeout(() => { document.activeElement; 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 b62151f..b120940 100644 --- a/packages/glsp-client/src/browser/crossmodel-selection-data-service.ts +++ b/packages/glsp-client/src/browser/crossmodel-selection-data-service.ts @@ -1,23 +1,24 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossReference, REFERENCE_CONTAINER_TYPE, REFERENCE_PROPERTY, REFERENCE_VALUE } from '@crossbreeze/protocol'; +import { CrossReference, REFERENCE_CONTAINER_TYPE, REFERENCE_PROPERTY, REFERENCE_VALUE, RenderProps } 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'; import { CrossModelSelectionDataService } from './crossmodel-selection-forwarder'; @injectable() export class CrossModelGLSPSelectionDataService extends CrossModelSelectionDataService { - async getSelectionData(root: Readonly, selectedElementIds: string[]): Promise { - return getSelectionDataFor(selectedElementIds.map(id => root.index.getById(id)).filter(isDefined)); + async getSelectionData(root: Readonly, selectedElementIds: string[]): Promise { + const selection = selectedElementIds.map(id => root.index.getById(id)).filter(isDefined); + return getSelectionDataFor(selection); } } export interface GModelElementInfo { type: string; reference?: CrossReference; + renderProps?: RenderProps; } export interface CrossModelSelectionData { @@ -31,20 +32,25 @@ export function getSelectionDataFor(selection: GModelElement[]): CrossModelSelec } export function getElementInfo(element: GModelElement): GModelElementInfo { + return { type: element.type, reference: getCrossReference(element), renderProps: getRenderProps(element) }; +} + +export function getCrossReference(element: GModelElement): CrossReference | undefined { if (hasArgs(element)) { - const referenceProperty = element.args[REFERENCE_PROPERTY]; const referenceContainerType = element.args[REFERENCE_CONTAINER_TYPE]; + const referenceProperty = element.args[REFERENCE_PROPERTY]; const referenceValue = element.args[REFERENCE_VALUE]; if (referenceProperty && referenceContainerType && referenceValue) { return { - type: element.type, - reference: { - container: { globalId: element.id, type: referenceContainerType.toString() }, - property: referenceProperty.toString(), - value: referenceValue.toString() - } + container: { globalId: element.id, type: referenceContainerType.toString() }, + property: referenceProperty.toString(), + value: referenceValue.toString() }; } } - return { type: element.type }; + return undefined; +} + +export function getRenderProps(element: GModelElement): RenderProps { + return hasArgs(element) ? RenderProps.read(element.args) : {}; } diff --git a/packages/glsp-client/src/browser/crossmodel-selection-forwarder.ts b/packages/glsp-client/src/browser/crossmodel-selection-forwarder.ts index 29d66fd..1336d20 100644 --- a/packages/glsp-client/src/browser/crossmodel-selection-forwarder.ts +++ b/packages/glsp-client/src/browser/crossmodel-selection-forwarder.ts @@ -46,7 +46,7 @@ export class CrossModelTheiaGLSPSelectionForwarder extends TheiaGLSPSelectionFor selectedElementsIDs, additionalSelectionData, widgetId: this.viewerOptions.baseDiv, - sourceUri: sourceUri + sourceUri }; this.theiaSelectionService.selection = glspSelection; } diff --git a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/drag-creation-tool.ts b/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/drag-creation-tool.ts index 5481668..1c5b740 100644 --- a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/drag-creation-tool.ts +++ b/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/drag-creation-tool.ts @@ -60,7 +60,7 @@ export class DragEdgeCreationMouseListener extends DragAwareMouseListener { const result: Action[] = super.mouseMove(target, event); if (this.dragStart && this.mappingEdgeCreationArgs) { const dragDistance = Point.maxDistance(this.dragStart, { x: event.clientX, y: event.clientY }); - if (dragDistance > 3) { + if (dragDistance > 10) { result.push(TriggerEdgeCreationAction.create(TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE, { args: this.mappingEdgeCreationArgs })); } } diff --git a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/edge-creation-tool-module.ts b/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/edge-creation-tool-module.ts index 90f06f4..c02ecb5 100644 --- a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/edge-creation-tool-module.ts +++ b/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/edge-creation-tool-module.ts @@ -14,7 +14,6 @@ import { import { RestoreViewportHandler } from '@eclipse-glsp/client/lib/features/viewport/viewport-handler'; import { CrossModelRestoreViewportHandler } from './crossmodel-viewport-handler'; import { DragEdgeCreationTool } from './drag-creation-tool'; -import { LiteralCreationPalette } from './literal-creation-tool'; import { MappingEdgeCreationTool } from './mapping-edge-creation-tool'; import { NoScrollOverNodeListener } from './scroll-mouse-listener'; @@ -22,7 +21,6 @@ export const mappingEdgeCreationToolModule = new FeatureModule( (bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; bindAsService(context, TYPES.IDefaultTool, DragEdgeCreationTool); - bindAsService(bind, TYPES.IUIExtension, LiteralCreationPalette); rebind(EdgeCreationTool).to(MappingEdgeCreationTool).inSingletonScope(); rebind(GLSPScrollMouseListener).to(NoScrollOverNodeListener).inSingletonScope(); rebind(RestoreViewportHandler).to(CrossModelRestoreViewportHandler).inSingletonScope(); diff --git a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/literal-creation-tool.ts b/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/literal-creation-tool.ts deleted file mode 100644 index 104b272..0000000 --- a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/literal-creation-tool.ts +++ /dev/null @@ -1,94 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2024 CrossBreeze. - ********************************************************************************/ -import { TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE } from '@crossbreeze/protocol'; -import { - CreateEdgeOperation, - DOMHelper, - GModelRoot, - LabeledAction, - MousePositionTracker, - Point, - TYPES, - ValidationDecorator, - ValidationStatus, - ViewerOptions, - Writable, - getAbsoluteClientBounds -} from '@eclipse-glsp/client'; -import { BaseAutocompletePalette } from '@eclipse-glsp/client/lib/base/auto-complete/base-autocomplete-palette'; -import { feedbackEdgeEndId } from '@eclipse-glsp/client/lib/features/tools/edge-creation/dangling-edge-feedback'; -import { inject } from '@theia/core/shared/inversify'; -import { SModelRootImpl } from 'sprotty'; - -export class LiteralCreationPalette extends BaseAutocompletePalette { - static readonly ID = 'literal-creation-editor'; - - @inject(TYPES.ViewerOptions) - protected viewerOptions: ViewerOptions; - - @inject(TYPES.DOMHelper) - protected domHelper: DOMHelper; - - @inject(MousePositionTracker) - protected mousePositionTracker: MousePositionTracker; - - protected targetAttributeId: string; - - id(): string { - return LiteralCreationPalette.ID; - } - - protected override initializeContents(containerElement: HTMLElement): void { - super.initializeContents(containerElement); - containerElement.classList.add('literal-creation-palette'); - - this.autocompleteWidget.inputField.placeholder = 'Provide a number or String value'; - - this.autocompleteWidget.configureValidation( - { validate: async () => ValidationStatus.NONE }, - new ValidationDecorator(containerElement) - ); - this.autocompleteWidget.configureTextSubmitHandler({ - executeFromTextOnlyInput: (input: string) => this.executeFromTextOnlyInput(input) - }); - } - - protected override onBeforeShow(containerElement: HTMLElement, root: Readonly, ...contextElementIds: string[]): void { - this.targetAttributeId = contextElementIds[0]; - this.autocompleteWidget.inputField.value = ''; - this.setPosition(root, containerElement); - } - - protected setPosition(root: Readonly, containerElement: HTMLElement): void { - const position: Writable = this.mousePositionTracker.lastPositionOnDiagram ?? { x: 0, y: 0 }; - - const edgeEnd = root.index.getById(feedbackEdgeEndId(root)); - if (edgeEnd) { - const bounds = getAbsoluteClientBounds(edgeEnd, this.domHelper, this.viewerOptions); - position.x = bounds.x; - position.y = bounds.y; - } - - containerElement.style.left = `${position.x}px`; - containerElement.style.top = `${position.y}px`; - containerElement.style.width = '200px'; - } - - protected executeFromTextOnlyInput(input: string): void { - this.actionDispatcher.dispatch( - CreateEdgeOperation.create({ - elementTypeId: TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE, - targetElementId: this.targetAttributeId, - sourceElementId: input, - args: { - isLiteral: true - } - }) - ); - } - - protected override async retrieveSuggestions(_root: Readonly, _input: string): Promise { - return []; - } -} diff --git a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/mapping-edge-creation-tool.ts b/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/mapping-edge-creation-tool.ts index 101bd4a..d4d2f93 100644 --- a/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/mapping-edge-creation-tool.ts +++ b/packages/glsp-client/src/browser/mapping-diagram/edge-creation-tool/mapping-edge-creation-tool.ts @@ -19,10 +19,8 @@ import { HoverFeedbackAction, IFeedbackActionDispatcher, ITypeHintProvider, - ModifyCSSFeedbackAction, MoveAction, Point, - SetUIExtensionVisibilityAction, TriggerEdgeCreationAction, cursorFeedbackAction, findChildrenAtPosition, @@ -40,8 +38,6 @@ import { import { injectable } from 'inversify'; import { AttributeCompartment } from '../../model'; import { SourceObjectNode, TargetObjectNode } from '../model'; -import { ExtendedEnableDefaultToolsAction } from './actions'; -import { LiteralCreationPalette } from './literal-creation-tool'; export type AttributeParent = 'target-object' | 'source-object'; @@ -140,11 +136,7 @@ export class MappingEdgeEndMovingListener extends FeedbackEdgeEndMovingMouseList } } -const CURSOR_LITERAL_CREATION = 'literal-creation'; - export class MappingEdgeCreationToolMouseListener extends EdgeCreationToolMouseListener implements Disposable { - protected literalCreation = false; - constructor( protected override triggerAction: MappingEdgeCreationAction, actionDispatcher: GLSPActionDispatcher, @@ -168,34 +160,11 @@ export class MappingEdgeCreationToolMouseListener extends EdgeCreationToolMouseL this.currentTarget = target.root.index.getById(targetPortId); this.allowedTarget = !!this.currentTarget; } - this.literalCreation = this.triggerAction.args.sourceAttributeParent === 'target-object' && target === target.root; - const cursorAction = this.literalCreation - ? ModifyCSSFeedbackAction.create({ add: [CURSOR_LITERAL_CREATION] }) - : ModifyCSSFeedbackAction.create({ remove: [CURSOR_LITERAL_CREATION] }); - return [this.updateEdgeFeedback(), cursorAction]; - } - - override nonDraggingMouseUp(element: GModelElement, event: MouseEvent): Action[] { - if (this.source && this.literalCreation) { - return [ - SetUIExtensionVisibilityAction.create({ - extensionId: LiteralCreationPalette.ID, - visible: true, - contextElementsId: [this.source] - }), - ExtendedEnableDefaultToolsAction.create({ focusGraph: false }) - ]; - } - return super.nonDraggingMouseUp(element, event); - } - - protected override reinitialize(): void { - super.reinitialize(); - this.actionDispatcher.dispatch(ModifyCSSFeedbackAction.create({ remove: [CURSOR_LITERAL_CREATION] })); + return [this.updateEdgeFeedback()]; } dispose(): void { - this.actionDispatcher.dispatch(ModifyCSSFeedbackAction.create({ remove: [CURSOR_LITERAL_CREATION] })); + // do nothing } } diff --git a/packages/glsp-client/src/browser/views.tsx b/packages/glsp-client/src/browser/views.tsx index add730d..e568a33 100644 --- a/packages/glsp-client/src/browser/views.tsx +++ b/packages/glsp-client/src/browser/views.tsx @@ -29,7 +29,7 @@ export class AttributeCompartmentView extends GCompartmentView { const vnode: any = ( { + async providePropertyData(selection: GlspSelection | undefined): Promise { if (!selection || !GlspSelection.is(selection) || !selection.sourceUri || selection.selectedElementsIDs.length === 0) { return undefined; } - const dataMap = selection.additionalSelectionData as CrossModelSelectionData; + const selectionData = selection.additionalSelectionData as CrossModelSelectionData; for (const selectedElementId of selection.selectedElementsIDs) { - const info = dataMap?.selectionDataMap.get(selectedElementId); - if (info?.reference) { - return this.modelService.resolveReference(info?.reference); + const renderData = await this.getPropertyData(selection, selectionData?.selectionDataMap.get(selectedElementId)); + if (renderData) { + return renderData; } } return undefined; } - async providePropertyData(selection: GlspSelection | undefined): Promise { - return this.getSelectedEntity(selection); + protected async getPropertyData(selection: GlspSelection, info?: GModelElementInfo): Promise { + if (info?.reference) { + const reference = await this.modelService.resolveReference(info.reference); + return reference ? { uri: reference?.uri, renderProps: info.renderProps } : undefined; + } else if (selection.sourceUri && info?.renderProps) { + return { uri: selection.sourceUri, renderProps: info.renderProps }; + } + return undefined; } } diff --git a/packages/property-view/src/browser/model-property-widget.tsx b/packages/property-view/src/browser/model-property-widget.tsx index 334888c..658dfd9 100644 --- a/packages/property-view/src/browser/model-property-widget.tsx +++ b/packages/property-view/src/browser/model-property-widget.tsx @@ -7,14 +7,18 @@ import { PropertyDataService } from '@theia/property-view/lib/browser/property-d import { PropertyViewContentWidget } from '@theia/property-view/lib/browser/property-view-content-widget'; import { CrossModelWidget } from '@crossbreeze/core/lib/browser'; -import { ResolvedElement } from '@crossbreeze/protocol'; +import { CrossModelRoot, RenderProps } from '@crossbreeze/protocol'; import { GLSPDiagramWidget, GlspSelection, getDiagramWidget } from '@eclipse-glsp/theia-integration'; import { inject, injectable } from '@theia/core/shared/inversify'; +import * as deepEqual from 'fast-deep-equal'; +import { PropertiesRenderData } from './model-data-service'; @injectable() export class ModelPropertyWidget extends CrossModelWidget implements PropertyViewContentWidget { @inject(ApplicationShell) protected shell: ApplicationShell; + protected renderData?: PropertiesRenderData; + constructor() { super(); this.node.tabIndex = 0; @@ -28,16 +32,21 @@ export class ModelPropertyWidget extends CrossModelWidget implements PropertyVie return; } if (propertyDataService) { - const selectionData = (await propertyDataService.providePropertyData(selection)) as ResolvedElement | undefined; - if (this.model?.uri.toString() === selectionData?.uri) { - return; + const renderData = (await propertyDataService.providePropertyData(selection)) as PropertiesRenderData | undefined; + if (this.model?.uri.toString() !== renderData?.uri || !deepEqual(this.renderData, renderData)) { + this.renderData = renderData; + this.setModel(renderData?.uri); } - this.setModel(selectionData?.uri); } else { + this.renderData = undefined; this.setModel(); } } + protected override getRenderProperties(root: CrossModelRoot): RenderProps { + return this.renderData?.renderProps ?? super.getRenderProperties(root); + } + protected override async closeModel(uri: string): Promise { if (this.model && this.dirty) { const toSave = this.model; diff --git a/packages/protocol/src/glsp/types.ts b/packages/protocol/src/glsp/types.ts index 8a6628c..29c2b1f 100644 --- a/packages/protocol/src/glsp/types.ts +++ b/packages/protocol/src/glsp/types.ts @@ -2,7 +2,7 @@ * Copyright (c) 2024 CrossBreeze. ********************************************************************************/ -import { DefaultTypes } from '@eclipse-glsp/protocol'; +import { Args, DefaultTypes } from '@eclipse-glsp/protocol'; // System Diagram export const ENTITY_NODE_TYPE = DefaultTypes.NODE + ':entity'; @@ -20,3 +20,22 @@ export const ATTRIBUTE_COMPARTMENT_TYPE = DefaultTypes.COMPARTMENT + ':attribute export const REFERENCE_CONTAINER_TYPE = 'reference-container-type'; export const REFERENCE_PROPERTY = 'reference-property'; export const REFERENCE_VALUE = 'reference-value'; + +export type RenderProps = Record; + +export namespace RenderProps { + export function key(name: string): string { + return 'render-prop-' + name; + } + + export function read(args: Args): RenderProps { + return Object.keys(args).reduce((renderProps, argKey) => { + if (argKey.startsWith('render-prop-')) { + renderProps[argKey.substring('render-prop-'.length)] = args[argKey]; + } + return renderProps; + }, {} as Args); + } + + export const TARGET_ATTRIBUTE_MAPPING_IDX = RenderProps.key('mappingIndex'); +} diff --git a/packages/protocol/src/model-service/protocol.ts b/packages/protocol/src/model-service/protocol.ts index b2c229d..477412c 100644 --- a/packages/protocol/src/model-service/protocol.ts +++ b/packages/protocol/src/model-service/protocol.ts @@ -14,6 +14,9 @@ export const CrossModelRegex = { * Serialized version of the semantic model generated by Langium. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type Reference = string; + export interface CrossModelElement { readonly $type: string; } @@ -27,6 +30,7 @@ export interface CrossModelRoot extends CrossModelElement { readonly $type: 'CrossModelRoot'; entity?: Entity; relationship?: Relationship; + mapping?: Mapping; } export function isCrossModelRoot(model?: any): model is CrossModelRoot { @@ -41,34 +45,85 @@ export interface Entity extends CrossModelElement, Identifiable { name?: string; } -export const EntityAttributeType = 'EntityAttribute'; -export interface EntityAttribute extends CrossModelElement, Identifiable { - readonly $type: typeof EntityAttributeType; +export interface Attribute extends CrossModelElement, Identifiable { datatype?: string; description?: string; name?: string; } +export const EntityAttributeType = 'EntityAttribute'; +export interface EntityAttribute extends Attribute { + readonly $type: typeof EntityAttributeType; +} + export const RelationshipType = 'Relationship'; export interface Relationship extends CrossModelElement, Identifiable { readonly $type: typeof RelationshipType; attributes: Array; - child?: string; + child?: Reference; description?: string; name?: string; - parent?: string; + parent?: Reference; type?: string; } export const RelationshipAttributeType = 'RelationshipAttribute'; export interface RelationshipAttribute extends CrossModelElement { readonly $type: typeof RelationshipAttributeType; - parent?: string; - child?: string; + parent?: Reference; + child?: Reference; } export const MappingType = 'Mapping'; +export interface Mapping extends CrossModelElement, Identifiable { + readonly $type: typeof MappingType; + sources: Array; + target: TargetObject; +} + +export const SourceObjectType = 'SourceObject'; +export interface SourceObject extends CrossModelElement, Identifiable { + readonly $type: typeof SourceObjectType; + entity?: Reference; + join?: 'from' | 'inner-join' | 'cross-join' | 'left-join' | 'apply'; +} + export const TargetObjectType = 'TargetObject'; +export interface TargetObject extends CrossModelElement { + readonly $type: typeof TargetObjectType; + entity?: Reference; + mappings: Array; +} + +export const AttributeMappingType = 'AttributeMapping'; +export interface AttributeMapping extends CrossModelElement { + readonly $type: typeof AttributeMappingType; + attribute?: AttributeMappingTarget; + sources: Array; + expression?: string; +} + +export const AttributeMappingTargetType = 'AttributeMappingTarget'; +export interface AttributeMappingTarget extends CrossModelElement { + readonly $type: typeof AttributeMappingTargetType; + value?: Reference; +} + +export const TargetObjectAttributeType = 'TargetObjectAttribute'; +export interface TargetObjectAttribute extends Attribute { + readonly $type: typeof TargetObjectAttributeType; +} + +export const AttributeMappingSourceType = 'AttributeMappingSource'; +export interface AttributeMappingSource extends CrossModelElement { + readonly $type: typeof AttributeMappingSourceType; + value: Reference; +} + +export const SourceObjectAttributeType = 'SourceObjectAttribute'; +export interface SourceObjectAttribute extends Attribute { + readonly $type: typeof SourceObjectAttributeType; +} export interface ClientModelArgs { uri: string; @@ -185,10 +240,9 @@ export interface CrossReference { value: string; } -export interface ResolvedElement { +export interface ResolvedElement { uri: string; model: CrossModelRoot; - match?: T; } export const OpenModel = new rpc.RequestType1('server/open'); diff --git a/packages/react-model-ui/src/ModelContext.tsx b/packages/react-model-ui/src/ModelContext.tsx index 26e5345..3589e52 100644 --- a/packages/react-model-ui/src/ModelContext.tsx +++ b/packages/react-model-ui/src/ModelContext.tsx @@ -1,7 +1,7 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossModelRoot, CrossReferenceContext, Entity, ReferenceableElement, Relationship } from '@crossbreeze/protocol'; +import { CrossModelRoot, CrossReferenceContext, Entity, Mapping, ReferenceableElement, Relationship } from '@crossbreeze/protocol'; import * as React from 'react'; import { DispatchAction, ModelReducer } from './ModelReducer'; @@ -61,3 +61,7 @@ export function useEntity(): Entity { export function useRelationship(): Relationship { return useModel().relationship!; } + +export function useMapping(): Mapping { + return useModel().mapping!; +} diff --git a/packages/react-model-ui/src/ModelReducer.tsx b/packages/react-model-ui/src/ModelReducer.tsx index 5e4315b..891d069 100644 --- a/packages/react-model-ui/src/ModelReducer.tsx +++ b/packages/react-model-ui/src/ModelReducer.tsx @@ -1,13 +1,7 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { - CrossModelRoot, - EntityAttribute, - EntityAttributeType, - RelationshipAttribute, - RelationshipAttributeType -} from '@crossbreeze/protocol'; +import { AttributeMappingSource, CrossModelRoot, EntityAttribute, RelationshipAttribute } from '@crossbreeze/protocol'; export interface ModelAction { type: string; @@ -35,7 +29,8 @@ export interface EntityAttributeUpdateAction extends ModelAction { } export interface EntityAttributeAddEmptyAction extends ModelAction { - type: 'entity:attribute:add-empty'; + type: 'entity:attribute:add-attribute'; + attribute: EntityAttribute; } export interface EntityAttributeMoveUpAction extends ModelAction { @@ -90,7 +85,8 @@ export interface RelationshipAttributeUpdateAction extends ModelAction { } export interface RelationshipAttributeAddEmptyAction extends ModelAction { - type: 'relationship:attribute:add-empty'; + type: 'relationship:attribute:add-relationship'; + attribute: RelationshipAttribute; } export interface RelationshipAttributeMoveUpAction extends ModelAction { @@ -108,6 +104,43 @@ export interface RelationshipAttributeDeleteAction extends ModelAction { attributeIdx: number; } +export interface AttributeMappingChangeExpressionAction extends ModelAction { + type: 'attribute-mapping:change-expression'; + mappingIdx: number; + expression: string; +} + +export interface AttributeMappingUpdateSourceAction extends ModelAction { + type: 'attribute-mapping:update-source'; + mappingIdx: number; + sourceIdx: number; + source: AttributeMappingSource; +} + +export interface AttributeMappingAddEmptySourceAction extends ModelAction { + type: 'attribute-mapping:add-source'; + mappingIdx: number; + source: AttributeMappingSource; +} + +export interface AttributeMappingMoveSourceUpAction extends ModelAction { + type: 'attribute-mapping:move-source-up'; + mappingIdx: number; + sourceIdx: number; +} + +export interface AttributeMappingMoveSourceDownAction extends ModelAction { + type: 'attribute-mapping:move-source-down'; + mappingIdx: number; + sourceIdx: number; +} + +export interface AttributeMappingDeleteSourceAction extends ModelAction { + type: 'attribute-mapping:delete-source'; + mappingIdx: number; + sourceIdx: number; +} + export type DispatchAction = | ModelUpdateAction | EntityChangeNameAction @@ -127,7 +160,13 @@ export type DispatchAction = | RelationshipAttributeAddEmptyAction | RelationshipAttributeMoveUpAction | RelationshipAttributeMoveDownAction - | RelationshipAttributeDeleteAction; + | RelationshipAttributeDeleteAction + | AttributeMappingChangeExpressionAction + | AttributeMappingUpdateSourceAction + | AttributeMappingAddEmptySourceAction + | AttributeMappingMoveSourceUpAction + | AttributeMappingMoveSourceDownAction + | AttributeMappingDeleteSourceAction; export type ModelStateReason = DispatchAction['type'] | 'model:initial'; @@ -168,14 +207,8 @@ export function ModelReducer(state: ModelState, action: DispatchAction): ModelSt }; break; - case 'entity:attribute:add-empty': - state.model.entity!.attributes.push({ - $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' - }); + case 'entity:attribute:add-attribute': + state.model.entity!.attributes.push(action.attribute); break; case 'entity:attribute:move-attribute-up': @@ -222,10 +255,8 @@ export function ModelReducer(state: ModelState, action: DispatchAction): ModelSt state.model.relationship!.attributes[action.attributeIdx] = action.attribute; break; - case 'relationship:attribute:add-empty': - state.model.relationship!.attributes.push({ - $type: RelationshipAttributeType - }); + case 'relationship:attribute:add-relationship': + state.model.relationship!.attributes.push(action.attribute); break; case 'relationship:attribute:move-attribute-up': @@ -248,6 +279,40 @@ export function ModelReducer(state: ModelState, action: DispatchAction): ModelSt state.model.relationship!.attributes.splice(action.attributeIdx, 1); break; + case 'attribute-mapping:change-expression': + state.model.mapping!.target.mappings[action.mappingIdx].expression = undefinedIfEmpty(action.expression); + break; + + case 'attribute-mapping:update-source': + state.model.mapping!.target.mappings[action.mappingIdx].sources[action.sourceIdx] = { ...action.source }; + break; + + case 'attribute-mapping:add-source': + state.model.mapping!.target.mappings[action.mappingIdx].sources.push(action.source); + break; + + case 'attribute-mapping:move-source-up': + if (action.sourceIdx > 0) { + const attributeMapping = state.model.mapping!.target.mappings[action.mappingIdx]; + const temp = attributeMapping.sources[action.sourceIdx - 1]; + attributeMapping.sources[action.sourceIdx - 1] = attributeMapping.sources[action.sourceIdx]; + attributeMapping.sources[action.sourceIdx] = temp; + } + break; + + case 'attribute-mapping:move-source-down': + if (action.sourceIdx < state.model.mapping!.target.mappings[action.mappingIdx].sources.length - 1) { + const attributeMapping = state.model.mapping!.target.mappings[action.mappingIdx]; + const temp = attributeMapping.sources[action.sourceIdx + 1]; + attributeMapping.sources[action.sourceIdx + 1] = attributeMapping.sources[action.sourceIdx]; + attributeMapping.sources[action.sourceIdx] = temp; + } + break; + + case 'attribute-mapping:delete-source': + state.model.mapping!.target.mappings[action.mappingIdx].sources.splice(action.sourceIdx, 1); + break; + default: { throw Error('Unknown ModelReducer action'); } @@ -255,16 +320,6 @@ export function ModelReducer(state: ModelState, action: DispatchAction): ModelSt 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); } diff --git a/packages/react-model-ui/src/views/common/AsyncAutoComplete.tsx b/packages/react-model-ui/src/views/common/AsyncAutoComplete.tsx index 003c0ea..71de436 100644 --- a/packages/react-model-ui/src/views/common/AsyncAutoComplete.tsx +++ b/packages/react-model-ui/src/views/common/AsyncAutoComplete.tsx @@ -18,7 +18,7 @@ export default function AsyncAutoComplete({ textFieldProps, ...props }: AsyncAutoCompleteProps): React.ReactElement { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = React.useState(props.open); const [options, setOptions] = React.useState([]); const loading = open && options.length === 0; @@ -40,12 +40,6 @@ export default function AsyncAutoComplete({ }; }, [loading, optionLoader]); - React.useEffect(() => { - if (!open) { - setOptions([]); - } - }, [open]); - return ( ({ + container: { globalId: mapping.id }, + syntheticElements: [ + { property: 'target', type: TargetObjectType }, + { property: 'mappings', type: AttributeMappingType }, + { property: 'sources', type: AttributeMappingSourceType } + ], + property: 'value' + }), + [mapping] + ); + const referenceableElements = React.useCallback(() => queryApi.findReferenceableElements(referenceCtx), [queryApi, referenceCtx]); + + const handleValueChange = React.useCallback( + (newValue: ReferenceableElement): void => { + const source = { $type: AttributeMappingSourceType, value: newValue.label, uri: newValue.uri }; + gridApi.current.setEditCellValue({ id, field, value: source }); + }, + [field, gridApi, id] + ); + + return ( + + autoFocus={hasFocus} + openOnFocus={true} + fullWidth={true} + label='' + optionLoader={referenceableElements} + onChange={(_evt, newValue) => handleValueChange(newValue)} + value={{ uri: value.uri ?? '', label: value.value.toString() ?? '', type: value.$type }} + clearOnBlur={true} + blurOnSelect={true} + selectOnFocus={true} + textFieldProps={{ sx: { margin: '0' } }} + isOptionEqualToValue={(option, val) => option.label === val.label} + /> + ); +} + +export type AttributeMappingSourceRow = GridComponentRow; + +export interface AttributeMappingSourcesDataGridProps { + mapping: AttributeMapping; + mappingIdx: number; +} + +export function AttributeMappingSourcesDataGrid({ mapping, mappingIdx }: AttributeMappingSourcesDataGridProps): React.ReactElement { + const dispatch = useModelDispatch(); + + // Callback for when the user stops editing a cell. + const handleSourceUpdate = React.useCallback( + (row: AttributeMappingSourceRow): AttributeMappingSourceRow => { + if (row.value === '') { + dispatch({ type: 'attribute-mapping:delete-source', mappingIdx, sourceIdx: row.idx }); + } else { + dispatch({ type: 'attribute-mapping:update-source', mappingIdx, sourceIdx: row.idx, source: row }); + } + return row; + }, + [dispatch, mappingIdx] + ); + + const handleAddSource = React.useCallback( + (row: AttributeMappingSourceRow): void => { + if (row.value !== '') { + dispatch({ type: 'attribute-mapping:add-source', mappingIdx, source: row }); + } + }, + [dispatch, mappingIdx] + ); + + const handleSourceUpward = React.useCallback( + (row: AttributeMappingSourceRow): void => dispatch({ type: 'attribute-mapping:move-source-up', mappingIdx, sourceIdx: row.idx }), + [dispatch, mappingIdx] + ); + + const handleSourceDownward = React.useCallback( + (row: AttributeMappingSourceRow): void => dispatch({ type: 'attribute-mapping:move-source-down', mappingIdx, sourceIdx: row.idx }), + [dispatch, mappingIdx] + ); + + const handleSourceDelete = React.useCallback( + (row: AttributeMappingSourceRow): void => dispatch({ type: 'attribute-mapping:delete-source', mappingIdx, sourceIdx: row.idx }), + [dispatch, mappingIdx] + ); + + const columns: GridColDef[] = React.useMemo( + () => [ + { + field: 'value', + flex: 200, + editable: true, + renderHeader: () => <>, + valueGetter: (_value, row) => row, + valueSetter: (value, row) => value, + valueFormatter: (value, row) => (value as AttributeMappingSource).value, + renderEditCell: params => , + type: 'singleSelect' + } as GridColDef + ], + [] + ); + + return ( + + ); +} diff --git a/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx b/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx index 62575be..ca94c83 100644 --- a/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx +++ b/packages/react-model-ui/src/views/common/EntityAttributesDataGrid.tsx @@ -2,11 +2,13 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { EntityAttribute, EntityAttributeType } from '@crossbreeze/protocol'; -import { GridColDef, GridRowModel } from '@mui/x-data-grid'; +import { GridColDef } from '@mui/x-data-grid'; import * as React from 'react'; import { useEntity, useModelDispatch } from '../../ModelContext'; import { ErrorView } from '../ErrorView'; -import AttributeGrid, { AttributeRow, ValidationFunction } from './AttributeGrid'; +import GridComponent, { GridComponentRow, ValidationFunction } from './GridComponent'; + +export type EntityAttributeRow = GridComponentRow; export function EntityAttributesDataGrid(): React.ReactElement { const entity = useEntity(); @@ -14,7 +16,7 @@ export function EntityAttributesDataGrid(): React.ReactElement { // Callback for when the user stops editing a cell. const handleRowUpdate = React.useCallback( - (attribute: AttributeRow): GridRowModel => { + (attribute: EntityAttributeRow): EntityAttributeRow => { // Handle change of name property. dispatch({ type: 'entity:attribute:update', @@ -33,12 +35,20 @@ export function EntityAttributesDataGrid(): React.ReactElement { [dispatch] ); - const handleAddAttribute = React.useCallback((): void => { - dispatch({ type: 'entity:attribute:add-empty' }); - }, [dispatch]); + const handleAddAttribute = React.useCallback( + (attribute: EntityAttributeRow): void => { + if (attribute.name) { + dispatch({ + type: 'entity:attribute:add-attribute', + attribute: { ...attribute, id: findName('Attribute', entity.attributes, attr => attr.id!) } + }); + } + }, + [dispatch, entity.attributes] + ); const handleAttributeUpward = React.useCallback( - (attribute: AttributeRow): void => { + (attribute: EntityAttributeRow): void => { dispatch({ type: 'entity:attribute:move-attribute-up', attributeIdx: attribute.idx @@ -48,7 +58,7 @@ export function EntityAttributesDataGrid(): React.ReactElement { ); const handleAttributeDownward = React.useCallback( - (attribute: AttributeRow): void => { + (attribute: EntityAttributeRow): void => { dispatch({ type: 'entity:attribute:move-attribute-down', attributeIdx: attribute.idx @@ -58,7 +68,7 @@ export function EntityAttributesDataGrid(): React.ReactElement { ); const handleAttributeDelete = React.useCallback( - (attribute: AttributeRow): void => { + (attribute: EntityAttributeRow): void => { dispatch({ type: 'entity:attribute:delete-attribute', attributeIdx: attribute.idx @@ -98,21 +108,45 @@ export function EntityAttributesDataGrid(): React.ReactElement { [] ); + const defaultEntry = React.useMemo( + () => ({ + $type: EntityAttributeType, + id: findName('Attribute', entity.attributes, attr => attr.id!), + $globalId: 'toBeAssigned', + name: findName('New Attribute', entity.attributes, attr => attr.name!), + datatype: 'Varchar' + }), + [entity.attributes] + ); + // Check if model initialized. Has to be here otherwise the compiler complains. if (entity === undefined) { return ; } return ( - ); } + +export 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; +} diff --git a/packages/react-model-ui/src/views/common/AttributeGrid.tsx b/packages/react-model-ui/src/views/common/GridComponent.tsx similarity index 61% rename from packages/react-model-ui/src/views/common/AttributeGrid.tsx rename to packages/react-model-ui/src/views/common/GridComponent.tsx index 9f471ec..4bd2507 100644 --- a/packages/react-model-ui/src/views/common/AttributeGrid.tsx +++ b/packages/react-model-ui/src/views/common/GridComponent.tsx @@ -12,7 +12,9 @@ import { GridActionsCellItem, GridColDef, GridEditCellProps, + GridOverlay, GridPreProcessEditCellProps, + GridRowEditStopParams, GridRowModes, GridRowModesModel, GridToolbarContainer, @@ -20,78 +22,95 @@ import { } from '@mui/x-data-grid'; import * as React from 'react'; -export type AttributeRow = T & +export type GridComponentRow = 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 interface GridComponentProps extends Omit, 'rows' | 'columns' | 'processRowUpdate'> { + gridData: T[]; + gridColumns: GridColDef[]; + noEntriesText?: string; + newEntryText: string; + defaultEntry: T; + onAdd: (toAdd: GridComponentRow) => void | GridComponentRow | Promise>; + onUpdate: (toUpdate: GridComponentRow) => void | GridComponentRow | Promise>; + onDelete: (toDelete: GridComponentRow) => void; + onMoveUp: (toMoveUp: GridComponentRow) => void; + onMoveDown: (toMoveDown: GridComponentRow) => void; + validateField?: ValidationFunction; } -export default function AttributeGrid({ - attributes, - attributeColumns, - onNewAttribute, +export default function GridComponent({ + gridData, + gridColumns, + noEntriesText, + newEntryText, + defaultEntry, + onAdd, onUpdate, onDelete, onMoveUp, onMoveDown, - validateAttribute, + validateField, ...props -}: AttributeGridProps): React.ReactElement { +}: GridComponentProps): React.ReactElement { const [rowModesModel, setRowModesModel] = React.useState({}); - const [columns, setColumns] = React.useState>[]>([]); - const [rows, setRows] = 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); + const error = validateField?.(column.field, params.props.value); return { ...params.props, error }; }, - [validateAttribute] + [validateField] ); const handleRowUpdate = React.useCallback( - (newRow: AttributeRow, oldRow: AttributeRow): AttributeRow => { + async (newRow: GridComponentRow, oldRow: GridComponentRow): Promise> => { const updatedRow = mergeRightToLeft(oldRow, newRow); - onUpdate(updatedRow); + if (updatedRow.idx === undefined || updatedRow.idx < 0) { + await onAdd(updatedRow); + setRows(oldRows => oldRows.filter(row => row.idx !== updatedRow.idx)); + return { ...updatedRow, _action: 'delete' }; + } else if (updatedRow.idx >= 0) { + await onUpdate(updatedRow); + } return updatedRow; }, - [onUpdate] + [onAdd, 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 } })); + const handleRowEditStop = React.useCallback((params: GridRowEditStopParams): void => { + if (params.row.idx < 0) { + setRows(oldRows => oldRows.filter(row => row.idx >= 0)); + setRowModesModel(oldModel => { + const newModel = { ...oldModel }; + delete newModel[params.row.idx]; + return newModel; + }); } - }, [attributes.length, rows]); + }, []); + + const getRowId = React.useCallback((row: GridComponentRow): number => row.idx, []); React.useEffect(() => { - setRows(attributes.map((data, idx) => ({ ...data, idx, isNew: false }))); - }, [attributes]); + setRows(gridData.map((data, idx) => ({ ...data, idx }))); + }, [gridData]); React.useEffect(() => { - const gridColumns = attributeColumns.map(column => ({ + const allColumns = gridColumns.map(column => ({ preProcessEditCellProps: params => validateRow(params, column), ...column - })) as GridColDef>[]; - gridColumns.push({ + })) as GridColDef>[]; + allColumns.push({ field: 'actions', type: 'actions', cellClassName: 'actions', @@ -121,22 +140,35 @@ export default function AttributeGrid({ /> ] }); - setColumns(gridColumns); - }, [attributeColumns, onDelete, onMoveDown, onMoveUp, rows.length, validateRow]); + setColumns(allColumns); + }, [gridColumns, onDelete, onMoveDown, onMoveUp, rows.length, validateRow]); + + const createNewEntry = React.useCallback(() => { + const id = -1; + if (!rows.find(row => row.idx === -1)) { + setRows(oldRows => [...oldRows, { ...defaultEntry, idx: id }]); + } + + // put new row in edit mode + const fieldToFocus = columns.length > 0 ? columns[0].field : undefined; + setRowModesModel(oldModel => ({ ...oldModel, [id]: { mode: GridRowModes.Edit, fieldToFocus } })); + }, [columns, defaultEntry, rows]); const EditToolbar = React.useMemo( () => ( - ), - [onNewAttribute] + [createNewEntry, newEntryText] ); + const NoRowsOverlay = React.useMemo(() => {noEntriesText ?? 'No Rows'}, [noEntriesText]); + return ( - > + > rows={rows} getRowId={getRowId} columns={columns} @@ -144,6 +176,7 @@ export default function AttributeGrid({ rowModesModel={rowModesModel} rowSelection={true} onRowModesModelChange={handleRowModesModelChange} + onRowEditStop={handleRowEditStop} processRowUpdate={handleRowUpdate} hideFooter={true} density='compact' @@ -153,7 +186,7 @@ export default function AttributeGrid({ disableMultipleRowSelection={true} disableColumnMenu={true} disableDensitySelector={true} - slots={{ toolbar: () => EditToolbar }} + slots={{ toolbar: () => EditToolbar, noRowsOverlay: () => NoRowsOverlay }} sx={{ fontSize: '1em', width: '100%', diff --git a/packages/react-model-ui/src/views/common/RelationshipAttributesDataGrid.tsx b/packages/react-model-ui/src/views/common/RelationshipAttributesDataGrid.tsx index 067268d..b61e063 100644 --- a/packages/react-model-ui/src/views/common/RelationshipAttributesDataGrid.tsx +++ b/packages/react-model-ui/src/views/common/RelationshipAttributesDataGrid.tsx @@ -1,12 +1,12 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossReferenceContext, EntityType, RelationshipAttribute } from '@crossbreeze/protocol'; -import { GridColDef, GridRenderEditCellParams, GridRowModel, useGridApiContext } from '@mui/x-data-grid'; +import { CrossReferenceContext, EntityType, RelationshipAttribute, RelationshipAttributeType } from '@crossbreeze/protocol'; +import { GridColDef, GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid'; import * as React from 'react'; import { useModelDispatch, useModelQueryApi, useRelationship } from '../../ModelContext'; import AsyncAutoComplete from './AsyncAutoComplete'; -import AttributeGrid, { AttributeRow } from './AttributeGrid'; +import GridComponent, { GridComponentRow } from './GridComponent'; export interface EditAttributePropertyComponentProps extends GridRenderEditCellParams { property: 'child' | 'parent'; @@ -55,13 +55,15 @@ export function EditAttributePropertyComponent({ ); } +export type RelationshipAttributeRow = GridComponentRow; + export function RelationshipAttributesDataGrid(): React.ReactElement { const relationship = useRelationship(); const dispatch = useModelDispatch(); // Callback for when the user stops editing a cell. const handleRowUpdate = React.useCallback( - (attribute: AttributeRow): GridRowModel => { + (attribute: RelationshipAttributeRow): RelationshipAttributeRow => { // Handle change of name property. dispatch({ type: 'relationship:attribute:update', @@ -77,12 +79,17 @@ export function RelationshipAttributesDataGrid(): React.ReactElement { [dispatch] ); - const handleAddAttribute = React.useCallback((): void => { - dispatch({ type: 'relationship:attribute:add-empty' }); - }, [dispatch]); + const handleAddAttribute = React.useCallback( + (attribute: RelationshipAttributeRow): void => { + if (attribute.child && attribute.parent) { + dispatch({ type: 'relationship:attribute:add-relationship', attribute }); + } + }, + [dispatch] + ); const handleAttributeUpward = React.useCallback( - (attribute: AttributeRow): void => { + (attribute: RelationshipAttributeRow): void => { dispatch({ type: 'relationship:attribute:move-attribute-up', attributeIdx: attribute.idx @@ -92,7 +99,7 @@ export function RelationshipAttributesDataGrid(): React.ReactElement { ); const handleAttributeDownward = React.useCallback( - (attribute: AttributeRow): void => { + (attribute: RelationshipAttributeRow): void => { dispatch({ type: 'relationship:attribute:move-attribute-down', attributeIdx: attribute.idx @@ -102,7 +109,7 @@ export function RelationshipAttributesDataGrid(): React.ReactElement { ); const handleAttributeDelete = React.useCallback( - (attribute: AttributeRow): void => { + (attribute: RelationshipAttributeRow): void => { dispatch({ type: 'relationship:attribute:delete-attribute', attributeIdx: attribute.idx @@ -134,14 +141,17 @@ export function RelationshipAttributesDataGrid(): React.ReactElement { ); return ( - ); diff --git a/packages/react-model-ui/src/views/common/index.ts b/packages/react-model-ui/src/views/common/index.ts index b392d19..265457d 100644 --- a/packages/react-model-ui/src/views/common/index.ts +++ b/packages/react-model-ui/src/views/common/index.ts @@ -2,6 +2,6 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ export * from './AsyncAutoComplete'; -export * from './AttributeGrid'; export * from './EntityAttributesDataGrid'; +export * from './GridComponent'; export * from './RelationshipAttributesDataGrid'; diff --git a/packages/react-model-ui/src/views/form/MappingForm.tsx b/packages/react-model-ui/src/views/form/MappingForm.tsx new file mode 100644 index 0000000..cbdf81b --- /dev/null +++ b/packages/react-model-ui/src/views/form/MappingForm.tsx @@ -0,0 +1,53 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { TextField } from '@mui/material'; +import * as React from 'react'; +import { useMapping, useModelDispatch } from '../../ModelContext'; +import { modelComponent } from '../../ModelViewer'; +import { themed } from '../../ThemedViewer'; +import { FormSection } from '../FormSection'; +import { AttributeMappingSourcesDataGrid } from '../common/AttributeMappingSourcesDataGrid'; +import { Form } from './Form'; + +export interface MappingRenderProps { + mappingIndex: number; +} + +export function MappingForm(props: MappingRenderProps): React.ReactElement { + const mapping = useMapping(); + const dispatch = useModelDispatch(); + const attributeMapping = mapping.target.mappings[props.mappingIndex]; + if (!attributeMapping) { + return <>; + } + + return ( +

+ + + + dispatch({ + type: 'attribute-mapping:change-expression', + mappingIdx: props.mappingIndex, + expression: event.target.value ?? '' + }) + } + /> + + + + +
+ ); +} + +export const MappingComponent = themed(modelComponent(MappingForm)); diff --git a/packages/react-model-ui/src/views/form/index.ts b/packages/react-model-ui/src/views/form/index.ts index 6cbdeb9..cbfe39f 100644 --- a/packages/react-model-ui/src/views/form/index.ts +++ b/packages/react-model-ui/src/views/form/index.ts @@ -3,4 +3,5 @@ ********************************************************************************/ export * from './EntityForm'; export * from './Header'; +export * from './MappingForm'; export * from './RelationshipForm';