diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png index c5c1c96a2ce..4766a9dfb92 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should add text to note and display it as markdown #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should add text to note and display it as markdown #0.png new file mode 100644 index 00000000000..7cf3a281fd8 Binary files /dev/null and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should add text to note and display it as markdown #0.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should allow to drag sticky note #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should allow to drag sticky note #0.png new file mode 100644 index 00000000000..77404342dfd Binary files /dev/null and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should allow to drag sticky note #0.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should disable sticky note when scenario is not saved #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should disable sticky note when scenario is not saved #0.png new file mode 100644 index 00000000000..e3022e7c644 Binary files /dev/null and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should disable sticky note when scenario is not saved #0.png differ diff --git a/designer/client/cypress/e2e/components.cy.ts b/designer/client/cypress/e2e/components.cy.ts index 88587a19555..cf77c52d321 100644 --- a/designer/client/cypress/e2e/components.cy.ts +++ b/designer/client/cypress/e2e/components.cy.ts @@ -233,7 +233,7 @@ describe("Components list", () => { cy.matchQuery("?TEXT=xxx"); cy.viewport(1600, 500); cy.wait(500); //ensure "loading" mask is hidden - cy.get("#app-container>main").matchImage(); + cy.get("#app-container>main").matchImage({ maxDiffThreshold: 0.01 }); }); it("should allow filtering by processing mode", () => { diff --git a/designer/client/cypress/e2e/creatorToolbar.cy.ts b/designer/client/cypress/e2e/creatorToolbar.cy.ts index 0eb4e53d80e..304f048f375 100644 --- a/designer/client/cypress/e2e/creatorToolbar.cy.ts +++ b/designer/client/cypress/e2e/creatorToolbar.cy.ts @@ -26,6 +26,7 @@ describe("Creator toolbar", () => { cy.contains(/^types$/i).click(); cy.contains(/^services$/i).click(); cy.contains(/^sinks$/i).click(); + cy.contains(/^sticky notes$/i).click(); cy.reload(); cy.get("@toolbar").matchImage(); cy.get("@toolbar").find("input").type("var"); diff --git a/designer/client/cypress/e2e/stickyNotes.cy.ts b/designer/client/cypress/e2e/stickyNotes.cy.ts new file mode 100644 index 00000000000..5a8faef69b0 --- /dev/null +++ b/designer/client/cypress/e2e/stickyNotes.cy.ts @@ -0,0 +1,65 @@ +describe("Sticky notes", () => { + const seed = "stickyNotes"; + + before(() => { + cy.deleteAllTestProcesses({ filter: seed, force: true }); + }); + + beforeEach(() => { + cy.visitNewProcess(seed, "stickyNotes"); + }); + + const screenshotOptions: Cypress.MatchImageOptions = { + screenshotConfig: { clip: { x: 0, y: 0, width: 1400, height: 600 } }, + }; + + it("should allow to drag sticky note", () => { + cy.layoutScenario(); + cy.contains(/^sticky notes$/i) + .should("exist") + .scrollIntoView(); + cy.get("[data-testid='component:sticky note']") + .should("be.visible") + .drag("#nk-graph-main", { + target: { + x: 600, + y: 300, + }, + force: true, + }); + + cy.get("[data-testid=graphPage]").matchImage(screenshotOptions); + }); + + it("should add text to note and display it as markdown", () => { + cy.layoutScenario(); + cy.contains(/^sticky notes$/i) + .should("exist") + .scrollIntoView(); + cy.get("[data-testid='component:sticky note']") + .should("be.visible") + .drag("#nk-graph-main", { + target: { + x: 600, + y: 300, + }, + force: true, + }); + cy.get(".sticky-note-content").dblclick(); + cy.get(".sticky-note-content textarea").type("# Title\n- p1\n- p2\n\n[link](href)"); + cy.get("[model-id='request']").click(); + cy.get("[data-testid=graphPage]").matchImage(screenshotOptions); + }); + + it("should disable sticky note when scenario is not saved", () => { + cy.layoutScenario(); + cy.contains(/^sticky notes$/i) + .should("exist") + .scrollIntoView(); + + cy.dragNode("request", { x: 600, y: 300 }); + + cy.get("[data-testid='component:sticky note']").should("have.class", "tool disabled"); + cy.get("[data-testid=graphPage]").matchImage(screenshotOptions); + }); +}); diff --git a/designer/client/cypress/fixtures/stickyNotes.json b/designer/client/cypress/fixtures/stickyNotes.json new file mode 100644 index 00000000000..d31aa75ff85 --- /dev/null +++ b/designer/client/cypress/fixtures/stickyNotes.json @@ -0,0 +1,58 @@ +{ + "metaData": { + "id": "sticky", + "additionalFields": { + "description": null, + "properties": { + "inputSchema": "{}", + "outputSchema": "{}", + "slug": "sticky" + }, + "metaDataType": "RequestResponseMetaData", + "showDescription": false + } + }, + "nodes": [ + { + "id": "request", + "ref": { + "typ": "request", + "parameters": [] + }, + "additionalFields": { + "description": null, + "layoutData": { + "x": 0, + "y": 0 + } + }, + "type": "Source" + }, + { + "id": "response", + "ref": { + "typ": "response", + "parameters": [ + { + "name": "Raw editor", + "expression": { + "language": "spel", + "expression": "false" + } + } + ] + }, + "endResult": null, + "isDisabled": null, + "additionalFields": { + "description": null, + "layoutData": { + "x": 0, + "y": 180 + } + }, + "type": "Sink" + } + ], + "additionalBranches": [] +} diff --git a/designer/client/src/actions/actionTypes.ts b/designer/client/src/actions/actionTypes.ts index 306aaea65fc..31218cd0e4b 100644 --- a/designer/client/src/actions/actionTypes.ts +++ b/designer/client/src/actions/actionTypes.ts @@ -10,6 +10,8 @@ export type ActionTypes = | "DELETE_NODES" | "NODES_CONNECTED" | "NODES_DISCONNECTED" + | "STICKY_NOTES_UPDATED" + | "STICKY_NOTE_DELETED" | "VALIDATION_RESULT" | "COPY_SELECTION" | "CUT_SELECTION" diff --git a/designer/client/src/actions/nk/assignSettings.ts b/designer/client/src/actions/nk/assignSettings.ts index fb54a22f52b..f26d0dab223 100644 --- a/designer/client/src/actions/nk/assignSettings.ts +++ b/designer/client/src/actions/nk/assignSettings.ts @@ -39,6 +39,13 @@ export type FeaturesSettings = { redirectAfterArchive: boolean; usageStatisticsReports: UsageStatisticsReports; surveySettings: SurveySettings; + stickyNotesSettings: StickyNotesSettings; +}; + +export type StickyNotesSettings = { + maxContentLength: number; + maxNotesCount: number; + enabled: boolean; }; export type TestDataSettings = { diff --git a/designer/client/src/actions/nk/process.ts b/designer/client/src/actions/nk/process.ts index d7209ee2de0..b00dfffe989 100644 --- a/designer/client/src/actions/nk/process.ts +++ b/designer/client/src/actions/nk/process.ts @@ -6,6 +6,9 @@ import { getProcessDefinitionData } from "../../reducers/selectors/settings"; import { ProcessDefinitionData, ScenarioGraph } from "../../types"; import { ThunkAction } from "../reduxTypes"; import HttpService from "./../../http/HttpService"; +import { layoutChanged, Position } from "./ui/layout"; +import { flushSync } from "react-dom"; +import { Dimensions, StickyNote } from "../../common/StickyNote"; export type ScenarioActions = | { type: "CORRECT_INVALID_SCENARIO"; processDefinitionData: ProcessDefinitionData } @@ -17,6 +20,7 @@ export function fetchProcessToDisplay(processName: ProcessName, versionId?: Proc return HttpService.fetchProcessDetails(processName, versionId).then((response) => { dispatch(displayTestCapabilities(processName, response.data.scenarioGraph)); + dispatch(fetchStickyNotesForScenario(processName, response.data.processVersionId)); dispatch({ type: "DISPLAY_PROCESS", scenario: response.data, @@ -56,6 +60,45 @@ export function displayTestCapabilities(processName: ProcessName, scenarioGraph: ); } +const refreshStickyNotes = (dispatch, scenarioName: string, scenarioVersionId: number) => { + return HttpService.getStickyNotes(scenarioName, scenarioVersionId).then((stickyNotes) => { + flushSync(() => { + dispatch({ type: "STICKY_NOTES_UPDATED", stickyNotes: stickyNotes.data }); + dispatch(layoutChanged()); + }); + }); +}; + +export function fetchStickyNotesForScenario(scenarioName: string, scenarioVersionId: number): ThunkAction { + return (dispatch) => refreshStickyNotes(dispatch, scenarioName, scenarioVersionId); +} + +export function stickyNoteUpdated(scenarioName: string, scenarioVersionId: number, stickyNote: StickyNote): ThunkAction { + return (dispatch) => { + HttpService.updateStickyNote(scenarioName, scenarioVersionId, stickyNote).then((_) => { + refreshStickyNotes(dispatch, scenarioName, scenarioVersionId); + }); + }; +} + +export function stickyNoteDeleted(scenarioName: string, stickyNoteId: number): ThunkAction { + return (dispatch) => { + HttpService.deleteStickyNote(scenarioName, stickyNoteId).then(() => { + flushSync(() => { + dispatch({ type: "STICKY_NOTE_DELETED", stickyNoteId }); + }); + }); + }; +} + +export function stickyNoteAdded(scenarioName: string, scenarioVersionId: number, position: Position, dimensions: Dimensions): ThunkAction { + return (dispatch) => { + HttpService.addStickyNote(scenarioName, scenarioVersionId, position, dimensions).then((_) => { + refreshStickyNotes(dispatch, scenarioName, scenarioVersionId); + }); + }; +} + export function displayCurrentProcessVersion(processName: ProcessName) { return fetchProcessToDisplay(processName); } diff --git a/designer/client/src/actions/notificationActions.tsx b/designer/client/src/actions/notificationActions.tsx index bd3250bfef9..44a2beff88b 100644 --- a/designer/client/src/actions/notificationActions.tsx +++ b/designer/client/src/actions/notificationActions.tsx @@ -2,6 +2,7 @@ import React from "react"; import Notifications from "react-notification-system-redux"; import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined"; import Notification from "../components/notifications/Notification"; import { Action } from "./reduxTypes"; @@ -25,3 +26,10 @@ export function info(message: string): Action { children: } message={message} />, }); } + +export function warn(message: string): Action { + return Notifications.warning({ + autoDismiss: 10, + children: } message={message} />, + }); +} diff --git a/designer/client/src/assets/json/nodeAttributes.json b/designer/client/src/assets/json/nodeAttributes.json index 2e18a54a2ff..46910e6201b 100644 --- a/designer/client/src/assets/json/nodeAttributes.json +++ b/designer/client/src/assets/json/nodeAttributes.json @@ -38,6 +38,9 @@ "Aggregate": { "name": "Aggregate" }, + "StickyNote": { + "name": "StickyNote" + }, "CustomNode": { "name": "CustomNode" }, diff --git a/designer/client/src/common/StickyNote.ts b/designer/client/src/common/StickyNote.ts new file mode 100644 index 00000000000..d525b8fd992 --- /dev/null +++ b/designer/client/src/common/StickyNote.ts @@ -0,0 +1,16 @@ +import { LayoutData } from "../types"; + +export type Dimensions = { width: number; height: number }; +export type ColorValueHex = `#${string}`; + +export interface StickyNote { + id?: string; + noteId: number; + content: string; + layoutData: LayoutData; + dimensions: Dimensions; + color: ColorValueHex; + targetEdge?: string; + editedBy: string; + editedAt: string; +} diff --git a/designer/client/src/components/ComponentDragPreview.tsx b/designer/client/src/components/ComponentDragPreview.tsx index 1028f91776b..c927d701401 100644 --- a/designer/client/src/components/ComponentDragPreview.tsx +++ b/designer/client/src/components/ComponentDragPreview.tsx @@ -1,11 +1,13 @@ import { css } from "@emotion/css"; -import React, { forwardRef, useEffect, useMemo, useState } from "react"; +import React, { forwardRef, ReactPortal, useEffect, useMemo, useState } from "react"; import { useDragDropManager, useDragLayer } from "react-dnd"; import { createPortal } from "react-dom"; import { useDebouncedValue } from "rooks"; import { NodeType } from "../types"; import { ComponentPreview } from "./ComponentPreview"; import { DndTypes } from "./toolbars/creator/Tool"; +import { StickyNotePreview } from "./StickyNotePreview"; +import { StickyNoteType } from "../types/stickyNote"; function useNotNull(value: T) { const [current, setCurrent] = useState(() => value); @@ -53,17 +55,22 @@ export const ComponentDragPreview = forwardRef nu return null; } - return createPortal( -
-
- -
-
, - document.body, - ); + function createPortalForPreview(child: JSX.Element): ReactPortal { + return createPortal( +
+
+ {child} +
+
, + document.body, + ); + } + + if (node?.type === StickyNoteType) return createPortalForPreview(); + return createPortalForPreview(); }); diff --git a/designer/client/src/components/ComponentPreview.tsx b/designer/client/src/components/ComponentPreview.tsx index dacb8dd7dae..8053083663a 100644 --- a/designer/client/src/components/ComponentPreview.tsx +++ b/designer/client/src/components/ComponentPreview.tsx @@ -74,6 +74,7 @@ export function ComponentPreview({ node, isActive, isOver }: { node: NodeType; i })); const colors = isOver ? nodeColorsHover : nodeColors; + return (
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 */ ` + +
+ +
${parsed}
+
+
+ `[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