diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e2cd20..38fc900e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,16 @@ ## [2.3.0](https://github.com/equinor/flyt/compare/v2.2.0...v2.3.0) (2024-06-18) - ### Features -* Click to rename project title ([#489](https://github.com/equinor/flyt/issues/489)) ([d74062f](https://github.com/equinor/flyt/commit/d74062fe1256dbdba4863e9041f0397df908ade1)) -* Edge labels ([#743](https://github.com/equinor/flyt/issues/743)) ([cae915c](https://github.com/equinor/flyt/commit/cae915ce4a9a7e69c1b894854d3997eb58c9c3ed)) - +- Click to rename project title ([#489](https://github.com/equinor/flyt/issues/489)) ([d74062f](https://github.com/equinor/flyt/commit/d74062fe1256dbdba4863e9041f0397df908ade1)) +- Edge labels ([#743](https://github.com/equinor/flyt/issues/743)) ([cae915c](https://github.com/equinor/flyt/commit/cae915ce4a9a7e69c1b894854d3997eb58c9c3ed)) ### Bug Fixes -* close scrim, fix ts errors ([#764](https://github.com/equinor/flyt/issues/764)) ([00ed2b6](https://github.com/equinor/flyt/commit/00ed2b62f2ca723335df7bf29a7c4180b228b55c)) -* edges no longer disappearing when creating hidden nodes ([#761](https://github.com/equinor/flyt/issues/761)) ([0f8ec0e](https://github.com/equinor/flyt/commit/0f8ec0ebad2f558b89c36f31a65750916d0562c4)) -* Remove duration bug ([#753](https://github.com/equinor/flyt/issues/753)) ([4edc93d](https://github.com/equinor/flyt/commit/4edc93db75872adbca7d98089487c35ac308f615)) +- close scrim, fix ts errors ([#764](https://github.com/equinor/flyt/issues/764)) ([00ed2b6](https://github.com/equinor/flyt/commit/00ed2b62f2ca723335df7bf29a7c4180b228b55c)) +- edges no longer disappearing when creating hidden nodes ([#761](https://github.com/equinor/flyt/issues/761)) ([0f8ec0e](https://github.com/equinor/flyt/commit/0f8ec0ebad2f558b89c36f31a65750916d0562c4)) +- Remove duration bug ([#753](https://github.com/equinor/flyt/issues/753)) ([4edc93d](https://github.com/equinor/flyt/commit/4edc93db75872adbca7d98089487c35ac308f615)) ## [2.2.0](https://github.com/equinor/flyt/compare/v2.1.0...v2.2.0) (2024-06-03) diff --git a/components/DeleteNodeDialog.tsx b/components/DeleteNodeDialog.tsx index 3c0f2c79..3f9ef951 100644 --- a/components/DeleteNodeDialog.tsx +++ b/components/DeleteNodeDialog.tsx @@ -1,16 +1,14 @@ -import { Button, Icon, Scrim, Typography } from "@equinor/eds-core-react"; -import { close as closeIcon, delete_forever } from "@equinor/eds-icons"; import { useAccount, useMsal } from "@azure/msal-react"; import { useMutation, useQueryClient } from "react-query"; import { getNodeTypeName } from "@/utils/getNodeTypeName"; import { notifyOthers } from "@/services/notifyOthers"; -import styles from "../layouts/default.layout.module.scss"; import { unknownErrorToString } from "@/utils/isError"; import { useStoreDispatch } from "hooks/storeHooks"; import { NodeDataApi } from "@/types/NodeDataApi"; import { NodeTypes } from "@/types/NodeTypes"; import { deleteVertice } from "services/graphApi"; import { useProjectId } from "@/hooks/useProjectId"; +import { ScrimDelete } from "./ScrimDelete"; export function DeleteNodeDialog(props: { objectToDelete: NodeDataApi; @@ -59,8 +57,8 @@ export function DeleteNodeDialog(props: { const { type } = props.objectToDelete; - const header = `Delete "${getNodeTypeName(type).toLowerCase()}"`; - let warningMessage = "This will delete the selected object."; + const header = `Delete ${getNodeTypeName(type).toLowerCase()}`; + let warningMessage = "This will delete the selected card."; if (type === mainActivity) { warningMessage = "This will delete everything under it.\nAre you sure you want to proceed?"; @@ -70,39 +68,16 @@ export function DeleteNodeDialog(props: { } const confirmMessage = "Delete"; return ( - - - {deleteMutation.isLoading ? ( - Deleting... - ) : ( - <> - - {header} - - - - - - {deleteMutation.error && ( - - {unknownErrorToString(deleteMutation.error)} - - )} - {warningMessage} - - - - - {confirmMessage} - - - > - )} - - + ); } diff --git a/components/ScrimDelete.module.scss b/components/ScrimDelete.module.scss new file mode 100644 index 00000000..18b920c8 --- /dev/null +++ b/components/ScrimDelete.module.scss @@ -0,0 +1,40 @@ +@import "../styles/variables"; + +.scrimHeaderWrapper { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: solid 1px #dcdcdc; +} + +.scrimTitle { + padding: 12px 18px; +} + +.scrimWrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + max-width: 80vw; + max-height: 80vh; + background-color: white; + border-radius: 2px; +} + +.scrimContent { + padding: 18px; +} + +.buttonsGroup { + display: flex; + justify-content: space-between; + padding: 18px; +} + +.loaderContainer { + display: flex; + align-items: center; + flex-direction: column; + gap: 15px; + padding: 50px; +} diff --git a/components/ScrimDelete.tsx b/components/ScrimDelete.tsx new file mode 100644 index 00000000..06c6c44b --- /dev/null +++ b/components/ScrimDelete.tsx @@ -0,0 +1,74 @@ +import { Button, Icon, Scrim, Typography } from "@equinor/eds-core-react"; +import styles from "./ScrimDelete.module.scss"; +import { close as closeIcon, delete_forever } from "@equinor/eds-icons"; +import { unknownErrorToString } from "@/utils/isError"; +import { CircularProgress } from "@equinor/eds-core-react"; + +type ScrimDelete = { + id: string; + open: boolean; + onConfirm: (id: string) => void; + onClose: () => void; + header?: string; + warningMessage?: string; + confirmMessage?: string; + error?: unknown; + isLoading?: boolean; +}; + +export const ScrimDelete = ({ + id, + open, + onConfirm, + onClose, + header, + warningMessage, + confirmMessage, + error, + isLoading, +}: ScrimDelete) => { + return ( + + + {isLoading ? ( + + + Deleting... + + ) : ( + <> + + + {header} + + + + + + + {!!error && ( + + {unknownErrorToString(error)} + + )} + {warningMessage} + + + onClose()}> + {"Cancel"} + + onConfirm(id)} + > + + {confirmMessage} + + + > + )} + + + ); +}; diff --git a/components/canvas/Canvas.tsx b/components/canvas/Canvas.tsx index e912465e..5d3a4336 100644 --- a/components/canvas/Canvas.tsx +++ b/components/canvas/Canvas.tsx @@ -33,11 +33,13 @@ import { useNodeMerge } from "./hooks/useNodeMerge"; import { useWebSocket } from "./hooks/useWebSocket"; import { getQIPRContainerWidth } from "./utils/getQIPRContainerWidth"; import { useProjectId } from "@/hooks/useProjectId"; +import { useEdgeDelete } from "./hooks/useEdgeDelete"; +import { ScrimDelete } from "../ScrimDelete"; import { MiniMapCustom } from "@/components/canvas/MiniMapCustom"; -import { EdgeDataApi } from "@/types/EdgeDataApi"; import { ZoomLevel } from "@/components/canvas/ZoomLevel"; import { edgeElementTypes } from "@/components/canvas/EdgeElementTypes"; import { createHiddenNodes } from "@/components/canvas/utils/createHiddenNodes"; +import { createEdges } from "./utils/createEdges"; type CanvasProps = { graph: Graph; @@ -60,29 +62,23 @@ const Canvas = ({ new Date("2024-04-24T00:08:00.000000Z").getTime(); let tempNodes: Node[] = []; - let tempEdges: Edge[] = []; - apiEdges.map((edge: EdgeDataApi) => { - const nodeSource = apiNodes.filter((node) => node.id === edge.source); - if (nodeSource[0] && nodeSource[0].type === NodeTypes.choice) { - tempEdges.push({ - ...edge, - type: "choice", - label: edge.edgeValue, - }); - } else { - tempEdges.push({ ...edge }); - } - }); + const tempEdges: Edge[] = apiEdges.map((e) => ({ ...e, label: e.edgeValue })); + const [isEditingEdgeText, setIsEditingEdgeText] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [visibleDeleteScrim, setVisibleDeleteScrim] = useState(false); + const [visibleDeleteNodeScrim, setVisibleDeleteNodeScrim] = useState(false); + const [edgeToBeDeletedId, setEdgeToBeDeletedId] = useState< + string | undefined + >(undefined); const [visibleLabelScrim, setVisibleLabelScrim] = useState(false); + const isEditingEdge = isEditingEdgeText || edgeToBeDeletedId; const { onNodeDragStart, onNodeDrag, onNodeDragStop } = useNodeDrag(); const { mutate: mergeNode, merging } = useNodeMerge(); const { mutate: addNode } = useNodeAdd(); + const { deleteEdgeMutation } = useEdgeDelete(); const { socketConnected, socketReason } = useWebSocket(); @@ -145,6 +141,7 @@ const Canvas = ({ type: node.type, height: shapeSize.height, width: shapeSize.width + getQIPRContainerWidth(node.tasks), + deletable: false, }); } else { tempNodes.push({ @@ -153,11 +150,14 @@ const Canvas = ({ ...node, parents: [], columnId: node.id, + shapeHeight: shapeSize.height, + shapeWidth: shapeSize.width, }, position: { x: 0, y: 0 }, type: node.type, height: shapeSize.height, width: shapeSize.width + getQIPRContainerWidth(node.tasks), + deletable: false, }); } @@ -167,6 +167,16 @@ const Canvas = ({ }); }; + const handleSetSelectedEdge = (selectedEdge: Edge | undefined) => { + if (userCanEdit && !isEditingEdge) { + const updatedEdges = edges.map((e) => { + e.selected = e.id === selectedEdge?.id; + return e; + }); + setEdges(updatedEdges); + } + }; + const mergedNodesLooping = new Map, number]>(); let mergedNodesReady: Node[] = []; @@ -237,11 +247,23 @@ const Canvas = ({ } createNodes(root); setNodesDepth(); - const { tempNodes: tempWithHiddenNodes, tempEdges: tempWithHiddenEdges } = - createHiddenNodes(tempNodes, tempEdges, shapeSize); + const { + tempNodes: tempWithHiddenNodes, + tempEdges: tempWithHiddenEdges, + longEdges, + } = createHiddenNodes(tempNodes, tempEdges, shapeSize); const finalNodes = setLayout(tempWithHiddenNodes, tempWithHiddenEdges); + const finalEdges = createEdges( + finalNodes, + tempWithHiddenEdges, + longEdges, + shapeSize, + userCanEdit, + setIsEditingEdgeText, + setEdgeToBeDeletedId + ); setNodes(finalNodes); - setEdges(tempWithHiddenEdges); + setEdges(finalEdges); }, [apiNodes, apiEdges, userCanEdit]); useCenterCanvas(); @@ -272,16 +294,38 @@ const Canvas = ({ {selectedNode && ( { - setVisibleDeleteScrim(false); + setVisibleDeleteNodeScrim(false); setSelectedNode(undefined); }} /> )} + {edgeToBeDeletedId && ( + { + deleteEdgeMutation.mutate( + { edgeId: id }, + { + onSuccess() { + setEdgeToBeDeletedId(undefined); + }, + } + ); + }} + onClose={() => setEdgeToBeDeletedId(undefined)} + header={"Delete line"} + warningMessage={"Are you sure you want to delete this line?"} + confirmMessage={"Delete"} + isLoading={deleteEdgeMutation.isLoading} + error={deleteEdgeMutation.error} + /> + )} setSelectedNode(undefined)} - onDelete={() => setVisibleDeleteScrim(true)} + onDelete={() => setVisibleDeleteNodeScrim(true)} canEdit={userCanEdit} selectedNode={selectedNode} /> @@ -300,6 +344,10 @@ const Canvas = ({ onNodeDragStart={onNodeDragStart} onNodeDrag={onNodeDrag} onNodeDragStop={onNodeDragStop} + elevateEdgesOnSelect={true} + edgesFocusable={userCanEdit} + onEdgeMouseEnter={(event, edge) => handleSetSelectedEdge(edge)} + onEdgeMouseLeave={() => handleSetSelectedEdge(undefined)} attributionPosition="bottom-right" connectionRadius={100} > diff --git a/components/canvas/CanvasTutorial/CanvasTutorial.tsx b/components/canvas/CanvasTutorial/CanvasTutorial.tsx index 1fad93f0..70e17b6e 100644 --- a/components/canvas/CanvasTutorial/CanvasTutorial.tsx +++ b/components/canvas/CanvasTutorial/CanvasTutorial.tsx @@ -5,6 +5,8 @@ import addNewSubActivity from "../../../public/CanvasTutorial/add-new-sub-activi import addNewChoice from "../../../public/CanvasTutorial/add-new-choice.gif"; import addNewWait from "../../../public/CanvasTutorial/add-new-wait.gif"; import mergeActivities from "../../../public/CanvasTutorial/merge-activities.gif"; +import renameEdge from "../../../public/CanvasTutorial/rename-edge.gif"; +import deleteEdge from "../../../public/CanvasTutorial/delete-edge.gif"; import mainActivity from "../../../public/CanvasTutorial/main-activity.svg"; import subActivity from "../../../public/CanvasTutorial/sub-activity.svg"; import choice from "../../../public/CanvasTutorial/choice.svg"; @@ -94,12 +96,31 @@ export const CanvasTutorial = () => { containerRef={refs["merge-activities"]} title="Merge activities" image={mergeActivities} - style={{ marginBottom: "32px" }} > Hover over the merge circle of a card at the end of a choice path.{" "} Drag and drop it onto the card you wish to merge into. + + Hover over a editable line. Look for the pencil icon to appear. + Click the edit button to start writing. Once you have finished, + click outside the text area to save your changes. Editable lines + can be found directly beneath a choice card. + + + Hover over a removable line. Click the remove button to remove the + line. Removeable lines are only found above a subactivity that has + multiple parent connections. + diff --git a/components/canvas/CanvasTutorial/CanvasTutorialButtonGroup.tsx b/components/canvas/CanvasTutorial/CanvasTutorialButtonGroup.tsx index ac4a2a9e..91fad6c1 100644 --- a/components/canvas/CanvasTutorial/CanvasTutorialButtonGroup.tsx +++ b/components/canvas/CanvasTutorial/CanvasTutorialButtonGroup.tsx @@ -11,6 +11,8 @@ const buttons: Omit[] = [ { section: "add-new-wait", title: "Wait" }, { section: "add-new-choice", title: "Choice" }, { section: "merge-activities", title: "Merge" }, + { section: "rename-edge", title: "Write on lines" }, + { section: "delete-edge", title: "Remove lines" }, ]; type CanvasTutorialButtonGroupProps = { diff --git a/components/canvas/CanvasTutorial/hooks/useCanvasTutorial.tsx b/components/canvas/CanvasTutorial/hooks/useCanvasTutorial.tsx index 0a8b626b..f959952b 100644 --- a/components/canvas/CanvasTutorial/hooks/useCanvasTutorial.tsx +++ b/components/canvas/CanvasTutorial/hooks/useCanvasTutorial.tsx @@ -8,6 +8,8 @@ const sectionQueryValues = [ "add-new-wait", "add-new-choice", "merge-activities", + "rename-edge", + "delete-edge", ] as const; export type SectionQueryValue = (typeof sectionQueryValues)[number]; @@ -51,6 +53,8 @@ const useSectionRefs = () => { const addNewWaitRef = useRef(null); const addNewChoiceRef = useRef(null); const mergeActivitiesRef = useRef(null); + const renameEdgeRef = useRef(null); + const deleteEdgeRef = useRef(null); const refs: { [key in SectionQueryValue]: RefObject } = { "add-new-main-activity": addNewMainActivityRef, @@ -58,6 +62,8 @@ const useSectionRefs = () => { "add-new-wait": addNewWaitRef, "add-new-choice": addNewChoiceRef, "merge-activities": mergeActivitiesRef, + "rename-edge": renameEdgeRef, + "delete-edge": deleteEdgeRef, }; return refs; diff --git a/components/canvas/ChoiceEdge.module.scss b/components/canvas/ChoiceEdge.module.scss deleted file mode 100644 index dd35af13..00000000 --- a/components/canvas/ChoiceEdge.module.scss +++ /dev/null @@ -1,28 +0,0 @@ -@import "styles/_variables.scss"; - -.choice-edge { - path { - color: #fff; - } -} - -.editButton { - box-shadow: 0 0 8px rgba(0, 0, 0, 0.14); - background: $white !important; -} - -.editButton:hover { - background: $equinor_HOVER-ALT !important; -} - -.textarea { - background: $canvas; - outline: none; - border: none; - border-radius: 4px; - height: auto; - width: 90px; - text-align: center; - resize: none; - cursor: pointer; -} diff --git a/components/canvas/ChoiceEdge.tsx b/components/canvas/ChoiceEdge.tsx deleted file mode 100644 index 68c39d1f..00000000 --- a/components/canvas/ChoiceEdge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - BaseEdge, - EdgeLabelRenderer, - EdgeProps, - getBezierPath, -} from "reactflow"; -import { EdgeLabel } from "@/components/canvas/ChoiceEdgeLabel"; - -export function ChoiceEdge({ - id, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - markerEnd, - label, - selected, -}: EdgeProps) { - const [edgePath, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - }); - - return ( - <> - - - - - - - > - ); -} diff --git a/components/canvas/ChoiceNode.tsx b/components/canvas/ChoiceNode.tsx index 65d472ca..20208bd4 100644 --- a/components/canvas/ChoiceNode.tsx +++ b/components/canvas/ChoiceNode.tsx @@ -48,7 +48,7 @@ export const ChoiceNode = ({ }, [dragging, connectionNodeId]); const renderNodeButtons = () => { - if (userCanEdit && hovering && !merging) { + if (userCanEdit && hovering && !merging && handleClickAddNode) { return ( <> @@ -79,7 +79,7 @@ export const ChoiceNode = ({ ) } /> - {mergeable && ( + {mergeable && handleMerge && ( handleMerge(e.source, e.target)} /> diff --git a/components/canvas/CustomEdge.tsx b/components/canvas/CustomEdge.tsx new file mode 100644 index 00000000..74e4fd9e --- /dev/null +++ b/components/canvas/CustomEdge.tsx @@ -0,0 +1,78 @@ +import { Button, Icon } from "@equinor/eds-core-react"; +import { close_circle_outlined } from "@equinor/eds-icons"; +import { BaseEdge, EdgeLabelRenderer, EdgeProps } from "reactflow"; +import styles from "./Edge.module.scss"; +import { getSvgStraightLineData } from "./drawSvgPath"; +import { EdgeLabel } from "./EdgeLabel"; +import colors from "@/theme/colors"; +import { useEffect, useState } from "react"; + +export const CustomEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + label, + selected, + interactionWidth, + data, +}: EdgeProps) => { + const points = [ + { x: sourceX, y: sourceY }, + { x: targetX, y: targetY }, + ]; + + data?.points && points.splice(1, 0, ...data.points); + + const [edgePath, labelX, labelY] = getSvgStraightLineData(points); + const [isEditingText, setIsEditingText] = useState(false); + + useEffect(() => { + data?.setIsEditingText(isEditingText); + }, [isEditingText]); + + return ( + <> + + + + + {selected && + data?.userCanEdit && + data?.onDelete && + !isEditingText && ( + data?.onDelete(id)}> + + + )} + + + > + ); +}; diff --git a/components/canvas/Edge.module.scss b/components/canvas/Edge.module.scss new file mode 100644 index 00000000..425d4773 --- /dev/null +++ b/components/canvas/Edge.module.scss @@ -0,0 +1,8 @@ +@import "styles/_variables.scss"; + +.edge-button-container { + position: absolute; + pointer-events: all; + border-radius: 50%; + background-color: $canvas; +} diff --git a/components/canvas/EdgeElementTypes.tsx b/components/canvas/EdgeElementTypes.tsx index f7e91f75..768d96a9 100644 --- a/components/canvas/EdgeElementTypes.tsx +++ b/components/canvas/EdgeElementTypes.tsx @@ -1,6 +1,6 @@ import { EdgeTypes } from "reactflow"; -import { ChoiceEdge } from "@/components/canvas/ChoiceEdge"; +import { CustomEdge } from "./CustomEdge"; export const edgeElementTypes: EdgeTypes = { - choice: ChoiceEdge, + custom: CustomEdge, }; diff --git a/components/canvas/EdgeLabel.module.scss b/components/canvas/EdgeLabel.module.scss new file mode 100644 index 00000000..b70df555 --- /dev/null +++ b/components/canvas/EdgeLabel.module.scss @@ -0,0 +1,12 @@ +@import "styles/_variables.scss"; + +.textarea { + background: $canvas; + outline: none; + border: none; + border-radius: 4px; + height: auto; + width: 90px; + text-align: center; + resize: none; +} diff --git a/components/canvas/ChoiceEdgeLabel.tsx b/components/canvas/EdgeLabel.tsx similarity index 50% rename from components/canvas/ChoiceEdgeLabel.tsx rename to components/canvas/EdgeLabel.tsx index 2b22bba3..007de58f 100644 --- a/components/canvas/ChoiceEdgeLabel.tsx +++ b/components/canvas/EdgeLabel.tsx @@ -2,7 +2,7 @@ import { useReactFlow } from "reactflow"; import { useEffect, useRef, useState } from "react"; import { Button, Icon } from "@equinor/eds-core-react"; import { edit } from "@equinor/eds-icons"; -import styles from "./ChoiceEdge.module.scss"; +import styles from "./EdgeLabel.module.scss"; import { patchEdge } from "@/services/graphApi"; import { useProjectId } from "@/hooks/useProjectId"; @@ -10,12 +10,20 @@ type EdgeLabelProps = { id: string; labelText?: string; selected: boolean; + readOnly: boolean; + setIsEditingText: (e: boolean) => void; }; -export function EdgeLabel({ id, labelText, selected }: EdgeLabelProps) { +export const EdgeLabel = ({ + id, + labelText, + selected, + readOnly, + setIsEditingText, +}: EdgeLabelProps) => { const { setEdges } = useReactFlow(); const { projectId } = useProjectId(); const [value, setValue] = useState(labelText); - const [showInput, setShowInput] = useState(!!value); + const [showInput, setShowInput] = useState(false); const inputRef = useRef(null); const numberOfRows = Math.ceil(Math.max(value?.length ?? 0, 1) / 12); @@ -31,7 +39,10 @@ export function EdgeLabel({ id, labelText, selected }: EdgeLabelProps) { useEffect(() => { const input = inputRef.current; if (!input) { - if (!value || value.length === 0) setShowInput(false); + if (!value || value.length === 0) { + setShowInput(false); + setIsEditingText(false); + } return; } @@ -44,41 +55,41 @@ export function EdgeLabel({ id, labelText, selected }: EdgeLabelProps) { }, [selected, value]); const ButtonComponent = ( - <> - { - setShowInput(true); - setTimeout(() => { - inputRef.current?.focus(); - }, 50); - }} - > - - - > + { + setShowInput(true); + setIsEditingText(true); + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + }} + > + + ); const TextAreaComponent = ( - <> - setValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - } - }} - onBlur={updateLabel} - /> - > + setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + } + }} + onBlur={updateLabel} + onFocus={() => inputRef.current?.select()} + /> ); - if (!value && !selected) return <>>; - return !value && selected && !showInput ? ButtonComponent : TextAreaComponent; -} + if (!value && (!selected || readOnly)) return <>>; + return selected && !showInput && !readOnly + ? ButtonComponent + : TextAreaComponent; +}; diff --git a/components/canvas/GenericNode.tsx b/components/canvas/GenericNode.tsx index 09742294..8efe5838 100644 --- a/components/canvas/GenericNode.tsx +++ b/components/canvas/GenericNode.tsx @@ -45,7 +45,13 @@ export const GenericNode = ({ : type === NodeTypes.output ? Position.Left : undefined; - if (userCanEdit && hovering && !merging && nodeButtonsPosition) { + if ( + userCanEdit && + hovering && + !merging && + nodeButtonsPosition && + handleClickAddNode + ) { return ( { - if (hovering && userCanEdit && !merging) + if (hovering && userCanEdit && !merging && handleClickAddNode) return ( <> diff --git a/components/canvas/NodeCard.tsx b/components/canvas/NodeCard.tsx index 01c0738a..b90f4dd0 100644 --- a/components/canvas/NodeCard.tsx +++ b/components/canvas/NodeCard.tsx @@ -1,7 +1,7 @@ import styles from "./Node.module.scss"; type NodeCard = { - onClick: () => void; + onClick?: () => void; hovering?: boolean; highlighted?: boolean; darkened?: boolean; diff --git a/components/canvas/SubActivityNode.tsx b/components/canvas/SubActivityNode.tsx index e6892160..90439422 100644 --- a/components/canvas/SubActivityNode.tsx +++ b/components/canvas/SubActivityNode.tsx @@ -56,7 +56,7 @@ export const SubActivityNode = ({ }, [dragging, connectionNodeId]); const renderNodeButtons = () => { - if (userCanEdit && hovering && !merging) { + if (userCanEdit && hovering && !merging && handleClickAddNode) { return ( <> @@ -75,7 +75,7 @@ export const SubActivityNode = ({ handleClickAddNode(id, NodeTypes.waiting, Position.Bottom) } /> - {mergeable && ( + {mergeable && handleMerge && ( handleMerge(e.source, e.target)} /> diff --git a/components/canvas/WaitingNode.tsx b/components/canvas/WaitingNode.tsx index ce8363fc..1c55cf48 100644 --- a/components/canvas/WaitingNode.tsx +++ b/components/canvas/WaitingNode.tsx @@ -56,7 +56,7 @@ export const WaitingNode = ({ }, [dragging, connectionNodeId]); const renderNodeButtons = () => { - if (userCanEdit && hovering && !merging) { + if (userCanEdit && hovering && !merging && handleClickAddNode) { return ( <> @@ -75,7 +75,7 @@ export const WaitingNode = ({ handleClickAddNode(id, NodeTypes.waiting, Position.Bottom) } /> - {mergeable && ( + {mergeable && handleMerge && ( handleMerge(e.source, e.target)} /> diff --git a/components/canvas/drawSvgPath.tsx b/components/canvas/drawSvgPath.tsx new file mode 100644 index 00000000..ede531a3 --- /dev/null +++ b/components/canvas/drawSvgPath.tsx @@ -0,0 +1,58 @@ +import type { XYPosition } from "reactflow"; + +export type svgLine = ( + points: XYPosition[] +) => [path: string, labelX: number, labelY: number]; + +/** +Finds the midpoint of a line in order to place the lines label in the correct position. +We traverse each segment (adjecent points) to make sure the midpoint is placed on a line. +If the traversed distance is half of the total distance we return the coordinates. +@param points - A list of the lines start, end and breakpoint XY coordinates +@returns [midPointX, midPointY] - the XY coordinates of the midpoint of the edge +*/ +const findMidpoint = (points: XYPosition[]) => { + let totalDistance = 0; + for (let i = 1; i < points.length; i++) { + const dx = points[i].x - points[i - 1].x; + const dy = points[i].y - points[i - 1].y; + totalDistance += Math.sqrt(dx * dx + dy * dy); + } + + let distanceSoFar = 0; + for (let i = 1; i < points.length; i++) { + const dx = points[i].x - points[i - 1].x; + const dy = points[i].y - points[i - 1].y; + const segmentDistance = Math.sqrt(dx * dx + dy * dy); + + if (distanceSoFar + segmentDistance >= totalDistance / 2) { + const remainingDistance = totalDistance / 2 - distanceSoFar; + const proportion = remainingDistance / segmentDistance; + + const midX = points[i - 1].x + dx * proportion; + const midY = points[i - 1].y + dy * proportion; + + return [midX, midY]; + } + + distanceSoFar += segmentDistance; + } + + const midPointX = (points[0].x + points[points.length - 1].x) / 2; + const midPointY = (points[0].y + points[points.length - 1].y) / 2; + + return [midPointX, midPointY]; +}; + +export const getSvgStraightLineData: svgLine = (points) => { + let path = ""; + + for (let i = 0; i < points.length; i++) { + const { x, y } = points[i]; + path += `${i === 0 ? "M" : "L"} ${x}, ${y} `; + } + + const [labelX, labelY] = findMidpoint(points); + + return [path, labelX, labelY]; +}; diff --git a/components/canvas/hooks/useEdgeDelete.tsx b/components/canvas/hooks/useEdgeDelete.tsx new file mode 100644 index 00000000..630cbc2a --- /dev/null +++ b/components/canvas/hooks/useEdgeDelete.tsx @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from "react-query"; +import { useStoreDispatch } from "../../../hooks/storeHooks"; +import { useProjectId } from "../../../hooks/useProjectId"; +import { deleteEdge } from "../../../services/graphApi"; +import { notifyOthers } from "../../../services/notifyOthers"; +import { unknownErrorToString } from "../../../utils/isError"; +import { useUserAccount } from "./useUserAccount"; + +export type EdgeDeleteParams = { + edgeId: string; +}; + +export const useEdgeDelete = () => { + const { projectId } = useProjectId(); + const dispatch = useStoreDispatch(); + const queryClient = useQueryClient(); + const account = useUserAccount(); + + const deleteEdgeMutation = useMutation( + ({ edgeId }: EdgeDeleteParams) => { + dispatch.setSnackMessage("⏳ Deleting line..."); + return deleteEdge(edgeId, projectId); + }, + { + onSuccess: () => { + dispatch.setSnackMessage("🗑️ Line deleted!"); + notifyOthers("Deleted a line", projectId, account); + queryClient.invalidateQueries(); + }, + onError: (e) => dispatch.setSnackMessage(unknownErrorToString(e)), + } + ); + + return { deleteEdgeMutation }; +}; diff --git a/components/canvas/utils/createEdges.tsx b/components/canvas/utils/createEdges.tsx new file mode 100644 index 00000000..13ed7ab0 --- /dev/null +++ b/components/canvas/utils/createEdges.tsx @@ -0,0 +1,63 @@ +import { EdgeTypes } from "@/types/EdgeTypes"; +import { NodeDataFull } from "@/types/NodeData"; +import { NodeTypes } from "@/types/NodeTypes"; +import { Edge, Node, XYPosition } from "reactflow"; + +const isWritable = (edge: Edge, nodes: Node[]) => { + const sourceNode = nodes.find((n) => n.id === edge.source); + return sourceNode?.type === NodeTypes.choice; +}; + +const isDeletable = (edge: Edge, edges: Edge[]) => + edges.find((e) => e.target === edge.target && e.id !== edge.id); + +const createLongEdges = ( + nodes: Node[], + longEdges: Edge[], + shapeSize: { height: number; width: number } +) => { + return longEdges.map((e) => { + const points: XYPosition[] = []; + e.data?.hiddenNodeTree.forEach((nId: string) => { + const hiddenNode = nodes.find((n) => n.id === nId); + if (hiddenNode) { + points.push({ + x: hiddenNode.position.x + shapeSize.width / 2, + y: hiddenNode.position.y, + }); + points.push({ + x: hiddenNode.position.x + shapeSize.width / 2, + y: hiddenNode.position.y + shapeSize.height, + }); + } + }); + e.data.points = points; + return e; + }); +}; + +export const createEdges = ( + nodes: Node[], + edges: Edge[], + longEdges: Edge[], + shapeSize: { height: number; width: number }, + userCanEdit: boolean, + setIsEditingEdgeText: (arg1: boolean) => void, + setEdgeToBeDeletedId: (arg1: string) => void +) => { + longEdges = createLongEdges(nodes, longEdges, shapeSize); + edges = edges.concat(longEdges); + return edges.map((e) => ({ + ...e, + type: EdgeTypes.custom, + deletable: false, + interactionWidth: 50, + data: { + ...e?.data, + setIsEditingText: (arg1: boolean) => setIsEditingEdgeText(arg1), + userCanEdit: userCanEdit, + writable: isWritable(e, nodes), + onDelete: isDeletable(e, edges) && (() => setEdgeToBeDeletedId(e.id)), + }, + })); +}; diff --git a/components/canvas/utils/createHiddenNodes.ts b/components/canvas/utils/createHiddenNodes.ts index 15911452..3ab5c6e2 100644 --- a/components/canvas/utils/createHiddenNodes.ts +++ b/components/canvas/utils/createHiddenNodes.ts @@ -9,6 +9,8 @@ export const createHiddenNodes = ( shapeSize: { width: number; height: number } ) => { const hiddenNodes: Node[] = []; + const longEdges: Edge[] = []; + tempNodes.forEach((node) => { if (!node.data.parents || node.data.parents.length <= 1) { return; @@ -34,6 +36,9 @@ export const createHiddenNodes = ( if (edge.source === tempParentNode.id && edge.target === node.id) { if (!originalEdge) { originalEdge = edge; + originalEdge.data = { + hiddenNodeTree: [], + }; } return newEdges; } @@ -45,22 +50,19 @@ export const createHiddenNodes = ( let tempParentNodeId = tempParentNode.id; for (let i = tempParentNode.data.depth; i < depthDeepestNode; i++) { const id = uid(); + const typedOriginalEdge = originalEdge as Edge | null; + typedOriginalEdge?.data?.hiddenNodeTree.push(id); hiddenNodes.push(createHiddenNode(id, tempParentNode, i, shapeSize)); - tempParentNode.type === NodeTypes.choice && - i === tempParentNode.data.depth && - originalEdge - ? tempEdges.push(createChoiceEdge(originalEdge, tempParentNodeId, id)) - : tempEdges.push(createNormalEdge(tempParentNodeId, id)); - - tempEdges.push(createStraightEdge(id)); + tempEdges.push(createHiddenEdge(tempParentNodeId, id)); tempParentNodeId = id; } - tempEdges.push(createNormalEdge(tempParentNodeId, node.id)); + tempEdges.push(createHiddenEdge(tempParentNodeId, node.id)); + originalEdge && longEdges.push(originalEdge); }); }); tempNodes = tempNodes.concat(hiddenNodes); - return { tempNodes, tempEdges }; + return { tempNodes, tempEdges, longEdges }; }; // Util function to get the depth of the deepest node parent of the current node iterated over @@ -81,7 +83,6 @@ const findDepthDeepestNode = ( }; // Below are Helper functions for creating the different nodes and edges - const createHiddenNode = ( id: string, parentNode: Node, @@ -105,23 +106,9 @@ const createHiddenNode = ( selectable: false, }); -const createNormalEdge = (parentId: string, id: string) => ({ +const createHiddenEdge = (parentId: string, id: string) => ({ id: `${parentId}=>${id}`, source: parentId, target: id, -}); - -const createStraightEdge = (id: string) => ({ - id: `${id}=>${id}`, - source: id, - target: id, - type: "straight", -}); - -const createChoiceEdge = (edge: Edge, sourceId: string, targetId: string) => ({ - id: edge.id, - source: sourceId, - target: targetId, - type: "choice", - label: edge.label, + hidden: true, }); diff --git a/package.json b/package.json index a40245fc..0a0478b4 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "react-masonry-css": "1.0.16", "react-query": "3.39.3", "react-table": "7.8.0", - "reactflow": "11.7.0", + "reactflow": "11.11.3", "rehype-sanitize": "5.0.1", "remark-gfm": "3.0.1", "sass": "1.64.1", diff --git a/public/CanvasTutorial/delete-edge.gif b/public/CanvasTutorial/delete-edge.gif new file mode 100644 index 00000000..56097f5c Binary files /dev/null and b/public/CanvasTutorial/delete-edge.gif differ diff --git a/public/CanvasTutorial/rename-edge.gif b/public/CanvasTutorial/rename-edge.gif new file mode 100644 index 00000000..d24c69c1 Binary files /dev/null and b/public/CanvasTutorial/rename-edge.gif differ diff --git a/services/graphApi.ts b/services/graphApi.ts index df58ee5e..aa0c8a7b 100644 --- a/services/graphApi.ts +++ b/services/graphApi.ts @@ -102,3 +102,11 @@ export const patchEdge = ( data ).then((r) => r.data); }; + +export const deleteEdge = ( + edgeId: string, + projectId: string +): Promise => + BaseAPIServices.delete(`${baseUrl}/graph/${projectId}/edges/${edgeId}`).then( + (r) => r.data + ); diff --git a/styles/_variables.scss b/styles/_variables.scss index ac733297..61e2edfb 100644 --- a/styles/_variables.scss +++ b/styles/_variables.scss @@ -10,6 +10,8 @@ $equinor_GRAY: #666; $equinor_MEDIUM_GRAY: #c2c2c2; $equinor_LIGHT_GRAY: #f2f2f2; +$canvas: #f7f7f7; + $mint: #defaeb; $white: #fff; $black: #000; diff --git a/theme/colors.ts b/theme/colors.ts index 878f7c99..9b4f4da5 100644 --- a/theme/colors.ts +++ b/theme/colors.ts @@ -5,6 +5,7 @@ export default { EQUINOR_PROMINENT: "#007079", EQUINOR_SECONDARY: "#243746", EQUINOR_DISABLED: "#E6E5E6", + EQUINOR_HOVER: "#004F55", EQUINOR_GRAY: "#666666", EQUINOR_MEDIUM_GRAY: "#C2C2C2", diff --git a/types/EdgeTypes.tsx b/types/EdgeTypes.tsx new file mode 100644 index 00000000..cc33bc73 --- /dev/null +++ b/types/EdgeTypes.tsx @@ -0,0 +1,3 @@ +export enum EdgeTypes { + custom = "custom", +} diff --git a/types/NodeData.ts b/types/NodeData.ts index 8bad55c4..27001b65 100644 --- a/types/NodeData.ts +++ b/types/NodeData.ts @@ -8,9 +8,9 @@ export type NodeData = { isValidDropTarget?: boolean; columnId: string | null; mergeOption?: boolean; - handleMerge: (sourceId: string | null, targetId: string | null) => void; - handleClickNode: () => void; - handleClickAddNode: ( + handleMerge?: (sourceId: string | null, targetId: string | null) => void; + handleClickNode?: () => void; + handleClickAddNode?: ( nodeId: string, type: NodeTypes, position: Position @@ -35,6 +35,8 @@ export type NodeDataHidden = Pick< | "isDropTarget" | "mergeOption" | "isChoiceChild" + | "shapeHeight" + | "shapeWidth" >; export type NodeDataFull = NodeData | NodeDataHidden; diff --git a/yarn.lock b/yarn.lock index 8468c98a..f1bbd97e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -841,39 +841,25 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@reactflow/background@11.2.0": - version "11.2.0" - resolved "https://registry.yarnpkg.com/@reactflow/background/-/background-11.2.0.tgz#2a6f89d4f4837d488629d32a2bd5f01708018115" - integrity sha512-Fd8Few2JsLuE/2GaIM6fkxEBaAJvfzi2Lc106HKi/ddX+dZs8NUsSwMsJy1Ajs8b4GbiX8v8axfKpbK6qFMV8w== +"@reactflow/background@11.3.13": + version "11.3.13" + resolved "https://registry.yarnpkg.com/@reactflow/background/-/background-11.3.13.tgz#a29bcdce01b5e881a330067bfd08c58c12fc7266" + integrity sha512-hkvpVEhgvfTDyCvdlitw4ioKCYLaaiRXnuEG+1QM3Np+7N1DiWF1XOv5I8AFyNoJL07yXEkbECUTsHvkBvcG5A== dependencies: - "@reactflow/core" "11.7.0" + "@reactflow/core" "11.11.3" classcat "^5.0.3" - zustand "^4.3.1" + zustand "^4.4.1" -"@reactflow/controls@11.1.11": - version "11.1.11" - resolved "https://registry.yarnpkg.com/@reactflow/controls/-/controls-11.1.11.tgz#d58e1bd9ddc2ee83fbf96130a7c54f44ca068c09" - integrity sha512-g6WrsszhNkQjzkJ9HbVUBkGGoUy2z8dQVgH6CYQEjuoonD15cWAPGvjyg8vx8oGG7CuktUhWu5JPivL6qjECow== +"@reactflow/controls@11.2.13": + version "11.2.13" + resolved "https://registry.yarnpkg.com/@reactflow/controls/-/controls-11.2.13.tgz#a05d86b82fc49e8ed0ca35d04c838a45f90f4f15" + integrity sha512-3xgEg6ALIVkAQCS4NiBjb7ad8Cb3D8CtA7Vvl4Hf5Ar2PIVs6FOaeft9s2iDZGtsWP35ECDYId1rIFVhQL8r+A== dependencies: - "@reactflow/core" "11.7.0" + "@reactflow/core" "11.11.3" classcat "^5.0.3" + zustand "^4.4.1" -"@reactflow/core@11.7.0": - version "11.7.0" - resolved "https://registry.yarnpkg.com/@reactflow/core/-/core-11.7.0.tgz#6d9bdc0b1de1c9251dd3651135450ab2d42c6562" - integrity sha512-UJcpbNRSupSSoMWh5UmRp6UUr0ug7xVKmMvadnkKKiNi9584q57nz4HMfkqwN3/ESbre7LD043yh2n678d/5FQ== - dependencies: - "@types/d3" "^7.4.0" - "@types/d3-drag" "^3.0.1" - "@types/d3-selection" "^3.0.3" - "@types/d3-zoom" "^3.0.1" - classcat "^5.0.3" - d3-drag "^3.0.0" - d3-selection "^3.0.0" - d3-zoom "^3.0.0" - zustand "^4.3.1" - -"@reactflow/core@^11.6.0": +"@reactflow/core@11.11.3": version "11.11.3" resolved "https://registry.yarnpkg.com/@reactflow/core/-/core-11.11.3.tgz#2cdc0c684931918125d505bec3cb94f115b87747" integrity sha512-+adHdUa7fJSEM93fWfjQwyWXeI92a1eLKwWbIstoCakHpL8UjzwhEh6sn+mN2h/59MlVI7Ehr1iGTt3MsfcIFA== @@ -888,38 +874,38 @@ d3-zoom "^3.0.0" zustand "^4.4.1" -"@reactflow/minimap@11.5.0": - version "11.5.0" - resolved "https://registry.yarnpkg.com/@reactflow/minimap/-/minimap-11.5.0.tgz#ddce263a41c2e65dd2febc09c26e93764ce76bfc" - integrity sha512-n/3tlaknLpi3zaqCC+tDDPvUTOjd6jglto9V3RB1F2wlaUEbCwmuoR2GYTkiRyZMvuskKyAoQW8+0DX0+cWwsA== +"@reactflow/minimap@11.7.13": + version "11.7.13" + resolved "https://registry.yarnpkg.com/@reactflow/minimap/-/minimap-11.7.13.tgz#99396175065a1e2d058b8639883d13c71777764f" + integrity sha512-m2MvdiGSyOu44LEcERDEl1Aj6x//UQRWo3HEAejNU4HQTlJnYrSN8tgrYF8TxC1+c/9UdyzQY5VYgrTwW4QWdg== dependencies: - "@reactflow/core" "11.7.0" + "@reactflow/core" "11.11.3" "@types/d3-selection" "^3.0.3" "@types/d3-zoom" "^3.0.1" classcat "^5.0.3" d3-selection "^3.0.0" d3-zoom "^3.0.0" - zustand "^4.3.1" + zustand "^4.4.1" -"@reactflow/node-resizer@2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@reactflow/node-resizer/-/node-resizer-2.1.0.tgz#7764211a7e00f873eab652937cffba8df7c02b6a" - integrity sha512-DVL8nnWsltP8/iANadAcTaDB4wsEkx2mOLlBEPNE3yc5loSm3u9l5m4enXRcBym61MiMuTtDPzZMyYYQUjuYIg== +"@reactflow/node-resizer@2.2.13": + version "2.2.13" + resolved "https://registry.yarnpkg.com/@reactflow/node-resizer/-/node-resizer-2.2.13.tgz#83faf6e2977f40b528bf100c0da634e08f8fb51a" + integrity sha512-X7ceQ2s3jFLgbkg03n2RYr4hm3jTVrzkW2W/8ANv/SZfuVmF8XJxlERuD8Eka5voKqLda0ywIZGAbw9GoHLfUQ== dependencies: - "@reactflow/core" "^11.6.0" + "@reactflow/core" "11.11.3" classcat "^5.0.4" d3-drag "^3.0.0" d3-selection "^3.0.0" - zustand "^4.3.1" + zustand "^4.4.1" -"@reactflow/node-toolbar@1.1.11": - version "1.1.11" - resolved "https://registry.yarnpkg.com/@reactflow/node-toolbar/-/node-toolbar-1.1.11.tgz#174b235d85de37cffba387af8f6fb315ec1d31d7" - integrity sha512-+hKtx+cvXwfCa9paGxE+G34rWRIIVEh68ZOqAtivClVmfqGzH/sEoGWtIOUyg9OEDNE1nEmZ1NrnpBGSmHHXFg== +"@reactflow/node-toolbar@1.3.13": + version "1.3.13" + resolved "https://registry.yarnpkg.com/@reactflow/node-toolbar/-/node-toolbar-1.3.13.tgz#f15a2e6ed89287a33c4305d3bd7f3991b85db4af" + integrity sha512-aknvNICO10uWdthFSpgD6ctY/CTBeJUMV9co8T9Ilugr08Nb89IQ4uD0dPmr031ewMQxixtYIkw+sSDDzd2aaQ== dependencies: - "@reactflow/core" "11.7.0" + "@reactflow/core" "11.11.3" classcat "^5.0.3" - zustand "^4.3.1" + zustand "^4.4.1" "@sinclair/typebox@^0.27.8": version "0.27.8" @@ -7062,17 +7048,17 @@ react@^18.2.0: dependencies: loose-envify "^1.1.0" -reactflow@11.7.0: - version "11.7.0" - resolved "https://registry.yarnpkg.com/reactflow/-/reactflow-11.7.0.tgz#821642ce9ce4a3a2fa6469053520cb032ff03ef4" - integrity sha512-bjfJV1iQZ+VwIlvsnd4TbXNs6kuJ5ONscud6fNkF8RY/oU2VUZpdjA3q1zwmgnjmJcIhxuBiBI5VLGajYx/Ozg== +reactflow@11.11.3: + version "11.11.3" + resolved "https://registry.yarnpkg.com/reactflow/-/reactflow-11.11.3.tgz#5e8d8b395bd443c6d10d7cef2101866ed185a1e0" + integrity sha512-wusd1Xpn1wgsSEv7UIa4NNraCwH9syBtubBy4xVNXg3b+CDKM+sFaF3hnMx0tr0et4km9urIDdNvwm34QiZong== dependencies: - "@reactflow/background" "11.2.0" - "@reactflow/controls" "11.1.11" - "@reactflow/core" "11.7.0" - "@reactflow/minimap" "11.5.0" - "@reactflow/node-resizer" "2.1.0" - "@reactflow/node-toolbar" "1.1.11" + "@reactflow/background" "11.3.13" + "@reactflow/controls" "11.2.13" + "@reactflow/core" "11.11.3" + "@reactflow/minimap" "11.7.13" + "@reactflow/node-resizer" "2.2.13" + "@reactflow/node-toolbar" "1.3.13" read-pkg-up@^3.0.0: version "3.0.0" @@ -8788,7 +8774,7 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zustand@^4.3.1, zustand@^4.4.1: +zustand@^4.4.1: version "4.5.2" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.2.tgz#fddbe7cac1e71d45413b3682cdb47b48034c3848" integrity sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==