diff --git a/designer/client/src/components/StickyNotePreview.tsx b/designer/client/src/components/StickyNotePreview.tsx
new file mode 100644
index 00000000000..af37f0fd504
--- /dev/null
+++ b/designer/client/src/components/StickyNotePreview.tsx
@@ -0,0 +1,62 @@
+import { css, cx } from "@emotion/css";
+import React from "react";
+import { BORDER_RADIUS, CONTENT_PADDING, iconBackgroundSize, iconSize } from "./graph/EspNode/esp";
+import { PreloadedIcon, stickyNoteIconSrc } from "./toolbars/creator/ComponentIcon";
+import { alpha, useTheme } from "@mui/material";
+import { getBorderColor, getStickyNoteBackgroundColor } from "../containers/theme/helpers";
+import { STICKY_NOTE_CONSTRAINTS, STICKY_NOTE_DEFAULT_COLOR } from "./graph/EspNode/stickyNote";
+
+const PREVIEW_SCALE = 0.9;
+const ACTIVE_ROTATION = 2;
+const INACTIVE_SCALE = 1.5;
+
+export function StickyNotePreview({ isActive, isOver }: { isActive?: boolean; isOver?: boolean }): JSX.Element {
+ const theme = useTheme();
+ const scale = isOver ? 1 : PREVIEW_SCALE;
+ const rotation = isActive ? (isOver ? -ACTIVE_ROTATION : ACTIVE_ROTATION) : 0;
+ const finalScale = isActive ? 1 : INACTIVE_SCALE;
+
+ const nodeStyles = css({
+ position: "relative",
+ width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH,
+ height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT,
+ borderRadius: BORDER_RADIUS,
+ boxSizing: "content-box",
+ display: "inline-flex",
+ filter: `drop-shadow(0 4px 8px ${alpha(theme.palette.common.black, 0.5)})`,
+ borderWidth: 0.5,
+ borderStyle: "solid",
+ transformOrigin: "80% 50%",
+ transform: `translate(-80%, -50%) scale(${scale}) rotate(${rotation}deg) scale(${finalScale})`,
+ opacity: isActive ? undefined : 0,
+ transition: "all .5s, opacity .3s",
+ willChange: "transform, opacity, border-color, background-color",
+ });
+
+ const colors = css({
+ opacity: 0.5,
+ borderColor: getBorderColor(theme),
+ backgroundColor: getStickyNoteBackgroundColor(theme, STICKY_NOTE_DEFAULT_COLOR).main,
+ });
+
+ const imageStyles = css({
+ padding: iconSize / 2 - CONTENT_PADDING / 2,
+ margin: CONTENT_PADDING / 2,
+ borderRadius: BORDER_RADIUS,
+ width: iconBackgroundSize / 2,
+ height: iconBackgroundSize / 2,
+ color: theme.palette.common.black,
+ "> svg": {
+ height: iconSize,
+ width: iconSize,
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/designer/client/src/components/graph/EspNode/stickyNote.ts b/designer/client/src/components/graph/EspNode/stickyNote.ts
new file mode 100644
index 00000000000..62d59811c61
--- /dev/null
+++ b/designer/client/src/components/graph/EspNode/stickyNote.ts
@@ -0,0 +1,128 @@
+import { Theme } from "@mui/material";
+import { dia, shapes, util, V } from "jointjs";
+import { getBorderColor } from "../../../containers/theme/helpers";
+import { StickyNote } from "../../../common/StickyNote";
+import { marked } from "marked";
+import { StickyNoteElement } from "../StickyNoteElement";
+import MarkupNodeJSON = dia.MarkupNodeJSON;
+
+export const STICKY_NOTE_CONSTRAINTS = {
+ MIN_WIDTH: 100,
+ MAX_WIDTH: 3000,
+ DEFAULT_WIDTH: 300,
+ MIN_HEIGHT: 100,
+ MAX_HEIGHT: 3000,
+ DEFAULT_HEIGHT: 250,
+} as const;
+
+export const BORDER_RADIUS = 3;
+export const CONTENT_PADDING = 5;
+export const ICON_SIZE = 20;
+export const STICKY_NOTE_DEFAULT_COLOR = "#eae672";
+export const MARKDOWN_EDITOR_NAME = "markdown-editor";
+
+const border: dia.MarkupNodeJSON = {
+ selector: "border",
+ tagName: "path",
+ className: "body",
+ attributes: {
+ width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH,
+ height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT,
+ strokeWidth: 1,
+ fill: "none",
+ rx: BORDER_RADIUS,
+ },
+};
+
+const icon: dia.MarkupNodeJSON = {
+ selector: "icon",
+ tagName: "use",
+ attributes: {
+ opacity: 1,
+ width: ICON_SIZE,
+ height: ICON_SIZE,
+ x: ICON_SIZE / 2,
+ y: ICON_SIZE / 2,
+ },
+};
+
+const body: dia.MarkupNodeJSON = {
+ selector: "body",
+ tagName: "path",
+};
+
+const renderer = new marked.Renderer();
+renderer.link = function (href, title, text) {
+ return `
${text}`;
+};
+renderer.image = function (href, title, text) {
+ // SVG don't support HTML img inside foreignObject
+ return `
${text} (attached img)`;
+};
+
+const foreignObject = (stickyNote: StickyNote): MarkupNodeJSON => {
+ let parsed;
+ try {
+ parsed = marked.parse(stickyNote.content, { renderer });
+ } catch (error) {
+ console.error("Failed to parse markdown:", error);
+ parsed = "Error: Could not parse content. See error logs in console";
+ }
+ const singleMarkupNode = util.svg/* xml */ `
+
+
+
+ `[0];
+ return singleMarkupNode as MarkupNodeJSON;
+};
+
+export const stickyNotePath = "M 0 0 L 19 0 L 19 19 L 0 19 L 0 0";
+
+const defaults = (theme: Theme) =>
+ util.defaultsDeep(
+ {
+ size: {
+ width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH,
+ height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT,
+ },
+ attrs: {
+ body: {
+ refD: stickyNotePath,
+ strokeWidth: 2,
+ fill: "#eae672",
+ filter: {
+ name: "dropShadow",
+ args: {
+ dx: 1,
+ dy: 1,
+ blur: 5,
+ opacity: 0.4,
+ },
+ },
+ },
+ foreignObject: {
+ width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH,
+ height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT - ICON_SIZE - CONTENT_PADDING * 4,
+ y: CONTENT_PADDING * 4 + ICON_SIZE,
+ fill: getBorderColor(theme),
+ },
+ border: {
+ refD: stickyNotePath,
+ stroke: getBorderColor(theme),
+ },
+ },
+ },
+ shapes.devs.Model.prototype.defaults,
+ );
+
+const protoProps = (theme: Theme, stickyNote: StickyNote) => {
+ return {
+ markup: [body, border, foreignObject(stickyNote), icon],
+ };
+};
+
+export const StickyNoteShape = (theme: Theme, stickyNote: StickyNote) =>
+ StickyNoteElement(defaults(theme), protoProps(theme, stickyNote)) as typeof shapes.devs.Model;
diff --git a/designer/client/src/components/graph/EspNode/stickyNoteElements.ts b/designer/client/src/components/graph/EspNode/stickyNoteElements.ts
new file mode 100644
index 00000000000..13649530032
--- /dev/null
+++ b/designer/client/src/components/graph/EspNode/stickyNoteElements.ts
@@ -0,0 +1,137 @@
+import { ProcessDefinitionData } from "../../../types";
+import { Theme } from "@mui/material";
+import { StickyNote } from "../../../common/StickyNote";
+import { dia, elementTools, shapes } from "jointjs";
+import { stickyNoteIcon } from "../../toolbars/creator/ComponentIcon";
+import { createStickyNoteId } from "../../../types/stickyNote";
+import { getStickyNoteBackgroundColor } from "../../../containers/theme/helpers";
+import { CONTENT_PADDING, ICON_SIZE, MARKDOWN_EDITOR_NAME, STICKY_NOTE_CONSTRAINTS, StickyNoteShape } from "./stickyNote";
+import { Events } from "../types";
+
+export type ModelWithTool = {
+ model: shapes.devs.Model;
+ tools: dia.ToolsView;
+};
+
+export function makeStickyNoteElement(
+ processDefinitionData: ProcessDefinitionData,
+ theme: Theme,
+): (stickyNote: StickyNote) => ModelWithTool {
+ return (stickyNote: StickyNote) => {
+ const attributes: shapes.devs.ModelAttributes = {
+ id: createStickyNoteId(stickyNote.noteId),
+ noteId: stickyNote.noteId,
+ attrs: {
+ size: {
+ width: stickyNote.dimensions.width,
+ height: stickyNote.dimensions.height,
+ },
+ body: {
+ fill: getStickyNoteBackgroundColor(theme, stickyNote.color).main,
+ opacity: 1,
+ },
+ foreignObject: {
+ width: stickyNote.dimensions.width,
+ height: stickyNote.dimensions.height - ICON_SIZE - CONTENT_PADDING * 4,
+ color: theme.palette.getContrastText(getStickyNoteBackgroundColor(theme, stickyNote.color).main),
+ },
+ icon: {
+ xlinkHref: stickyNoteIcon(),
+ opacity: 1,
+ color: theme.palette.getContrastText(getStickyNoteBackgroundColor(theme, stickyNote.color).main),
+ },
+ border: {
+ stroke: getStickyNoteBackgroundColor(theme, stickyNote.color).dark,
+ strokeWidth: 1,
+ },
+ },
+ rankDir: "R",
+ };
+
+ const ThemedStickyNoteShape = StickyNoteShape(theme, stickyNote);
+ const stickyNoteModel = new ThemedStickyNoteShape(attributes);
+
+ const removeButtonTool = new elementTools.Remove({
+ focusOpacity: 0.5,
+ rotate: true,
+ x: stickyNote.dimensions.width - 20,
+ y: "0%",
+ offset: { x: 0, y: 20 },
+ className: "sticky-note-remove-tool",
+ action: function () {
+ stickyNoteModel.trigger(Events.CELL_DELETED, stickyNoteModel);
+ },
+ });
+
+ const ResizeTool = elementTools.Control.extend({
+ children: [
+ {
+ tagName: "path",
+ selector: "handle",
+ attributes: {
+ d: "M 4 0 L 4 4 L 0 4 L 0 5 L 5 5 L 5 0 L 4 0",
+ stroke: getStickyNoteBackgroundColor(theme, stickyNote.color).light,
+ cursor: "se-resize",
+ },
+ },
+ {
+ tagName: "rect",
+ selector: "extras",
+ attributes: {
+ "pointer-events": "none",
+ fill: "none",
+ stroke: getStickyNoteBackgroundColor(theme, stickyNote.color).light,
+ "stroke-dasharray": "2,3",
+ rx: 6,
+ ry: 6,
+ },
+ },
+ ],
+ documentEvents: {
+ mousemove: "onPointerMove",
+ touchmove: "onPointerMove",
+ mouseup: "onPointerUpCustom",
+ touchend: "onPointerUpCustom",
+ touchcancel: "onPointerUp",
+ },
+ getPosition: function (view) {
+ const model = view.model;
+ const { width, height } = model.size();
+ return { x: width, y: height };
+ },
+ setPosition: function (view, coordinates) {
+ const model = view.model;
+ model.resize(
+ Math.max(
+ Math.min(STICKY_NOTE_CONSTRAINTS.MAX_WIDTH, Math.round(coordinates.x - 10)),
+ STICKY_NOTE_CONSTRAINTS.MIN_WIDTH,
+ ),
+ Math.max(
+ Math.min(STICKY_NOTE_CONSTRAINTS.MAX_HEIGHT, Math.round(coordinates.y - 10)),
+ STICKY_NOTE_CONSTRAINTS.MIN_HEIGHT,
+ ),
+ );
+ },
+ onPointerUpCustom: function (evt: dia.Event) {
+ this.onPointerUp(evt);
+ stickyNoteModel.trigger(Events.CELL_RESIZED, stickyNoteModel);
+ },
+ });
+
+ const tools: dia.ToolsView = new dia.ToolsView({
+ tools: [
+ new ResizeTool({
+ selector: "body",
+ scale: 2,
+ }),
+ removeButtonTool,
+ ],
+ });
+ stickyNoteModel.resize(
+ Math.max(stickyNote.dimensions.width, STICKY_NOTE_CONSTRAINTS.MIN_WIDTH),
+ Math.max(stickyNote.dimensions.height, STICKY_NOTE_CONSTRAINTS.MIN_HEIGHT),
+ );
+ stickyNoteModel.attr(`${MARKDOWN_EDITOR_NAME}/props/value`, stickyNote.content);
+ return { model: stickyNoteModel, tools };
+ };
+}
diff --git a/designer/client/src/components/graph/Graph.tsx b/designer/client/src/components/graph/Graph.tsx
index 5f861458287..f1f045dc0b7 100644
--- a/designer/client/src/components/graph/Graph.tsx
+++ b/designer/client/src/components/graph/Graph.tsx
@@ -18,7 +18,14 @@ import { Scenario } from "../Process/types";
import { createUniqueArrowMarker } from "./arrowMarker";
import { updateNodeCounts } from "./EspNode/element";
import { getDefaultLinkCreator } from "./EspNode/link";
-import { applyCellChanges, calcLayout, createPaper, isModelElement } from "./GraphPartialsInTS";
+import {
+ applyCellChanges,
+ calcLayout,
+ createPaper,
+ getStickyNoteCopyFromCell,
+ isModelElement,
+ isStickyNoteElement,
+} from "./GraphPartialsInTS";
import { getCellsToLayout } from "./GraphPartialsInTS/calcLayout";
import { isEdgeConnected } from "./GraphPartialsInTS/EdgeUtils";
import { updateLayout } from "./GraphPartialsInTS/updateLayout";
@@ -39,6 +46,11 @@ import { Events, GraphProps } from "./types";
import { filterDragHovered, getLinkNodes, setLinksHovered } from "./utils/dragHelpers";
import * as GraphUtils from "./utils/graphUtils";
import { handleGraphEvent } from "./utils/graphUtils";
+import { StickyNote } from "../../common/StickyNote";
+import { StickyNoteElement, StickyNoteElementView } from "./StickyNoteElement";
+import { STICKY_NOTE_CONSTRAINTS } from "./EspNode/stickyNote";
+import { NotificationActions } from "../../http/HttpService";
+import i18next from "i18next";
function clamp(number: number, max: number) {
return Math.round(Math.min(max, Math.max(-max, number)));
@@ -55,6 +67,15 @@ type Props = GraphProps & {
theme: Theme;
translation: UseTranslationResponse
;
handleStatisticsEvent: (event: TrackEventParams) => void;
+ notifications: NotificationActions;
+};
+
+export const nuGraphNamespace = {
+ ...shapes,
+ stickyNote: {
+ StickyNoteElement,
+ StickyNoteElementView,
+ },
};
function handleActionOnLongPress(
@@ -111,6 +132,7 @@ export class Graph extends React.Component {
model: this.graph,
el: this.getEspGraphRef(),
validateConnection: this.twoWayValidateConnection,
+ cellViewNamespace: nuGraphNamespace,
validateMagnet: this.validateMagnet,
interactive: (cellView: dia.CellView) => {
const { model } = cellView;
@@ -212,6 +234,24 @@ export class Graph extends React.Component {
this.handleInjectBetweenNodes(cell.model, linkBelowCell);
batchGroupBy.end(group);
}
+ if (isStickyNoteElement(cell.model)) {
+ this.processGraphPaper.hideTools();
+ if (!this.props.isPristine) {
+ this.props.notifications.warn(
+ i18next.t(
+ "notification.warn.cannotMoveOnUnsavedVersion",
+ "Save scenario before making any changes to sticky notes",
+ ),
+ );
+ return;
+ }
+ cell.showTools();
+ const updatedStickyNote = getStickyNoteCopyFromCell(this.props.stickyNotes, cell.model);
+ if (!updatedStickyNote) return;
+ const position = cell.model.get("position");
+ updatedStickyNote.layoutData = { x: position.x, y: position.y };
+ this.updateStickyNote(this.props.scenario.name, this.props.scenario.processVersionId, updatedStickyNote);
+ }
})
.on(Events.LINK_CONNECT, (linkView: dia.LinkView, evt: dia.Event, targetView: dia.CellView, targetMagnet: SVGElement) => {
if (this.props.isFragment === true) return;
@@ -236,12 +276,16 @@ export class Graph extends React.Component {
return linkBelowCell;
}
- drawGraph = (scenarioGraph: ScenarioGraph, layout: Layout, processDefinitionData: ProcessDefinitionData): void => {
+ drawGraph = (
+ scenarioGraph: ScenarioGraph,
+ stickyNotes: StickyNote[],
+ layout: Layout,
+ processDefinitionData: ProcessDefinitionData,
+ ): void => {
const { theme } = this.props;
this.redrawing = true;
-
- applyCellChanges(this.processGraphPaper, scenarioGraph, processDefinitionData, theme);
+ applyCellChanges(this.processGraphPaper, scenarioGraph, stickyNotes, processDefinitionData, theme);
if (isEmpty(layout)) {
this.forceLayout();
@@ -304,7 +348,7 @@ export class Graph extends React.Component {
constructor(props: Props) {
super(props);
- this.graph = new dia.Graph();
+ this.graph = new dia.Graph({}, { cellNamespace: nuGraphNamespace });
this.bindNodeRemove();
this.bindNodesMoving();
}
@@ -337,8 +381,22 @@ export class Graph extends React.Component {
}
};
+ const showStickyNoteTools = (cellView: dia.CellView) => {
+ cellView.showTools();
+ };
+
+ const hideToolsOnBlankClick = (evt: dia.Event) => {
+ evt.preventDefault();
+ this.processGraphPaper.hideTools();
+ };
+
const selectNode = (cellView: dia.CellView, evt: dia.Event) => {
if (this.props.isFragment === true) return;
+ this.processGraphPaper.hideTools();
+ if (isStickyNoteElement(cellView.model)) {
+ if (!this.props.isPristine) return;
+ showStickyNoteTools(cellView);
+ }
if (this.props.nodeSelectionEnabled) {
const nodeDataId = cellView.model.attributes.nodeData?.id;
if (!nodeDataId) {
@@ -353,7 +411,10 @@ export class Graph extends React.Component {
}
};
- this.processGraphPaper.on(Events.CELL_POINTERDOWN, handleGraphEvent(handleActionOnLongPress(showNodeDetails, selectNode), null));
+ this.processGraphPaper.on(
+ Events.CELL_POINTERDOWN,
+ handleGraphEvent(handleActionOnLongPress(showNodeDetails, selectNode), null, (view) => !isStickyNoteElement(view.model)),
+ );
this.processGraphPaper.on(
Events.LINK_POINTERDOWN,
handleGraphEvent(
@@ -362,16 +423,21 @@ export class Graph extends React.Component {
),
);
- this.processGraphPaper.on(Events.CELL_POINTERCLICK, handleGraphEvent(null, selectNode));
+ this.processGraphPaper.on(
+ Events.CELL_POINTERCLICK,
+ handleGraphEvent(null, selectNode, (view) => !isStickyNoteElement(view.model)),
+ );
this.processGraphPaper.on(Events.CELL_POINTERDBLCLICK, handleGraphEvent(null, showNodeDetails));
+ this.processGraphPaper.on(Events.BLANK_POINTERCLICK, hideToolsOnBlankClick);
this.hooverHandling();
}
componentDidMount(): void {
this.processGraphPaper = this.createPaper();
- this.drawGraph(this.props.scenario.scenarioGraph, this.props.layout, this.props.processDefinitionData);
+ this.drawGraph(this.props.scenario.scenarioGraph, this.props.stickyNotes, this.props.layout, this.props.processDefinitionData);
this.processGraphPaper.unfreeze();
+ this.processGraphPaper.hideTools();
this._prepareContentForExport();
// event handlers binding below. order sometimes matters
@@ -414,6 +480,62 @@ export class Graph extends React.Component {
this.highlightHoveredLink();
});
+ this.graph.on(Events.CELL_RESIZED, (cell: dia.Element) => {
+ if (isStickyNoteElement(cell)) {
+ if (!this.props.isPristine) {
+ this.props.notifications.warn(
+ i18next.t("notification.warn.cannotResizeOnUnsavedVersion", "Save scenario before resizing sticky note"),
+ );
+ return;
+ }
+ const updatedStickyNote = getStickyNoteCopyFromCell(this.props.stickyNotes, cell);
+ if (!updatedStickyNote) return;
+ const position = cell.get("position");
+ const size = cell.get("size");
+ // TODO move max width and height to some config?
+ const width = Math.max(
+ STICKY_NOTE_CONSTRAINTS.MIN_WIDTH,
+ Math.min(STICKY_NOTE_CONSTRAINTS.MAX_WIDTH, Math.round(size.width)),
+ );
+ const height = Math.max(
+ STICKY_NOTE_CONSTRAINTS.MIN_HEIGHT,
+ Math.min(STICKY_NOTE_CONSTRAINTS.MAX_HEIGHT, Math.round(size.height)),
+ );
+ updatedStickyNote.layoutData = { x: position.x, y: position.y };
+ updatedStickyNote.dimensions = { width, height };
+ this.updateStickyNote(this.props.scenario.name, this.props.scenario.processVersionId, updatedStickyNote);
+ }
+ });
+
+ this.graph.on(Events.CELL_CONTENT_UPDATED, (cell: dia.Element, content: string) => {
+ if (isStickyNoteElement(cell)) {
+ if (!this.props.isPristine) {
+ this.props.notifications.warn(
+ i18next.t("notification.warn.cannotUpdateOnUnsavedVersion", "Save scenario before updating sticky note"),
+ );
+ return;
+ }
+ const updatedStickyNote = getStickyNoteCopyFromCell(this.props.stickyNotes, cell);
+ if (!updatedStickyNote) return;
+ if (updatedStickyNote.content == content) return;
+ updatedStickyNote.content = content;
+ this.updateStickyNote(this.props.scenario.name, this.props.scenario.processVersionId, updatedStickyNote);
+ }
+ });
+
+ this.graph.on(Events.CELL_DELETED, (cell: dia.Element) => {
+ if (isStickyNoteElement(cell)) {
+ if (!this.props.isPristine) {
+ this.props.notifications.warn(
+ i18next.t("notification.warn.cannotDeleteOnUnsavedVersion", "Save scenario before deleting sticky note"),
+ );
+ return;
+ }
+ const noteId = Number(cell.get("noteId"));
+ this.deleteStickyNote(this.props.scenario.name, noteId);
+ }
+ });
+
//we want to inject node during 'Drag and Drop' from toolbox
this.graph.on(Events.ADD, (cell: dia.Element) => {
if (isModelElement(cell)) {
@@ -436,15 +558,41 @@ export class Graph extends React.Component {
}
}
+ addStickyNote(scenarioName: string, scenarioVersionId: number, position: Position): void {
+ if (this.props.isFragment === true) return;
+ const canAddStickyNote = this.props.capabilities.editFrontend;
+ if (canAddStickyNote) {
+ const dimensions = { width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH, height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT };
+ this.props.stickyNoteAdded(scenarioName, scenarioVersionId, position, dimensions);
+ }
+ }
+
+ updateStickyNote(scenarioName: string, scenarioVersionId: number, stickyNote: StickyNote): void {
+ if (this.props.isFragment === true) return;
+ const canUpdateStickyNote = this.props.capabilities.editFrontend;
+ if (canUpdateStickyNote) {
+ this.props.stickyNoteUpdated(scenarioName, scenarioVersionId, stickyNote);
+ }
+ }
+
+ deleteStickyNote(scenarioName: string, stickyNoteId: number): void {
+ if (this.props.isFragment === true) return;
+ const canUpdateStickyNote = this.props.capabilities.editFrontend;
+ if (canUpdateStickyNote) {
+ this.props.stickyNoteDeleted(scenarioName, stickyNoteId);
+ }
+ }
+
// eslint-disable-next-line react/no-deprecated
componentWillUpdate(nextProps: Props): void {
const processChanged =
!isEqual(this.props.scenario.scenarioGraph, nextProps.scenario.scenarioGraph) ||
!isEqual(this.props.scenario.validationResult, nextProps.scenario.validationResult) ||
+ !isEqual(this.props.stickyNotes, nextProps.stickyNotes) ||
!isEqual(this.props.layout, nextProps.layout) ||
!isEqual(this.props.processDefinitionData, nextProps.processDefinitionData);
if (processChanged) {
- this.drawGraph(nextProps.scenario.scenarioGraph, nextProps.layout, nextProps.processDefinitionData);
+ this.drawGraph(nextProps.scenario.scenarioGraph, nextProps.stickyNotes, nextProps.layout, nextProps.processDefinitionData);
}
//when e.g. layout changed we have to remember to highlight nodes
diff --git a/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts b/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts
index d09c02db54d..ccb78ecb7b6 100644
--- a/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts
+++ b/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts
@@ -7,26 +7,32 @@ import NodeUtils from "../NodeUtils";
import { isEdgeConnected } from "./EdgeUtils";
import { updateChangedCells } from "./updateChangedCells";
import { Theme } from "@mui/material";
+import { StickyNote } from "../../../common/StickyNote";
+import { makeStickyNoteElement, ModelWithTool } from "../EspNode/stickyNoteElements";
export function applyCellChanges(
paper: dia.Paper,
scenarioGraph: ScenarioGraph,
+ stickyNotes: StickyNote[],
processDefinitionData: ProcessDefinitionData,
theme: Theme,
): void {
const graph = paper.model;
const nodeElements = NodeUtils.nodesFromScenarioGraph(scenarioGraph).map(makeElement(processDefinitionData, theme));
+ const stickyNotesModelsWithTools: ModelWithTool[] = stickyNotes.map(makeStickyNoteElement(processDefinitionData, theme));
+ const stickyNotesModels = stickyNotesModelsWithTools.map((a) => a.model);
const edges = NodeUtils.edgesFromScenarioGraph(scenarioGraph);
const indexed = flatMap(groupBy(edges, "from"), (edges) => edges.map((edge, i) => ({ ...edge, index: ++i })));
const edgeElements = indexed.filter(isEdgeConnected).map((value) => makeLink(value, paper, theme));
- const cells = [...nodeElements, ...edgeElements];
+ const cells = [...nodeElements, ...edgeElements, ...stickyNotesModels];
const currentCells = graph.getCells();
const currentIds = currentCells.map((c) => c.id);
const newCells = cells.filter((cell) => !currentIds.includes(cell.id));
+ const newStickyNotesModelsWithTools = stickyNotesModelsWithTools.filter((s) => !currentIds.includes(s.model.id));
const deletedCells = currentCells.filter((oldCell) => !cells.find((cell) => cell.id === oldCell.id));
const changedCells = cells.filter((cell) => {
const old = graph.getCell(cell.id);
@@ -36,4 +42,17 @@ export function applyCellChanges(
graph.removeCells(deletedCells);
updateChangedCells(graph, changedCells);
graph.addCells(newCells);
+
+ newStickyNotesModelsWithTools.forEach((m) => {
+ try {
+ const view = m.model.findView(paper);
+ if (!view) {
+ console.warn(`View not found for stickyNote model: ${m.model.id}`);
+ return;
+ }
+ view.addTools(m.tools);
+ } catch (error) {
+ console.error(`Failed to add tools to stickyNote view:`, error);
+ }
+ });
}
diff --git a/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts b/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts
index 4723616cc14..896dcef5d82 100644
--- a/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts
+++ b/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts
@@ -1,4 +1,6 @@
import { dia, shapes } from "jointjs";
+import { StickyNote } from "../../../common/StickyNote";
+import { cloneDeep } from "lodash";
export const isLink = (c: dia.Cell): c is dia.Link => c.isLink();
export const isElement = (c: dia.Cell): c is dia.Element => c?.isElement();
@@ -7,6 +9,17 @@ export function isModelElement(el: dia.Cell): el is shapes.devs.Model {
return el instanceof shapes.devs.Model;
}
+export function isStickyNoteElement(el: dia.Cell): el is shapes.devs.Model {
+ return isElement(el) && el.get("type") === `stickyNote.StickyNoteElement`;
+}
+
+export function getStickyNoteCopyFromCell(stickyNotes: StickyNote[], el: dia.Cell): StickyNote | undefined {
+ const noteId = el.get("noteId");
+ if (!isStickyNoteElement(el) || !noteId) return undefined;
+ const stickyNote = stickyNotes.find((note) => note.noteId == noteId);
+ return stickyNote ? cloneDeep(stickyNote) : undefined;
+}
+
export function isConnected(el: dia.Element): boolean {
return el.graph.getNeighbors(el).length > 0;
}
diff --git a/designer/client/src/components/graph/GraphWrapped.tsx b/designer/client/src/components/graph/GraphWrapped.tsx
index d9b5351e844..4847d6123d7 100644
--- a/designer/client/src/components/graph/GraphWrapped.tsx
+++ b/designer/client/src/components/graph/GraphWrapped.tsx
@@ -1,7 +1,7 @@
import { useTheme } from "@mui/material";
import React, { forwardRef, useRef } from "react";
import { useTranslation } from "react-i18next";
-import { useSelector } from "react-redux";
+import { useDispatch, useSelector } from "react-redux";
import { useForkRef } from "rooks";
import { useEventTracking } from "../../containers/event-tracking";
import { getProcessCategory, getSelectionState, isPristine } from "../../reducers/selectors/graph";
@@ -12,10 +12,13 @@ import { Graph } from "./Graph";
import { GraphStyledWrapper } from "./graphStyledWrapper";
import { NodeDescriptionPopover } from "./NodeDescriptionPopover";
import { GraphProps } from "./types";
+import { bindActionCreators } from "redux";
+import * as NotificationActions from "../../actions/notificationActions";
// Graph wrapped to make partial (for now) refactor to TS and hooks
export default forwardRef(function GraphWrapped(props, forwardedRef): JSX.Element {
const { openNodeWindow } = useWindows();
+ const dispatch = useDispatch();
const userSettings = useSelector(getUserSettings);
const pristine = useSelector(isPristine);
const processCategory = useSelector(getProcessCategory);
@@ -25,7 +28,7 @@ export default forwardRef(function GraphWrapped(props, forwar
const theme = useTheme();
const translation = useTranslation();
const { trackEvent } = useEventTracking();
-
+ const notifications = bindActionCreators(NotificationActions, dispatch);
const graphRef = useRef();
const ref = useForkRef(graphRef, forwardedRef);
@@ -45,6 +48,7 @@ export default forwardRef(function GraphWrapped(props, forwar
theme={theme}
translation={translation}
handleStatisticsEvent={trackEvent}
+ notifications={notifications}
/>
diff --git a/designer/client/src/components/graph/NodeDescriptionPopover.tsx b/designer/client/src/components/graph/NodeDescriptionPopover.tsx
index 3bf06feba19..ec6cc328045 100644
--- a/designer/client/src/components/graph/NodeDescriptionPopover.tsx
+++ b/designer/client/src/components/graph/NodeDescriptionPopover.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Graph } from "./Graph";
import { MarkdownStyled } from "./node-modal/MarkdownStyled";
import { Events } from "./types";
+import { isStickyNoteElement } from "./GraphPartialsInTS";
const useTimeout = >(
callback: (...args: A) => void,
@@ -119,6 +120,7 @@ export function NodeDescriptionPopover(props: NodeDescriptionPopoverProps) {
const lastTarget = useRef(null);
const enterTimer = useTimeout((view: dia.CellView, el: Element) => {
+ if (isStickyNoteElement(view.model)) return; //Dont use it for stickyNotes
setData(
el
? [t("graph.node.counts.title", "number of messages that passed downstream"), el]
diff --git a/designer/client/src/components/graph/ProcessGraph.tsx b/designer/client/src/components/graph/ProcessGraph.tsx
index 097c02daac2..771dab0f911 100644
--- a/designer/client/src/components/graph/ProcessGraph.tsx
+++ b/designer/client/src/components/graph/ProcessGraph.tsx
@@ -3,17 +3,29 @@ import { g } from "jointjs";
import { mapValues } from "lodash";
import { useDrop } from "react-dnd";
import { useDispatch, useSelector } from "react-redux";
-import { getScenario, getLayout, getProcessCounts } from "../../reducers/selectors/graph";
+import { getScenario, getLayout, getProcessCounts, getStickyNotes } from "../../reducers/selectors/graph";
import { setLinksHovered } from "./utils/dragHelpers";
import { Graph } from "./Graph";
import GraphWrapped from "./GraphWrapped";
import { RECT_HEIGHT, RECT_WIDTH } from "./EspNode/esp";
import NodeUtils from "./NodeUtils";
import { DndTypes } from "../toolbars/creator/Tool";
-import { injectNode, layoutChanged, nodeAdded, nodesConnected, nodesDisconnected, resetSelection, toggleSelection } from "../../actions/nk";
+import {
+ injectNode,
+ layoutChanged,
+ nodeAdded,
+ nodesConnected,
+ nodesDisconnected,
+ resetSelection,
+ stickyNoteAdded,
+ stickyNoteUpdated,
+ stickyNoteDeleted,
+ toggleSelection,
+} from "../../actions/nk";
import { NodeType } from "../../types";
import { Capabilities } from "../../reducers/selectors/other";
import { bindActionCreators } from "redux";
+import { StickyNoteType } from "../../types/stickyNote";
export const ProcessGraph = forwardRef(function ProcessGraph(
{ capabilities },
@@ -21,6 +33,7 @@ export const ProcessGraph = forwardRef(fu
): JSX.Element {
const scenario = useSelector(getScenario);
const processCounts = useSelector(getProcessCounts);
+ const stickyNotes = useSelector(getStickyNotes);
const layout = useSelector(getLayout);
const graph = useRef();
@@ -33,8 +46,12 @@ export const ProcessGraph = forwardRef(fu
const relOffset = graph.current.processGraphPaper.clientToLocalPoint(clientOffset);
// to make node horizontally aligned
const nodeInputRelOffset = relOffset.offset(RECT_WIDTH * -0.8, RECT_HEIGHT * -0.5);
- graph.current.addNode(monitor.getItem(), mapValues(nodeInputRelOffset, Math.round));
- setLinksHovered(graph.current.graph);
+ if (item?.type === StickyNoteType) {
+ graph.current.addStickyNote(scenario.name, scenario.processVersionId, mapValues(nodeInputRelOffset, Math.round));
+ } else {
+ graph.current.addNode(monitor.getItem(), mapValues(nodeInputRelOffset, Math.round));
+ setLinksHovered(graph.current.graph);
+ }
},
hover: (item: NodeType, monitor) => {
const node = item;
@@ -67,6 +84,9 @@ export const ProcessGraph = forwardRef(fu
layoutChanged,
injectNode,
nodeAdded,
+ stickyNoteAdded,
+ stickyNoteUpdated,
+ stickyNoteDeleted,
resetSelection,
toggleSelection,
},
@@ -81,6 +101,7 @@ export const ProcessGraph = forwardRef(fu
connectDropTarget={connectDropTarget}
isDraggingOver={isDraggingOver}
capabilities={capabilities}
+ stickyNotes={stickyNotes}
divId={"nk-graph-main"}
nodeSelectionEnabled
scenario={scenario}
diff --git a/designer/client/src/components/graph/StickyNoteElement.ts b/designer/client/src/components/graph/StickyNoteElement.ts
new file mode 100644
index 00000000000..9d59f7035de
--- /dev/null
+++ b/designer/client/src/components/graph/StickyNoteElement.ts
@@ -0,0 +1,52 @@
+import { dia } from "jointjs";
+import { Events } from "./types";
+import { MARKDOWN_EDITOR_NAME } from "./EspNode/stickyNote";
+import MarkupNodeJSON = dia.MarkupNodeJSON;
+
+export interface StickyNoteDefaults {
+ position?: { x: number; y: number };
+ size?: { width: number; height: number };
+ attrs?: Record;
+}
+
+export interface StickyNoteProtoProps {
+ markup: (dia.MarkupNodeJSON | MarkupNodeJSON)[];
+ [key: string]: unknown;
+}
+
+export const StickyNoteElement = (defaults?: StickyNoteDefaults, protoProps?: StickyNoteProtoProps) =>
+ dia.Element.define("stickyNote.StickyNoteElement", defaults, protoProps);
+
+export const StickyNoteElementView = dia.ElementView.extend({
+ events: {
+ "click .sticky-note-markdown-editor": "stopPropagation",
+ "keydown textarea": "selectAll",
+ "focusout .sticky-note-markdown-editor": "onChange",
+ "dblclick .sticky-note-content": "showEditor",
+ },
+
+ stopPropagation: function (evt) {
+ evt.stopPropagation();
+ },
+
+ showEditor: function (evt) {
+ evt.stopPropagation();
+ this.model.attr(`${MARKDOWN_EDITOR_NAME}/props/disabled`, false);
+ evt.currentTarget.querySelector("textarea").focus({ preventScroll: true });
+ },
+
+ selectAll: function (evt) {
+ if (evt.code === "KeyA") {
+ if (evt.ctrlKey || evt.metaKey) {
+ evt.preventDefault();
+ evt.target.select();
+ }
+ }
+ },
+
+ onChange: function (evt) {
+ this.model.trigger(Events.CELL_CONTENT_UPDATED, this.model, evt.target.value);
+ this.model.attr(`${MARKDOWN_EDITOR_NAME}/props/value`, evt.target.value);
+ this.model.attr(`${MARKDOWN_EDITOR_NAME}/props/disabled`, true);
+ },
+});
diff --git a/designer/client/src/components/graph/fragmentGraph.tsx b/designer/client/src/components/graph/fragmentGraph.tsx
index 645a696d21d..4694935ee44 100644
--- a/designer/client/src/components/graph/fragmentGraph.tsx
+++ b/designer/client/src/components/graph/fragmentGraph.tsx
@@ -4,19 +4,21 @@ import React, { forwardRef } from "react";
import { Graph } from "./Graph";
import { GraphProps } from "./types";
-export const FragmentGraphPreview = forwardRef>(
- function FragmentGraphPreview({ processCounts, scenario, nodeIdPrefixForFragmentTests }, ref) {
- return (
-
- );
- },
-);
+export const FragmentGraphPreview = forwardRef<
+ Graph,
+ Pick
+>(function FragmentGraphPreview({ processCounts, scenario, stickyNotes, nodeIdPrefixForFragmentTests }, ref) {
+ return (
+
+ );
+});
diff --git a/designer/client/src/components/graph/graphStyledWrapper.ts b/designer/client/src/components/graph/graphStyledWrapper.ts
index c0ab9fb6fee..3a6afcffa39 100644
--- a/designer/client/src/components/graph/graphStyledWrapper.ts
+++ b/designer/client/src/components/graph/graphStyledWrapper.ts
@@ -1,5 +1,5 @@
import { CSSProperties } from "react";
-import { css, styled, Theme } from "@mui/material";
+import { alpha, css, styled, Theme } from "@mui/material";
import { blend } from "@mui/system";
import { blendLighten } from "../../containers/theme/helpers";
@@ -181,6 +181,41 @@ export const GraphStyledWrapper = styled("div")(({ theme }) =>
transition: "all 0.25s ease-in-out",
},
},
+ ".sticky-note-markdown": {
+ width: "100%",
+ height: "100%",
+ paddingLeft: "10px",
+ paddingRight: "10px",
+ },
+ ".sticky-note-markdown-editor": {
+ paddingLeft: theme.spacing(1),
+ paddingRight: theme.spacing(1),
+ backgroundColor: alpha(theme.palette.common.white, 0.3),
+ color: theme.palette.common.black,
+ fontFamily: theme.typography.fontFamily,
+ fontSize: theme.typography.body1.fontSize,
+ resize: "none",
+ width: "100%",
+ height: "100%",
+ borderStyle: "none",
+ borderColor: "Transparent",
+ whiteSpace: "pre-line",
+ overflow: "hidden",
+ },
+ ".sticky-note-markdown-editor:focus": {
+ outline: "none",
+ boxShadow: `0 0 0 2px ${theme.palette.primary.main}`,
+ },
+ ".sticky-note-content": {
+ width: "100%",
+ height: "100%",
+ },
+ ".joint-sticky-note-remove-tool > circle": {
+ fill: "#ca344c",
+ },
+ ".sticky-note-markdown-editor:disabled": {
+ display: "none",
+ },
},
]),
);
diff --git a/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx b/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx
index b96d03547ac..c3fe66b3c60 100644
--- a/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx
+++ b/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx
@@ -1,7 +1,7 @@
import React, { useCallback, useState } from "react";
import { useSelector } from "react-redux";
import HttpService from "../../../../http/HttpService";
-import { getProcessCounts } from "../../../../reducers/selectors/graph";
+import { getProcessCounts, getStickyNotes } from "../../../../reducers/selectors/graph";
import { FragmentNodeType } from "../../../../types";
import { ErrorBoundary, DialogErrorFallbackComponent } from "../../../common/error-boundary";
import NodeUtils from "../../NodeUtils";
@@ -14,6 +14,7 @@ import { Scenario } from "../../../Process/types";
export function FragmentContent({ nodeToDisplay }: { nodeToDisplay: FragmentNodeType }): JSX.Element {
const processCounts = useSelector(getProcessCounts);
+ const stickyNotes = useSelector(getStickyNotes);
const processDefinitionData = useSelector(getProcessDefinitionData);
const [fragmentContent, setFragmentContent] = useState(null);
@@ -40,6 +41,7 @@ export function FragmentContent({ nodeToDisplay }: { nodeToDisplay: FragmentNode
)}
diff --git a/designer/client/src/components/graph/types.ts b/designer/client/src/components/graph/types.ts
index ed27ac699fd..d5b6279b82c 100644
--- a/designer/client/src/components/graph/types.ts
+++ b/designer/client/src/components/graph/types.ts
@@ -7,10 +7,14 @@ import {
nodesConnected,
nodesDisconnected,
resetSelection,
+ stickyNoteAdded,
+ stickyNoteDeleted,
+ stickyNoteUpdated,
toggleSelection,
} from "../../actions/nk";
import { Capabilities } from "../../reducers/selectors/other";
import { Scenario } from "../Process/types";
+import { StickyNote } from "../../common/StickyNote";
type ScenarioGraphProps = {
nodesConnected: typeof nodesConnected;
@@ -18,9 +22,13 @@ type ScenarioGraphProps = {
layoutChanged: typeof layoutChanged;
injectNode: typeof injectNode;
nodeAdded: typeof nodeAdded;
+ stickyNoteAdded: typeof stickyNoteAdded;
+ stickyNoteUpdated: typeof stickyNoteUpdated;
+ stickyNoteDeleted: typeof stickyNoteDeleted;
resetSelection: typeof resetSelection;
toggleSelection: typeof toggleSelection;
+ stickyNotes: StickyNote[];
scenario: Scenario;
divId: string;
nodeIdPrefixForFragmentTests?: string;
@@ -38,6 +46,7 @@ type ScenarioGraphProps = {
type FragmentGraphProps = {
scenario: Scenario;
+ stickyNotes: StickyNote[];
divId: string;
nodeIdPrefixForFragmentTests: string;
processCounts: ProcessCounts;
@@ -65,6 +74,9 @@ export enum Events {
CELL_MOUSEENTER = "cell:mouseenter",
CELL_MOUSELEAVE = "cell:mouseleave",
CELL_MOVED = "cellCustom:moved",
+ CELL_RESIZED = "cellCustom:resized",
+ CELL_CONTENT_UPDATED = "cellCustom:contentUpdated",
+ CELL_DELETED = "cellCustom:deleted",
BLANK_POINTERCLICK = "blank:pointerclick",
BLANK_POINTERDOWN = "blank:pointerdown",
BLANK_POINTERUP = "blank:pointerup",
diff --git a/designer/client/src/components/graph/utils/graphUtils.ts b/designer/client/src/components/graph/utils/graphUtils.ts
index 3da1cda198c..25c445b0431 100644
--- a/designer/client/src/components/graph/utils/graphUtils.ts
+++ b/designer/client/src/components/graph/utils/graphUtils.ts
@@ -127,8 +127,9 @@ export const handleGraphEvent =
(
touchEvent: ((view: T, event: dia.Event) => void) | null,
mouseEvent: ((view: T, event: dia.Event) => void) | null,
+ preventDefault: (view: T) => boolean = () => true,
) =>
(view: T, event: dia.Event) => {
- event.preventDefault();
+ if (preventDefault(view)) event.preventDefault();
return isTouchEvent(event) ? touchEvent?.(view, event) : mouseEvent?.(view, event);
};
diff --git a/designer/client/src/components/toolbars/creator/ComponentIcon.tsx b/designer/client/src/components/toolbars/creator/ComponentIcon.tsx
index 1b8341b5931..bd3c350151a 100644
--- a/designer/client/src/components/toolbars/creator/ComponentIcon.tsx
+++ b/designer/client/src/components/toolbars/creator/ComponentIcon.tsx
@@ -7,6 +7,7 @@ import { NodeType, ProcessDefinitionData } from "../../../types";
import { InlineSvg } from "../../SvgDiv";
import { Icon } from "./Icon";
import { createRoot } from "react-dom/client";
+import { StickyNoteType } from "../../../types/stickyNote";
let preloadedIndex = 0;
const preloadBeImage = memoize((src: string): string | null => {
@@ -22,6 +23,11 @@ const preloadBeImage = memoize((src: string): string | null => {
return `#${id}`;
});
+export const stickyNoteIconSrc = `/assets/components/${StickyNoteType}.svg`;
+export function stickyNoteIcon(): string | null {
+ return preloadBeImage(stickyNoteIconSrc);
+}
+
export function getComponentIconSrc(node: NodeType, { components }: ProcessDefinitionData): string | null {
// missing type means that node is the fake properties component
// TODO we should split properties node logic and normal components logic
diff --git a/designer/client/src/components/toolbars/creator/StickyNoteComponent.tsx b/designer/client/src/components/toolbars/creator/StickyNoteComponent.tsx
new file mode 100644
index 00000000000..23e2fc5760c
--- /dev/null
+++ b/designer/client/src/components/toolbars/creator/StickyNoteComponent.tsx
@@ -0,0 +1,19 @@
+import { StickyNoteType } from "../../../types/stickyNote";
+import { ComponentGroup } from "../../../types";
+
+const noteModel = { id: "StickyNoteToAdd", type: StickyNoteType, isDisabled: false };
+export const stickyNoteComponentGroup = (pristine: boolean) => {
+ return [
+ {
+ components: [
+ {
+ node: noteModel,
+ label: "sticky note",
+ componentId: StickyNoteType + "_" + pristine,
+ disabled: () => !pristine,
+ },
+ ],
+ name: "sticky notes",
+ } as ComponentGroup,
+ ];
+};
diff --git a/designer/client/src/components/toolbars/creator/ToolBox.tsx b/designer/client/src/components/toolbars/creator/ToolBox.tsx
index def6637eb36..f7217a53d02 100644
--- a/designer/client/src/components/toolbars/creator/ToolBox.tsx
+++ b/designer/client/src/components/toolbars/creator/ToolBox.tsx
@@ -1,15 +1,18 @@
-import { lighten, styled } from "@mui/material";
-import { getLuminance } from "@mui/system/colorManipulator";
import React, { useMemo } from "react";
-import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import "react-treeview/react-treeview.css";
import { filterComponentsByLabel } from "../../../common/ProcessDefinitionUtils";
-import { blendDarken, blendLighten } from "../../../containers/theme/helpers";
-import { getProcessDefinitionData } from "../../../reducers/selectors/settings";
+import { getProcessDefinitionData, getStickyNotesSettings } from "../../../reducers/selectors/settings";
import { ComponentGroup } from "../../../types";
import Tool from "./Tool";
import { ToolboxComponentGroup } from "./ToolboxComponentGroup";
+import { useTranslation } from "react-i18next";
+import { lighten, styled } from "@mui/material";
+import { blendDarken, blendLighten } from "../../../containers/theme/helpers";
+import { getLuminance } from "@mui/system/colorManipulator";
+import { isPristine } from "../../../reducers/selectors/graph";
+import { concat } from "lodash";
+import { stickyNoteComponentGroup } from "./StickyNoteComponent";
const StyledToolbox = styled("div")(({ theme }) => ({
fontSize: "14px",
@@ -112,16 +115,17 @@ type ToolBoxProps = {
export default function ToolBox(props: ToolBoxProps): JSX.Element {
const processDefinitionData = useSelector(getProcessDefinitionData);
+ const stickyNotesSettings = useSelector(getStickyNotesSettings);
+ const pristine = useSelector(isPristine);
const { t } = useTranslation();
const componentGroups: ComponentGroup[] = useMemo(() => processDefinitionData.componentGroups, [processDefinitionData]);
-
const filters = useMemo(() => props.filter?.toLowerCase().split(/\s/).filter(Boolean), [props.filter]);
-
- const groups = useMemo(
- () => componentGroups.map(filterComponentsByLabel(filters)).filter((g) => g.components.length > 0),
- [componentGroups, filters],
- );
+ const stickyNoteToolGroup = useMemo(() => stickyNoteComponentGroup(pristine), [pristine, props, t]);
+ const groups = useMemo(() => {
+ const allComponentGroups = stickyNotesSettings.enabled ? concat(componentGroups, stickyNoteToolGroup) : componentGroups;
+ return allComponentGroups.map(filterComponentsByLabel(filters)).filter((g) => g.components.length > 0);
+ }, [componentGroups, filters, stickyNoteToolGroup, stickyNotesSettings]);
return (
diff --git a/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx b/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx
index 4397aee00e1..966e138f255 100644
--- a/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx
+++ b/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx
@@ -73,7 +73,13 @@ export function ToolboxComponentGroup(props: Props): JSX.Element {
const elements = useMemo(
() =>
componentGroup.components.map((component) => (
-
+
)),
[highlights, componentGroup.components],
);
diff --git a/designer/client/src/containers/theme/helpers.ts b/designer/client/src/containers/theme/helpers.ts
index 8f6c16fe3d3..9f3cab9a602 100644
--- a/designer/client/src/containers/theme/helpers.ts
+++ b/designer/client/src/containers/theme/helpers.ts
@@ -1,6 +1,7 @@
import { rgbToHex, Theme } from "@mui/material";
import { blend } from "@mui/system";
import { getLuminance } from "@mui/system/colorManipulator";
+import { STICKY_NOTE_DEFAULT_COLOR } from "../../components/graph/EspNode/stickyNote";
export const blendDarken = (color: string, opacity: number) => rgbToHex(blend(color, "#000000", opacity));
export const blendLighten = (color: string, opacity: number) => rgbToHex(blend(color, "#ffffff", opacity));
@@ -14,3 +15,12 @@ export function getNodeBorderColor(theme: Theme) {
? blendDarken(theme.palette.background.paper, 0.4)
: blendLighten(theme.palette.background.paper, 0.6);
}
+
+export function getStickyNoteBackgroundColor(theme: Theme, color: string) {
+ const isValidColor = CSS.supports("color", color);
+ return theme.palette.augmentColor({
+ color: {
+ main: isValidColor ? color : STICKY_NOTE_DEFAULT_COLOR,
+ },
+ });
+}
diff --git a/designer/client/src/containers/theme/nuTheme.tsx b/designer/client/src/containers/theme/nuTheme.tsx
index fc837aee834..3ca43d5c420 100644
--- a/designer/client/src/containers/theme/nuTheme.tsx
+++ b/designer/client/src/containers/theme/nuTheme.tsx
@@ -165,6 +165,7 @@ export const nuTheme = (mode: PaletteMode, setMode: Dispatch ({
backgroundColor: theme.palette.warning.main,
+ color: blendDarken(theme.palette.text.primary, 0.9),
}),
standardInfo: ({ theme }) => ({
backgroundColor: theme.palette.primary.main,
diff --git a/designer/client/src/http/HttpService.ts b/designer/client/src/http/HttpService.ts
index ae65597f9d5..9fcf4cdadc6 100644
--- a/designer/client/src/http/HttpService.ts
+++ b/designer/client/src/http/HttpService.ts
@@ -3,7 +3,7 @@ import { AxiosError, AxiosResponse } from "axios";
import FileSaver from "file-saver";
import i18next from "i18next";
import { Moment } from "moment";
-import { ProcessingType, SettingsData, ValidationData, ValidationRequest } from "../actions/nk";
+import { Position, ProcessingType, SettingsData, ValidationData, ValidationRequest } from "../actions/nk";
import { GenericValidationRequest, TestAdhocValidationRequest } from "../actions/nk/adhocTesting";
import api from "../api";
import { UserData } from "../common/models/User";
@@ -33,10 +33,12 @@ import { EventTrackingSelectorType, EventTrackingType } from "../containers/even
import { BackendNotification } from "../containers/Notifications";
import { ProcessCounts } from "../reducers/graph";
import { AuthenticationSettings } from "../reducers/settings";
-import { Expression, NodeType, ProcessAdditionalFields, ProcessDefinitionData, ScenarioGraph, VariableTypes } from "../types";
+import { Expression, LayoutData, NodeType, ProcessAdditionalFields, ProcessDefinitionData, ScenarioGraph, VariableTypes } from "../types";
import { Instant, WithId } from "../types/common";
import { fixAggregateParameters, fixBranchParametersTemplate } from "./parametersUtils";
import { handleAxiosError } from "../devHelpers";
+import { Dimensions, StickyNote } from "../common/StickyNote";
+import { STICKY_NOTE_DEFAULT_COLOR } from "../components/graph/EspNode/stickyNote";
type HealthCheckProcessDeploymentType = {
status: string;
@@ -124,9 +126,10 @@ export type ComponentUsageType = {
lastAction: ProcessActionType;
};
-type NotificationActions = {
+export type NotificationActions = {
success(message: string): void;
error(message: string, error: string, showErrorText: boolean): void;
+ warn(message: string): void;
};
export interface TestProcessResponse {
@@ -682,6 +685,57 @@ class HttpService {
return promise;
}
+ addStickyNote(scenarioName: string, scenarioVersionId: number, position: Position, dimensions: Dimensions) {
+ const promise = api.post(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes`, {
+ scenarioVersionId,
+ content: "",
+ layoutData: position,
+ color: STICKY_NOTE_DEFAULT_COLOR, //TODO add config for default sticky note color? For now this is default.
+ dimensions: dimensions,
+ });
+ promise.catch((error) => {
+ const errorMsg: string = error?.response?.data;
+ this.#addError("Failed to add sticky note" + (errorMsg ? ": " + errorMsg : ""), error, true);
+ });
+ return promise;
+ }
+
+ deleteStickyNote(scenarioName: string, stickyNoteId: number) {
+ const promise = api.delete(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes/${stickyNoteId}`);
+ promise.catch((error) =>
+ this.#addError(
+ i18next.t("notification.error.failedToDeleteStickyNote", `Failed to delete sticky note with id: ${stickyNoteId}`),
+ error,
+ true,
+ ),
+ );
+ return promise;
+ }
+
+ updateStickyNote(scenarioName: string, scenarioVersionId: number, stickyNote: StickyNote) {
+ const promise = api.put(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes`, {
+ noteId: stickyNote.noteId,
+ scenarioVersionId,
+ content: stickyNote.content,
+ layoutData: stickyNote.layoutData,
+ color: stickyNote.color,
+ dimensions: stickyNote.dimensions,
+ });
+ promise.catch((error) => {
+ const errorMsg = error?.response?.data;
+ this.#addError("Failed to update sticky note" + errorMsg ? ": " + errorMsg : "", error, true);
+ });
+ return promise;
+ }
+
+ getStickyNotes(scenarioName: string, scenarioVersionId: number): Promise> {
+ const promise = api.get(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes?scenarioVersionId=${scenarioVersionId}`);
+ promise.catch((error) =>
+ this.#addError(i18next.t("notification.error.failedToGetStickyNotes", "Failed to get sticky notes"), error, true),
+ );
+ return promise;
+ }
+
fetchProcessCounts(processName: string, dateFrom: Moment, dateTo: Moment): Promise> {
//we use offset date time instead of timestamp to pass info about user time zone to BE
const format = (date: Moment) => date?.format("YYYY-MM-DDTHH:mm:ssZ");
diff --git a/designer/client/src/reducers/graph/reducer.ts b/designer/client/src/reducers/graph/reducer.ts
index b5bbaacafd9..333cfd8d70f 100644
--- a/designer/client/src/reducers/graph/reducer.ts
+++ b/designer/client/src/reducers/graph/reducer.ts
@@ -16,9 +16,12 @@ import { selectionState } from "./selectionState";
import { GraphState } from "./types";
import {
addNodesWithLayout,
+ addStickyNotesWithLayout,
adjustBranchParametersAfterDisconnect,
createEdge,
enrichNodeWithProcessDependentData,
+ prepareNewStickyNotesWithLayout,
+ removeStickyNoteFromLayout,
updateAfterNodeDelete,
updateLayoutAfterNodeIdChange,
} from "./utils";
@@ -238,6 +241,18 @@ const graphReducer: Reducer = (state = emptyGraphState, action) => {
layout: action.layout,
});
}
+ case "STICKY_NOTES_UPDATED": {
+ const { stickyNotes, layout } = prepareNewStickyNotesWithLayout(state, action.stickyNotes);
+ return {
+ ...addStickyNotesWithLayout(state, { stickyNotes, layout }),
+ };
+ }
+ case "STICKY_NOTE_DELETED": {
+ const { stickyNotes, layout } = removeStickyNoteFromLayout(state, action.stickyNoteId);
+ return {
+ ...addStickyNotesWithLayout(state, { stickyNotes, layout }),
+ };
+ }
case "NODES_WITH_EDGES_ADDED": {
const { nodes, layout, idMapping, processDefinitionData, edges } = action;
diff --git a/designer/client/src/reducers/graph/types.ts b/designer/client/src/reducers/graph/types.ts
index 4f4002057f5..f22bd1e4623 100644
--- a/designer/client/src/reducers/graph/types.ts
+++ b/designer/client/src/reducers/graph/types.ts
@@ -1,6 +1,7 @@
import { Layout, RefreshData } from "../../actions/nk";
import { Scenario } from "../../components/Process/types";
import { TestCapabilities, TestFormParameters, TestResults } from "../../common/TestResultUtils";
+import { StickyNote } from "../../common/StickyNote";
export interface NodeCounts {
errors?: number;
@@ -13,6 +14,7 @@ export type ProcessCounts = Record;
export type GraphState = {
scenarioLoading: boolean;
scenario?: Scenario;
+ stickyNotes?: StickyNote[];
selectionState?: string[];
layout: Layout;
testCapabilities?: TestCapabilities;
diff --git a/designer/client/src/reducers/graph/utils.ts b/designer/client/src/reducers/graph/utils.ts
index f19cbc50075..dbc2b175692 100644
--- a/designer/client/src/reducers/graph/utils.ts
+++ b/designer/client/src/reducers/graph/utils.ts
@@ -5,6 +5,8 @@ import { ExpressionLang } from "../../components/graph/node-modal/editors/expres
import NodeUtils from "../../components/graph/NodeUtils";
import { BranchParams, Edge, EdgeType, NodeId, NodeType, ProcessDefinitionData } from "../../types";
import { GraphState } from "./types";
+import { StickyNote } from "../../common/StickyNote";
+import { createStickyNoteId } from "../../types/stickyNote";
export function updateLayoutAfterNodeIdChange(layout: Layout, oldId: NodeId, newId: NodeId): Layout {
return map(layout, (n) => (oldId === n.id ? { ...n, id: newId } : n));
@@ -77,6 +79,33 @@ export function prepareNewNodesWithLayout(
};
}
+export function removeStickyNoteFromLayout(state: GraphState, stickyNoteId: number): { layout: NodePosition[]; stickyNotes: StickyNote[] } {
+ const { layout } = state;
+ const stickyNoteLayoutId = createStickyNoteId(stickyNoteId);
+ const updatedStickyNotes = state.stickyNotes.filter((n) => n.noteId !== stickyNoteId);
+ const updatedLayout = updatedStickyNotes.map((stickyNote) => {
+ return { id: stickyNote.id, position: stickyNote.layoutData };
+ });
+ return {
+ stickyNotes: [...updatedStickyNotes],
+ layout: [...layout.filter((l) => l.id !== stickyNoteLayoutId), ...updatedLayout],
+ };
+}
+
+export function prepareNewStickyNotesWithLayout(
+ state: GraphState,
+ stickyNotes: StickyNote[],
+): { layout: NodePosition[]; stickyNotes: StickyNote[] } {
+ const { layout } = state;
+ const updatedLayout = stickyNotes.map((stickyNote) => {
+ return { id: createStickyNoteId(stickyNote.noteId), position: stickyNote.layoutData };
+ });
+ return {
+ stickyNotes: [...stickyNotes],
+ layout: [...layout, ...updatedLayout],
+ };
+}
+
export function addNodesWithLayout(
state: GraphState,
changes: {
@@ -103,6 +132,17 @@ export function addNodesWithLayout(
};
}
+export function addStickyNotesWithLayout(
+ state: GraphState,
+ { stickyNotes, layout }: ReturnType,
+): GraphState {
+ return {
+ ...state,
+ stickyNotes: stickyNotes,
+ layout,
+ };
+}
+
export function createEdge(
fromNode: NodeType,
toNode: NodeType,
diff --git a/designer/client/src/reducers/selectors/graph.ts b/designer/client/src/reducers/selectors/graph.ts
index 3fbf656d70a..473f3251439 100644
--- a/designer/client/src/reducers/selectors/graph.ts
+++ b/designer/client/src/reducers/selectors/graph.ts
@@ -8,6 +8,8 @@ import { ProcessCounts } from "../graph";
import { RootState } from "../index";
import { getProcessState } from "./scenarioState";
import { TestFormParameters } from "../../common/TestResultUtils";
+import { StickyNote } from "../../common/StickyNote";
+import { getStickyNotesSettings } from "./settings";
export const getGraph = (state: RootState) => state.graphReducer.history.present;
@@ -75,6 +77,9 @@ export const getTestParameters = createSelector(getGraph, (g) => g.testFormParam
export const getTestResults = createSelector(getGraph, (g) => g.testResults);
export const getProcessCountsRefresh = createSelector(getGraph, (g) => g.processCountsRefresh || null);
export const getProcessCounts = createSelector(getGraph, (g): ProcessCounts => g.processCounts || ({} as ProcessCounts));
+export const getStickyNotes = createSelector([getGraph, getStickyNotesSettings], (g, settings) =>
+ settings.enabled ? g.stickyNotes || ([] as StickyNote[]) : ([] as StickyNote[]),
+);
export const getShowRunProcessDetails = createSelector(
[getTestResults, getProcessCounts],
(testResults, processCounts) => testResults || processCounts,
diff --git a/designer/client/src/reducers/selectors/settings.ts b/designer/client/src/reducers/selectors/settings.ts
index e222a76b8ae..382290a60ea 100644
--- a/designer/client/src/reducers/selectors/settings.ts
+++ b/designer/client/src/reducers/selectors/settings.ts
@@ -14,6 +14,7 @@ export const getEnvironmentAlert = createSelector(getFeatureSettings, (s) => s?.
export const getTabs = createSelector(getFeatureSettings, (s): DynamicTabData[] => uniqBy(s.tabs || [], (t) => t.id));
export const getTargetEnvironmentId = createSelector(getFeatureSettings, (s) => s?.remoteEnvironment?.targetEnvironmentId);
export const getSurveySettings = createSelector(getFeatureSettings, (s) => s?.surveySettings);
+export const getStickyNotesSettings = createSelector(getFeatureSettings, (s) => s?.stickyNotesSettings);
export const getLoggedUser = createSelector(getSettings, (s) => s.loggedUser);
export const getLoggedUserId = createSelector(getLoggedUser, (s) => s.id);
export const getProcessDefinitionData = createSelector(getSettings, (s) => s.processDefinitionData || ({} as ProcessDefinitionData));
diff --git a/designer/client/src/types/component.ts b/designer/client/src/types/component.ts
index 6999e20af94..acd979bd43d 100644
--- a/designer/client/src/types/component.ts
+++ b/designer/client/src/types/component.ts
@@ -5,6 +5,7 @@ export type Component = {
node: NodeType;
label: string;
componentId: string;
+ disabled?: () => boolean;
};
export type ComponentGroup = {
components: Component[];
diff --git a/designer/client/src/types/node.ts b/designer/client/src/types/node.ts
index c021f21c87e..014a4c13670 100644
--- a/designer/client/src/types/node.ts
+++ b/designer/client/src/types/node.ts
@@ -1,7 +1,8 @@
import { ProcessAdditionalFields, ReturnedType } from "./scenarioGraph";
import { FragmentInputParameter } from "../components/graph/node-modal/fragment-input-definition/item";
+import { StickyNoteType } from "./stickyNote";
-type Type = "FragmentInput" | string;
+type Type = "FragmentInput" | typeof StickyNoteType | string;
export type LayoutData = { x: number; y: number };
diff --git a/designer/client/src/types/stickyNote.ts b/designer/client/src/types/stickyNote.ts
new file mode 100644
index 00000000000..eb1758c2660
--- /dev/null
+++ b/designer/client/src/types/stickyNote.ts
@@ -0,0 +1,4 @@
+export const StickyNoteType = "StickyNote";
+export function createStickyNoteId(noteId: number) {
+ return StickyNoteType + "_" + noteId;
+}
diff --git a/designer/server/src/main/resources/defaultDesignerConfig.conf b/designer/server/src/main/resources/defaultDesignerConfig.conf
index b6c2e5c724f..9084c19c84b 100644
--- a/designer/server/src/main/resources/defaultDesignerConfig.conf
+++ b/designer/server/src/main/resources/defaultDesignerConfig.conf
@@ -211,6 +211,12 @@ testDataSettings: {
resultsMaxBytes: 50000000
}
+stickyNotesSettings: {
+ maxContentLength: 5000,
+ maxNotesCount: 5
+ enabled: true
+}
+
scenarioLabelSettings: {
validationRules = [
{
diff --git a/designer/server/src/main/resources/web/static/assets/components/StickyNote.svg b/designer/server/src/main/resources/web/static/assets/components/StickyNote.svg
new file mode 100644
index 00000000000..db4797cc376
--- /dev/null
+++ b/designer/server/src/main/resources/web/static/assets/components/StickyNote.svg
@@ -0,0 +1 @@
+
diff --git a/designer/server/src/main/scala/db/migration/V1_060__CreateStickyNotesDefinition.scala b/designer/server/src/main/scala/db/migration/V1_060__CreateStickyNotesDefinition.scala
new file mode 100644
index 00000000000..eef502e1b11
--- /dev/null
+++ b/designer/server/src/main/scala/db/migration/V1_060__CreateStickyNotesDefinition.scala
@@ -0,0 +1,89 @@
+package db.migration
+
+import com.typesafe.scalalogging.LazyLogging
+import db.migration.V1_060__CreateStickyNotesDefinition.StickyNotesDefinitions
+import pl.touk.nussknacker.ui.db.migration.SlickMigration
+import slick.jdbc.JdbcProfile
+import slick.sql.SqlProfile.ColumnOption.NotNull
+import shapeless.syntax.std.tuple._
+import java.sql.Timestamp
+import java.util.UUID
+import scala.concurrent.ExecutionContext.Implicits.global
+
+trait V1_060__CreateStickyNotesDefinition extends SlickMigration with LazyLogging {
+
+ import profile.api._
+
+ private val definitions = new StickyNotesDefinitions(profile)
+
+ override def migrateActions: DBIOAction[Any, NoStream, Effect.All] = {
+ logger.info("Starting migration V1_060__CreateStickyNotesDefinition")
+ for {
+ _ <- definitions.stickyNotesEntityTable.schema.create
+ _ <-
+ sqlu"""ALTER TABLE "sticky_notes" ADD CONSTRAINT "sticky_notes_scenario_version_fk" FOREIGN KEY ("scenario_id", "scenario_version_id") REFERENCES "process_versions" ("process_id", "id") ON DELETE CASCADE;"""
+ } yield logger.info("Execution finished for migration V1_060__CreateStickyNotesDefinition")
+ }
+
+}
+
+object V1_060__CreateStickyNotesDefinition {
+
+ class StickyNotesDefinitions(val profile: JdbcProfile) {
+ import profile.api._
+ val stickyNotesEntityTable = TableQuery[StickyNotesEntity]
+
+ class StickyNotesEntity(tag: Tag) extends Table[StickyNoteEventEntityData](tag, "sticky_notes") {
+
+ def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
+ def noteCorrelationId = column[UUID]("note_correlation_id", NotNull)
+ def content = column[String]("content", NotNull)
+ def layoutData = column[String]("layout_data", NotNull)
+ def color = column[String]("color", NotNull)
+ def dimensions = column[String]("dimensions", NotNull)
+ def targetEdge = column[Option[String]]("target_edge")
+ def eventCreator = column[String]("event_creator", NotNull)
+ def eventDate = column[Timestamp]("event_date", NotNull)
+ def eventType = column[String]("event_type", NotNull)
+ def scenarioId = column[Long]("scenario_id", NotNull)
+ def scenarioVersionId = column[Long]("scenario_version_id", NotNull)
+
+ def tupleWithoutAutoIncId = (
+ noteCorrelationId,
+ content,
+ layoutData,
+ color,
+ dimensions,
+ targetEdge,
+ eventCreator,
+ eventDate,
+ eventType,
+ scenarioId,
+ scenarioVersionId
+ )
+
+ override def * =
+ (id :: tupleWithoutAutoIncId.productElements).tupled <> (
+ StickyNoteEventEntityData.apply _ tupled, StickyNoteEventEntityData.unapply
+ )
+
+ }
+
+ }
+
+ final case class StickyNoteEventEntityData(
+ id: Long,
+ noteCorrelationId: UUID,
+ content: String,
+ layoutData: String,
+ color: String,
+ dimensions: String,
+ targetEdge: Option[String],
+ eventCreator: String,
+ eventDate: Timestamp,
+ eventType: String,
+ scenarioId: Long,
+ scenarioVersionId: Long
+ )
+
+}
diff --git a/designer/server/src/main/scala/db/migration/hsql/V1_060__CreateStickyNotes.scala b/designer/server/src/main/scala/db/migration/hsql/V1_060__CreateStickyNotes.scala
new file mode 100644
index 00000000000..86c5dd34568
--- /dev/null
+++ b/designer/server/src/main/scala/db/migration/hsql/V1_060__CreateStickyNotes.scala
@@ -0,0 +1,8 @@
+package db.migration.hsql
+
+import db.migration.V1_060__CreateStickyNotesDefinition
+import slick.jdbc.{HsqldbProfile, JdbcProfile}
+
+class V1_060__CreateStickyNotes extends V1_060__CreateStickyNotesDefinition {
+ override protected lazy val profile: JdbcProfile = HsqldbProfile
+}
diff --git a/designer/server/src/main/scala/db/migration/postgres/V1_060__CreateStickyNotes.scala b/designer/server/src/main/scala/db/migration/postgres/V1_060__CreateStickyNotes.scala
new file mode 100644
index 00000000000..e2887ece894
--- /dev/null
+++ b/designer/server/src/main/scala/db/migration/postgres/V1_060__CreateStickyNotes.scala
@@ -0,0 +1,8 @@
+package db.migration.postgres
+
+import db.migration.V1_060__CreateStickyNotesDefinition
+import slick.jdbc.{JdbcProfile, PostgresProfile}
+
+class V1_060__CreateStickyNotes extends V1_060__CreateStickyNotesDefinition {
+ override protected lazy val profile: JdbcProfile = PostgresProfile
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala
index 5c17a33ea9c..e2915233594 100644
--- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala
@@ -7,6 +7,7 @@ import io.circe.{Decoder, Encoder}
import io.circe.generic.JsonCodec
import pl.touk.nussknacker.ui.config.{FeatureTogglesConfig, UsageStatisticsReportsConfig}
import pl.touk.nussknacker.engine.api.CirceUtil.codecs._
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesSettings
import pl.touk.nussknacker.ui.statistics.{Fingerprint, FingerprintService}
import java.net.URL
@@ -42,7 +43,8 @@ class SettingsResources(
testDataSettings = config.testDataSettings,
redirectAfterArchive = config.redirectAfterArchive,
usageStatisticsReports =
- UsageStatisticsReportsSettings(usageStatisticsReportsConfig, fingerprint.toOption)
+ UsageStatisticsReportsSettings(usageStatisticsReportsConfig, fingerprint.toOption),
+ stickyNotesSettings = config.stickyNotesSettings
)
val authenticationSettings = AuthenticationSettings(
authenticationMethod
@@ -134,6 +136,7 @@ object TopTabType extends Enumeration {
tabs: Option[List[TopTab]],
intervalTimeSettings: IntervalTimeSettings,
testDataSettings: TestDataSettings,
+ stickyNotesSettings: StickyNotesSettings,
redirectAfterArchive: Boolean,
usageStatisticsReports: UsageStatisticsReportsSettings,
)
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpService.scala
new file mode 100644
index 00000000000..0c29c5257c3
--- /dev/null
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpService.scala
@@ -0,0 +1,230 @@
+package pl.touk.nussknacker.ui.api
+
+import cats.data.EitherT
+import com.typesafe.scalalogging.LazyLogging
+import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId}
+import pl.touk.nussknacker.security.Permission
+import pl.touk.nussknacker.security.Permission.Permission
+import pl.touk.nussknacker.ui.api.description.StickyNotesApiEndpoints
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{
+ StickyNote,
+ StickyNoteAddRequest,
+ StickyNoteCorrelationId,
+ StickyNoteId,
+ StickyNoteUpdateRequest,
+ StickyNotesError,
+ StickyNotesSettings
+}
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesError.{
+ NoPermission,
+ NoScenario,
+ StickyNoteContentTooLong,
+ StickyNoteCountLimitReached
+}
+import pl.touk.nussknacker.ui.process.repository.stickynotes.StickyNotesRepository
+import pl.touk.nussknacker.ui.process.repository.DBIOActionRunner
+import pl.touk.nussknacker.ui.process.ProcessService
+import pl.touk.nussknacker.ui.security.api.{AuthManager, LoggedUser}
+
+import scala.concurrent.{ExecutionContext, Future}
+
+class StickyNotesApiHttpService(
+ authManager: AuthManager,
+ stickyNotesRepository: StickyNotesRepository,
+ scenarioService: ProcessService,
+ scenarioAuthorizer: AuthorizeProcess,
+ dbioActionRunner: DBIOActionRunner,
+ stickyNotesSettings: StickyNotesSettings
+)(implicit executionContext: ExecutionContext)
+ extends BaseHttpService(authManager)
+ with LazyLogging {
+
+ private val securityInput = authManager.authenticationEndpointInput()
+
+ private val endpoints = new StickyNotesApiEndpoints(securityInput)
+
+ expose {
+ endpoints.stickyNotesGetEndpoint
+ .serverSecurityLogic(authorizeKnownUser[StickyNotesError])
+ .serverLogicEitherT { implicit loggedUser =>
+ { case (scenarioName, versionId) =>
+ for {
+ scenarioId <- getScenarioIdByName(scenarioName)
+ _ <- isAuthorized(scenarioId, Permission.Read)
+ processActivity <- fetchStickyNotes(scenarioId, versionId)
+ } yield processActivity.toList
+ }
+ }
+ }
+
+ expose {
+ endpoints.stickyNotesAddEndpoint
+ .serverSecurityLogic(authorizeKnownUser[StickyNotesError])
+ .serverLogicEitherT { implicit loggedUser =>
+ { case (scenarioName, requestBody) =>
+ for {
+ scenarioId <- getScenarioIdByName(scenarioName)
+ _ <- isAuthorized(scenarioId, Permission.Write)
+ count <- getStickyNotesCount(scenarioId, requestBody.scenarioVersionId)
+ _ <- validateStickyNotesCount(count, stickyNotesSettings)
+ _ <- validateStickyNoteContent(requestBody.content, stickyNotesSettings)
+ processActivity <- addStickyNote(scenarioId, requestBody)
+ } yield processActivity
+ }
+ }
+ }
+
+ expose {
+ endpoints.stickyNotesUpdateEndpoint
+ .serverSecurityLogic(authorizeKnownUser[StickyNotesError])
+ .serverLogicEitherT { implicit loggedUser =>
+ { case (scenarioName, requestBody) =>
+ for {
+ scenarioId <- getScenarioIdByName(scenarioName)
+ _ <- isAuthorized(scenarioId, Permission.Write)
+ _ <- validateStickyNoteContent(requestBody.content, stickyNotesSettings)
+ processActivity <- updateStickyNote(requestBody)
+ } yield processActivity.toInt
+ }
+ }
+ }
+
+ expose {
+ endpoints.stickyNotesDeleteEndpoint
+ .serverSecurityLogic(authorizeKnownUser[StickyNotesError])
+ .serverLogicEitherT { implicit loggedUser =>
+ { case (scenarioName, noteId) =>
+ for {
+ scenarioId <- getScenarioIdByName(scenarioName)
+ _ <- isAuthorized(scenarioId, Permission.Write)
+ processActivity <- deleteStickyNote(noteId)
+ } yield processActivity.toInt
+ }
+ }
+
+ }
+
+ private def getScenarioIdByName(scenarioName: ProcessName) = {
+ EitherT.fromOptionF(
+ scenarioService.getProcessId(scenarioName),
+ NoScenario(scenarioName)
+ )
+ }
+
+ private def isAuthorized(scenarioId: ProcessId, permission: Permission)(
+ implicit loggedUser: LoggedUser
+ ): EitherT[Future, StickyNotesError, Unit] =
+ EitherT(
+ scenarioAuthorizer
+ .check(scenarioId, permission, loggedUser)
+ .map[Either[StickyNotesError, Unit]] {
+ case true => Right(())
+ case false => Left(NoPermission)
+ }
+ )
+
+ private def fetchStickyNotes(scenarioId: ProcessId, versionId: VersionId)(
+ implicit loggedUser: LoggedUser
+ ): EitherT[Future, StickyNotesError, Seq[StickyNote]] =
+ EitherT
+ .right(
+ dbioActionRunner.run(
+ stickyNotesRepository.findStickyNotes(scenarioId, versionId)
+ )
+ )
+
+ private def getStickyNotesCount(scenarioId: ProcessId, versionId: VersionId): EitherT[Future, StickyNotesError, Int] =
+ EitherT
+ .right(
+ dbioActionRunner
+ .run(
+ stickyNotesRepository.countStickyNotes(scenarioId, versionId)
+ )
+ )
+
+ private def deleteStickyNote(noteId: StickyNoteId)(
+ implicit loggedUser: LoggedUser
+ ): EitherT[Future, StickyNotesError, Int] =
+ for {
+ note <- EitherT.fromOptionF(
+ dbioActionRunner.run(
+ stickyNotesRepository.findStickyNoteById(noteId)
+ ),
+ StickyNotesError.NoStickyNote(noteId)
+ )
+ _ <- isAuthorized(note.scenarioId, Permission.Write)
+ result <- EitherT.right(
+ dbioActionRunner.run(
+ stickyNotesRepository.deleteStickyNote(noteId)
+ )
+ )
+ } yield result
+
+ private def validateStickyNotesCount(
+ stickyNotesCount: Int,
+ stickyNotesConfig: StickyNotesSettings
+ ): EitherT[Future, StickyNotesError, Unit] =
+ EitherT.fromEither(
+ Either.cond(
+ stickyNotesCount < stickyNotesConfig.maxNotesCount,
+ (),
+ StickyNoteCountLimitReached(stickyNotesConfig.maxNotesCount)
+ )
+ )
+
+ private def addStickyNote(scenarioId: ProcessId, requestBody: StickyNoteAddRequest)(
+ implicit loggedUser: LoggedUser
+ ): EitherT[Future, StickyNotesError, StickyNoteCorrelationId] =
+ EitherT
+ .right(
+ dbioActionRunner.run(
+ stickyNotesRepository.addStickyNote(
+ requestBody.content,
+ requestBody.layoutData,
+ requestBody.color,
+ requestBody.dimensions,
+ requestBody.targetEdge,
+ scenarioId,
+ requestBody.scenarioVersionId
+ )
+ )
+ )
+
+ private def validateStickyNoteContent(
+ content: String,
+ stickyNotesConfig: StickyNotesSettings
+ ): EitherT[Future, StickyNotesError, Unit] =
+ EitherT.fromEither(
+ Either.cond(
+ content.length <= stickyNotesConfig.maxContentLength,
+ (),
+ StickyNoteContentTooLong(content.length, stickyNotesConfig.maxContentLength)
+ )
+ )
+
+ private def updateStickyNote(requestBody: StickyNoteUpdateRequest)(
+ implicit loggedUser: LoggedUser
+ ): EitherT[Future, StickyNotesError, Int] = for {
+ note <- EitherT.fromOptionF(
+ dbioActionRunner.run(
+ stickyNotesRepository.findStickyNoteById(requestBody.noteId)
+ ),
+ StickyNotesError.NoStickyNote(requestBody.noteId)
+ )
+ _ <- isAuthorized(note.scenarioId, Permission.Write)
+ result <- EitherT.right(
+ dbioActionRunner.run(
+ stickyNotesRepository.updateStickyNote(
+ requestBody.noteId,
+ requestBody.content,
+ requestBody.layoutData,
+ requestBody.color,
+ requestBody.dimensions,
+ requestBody.targetEdge,
+ requestBody.scenarioVersionId
+ )
+ )
+ )
+ } yield result
+
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/StickyNotesApiEndpoints.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/StickyNotesApiEndpoints.scala
new file mode 100644
index 00000000000..4dff4ac3d0e
--- /dev/null
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/StickyNotesApiEndpoints.scala
@@ -0,0 +1,219 @@
+package pl.touk.nussknacker.ui.api.description
+
+import io.circe.Encoder
+import pl.touk.nussknacker.engine.api.LayoutData
+import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId}
+import pl.touk.nussknacker.engine.api.typed.typing._
+import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions
+import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions.SecuredEndpoint
+import pl.touk.nussknacker.security.AuthCredentials
+import pl.touk.nussknacker.ui.api.TapirCodecs
+import pl.touk.nussknacker.ui.api.TapirCodecs.ScenarioNameCodec._
+import pl.touk.nussknacker.ui.api.description.StickyNotesApiEndpoints.Examples.{
+ noScenarioExample,
+ noStickyNoteExample,
+ stickyNoteContentTooLongExample,
+ stickyNoteCountLimitReachedExample
+}
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNoteId
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesError.{
+ NoScenario,
+ NoStickyNote,
+ StickyNoteContentTooLong,
+ StickyNoteCountLimitReached
+}
+import sttp.model.StatusCode.{BadRequest, NotFound, Ok}
+import sttp.tapir.EndpointIO.Example
+import sttp.tapir._
+import sttp.tapir.json.circe.jsonBody
+
+import java.time.Instant
+
+class StickyNotesApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpointDefinitions {
+
+ import stickynotes.Dtos._
+ import TapirCodecs.VersionIdCodec._
+
+ lazy val encoder: Encoder[TypingResult] = TypingResult.encoder
+
+ private val exampleInstantDate = Instant.ofEpochMilli(1730136602)
+
+ private val exampleStickyNote = StickyNote(
+ StickyNoteId(1),
+ "##Title \nNote1",
+ LayoutData(20, 30),
+ "#99aa20",
+ Dimensions(300, 200),
+ None,
+ "Marco",
+ exampleInstantDate
+ )
+
+ lazy val stickyNotesGetEndpoint: SecuredEndpoint[
+ (ProcessName, VersionId),
+ StickyNotesError,
+ List[StickyNote],
+ Any
+ ] = {
+ baseNuApiEndpoint
+ .summary("Returns sticky nodes for given scenario with version")
+ .tag("StickyNotes")
+ .get
+ .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes" / query[VersionId]("scenarioVersionId"))
+ .out(
+ statusCode(Ok).and(
+ jsonBody[List[StickyNote]]
+ .examples(
+ List(
+ Example.of(
+ summary = Some("List of valid sticky notes returned for scenario"),
+ value = List(
+ exampleStickyNote,
+ exampleStickyNote.copy(noteId = StickyNoteId(2))
+ )
+ )
+ )
+ )
+ )
+ )
+ .errorOut(
+ oneOf[StickyNotesError](
+ noScenarioExample
+ )
+ )
+ .withSecurity(auth)
+ }
+
+ lazy val stickyNotesUpdateEndpoint
+ : SecuredEndpoint[(ProcessName, StickyNoteUpdateRequest), StickyNotesError, Unit, Any] = {
+ baseNuApiEndpoint
+ .summary("Updates sticky note with new values")
+ .tag("StickyNotes")
+ .put
+ .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes")
+ .in(
+ jsonBody[StickyNoteUpdateRequest]
+ .example(
+ StickyNoteUpdateRequest(
+ StickyNoteId(1),
+ VersionId(1),
+ "",
+ LayoutData(12, 33),
+ "#441022",
+ Dimensions(300, 200),
+ None
+ )
+ )
+ )
+ .out(
+ statusCode(Ok)
+ )
+ .errorOut(
+ oneOf[StickyNotesError](
+ noScenarioExample,
+ noStickyNoteExample,
+ stickyNoteContentTooLongExample
+ )
+ )
+ .withSecurity(auth)
+ }
+
+ lazy val stickyNotesAddEndpoint
+ : SecuredEndpoint[(ProcessName, StickyNoteAddRequest), StickyNotesError, StickyNoteCorrelationId, Any] = {
+ baseNuApiEndpoint
+ .summary("Creates new sticky note with given content")
+ .tag("StickyNotes")
+ .post
+ .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes")
+ .in(
+ jsonBody[StickyNoteAddRequest]
+ .example(StickyNoteAddRequest(VersionId(1), "", LayoutData(12, 33), "#441022", Dimensions(300, 200), None))
+ )
+ .out(jsonBody[StickyNoteCorrelationId])
+ .errorOut(
+ oneOf[StickyNotesError](
+ noScenarioExample,
+ stickyNoteContentTooLongExample,
+ stickyNoteCountLimitReachedExample
+ )
+ )
+ .withSecurity(auth)
+ }
+
+ lazy val stickyNotesDeleteEndpoint: SecuredEndpoint[(ProcessName, StickyNoteId), StickyNotesError, Unit, Any] = {
+ baseNuApiEndpoint
+ .summary("Deletes stickyNote by given noteId")
+ .tag("StickyNotes")
+ .delete
+ .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes" / path[StickyNoteId]("noteId"))
+ .out(
+ statusCode(Ok)
+ )
+ .errorOut(
+ oneOf[StickyNotesError](
+ noStickyNoteExample,
+ noScenarioExample
+ )
+ )
+ .withSecurity(auth)
+ }
+
+}
+
+object StickyNotesApiEndpoints {
+
+ object Examples {
+
+ val noScenarioExample: EndpointOutput.OneOfVariant[NoScenario] =
+ oneOfVariantFromMatchType(
+ NotFound,
+ plainBody[NoScenario]
+ .example(
+ Example.of(
+ summary = Some("No scenario {scenarioName} found"),
+ value = NoScenario(ProcessName("s1"))
+ )
+ )
+ )
+
+ val noStickyNoteExample: EndpointOutput.OneOfVariant[NoStickyNote] =
+ oneOfVariantFromMatchType(
+ NotFound,
+ plainBody[NoStickyNote]
+ .example(
+ Example.of(
+ summary = Some("No sticky note with id: 3 was found"),
+ value = NoStickyNote(StickyNoteId(3))
+ )
+ )
+ )
+
+ val stickyNoteContentTooLongExample: EndpointOutput.OneOfVariant[StickyNoteContentTooLong] =
+ oneOfVariantFromMatchType(
+ BadRequest,
+ plainBody[StickyNoteContentTooLong]
+ .example(
+ Example.of(
+ summary = Some("Provided note content is too long (5004 characters). Max content length is 5000."),
+ value = StickyNoteContentTooLong(count = 5004, max = 5000)
+ )
+ )
+ )
+
+ val stickyNoteCountLimitReachedExample: EndpointOutput.OneOfVariant[StickyNoteCountLimitReached] =
+ oneOfVariantFromMatchType(
+ BadRequest,
+ plainBody[StickyNoteCountLimitReached]
+ .example(
+ Example.of(
+ summary = Some(
+ "Cannot add another sticky note, since max number of sticky notes was reached: 5 (see configuration)."
+ ),
+ value = StickyNoteCountLimitReached(max = 5)
+ )
+ )
+ )
+
+ }
+
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/Dtos.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/Dtos.scala
new file mode 100644
index 00000000000..82e0e0e5d4a
--- /dev/null
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/Dtos.scala
@@ -0,0 +1,119 @@
+package pl.touk.nussknacker.ui.api.description.stickynotes
+
+import derevo.circe.{decoder, encoder}
+import derevo.derive
+import io.circe.generic.JsonCodec
+import io.circe.{Decoder, Encoder}
+import pl.touk.nussknacker.engine.api.LayoutData
+import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId}
+import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions
+import pl.touk.nussknacker.ui.api.BaseHttpService.CustomAuthorizationError
+import sttp.tapir.{Codec, CodecFormat, Schema}
+import sttp.tapir.derevo.schema
+
+import java.time.Instant
+import java.util.UUID
+
+object Dtos {
+ import pl.touk.nussknacker.ui.api.TapirCodecs.VersionIdCodec.{schema => versionIdSchema}
+
+ final case class StickyNoteId(value: Long) extends AnyVal
+
+ object StickyNoteId {
+ implicit val encoder: Encoder[StickyNoteId] = Encoder.encodeLong.contramap(_.value)
+ implicit val decoder: Decoder[StickyNoteId] = Decoder.decodeLong.map(StickyNoteId(_))
+ }
+
+ final case class StickyNoteCorrelationId(value: UUID) extends AnyVal
+
+ object StickyNoteCorrelationId {
+ implicit val encoder: Encoder[StickyNoteCorrelationId] = Encoder.encodeUUID.contramap(_.value)
+ implicit val decoder: Decoder[StickyNoteCorrelationId] = Decoder.decodeUUID.map(StickyNoteCorrelationId(_))
+ }
+
+ implicit lazy val stickyNoteCorrelationIdSchema: Schema[StickyNoteCorrelationId] =
+ Schema.schemaForUUID.as[StickyNoteCorrelationId]
+ implicit lazy val stickyNoteIdSchema: Schema[StickyNoteId] = Schema.schemaForLong.as[StickyNoteId]
+
+ @derive(encoder, decoder, schema)
+ case class Dimensions(
+ width: Long,
+ height: Long
+ )
+
+ @derive(encoder, decoder, schema)
+ case class StickyNote(
+ noteId: StickyNoteId,
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String],
+ editedBy: String,
+ editedAt: Instant
+ )
+
+ @derive(encoder, decoder, schema)
+ case class StickyNoteAddRequest(
+ scenarioVersionId: VersionId,
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String]
+ )
+
+ @derive(encoder, decoder, schema)
+ case class StickyNoteUpdateRequest(
+ noteId: StickyNoteId,
+ scenarioVersionId: VersionId,
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String]
+ )
+
+ @JsonCodec case class StickyNotesSettings(
+ maxContentLength: Int,
+ maxNotesCount: Int,
+ enabled: Boolean
+ )
+
+ object StickyNotesSettings {
+ val default: StickyNotesSettings = StickyNotesSettings(maxContentLength = 5000, maxNotesCount = 5, enabled = true)
+ }
+
+ sealed trait StickyNotesError
+
+ implicit lazy val layoutDataSchema: Schema[LayoutData] = Schema.derived
+
+ object StickyNotesError {
+
+ final case class NoScenario(scenarioName: ProcessName) extends StickyNotesError
+ final case object NoPermission extends StickyNotesError with CustomAuthorizationError
+ final case class StickyNoteContentTooLong(count: Int, max: Int) extends StickyNotesError
+ final case class StickyNoteCountLimitReached(max: Int) extends StickyNotesError
+ final case class NoStickyNote(noteId: StickyNoteId) extends StickyNotesError
+
+ implicit val noScenarioCodec: Codec[String, NoScenario, CodecFormat.TextPlain] =
+ BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[NoScenario](e => s"No scenario ${e.scenarioName} found")
+
+ implicit val noStickyNoteCodec: Codec[String, NoStickyNote, CodecFormat.TextPlain] =
+ BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[NoStickyNote](e =>
+ s"No sticky note with id: ${e.noteId} was found"
+ )
+
+ implicit val stickyNoteContentTooLongCodec: Codec[String, StickyNoteContentTooLong, CodecFormat.TextPlain] =
+ BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[StickyNoteContentTooLong](e =>
+ s"Provided note content is too long (${e.count} characters). Max content length is ${e.max}."
+ )
+
+ implicit val stickyNoteCountLimitReachedCodec: Codec[String, StickyNoteCountLimitReached, CodecFormat.TextPlain] =
+ BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[StickyNoteCountLimitReached](e =>
+ s"Cannot add another sticky note, since max number of sticky notes was reached: ${e.max} (see configuration)."
+ )
+
+ }
+
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/StickyNoteEvent.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/StickyNoteEvent.scala
new file mode 100644
index 00000000000..64d62164c90
--- /dev/null
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/StickyNoteEvent.scala
@@ -0,0 +1,14 @@
+package pl.touk.nussknacker.ui.api.description.stickynotes
+
+import io.circe.{Decoder, Encoder}
+
+object StickyNoteEvent extends Enumeration {
+ implicit val typeEncoder: Encoder[StickyNoteEvent.Value] = Encoder.encodeEnumeration(StickyNoteEvent)
+ implicit val typeDecoder: Decoder[StickyNoteEvent.Value] = Decoder.decodeEnumeration(StickyNoteEvent)
+
+ type StickyNoteEvent = Value
+ val StickyNoteCreated: Value = Value("CREATED")
+ val StickyNoteUpdated: Value = Value("UPDATED")
+ val StickyNoteDeleted: Value = Value("DELETED")
+
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala
index 1ee85981ab3..410a29037f1 100644
--- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala
@@ -7,6 +7,7 @@ import net.ceedubs.ficus.readers.ValueReader
import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode
import pl.touk.nussknacker.engine.util.config.FicusReaders
import pl.touk.nussknacker.ui.api._
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesSettings
import pl.touk.nussknacker.ui.config.Implicits.parseOptionalConfig
import pl.touk.nussknacker.ui.process.migrate.HttpRemoteEnvironmentConfig
@@ -28,7 +29,8 @@ final case class FeatureTogglesConfig(
testDataSettings: TestDataSettings,
enableConfigEndpoint: Boolean,
redirectAfterArchive: Boolean,
- componentDefinitionExtractionMode: ComponentDefinitionExtractionMode
+ componentDefinitionExtractionMode: ComponentDefinitionExtractionMode,
+ stickyNotesSettings: StickyNotesSettings
)
object FeatureTogglesConfig extends LazyLogging {
@@ -56,8 +58,10 @@ object FeatureTogglesConfig extends LazyLogging {
val tabs = parseOptionalConfig[List[TopTab]](config, "tabs")
val intervalTimeSettings = config.as[IntervalTimeSettings]("intervalTimeSettings")
val testDataSettings = config.as[TestDataSettings]("testDataSettings")
- val redirectAfterArchive = config.getAs[Boolean]("redirectAfterArchive").getOrElse(true)
- val componentDefinitionExtractionMode = parseComponentDefinitionExtractionMode(config)
+ val stickyNotesSettings =
+ config.getAs[StickyNotesSettings]("stickyNotesSettings").getOrElse(StickyNotesSettings.default)
+ val redirectAfterArchive = config.getAs[Boolean]("redirectAfterArchive").getOrElse(true)
+ val componentDefinitionExtractionMode = parseComponentDefinitionExtractionMode(config)
FeatureTogglesConfig(
development = isDevelopmentMode,
@@ -76,6 +80,7 @@ object FeatureTogglesConfig extends LazyLogging {
enableConfigEndpoint = enableConfigEndpoint,
redirectAfterArchive = redirectAfterArchive,
componentDefinitionExtractionMode = componentDefinitionExtractionMode,
+ stickyNotesSettings = stickyNotesSettings
)
}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala
index 034df3f48bc..a3e7344c378 100644
--- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala
@@ -11,7 +11,8 @@ trait NuTables
with ScenarioActivityEntityFactory
with ScenarioLabelsEntityFactory
with AttachmentEntityFactory
- with DeploymentEntityFactory {
+ with DeploymentEntityFactory
+ with StickyNotesEntityFactory {
protected val profile: JdbcProfile
}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/StickyNotesEntityFactory.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/StickyNotesEntityFactory.scala
new file mode 100644
index 00000000000..2bff4a1b639
--- /dev/null
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/StickyNotesEntityFactory.scala
@@ -0,0 +1,112 @@
+package pl.touk.nussknacker.ui.db.entity
+
+import pl.touk.nussknacker.engine.api.LayoutData
+import pl.touk.nussknacker.engine.api.process.{ProcessId, VersionId}
+import pl.touk.nussknacker.ui.api.description.stickynotes.StickyNoteEvent
+import pl.touk.nussknacker.ui.api.description.stickynotes.StickyNoteEvent.StickyNoteEvent
+import slick.lifted.{ProvenShape, TableQuery => LTableQuery}
+import slick.sql.SqlProfile.ColumnOption.NotNull
+import io.circe.syntax._
+import io.circe._
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{
+ Dimensions,
+ StickyNote,
+ StickyNoteCorrelationId,
+ StickyNoteId
+}
+
+import java.sql.Timestamp
+import java.util.UUID
+
+trait StickyNotesEntityFactory extends BaseEntityFactory {
+
+ import profile.api._
+
+ val processVersionsTable: LTableQuery[ProcessVersionEntityFactory#ProcessVersionEntity]
+
+ class StickyNotesEntity(tag: Tag) extends Table[StickyNoteEventEntityData](tag, "sticky_notes") {
+
+ def id = column[StickyNoteId]("id", O.PrimaryKey, O.AutoInc)
+ def noteCorrelationId = column[StickyNoteCorrelationId]("note_correlation_id", NotNull)
+ def content = column[String]("content", NotNull)
+ def layoutData = column[LayoutData]("layout_data", NotNull)
+ def color = column[String]("color", NotNull)
+ def dimensions = column[Dimensions]("dimensions", NotNull)
+ def targetEdge = column[Option[String]]("target_edge")
+ def eventCreator = column[String]("event_creator", NotNull)
+ def eventDate = column[Timestamp]("event_date", NotNull)
+ def eventType = column[StickyNoteEvent]("event_type", NotNull)
+ def scenarioId = column[ProcessId]("scenario_id", NotNull)
+ def scenarioVersionId = column[VersionId]("scenario_version_id", NotNull)
+
+ def * : ProvenShape[StickyNoteEventEntityData] = (
+ id,
+ noteCorrelationId,
+ content,
+ layoutData,
+ color,
+ dimensions,
+ targetEdge,
+ eventCreator,
+ eventDate,
+ eventType,
+ scenarioId,
+ scenarioVersionId
+ ) <> (StickyNoteEventEntityData.apply _ tupled, StickyNoteEventEntityData.unapply)
+
+ def scenarioVersion =
+ foreignKey("sticky_notes_scenario_version_fk", (scenarioId, scenarioVersionId), processVersionsTable)(
+ t => (t.processId, t.id),
+ onUpdate = ForeignKeyAction.Cascade,
+ onDelete = ForeignKeyAction.Cascade
+ )
+
+ }
+
+ implicit def stickyNoteEventColumnTyped: BaseColumnType[StickyNoteEvent] =
+ MappedColumnType.base[StickyNoteEvent, String](_.toString, StickyNoteEvent.withName)
+
+ implicit def stickyNoteIdColumnTyped: BaseColumnType[StickyNoteId] =
+ MappedColumnType.base[StickyNoteId, Long](_.value, StickyNoteId(_))
+ implicit def stickyNoteCorrelationIdColumnTyped: BaseColumnType[StickyNoteCorrelationId] =
+ MappedColumnType.base[StickyNoteCorrelationId, UUID](_.value, StickyNoteCorrelationId(_))
+
+ implicit def layoutDataColumnTyped: BaseColumnType[LayoutData] = MappedColumnType.base[LayoutData, String](
+ _.asJson.noSpaces,
+ jsonStr =>
+ parser.parse(jsonStr).flatMap(Decoder[LayoutData].decodeJson) match {
+ case Right(layoutData) => layoutData
+ case Left(error) => throw error
+ }
+ )
+
+ implicit def dimensionsColumnTyped: BaseColumnType[Dimensions] = MappedColumnType.base[Dimensions, String](
+ _.asJson.noSpaces,
+ jsonStr =>
+ parser.parse(jsonStr).flatMap(Decoder[Dimensions].decodeJson) match {
+ case Right(dimensions) => dimensions
+ case Left(error) => throw error
+ }
+ )
+
+ val stickyNotesTable: LTableQuery[StickyNotesEntityFactory#StickyNotesEntity] = LTableQuery(new StickyNotesEntity(_))
+
+}
+
+final case class StickyNoteEventEntityData(
+ id: StickyNoteId,
+ noteCorrelationId: StickyNoteCorrelationId,
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String],
+ eventCreator: String,
+ eventDate: Timestamp,
+ eventType: StickyNoteEvent,
+ scenarioId: ProcessId,
+ scenarioVersionId: VersionId
+) {
+ def toStickyNote: StickyNote =
+ StickyNote(id, content, layoutData, color, dimensions, targetEdge, eventCreator, eventDate.toInstant)
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/DbStickyNotesRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/DbStickyNotesRepository.scala
new file mode 100644
index 00000000000..8acf546cf28
--- /dev/null
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/DbStickyNotesRepository.scala
@@ -0,0 +1,174 @@
+package pl.touk.nussknacker.ui.process.repository.stickynotes
+
+import com.typesafe.scalalogging.LazyLogging
+import db.util.DBIOActionInstances.DB
+import pl.touk.nussknacker.engine.api.LayoutData
+import pl.touk.nussknacker.engine.api.process.{ProcessId, VersionId}
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{
+ Dimensions,
+ StickyNote,
+ StickyNoteCorrelationId,
+ StickyNoteId
+}
+import pl.touk.nussknacker.ui.api.description.stickynotes.StickyNoteEvent
+import pl.touk.nussknacker.ui.db.entity.StickyNoteEventEntityData
+import pl.touk.nussknacker.ui.db.{DbRef, NuTables}
+import pl.touk.nussknacker.ui.process.repository.DbioRepository
+import pl.touk.nussknacker.ui.security.api.LoggedUser
+import slick.dbio.DBIOAction
+
+import java.sql.Timestamp
+import java.time.Clock
+import java.util.UUID
+import scala.concurrent.ExecutionContext
+
+class DbStickyNotesRepository private (override protected val dbRef: DbRef, override val clock: Clock)(
+ implicit executionContext: ExecutionContext
+) extends DbioRepository
+ with NuTables
+ with StickyNotesRepository
+ with LazyLogging {
+
+ import profile.api._
+
+ override def findStickyNotes(scenarioId: ProcessId, scenarioVersionId: VersionId): DB[Seq[StickyNote]] = {
+ run(
+ stickyNotesTable
+ .filter(event => event.scenarioId === scenarioId && event.scenarioVersionId <= scenarioVersionId)
+ .groupBy(_.noteCorrelationId)
+ .map { case (noteCorrelationId, notes) => (noteCorrelationId, notes.map(_.eventDate).max) }
+ .join(stickyNotesTable)
+ .on { case ((noteCorrelationId, eventDate), event) =>
+ event.noteCorrelationId === noteCorrelationId && event.eventDate === eventDate
+ }
+ .map { case ((_, _), event) => event }
+ .result
+ .map(events => events.filter(_.eventType != StickyNoteEvent.StickyNoteDeleted).map(_.toStickyNote))
+ )
+ }
+
+ override def countStickyNotes(scenarioId: ProcessId, scenarioVersionId: VersionId): DB[Int] = {
+ run(
+ stickyNotesTable
+ .filter(event => event.scenarioId === scenarioId && event.scenarioVersionId <= scenarioVersionId)
+ .groupBy(_.noteCorrelationId)
+ .map { case (noteCorrelationId, notes) => (noteCorrelationId, notes.map(_.eventDate).max) }
+ .join(stickyNotesTable)
+ .on { case ((noteCorrelationId, eventDate), event) =>
+ event.noteCorrelationId === noteCorrelationId && event.eventDate === eventDate
+ }
+ .map { case ((_, _), event) => event }
+ .result
+ .map(events => events.count(_.eventType != StickyNoteEvent.StickyNoteDeleted))
+ )
+ }
+
+ override def findStickyNoteById(
+ noteId: StickyNoteId
+ )(implicit user: LoggedUser): DB[Option[StickyNoteEventEntityData]] = {
+ run(
+ stickyNotesTable
+ .filter(_.id === noteId)
+ .result
+ .headOption
+ )
+ }
+
+ override def addStickyNote(
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String],
+ scenarioId: ProcessId,
+ scenarioVersionId: VersionId
+ )(
+ implicit user: LoggedUser
+ ): DB[StickyNoteCorrelationId] = {
+ val now = Timestamp.from(clock.instant())
+ val entity = StickyNoteEventEntityData(
+ id = StickyNoteId(0), // ignored since id is AutoInc
+ noteCorrelationId = StickyNoteCorrelationId(UUID.randomUUID()),
+ content = content,
+ layoutData = layoutData,
+ color = color,
+ dimensions = dimensions,
+ targetEdge = targetEdge,
+ eventDate = now,
+ eventCreator = user.id,
+ eventType = StickyNoteEvent.StickyNoteCreated,
+ scenarioId = scenarioId,
+ scenarioVersionId = scenarioVersionId
+ )
+ run(stickyNotesTable += entity).flatMap {
+ case 0 => DBIOAction.failed(new IllegalStateException(s"This is odd, no sticky note was added"))
+ case 1 => DBIOAction.successful(entity.noteCorrelationId)
+ case n =>
+ DBIOAction.failed(
+ new IllegalStateException(s"This is odd, more than one sticky note were added (added $n records).")
+ )
+ }
+
+ }
+
+ private def updateStickyNote(id: StickyNoteId, updateAction: StickyNoteEventEntityData => StickyNoteEventEntityData)(
+ implicit user: LoggedUser
+ ): DB[Int] = {
+ run(for {
+ actionResult <- stickyNotesTable.filter(_.id === id).result.headOption.flatMap {
+ case None =>
+ DBIOAction.failed(
+ new NoSuchElementException(s"Trying to update record (id=${id.value}) which is not present in the database")
+ )
+ case Some(latestEvent) =>
+ val newEvent = updateAction(latestEvent)
+ stickyNotesTable += newEvent
+ }
+ } yield actionResult)
+ }
+
+ override def updateStickyNote(
+ noteId: StickyNoteId,
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String],
+ scenarioVersionId: VersionId,
+ )(
+ implicit user: LoggedUser
+ ): DB[Int] = {
+ val now = Timestamp.from(clock.instant())
+ def updateAction(latestEvent: StickyNoteEventEntityData): StickyNoteEventEntityData = latestEvent.copy(
+ eventDate = now,
+ eventCreator = user.id,
+ eventType = StickyNoteEvent.StickyNoteUpdated,
+ content = content,
+ color = color,
+ dimensions = dimensions,
+ targetEdge = targetEdge,
+ layoutData = layoutData,
+ scenarioVersionId = scenarioVersionId
+ )
+ updateStickyNote(noteId, updateAction)
+ }
+
+ override def deleteStickyNote(noteId: StickyNoteId)(implicit user: LoggedUser): DB[Int] = {
+ val now = Timestamp.from(clock.instant())
+ def updateAction(latestEvent: StickyNoteEventEntityData): StickyNoteEventEntityData = latestEvent.copy(
+ eventDate = now,
+ eventCreator = user.id,
+ eventType = StickyNoteEvent.StickyNoteDeleted
+ )
+ updateStickyNote(noteId, updateAction)
+ }
+
+}
+
+object DbStickyNotesRepository {
+
+ def create(dbRef: DbRef, clock: Clock)(
+ implicit executionContext: ExecutionContext,
+ ): StickyNotesRepository = new DbStickyNotesRepository(dbRef, clock)
+
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/StickyNotesRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/StickyNotesRepository.scala
new file mode 100644
index 00000000000..6f762c7ad49
--- /dev/null
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/StickyNotesRepository.scala
@@ -0,0 +1,55 @@
+package pl.touk.nussknacker.ui.process.repository.stickynotes
+
+import db.util.DBIOActionInstances.DB
+import pl.touk.nussknacker.engine.api.LayoutData
+import pl.touk.nussknacker.engine.api.process.{ProcessId, VersionId}
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{
+ Dimensions,
+ StickyNote,
+ StickyNoteCorrelationId,
+ StickyNoteId
+}
+import pl.touk.nussknacker.ui.db.entity.StickyNoteEventEntityData
+import pl.touk.nussknacker.ui.security.api.LoggedUser
+
+import java.time.Clock
+
+trait StickyNotesRepository {
+
+ def clock: Clock
+
+ def findStickyNotes(
+ scenarioId: ProcessId,
+ scenarioVersionId: VersionId
+ ): DB[Seq[StickyNote]]
+
+ def countStickyNotes(
+ scenarioId: ProcessId,
+ scenarioVersionId: VersionId
+ ): DB[Int]
+
+ def addStickyNote(
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String],
+ scenarioId: ProcessId,
+ scenarioVersionId: VersionId
+ )(implicit user: LoggedUser): DB[StickyNoteCorrelationId]
+
+ def updateStickyNote(
+ noteId: StickyNoteId,
+ content: String,
+ layoutData: LayoutData,
+ color: String,
+ dimensions: Dimensions,
+ targetEdge: Option[String],
+ scenarioVersionId: VersionId,
+ )(implicit user: LoggedUser): DB[Int]
+
+ def findStickyNoteById(noteId: StickyNoteId)(implicit user: LoggedUser): DB[Option[StickyNoteEventEntityData]]
+
+ def deleteStickyNote(noteId: StickyNoteId)(implicit user: LoggedUser): DB[Int]
+
+}
diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala
index 7e76f370fb0..ec0da71149b 100644
--- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala
+++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala
@@ -71,6 +71,7 @@ import pl.touk.nussknacker.ui.process.processingtype.provider.ReloadableProcessi
import pl.touk.nussknacker.ui.process.repository._
import pl.touk.nussknacker.ui.process.repository.activities.{DbScenarioActivityRepository, ScenarioActivityRepository}
import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService
+import pl.touk.nussknacker.ui.process.repository.stickynotes.DbStickyNotesRepository
import pl.touk.nussknacker.ui.process.test.{PreliminaryScenarioTestDataSerDe, ScenarioTestService}
import pl.touk.nussknacker.ui.process.version.{ScenarioGraphVersionRepository, ScenarioGraphVersionService}
import pl.touk.nussknacker.ui.processreport.ProcessCounter
@@ -172,6 +173,7 @@ class AkkaHttpBasedRouteProvider(
implicit val implicitDbioRunner: DBIOActionRunner = dbioRunner
val scenarioActivityRepository = DbScenarioActivityRepository.create(dbRef, designerClock)
val actionRepository = DbScenarioActionRepository.create(dbRef, modelBuildInfo)
+ val stickyNotesRepository = DbStickyNotesRepository.create(dbRef, designerClock)
val scenarioLabelsRepository = new ScenarioLabelsRepository(dbRef)
val processRepository = DBFetchingProcessRepository.create(dbRef, actionRepository, scenarioLabelsRepository)
// TODO: get rid of Future based repositories - it is easier to use everywhere one implementation - DBIOAction based which allows transactions handling
@@ -409,6 +411,15 @@ class AkkaHttpBasedRouteProvider(
scenarioService = processService,
)
+ val stickyNotesApiHttpService = new StickyNotesApiHttpService(
+ authManager = authManager,
+ stickyNotesRepository = stickyNotesRepository,
+ scenarioService = processService,
+ scenarioAuthorizer = processAuthorizer,
+ dbioRunner,
+ stickyNotesSettings = featureTogglesConfig.stickyNotesSettings
+ )
+
val scenarioActivityApiHttpService = new ScenarioActivityApiHttpService(
authManager = authManager,
fetchScenarioActivityService = fetchScenarioActivityService,
@@ -601,6 +612,7 @@ class AkkaHttpBasedRouteProvider(
scenarioActivityApiHttpService,
scenarioLabelsApiHttpService,
scenarioParametersHttpService,
+ stickyNotesApiHttpService,
userApiHttpService,
statisticsApiHttpService
)
diff --git a/designer/server/src/test/resources/config/common-designer.conf b/designer/server/src/test/resources/config/common-designer.conf
index 508a1300a82..ce04adefb40 100644
--- a/designer/server/src/test/resources/config/common-designer.conf
+++ b/designer/server/src/test/resources/config/common-designer.conf
@@ -54,6 +54,12 @@ testDataSettings: {
resultsMaxBytes: 50000000
}
+stickyNotesSettings: {
+ maxContentLength: 5000,
+ maxNotesCount: 5,
+ enabled: true
+}
+
scenarioLabelSettings: {
validationRules = [
{
diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala
index 93beb1f8d1e..dd3f02b9294 100644
--- a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala
+++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala
@@ -6,6 +6,8 @@ import pl.touk.nussknacker.test.base.db.WithTestDb
import pl.touk.nussknacker.test.config.WithSimplifiedDesignerConfig
import pl.touk.nussknacker.test.config.WithSimplifiedDesignerConfig.TestCategory
import pl.touk.nussknacker.test.utils.domain.ScenarioHelper
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{StickyNoteAddRequest, StickyNoteCorrelationId}
+import pl.touk.nussknacker.ui.process.repository.ProcessRepository.ProcessUpdated
import scala.concurrent.ExecutionContext.Implicits.global
@@ -39,4 +41,12 @@ trait WithSimplifiedConfigScenarioHelper {
rawScenarioHelper.createSavedScenario(scenario, usedCategory.stringify, isFragment = true)
}
+ def updateScenario(scenarioName: ProcessName, newVersion: CanonicalProcess): ProcessUpdated = {
+ rawScenarioHelper.updateScenario(scenarioName, newVersion)
+ }
+
+ def addStickyNote(scenarioName: ProcessName, request: StickyNoteAddRequest): StickyNoteCorrelationId = {
+ rawScenarioHelper.addStickyNote(scenarioName, request)
+ }
+
}
diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala
index 7f893afe583..91f3ed111fd 100644
--- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala
+++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala
@@ -10,14 +10,20 @@ import pl.touk.nussknacker.engine.management.FlinkStreamingPropertiesConfig
import pl.touk.nussknacker.test.PatientScalaFutures
import pl.touk.nussknacker.test.config.WithSimplifiedDesignerConfig.TestProcessingType.Streaming
import pl.touk.nussknacker.test.mock.TestAdditionalUIConfigProvider
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{StickyNoteAddRequest, StickyNoteCorrelationId}
import pl.touk.nussknacker.ui.db.DbRef
import pl.touk.nussknacker.ui.definition.ScenarioPropertiesConfigFinalizer
import pl.touk.nussknacker.ui.process.NewProcessPreparer
import pl.touk.nussknacker.ui.process.processingtype.ValueWithRestriction
import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider
-import pl.touk.nussknacker.ui.process.repository.ProcessRepository.CreateProcessAction
+import pl.touk.nussknacker.ui.process.repository.ProcessRepository.{
+ CreateProcessAction,
+ ProcessUpdated,
+ UpdateProcessAction
+}
import pl.touk.nussknacker.ui.process.repository._
import pl.touk.nussknacker.ui.process.repository.activities.DbScenarioActivityRepository
+import pl.touk.nussknacker.ui.process.repository.stickynotes.{DbStickyNotesRepository, StickyNotesRepository}
import pl.touk.nussknacker.ui.security.api.{LoggedUser, RealLoggedUser}
import slick.dbio.DBIOAction
@@ -39,6 +45,7 @@ private[test] class ScenarioHelper(dbRef: DbRef, clock: Clock, designerConfig: C
)
private val scenarioLabelsRepository: ScenarioLabelsRepository = new ScenarioLabelsRepository(dbRef)
+ private val stickyNotesRepository: StickyNotesRepository = DbStickyNotesRepository.create(dbRef, clock)
private val writeScenarioRepository: DBProcessRepository = new DBProcessRepository(
dbRef,
@@ -75,6 +82,20 @@ private[test] class ScenarioHelper(dbRef: DbRef, clock: Clock, designerConfig: C
saveAndGetId(scenario, category, isFragment).futureValue
}
+ def updateScenario(
+ scenarioName: ProcessName,
+ newScenario: CanonicalProcess
+ ): ProcessUpdated = {
+ updateAndGetScenarioVersions(scenarioName, newScenario).futureValue
+ }
+
+ def addStickyNote(
+ scenarioName: ProcessName,
+ request: StickyNoteAddRequest
+ ): StickyNoteCorrelationId = {
+ addStickyNoteForScenario(scenarioName, request).futureValue
+ }
+
def createDeployedExampleScenario(scenarioName: ProcessName, category: String, isFragment: Boolean): ProcessId = {
(for {
id <- prepareValidScenario(scenarioName, category, isFragment)
@@ -189,6 +210,44 @@ private[test] class ScenarioHelper(dbRef: DbRef, clock: Clock, designerConfig: C
} yield id
}
+ private def updateAndGetScenarioVersions(
+ scenarioName: ProcessName,
+ newScenario: CanonicalProcess
+ ): Future[ProcessUpdated] = {
+ for {
+ scenarioId <- futureFetchingScenarioRepository.fetchProcessId(scenarioName).map(_.get)
+ action = UpdateProcessAction(
+ scenarioId,
+ newScenario,
+ comment = None,
+ labels = List.empty,
+ increaseVersionWhenJsonNotChanged = true,
+ forwardedUserName = None
+ )
+ processUpdated <- dbioRunner.runInTransaction(writeScenarioRepository.updateProcess(action))
+ } yield processUpdated
+ }
+
+ private def addStickyNoteForScenario(
+ scenarioName: ProcessName,
+ request: StickyNoteAddRequest
+ ): Future[StickyNoteCorrelationId] = {
+ for {
+ scenarioId <- futureFetchingScenarioRepository.fetchProcessId(scenarioName).map(_.get)
+ noteCorrelationId <- dbioRunner.runInTransaction(
+ stickyNotesRepository.addStickyNote(
+ request.content,
+ request.layoutData,
+ request.color,
+ request.dimensions,
+ request.targetEdge,
+ scenarioId,
+ request.scenarioVersionId
+ )
+ )
+ } yield noteCorrelationId
+ }
+
private def mapProcessingTypeDataProvider[T](value: T) = {
ProcessingTypeDataProvider.withEmptyCombinedData(
processingTypeWithCategories.map { case (processingType, _) =>
diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpServiceBusinessSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpServiceBusinessSpec.scala
new file mode 100644
index 00000000000..fcaeffd1ae3
--- /dev/null
+++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpServiceBusinessSpec.scala
@@ -0,0 +1,165 @@
+package pl.touk.nussknacker.ui.api
+
+import io.restassured.RestAssured.`given`
+import io.restassured.module.scala.RestAssuredSupport.AddThenToResponse
+import org.scalatest.freespec.AnyFreeSpecLike
+import pl.touk.nussknacker.engine.api.LayoutData
+import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId}
+import pl.touk.nussknacker.engine.build.ScenarioBuilder
+import pl.touk.nussknacker.test.base.it.{NuItTest, WithSimplifiedConfigScenarioHelper}
+import pl.touk.nussknacker.test.config.{
+ WithBusinessCaseRestAssuredUsersExtensions,
+ WithMockableDeploymentManager,
+ WithSimplifiedDesignerConfig
+}
+import pl.touk.nussknacker.test.{
+ NuRestAssureExtensions,
+ NuRestAssureMatchers,
+ RestAssuredVerboseLoggingIfValidationFails
+}
+import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{Dimensions, StickyNoteAddRequest}
+
+import java.util.UUID
+
+class StickyNotesApiHttpServiceBusinessSpec
+ extends AnyFreeSpecLike
+ with NuItTest
+ with WithSimplifiedDesignerConfig
+ with WithSimplifiedConfigScenarioHelper
+ with WithMockableDeploymentManager
+ with WithBusinessCaseRestAssuredUsersExtensions
+ with NuRestAssureExtensions
+ with NuRestAssureMatchers
+ with RestAssuredVerboseLoggingIfValidationFails {
+
+ private val exampleScenarioName = UUID.randomUUID().toString
+
+ private val exampleScenario = ScenarioBuilder
+ .requestResponse(exampleScenarioName)
+ .source("sourceId", "barSource")
+ .emptySink("sinkId", "barSink")
+
+ private def stickyNoteToAdd(versionId: VersionId, content: String): StickyNoteAddRequest =
+ StickyNoteAddRequest(versionId, content, LayoutData(0, 1), "#aabbcc", Dimensions(300, 200), None)
+
+ "The GET stickyNotes for scenario" - {
+ "return no notes if nothing was created" in {
+ given()
+ .applicationState {
+ createSavedScenario(exampleScenario)
+ }
+ .when()
+ .basicAuthAllPermUser()
+ .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=0")
+ .Then()
+ .statusCode(200)
+ .equalsJsonBody("[]")
+ }
+
+ "return 404 if no scenario with given name exists" in {
+ given()
+ .when()
+ .basicAuthAllPermUser()
+ .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=0")
+ .Then()
+ .statusCode(404)
+ .equalsPlainBody(s"No scenario $exampleScenarioName found")
+ }
+
+ "return zero notes for scenarioVersion=1 if notes were added in scenarioVersion=2" in {
+ given()
+ .applicationState {
+ createSavedScenario(exampleScenario)
+ val updatedProcess = updateScenario(ProcessName(exampleScenarioName), exampleScenario)
+ addStickyNote(ProcessName(exampleScenarioName), stickyNoteToAdd(updatedProcess.newVersion.get, ""))
+ }
+ .when()
+ .basicAuthAllPermUser()
+ .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=1")
+ .Then()
+ .statusCode(200)
+ .equalsJsonBody("[]")
+ }
+
+ "return sticky notes for scenarioVersion=2" in {
+ given()
+ .applicationState {
+ createSavedScenario(exampleScenario)
+ val updatedProcess = updateScenario(ProcessName(exampleScenarioName), exampleScenario)
+ addStickyNote(ProcessName(exampleScenarioName), stickyNoteToAdd(updatedProcess.newVersion.get, "title1"))
+ }
+ .when()
+ .basicAuthAllPermUser()
+ .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=2")
+ .Then()
+ .statusCode(200)
+ .body(
+ matchJsonWithRegexValues(
+ s"""[
+ {
+ "noteId": "${regexes.digitsRegex}",
+ "content": "title1",
+ "layoutData": {
+ "x": 0,
+ "y": 1
+ },
+ "color": "#aabbcc",
+ "dimensions": {
+ "width": 300,
+ "height": 200
+ },
+ "targetEdge": null,
+ "editedBy": "admin",
+ "editedAt": "${regexes.zuluDateRegex}"
+ }
+ ]""".stripMargin
+ )
+ )
+
+ }
+
+ "return sticky notes for scenarioVersion=2 even if more for scenarioVersion=3 were added" in {
+ given()
+ .applicationState {
+ createSavedScenario(exampleScenario)
+ val updatedProcess = updateScenario(ProcessName(exampleScenarioName), exampleScenario)
+ addStickyNote(ProcessName(exampleScenarioName), stickyNoteToAdd(updatedProcess.newVersion.get, "sticky 1"))
+ val updatedProcessOnceMore = updateScenario(ProcessName(exampleScenarioName), exampleScenario)
+ addStickyNote(
+ ProcessName(exampleScenarioName),
+ stickyNoteToAdd(updatedProcessOnceMore.newVersion.get, "sticky 2")
+ )
+ }
+ .when()
+ .basicAuthAllPermUser()
+ .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=2")
+ .Then()
+ .statusCode(200)
+ .body(
+ matchJsonWithRegexValues(
+ s"""[
+ {
+ "noteId": "${regexes.digitsRegex}",
+ "content": "sticky 1",
+ "layoutData": {
+ "x": 0,
+ "y": 1
+ },
+ "color": "#aabbcc",
+ "dimensions": {
+ "width": 300,
+ "height": 200
+ },
+ "targetEdge": null,
+ "editedBy": "admin",
+ "editedAt": "${regexes.zuluDateRegex}"
+ }
+ ]""".stripMargin
+ )
+ )
+
+ }
+
+ }
+
+}
diff --git a/docs-internal/api/nu-designer-openapi.yaml b/docs-internal/api/nu-designer-openapi.yaml
index b20a4e94ff9..849a6c93f88 100644
--- a/docs-internal/api/nu-designer-openapi.yaml
+++ b/docs-internal/api/nu-designer-openapi.yaml
@@ -2452,6 +2452,406 @@ paths:
security:
- {}
- httpAuth: []
+ /api/processes/{scenarioName}/stickyNotes:
+ get:
+ tags:
+ - StickyNotes
+ summary: Returns sticky nodes for given scenario with version
+ operationId: getApiProcessesScenarionameStickynotes
+ parameters:
+ - name: Nu-Impersonate-User-Identity
+ in: header
+ required: false
+ schema:
+ type:
+ - string
+ - 'null'
+ - name: scenarioName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: scenarioVersionId
+ in: query
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: ''
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/StickyNote'
+ examples:
+ Example:
+ summary: List of valid sticky notes returned for scenario
+ value:
+ - noteId: 1
+ content: "##Title \nNote1"
+ layoutData:
+ x: 20
+ y: 30
+ color: '#99aa20'
+ dimensions:
+ width: 300
+ height: 200
+ editedBy: Marco
+ editedAt: '1970-01-21T00:35:36.602Z'
+ - noteId: 2
+ content: "##Title \nNote1"
+ layoutData:
+ x: 20
+ y: 30
+ color: '#99aa20'
+ dimensions:
+ width: 300
+ height: 200
+ editedBy: Marco
+ editedAt: '1970-01-21T00:35:36.602Z'
+ '400':
+ description: 'Invalid value for: header Nu-Impersonate-User-Identity, Invalid
+ value for: query parameter scenarioVersionId'
+ content:
+ text/plain:
+ schema:
+ type: string
+ '401':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ CannotAuthenticateUser:
+ value: The supplied authentication is invalid
+ ImpersonatedUserNotExistsError:
+ value: No impersonated user data found for provided identity
+ '403':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ InsufficientPermission:
+ value: The supplied authentication is not authorized to access this
+ resource
+ ImpersonationMissingPermission:
+ value: The supplied authentication is not authorized to impersonate
+ '404':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ Example:
+ summary: No scenario {scenarioName} found
+ value: No scenario s1 found
+ '501':
+ description: Impersonation is not supported for defined authentication mechanism
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ Example:
+ summary: Cannot authenticate impersonated user as impersonation
+ is not supported by the authentication mechanism
+ value: Provided authentication method does not support impersonation
+ security:
+ - {}
+ - httpAuth: []
+ put:
+ tags:
+ - StickyNotes
+ summary: Updates sticky note with new values
+ operationId: putApiProcessesScenarionameStickynotes
+ parameters:
+ - name: Nu-Impersonate-User-Identity
+ in: header
+ required: false
+ schema:
+ type:
+ - string
+ - 'null'
+ - name: scenarioName
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StickyNoteUpdateRequest'
+ example:
+ noteId: 1
+ scenarioVersionId: 1
+ content: ''
+ layoutData:
+ x: 12
+ y: 33
+ color: '#441022'
+ dimensions:
+ width: 300
+ height: 200
+ required: true
+ responses:
+ '200':
+ description: ''
+ '400':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ Example:
+ summary: Provided note content is too long (5004 characters). Max
+ content length is 5000.
+ value: Provided note content is too long (5004 characters). Max
+ content length is 5000.
+ '401':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ CannotAuthenticateUser:
+ value: The supplied authentication is invalid
+ ImpersonatedUserNotExistsError:
+ value: No impersonated user data found for provided identity
+ '403':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ InsufficientPermission:
+ value: The supplied authentication is not authorized to access this
+ resource
+ ImpersonationMissingPermission:
+ value: The supplied authentication is not authorized to impersonate
+ '404':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ anyOf:
+ - type: string
+ - type: string
+ examples:
+ Example:
+ summary: 'No sticky note with id: 3 was found'
+ value: 'No sticky note with id: StickyNoteId(3) was found'
+ '501':
+ description: Impersonation is not supported for defined authentication mechanism
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ Example:
+ summary: Cannot authenticate impersonated user as impersonation
+ is not supported by the authentication mechanism
+ value: Provided authentication method does not support impersonation
+ security:
+ - {}
+ - httpAuth: []
+ post:
+ tags:
+ - StickyNotes
+ summary: Creates new sticky note with given content
+ operationId: postApiProcessesScenarionameStickynotes
+ parameters:
+ - name: Nu-Impersonate-User-Identity
+ in: header
+ required: false
+ schema:
+ type:
+ - string
+ - 'null'
+ - name: scenarioName
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StickyNoteAddRequest'
+ example:
+ scenarioVersionId: 1
+ content: ''
+ layoutData:
+ x: 12
+ y: 33
+ color: '#441022'
+ dimensions:
+ width: 300
+ height: 200
+ required: true
+ responses:
+ '200':
+ description: ''
+ content:
+ application/json:
+ schema:
+ type: string
+ format: uuid
+ '400':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ anyOf:
+ - type: string
+ - type: string
+ examples:
+ Example:
+ summary: 'Cannot add another sticky note, since max number of sticky
+ notes was reached: 5 (see configuration).'
+ value: 'Cannot add another sticky note, since max number of sticky
+ notes was reached: 5 (see configuration).'
+ '401':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ CannotAuthenticateUser:
+ value: The supplied authentication is invalid
+ ImpersonatedUserNotExistsError:
+ value: No impersonated user data found for provided identity
+ '403':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ InsufficientPermission:
+ value: The supplied authentication is not authorized to access this
+ resource
+ ImpersonationMissingPermission:
+ value: The supplied authentication is not authorized to impersonate
+ '404':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ Example:
+ summary: No scenario {scenarioName} found
+ value: No scenario s1 found
+ '501':
+ description: Impersonation is not supported for defined authentication mechanism
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ Example:
+ summary: Cannot authenticate impersonated user as impersonation
+ is not supported by the authentication mechanism
+ value: Provided authentication method does not support impersonation
+ security:
+ - {}
+ - httpAuth: []
+ /api/processes/{scenarioName}/stickyNotes/{noteId}:
+ delete:
+ tags:
+ - StickyNotes
+ summary: Deletes stickyNote by given noteId
+ operationId: deleteApiProcessesScenarionameStickynotesNoteid
+ parameters:
+ - name: Nu-Impersonate-User-Identity
+ in: header
+ required: false
+ schema:
+ type:
+ - string
+ - 'null'
+ - name: scenarioName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: noteId
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: ''
+ '400':
+ description: 'Invalid value for: header Nu-Impersonate-User-Identity, Invalid
+ value for: path parameter noteId'
+ content:
+ text/plain:
+ schema:
+ type: string
+ '401':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ CannotAuthenticateUser:
+ value: The supplied authentication is invalid
+ ImpersonatedUserNotExistsError:
+ value: No impersonated user data found for provided identity
+ '403':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ InsufficientPermission:
+ value: The supplied authentication is not authorized to access this
+ resource
+ ImpersonationMissingPermission:
+ value: The supplied authentication is not authorized to impersonate
+ '404':
+ description: ''
+ content:
+ text/plain:
+ schema:
+ anyOf:
+ - type: string
+ - type: string
+ examples:
+ Example:
+ summary: No scenario {scenarioName} found
+ value: No scenario s1 found
+ '501':
+ description: Impersonation is not supported for defined authentication mechanism
+ content:
+ text/plain:
+ schema:
+ type: string
+ examples:
+ Example:
+ summary: Cannot authenticate impersonated user as impersonation
+ is not supported by the authentication mechanism
+ value: Provided authentication method does not support impersonation
+ security:
+ - {}
+ - httpAuth: []
/api/scenarioTesting/{scenarioName}/adhoc/validate:
post:
tags:
@@ -4987,6 +5387,19 @@ components:
properties:
dictId:
type: string
+ Dimensions:
+ title: Dimensions
+ type: object
+ required:
+ - width
+ - height
+ properties:
+ width:
+ type: integer
+ format: int64
+ height:
+ type: integer
+ format: int64
DisplayableUser:
title: DisplayableUser
type: object
@@ -6664,6 +7077,92 @@ components:
type: string
enum:
- ERROR
+ StickyNote:
+ title: StickyNote
+ type: object
+ required:
+ - noteId
+ - content
+ - layoutData
+ - color
+ - dimensions
+ - editedBy
+ - editedAt
+ properties:
+ noteId:
+ type: integer
+ format: int64
+ content:
+ type: string
+ layoutData:
+ $ref: '#/components/schemas/LayoutData'
+ color:
+ type: string
+ dimensions:
+ $ref: '#/components/schemas/Dimensions'
+ targetEdge:
+ type:
+ - string
+ - 'null'
+ editedBy:
+ type: string
+ editedAt:
+ type: string
+ format: date-time
+ StickyNoteAddRequest:
+ title: StickyNoteAddRequest
+ type: object
+ required:
+ - scenarioVersionId
+ - content
+ - layoutData
+ - color
+ - dimensions
+ properties:
+ scenarioVersionId:
+ type: integer
+ format: int64
+ content:
+ type: string
+ layoutData:
+ $ref: '#/components/schemas/LayoutData'
+ color:
+ type: string
+ dimensions:
+ $ref: '#/components/schemas/Dimensions'
+ targetEdge:
+ type:
+ - string
+ - 'null'
+ StickyNoteUpdateRequest:
+ title: StickyNoteUpdateRequest
+ type: object
+ required:
+ - noteId
+ - scenarioVersionId
+ - content
+ - layoutData
+ - color
+ - dimensions
+ properties:
+ noteId:
+ type: integer
+ format: int64
+ scenarioVersionId:
+ type: integer
+ format: int64
+ content:
+ type: string
+ layoutData:
+ $ref: '#/components/schemas/LayoutData'
+ color:
+ type: string
+ dimensions:
+ $ref: '#/components/schemas/Dimensions'
+ targetEdge:
+ type:
+ - string
+ - 'null'
StringParameterEditor:
title: StringParameterEditor
type: object
diff --git a/docs/Changelog.md b/docs/Changelog.md
index 67927213ae4..b4f42020bd6 100644
--- a/docs/Changelog.md
+++ b/docs/Changelog.md
@@ -10,6 +10,10 @@
### 1.19.0 (Not released yet)
+* [#7181](https://github.com/TouK/nussknacker/pull/7181) StickyNotes feature
+ * sticky notes are designed to store information inside scenario/fragment, they are separate from graph nodes and do not take part in scenario logic
+ * new API available under `processes/{scenarioName}/stickyNotes`
+ * configuration `stickyNotesSettings` allowing to hide/show stickyNotes, set sticky notes max content length or its max number on a graph
* [#7145](https://github.com/TouK/nussknacker/pull/7145) Lift TypingResult information for dictionaries
* [#7116](https://github.com/TouK/nussknacker/pull/7116) Improve missing Flink Kafka Source / Sink TypeInformation
* [#7123](https://github.com/TouK/nussknacker/pull/7123) Fix deployments for scenarios with dict editors after model reload
diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md
index ac47ca0fbd2..fd2975f560e 100644
--- a/docs/MigrationGuide.md
+++ b/docs/MigrationGuide.md
@@ -4,6 +4,15 @@ To see the biggest differences please consult the [changelog](Changelog.md).
## In version 1.19.0 (Not released yet)
+
+### Configuration changes
+
+* [#7181](https://github.com/TouK/nussknacker/pull/7181) Added designer configuration: stickyNotesSettings
+ * maxContentLength - max length of a sticky notes content (characters)
+ * maxNotesCount - max count of sticky notes inside one scenario/fragment
+ * enabled - if set to false stickyNotes feature is disabled, stickyNotes cant be created, they are also not loaded to graph
+
+
### Other changes
* [#7116](https://github.com/TouK/nussknacker/pull/7116) Improve missing Flink Kafka Source / Sink TypeInformation