From f16c04013c5577d5a611fa53ec37fd070f79f2f6 Mon Sep 17 00:00:00 2001 From: Tobias Ortmayr Date: Sun, 24 Sep 2023 15:14:37 +0200 Subject: [PATCH] GLSP-211: Revise TypeHints and server side feedback for creation actions - Refactor EdgeTypeHint definition - Add a new 'dynamic' property, indicating that new edges need and additional check with the server before allowing creation - Make source/target element type ids properties optional If not defined, all potential element types are considered to be valid sources/targets - Add `RequestEdgeCheckAction` and `EdgeCheckResultAction` response to implement the dynamic check - Update the EdgeCreationTool to check with the server when trying to create a new edge configured as Dynamic -Refactor `TypeHintsProvider` and `ApplyTypeHintsCommand - Simplify type-hints aware can connect implementation. Just validate against the source/target types of the edge hint that is applicable for the given routable - Ensure that the default (i.e. class-level implementation) of `canConnect` is called if no typehint is applicable to the given routable - Remove `getValidEdgeElementTypes` function from `TypeHintsProvider`. Was only used for the old type-hints aware `canConnect` implementation. Is no longer needed and incomplete anyways (does not consider nested subtypes) - Add tests for type-hints feature Fixes https://github.com/eclipse-glsp/glsp/issues/45 - Remove `hasCompatibleType` utility function from `smodel-util` as it is no longer used and the implementation was incomplete anyways (did not consider nested subtypes) - Also: Don't use stroked lines for edges until eclipse-glsp/glsp/issues/1083 is fixed Part of eclipse-glsp/glsp/issues/211 Co-authored-by: Camille Letavernier --- packages/client/css/glsp-sprotty.css | 3 - .../features/hints/type-hint-provider.spec.ts | 356 ++++++++++++++++++ .../src/features/hints/type-hint-provider.ts | 248 ++++++++++++ .../src/features/hints/type-hints-module.ts | 2 +- .../client/src/features/hints/type-hints.ts | 220 ----------- .../client/src/features/tools/base-tools.ts | 5 +- .../tools/edge-creation/edge-creation-tool.ts | 93 +++-- packages/client/src/index.ts | 2 +- packages/client/src/utils/smodel-util.ts | 13 +- .../element-type-hints.spec.ts | 129 ++++++- .../src/action-protocol/element-type-hints.ts | 144 ++++++- 11 files changed, 944 insertions(+), 271 deletions(-) create mode 100644 packages/client/src/features/hints/type-hint-provider.spec.ts create mode 100644 packages/client/src/features/hints/type-hint-provider.ts delete mode 100644 packages/client/src/features/hints/type-hints.ts diff --git a/packages/client/css/glsp-sprotty.css b/packages/client/css/glsp-sprotty.css index 52845c3d..57ce1f85 100644 --- a/packages/client/css/glsp-sprotty.css +++ b/packages/client/css/glsp-sprotty.css @@ -98,9 +98,6 @@ .sprotty-edge.selected { stroke: #1d80d1; stroke-width: 1.5px; - stroke-dashoffset: 5; - stroke-dasharray: 5, 5; - stroke-linecap: round; } .sprotty-edge.mouseover:not(.selected) .arrow, diff --git a/packages/client/src/features/hints/type-hint-provider.spec.ts b/packages/client/src/features/hints/type-hint-provider.spec.ts new file mode 100644 index 00000000..f09b66eb --- /dev/null +++ b/packages/client/src/features/hints/type-hint-provider.spec.ts @@ -0,0 +1,356 @@ +/******************************************************************************** + * Copyright (c) 2023 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { expect } from 'chai'; +import { Container } from 'inversify'; +import * as sinon from 'sinon'; +import { + AnimationFrameSyncer, + CommandExecutionContext, + ConsoleLogger, + EdgeTypeHint, + SChildElement, + SEdge, + SModelFactory, + SModelRoot, + SNode, + SetTypeHintsAction, + ShapeTypeHint, + TYPES, + Writable, + bindOrRebind, + createFeatureSet, + editFeature, + isDeletable, + isMoveable +} from '~glsp-sprotty'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; +import { FeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; +import { isResizable } from '../change-bounds/model'; +import { isReconnectable } from '../reconnect/model'; +import { Containable, isContainable, isReparentable } from './model'; +import { ApplyTypeHintsAction, ApplyTypeHintsCommand, ITypeHintProvider, TypeHintProvider } from './type-hint-provider'; + +describe('TypeHintProvider', () => { + const container = new Container(); + container.bind(GLSPActionDispatcher).toConstantValue(sinon.createStubInstance(GLSPActionDispatcher)); + container.bind(TYPES.IFeedbackActionDispatcher).toConstantValue(sinon.createStubInstance(FeedbackActionDispatcher)); + const typeHintProvider = container.resolve(TypeHintProvider); + + describe('getShapeTypeHint', () => { + const nodeHint: ShapeTypeHint = { + deletable: true, + elementTypeId: 'node', + reparentable: false, + repositionable: true, + resizable: true + }; + const taskHint: ShapeTypeHint = { + deletable: true, + elementTypeId: 'node:task', + reparentable: false, + repositionable: true, + resizable: true + }; + + it('should return `undefined` if no `SetTypeHintsAction` has been handled yet', () => { + expect(typeHintProvider.getShapeTypeHint('some')).to.be.undefined; + }); + it('should return `undefined` if no hint is registered for the given type (exact type match)', () => { + typeHintProvider.handle(SetTypeHintsAction.create({ shapeHints: [nodeHint], edgeHints: [] })); + expect(typeHintProvider.getShapeTypeHint('port')).to.be.undefined; + }); + it('should return the corresponding type hint for the given type (exact type match)', () => { + typeHintProvider.handle(SetTypeHintsAction.create({ shapeHints: [nodeHint, taskHint], edgeHints: [] })); + expect(typeHintProvider.getShapeTypeHint('node')).to.equal(nodeHint); + expect(typeHintProvider.getShapeTypeHint('node:task')).to.equal(taskHint); + }); + it('should return the corresponding type hint for the given type (sub type match)', () => { + typeHintProvider.handle(SetTypeHintsAction.create({ shapeHints: [nodeHint, taskHint], edgeHints: [] })); + expect(typeHintProvider.getShapeTypeHint('node:task:manual')).to.equal(taskHint); + expect(typeHintProvider.getShapeTypeHint('node:task:manual:foo')).to.equal(taskHint); + expect(typeHintProvider.getShapeTypeHint('node:event')).to.equal(nodeHint); + expect(typeHintProvider.getShapeTypeHint('node:event:initial')).to.equal(nodeHint); + }); + }); + describe('getEdgeTypeHint', () => { + const edgeHint: EdgeTypeHint = { + deletable: true, + elementTypeId: 'edge', + repositionable: true, + routable: true, + dynamic: false + }; + const fooEdgeHint: EdgeTypeHint = { + deletable: true, + elementTypeId: 'edge:foo', + repositionable: true, + routable: true, + dynamic: true + }; + it('should return `undefined` if no `SetTypeHintsAction` has been handled yet', () => { + expect(typeHintProvider.getEdgeTypeHint('some')).to.be.undefined; + }); + it('should return `undefined` if no hint is registered for the given type (exact type match)', () => { + typeHintProvider.handle(SetTypeHintsAction.create({ shapeHints: [], edgeHints: [edgeHint] })); + expect(typeHintProvider.getEdgeTypeHint('link')).to.be.undefined; + }); + it('should return the corresponding type hint for the given type (exact type match)', () => { + typeHintProvider.handle(SetTypeHintsAction.create({ shapeHints: [], edgeHints: [edgeHint, fooEdgeHint] })); + expect(typeHintProvider.getEdgeTypeHint('edge')).to.equal(edgeHint); + expect(typeHintProvider.getEdgeTypeHint('edge:foo')).to.equal(fooEdgeHint); + }); + it('should return the corresponding type hint for the given type (sub type match)', () => { + typeHintProvider.handle(SetTypeHintsAction.create({ shapeHints: [], edgeHints: [edgeHint, fooEdgeHint] })); + expect(typeHintProvider.getEdgeTypeHint('edge:foo:bar')).to.equal(fooEdgeHint); + expect(typeHintProvider.getEdgeTypeHint('edge:foo:bar:baz')).to.equal(fooEdgeHint); + expect(typeHintProvider.getEdgeTypeHint('edge:some')).to.equal(edgeHint); + expect(typeHintProvider.getEdgeTypeHint('edge:some:other')).to.equal(edgeHint); + }); + }); +}); +describe('ApplyTypeHintCommand', () => { + function createCommandExecutionContext(child: SChildElement): CommandExecutionContext { + const root = new SModelRoot(); + root.id = 'root'; + root.type = 'root'; + root.add(child); + return { + root, + modelFactory, + duration: 0, + modelChanged: undefined!, + logger: new ConsoleLogger(), + syncer: new AnimationFrameSyncer() + }; + } + + function createNode(type?: string): SNode { + const node = new SNode(); + node.type = type ?? 'node'; + node.id = 'node'; + node.features = createFeatureSet(SNode.DEFAULT_FEATURES); + return node; + } + + function createEdge(type?: string): SEdge { + const edge = new SEdge(); + edge.type = type ?? 'edge'; + edge.id = 'edge'; + edge.features = createFeatureSet(SEdge.DEFAULT_FEATURES); + return edge; + } + + const sandbox = sinon.createSandbox(); + const container = new Container(); + const modelFactory = sinon.createStubInstance(SModelFactory); + const typeHintProviderMock = sandbox.stub({ + getEdgeTypeHint: () => undefined, + getShapeTypeHint: () => undefined + }); + container.bind(GLSPActionDispatcher).toConstantValue(sandbox.createStubInstance(GLSPActionDispatcher)); + container.bind(TYPES.IFeedbackActionDispatcher).toConstantValue(sandbox.createStubInstance(FeedbackActionDispatcher)); + container.bind(TYPES.ITypeHintProvider).toConstantValue(typeHintProviderMock); + bindOrRebind(container, TYPES.Action).toConstantValue(ApplyTypeHintsAction.create()); + const command = container.resolve(ApplyTypeHintsCommand); + + beforeEach(() => { + sandbox.reset(); + }); + + describe('test hints to model feature translation (after command execution)`', () => { + describe('ShapeTypeHint', () => { + const allEnabledHint: ShapeTypeHint = { + elementTypeId: 'node', + deletable: true, + reparentable: true, + repositionable: true, + resizable: true, + containableElementTypeIds: [] + }; + const allDisabledHint: ShapeTypeHint = { + elementTypeId: 'node', + deletable: false, + reparentable: false, + repositionable: false, + resizable: false, + containableElementTypeIds: [] + }; + it('should not modify feature set of model element with no applicable type hint', () => { + typeHintProviderMock.getShapeTypeHint.returns(undefined); + const result = command.execute(createCommandExecutionContext(createNode())); + const element = result.children[0]; + expect(SNode.DEFAULT_FEATURES, 'Element should have default feature set').to.have.same.members([ + ...(element.features as Set) + ]); + }); + it('should add all enabled (`true`) features, derived from the applied type hint, to the model', () => { + typeHintProviderMock.getShapeTypeHint.returns(allEnabledHint); + const result = command.execute(createCommandExecutionContext(createNode())); + const element = result.children[0]; + expect(isDeletable(element), 'Element should have deletable feature').to.be.true; + expect(isReparentable(element), 'Element should have reparentable feature').to.be.true; + expect(isMoveable(element), 'Element should have moveable feature').to.be.true; + expect(isContainable(element), 'Element should have containable feature').to.be.true; + expect(isResizable(element), 'Element should have resizeable feature').to.be.true; + }); + it('should remove all disabled (`false`) features, derived from the applied type hint, from the model', () => { + typeHintProviderMock.getShapeTypeHint.returns(allDisabledHint); + const result = command.execute(createCommandExecutionContext(createNode())); + const element = result.children[0]; + expect(isDeletable(element), 'Element should not have deletable feature').to.be.false; + expect(isReparentable(element), 'Element should not have reparentable feature').to.be.false; + expect(isMoveable(element), 'Element should not have moveable feature').to.be.false; + expect(isResizable(element), 'Element should not have resizeable feature').to.be.false; + }); + describe('`isConnectable` (after hint has been applied to element)', () => { + const shapeHint: Writable = { + deletable: false, + elementTypeId: 'node', + reparentable: false, + repositionable: false, + resizable: false + }; + const edgeHint: Writable = { + deletable: false, + elementTypeId: 'edge', + repositionable: false, + routable: false + }; + it('should return `true` if source/target elements are not defined in edge hint', () => { + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + typeHintProviderMock.getEdgeTypeHint.returns(edgeHint); + const result = command.execute(createCommandExecutionContext(createNode())); + const element = result.children[0] as SNode; + const edge = createEdge(); + expect(element.canConnect(edge, 'source')).to.be.true; + expect(element.canConnect(edge, 'target')).to.be.true; + }); + it('should return `false` if element type is not in source/target elements of edge hint', () => { + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + typeHintProviderMock.getEdgeTypeHint.returns(edgeHint); + edgeHint.sourceElementTypeIds = []; + edgeHint.targetElementTypeIds = []; + const result = command.execute(createCommandExecutionContext(createNode())); + const element = result.children[0] as SNode; + const edge = createEdge(); + expect(element.canConnect(edge, 'source')).to.be.false; + expect(element.canConnect(edge, 'target')).to.be.false; + }); + it('should return `true` if element type is in source/target elements of edge hint (exact type)', () => { + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + typeHintProviderMock.getEdgeTypeHint.returns(edgeHint); + edgeHint.sourceElementTypeIds = ['node']; + edgeHint.targetElementTypeIds = ['node']; + const result = command.execute(createCommandExecutionContext(createNode())); + const element = result.children[0] as SNode; + const edge = createEdge(); + expect(element.canConnect(edge, 'source')).to.be.true; + expect(element.canConnect(edge, 'target')).to.be.true; + }); + it('should return `true` if element super type is in source/target elements of edge hint (super type)', () => { + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + typeHintProviderMock.getEdgeTypeHint.returns(edgeHint); + edgeHint.sourceElementTypeIds = ['node']; + edgeHint.targetElementTypeIds = ['node']; + const result = command.execute(createCommandExecutionContext(createNode('node:task:automated'))); + const element = result.children[0] as SNode; + const edge = createEdge(); + expect(element.canConnect(edge, 'source')).to.be.true; + expect(element.canConnect(edge, 'target')).to.be.true; + }); + it('should fallback to class-level `canConnect` implementation if no edge hint is applicable to routable', () => { + typeHintProviderMock.getEdgeTypeHint.returns(undefined); + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + const node = createNode(); + const originalCanConnectSpy = sinon.spy(node, 'canConnect'); + const result = command.execute(createCommandExecutionContext(node)); + const element = result.children[0] as SNode; + const edge = createEdge(); + expect(element.canConnect(edge, 'source')).to.be.true; + expect(element.canConnect(edge, 'target')).to.be.true; + expect(originalCanConnectSpy.called).to.be.true; + }); + }); + describe('`isContainable` (after hint has been applied to element)', () => { + const shapeHint: Writable = { + deletable: false, + elementTypeId: 'node', + reparentable: false, + repositionable: false, + resizable: false + }; + it('should return `false` if corresponding hint has no containable elements defined', () => { + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + const result = command.execute(createCommandExecutionContext(createNode('node'))); + const element = result.children[0] as SNode & Containable; + expect(element.isContainableElement('other')).to.be.false; + }); + it('should return `true` if corresponding hint has containable element with matching type', () => { + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + shapeHint.containableElementTypeIds = ['node']; + const result = command.execute(createCommandExecutionContext(createNode('node'))); + const element = result.children[0] as SNode & Containable; + expect(element.isContainableElement('node')).to.be.true; + }); + it('should return `true` if corresponding hint as has containable element with matching super type', () => { + typeHintProviderMock.getShapeTypeHint.returns(shapeHint); + shapeHint.containableElementTypeIds = ['node']; + const result = command.execute(createCommandExecutionContext(createNode('node'))); + const element = result.children[0] as SNode & Containable; + expect(element.isContainableElement('node:task:automated')).to.be.true; + }); + }); + }); + describe('EdgeTypeHint', () => { + const allEnabledHint: EdgeTypeHint = { + elementTypeId: 'edge', + deletable: true, + repositionable: true, + routable: true + }; + const allDisabledHint: EdgeTypeHint = { + elementTypeId: 'edge', + deletable: false, + repositionable: false, + routable: false + }; + it('should not modify feature set of model element with no applicable type hint', () => { + typeHintProviderMock.getEdgeTypeHint.returns(undefined); + const result = command.execute(createCommandExecutionContext(createEdge())); + const element = result.children[0]; + expect(SEdge.DEFAULT_FEATURES, 'Element should have default feature set').to.have.same.members([ + ...(element.features as Set) + ]); + }); + it('should add all enabled (`true`) features, derived from the applied type hint, to the model', () => { + typeHintProviderMock.getEdgeTypeHint.returns(allEnabledHint); + const result = command.execute(createCommandExecutionContext(createEdge())); + const element = result.children[0]; + expect(isDeletable(element), 'Element should have deletable feature').to.be.true; + expect(element.hasFeature(editFeature), 'Element should have edit feature').to.be.true; + expect(isReconnectable(element), 'Element should have reconnectable feature').to.be.true; + }); + it('should remove all disabled (`false`) features, derived from the applied type hint, from the model', () => { + typeHintProviderMock.getEdgeTypeHint.returns(allDisabledHint); + const result = command.execute(createCommandExecutionContext(createEdge())); + const element = result.children[0]; + expect(isDeletable(element), 'Element should not have deletable feature').to.be.false; + expect(element.hasFeature(editFeature), 'Element should not have edit feature').to.be.false; + expect(isReconnectable(element), 'Element should not have reconnectable feature').to.be.false; + }); + }); + }); +}); diff --git a/packages/client/src/features/hints/type-hint-provider.ts b/packages/client/src/features/hints/type-hint-provider.ts new file mode 100644 index 00000000..2c4e5cef --- /dev/null +++ b/packages/client/src/features/hints/type-hint-provider.ts @@ -0,0 +1,248 @@ +/******************************************************************************** + * Copyright (c) 2019-2023 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { inject, injectable } from 'inversify'; +import { + Action, + CommandExecutionContext, + Connectable, + EdgeTypeHint, + IActionHandler, + RequestTypeHintsAction, + SEdge, + SModelElement, + SModelElementSchema, + SModelRoot, + SRoutableElement, + SShapeElement, + SetTypeHintsAction, + ShapeTypeHint, + TYPES, + TypeHint, + connectableFeature, + deletableFeature, + editFeature, + isConnectable, + moveFeature +} from '~glsp-sprotty'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; +import { IFeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; +import { FeedbackCommand } from '../../base/feedback/feedback-command'; +import { IDiagramStartup } from '../../base/model/diagram-loader'; +import { getElementTypeId } from '../../utils/smodel-util'; +import { resizeFeature } from '../change-bounds/model'; +import { reconnectFeature } from '../reconnect/model'; +import { containerFeature, isContainable, reparentFeature } from './model'; + +/** + * Is dispatched by the {@link TypeHintProvider} to apply the type hints received from the server + * onto the graphical model. The action is dispatched as persistent feedback to ensure the applied type hints + * don't get lost after a server-side model update. + */ +export interface ApplyTypeHintsAction extends Action { + kind: typeof ApplyTypeHintsAction.KIND; +} + +export namespace ApplyTypeHintsAction { + export const KIND = 'applyTypeHints'; + + export function is(object: any): object is ApplyTypeHintsAction { + return Action.hasKind(object, KIND); + } + + export function create(): ApplyTypeHintsAction { + return { kind: KIND }; + } +} + +type CanConnectFn = Connectable['canConnect']; + +/** + * Command that processes the entire model and for each model element applies its + * type hints i.e. translates the type hint information into corresponding model features + * and adds/removes them from the model element. + */ +@injectable() +export class ApplyTypeHintsCommand extends FeedbackCommand { + public static KIND = ApplyTypeHintsAction.KIND; + public override readonly priority = 10; + + @inject(TYPES.ITypeHintProvider) + protected typeHintProvider: ITypeHintProvider; + + constructor(@inject(TYPES.Action) protected action: ApplyTypeHintsAction) { + super(); + } + + execute(context: CommandExecutionContext): SModelRoot { + context.root.index.all().forEach(element => { + if (element instanceof SShapeElement || element instanceof SModelRoot) { + return this.applyShapeTypeHint(element); + } + if (element instanceof SEdge) { + this.applyEdgeTypeHint(element); + } + }); + return context.root; + } + + protected applyEdgeTypeHint(element: SModelElement): void { + const hint = this.typeHintProvider.getEdgeTypeHint(element); + + if (hint && element.features instanceof Set) { + addOrRemove(element.features, deletableFeature, hint.deletable); + addOrRemove(element.features, editFeature, hint.routable); + addOrRemove(element.features, reconnectFeature, hint.repositionable); + } + } + + protected applyShapeTypeHint(element: SModelElement): void { + const hint = this.typeHintProvider.getShapeTypeHint(element); + if (hint && element.features instanceof Set) { + addOrRemove(element.features, deletableFeature, hint.deletable); + addOrRemove(element.features, moveFeature, hint.repositionable); + addOrRemove(element.features, resizeFeature, hint.resizable); + addOrRemove(element.features, reparentFeature, hint.reparentable); + + addOrRemove(element.features, containerFeature, true); + if (isContainable(element)) { + element.isContainableElement = input => this.isContainableElement(input, hint); + } + + const fallbackCanConnect = isConnectable(element) ? element.canConnect.bind(element) : undefined; + addOrRemove(element.features, connectableFeature, true); + if (isConnectable(element)) { + element.canConnect = (routable, role) => this.canConnect(routable, role, element, fallbackCanConnect); + } + } + } + + /** + * Type hints aware wrapper function for `Connectable.canConnect`. After type hints have been applied + * the `canConnect` implementation of `connectable` model elements (with a matching hint) will forward to this method. + */ + protected canConnect( + routable: SRoutableElement, + role: 'source' | 'target', + element: SModelElement, + fallbackCanConnect?: CanConnectFn + ): boolean { + const edgeHint = this.typeHintProvider.getEdgeTypeHint(routable.type); + if (!edgeHint) { + return fallbackCanConnect?.(routable, role) ?? false; + } + const validElementIds = role === 'source' ? edgeHint.sourceElementTypeIds : edgeHint.targetElementTypeIds; + // If no source/target element ids are defined in the hint all elements are considered valid + if (!validElementIds) { + return true; + } + const elementType = element.type + ':'; + return validElementIds.some(type => elementType.startsWith(type)); + } + + /** + * Type hints aware wrapper function for `Containable.isContainableElement`. After type hints have been applied + * the `isContainableElement` implementation of `containable` model elements (with a matching hint) will forward to this method. + */ + protected isContainableElement(input: SModelElement | SModelElementSchema | string, hint: ShapeTypeHint): boolean { + const elemenType = getElementTypeId(input) + ':'; + return hint.containableElementTypeIds?.some(type => elemenType.startsWith(type)) ?? false; + } +} + +function addOrRemove(features: Set, feature: symbol, add: boolean): void { + if (add && !features.has(feature)) { + features.add(feature); + } else if (!add && features.has(feature)) { + features.delete(feature); + } +} + +/** + * Provides query methods for retrieving the type hint that is applicable for a given model element. + * If there is no type hint registered for the given element type the hint of the most concrete subtype (if any) + * is returned instead. Subtypes are declared with a `:` delimiter. + * For example consider the type `node:task:manual`. Then the provider fist checks wether there is + * a type hint registered for `node:task:manual`. If not it checks wether there is one registered + * for `node:task` and finally it checks wether there is a type hint for `node`. + */ +export interface ITypeHintProvider { + /** + * Retrieve the most applicable {@link ShapeTypeHint} for the given model element. + * + * @param input The model element whose type hint should be retrieved + * @returns The most applicable hint of the given element or `undefined` if no matching hint is registered. + */ + getShapeTypeHint(input: SModelElement | SModelElementSchema | string): ShapeTypeHint | undefined; + /** + * Retrieve the most applicable {@link EdgeTypeHint} for the given model element. + * + * @param input The model element whose type hint should be retrieved + * @returns The most applicable hint of the given element or `undefined` if no matching hint is registered. + */ + getEdgeTypeHint(input: SModelElement | SModelElementSchema | string): EdgeTypeHint | undefined; +} + +@injectable() +export class TypeHintProvider implements IActionHandler, ITypeHintProvider, IDiagramStartup { + @inject(TYPES.IFeedbackActionDispatcher) + protected feedbackActionDispatcher: IFeedbackActionDispatcher; + + @inject(GLSPActionDispatcher) + protected actionDispatcher: GLSPActionDispatcher; + + protected shapeHints: Map = new Map(); + protected edgeHints: Map = new Map(); + + handle(action: SetTypeHintsAction): void { + this.shapeHints.clear(); + this.edgeHints.clear(); + action.shapeHints.forEach(hint => this.shapeHints.set(hint.elementTypeId, hint)); + action.edgeHints.forEach(hint => this.edgeHints.set(hint.elementTypeId, hint)); + this.feedbackActionDispatcher.registerFeedback(this, [ApplyTypeHintsAction.create()]); + } + + getShapeTypeHint(input: SModelElement | SModelElementSchema | string): ShapeTypeHint | undefined { + return this.getTypeHint(input, this.shapeHints); + } + + getEdgeTypeHint(input: SModelElement | SModelElementSchema | string): EdgeTypeHint | undefined { + return this.getTypeHint(input, this.edgeHints); + } + + protected getTypeHint(input: SModelElement | SModelElementSchema | string, hints: Map): T | undefined { + const type = getElementTypeId(input); + let hint = hints.get(type); + // Check subtypes + if (hint === undefined) { + const subtypes = type.split(':'); + while (hint === undefined && subtypes.length > 0) { + subtypes.pop(); + hint = hints.get(subtypes.join(':')); + if (hint) { + // add received subtype hint to map to avoid future recomputation + hints.set(type, hint); + break; + } + } + } + return hint; + } + + async postRequestModel(): Promise { + const setTypeHintsAction = await this.actionDispatcher.request(RequestTypeHintsAction.create()); + this.handle(setTypeHintsAction); + } +} diff --git a/packages/client/src/features/hints/type-hints-module.ts b/packages/client/src/features/hints/type-hints-module.ts index 0ba11f9d..7d3f8799 100644 --- a/packages/client/src/features/hints/type-hints-module.ts +++ b/packages/client/src/features/hints/type-hints-module.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { FeatureModule, SetTypeHintsAction, TYPES, bindAsService, configureActionHandler, configureCommand } from '~glsp-sprotty'; -import { ApplyTypeHintsCommand, TypeHintProvider } from './type-hints'; +import { ApplyTypeHintsCommand, TypeHintProvider } from './type-hint-provider'; export const typeHintsModule = new FeatureModule((bind, unbind, isBound) => { const context = { bind, unbind, isBound }; diff --git a/packages/client/src/features/hints/type-hints.ts b/packages/client/src/features/hints/type-hints.ts deleted file mode 100644 index bf618430..00000000 --- a/packages/client/src/features/hints/type-hints.ts +++ /dev/null @@ -1,220 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2019-2023 EclipseSource and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { inject, injectable } from 'inversify'; -import { - Action, - CommandExecutionContext, - CommandReturn, - Connectable, - EdgeTypeHint, - FeatureSet, - IActionHandler, - ICommand, - MaybePromise, - RequestTypeHintsAction, - SEdge, - SModelElement, - SModelRoot, - SShapeElement, - SetTypeHintsAction, - ShapeTypeHint, - TYPES, - TypeHint, - connectableFeature, - deletableFeature, - editFeature, - moveFeature -} from '~glsp-sprotty'; -import { GLSPActionDispatcher } from '../../base/action-dispatcher'; -import { IFeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; -import { FeedbackCommand } from '../../base/feedback/feedback-command'; -import { IDiagramStartup } from '../../base/model/diagram-loader'; -import { getElementTypeId, hasCompatibleType } from '../../utils/smodel-util'; -import { resizeFeature } from '../change-bounds/model'; -import { reconnectFeature } from '../reconnect/model'; -import { Containable, containerFeature, reparentFeature } from './model'; - -/** - * Is dispatched by the {@link TypeHintProvider} to apply the type hints received from the server - * onto the graphical model. The action is dispatched as persistent feedback to ensure the applied type hints - * don't get lost after a server-side model update. - */ -export interface ApplyTypeHintsAction extends Action { - kind: typeof ApplyTypeHintsAction.KIND; -} - -export namespace ApplyTypeHintsAction { - export const KIND = 'applyTypeHints'; - - export function is(object: any): object is ApplyTypeHintsAction { - return Action.hasKind(object, KIND); - } - - export function create(): ApplyTypeHintsAction { - return { kind: KIND }; - } -} - -@injectable() -export class ApplyTypeHintsCommand extends FeedbackCommand { - public static KIND = ApplyTypeHintsAction.KIND; - public override readonly priority = 10; - - @inject(TYPES.ITypeHintProvider) protected typeHintProvider: ITypeHintProvider; - - constructor(@inject(TYPES.Action) protected action: Action) { - super(); - } - - execute(context: CommandExecutionContext): CommandReturn { - context.root.index.all().forEach(element => { - if (element instanceof SShapeElement || element instanceof SModelRoot) { - this.applyShapeTypeHint(element); - } else if (element instanceof SEdge) { - return this.applyEdgeTypeHint(element); - } - }); - return context.root; - } - - protected applyEdgeTypeHint(element: SModelElement): void { - const hint = this.typeHintProvider.getEdgeTypeHint(element); - if (hint && isModifiableFeatureSet(element.features)) { - addOrRemove(element.features, deletableFeature, hint.deletable); - addOrRemove(element.features, editFeature, hint.routable); - addOrRemove(element.features, reconnectFeature, hint.repositionable); - } - } - - protected applyShapeTypeHint(element: SModelElement): void { - const hint = this.typeHintProvider.getShapeTypeHint(element); - if (hint && isModifiableFeatureSet(element.features)) { - addOrRemove(element.features, deletableFeature, hint.deletable); - addOrRemove(element.features, moveFeature, hint.repositionable); - addOrRemove(element.features, resizeFeature, hint.resizable); - addOrRemove(element.features, reparentFeature, hint.reparentable); - - addOrRemove(element.features, containerFeature, true); - const containable = createContainable(hint); - Object.assign(element, containable); - - addOrRemove(element.features, connectableFeature, true); - const validSourceEdges = this.typeHintProvider.getValidEdgeElementTypes(element, 'source'); - const validTargetEdges = this.typeHintProvider.getValidEdgeElementTypes(element, 'target'); - const connectable = createConnectable(validSourceEdges, validTargetEdges); - Object.assign(element, connectable); - } - } -} - -function createConnectable(validSourceEdges: string[], validTargetEdges: string[]): Connectable { - return { - canConnect: (routable, role) => - role === 'source' ? validSourceEdges.includes(routable.type) : validTargetEdges.includes(routable.type) - }; -} - -function createContainable(hint: ShapeTypeHint): Containable { - return { - isContainableElement: element => - hint.containableElementTypeIds ? hint.containableElementTypeIds.includes(getElementTypeId(element)) : false - }; -} - -function addOrRemove(features: Set, feature: symbol, add: boolean): void { - if (add && !features.has(feature)) { - features.add(feature); - } else if (!add && features.has(feature)) { - features.delete(feature); - } -} - -function isModifiableFeatureSet(featureSet?: FeatureSet): featureSet is FeatureSet & Set { - return featureSet !== undefined && featureSet instanceof Set; -} - -export interface ITypeHintProvider { - getShapeTypeHint(input: SModelElement | SModelElement | string): ShapeTypeHint | undefined; - getEdgeTypeHint(input: SModelElement | SModelElement | string): EdgeTypeHint | undefined; - getValidEdgeElementTypes(input: SModelElement | SModelElement | string, role: 'source' | 'target'): string[]; -} - -@injectable() -export class TypeHintProvider implements IActionHandler, ITypeHintProvider, IDiagramStartup { - @inject(TYPES.IFeedbackActionDispatcher) - protected feedbackActionDispatcher: IFeedbackActionDispatcher; - - @inject(GLSPActionDispatcher) - protected actionDispatcher: GLSPActionDispatcher; - - protected shapeHints: Map = new Map(); - protected edgeHints: Map = new Map(); - - handle(action: Action): ICommand | Action | void { - if (SetTypeHintsAction.is(action)) { - action.shapeHints.forEach(hint => this.shapeHints.set(hint.elementTypeId, hint)); - action.edgeHints.forEach(hint => this.edgeHints.set(hint.elementTypeId, hint)); - this.feedbackActionDispatcher.registerFeedback(this, [ApplyTypeHintsAction.create()]); - } - } - - getValidEdgeElementTypes(input: SModelElement | SModelElement | string, role: 'source' | 'target'): string[] { - const elementTypeId = getElementTypeId(input); - if (role === 'source') { - return Array.from( - Array.from(this.edgeHints.values()) - .filter(hint => - hint.sourceElementTypeIds.some(sourceElementTypeId => hasCompatibleType(elementTypeId, sourceElementTypeId)) - ) - .map(hint => hint.elementTypeId) - ); - } else { - return Array.from( - Array.from(this.edgeHints.values()) - .filter(hint => - hint.targetElementTypeIds.some(targetElementTypeId => hasCompatibleType(elementTypeId, targetElementTypeId)) - ) - .map(hint => hint.elementTypeId) - ); - } - } - - getShapeTypeHint(input: SModelElement | SModelElement | string): ShapeTypeHint | undefined { - return getTypeHint(input, this.shapeHints); - } - - getEdgeTypeHint(input: SModelElement | SModelElement | string): EdgeTypeHint | undefined { - return getTypeHint(input, this.edgeHints); - } - - postRequestModel(): MaybePromise { - this.actionDispatcher.dispatch(RequestTypeHintsAction.create()); - } -} - -function getTypeHint(input: SModelElement | SModelElement | string, hints: Map): T | undefined { - const type = getElementTypeId(input); - let hint = hints.get(type); - // Check subtypes - if (hint === undefined) { - const subtypes = type.split(':'); - while (hint === undefined && subtypes.length > 0) { - subtypes.pop(); - hint = hints.get(subtypes.join(':')); - } - } - return hint; -} diff --git a/packages/client/src/features/tools/base-tools.ts b/packages/client/src/features/tools/base-tools.ts index fe23c1a8..90b5a901 100644 --- a/packages/client/src/features/tools/base-tools.ts +++ b/packages/client/src/features/tools/base-tools.ts @@ -14,7 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { inject, injectable } from 'inversify'; -import { Action, Disposable, DisposableCollection, IActionDispatcher, IActionHandler, TYPES } from '~glsp-sprotty'; +import { Action, Disposable, DisposableCollection, IActionHandler, TYPES } from '~glsp-sprotty'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; import { EditorContextService } from '../../base/editor-context-service'; import { IFeedbackActionDispatcher, IFeedbackEmitter } from '../../base/feedback/feedback-action-dispatcher'; import { EnableToolsAction, Tool } from '../../base/tool-manager/tool'; @@ -27,7 +28,7 @@ import { GLSPMouseTool } from '../../base/view/mouse-tool'; @injectable() export abstract class BaseEditTool implements Tool { @inject(TYPES.IFeedbackActionDispatcher) protected feedbackDispatcher: IFeedbackActionDispatcher; - @inject(TYPES.IActionDispatcher) protected actionDispatcher: IActionDispatcher; + @inject(TYPES.IActionDispatcher) protected actionDispatcher: GLSPActionDispatcher; @inject(GLSPMouseTool) protected mouseTool: GLSPMouseTool; @inject(GLSPKeyTool) protected keyTool: GLSPKeyTool; @inject(EditorContextService) protected readonly editorContext: EditorContextService; diff --git a/packages/client/src/features/tools/edge-creation/edge-creation-tool.ts b/packages/client/src/features/tools/edge-creation/edge-creation-tool.ts index 4aca5b6b..655af159 100644 --- a/packages/client/src/features/tools/edge-creation/edge-creation-tool.ts +++ b/packages/client/src/features/tools/edge-creation/edge-creation-tool.ts @@ -18,17 +18,20 @@ import { Action, AnchorComputerRegistry, CreateEdgeOperation, + RequestCheckEdgeAction, SEdge, SModelElement, + TYPES, TriggerEdgeCreationAction, findParentByFeature, isConnectable, isCtrlOrCmd } from '~glsp-sprotty'; +import { GLSPActionDispatcher } from '../../../base/action-dispatcher'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; - import { CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback'; import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; +import { ITypeHintProvider } from '../../hints/type-hint-provider'; import { BaseCreationTool } from '../base-tools'; import { DrawFeedbackEdgeAction, RemoveFeedbackEdgeAction } from './dangling-edge-feedback'; import { FeedbackEdgeEndMovingMouseListener } from './edge-creation-tool-feedback'; @@ -42,6 +45,8 @@ export class EdgeCreationTool extends BaseCreationTool { + if (this.pendingDynamicCheck) { + this.allowedTarget = result.isValid; + this.actionDispatcher.dispatch(this.updateEdgeFeedback()); + this.pendingDynamicCheck = false; + } + }) + .catch(err => console.error('Dynamic edge check failed with: ', err)); + // Temporarily mark the target as invalid while we wait for the server response, + // so a fast-clicking user doesn't get a chance to create the edge in the meantime. + return false; } - protected isAllowedTarget(element: SModelElement | undefined): boolean { - return element !== undefined && isConnectable(element) && element.canConnect(this.proxyEdge, 'target'); + protected isDynamic(edgeTypeId: string): boolean { + const typeHint = this.typeHintProvider.getEdgeTypeHint(edgeTypeId); + return typeHint?.dynamic ?? false; } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 004d5dc6..6a724e41 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -71,7 +71,7 @@ export * from './features/copy-paste/copy-paste-handler'; export * from './features/decoration/decoration-placer'; export * from './features/export/glsp-svg-exporter'; export * from './features/hints/model'; -export * from './features/hints/type-hints'; +export * from './features/hints/type-hint-provider'; export * from './features/hover/hover'; export * from './features/label-edit/edit-label-tool'; export * from './features/label-edit/edit-label-validator'; diff --git a/packages/client/src/utils/smodel-util.ts b/packages/client/src/utils/smodel-util.ts index 5dbe29fc..5e9101d2 100644 --- a/packages/client/src/utils/smodel-util.ts +++ b/packages/client/src/utils/smodel-util.ts @@ -306,15 +306,6 @@ export function calcRoute( return calculatedRoute; } -/** - * Checks if the model is compatible with the passed type string. - * (either has the same type or a subtype of this type) - */ -export function hasCompatibleType(input: SModelElement | SModelElementSchema | string, type: string): boolean { - const inputType = getElementTypeId(input); - return inputType === type ? true : inputType.split(':').includes(type); -} - /** * Convenience function to retrieve the model element type from a given input. The input * can either be a {@link SModelElement}, {@link SModelElementSchema} or a string. @@ -323,8 +314,8 @@ export function hasCompatibleType(input: SModelElement | SModelElementSchema | s */ export function getElementTypeId(input: SModelElement | SModelElementSchema | string): string { if (typeof input === 'string') { - return input as string; + return input; } else { - return (input as any)['type'] as string; + return input.type; } } diff --git a/packages/protocol/src/action-protocol/element-type-hints.spec.ts b/packages/protocol/src/action-protocol/element-type-hints.spec.ts index e6d6f377..a5752b22 100644 --- a/packages/protocol/src/action-protocol/element-type-hints.spec.ts +++ b/packages/protocol/src/action-protocol/element-type-hints.spec.ts @@ -15,7 +15,7 @@ ********************************************************************************/ /* eslint-disable max-len */ import { expect } from 'chai'; -import { RequestTypeHintsAction, SetTypeHintsAction } from './element-type-hints'; +import { CheckEdgeResultAction, RequestCheckEdgeAction, RequestTypeHintsAction, SetTypeHintsAction } from './element-type-hints'; /** * Tests for the utility functions declared in the namespaces of the protocol * action definitions. @@ -136,4 +136,131 @@ describe('Element type hints actions', () => { }); }); }); + describe('RequestCheckEdgeAction', () => { + describe('is', () => { + it('should return true for an object having the correct type and a value for all required interface properties', () => { + const action: RequestCheckEdgeAction = { + kind: 'requestCheckEdge', + edgeType: 'edge', + sourceElementId: 'source', + requestId: '' + }; + expect(RequestCheckEdgeAction.is(action)).to.be.true; + }); + it('should return true for an object having the correct type and a value for all required interface & optional properties', () => { + const action: RequestCheckEdgeAction = { + kind: 'requestCheckEdge', + edgeType: 'edge', + sourceElementId: 'source', + targetElementId: 'target', + requestId: '' + }; + expect(RequestCheckEdgeAction.is(action)).to.be.true; + }); + it('should return false for `undefined`', () => { + expect(RequestCheckEdgeAction.is(undefined)).to.be.false; + }); + it('should return false for an object that does not have all required interface properties', () => { + expect(RequestCheckEdgeAction.is({ kind: 'notTheRightOne' })).to.be.false; + }); + }); + + describe('create', () => { + it('should return an object conforming to the interface with matching properties for the given required arguments and default values for the optional arguments', () => { + const expected: RequestCheckEdgeAction = { + kind: 'requestCheckEdge', + edgeType: 'edge', + sourceElementId: 'source', + requestId: '', + targetElementId: undefined + }; + + expect(RequestCheckEdgeAction.create({ edgeType: 'edge', sourceElement: 'source' })).to.deep.equals(expected); + }); + it('should return an object conforming to the interface with matching properties for the given required and optional arguments', () => { + const expected: RequestCheckEdgeAction = { + kind: 'requestCheckEdge', + edgeType: 'edge', + sourceElementId: 'source', + targetElementId: 'target', + requestId: 'myRequest' + }; + + expect( + RequestCheckEdgeAction.create({ + edgeType: 'edge', + sourceElement: 'source', + targetElement: 'target', + requestId: 'myRequest' + }) + ).to.deep.equals(expected); + }); + }); + }); + describe('CheckEdgeResultAction', () => { + describe('is', () => { + it('should return true for an object having the correct type and a value for all required interface properties', () => { + const action: CheckEdgeResultAction = { + isValid: true, + kind: 'checkEdgeTargetResult', + edgeType: 'edge', + sourceElementId: 'source', + responseId: '' + }; + expect(CheckEdgeResultAction.is(action)).to.be.true; + }); + it('should return true for an object having the correct type and a value for all required interface & optional properties', () => { + const action: CheckEdgeResultAction = { + isValid: true, + kind: 'checkEdgeTargetResult', + edgeType: 'edge', + sourceElementId: 'source', + targetElementId: 'target', + responseId: 'myResponse' + }; + expect(CheckEdgeResultAction.is(action)).to.be.true; + }); + it('should return false for `undefined`', () => { + expect(CheckEdgeResultAction.is(undefined)).to.be.false; + }); + it('should return false for an object that does not have all required interface properties', () => { + expect(CheckEdgeResultAction.is({ kind: 'notTheRightOne' })).to.be.false; + }); + }); + + describe('create', () => { + it('should return an object conforming to the interface with matching properties for the given required arguments and default values for the optional arguments', () => { + const expected: CheckEdgeResultAction = { + isValid: true, + kind: 'checkEdgeTargetResult', + edgeType: 'edge', + sourceElementId: 'source', + responseId: '' + }; + expect(CheckEdgeResultAction.create({ edgeType: 'edge', isValid: true, sourceElementId: 'source' })).to.deep.equals( + expected + ); + }); + it('should return an object conforming to the interface with matching properties for the given required and optional arguments', () => { + const expected: CheckEdgeResultAction = { + isValid: true, + kind: 'checkEdgeTargetResult', + edgeType: 'edge', + sourceElementId: 'source', + targetElementId: 'target', + responseId: 'myResponse' + }; + + expect( + CheckEdgeResultAction.create({ + edgeType: 'edge', + isValid: true, + sourceElementId: 'source', + targetElementId: 'target', + responseId: 'myResponse' + }) + ).to.deep.equals(expected); + }); + }); + }); }); diff --git a/packages/protocol/src/action-protocol/element-type-hints.ts b/packages/protocol/src/action-protocol/element-type-hints.ts index a0bbebe6..a115c6b5 100644 --- a/packages/protocol/src/action-protocol/element-type-hints.ts +++ b/packages/protocol/src/action-protocol/element-type-hints.ts @@ -14,7 +14,9 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { hasArrayProp } from '../utils/type-util'; +import { SModelElement } from 'sprotty-protocol'; +import { SModelElementSchema } from '.'; +import { hasArrayProp, hasBooleanProp, hasStringProp } from '../utils/type-util'; import { Action, RequestAction, ResponseAction } from './base-protocol'; /** @@ -63,25 +65,37 @@ export interface ShapeTypeHint extends TypeHint { */ export interface EdgeTypeHint extends TypeHint { /** - * Specifies whether the routing of this element can be changed. + * Specifies whether the routing points of the edge can be changed + * i.e. edited by the user. */ readonly routable: boolean; /** - * Allowed source element types for this edge type + * Allowed source element types for this edge type. + * If not defined any element can be used as source element for this edge. */ - readonly sourceElementTypeIds: string[]; + readonly sourceElementTypeIds?: string[]; /** - * Allowed targe element types for this edge type + * Allowed target element types for this edge type + * If not defined any element can be used as target element for this edge. */ - readonly targetElementTypeIds: string[]; + readonly targetElementTypeIds?: string[]; + + /** + * Indicates whether this type hint is dynamic or not. Dynamic edge type hints + * require an additional runtime check before creating an edge, when checking + * source and target element types is not sufficient. + * + * @see {@link RequestCheckEdgeAction} + */ + readonly dynamic?: boolean; } /** * Sent from the client to the server in order to request hints on whether certain modifications are allowed for a specific element type. * The `RequestTypeHintsAction` is optional, but should usually be among the first messages sent from the client to the server after - * receiving the model via RequestModelAction. The response is a {@link SetTypeHintsAction}. + * receiving the model via `RequestModelAction`. The response is a {@link SetTypeHintsAction}. * The corresponding namespace declares the action kind as constant and offers helper functions for type guard checks * and creating new `RequestTypeHintsActions`. */ @@ -133,3 +147,119 @@ export namespace SetTypeHintsAction { }; } } + +/** + * Send a Request to the server to check if an element is a valid target + * when creating a new Edge. Typically dispatched twice, once for checking + * the source element (with a dynamic hint) and a second time when trying to connect to a + * target (with a dynamic hint). + */ +export interface RequestCheckEdgeAction extends RequestAction { + kind: typeof RequestCheckEdgeAction.KIND; + + /** + * The element type of the edge being created. + */ + edgeType: string; + + /** + * The ID of the edge source element. + */ + sourceElementId: string; + + /** + * The ID of the edge target element to check. + */ + targetElementId?: string; +} + +export namespace RequestCheckEdgeAction { + export const KIND = 'requestCheckEdge'; + + export function is(object: unknown): object is RequestCheckEdgeAction { + return ( + Action.hasKind(object, KIND) && + hasStringProp(object, 'edgeType') && + hasStringProp(object, 'sourceElementId') && + hasStringProp(object, 'targetElementId', true) + ); + } + + export function create(options: { + sourceElement: SModelElement | SModelElementSchema | string; + targetElement?: SModelElement | SModelElementSchema | string; + edgeType: string; + requestId?: string; + }): RequestCheckEdgeAction { + return { + kind: KIND, + edgeType: options.edgeType, + sourceElementId: getElementId(options.sourceElement), + targetElementId: options.targetElement ? getElementId(options.targetElement) : undefined, + requestId: options.requestId ?? '' + }; + } +} + +function getElementId(element: SModelElement | string): string { + if (typeof element === 'string') { + return element; + } + return element.id; +} + +/** + * Response Action for a {@link RequestCheckEdgeAction}. It provides + * a boolean indicating whether the requested element is a valid target + * for the edge being created and the context edge context information (type, source, target). + */ +export interface CheckEdgeResultAction extends ResponseAction { + kind: typeof CheckEdgeResultAction.KIND; + + /** + * true if the selected element is a valid target for this edge, + * false otherwise. + */ + isValid: boolean; + /** + * The element type of the edge that has been checked. + */ + edgeType: string; + + /** + * The ID of the source element of the edge that has been checked. + */ + sourceElementId: string; + /** + * The ID of the target element of the edge that has been checked. + */ + targetElementId?: string; +} + +export namespace CheckEdgeResultAction { + export const KIND = 'checkEdgeTargetResult'; + + export function is(object: unknown): object is CheckEdgeResultAction { + return ( + Action.hasKind(object, KIND) && + hasBooleanProp(object, 'isValid') && + hasStringProp(object, 'edgeType') && + hasStringProp(object, 'sourceElementId') && + hasStringProp(object, 'targetElementId', true) + ); + } + + export function create(options: { + isValid: boolean; + edgeType: string; + sourceElementId: string; + targetElementId?: string; + responseId?: string; + }): CheckEdgeResultAction { + return { + kind: KIND, + responseId: '', + ...options + }; + } +}