├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── PCF-WebFormStepVisualizer.pcfproj ├── README.md ├── Screenshots ├── WebFormStepVisualizer_v1.mp4 └── webformstepvisualizer_v0_2_0_0.gif ├── Solutions └── WebFormStepsVisualizer │ ├── .gitignore │ ├── WebFormStepsVisualizer.cdsproj │ └── src │ └── Other │ ├── Customizations.xml │ ├── Relationships.xml │ └── Solution.xml ├── WebFormStepsVisualizer ├── Components │ ├── App.tsx │ ├── Buttons │ │ └── AsyncPrimaryButton.tsx │ ├── Flow.tsx │ ├── Forms │ │ ├── CreateForm.tsx │ │ ├── DefaultForm.tsx │ │ └── EditForm.tsx │ ├── Nodes │ │ ├── ConditionDragHandleNode.tsx │ │ ├── DragHandleNode.tsx │ │ └── DragNodeBody.tsx │ ├── Page.tsx │ └── Sidebar.tsx ├── ControlManifest.Input.xml ├── assets │ └── css │ │ └── main.css ├── index.ts └── utils │ ├── Interfaces.ts │ └── SampleData.ts ├── package-lock.json ├── package.json ├── pcfconfig.json └── tsconfig.json /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: 7 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [12.x] 18 | env: 19 | msbuildtarget: Solutions/WebFormStepsVisualizer 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Solution Unique name 30 | id: solution-unique-name 31 | uses: mavrosxristoforos/get-xml-info@1.0 32 | with: 33 | xml-file: ${{ env.msbuildtarget }}/src/Other/Solution.xml 34 | xpath: "//ImportExportXml/SolutionManifest/UniqueName" 35 | 36 | - name: Solution Version 37 | id: solution-version 38 | uses: mavrosxristoforos/get-xml-info@1.0 39 | with: 40 | xml-file: ${{ env.msbuildtarget }}/src/Other/Solution.xml 41 | xpath: "//ImportExportXml/SolutionManifest/Version" 42 | 43 | - name: Install dependencies 44 | run: npm ci 45 | 46 | - run: npm run build --if-present 47 | 48 | - name: setup-msbuild 49 | uses: microsoft/setup-msbuild@v1 50 | 51 | - name: MSBuild 52 | working-directory: ${{ env.msbuildtarget }} 53 | run: msbuild /restore /t:rebuild 54 | 55 | - name: Managed Solution Artifact ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_managed.zip 56 | uses: actions/upload-artifact@v2 57 | with: 58 | name: ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_managed.zip 59 | path: ${{ env.msbuildtarget }}/bin/Debug/${{ steps.solution-unique-name.outputs.info }}_managed.zip 60 | 61 | - name: Unmanaged Solution Artifact ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_unmanaged.zip 62 | uses: actions/upload-artifact@v2 63 | with: 64 | name: ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_unmanaged.zip 65 | path: ${{ env.msbuildtarget }}/bin/Debug/${{ steps.solution-unique-name.outputs.info }}.zip 66 | 67 | - name: Create Release ${{ steps.solution-unique-name.outputs.info }}_v${{ steps.solution-version.outputs.info }} 68 | id: create_release 69 | uses: actions/create-release@v1 70 | if: contains(github.ref, 'refs/tags/v') 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | with: 74 | tag_name: ${{ github.ref }} 75 | release_name: ${{ steps.solution-unique-name.outputs.info }}_v${{ steps.solution-version.outputs.info }} 76 | draft: false 77 | prerelease: false 78 | 79 | - name: Upload Release Asset ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_managed.zip (Managed) 80 | id: release-managed-solution 81 | uses: actions/upload-release-asset@v1 82 | if: steps.create_release.conclusion == 'success' 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | with: 86 | upload_url: ${{ steps.create_release.outputs.upload_url }} 87 | asset_path: ${{ env.msbuildtarget }}/bin/Debug/${{ steps.solution-unique-name.outputs.info }}_managed.zip 88 | asset_name: ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_managed.zip 89 | asset_content_type: application/zip 90 | 91 | - name: Upload Release Asset ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_unmanaged.zip (Unmanaged) 92 | id: release-unmanaged-solution 93 | uses: actions/upload-release-asset@v1 94 | if: steps.create_release.conclusion == 'success' 95 | env: 96 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | with: 98 | upload_url: ${{ steps.create_release.outputs.upload_url }} 99 | asset_path: ${{ env.msbuildtarget }}/bin/Debug/${{ steps.solution-unique-name.outputs.info }}.zip 100 | asset_name: ${{ steps.solution-unique-name.outputs.info }}_${{ steps.solution-version.outputs.info }}_unmanaged.zip 101 | asset_content_type: application/zip 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # generated directory 7 | **/generated 8 | 9 | # output directory 10 | /out 11 | 12 | # msbuild output directories 13 | /bin 14 | /obj 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oleksandr Olashyn (dancingwithcrm) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PCF-WebFormStepVisualizer.pcfproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps 5 | 6 | 7 | 8 | 9 | 10 | 11 | PCF-WebFormStepVisualizer 12 | 84f0f4c3-4d6f-4054-b72d-9622a3929b36 13 | $(MSBuildThisFileDirectory)out\controls 14 | 15 | 16 | 17 | v4.6.2 18 | 19 | net462 20 | PackageReference 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://vshymanskyy.github.io/StandWithUkraine) 2 | 3 | # WebForm Steps Visualizer 4 | 5 | Make it easier to work with Web Forms by visualizing your Web Form Steps as a flow chart. 6 | 7 | Create, edit, reorder and delete your components with ease. 8 | 9 | Available functionality: 10 | 11 | * Create/Update/Delete operation 12 | * Open record in a new tab 13 | * Reorder/link steps 14 | 15 | ## Demo 16 | 17 | https://user-images.githubusercontent.com/17760686/184469066-e225e6c9-be84-456c-986a-f1d30c96525f.mp4 18 | 19 | ## Install 20 | 21 | 1. Download the latest version of the solution from [releases](https://github.com/OOlashyn/PCF-WebFormStepVisualizer/releases). 22 | 2. Import the solution to your Dataverse instance 23 | 3. You need to place this control on top of any single line text field. Value in that field won't be changed - it is used only as a placeholder. 24 | 25 | ## Known Issues 26 | 27 | * Inconsistent placement of the steps after conditional step 28 | 29 | 30 | -------------------------------------------------------------------------------- /Screenshots/WebFormStepVisualizer_v1.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OOlashyn/PCF-WebFormStepVisualizer/755f0ffe77f116d3b2db112dc8387e45c047bb6a/Screenshots/WebFormStepVisualizer_v1.mp4 -------------------------------------------------------------------------------- /Screenshots/webformstepvisualizer_v0_2_0_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OOlashyn/PCF-WebFormStepVisualizer/755f0ffe77f116d3b2db112dc8387e45c047bb6a/Screenshots/webformstepvisualizer_v0_2_0_0.gif -------------------------------------------------------------------------------- /Solutions/WebFormStepsVisualizer/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # msbuild output directories 4 | /bin 5 | /obj -------------------------------------------------------------------------------- /Solutions/WebFormStepsVisualizer/WebFormStepsVisualizer.cdsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps 5 | 6 | 7 | 8 | 9 | 10 | 11 | 92ad235a-ed98-4827-ae51-391cebe0b403 12 | v4.6.2 13 | 14 | net462 15 | PackageReference 16 | src 17 | 18 | 19 | 20 | Both 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | PreserveNewest 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Solutions/WebFormStepsVisualizer/src/Other/Customizations.xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 1033 17 | 18 | -------------------------------------------------------------------------------- /Solutions/WebFormStepsVisualizer/src/Other/Relationships.xml: -------------------------------------------------------------------------------- 1 |  2 | -------------------------------------------------------------------------------- /Solutions/WebFormStepsVisualizer/src/Other/Solution.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WebFormStepsVisualizer 6 | 7 | 8 | 9 | 10 | 11 | 1.0.0.0 12 | 13 | 2 14 | 15 | 16 | dancingwithcrm 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | dwcrm 29 | 30 | 86015 31 | 32 | 33 |
34 | 1 35 | 1 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 1 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 |
62 | 2 63 | 1 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 1 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 |
89 |
90 |
91 | 92 | 93 |
94 |
-------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/App.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@fluentui/react/lib/Spinner"; 2 | import * as React from "react"; 3 | 4 | import { IAppActions, IEntity, WebFormStep } from "../utils/Interfaces"; 5 | import { Flow } from "./Flow"; 6 | 7 | export interface IAppProps { 8 | getWebFormSteps: () => Promise; 9 | getEntitiesMetadata: () => Promise; 10 | actions: IAppActions; 11 | } 12 | 13 | export const App = React.memo((props: IAppProps) => { 14 | const [webFormSteps, setWebFormSteps] = React.useState([]); 15 | const [entities, setEntities] = React.useState([]); 16 | 17 | const [isLoading, setIsLoading] = React.useState(true); 18 | const [isMetadataLoading, setIsMetadataLoading] = React.useState(true); 19 | 20 | React.useEffect(() => { 21 | props 22 | .getWebFormSteps() 23 | .then((steps: WebFormStep[]) => { 24 | setWebFormSteps(steps); 25 | setIsLoading(false); 26 | }) 27 | .catch((error) => { 28 | console.error(error); 29 | setIsLoading(false); 30 | }); 31 | 32 | props 33 | .getEntitiesMetadata() 34 | .then(res => res.json()) 35 | .then(resultJson => { 36 | const entitymetadata = resultJson["EntityMetadata"]; 37 | 38 | const getDisplayName = (item: any) => { 39 | if ( 40 | item.DisplayName?.UserLocalizedLabel != null && 41 | item.DisplayName?.UserLocalizedLabel?.Label != undefined 42 | ) { 43 | return item.DisplayName.UserLocalizedLabel.Label; 44 | } 45 | return ""; 46 | }; 47 | 48 | let entities: IEntity[] = entitymetadata.map((item: any) => { 49 | return { 50 | logicalName: item.LogicalName, 51 | displayName: getDisplayName(item) || item.LogicalName, 52 | }; 53 | }); 54 | 55 | entities = entities.sort((a,b) => { 56 | const nameA = a.displayName.toUpperCase(); // ignore upper and lowercase 57 | const nameB = b.displayName.toUpperCase(); // ignore upper and lowercase 58 | if (nameA < nameB) { 59 | return -1; 60 | } 61 | if (nameA > nameB) { 62 | return 1; 63 | } 64 | 65 | // names must be equal 66 | return 0; 67 | }); 68 | 69 | setEntities(entities); 70 | setIsMetadataLoading(false); 71 | }) 72 | .catch(function (error) { 73 | console.error(error.message); 74 | setIsMetadataLoading(false); 75 | }); 76 | }, []); 77 | 78 | if (isLoading) { 79 | return ( 80 |
81 | 82 |
83 | ); 84 | } else { 85 | return ( 86 | 92 | ); 93 | } 94 | }); 95 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Buttons/AsyncPrimaryButton.tsx: -------------------------------------------------------------------------------- 1 | import { PrimaryButton } from "@fluentui/react/lib/Button"; 2 | import { IIconProps } from "@fluentui/react/lib/Icon"; 3 | import * as React from "react"; 4 | 5 | export const AsyncPrimaryButton = (props: { 6 | onClick: () => Promise; 7 | text: string; 8 | loadingText: string; 9 | style?: React.CSSProperties | undefined; 10 | iconProps?: IIconProps | undefined 11 | }) => { 12 | const [buttonState, setButtonState] = React.useState("loaded"); 13 | 14 | const onClick = async () => { 15 | setButtonState("loading"); 16 | await props.onClick(); 17 | setButtonState("loaded"); 18 | }; 19 | 20 | return ( 21 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Flow.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Page } from "./Page"; 4 | import styled from "styled-components"; 5 | import { Sidebar } from "./Sidebar"; 6 | import ReactFlow, { 7 | Node, 8 | Edge, 9 | ReactFlowProvider, 10 | useNodesState, 11 | useEdgesState, 12 | addEdge, 13 | ConnectionLineType, 14 | } from "react-flow-renderer"; 15 | import { 16 | IAppActions, 17 | IEntity, 18 | WebFormStep, 19 | WebFormStepType, 20 | } from "../utils/Interfaces"; 21 | import ConditionDragHandleNode from "./Nodes/ConditionDragHandleNode"; 22 | import DragHandleNode from "./Nodes/DragHandleNode"; 23 | import dagre from "dagre"; 24 | import { PrimaryButton } from "@fluentui/react/lib/Button"; 25 | 26 | const Content = styled.div` 27 | display: flex; 28 | flex-direction: column; 29 | flex: 1; 30 | overflow: hidden; 31 | `; 32 | 33 | export interface IFlowProps { 34 | actions: IAppActions; 35 | webFormSteps: WebFormStep[]; 36 | entities: IEntity[]; 37 | isMetadataLoading: boolean; 38 | } 39 | 40 | const nodeTypes = { 41 | nodeWithDragHandle: DragHandleNode, 42 | conditionNodeWithDragHandle: ConditionDragHandleNode, 43 | }; 44 | 45 | const dagreGraph = new dagre.graphlib.Graph(); 46 | dagreGraph.setDefaultEdgeLabel(() => ({})); 47 | 48 | const nodeWidth = 150; 49 | const nodeHeight = 50; 50 | 51 | const getLayoutedElements = ( 52 | nodes: Node[], 53 | edges: Edge[], 54 | direction = "TB" 55 | ) => { 56 | dagreGraph.setGraph({ rankdir: direction }); 57 | 58 | nodes.forEach((node) => { 59 | dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); 60 | }); 61 | 62 | edges.forEach((edge) => { 63 | dagreGraph.setEdge(edge.source, edge.target); 64 | }); 65 | 66 | dagre.layout(dagreGraph); 67 | 68 | nodes.forEach((node) => { 69 | const nodeWithPosition = dagreGraph.node(node.id); 70 | ///@ts-ignore 71 | node.targetPosition = "top"; 72 | ///@ts-ignore 73 | node.sourcePosition = "bottom"; 74 | 75 | // We are shifting the dagre node position (anchor=center center) to the top left 76 | // so it matches the React Flow node anchor point (top left). 77 | node.position = { 78 | x: nodeWithPosition.x - nodeWidth / 2, 79 | y: nodeWithPosition.y - nodeHeight / 2, 80 | }; 81 | 82 | return node; 83 | }); 84 | 85 | return { nodes, edges }; 86 | }; 87 | 88 | const getIntialNodesAndEdges = ( 89 | webFormSteps: WebFormStep[], 90 | setSelectedNodeId: React.Dispatch>, 91 | setCreateMode: React.Dispatch> 92 | ) => { 93 | let initialNodes: Node[] = []; 94 | let initialEdges: Edge[] = []; 95 | 96 | webFormSteps.forEach((step, index) => { 97 | let node: Node = { 98 | id: step.adx_webformstepid, 99 | type: "nodeWithDragHandle", 100 | position: { 101 | x: 100, 102 | y: 100, 103 | }, 104 | data: { 105 | label: step.adx_name, 106 | setSelected: () => { 107 | setSelectedNodeId(step.adx_webformstepid); 108 | setCreateMode(false); 109 | }, 110 | }, 111 | style: { 112 | border: "1px solid #777", 113 | backgroundColor: "white", 114 | }, 115 | dragHandle: ".custom-drag-handle", 116 | }; 117 | 118 | if (step.adx_type == WebFormStepType.Condition) { 119 | node.type = "conditionNodeWithDragHandle"; 120 | } 121 | 122 | if (step.adx_nextstep) { 123 | let edge: Edge = { 124 | id: `${step.adx_webformstepid}-${step.adx_nextstep}`, 125 | source: step.adx_webformstepid, 126 | target: step.adx_nextstep, 127 | style: { 128 | strokeWidth: 2, 129 | }, 130 | }; 131 | if (step.adx_type == WebFormStepType.Condition) { 132 | edge.sourceHandle = "next"; 133 | edge.className = "success-edge"; 134 | } 135 | initialEdges.push(edge); 136 | } 137 | 138 | if (step.adx_conditiondefaultnextstep) { 139 | initialEdges.push({ 140 | id: `${step.adx_webformstepid}-${step.adx_conditiondefaultnextstep}`, 141 | source: step.adx_webformstepid, 142 | target: step.adx_conditiondefaultnextstep, 143 | sourceHandle: "default", 144 | className: "error-edge", 145 | }); 146 | } 147 | 148 | initialNodes.push(node); 149 | }); 150 | 151 | return { initialNodes, initialEdges }; 152 | }; 153 | 154 | export const Flow = (props: IFlowProps) => { 155 | const [selectedNodeId, setSelectedNodeId] = React.useState(""); 156 | const [webFormSteps, setWebFormSteps] = React.useState( 157 | props.webFormSteps 158 | ); 159 | const [connectionToUpdate, setConnectionsToUpdate] = React.useState< 160 | Partial[] 161 | >([]); 162 | 163 | const [isCreateMode, setCreateMode] = React.useState(false); 164 | 165 | const updateChangedRecords = async () => { 166 | if (connectionToUpdate.length == 0) { 167 | return null; 168 | } 169 | let isSuccess = true; 170 | 171 | const results = await props.actions.updateRecords(connectionToUpdate); 172 | 173 | // array of errors 174 | let errors: any[] = []; 175 | 176 | results?.forEach((result: any) => { 177 | if (result.status == "rejected") { 178 | errors.push(result.reason.responseText); 179 | } 180 | }); 181 | 182 | if (errors.length > 0) { 183 | isSuccess = false; 184 | errors.forEach((error) => { 185 | console.error("Error while updating web steps", error); 186 | }); 187 | } 188 | 189 | setConnectionsToUpdate([]); 190 | 191 | return isSuccess; 192 | }; 193 | 194 | const clearSelected = () => setSelectedNodeId(""); 195 | 196 | let { initialNodes, initialEdges } = getIntialNodesAndEdges( 197 | webFormSteps, 198 | setSelectedNodeId, 199 | setCreateMode 200 | ); 201 | 202 | const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( 203 | initialNodes, 204 | initialEdges 205 | ); 206 | 207 | const reactFlowWrapper = React.useRef(null); 208 | 209 | const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes); 210 | const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges); 211 | const [reactFlowInstance, setReactFlowInstance] = React.useState(null); 212 | 213 | const onEdgesDelete = React.useCallback((deletedEdges) => { 214 | deletedEdges.forEach( 215 | (params: { id: string; source: string; sourceHandle?: string }) => { 216 | let stepToUpdate = connectionToUpdate.findIndex( 217 | (el) => el.adx_webformstepid == params.source 218 | ); 219 | 220 | if (stepToUpdate != -1) { 221 | setConnectionsToUpdate((connections) => 222 | connections.map((connection) => { 223 | if (connection.adx_webformstepid == params.source) { 224 | if (params.sourceHandle == "default") { 225 | connection.adx_conditiondefaultnextstep = null; 226 | } else { 227 | connection.adx_nextstep = null; 228 | } 229 | } 230 | return connection; 231 | }) 232 | ); 233 | } else { 234 | setConnectionsToUpdate((connections) => { 235 | let newConnection: Partial = { 236 | adx_webformstepid: params.source, 237 | }; 238 | 239 | if (params.sourceHandle == "default") { 240 | newConnection.adx_conditiondefaultnextstep = null; 241 | } else { 242 | newConnection.adx_nextstep = null; 243 | } 244 | 245 | return connections.concat(newConnection); 246 | }); 247 | } 248 | } 249 | ); 250 | }, []); 251 | 252 | const onConnect = React.useCallback((params) => { 253 | let stepToUpdate = connectionToUpdate.findIndex( 254 | (el) => el.adx_webformstepid == params.source 255 | ); 256 | 257 | if (stepToUpdate != -1) { 258 | setConnectionsToUpdate((connections) => 259 | connections.map((connection) => { 260 | if (connection.adx_webformstepid == params.source) { 261 | if (params.sourceHandle == "default") { 262 | connection.adx_conditiondefaultnextstep = params.target; 263 | } else { 264 | connection.adx_nextstep = params.target; 265 | } 266 | } 267 | return connection; 268 | }) 269 | ); 270 | } else { 271 | setConnectionsToUpdate((connections) => { 272 | let newConnection: Partial = { 273 | adx_webformstepid: params.source, 274 | }; 275 | 276 | if (params.sourceHandle == "default") { 277 | newConnection.adx_conditiondefaultnextstep = params.target; 278 | } else { 279 | newConnection.adx_nextstep = params.target; 280 | } 281 | 282 | return connections.concat(newConnection); 283 | }); 284 | } 285 | 286 | let edge = { 287 | ...params, 288 | type: ConnectionLineType.Bezier, 289 | }; 290 | 291 | switch (params.sourceHandle) { 292 | case "default": 293 | edge.className = "error-edge"; 294 | break; 295 | case "next": 296 | edge.className = "success-edge"; 297 | break; 298 | default: 299 | break; 300 | } 301 | 302 | setEdges((eds) => addEdge(edge, eds)); 303 | }, []); 304 | 305 | const createNewNode = (newWebFormStep: WebFormStep) => { 306 | ///@ts-ignore 307 | const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); 308 | 309 | let newNode: Node = { 310 | id: newWebFormStep.adx_webformstepid, 311 | type: 312 | newWebFormStep.adx_type == WebFormStepType.Condition 313 | ? "conditionNodeWithDragHandle" 314 | : "nodeWithDragHandle", 315 | position: { 316 | x: reactFlowBounds.left - 115, 317 | y: reactFlowBounds.top - 70, 318 | }, 319 | data: { 320 | label: newWebFormStep.adx_name, 321 | setSelected: () => { 322 | setSelectedNodeId(newWebFormStep.adx_webformstepid); 323 | setCreateMode(false); 324 | }, 325 | }, 326 | style: { 327 | border: "1px solid #777", 328 | backgroundColor: "white", 329 | }, 330 | dragHandle: ".custom-drag-handle", 331 | }; 332 | 333 | setNodes((nds) => nds.concat(newNode)); 334 | 335 | setWebFormSteps((steps) => steps.concat(newWebFormStep)); 336 | 337 | setCreateMode(false); 338 | }; 339 | 340 | const removeNode = (nodeId: string) => { 341 | setNodes((nds) => nds.filter((node) => node.id !== nodeId)); 342 | }; 343 | 344 | const updateNode = (webFormStep: WebFormStep) => { 345 | setNodes((nds: Node[]) => 346 | nds.map((node) => { 347 | if (node.id === webFormStep.adx_webformstepid) { 348 | node.data = { 349 | ...node.data, 350 | label: webFormStep.adx_name, 351 | }; 352 | } 353 | 354 | return node; 355 | }) 356 | ); 357 | }; 358 | 359 | return ( 360 | 361 | 362 | 363 | 376 | 377 | 392 | 393 | 394 | ); 395 | }; 396 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Forms/CreateForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { DefaultButton } from "@fluentui/react/lib/Button"; 4 | 5 | import { IEntity, ISidebarMessageBar, WebFormStep } from "../../utils/Interfaces"; 6 | 7 | import { DefaultForm } from "./DefaultForm"; 8 | import { AsyncPrimaryButton } from "../Buttons/AsyncPrimaryButton"; 9 | 10 | interface ICreateForm { 11 | isCreateMode: boolean; 12 | setCreateMode: Function; 13 | createNewNode: (newWebFormStep: WebFormStep) => void; 14 | webFormSteps: WebFormStep[]; 15 | entities: IEntity[]; 16 | isMetadataLoading: boolean; 17 | createRecord: ( 18 | record: WebFormStep, 19 | setAsStartStep?: boolean 20 | ) => Promise<{ 21 | isSuccess: boolean; 22 | value: string | undefined; 23 | }>; 24 | setMessageBar: React.Dispatch>; 25 | } 26 | 27 | export const CreateForm = (props: ICreateForm) => { 28 | const [newStep, setStep] = React.useState({} as WebFormStep); 29 | 30 | React.useEffect(() => { 31 | setStep({} as WebFormStep); 32 | }, [props.isCreateMode]); 33 | 34 | const createRecord = async () => { 35 | let newStepId = await props.createRecord( 36 | newStep, 37 | props.webFormSteps.length == 0 38 | ); 39 | if (newStepId.isSuccess) { 40 | props.createNewNode({ 41 | ...newStep, 42 | adx_webformstepid: newStepId.value ?? "", 43 | }); 44 | props.setCreateMode(false); 45 | props.setMessageBar({messageType: "success", messageText: "Step created successfully!"}); 46 | } else { 47 | props.setMessageBar({messageType: "error", messageText: "An error occured. Please check console for more details"}); 48 | } 49 | }; 50 | 51 | const cancelChanges = () => { 52 | props.setCreateMode(false); 53 | }; 54 | 55 | const createFormButtons = ( 56 | <> 57 | 63 | cancelChanges()} 67 | /> 68 | 69 | ); 70 | 71 | return ( 72 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Forms/DefaultForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { IconButton } from "@fluentui/react/lib/Button"; 4 | import { Dropdown, IDropdownOption } from "@fluentui/react/lib/Dropdown"; 5 | import { IStackTokens, Stack } from "@fluentui/react/lib/Stack"; 6 | import { TextField } from "@fluentui/react/lib/TextField"; 7 | 8 | import { 9 | IEntity, 10 | WebFormStep, 11 | WebFormStepMode, 12 | WebFormStepTableSourceType, 13 | WebFormStepType, 14 | } from "../../utils/Interfaces"; 15 | import { Spinner } from "@fluentui/react/lib/Spinner"; 16 | import { 17 | IComboBox, 18 | IComboBoxOption, 19 | VirtualizedComboBox, 20 | } from "@fluentui/react/lib/ComboBox"; 21 | 22 | const verticalGapStackTokens: IStackTokens = { 23 | childrenGap: 10, 24 | padding: 20, 25 | }; 26 | 27 | const typeOptions: IDropdownOption[] = [ 28 | { key: WebFormStepType.Condition, text: "Condition" }, 29 | { key: WebFormStepType.LoadForm, text: "Load Form" }, 30 | { key: WebFormStepType.LoadTab, text: "Load Tab" }, 31 | { key: WebFormStepType.LoadUserControl, text: "Load User Control" }, 32 | { key: WebFormStepType.Redirect, text: "Redirect" }, 33 | ]; 34 | 35 | const modeOptions: IDropdownOption[] = [ 36 | { key: WebFormStepMode.Insert, text: "Insert" }, 37 | { key: WebFormStepMode.Edit, text: "Edit" }, 38 | { key: WebFormStepMode.ReadOnly, text: "Read Only" }, 39 | ]; 40 | 41 | const sourceTypeOptions: IDropdownOption[] = [ 42 | { key: WebFormStepTableSourceType.QueryString, text: "Query String" }, 43 | { 44 | key: WebFormStepTableSourceType.CurrentPortalUser, 45 | text: "Current Portal User", 46 | }, 47 | { 48 | key: WebFormStepTableSourceType.ResultFromPreviousStep, 49 | text: "Result From Previous Step", 50 | }, 51 | { 52 | key: WebFormStepTableSourceType.RecordAssociateToCurrentPortalUser, 53 | text: "Record Associate To Current Portal User", 54 | }, 55 | ]; 56 | 57 | export const DefaultForm = ({ 58 | closeDialog, 59 | step, 60 | setSelectedStep, 61 | buttons, 62 | webFormSteps, 63 | entities, 64 | isMetadataLoading, 65 | }: { 66 | closeDialog: () => void; 67 | step: WebFormStep; 68 | setSelectedStep: Function; 69 | buttons: JSX.Element; 70 | webFormSteps: WebFormStep[]; 71 | entities: IEntity[]; 72 | isMetadataLoading: boolean; 73 | }) => { 74 | const previousStepSelectorOptions: IDropdownOption[] = webFormSteps.map( 75 | (step) => { 76 | let option: IDropdownOption = { 77 | key: step.adx_webformstepid, 78 | text: step.adx_name, 79 | }; 80 | 81 | return option; 82 | } 83 | ); 84 | 85 | const targetTableEntities: IComboBoxOption[] = entities.map((entity) => { 86 | let option: IComboBoxOption = { 87 | key: entity.logicalName, 88 | text: `${entity.displayName} (${entity.logicalName})`, 89 | }; 90 | 91 | return option; 92 | }); 93 | 94 | const targetTable = isMetadataLoading ? ( 95 | <> 96 | 101 | 107 | 108 | ) : ( 109 | , 119 | item: IDropdownOption | undefined 120 | ): void => { 121 | setSelectedStep({ 122 | ...step, 123 | adx_targetentitylogicalname: item?.key as string, 124 | }); 125 | }} 126 | required 127 | /> 128 | ); 129 | 130 | return ( 131 | <> 132 | 133 | 134 | closeDialog()} 137 | style={{color: "#666666"}} 138 | /> 139 | 140 | , 145 | newValue?: string 146 | ) => { 147 | setSelectedStep({ ...step, adx_name: newValue || "" }); 148 | }} 149 | onGetErrorMessage={() => 150 | step.adx_name != "" ? "" : "Name is required" 151 | } 152 | required 153 | /> 154 | {targetTable} 155 | , 161 | item: IDropdownOption | undefined 162 | ): void => { 163 | setSelectedStep({ 164 | ...step, 165 | adx_type: item?.key as WebFormStepType, 166 | }); 167 | }} 168 | required 169 | /> 170 | {step.adx_type == WebFormStepType.Condition ? ( 171 | , 176 | newValue?: string 177 | ) => { 178 | setSelectedStep({ 179 | ...step, 180 | adx_condition: newValue || "", 181 | }); 182 | }} 183 | required 184 | /> 185 | ) : null} 186 | , 193 | item: IDropdownOption | undefined 194 | ): void => { 195 | if (item?.key == WebFormStepMode.Insert) { 196 | setSelectedStep({ 197 | ...step, 198 | adx_mode: item?.key as WebFormStepMode, 199 | adx_entitysourcetype: null, 200 | }); 201 | } else { 202 | setSelectedStep({ 203 | ...step, 204 | adx_mode: item?.key as WebFormStepMode, 205 | }); 206 | } 207 | }} 208 | required={step.adx_type != WebFormStepType.Condition} 209 | /> 210 | {step.adx_mode == WebFormStepMode.Edit || 211 | step.adx_mode == WebFormStepMode.ReadOnly ? ( 212 | <> 213 | , 219 | item: IDropdownOption | undefined 220 | ): void => { 221 | setSelectedStep({ 222 | ...step, 223 | adx_entitysourcetype: item?.key as WebFormStepTableSourceType, 224 | }); 225 | }} 226 | required 227 | /> 228 | {step.adx_entitysourcetype == 229 | WebFormStepTableSourceType.ResultFromPreviousStep ? ( 230 | , 236 | item: IDropdownOption | undefined 237 | ): void => { 238 | setSelectedStep({ 239 | ...step, 240 | adx_entitysourcestep: item?.key as string, 241 | }); 242 | }} 243 | required 244 | /> 245 | ) : null} 246 | 247 | ) : null} 248 | {buttons} 249 | 250 | 251 | ); 252 | }; 253 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Forms/EditForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { DefaultButton } from "@fluentui/react/lib/Button"; 4 | import { Dialog, DialogType, DialogFooter } from "@fluentui/react/lib/Dialog"; 5 | 6 | import { 7 | IAppActions, 8 | IEntity, 9 | ISidebarMessageBar, 10 | WebFormStep, 11 | } from "../../utils/Interfaces"; 12 | 13 | import { DefaultForm } from "./DefaultForm"; 14 | import { AsyncPrimaryButton } from "../Buttons/AsyncPrimaryButton"; 15 | 16 | interface IEditFormProps { 17 | selectedNodeId: string; 18 | clearSelected: () => void; 19 | webFormSteps: WebFormStep[]; 20 | setWebFormSteps: React.Dispatch>; 21 | entities: IEntity[]; 22 | isMetadataLoading: boolean; 23 | removeNode: (nodeId: string) => void; 24 | actions: IAppActions; 25 | setMessageBar: React.Dispatch>; 26 | updateNode: (webFormStep: WebFormStep) => void; 27 | } 28 | 29 | export const EditForm = ({ 30 | selectedNodeId, 31 | clearSelected, 32 | webFormSteps, 33 | setWebFormSteps, 34 | entities, 35 | isMetadataLoading, 36 | removeNode, 37 | actions, 38 | setMessageBar, 39 | updateNode, 40 | }: IEditFormProps) => { 41 | const [selectedStep, setSelectedStep] = React.useState( 42 | webFormSteps.find((el) => el.adx_webformstepid == selectedNodeId) 43 | ); 44 | 45 | const [isDeleteDialogHidden, setIsDeleteDialogHidden] = React.useState(true); 46 | 47 | const hideDeleteDialog = () => setIsDeleteDialogHidden(true); 48 | 49 | React.useEffect(() => { 50 | setSelectedStep( 51 | webFormSteps.find((el) => el.adx_webformstepid == selectedNodeId) 52 | ); 53 | }, [selectedNodeId, webFormSteps]); 54 | 55 | const removeRecord = async () => { 56 | let isError = await actions.deleteRecord( 57 | selectedStep?.adx_webformstepid || "" 58 | ); 59 | 60 | if (isError) { 61 | hideDeleteDialog(); 62 | setMessageBar({messageType: "error", messageText: "An error occured. Please check console for more details"}); 63 | } else { 64 | removeNode(selectedStep?.adx_webformstepid || ""); 65 | 66 | setWebFormSteps((steps) => 67 | steps.filter( 68 | (step) => 69 | step.adx_webformstepid !== selectedStep?.adx_webformstepid || "" 70 | ) 71 | ); 72 | hideDeleteDialog(); 73 | clearSelected(); 74 | } 75 | }; 76 | 77 | const saveRecord = async () => { 78 | let isSaved = await actions.saveRecord( 79 | selectedStep || ({} as WebFormStep) 80 | ); 81 | 82 | if (isSaved) { 83 | setWebFormSteps((steps: WebFormStep[]) => 84 | steps.map((step) => { 85 | if (step.adx_webformstepid == selectedStep?.adx_webformstepid) { 86 | step = { 87 | ...step, 88 | adx_name: selectedStep.adx_name, 89 | adx_type: selectedStep.adx_type, 90 | adx_targetentitylogicalname: 91 | selectedStep.adx_targetentitylogicalname, 92 | adx_mode: selectedStep.adx_mode, 93 | adx_condition: selectedStep.adx_condition, 94 | }; 95 | } 96 | return step; 97 | }) 98 | ); 99 | updateNode(selectedStep as WebFormStep); 100 | setMessageBar({messageType: "success", messageText: "Record updated successfully"}); 101 | } else { 102 | setMessageBar({messageType: "error", messageText: "An error occured. Please check console for more details"}); 103 | } 104 | } 105 | 106 | const editFormButtons = ( 107 |
108 | 109 | 112 | actions.openRecord(selectedStep?.adx_webformstepid || "") 113 | } 114 | style={{ margin: "0 5px" }} 115 | /> 116 | setIsDeleteDialogHidden(false)} 119 | style={{ marginLeft: "5px" }} 120 | /> 121 | 141 |
142 | ); 143 | 144 | if (selectedStep) { 145 | return ( 146 | 155 | ); 156 | } else { 157 | return
Selected node not found
; 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Nodes/ConditionDragHandleNode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Handle, Position } from "react-flow-renderer"; 3 | import { DragNodeBody } from "./DragNodeBody"; 4 | 5 | function ConditionDragHandleNode({ 6 | data, 7 | isConnectable, 8 | }: { 9 | data: any; 10 | isConnectable: boolean; 11 | }) { 12 | return ( 13 | <> 14 | 19 | 20 | 27 | 34 | 35 | ); 36 | } 37 | 38 | export default React.memo(ConditionDragHandleNode); 39 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Nodes/DragHandleNode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Handle, Position } from "react-flow-renderer"; 3 | import { DragNodeBody } from "./DragNodeBody"; 4 | 5 | function DragHandleNode({ 6 | data, 7 | isConnectable, 8 | }: { 9 | data: any; 10 | isConnectable: boolean; 11 | }) { 12 | return ( 13 | <> 14 | 19 | 20 | 25 | 26 | ); 27 | } 28 | 29 | export default React.memo(DragHandleNode); 30 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Nodes/DragNodeBody.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@fluentui/react/lib/Icon"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | 5 | const DragNode = styled.div` 6 | display: flex; 7 | width: 150px; 8 | height: 50px; 9 | align-items: center; 10 | justify-content: center; 11 | `; 12 | 13 | export const DragNodeBody = ({ data }: any) => { 14 | return ( 15 | 16 |
{data.label}
17 |
18 | data.setSelected()} 21 | className="node-controls__icon" 22 | /> 23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const PageContent = styled.div.attrs(props => ({className: props.className,}))` 5 | display: flex; 6 | flex-direction: row; 7 | flex: 1; 8 | width:80vw; 9 | max-width: 100vw; 10 | max-height: 100vh; 11 | height: 800px; 12 | white-space: normal; 13 | ` 14 | 15 | export const Page = ({ children }: { children: any}) => ( 16 |
17 | {children} 18 |
19 | ) 20 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/Components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { PrimaryButton } from "@fluentui/react/lib/Button"; 4 | import styled from "styled-components"; 5 | 6 | import { 7 | WebFormStepType, 8 | WebFormStepMode, 9 | WebFormStep, 10 | IEntity, 11 | IAppActions, 12 | ISidebarMessageBar, 13 | } from "../utils/Interfaces"; 14 | 15 | import { CreateForm } from "./Forms/CreateForm"; 16 | 17 | import { EditForm } from "./Forms/EditForm"; 18 | import { MessageBar, MessageBarType } from "@fluentui/react/lib/MessageBar"; 19 | import { AsyncPrimaryButton } from "./Buttons/AsyncPrimaryButton"; 20 | 21 | const SidebarWrapper = styled.div` 22 | width: 300px; 23 | background: #f3f2f1; 24 | display: flex; 25 | flex-direction: column; 26 | flex-shrink: 0; 27 | text-align: start; 28 | `; 29 | 30 | const TextBlock = styled.div` 31 | margin: 10px; 32 | padding: 10px; 33 | line-height: 1.4em; 34 | color: black; 35 | `; 36 | 37 | export interface ISelectedEntityProps { 38 | title: string; 39 | entity: string; 40 | type: WebFormStepType; 41 | mode: WebFormStepMode; 42 | id: string; 43 | condition: string; 44 | } 45 | 46 | interface ISidebarProps { 47 | clearSelected: () => void; 48 | webFormSteps: WebFormStep[]; 49 | setWebFormSteps: React.Dispatch>; 50 | selectedNodeId: string; 51 | isCreateMode: boolean; 52 | setCreateMode: Function; 53 | createNewNode: (newWebFormStep: WebFormStep) => void; 54 | entities: IEntity[]; 55 | isMetadataLoading: boolean; 56 | removeNode: (nodeId: string) => void; 57 | actions: IAppActions; 58 | updateChangedRecords: () => Promise; 59 | updateNode: (webFormStep: WebFormStep) => void; 60 | } 61 | 62 | export const Sidebar = (props: ISidebarProps) => { 63 | const [messageBar, setMessageBar] = React.useState(); 64 | 65 | const updateModifiedRecords = async () => { 66 | let result = await props.updateChangedRecords(); 67 | 68 | if(result === true) { 69 | setMessageBar({messageType: "success", messageText: "Records updated successfully"}); 70 | } 71 | 72 | if(result === false) { 73 | setMessageBar({messageType: "error", messageText: "An error occured. Please check console for more details"}); 74 | } 75 | } 76 | 77 | let editForm = null; 78 | let defaultSidebar = null; 79 | let createForm = null; 80 | 81 | if (props.selectedNodeId != "") { 82 | editForm = ( 83 | 95 | ); 96 | } 97 | 98 | if (props.isCreateMode && props.selectedNodeId == "") { 99 | createForm = ( 100 | 110 | ); 111 | } 112 | 113 | if (!editForm && !createForm) { 114 | defaultSidebar = ( 115 | <> 116 | 117 | Click on a Node info icon to see details or create new step using 118 | button below 119 | 120 | props.setCreateMode(true)} 125 | /> 126 | 127 | Update existing step order and save changes using button below 128 | 129 | 136 | 137 | ); 138 | } 139 | 140 | return ( 141 | 142 | {messageBar?.messageType == "error" && ( 143 | { 147 | setMessageBar({messageText: "", messageType: ""}); 148 | }} 149 | dismissButtonAriaLabel="Close" 150 | > 151 | {messageBar.messageText} 152 | 153 | )} 154 | {messageBar?.messageType == "success" && ( 155 | { 159 | setMessageBar({messageText: "", messageType: ""}); 160 | }} 161 | dismissButtonAriaLabel="Close" 162 | > 163 | {messageBar.messageText} 164 | 165 | )} 166 | {editForm} 167 | {createForm} 168 | {defaultSidebar} 169 | 170 | ); 171 | }; 172 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/ControlManifest.Input.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/assets/css/main.css: -------------------------------------------------------------------------------- 1 | .DancingWithCrmControls\.WebFormStepsVisualizer { 2 | display: flex; 3 | flex-direction: row; 4 | flex: 1; 5 | width:80vw; 6 | max-width: 100vw; 7 | max-height: 100vh; 8 | height: 800px; 9 | white-space: normal; 10 | } 11 | 12 | .DancingWithCrmControls\.WebFormStepsVisualizer .success-edge > path { 13 | stroke: green; 14 | } 15 | 16 | .DancingWithCrmControls\.WebFormStepsVisualizer .error-edge > path { 17 | stroke: red; 18 | } 19 | 20 | .DancingWithCrmControls\.WebFormStepsVisualizer .react-flow__edge > path { 21 | stroke-width: 2; 22 | } 23 | 24 | .DancingWithCrmControls\.WebFormStepsVisualizer .success-edge.selected > path, 25 | .DancingWithCrmControls\.WebFormStepsVisualizer .react-flow__edge.selected > path, 26 | .DancingWithCrmControls\.WebFormStepsVisualizer .error-edge.selected > path { 27 | stroke: blue !important; 28 | } 29 | 30 | .DancingWithCrmControls\.WebFormStepsVisualizer .node-controls { 31 | display: flex; 32 | flex-direction: column; 33 | justify-content: center; 34 | background-color: #eee; 35 | height: 100%; 36 | } 37 | 38 | .DancingWithCrmControls\.WebFormStepsVisualizer .node-controls__icon { 39 | padding: 0px 5px; 40 | cursor: pointer; 41 | } 42 | 43 | .DancingWithCrmControls\.WebFormStepsVisualizer .node-text { 44 | text-align: center; 45 | font-size: 11px; 46 | padding: 2px 5px; 47 | width: 120px; 48 | height: 100%; 49 | display: flex; 50 | justify-content: center; 51 | align-items: center; 52 | } 53 | 54 | .DancingWithCrmControls\.WebFormStepsVisualizer .edit-form_buttons-container { 55 | display: flex; 56 | justify-content: space-between; 57 | margin-top: 20px !important; 58 | } -------------------------------------------------------------------------------- /WebFormStepsVisualizer/index.ts: -------------------------------------------------------------------------------- 1 | import { IInputs, IOutputs } from "./generated/ManifestTypes"; 2 | 3 | import * as React from "react"; 4 | 5 | import { App, IAppProps } from "./components/App"; 6 | import { WebFormStep } from "./utils/Interfaces"; 7 | 8 | export class WebFormStepsVisualizer 9 | implements ComponentFramework.ReactControl 10 | { 11 | private _context: ComponentFramework.Context; 12 | private props: IAppProps; 13 | 14 | /** 15 | * Empty constructor. 16 | */ 17 | constructor() {} 18 | 19 | /** 20 | * Used to initialize the control instance. Controls can kick off remote server calls and other initialization actions here. 21 | * Data-set values are not initialized here, use updateView. 22 | * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to property names defined in the manifest, as well as utility functions. 23 | * @param notifyOutputChanged A callback method to alert the framework that the control has new outputs ready to be retrieved asynchronously. 24 | * @param state A piece of data that persists in one session for a single user. Can be set at any point in a controls life cycle by calling 'setControlState' in the Mode interface. 25 | * @param container If a control is marked control-type='standard', it will receive an empty div element within which it can render its content. 26 | */ 27 | public init( 28 | context: ComponentFramework.Context, 29 | notifyOutputChanged: () => void, 30 | state: ComponentFramework.Dictionary, 31 | container: HTMLDivElement 32 | ) { 33 | this._context = context; 34 | 35 | this.props = { 36 | actions: { 37 | openRecord: this.openRecord, 38 | saveRecord: this.saveRecord, 39 | createRecord: this.createRecord, 40 | updateRecords: this.updateRecords, 41 | deleteRecord: this.deleteRecord, 42 | }, 43 | getWebFormSteps: this.getWebFormSteps, 44 | getEntitiesMetadata: this.getEntitiesMetadata, 45 | }; 46 | } 47 | 48 | public getWebFormSteps = async () => { 49 | const webFormId: string = (this._context.mode as any).contextInfo.entityId; 50 | 51 | if (webFormId) { 52 | let currentWebForm; 53 | 54 | try { 55 | currentWebForm = await this._context.webAPI.retrieveRecord( 56 | "adx_webform", 57 | webFormId, 58 | "?$select=_adx_startstep_value" 59 | ); 60 | } catch (error) { 61 | console.error(error); 62 | } 63 | 64 | if (currentWebForm && currentWebForm._adx_startstep_value) { 65 | const select = 66 | "$select=_adx_nextstep_value,adx_type,_adx_conditiondefaultnextstep_value,adx_webformstepid,adx_name,adx_condition,adx_targetentitylogicalname,adx_mode,adx_entitysourcetype,_adx_entitysourcestep_value"; 67 | const filter = `$filter=_adx_webform_value eq ${webFormId}`; 68 | 69 | const searchOption = `?${select}&${filter}`; 70 | 71 | let results = await this._context.webAPI.retrieveMultipleRecords( 72 | "adx_webformstep", 73 | searchOption 74 | ); 75 | 76 | if (results.entities) { 77 | let webFormStepsArr: WebFormStep[] = results.entities.map( 78 | (entity) => { 79 | let step: WebFormStep = { 80 | adx_webformstepid: entity.adx_webformstepid, 81 | adx_name: entity.adx_name, 82 | adx_type: entity.adx_type, 83 | adx_targetentitylogicalname: entity.adx_targetentitylogicalname, 84 | adx_mode: entity.adx_mode, 85 | adx_condition: entity.adx_condition, 86 | adx_conditiondefaultnextstep: 87 | entity._adx_conditiondefaultnextstep_value, 88 | adx_nextstep: entity._adx_nextstep_value, 89 | adx_entitysourcetype: entity.adx_entitysourcetype, 90 | adx_entitysourcestep: entity._adx_entitysourcestep_value, 91 | }; 92 | 93 | return step; 94 | } 95 | ); 96 | 97 | return webFormStepsArr; 98 | } 99 | } else { 100 | console.log("No Start Step present on Web Form"); 101 | return []; 102 | } 103 | } 104 | 105 | return []; 106 | }; 107 | 108 | public openRecord = (id: string) => { 109 | let options: ComponentFramework.NavigationApi.EntityFormOptions = { 110 | entityName: "adx_webformstep", 111 | entityId: id, 112 | openInNewWindow: true, 113 | }; 114 | 115 | this._context.navigation.openForm(options); 116 | }; 117 | 118 | public saveRecord = async (record: WebFormStep) => { 119 | let isSuccess = true; 120 | 121 | let recordToUpdate: any = { 122 | adx_name: record.adx_name, 123 | adx_type: record.adx_type, 124 | adx_targetentitylogicalname: record.adx_targetentitylogicalname, 125 | adx_mode: record.adx_mode, 126 | adx_condition: record.adx_condition, 127 | adx_entitysourcetype: record.adx_entitysourcetype, 128 | }; 129 | 130 | if (record.adx_entitysourcestep) { 131 | recordToUpdate[ 132 | "adx_entitysourcestep@odata.bind" 133 | ] = `/adx_webformsteps(${record.adx_entitysourcestep})`; 134 | } 135 | 136 | try { 137 | await this._context.webAPI.updateRecord( 138 | "adx_webformstep", 139 | record.adx_webformstepid, 140 | recordToUpdate 141 | ); 142 | } catch (error) { 143 | isSuccess = false; 144 | console.error(error); 145 | } 146 | 147 | return isSuccess; 148 | }; 149 | 150 | public createRecord = async (record: WebFormStep, setAsStartStep = false) => { 151 | const webFormId: string = (this._context.mode as any).contextInfo.entityId; 152 | 153 | const { adx_entitysourcestep, ...recordWithoutSourceStep } = record; 154 | 155 | let recordToCreate: any = { 156 | "adx_webform@odata.bind": `/adx_webforms(${webFormId})`, 157 | ...recordWithoutSourceStep, 158 | }; 159 | 160 | if (adx_entitysourcestep != null && adx_entitysourcestep != undefined) { 161 | recordToCreate[ 162 | "adx_entitysourcestep@odata.bind" 163 | ] = `/adx_webformsteps(${adx_entitysourcestep})`; 164 | } 165 | 166 | let isSuccess = true; 167 | let result; 168 | try { 169 | result = await this._context.webAPI.createRecord( 170 | "adx_webformstep", 171 | recordToCreate 172 | ); 173 | } catch (error) { 174 | console.error(error); 175 | isSuccess = false; 176 | } 177 | 178 | if (result?.id != undefined && setAsStartStep) { 179 | await this._context.webAPI.updateRecord( 180 | "adx_webform", 181 | webFormId, 182 | { 183 | "adx_startstep@odata.bind": `/adx_webformsteps(${result.id})`, 184 | } 185 | ); 186 | } 187 | 188 | return { isSuccess: isSuccess, value: result?.id}; 189 | }; 190 | 191 | public getEntitiesMetadata = () => { 192 | return fetch( 193 | "/api/data/v9.2/RetrieveAllEntities(EntityFilters=@EntityFilters,RetrieveAsIfPublished=@RetrieveAsIfPublished)?@EntityFilters=Microsoft.Dynamics.CRM.EntityFilters'Entity'&@RetrieveAsIfPublished=false", 194 | { 195 | method: "GET", 196 | headers: { 197 | "OData-MaxVersion": "4.0", 198 | "OData-Version": "4.0", 199 | "Content-Type": "application/json; charset=utf-8", 200 | Accept: "application/json", 201 | }, 202 | } 203 | ); 204 | }; 205 | 206 | public updateRecords = async (recordsToUpdate: Partial[]) => { 207 | let deffereds = []; 208 | 209 | for (let index = 0; index < recordsToUpdate.length; index++) { 210 | const step = recordsToUpdate[index]; 211 | let recordToUpdate: any = {}; 212 | 213 | if (step.adx_nextstep != null) { 214 | recordToUpdate[ 215 | "adx_nextstep@odata.bind" 216 | ] = `/adx_webformsteps(${step.adx_nextstep})`; 217 | } 218 | 219 | if (step.adx_nextstep === null) { 220 | recordToUpdate["adx_nextstep@odata.bind"] = null; 221 | } 222 | 223 | if (step.adx_conditiondefaultnextstep != null) { 224 | recordToUpdate[ 225 | "adx_conditiondefaultnextstep@odata.bind" 226 | ] = `/adx_webformsteps(${step.adx_conditiondefaultnextstep})`; 227 | } 228 | if (step.adx_conditiondefaultnextstep === null) { 229 | recordToUpdate["adx_conditiondefaultnextstep@odata.bind"] = null; 230 | } 231 | 232 | deffereds.push( 233 | this._context.webAPI.updateRecord( 234 | "adx_webformstep", 235 | step.adx_webformstepid as string, 236 | recordToUpdate 237 | ) 238 | ); 239 | } 240 | 241 | return Promise.allSettled(deffereds); 242 | }; 243 | 244 | public deleteRecord: (recordId: string) => Promise = async ( 245 | recordId: string 246 | ) => { 247 | let isError = false; 248 | try { 249 | await this._context.webAPI.deleteRecord( 250 | "adx_webformstep", 251 | recordId 252 | ); 253 | } catch (error) { 254 | isError = true; 255 | console.error(error); 256 | } 257 | 258 | return isError; 259 | }; 260 | 261 | /** 262 | * Called when any value in the property bag has changed. This includes field values, data-sets, global values such as container height and width, offline status, control metadata values such as label, visible, etc. 263 | * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions 264 | */ 265 | public updateView( 266 | context: ComponentFramework.Context 267 | ): React.ReactElement { 268 | // Add code to update control view 269 | return React.createElement(App, this.props); 270 | } 271 | 272 | /** 273 | * It is called by the framework prior to a control receiving new data. 274 | * @returns an object based on nomenclature defined in manifest, expecting object[s] for property marked as “bound” or “output” 275 | */ 276 | public getOutputs(): IOutputs { 277 | return {}; 278 | } 279 | 280 | /** 281 | * Called when the control is to be removed from the DOM tree. Controls should use this call for cleanup. 282 | * i.e. cancelling any pending remote calls, removing listeners, etc. 283 | */ 284 | public destroy(): void { 285 | // Add code to cleanup control if necessary 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/utils/Interfaces.ts: -------------------------------------------------------------------------------- 1 | export enum WebFormStepType { 2 | Condition = 100000000, 3 | LoadForm, 4 | LoadTab, 5 | LoadUserControl, 6 | Redirect, 7 | } 8 | 9 | export enum WebFormStepMode { 10 | Insert = 100000000, 11 | Edit, 12 | ReadOnly, 13 | } 14 | 15 | export enum WebFormStepTableSourceType { 16 | QueryString = 100000001, 17 | CurrentPortalUser, 18 | ResultFromPreviousStep, 19 | RecordAssociateToCurrentPortalUser, 20 | } 21 | 22 | export interface WebFormStep { 23 | adx_webformstepid: string; 24 | adx_name: string; 25 | adx_type: WebFormStepType; 26 | adx_targetentitylogicalname: string; 27 | adx_mode: WebFormStepMode | null; 28 | adx_condition: string | null; 29 | adx_conditiondefaultnextstep: string | null; 30 | adx_nextstep: string | null; 31 | adx_entitysourcetype?: WebFormStepTableSourceType | null; 32 | adx_entitysourcestep?: string | null; 33 | } 34 | 35 | export interface IEntity { 36 | logicalName: string; 37 | displayName: string; 38 | } 39 | 40 | export interface ISidebarMessageBar { 41 | messageType: string; 42 | messageText: string; 43 | } 44 | 45 | export interface IAppActions { 46 | openRecord: (id: string) => void; 47 | saveRecord: (record: WebFormStep) => Promise; 48 | createRecord: ( 49 | record: WebFormStep, 50 | setAsStartStep?: boolean 51 | ) => Promise<{ 52 | isSuccess: boolean; 53 | value: string | undefined; 54 | }>; 55 | updateRecords: ( 56 | recordsToUpdate: Partial[] 57 | ) => Promise< 58 | PromiseSettledResult[] | undefined 59 | >; 60 | deleteRecord: (recordId: string) => Promise; 61 | } 62 | -------------------------------------------------------------------------------- /WebFormStepsVisualizer/utils/SampleData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebFormStep, 3 | WebFormStepType, 4 | WebFormStepMode, 5 | WebFormStepTableSourceType, 6 | } from "./Interfaces"; 7 | 8 | export const SampleWebFormSteps: WebFormStep[] = [ 9 | { 10 | adx_webformstepid: "1bdcfcb9-5898-ea11-a812-000d3aa98048", 11 | adx_name: "Add Account", 12 | adx_type: WebFormStepType.LoadForm, 13 | adx_targetentitylogicalname: "account", 14 | adx_mode: WebFormStepMode.Insert, 15 | adx_condition: null, 16 | adx_conditiondefaultnextstep: null, 17 | adx_nextstep: "c4f2c8de-5898-ea11-a812-000d3aa98048", 18 | }, 19 | { 20 | adx_webformstepid: "c4f2c8de-5898-ea11-a812-000d3aa98048", 21 | adx_name: "Add Contact", 22 | adx_type: WebFormStepType.LoadForm, 23 | adx_targetentitylogicalname: "contact", 24 | adx_mode: WebFormStepMode.Insert, 25 | adx_condition: null, 26 | adx_conditiondefaultnextstep: null, 27 | adx_nextstep: "ab741a9b-5998-ea11-a812-000d3aa98048", 28 | }, 29 | { 30 | adx_webformstepid: "ab741a9b-5998-ea11-a812-000d3aa98048", 31 | adx_name: "Condition Split", 32 | adx_type: WebFormStepType.Condition, 33 | adx_targetentitylogicalname: "contact", 34 | adx_mode: null, 35 | adx_condition: "dwcrm_splittype == true", 36 | adx_conditiondefaultnextstep: "b47daef7-5998-ea11-a812-000d3aa98048", 37 | adx_nextstep: "f49da5cc-5998-ea11-a812-000d3aa98048", 38 | }, 39 | { 40 | adx_webformstepid: "b47daef7-5998-ea11-a812-000d3aa98048", 41 | adx_name: "Add Example", 42 | adx_type: WebFormStepType.LoadForm, 43 | adx_targetentitylogicalname: "dwcrm_example", 44 | adx_mode: WebFormStepMode.Insert, 45 | adx_condition: null, 46 | adx_conditiondefaultnextstep: null, 47 | adx_nextstep: "0793f271-187c-4072-982d-fa51cb2c99da", 48 | }, 49 | { 50 | adx_webformstepid: "0793f271-187c-4072-982d-fa51cb2c99da", 51 | adx_name: "Add Second Example", 52 | adx_type: WebFormStepType.LoadForm, 53 | adx_targetentitylogicalname: "dwcrm_example", 54 | adx_mode: WebFormStepMode.Insert, 55 | adx_condition: null, 56 | adx_conditiondefaultnextstep: null, 57 | adx_nextstep: "36fced2c-5a98-ea11-a812-000d3aa98048", 58 | }, 59 | { 60 | adx_webformstepid: "f49da5cc-5998-ea11-a812-000d3aa98048", 61 | adx_name: "Add Sample", 62 | adx_type: WebFormStepType.LoadForm, 63 | adx_targetentitylogicalname: "dwcrm_sample", 64 | adx_mode: WebFormStepMode.Insert, 65 | adx_condition: null, 66 | adx_conditiondefaultnextstep: null, 67 | adx_nextstep: "30f9d385-52d3-4a50-a775-50c943985061", 68 | }, 69 | { 70 | adx_webformstepid: "30f9d385-52d3-4a50-a775-50c943985061", 71 | adx_name: "Add Second Sample", 72 | adx_type: WebFormStepType.LoadForm, 73 | adx_targetentitylogicalname: "dwcrm_sample", 74 | adx_mode: WebFormStepMode.Insert, 75 | adx_condition: null, 76 | adx_conditiondefaultnextstep: null, 77 | adx_nextstep: "36fced2c-5a98-ea11-a812-000d3aa98048", 78 | }, 79 | { 80 | adx_webformstepid: "36fced2c-5a98-ea11-a812-000d3aa98048", 81 | adx_name: "Edit Contact", 82 | adx_type: WebFormStepType.LoadForm, 83 | adx_targetentitylogicalname: "contact", 84 | adx_mode: WebFormStepMode.Edit, 85 | adx_condition: null, 86 | adx_conditiondefaultnextstep: null, 87 | adx_nextstep: null, 88 | adx_entitysourcestep: "c4f2c8de-5898-ea11-a812-000d3aa98048", 89 | adx_entitysourcetype: WebFormStepTableSourceType.ResultFromPreviousStep, 90 | }, 91 | ]; 92 | 93 | export const getWebFormStepsLocalDev = async () => { 94 | await new Promise((resolve) => { 95 | // adding delay of 3s 96 | setTimeout(() => { 97 | resolve("success"); 98 | }, 3000); 99 | }); 100 | 101 | return SampleWebFormSteps; 102 | }; 103 | 104 | export const createRecordTest = async ( 105 | record: WebFormStep, 106 | setAsStartStep = false 107 | ) => { 108 | let result: ComponentFramework.LookupValue = await new Promise((resolve) => { 109 | // adding delay of 3s 110 | setTimeout(() => { 111 | resolve({ 112 | id: 113 | Math.floor(Math.random() * 100).toString() + 114 | "232f2648-3ca3-4039-b0e5-fc7f330705d" + 115 | Math.floor(Math.random() * 100).toString(), 116 | } as ComponentFramework.LookupValue); 117 | }, 3000); 118 | }); 119 | 120 | return result.id; 121 | }; 122 | 123 | export const getEntityMetadataTest = () => { 124 | return new Promise((resolve) => { 125 | // adding delay of 3s 126 | setTimeout(() => { 127 | resolve({ 128 | EntityMetadata: [ 129 | { 130 | LogicalName: "account", 131 | DisplayName: { 132 | UserLocalizedLabel: { 133 | Label: "Account", 134 | }, 135 | }, 136 | }, 137 | { 138 | LogicalName: "contact", 139 | DisplayName: { 140 | UserLocalizedLabel: { 141 | Label: "Contact", 142 | }, 143 | }, 144 | }, 145 | { 146 | LogicalName: "feedback", 147 | DisplayName: { 148 | UserLocalizedLabel: { 149 | Label: "feedback", 150 | }, 151 | }, 152 | }, 153 | ], 154 | }); 155 | }, 2000); 156 | }); 157 | }; 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pcf-project", 3 | "version": "1.0.0", 4 | "description": "Project containing your PowerApps Component Framework (PCF) control.", 5 | "scripts": { 6 | "build": "pcf-scripts build", 7 | "clean": "pcf-scripts clean", 8 | "rebuild": "pcf-scripts rebuild", 9 | "start": "pcf-scripts start" 10 | }, 11 | "dependencies": { 12 | "@fluentui/react": "^8.29.0", 13 | "@types/dagre": "^0.7.47", 14 | "@types/node": "^10.17.26", 15 | "@types/powerapps-component-framework": "^1.2.3", 16 | "@types/react": "^16.9.41", 17 | "@types/react-dom": "^16.9.8", 18 | "@types/styled-components": "^5.1.0", 19 | "dagre": "^0.8.5", 20 | "react": "^16.8.6", 21 | "react-dom": "^16.8.6", 22 | "react-flow-renderer": "^10.3.10", 23 | "react-is": "^18.2.0", 24 | "styled-components": "^5.1.1" 25 | }, 26 | "devDependencies": { 27 | "pcf-scripts": "^1.3.3", 28 | "pcf-start": "^1.3.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pcfconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "outDir": "./out/controls" 3 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/pcf-scripts/tsconfig_base.json", 3 | "compilerOptions": { 4 | "typeRoots": ["node_modules/@types"], 5 | "esModuleInterop": true, 6 | "target": "ES6", 7 | "lib": ["ES2020", "DOM"] 8 | } 9 | } --------------------------------------------------------------------------------