Skip to content

Commit

Permalink
feat(graph-arc): can add or remove arcs
Browse files Browse the repository at this point in the history
  • Loading branch information
Mendes Hugo committed Aug 20, 2023
1 parent 45857d7 commit bbf3ce2
Show file tree
Hide file tree
Showing 10 changed files with 1,342 additions and 33 deletions.
6 changes: 3 additions & 3 deletions apps/backend/src/app/graph/arc/graph-arc.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
Delete,
Get,
NotFoundException,
NotImplementedException,
Param,
Patch,
Post,
Expand Down Expand Up @@ -71,8 +70,9 @@ export class GraphArcController implements EndpointTransformed {
@ApiCreatedResponse({ type: GraphArcDto })
@ApiGraphParam()
@Post()
public create(@GraphInterceptedParam() _graph: Graph, @Body() _body: GraphArcCreateDto) {
return Promise.reject(new NotImplementedException(`Can not create an arc yet.`));
public create(@GraphInterceptedParam() _graph: Graph, @Body() body: GraphArcCreateDto) {
// TODO: verify graph
return this.service.create(body);
}

@ApiExcludeEndpoint()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata } from "@storybook/angular";
import { Jsonify } from "type-fest";
import { GraphDto } from "~/lib/common/app/graph/dtos";
Expand All @@ -6,7 +7,7 @@ import { BASE_SEED } from "~/lib/common/seeds";

import { GraphComponent } from "./graph.component";

const getGraphContent = (graph: GraphDto): Pick<GraphComponent, "arcs" | "nodes"> => {
const getGraphContent = (graph: GraphDto): Pick<GraphComponent, "actions" | "arcs" | "nodes"> => {
const { graphArcs, graphNodeInputs, graphNodeOutputs, graphNodes } = JSON.parse(
JSON.stringify(BASE_SEED.graph)
) as Jsonify<typeof BASE_SEED.graph>;
Expand All @@ -19,21 +20,37 @@ const getGraphContent = (graph: GraphDto): Pick<GraphComponent, "arcs" | "nodes"
outputs: graphNodeOutputs.filter(({ __graph_node }) => __graph_node === node._id)
}));

const arcs = graphArcs.filter(({ __from, __to }) =>
nodes.some(
({ inputs, outputs }) =>
inputs.some(({ _id }) => _id === __to) || outputs.some(({ _id }) => _id === __from)
)
);

return {
arcs: graphArcs.filter(({ __from, __to }) =>
nodes.some(
({ inputs, outputs }) =>
inputs.some(({ _id }) => _id === __to) ||
outputs.some(({ _id }) => _id === __from)
)
),
actions: {
arc: {
create: toCreate => {
action("Arc to create")(toCreate);

const arc = arcs[arcs.length - 1];
return Promise.resolve({ ...arc, ...toCreate, _id: arc._id * 10 });
},
remove: arc =>
Promise.resolve().then(() => {
action("Arc to remove")(arc);
})
}
},
arcs,
nodes
};
};

export const NodeFunctionDivision = {
args: {
...getGraphContent(BASE_SEED.graph.graphs[0])
...getGraphContent(BASE_SEED.graph.graphs[0]),
readonly: false
} satisfies Partial<GraphComponent>
} satisfies Meta<GraphComponent>;

Expand Down
182 changes: 177 additions & 5 deletions apps/frontend/src/app/graph/components/graph/graph.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import {
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild
} from "@angular/core";
import { GetSchemes, NodeEditor } from "rete";
import { GetSchemes, NodeEditor, Root } from "rete";
import { AngularArea2D, AngularPlugin, Presets as AngularPresets } from "rete-angular-plugin/16";
import { AreaExtensions, AreaPlugin } from "rete-area-plugin";
import { Area2D, AreaExtensions, AreaPlugin } from "rete-area-plugin";
import { ConnectionPlugin, Presets as ConnectionPresets } from "rete-connection-plugin";
import { ReadonlyPlugin } from "rete-readonly-plugin";
import { bufferToggle, filter, map, Observable, Subject } from "rxjs";
import { GraphArcCreateDto } from "~/lib/common/app/graph/dtos/arc";
import { PositionDto } from "~/lib/common/app/graph/dtos/node";
import { GraphArc, GraphNode } from "~/lib/common/app/graph/endpoints";
import { ReteConnection, ReteInput, ReteNode, ReteOutput } from "~/lib/ng/lib/rete";

Expand All @@ -25,6 +29,51 @@ import { ReteSocketComponent } from "../rete/socket/rete.socket.component";
type Schemes = GetSchemes<ReteNode, ReteConnection>;
type AreaExtra = AngularArea2D<Schemes>;

export interface GraphNodeMoved {
/**
* The new position of the node
*/
current: PositionDto;
/**
* The node that has been moved
*/
node: GraphNode;
/**
* The previous position of the node
*/
previous: PositionDto;
}

/**
* The possible actions on the graph when adding, removing content.
* If not provided, the features are not enabled.
*/
export interface GraphActions {
/**
* The actions on arcs
*/
arc?: {
/**
* When an arc is being created
*
* Cancels the change if on any fail.
*
* @param toCreate the arc to create
* @returns the created arc
*/
create?: (toCreate: GraphArcCreateDto) => Promise<GraphArc>;
/**
* When an arc is being deleted
*
* Cancels the change if on any fail.
*
* @param arc to delete
* @returns An empty promise once the arc is deleted.
*/
remove?: (arc: GraphArc) => Promise<void>;
};
}

/**
* The {@link GraphComponent} manages the whole graph view and edition
*/
Expand Down Expand Up @@ -57,6 +106,18 @@ export class GraphComponent implements AfterViewInit, OnDestroy, OnChanges {
@Input()
public readonly = true;

/**
* The actions to update the graph
*/
@Input()
public actions: GraphActions = {};

/**
* When a node has been moved on the graph
*/
@Output()
public readonly nodeMoved: Observable<GraphNodeMoved>;

// The ref to this component is not used, so content can more easily be added to this component (context menu, pop-ups, ...)
@ViewChild("graph", { static: true })
private readonly container!: ElementRef<HTMLElement>;
Expand All @@ -71,7 +132,46 @@ export class GraphComponent implements AfterViewInit, OnDestroy, OnChanges {
readonly: ReadonlyPlugin<Schemes>;
};

public constructor(private readonly injector: Injector) {}
/**
* Observable on the area events.
* To pipe and use only what is needed
*/
private readonly area$ = new Subject<Area2D<Schemes> | AreaExtra | Root<Schemes>>();

public constructor(private readonly injector: Injector) {
this.nodeMoved = this.area$.pipe(
// Only want the translation and when the node is dragged
filter(({ type }) => type === "nodetranslated" || type === "nodedragged"),
bufferToggle(
// Start buffering when the node is picked
this.area$.pipe(filter(({ type }) => type === "nodepicked")),
// And stop when it is dragged
() => this.area$.pipe(filter(({ type }) => type === "nodedragged"))
),
// Ignore when there is not enough emits
filter(buffer => buffer.length >= 3),
map(([previous, ...rest]) => {
const [node, current] = rest.slice().reverse();

// For type inference
if (
previous.type === "nodetranslated" &&
current.type === "nodetranslated" &&
node.type === "nodedragged"
) {
return {
current: current.data.position,
node: node.data.graphNode,
previous: previous.data.previous
} satisfies GraphNodeMoved;
}

throw new Error("Should not happen");
}),
// Ignore if the node is just clicked or been moved to the same place
filter(({ current, previous }) => previous.x !== current.x || previous.y !== current.y)
);
}

/**
* @inheritDoc
Expand Down Expand Up @@ -132,12 +232,13 @@ export class GraphComponent implements AfterViewInit, OnDestroy, OnChanges {
await area.translate(reteNode.id, position);
}

for (const { __from, __to } of this.arcs) {
for (const arc of this.arcs) {
const { __from, __to } = arc;
const output = outputsMap.get(__from);
const input = inputsMap.get(__to);

if (input && output) {
await editor.addConnection(new ReteConnection(output, input));
await editor.addConnection(new ReteConnection(arc, output, input));
}
}

Expand All @@ -151,13 +252,84 @@ export class GraphComponent implements AfterViewInit, OnDestroy, OnChanges {
// TODO: on readonly changes
if (this.readonly) {
readonlyPlugin.enable();

// There is a bug in the library that allows to visually modify arcs
area.addPipe(() => undefined);
editor.addPipe(() => undefined);
return;
}

// Only add at the end to avoid triggering the event for the construction of the component
area.addPipe(context => {
// TODO: filters?
this.area$.next(context);
return context;
});

editor.addPipe(context => {
if (context.type === "connectioncreate") {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- The library still uses the default Connection class
if (context.data.arc) {
return context;
}

const { actions } = this;
if (!actions.arc?.create) {
return undefined;
}

const source = editor.getNode(context.data.source);
const target = editor.getNode(context.data.target);

const sourceOutput = source.outputs[context.data.sourceOutput];
const targetInput = target.inputs[context.data.targetInput];

if (!sourceOutput || !targetInput) {
return undefined;
}

// TODO: Test cyclic here, before continuing
return (
actions.arc
.create({ __from: sourceOutput.output._id, __to: targetInput.input._id })
.then(async arc => {
// FIXME: return a modified context (library does not keep the custom `ReteConnection`)
await this.rete?.editor.addConnection(
new ReteConnection(arc, sourceOutput, targetInput)
);

return undefined;
})
// TODO: error message
.catch(() => undefined)
);
}

if (context.type === "connectionremove") {
const { actions } = this;
if (!actions.arc?.remove) {
return undefined;
}

return (
actions.arc
.remove(context.data.arc)
.then(() => context)
// TODO: error message
.catch(() => undefined)
);
}

return context;
});
}

/**
* @inheritDoc
*/
public async ngOnDestroy() {
this.area$.complete();

if (!this.rete) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { Component, Input, OnChanges } from "@angular/core";
import { ReteModule } from "rete-angular-plugin/16";
import { ReteNode } from "~/lib/ng/lib/rete";

Expand All @@ -11,7 +11,7 @@ import { ReteNode } from "~/lib/ng/lib/rete";

imports: [CommonModule, ReteModule]
})
export class ReteNodeComponent {
export class ReteNodeComponent implements OnChanges {
// TODO

@Input({ required: true })
Expand All @@ -26,4 +26,11 @@ export class ReteNodeComponent {
*/
@Input({ required: true })
public readonly rendered!: () => void;

protected seed = 0;

public ngOnChanges() {
// force render sockets
++this.seed;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
<app-graph
*ngIf="state.state === 'success'"
class="flex-1"
[actions]="getActions(state.data.workflow)"
[arcs]="state.data.arcs"
[nodes]="state.data.nodes"
[readonly]="true"
[readonly]="false"
(nodeMoved)="handleNodeMove(state.data.workflow, $event)"
></app-graph>
</div>
Loading

0 comments on commit bbf3ce2

Please sign in to comment.