diff --git a/client/src/App.jsx b/client/src/App.jsx index 5e7dabe..4478920 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,8 +4,10 @@ import { useDispatch } from 'react-redux'; import Hero from './pages/Hero/Hero.jsx'; import TriggerWorkflowAuthenticated from './pages/TriggerWorkflow/TriggerWorkflow.jsx'; import TriggerWorkflowFormAuthenticated from './pages/TriggerWorkflowForm/TriggerWorkflowForm.jsx'; +import ManageWorkflowAuthenticated from './pages/ManageWorkflow/ManageWorkflow.jsx'; import { LoginStatus, ROUTE } from './constants.js'; import { api } from './api'; +import HomeAuthenticated from './pages/Home/Home.jsx'; import { authorizeUser, closeLoadingCircleInPopup, @@ -30,7 +32,7 @@ function App() { const { data: userInfo } = await api.acg.callbackExecute(code); dispatch(authorizeUser(LoginStatus.ACG, userInfo.name, userInfo.email)); - navigate(ROUTE.TRIGGER); + navigate(ROUTE.HOME); dispatch(closePopupWindow()); dispatch(closeLoadingCircleInPopup()); }; @@ -41,7 +43,9 @@ function App() { return ( } /> + } /> } /> + } /> } /> ); diff --git a/client/src/api/index.js b/client/src/api/index.js index dcf7ca7..e84082f 100644 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -88,5 +88,29 @@ export const api = Object.freeze({ return error.response; } }, + pauseWorkflow: async workflow => { + try { + const response = await instance.post(`/workflows/${workflow.id}/pause`); + return response; + } catch (error) { + return error.response; + } + }, + resumeWorkflow: async workflow => { + try { + const response = await instance.post(`/workflows/${workflow.id}/resume`); + return response; + } catch (error) { + return error.response; + } + }, + getWorkflowInstances: async workflowId => { + const response = await instance.get(`/workflows/${workflowId}/instances`); + return response; + }, + cancelWorkflowInstance: async (workflowId, instanceId) => { + const response = await instance.post(`/workflows/${workflowId}/instances/${instanceId}/cancel`); + return response; + } }, }); diff --git a/client/src/assets/img/dropdown-arrow.svg b/client/src/assets/img/dropdown-arrow.svg new file mode 100644 index 0000000..e824a42 --- /dev/null +++ b/client/src/assets/img/dropdown-arrow.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/client/src/assets/img/instance-canceled.svg b/client/src/assets/img/instance-canceled.svg new file mode 100644 index 0000000..c00ad92 --- /dev/null +++ b/client/src/assets/img/instance-canceled.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/client/src/assets/img/instance-completed.svg b/client/src/assets/img/instance-completed.svg new file mode 100644 index 0000000..cdae130 --- /dev/null +++ b/client/src/assets/img/instance-completed.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/client/src/assets/img/workflow-manage.svg b/client/src/assets/img/workflow-manage.svg new file mode 100644 index 0000000..d7a2795 --- /dev/null +++ b/client/src/assets/img/workflow-manage.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/src/assets/text.json b/client/src/assets/text.json index 8a6bf9f..24436cd 100644 --- a/client/src/assets/text.json +++ b/client/src/assets/text.json @@ -41,6 +41,16 @@ "field2": "HR manager email" } }, + "home": { + "card1": { + "title": "Trigger a workflow", + "description": "Get a list of workflow definitions and trigger a workflow instance" + }, + "card2": { + "title": "Manage workflows", + "description": "Get the status of existing workflow instances and cancel instances" + } + }, "loader": { "title": "Waiting for log in" }, @@ -60,6 +70,12 @@ "step1Description": "Display all of the workflow definitions in the user's account by calling the WorkflowManagment: getWorkflowDefinitions endpoint. Each row on this page represents a workflow definition.", "step2Description": "After the user chooses a workflow to trigger, the next step is to get the workflow definition by calling the WorkflowMangement: getWorkflowDefinition endpoint. The response for this API call includes a triggerURL property that will be used in the next step.", "step3Description": "Finally, call the WorkflowTrigger: triggerWorkflow endpoint to trigger the workflow instance. The response of this call includes the workflowInstanceUrl where the workflow participant can complete the workflow steps." + }, + "manageWorkflow": { + "mainDescription": "This scenario displays a list of workflow instances, the status of those instances, and the option to cancel them.", + "step1Description": "The WorkflowInstanceManagement: getWorkflowInstances endpoint is called to get a list of the workflow instances that have been triggered by the sample app.", + "step2Description": "To get the status of a given workflow instance, the WorkflowInstanceManagement: getWorkflowInstance method is called.", + "step3Description": "If a user chooses to cancel a workflow instance, the WorkflowInstanceManagement: cancelWorkflowInstance method is called." } }, "workflowList": { @@ -68,7 +84,16 @@ "columns": { "lastRunStatus": "Status of last run", "workflowName": "Workflow name", - "workflowType": "Workflow type" + "workflowType": "Workflow type", + "workflowStatus": "Workflow status" + } + }, + "instanceList": { + "cancelButton": "Cancel", + "columns": { + "instance": "Instance", + "progress": "Progress", + "startedBy": "Started by" } }, "pageTitles": { @@ -93,7 +118,12 @@ "triggerNewWorkflow": "Trigger new workflow ->", "getStarted": "Get started", "moreInfo": "More info", - "backHome": "← Back to workflows" + "backToWorkflows": "← Back to workflows", + "updateWorkflow": "Update workflow status", + "cancelWorkflow": "Cancel workflow", + "backHome": "← Back to home", + "pauseWorkflow": "Pause the creation of instances", + "resumeWorkflow": "Resume the creation of instances" }, "links": { "github": "https://github.com/docusign/sample-app-workflows-node", diff --git a/client/src/components/Card/Card.jsx b/client/src/components/Card/Card.jsx new file mode 100644 index 0000000..5ce92cd --- /dev/null +++ b/client/src/components/Card/Card.jsx @@ -0,0 +1,22 @@ +import { Link } from 'react-router-dom'; +import styles from './Card.module.css'; +import textContent from '../../assets/text.json'; + +const Card = ({ img, title, description, linkTo }) => { + return ( +
+
+ +

{title}

+
{description}
+ + + +
+
+ ); +}; + +export default Card; \ No newline at end of file diff --git a/client/src/components/Card/Card.module.css b/client/src/components/Card/Card.module.css new file mode 100644 index 0000000..44960a1 --- /dev/null +++ b/client/src/components/Card/Card.module.css @@ -0,0 +1,76 @@ +.cardBody { + display: flex; + height: 254px; + width: 384px; + background-color: var(--grey-light); + border-radius: 8px; + overflow: visible; +} + +.cardBody img { + width: 44px; + height: 44px; +} + +.cardBody h4 { + margin: 8px auto; + color: var(--black-main); + text-align: center; + font-size: 20px; + line-height: 24px; + font-weight: 500; +} + +.cardBody h5 { + margin: 16px auto 0 auto; + min-height: 42px; + color: var(--black-main); + text-align: center; + font-size: 14px; + line-height: 20px; + font-weight: 300; +} + +.cardBody button { + margin-top: 10px; + height: 40px; + max-width: 156px; + background-color: var(--primary-main); + border-radius: 2px; + + color: var(--white-main); + font-size: 16px; + font-weight: 400; + line-height: 24px; +} + +.cardBody button:hover { + background-color: var(--secondary-main); +} + +.cardContainer { + height: 100%; + width: 100%; + padding: 32px; + display: flex; + flex-direction: column; + align-items: center; + overflow: visible; +} + +.buttonGroup { + display: flex; + flex-direction: row; +} + +.moreInfo { + line-height: 10px !important; + background-color: var(--grey-light) !important; + color: var(--black-light) !important; +} + +.moreInfo:hover, +.moreInfo:focus, +.moreInfo:active { + border: none; +} \ No newline at end of file diff --git a/client/src/components/InstanceInfo/InstanceInfo.jsx b/client/src/components/InstanceInfo/InstanceInfo.jsx new file mode 100644 index 0000000..59958bc --- /dev/null +++ b/client/src/components/InstanceInfo/InstanceInfo.jsx @@ -0,0 +1,74 @@ +import styles from './InstanceInfo.module.css'; +import instanceCompletedSvg from '../../assets/img/instance-completed.svg'; +import instanceCanceledSvg from '../../assets/img/instance-canceled.svg'; +import textContent from '../../assets/text.json'; +import { api } from '../../api'; +import { useState } from 'react'; +import StatusLoader from '../StatusLoader/StatusLoader.jsx'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateWorkflowDefinitions } from '../../store/actions/workflows.action.js'; + +const InstanceInfo = ({ workflowId, instance }) => { + const dispatch = useDispatch(); + const workflows = useSelector(state => state.workflows.workflows); + const [isStatusRefreshing, setIsStatusRefreshing] = useState(false); + + const { lastCompletedStep, totalSteps, workflowStatus } = instance; + const progress = Math.min((lastCompletedStep / totalSteps) * 100, 100); + const isFailed = workflowStatus.toLowerCase() === 'failed'; + const isInProgress = workflowStatus.toLowerCase() === 'in progress'; + const isSuccessful = workflowStatus.toLowerCase() === 'completed'; + + const handleCancelInstance = async () => { + setIsStatusRefreshing(true); + await api.workflows.cancelWorkflowInstance(workflowId, instance.id); + + const updatedWorkflows = workflows.map(workflow => { + if (workflow.id !== workflowId) return workflow; + + const newInstanceList = workflow.instances.map(inst => { + if (inst.id !== instance.id) return inst; + + const updatedInstance = { ...inst, workflowStatus: 'Canceled' }; + return updatedInstance; + }) + + const updatedWorkflow = { ...workflow, instances: newInstanceList }; + return updatedWorkflow; + }) + + dispatch(updateWorkflowDefinitions(updatedWorkflows)); + setIsStatusRefreshing(false); + } + + return ( +
+
+

{instance.name}

+
+ +
+ {isStatusRefreshing ? ( + + ) : isInProgress || isFailed ? ( + <> + +
{instance.workflowStatus}
+ + ) : ( +

{workflowStatus}{workflowStatus}

+ )} +
+ +
+

{instance.startedByName}

+
+ +
+ +
+
+ ); +} + +export default InstanceInfo; diff --git a/client/src/components/InstanceInfo/InstanceInfo.module.css b/client/src/components/InstanceInfo/InstanceInfo.module.css new file mode 100644 index 0000000..b12814a --- /dev/null +++ b/client/src/components/InstanceInfo/InstanceInfo.module.css @@ -0,0 +1,113 @@ +.instanceListRow { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.cell { + padding: 24px; +} + +.cell p { + text-align: start !important; + width: 110px !important; + padding: 0; + margin: 0; + font-size: 12px; + font-weight: 400; + color: var(--black-extralight); +} + +.cell h4 { + min-width: 145px; + padding: 0; + margin: 0; + font-size: 14px; + font-weight: 400; + color: var(--black-main); + text-align: left; +} + +.cell h5 { + min-width: 145px; + padding: 0; + margin: 0; + font-size: 12px; + font-weight: 400; + color: var(--black-main); + text-align: left; +} + +.cell img { + padding-right: 10px; +} + +.cell button { + padding: 0; + margin: 0; + height: 36px; + width: 150px; + border-radius: 2px; + font-size: 14px; + font-weight: 500; + color: var(--black-main); + background-color: var(--grey-main); + border: 1px solid var(--black-extralight); +} + +progress { + color: green; + height: 9px; + width: 100%; + border-radius: 10px; + margin: -10px 0 10px; + + /* Firefox: Unfilled portion of the progress bar */ + background: rgb(222, 222, 222); +} + +/* Firefox: Filled portion of the progress bar */ +progress::-moz-progress-bar { + background: currentColor; + border-radius: 10px; +} + +/* Chrome & Safari: Unfilled portion of the progress bar */ +progress::-webkit-progress-bar { + background: rgb(223, 223, 223); + border-radius: 10px; +} + +/* Chrome & Safari: Filled portion of the progress bar */ +progress::-webkit-progress-value { + background: currentColor; + border-radius: 10px; +} + +.progressFail { + color: darkred; +} +.progressSuccess { + color: green; +} + +.instanceListRow button:disabled, +.instanceListRow button[disabled] { + pointer-events: none; + background-color: var(--gray-light) !important; + color: var(--gray-dark); + cursor: not-allowed; + opacity: 0.6; +} + +.instanceListRow button { + max-height: 32px; + font-size: 16px; + font-weight: 400; + background-color: var(--white-main); +} + +.instanceListRow button:hover { + cursor: pointer; + background-color: var(--blue-light); +} diff --git a/client/src/components/InstanceList/InstanceList.jsx b/client/src/components/InstanceList/InstanceList.jsx new file mode 100644 index 0000000..a27eece --- /dev/null +++ b/client/src/components/InstanceList/InstanceList.jsx @@ -0,0 +1,35 @@ +import styles from './InstanceList.module.css'; +import textContent from '../../assets/text.json'; +import InstanceInfo from '../InstanceInfo/InstanceInfo' + +const InstanceList = ({ workflowId, items }) => { + if (!items?.length) + return ( +
+
+

{textContent.instanceList.doNotHaveInstance}

+

{textContent.instanceList.pleaseTriggerWorkflow}

+
+
+ ); + + return ( +
+
+
+

{textContent.instanceList.columns.instance}

+

{textContent.instanceList.columns.progress}

+

{textContent.instanceList.columns.startedBy}

+
+ +
+ {items.map((item) => ( + + ))} +
+
+
+ ); +}; + +export default InstanceList; diff --git a/client/src/components/InstanceList/InstanceList.module.css b/client/src/components/InstanceList/InstanceList.module.css new file mode 100644 index 0000000..2c820cb --- /dev/null +++ b/client/src/components/InstanceList/InstanceList.module.css @@ -0,0 +1,23 @@ +.instanceList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.headerRow { + position: sticky; + top: -1rem; + background-color: var(--white-main); + flex-direction: row; + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + height: 30px; + width: 100%; + justify-items: center; + justify-self: center; + align-items: center; + gap: 8px; + margin-top: 0; + margin-bottom: 0; +} diff --git a/client/src/components/LoginForm/LoginForm.jsx b/client/src/components/LoginForm/LoginForm.jsx index f10cee9..1fb1e91 100644 --- a/client/src/components/LoginForm/LoginForm.jsx +++ b/client/src/components/LoginForm/LoginForm.jsx @@ -26,7 +26,7 @@ const LoginForm = ({ togglePopup }) => { if (authType === LoginStatus.JWT) { const { data: userInfo } = await api.jwt.login(); dispatch(authorizeUser(authType, userInfo.name, userInfo.email)); - navigate(ROUTE.TRIGGER); + navigate(ROUTE.HOME); dispatch(closePopupWindow()); dispatch(closeLoadingCircleInPopup()); } diff --git a/client/src/components/Popups/WorkflowTriggerResult/WorkflowTriggerResult.jsx b/client/src/components/Popups/WorkflowTriggerResult/WorkflowTriggerResult.jsx index 0fb69ba..b174301 100644 --- a/client/src/components/Popups/WorkflowTriggerResult/WorkflowTriggerResult.jsx +++ b/client/src/components/Popups/WorkflowTriggerResult/WorkflowTriggerResult.jsx @@ -13,7 +13,7 @@ const WorkflowTriggerResult = ({ workflowInstanceUrl }) => { const handleFinishTrigger = async () => { dispatch(closePopupWindow()); - navigate(ROUTE.TRIGGER); + navigate(ROUTE.HOME); }; return ( diff --git a/client/src/components/StatusLoader/StatusLoader.jsx b/client/src/components/StatusLoader/StatusLoader.jsx new file mode 100644 index 0000000..d59dd53 --- /dev/null +++ b/client/src/components/StatusLoader/StatusLoader.jsx @@ -0,0 +1,15 @@ +import styles from './StatusLoader.module.css'; + +const StatusLoader = () => { + return ( +
+
+
+
+
+
+
+ ); +}; + +export default StatusLoader; \ No newline at end of file diff --git a/client/src/components/StatusLoader/StatusLoader.module.css b/client/src/components/StatusLoader/StatusLoader.module.css new file mode 100644 index 0000000..4ee921d --- /dev/null +++ b/client/src/components/StatusLoader/StatusLoader.module.css @@ -0,0 +1,46 @@ +.loader { + --size: 4px; + + display: flex; + justify-content: center; + align-items: center; + gap: calc(var(--size)); + height: calc(var(--size) * 5); + width: 80px; +} + +.loader div { + width: var(--size); + height: var(--size); + border-radius: var(--size); + background-color: #ffd700; + animation: wave 2s infinite ease-in-out; +} + +@keyframes wave { + 25% { + height: calc(var(--size) * 5); + background-color: #fc00ff; + } + + 50% { + height: var(--size); + background-color: #9c73f8; + } +} + +.loader :nth-child(2) { + animation-delay: 0.2s; +} + +.loader :nth-child(3) { + animation-delay: 0.4s; +} + +.loader :nth-child(4) { + animation-delay: 0.6s; +} + +.loader :nth-child(5) { + animation-delay: 0.8s; +} \ No newline at end of file diff --git a/client/src/components/TriggerForm/TriggerForm.jsx b/client/src/components/TriggerForm/TriggerForm.jsx index 2b99559..448e925 100644 --- a/client/src/components/TriggerForm/TriggerForm.jsx +++ b/client/src/components/TriggerForm/TriggerForm.jsx @@ -49,10 +49,10 @@ const TriggerForm = ({ workflowId, templateType }) => { case "-": try { api.workflows.getWorkflowTriggerRequirements(workflowId).then(data => { - const result = Object.values(data.data.trigger_input_schema) - .filter(entry => entry.field_name !== "startDate") + const result = Object.values(data.data.triggerInputSchema) + .filter(entry => entry.fieldName !== "startDate") .map(entry => ({ - field_name: entry.field_name, + fieldName: entry.fieldName, })); setRelevantFormFields(generateDynamicForm(result, 'Custom')); @@ -66,8 +66,8 @@ const TriggerForm = ({ workflowId, templateType }) => { const generateDynamicForm = (fieldNames) => { return fieldNames.map((field) => ({ - fieldHeader: field.field_name, - fieldName: field.field_name, + fieldHeader: field.fieldName, + fieldName: field.fieldName, value: '', })); }; @@ -80,7 +80,7 @@ const TriggerForm = ({ workflowId, templateType }) => { const handleCloseTriggerPopup = () => { dispatch(closePopupWindow()); - navigate(ROUTE.TRIGGER); + navigate(ROUTE.HOME); }; const handleSubmit = async event => { diff --git a/client/src/components/WorkflowDescription/WorkflowDescription.jsx b/client/src/components/WorkflowDescription/WorkflowDescription.jsx index 3d63d91..4e1b4df 100644 --- a/client/src/components/WorkflowDescription/WorkflowDescription.jsx +++ b/client/src/components/WorkflowDescription/WorkflowDescription.jsx @@ -2,15 +2,15 @@ import { Link } from 'react-router-dom'; import styles from './WorkflowDescription.module.css'; import textContent from '../../assets/text.json'; -const WorkflowDescription = ({ title, behindTheScenesComponent, backRoute }) => { +const WorkflowDescription = ({ title, behindTheScenesComponent, backRoute, backText }) => { return (
{backRoute && ( - + )} - +

{title}

)} -
= 2 ? listStyles : {}}> + {interactionType === WorkflowItemsInteractionType.MANAGE && ( +
+

{textContent.workflowList.columns.workflowName}

+

{textContent.workflowList.columns.workflowStatus}

+
+ )} + +
= 2 ? listStyles : {}}> {items.map((item, idx) => (
-
+
toggleDropdown(idx)}> + {interactionType === WorkflowItemsInteractionType.MANAGE && ( + + )}

{WorkflowItemsInteractionType.TRIGGER ? item.name : item.instanceName}

+ {interactionType === WorkflowItemsInteractionType.MANAGE && ( +
+ {loadingWorkflow.isLoading && loadingWorkflow.id === item.id ? ( + + ) : ( + + )} +
+ )} + {interactionType === WorkflowItemsInteractionType.TRIGGER && ( )} + + {interactionType === WorkflowItemsInteractionType.MANAGE && ( + + )} + + {interactionType === WorkflowItemsInteractionType.MANAGE && openLists.has(idx) && ( +
+ +
+ )}
))}
-
+
); }; diff --git a/client/src/components/WorkflowList/WorkflowList.module.css b/client/src/components/WorkflowList/WorkflowList.module.css index 628bea1..beafe5d 100644 --- a/client/src/components/WorkflowList/WorkflowList.module.css +++ b/client/src/components/WorkflowList/WorkflowList.module.css @@ -1,5 +1,6 @@ .listGroup { width: 100%; + @media screen and (min-width: 992px) { margin-left: 34px; max-height: 62vh; @@ -56,7 +57,7 @@ border: 1px solid var(--black-extralight); } -.list { +.list2 { max-height: 63.5vh; padding: 1rem 0; -webkit-overflow-scrolling: touch; @@ -69,27 +70,51 @@ background-color: var(--white-main); } -.list > .listRow { +.list3 { + max-height: 63.5vh; + padding: 1rem 0; + -webkit-overflow-scrolling: touch; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + width: 100%; + justify-items: normal; + align-items: center; + gap: 14px; + background-color: var(--white-main); +} + +.list>.listRow { display: contents; } -.list > .listRow > * { +.list>.listRow>* { text-align: center; } -.list > .listRow > *:nth-child(1) { +.list>.listRow>*:nth-child(1) { justify-self: start; } -.list > .listRow > *:nth-child(2) { +.list>.listRow>*:nth-child(2) { justify-self: center; } -.list > .listRow > *:nth-child(3) { +.list>.listRow>*:nth-child(3) { justify-self: end; } -.listRow .cell1 { +.listRow .cell1, .dropdownCell { + display: flex; + flex-direction: row; + gap: 16px; + margin-left: 10px; +} + +.listRow .dropdownCell:hover { + cursor: pointer; +} + +.listRow .cell2 { display: flex; flex-direction: row; gap: 16px; @@ -100,13 +125,30 @@ margin-right: 10px; } +.listRow .instanceListRow { + grid-column: 1 / -1; +} + .headerRow { - display: flex; flex-direction: row; width: 100%; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + width: 100%; + justify-items: normal; + align-items: center; + gap: 14px; margin-bottom: 0.5rem; } +.headerRow .nameHeader { + justify-self: center; +} + +.headerRow .statusHeader { + justify-self: center; +} + .headerRow div { display: flex; flex-direction: row; @@ -186,6 +228,20 @@ background-color: var(--primary-main); } +.dropdownItemDisabled { + pointer-events: none; + background-color: var(--gray-light) !important; + color: var(--gray-dark); + cursor: not-allowed; + opacity: 0.6; +} + +.dropdownItemDisabled:hover, +.dropdownItemDisabled:active { + background-color: var(--gray-light) !important; + cursor: not-allowed; +} + .emptyListContainer { display: flex; flex-direction: column; @@ -235,4 +291,4 @@ height: 40px; text-align: center; text-decoration: none; -} +} \ No newline at end of file diff --git a/client/src/components/WorkflowStatusPill/WorkflowStatusPill.jsx b/client/src/components/WorkflowStatusPill/WorkflowStatusPill.jsx new file mode 100644 index 0000000..0570a6c --- /dev/null +++ b/client/src/components/WorkflowStatusPill/WorkflowStatusPill.jsx @@ -0,0 +1,11 @@ +import styles from './WorkflowStatusPill.module.css'; + +const WorkflowStatusPill = ({ status }) => { + return ( +
+ {status} +
+ ); +} + +export default WorkflowStatusPill; \ No newline at end of file diff --git a/client/src/components/WorkflowStatusPill/WorkflowStatusPill.module.css b/client/src/components/WorkflowStatusPill/WorkflowStatusPill.module.css new file mode 100644 index 0000000..07bae72 --- /dev/null +++ b/client/src/components/WorkflowStatusPill/WorkflowStatusPill.module.css @@ -0,0 +1,18 @@ +.workflowStatusPill { + height: 20px; + width: 80px; + border-radius: 100px; + text-align: center; + font-size: 12px; + font-weight: 400; +} + +.active { + background-color: var(--green-light); + color: var(--green-main); +} + +.paused { + background-color: var(--red-light); + color: var(--error-main); +} \ No newline at end of file diff --git a/client/src/constants.js b/client/src/constants.js index 4e9656e..7d5fab5 100644 --- a/client/src/constants.js +++ b/client/src/constants.js @@ -1,6 +1,8 @@ export const ROUTE = { ROOT: '/', + HOME: '/home', TRIGGER: '/trigger-workflow', + MANAGE: '/manage-workflow', TRIGGERFORM: '/trigger-workflow/form', }; @@ -11,6 +13,12 @@ export const LoginStatus = { export const WorkflowItemsInteractionType = { TRIGGER: 'Trigger', + MANAGE: 'Manage', +}; + +export const WorkflowStatus = { + active: 'active', + paused: 'paused', }; export const WorkflowTriggerResponse = { diff --git a/client/src/pages/Hero/Hero.jsx b/client/src/pages/Hero/Hero.jsx index 245f997..5f18a22 100644 --- a/client/src/pages/Hero/Hero.jsx +++ b/client/src/pages/Hero/Hero.jsx @@ -23,11 +23,11 @@ const Hero = () => { if (authType === LoginStatus.ACG) { const isLoggedIn = await api.acg.loginStatus(); - isLoggedIn && navigate(ROUTE.TRIGGER); + isLoggedIn && navigate(ROUTE.HOME); } if (authType === LoginStatus.JWT) { const isLoggedIn = await api.jwt.loginStatus(); - isLoggedIn && navigate(ROUTE.TRIGGER); + isLoggedIn && navigate(ROUTE.HOME); } }; diff --git a/client/src/pages/Home/Home.jsx b/client/src/pages/Home/Home.jsx new file mode 100644 index 0000000..06021d2 --- /dev/null +++ b/client/src/pages/Home/Home.jsx @@ -0,0 +1,42 @@ +import { useSelector } from 'react-redux'; +import styles from './Home.module.css'; +import Header from '../../components/Header/Header.jsx'; +import Footer from '../../components/Footer/Footer.jsx'; +import Card from '../../components/Card/Card.jsx'; +import withAuth from '../../hocs/withAuth/withAuth.jsx'; +import { ROUTE } from '../../constants.js'; +import textContent from '../../assets/text.json'; +import img2 from '../../assets/img/workflow-trigger.svg'; +import img3 from '../../assets/img/workflow-manage.svg'; + +const Home = () => { + return ( +
+
+
+

{textContent.hero.title}

+

{textContent.hero.paragraph}

+
+
+ + +
+
+ ); +}; + +const HomeAuthenticated = withAuth(Home); +export default HomeAuthenticated; \ No newline at end of file diff --git a/client/src/pages/Home/Home.module.css b/client/src/pages/Home/Home.module.css new file mode 100644 index 0000000..0f52181 --- /dev/null +++ b/client/src/pages/Home/Home.module.css @@ -0,0 +1,34 @@ +.messageBox { + display: flex; + flex-direction: column; + margin-top: 2.5%; +} + +.messageBox h1 { + text-align: center; + margin-bottom: 25px; + + color: var(--black-main); + line-height: 72px; + font-size: 36px; + font-weight: 600; +} + +.messageBox p { + text-align: center; + line-height: 28px; + margin-top: -1.5em; + + color: var(--black-light); + font-size: 18px; + font-weight: 300; +} + +.cardContainer { + margin: 2em auto 2.8em auto; + display: flex; + flex-direction: row; + justify-content: center; + gap: 24px; + overflow: visible; +} \ No newline at end of file diff --git a/client/src/pages/ManageWorkflow/ManageWorkflow.jsx b/client/src/pages/ManageWorkflow/ManageWorkflow.jsx new file mode 100644 index 0000000..db7aeed --- /dev/null +++ b/client/src/pages/ManageWorkflow/ManageWorkflow.jsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import styles from './ManageWorkflow.module.css'; +import Header from '../../components/Header/Header.jsx'; +import Footer from '../../components/Footer/Footer.jsx'; +import textContent from '../../assets/text.json'; +import withAuth from '../../hocs/withAuth/withAuth.jsx'; +import WorkflowList from '../../components/WorkflowList/WorkflowList.jsx'; +import WorkflowDescription from '../../components/WorkflowDescription/WorkflowDescription.jsx'; +import ManageBehindTheScenes from '../../components/WorkflowDescription/BehindTheScenes/ManageBehindTheScenes.jsx'; +import { ROUTE, WorkflowItemsInteractionType, TemplateType, LoginStatus } from '../../constants.js'; +import { api } from '../../api'; +import { updateWorkflowDefinitions } from '../../store/actions'; + +const ManageWorkflow = () => { + const [isWorkflowListLoading, setWorkflowListLoading] = useState(false); + const dispatch = useDispatch(); + const location = useLocation(); + const workflows = useSelector(state => state.workflows.workflows); + const authType = useSelector(state => state.auth.authType); + + useEffect(() => { + const getWorkflowDefinitions = async () => { + setWorkflowListLoading(true); + const definitionsResponse = await api.workflows.getWorkflowDefinitions(); + const workflowDefinitions = await Promise.all(definitionsResponse.data.data.filter(definition => definition.status !== 'inactive') + .map(async definition => { + if (workflows.length) { + const foundWorkflow = workflows.find(workflow => workflow.id === definition.id); + if (foundWorkflow) return foundWorkflow; + } + + const instancesResponse = await api.workflows.getWorkflowInstances(definition.id); + const instances = instancesResponse.data.data.map(instance => { + return { + id: instance.id, + name: instance.name, + lastCompletedStep: instance.lastCompletedStep, + totalSteps: instance.totalSteps, + workflowStatus: instance.workflowStatus, + startedByName: instance.startedByName, + }; + }); + + const templateKeys = Object.keys(TemplateType); + const foundKey = templateKeys.find(key => definition.name.startsWith(TemplateType[key].name)); + if (!foundKey) { + if (authType === LoginStatus.JWT) + return null; + + return { + id: definition.id, + name: definition.name, + instanceState: definition.status, + instances: instances, + }; + } + + return { + id: definition.id, + name: `${TemplateType[foundKey]?.name}`, + instanceState: definition.status, + instances: instances, + }; + }) + .filter(definition => !!definition)); + + dispatch(updateWorkflowDefinitions(workflowDefinitions)); + setWorkflowListLoading(false); + }; + + getWorkflowDefinitions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, location.pathname]); + + return ( +
+
+
+ } + backRoute={ROUTE.HOME} + backText={textContent.buttons.backHome} + /> + +
+
+ ); +}; + +const ManageWorkflowAuthenticated = withAuth(ManageWorkflow); +export default ManageWorkflowAuthenticated; \ No newline at end of file diff --git a/client/src/pages/ManageWorkflow/ManageWorkflow.module.css b/client/src/pages/ManageWorkflow/ManageWorkflow.module.css new file mode 100644 index 0000000..bda2f13 --- /dev/null +++ b/client/src/pages/ManageWorkflow/ManageWorkflow.module.css @@ -0,0 +1,8 @@ +.contentContainer { + display: flex; + flex-direction: row; + margin-left: 5%; + margin-right: 5%; + width: 90%; + justify-content: space-between; +} \ No newline at end of file diff --git a/client/src/pages/TriggerWorkflow/TriggerWorkflow.jsx b/client/src/pages/TriggerWorkflow/TriggerWorkflow.jsx index 34c68c8..843c6d3 100644 --- a/client/src/pages/TriggerWorkflow/TriggerWorkflow.jsx +++ b/client/src/pages/TriggerWorkflow/TriggerWorkflow.jsx @@ -12,6 +12,7 @@ import TriggerBehindTheScenes from '../../components/WorkflowDescription/BehindT import { LoginStatus, TemplateType, WorkflowItemsInteractionType } from '../../constants.js'; import { api } from '../../api'; import { updateWorkflowDefinitions } from '../../store/actions'; +import { ROUTE } from '../../constants.js'; const TriggerWorkflow = () => { const dispatch = useDispatch(); @@ -24,7 +25,7 @@ const TriggerWorkflow = () => { const getWorkflowDefinitions = async () => { setWorkflowListLoading(true); const definitionsResponse = await api.workflows.getWorkflowDefinitions(); - const workflowDefinitions = definitionsResponse.data.data.workflows.filter(definition => definition.status !== 'inactive') + const workflowDefinitions = definitionsResponse.data.data.filter(definition => definition.status !== 'inactive') .map(definition => { if (workflows.length) { const foundWorkflow = workflows.find(workflow => workflow.id === definition.id); @@ -34,7 +35,7 @@ const TriggerWorkflow = () => { const templateKeys = Object.keys(TemplateType); const foundKey = templateKeys.find(key => definition.name.startsWith(TemplateType[key].name)); if (!foundKey) { - if(authType === LoginStatus.JWT) + if (authType === LoginStatus.JWT) return null; return { @@ -67,6 +68,8 @@ const TriggerWorkflow = () => { } + backRoute={ROUTE.HOME} + backText={textContent.buttons.backHome} /> { const triggerUrl = searchParams.get('triggerUrl'); const triggerUrlPattern = /^https:\/\/(?!.*javascript)[^()]+$/i; - -function isValidTriggerUrl(url) { - try { - const decoded = decodeURIComponent(url); - const parsedUrl = new URL(decoded); - // Only allow https and the exact hostname - return ( - parsedUrl.protocol === 'https:' && - parsedUrl.hostname === 'apps-d.docusign.com' - ); - } catch { - return false; + + function isValidTriggerUrl(url) { + try { + const decoded = decodeURIComponent(url); + const parsedUrl = new URL(decoded); + // Only allow https and the exact hostname + return ( + parsedUrl.protocol === 'https:' && + parsedUrl.hostname === 'apps-d.docusign.com' + ); + } catch { + return false; + } } -} - + if (triggerUrl !== null && isValidTriggerUrl(triggerUrl)) { return (
@@ -42,6 +42,7 @@ function isValidTriggerUrl(url) { title={textContent.pageTitles.completeWorkflow} behindTheScenesComponent={} backRoute={ROUTE.TRIGGER} + backText={textContent.buttons.backToWorkflows} />
@@ -62,6 +63,7 @@ function isValidTriggerUrl(url) { title={textContent.pageTitles.triggerWorkflow} behindTheScenesComponent={} backRoute={ROUTE.TRIGGER} + backText={textContent.buttons.backToWorkflows} />
diff --git a/server/api/apiFactory.js b/server/api/apiFactory.js deleted file mode 100644 index 759facd..0000000 --- a/server/api/apiFactory.js +++ /dev/null @@ -1,63 +0,0 @@ -const configureInterceptors = (api, accessToken) => { - // Request interceptor for API calls - api.interceptors.request.use( - async config => { - config.headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - config.headers.Authorization = `Bearer ${accessToken}`; - return config; - }, - error => { - Promise.reject(error); - } - ); - - api.interceptors.response.use( - response => response, - error => { - // eslint-disable-next-line no-console - console.error(`API call failed. Error: ${error}`); - return Promise.reject(error); - } - ); - return api; -}; - -const createAPI = (axios, accessToken) => { - const api = configureInterceptors( - axios.create({ - withCredentials: false, - }), - accessToken - ); - return api; -}; - -const createMaestroApi = (axios, basePath, accountId, accessToken) => { - const api = createAPI(axios, accessToken); - - const getWorkflowDefinitions = async params => { - const response = await api.get(`${basePath}/accounts/${accountId}/workflows`, { params }); - return response.data; - }; - - const getTriggerRequirements = async workflowId => { - const response = await api.get(`${basePath}/accounts/${accountId}/workflows/${workflowId}/trigger-requirements`); - return response.data; - }; - - const triggerWorkflow = async (args, triggerUrl) => { - const response = await api.post(triggerUrl, args); - return response.data; - }; - - return { - getWorkflowDefinitions, - getTriggerRequirements, - triggerWorkflow, - }; -}; - -module.exports = { createMaestroApi }; diff --git a/server/api/index.js b/server/api/index.js deleted file mode 100644 index 1b6ac16..0000000 --- a/server/api/index.js +++ /dev/null @@ -1,6 +0,0 @@ -const axios = require('axios'); -const { createMaestroApi } = require('./apiFactory'); - -const initMaestroApi = (accountId, basePath, accessToken) => createMaestroApi(axios, basePath, accountId, accessToken); - -module.exports = { initMaestroApi }; diff --git a/server/constants.js b/server/constants.js index f82a176..05986eb 100644 --- a/server/constants.js +++ b/server/constants.js @@ -18,7 +18,7 @@ const ISSUES = { TRIGGER_ISSUE: 'Incompatible workflow', }; -const MAESTRO_SCOPES = ['signature', 'aow_manage', 'impersonation']; +const MAESTRO_SCOPES = ['signature', 'aow_manage']; module.exports = { scopes: MAESTRO_SCOPES, diff --git a/server/controllers/workflowsController.js b/server/controllers/workflowsController.js index c8ee2c0..8f2bf79 100644 --- a/server/controllers/workflowsController.js +++ b/server/controllers/workflowsController.js @@ -83,11 +83,72 @@ class WorkflowsController { } }; + static pauseWorkflow = async (req, res) => { + try { + const args = { + workflowId: req.params.definitionId, + accessToken: req?.user?.accessToken || req?.session?.accessToken, + accountId: req.session.accountId, + }; + + const result = await WorkflowsService.pauseWorkflow(args); + res.status(200).send(result); + } catch (error) { + this.handleErrorResponse(error, res); + } + }; + + static resumePausedWorkflow = async (req, res) => { + try { + const args = { + workflowId: req.params.definitionId, + accessToken: req?.user?.accessToken || req?.session?.accessToken, + accountId: req.session.accountId, + }; + + const result = await WorkflowsService.resumePausedWorkflow(args); + res.status(200).send(result); + } catch (error) { + this.handleErrorResponse(error, res); + } + }; + + static getInstances = async (req, res) => { + try { + const args = { + workflowId: req.params.definitionId, + accessToken: req?.user?.accessToken || req?.session?.accessToken, + accountId: req.session.accountId, + }; + + const result = await WorkflowsService.getInstances(args); + res.status(200).send(result); + } catch (error) { + this.handleErrorResponse(error, res); + } + }; + + static cancelWorkflow = async (req, res) => { + try { + const args = { + workflowId: req.params.definitionId, + instanceId: req.params.instanceId, + accessToken: req?.user?.accessToken || req?.session?.accessToken, + accountId: req.session.accountId, + }; + + const result = await WorkflowsService.cancelWorkflow(args); + res.status(200).send(result); + } catch (error) { + this.handleErrorResponse(error, res); + } + }; + static handleErrorResponse(error, res) { this.logger.error(`handleErrorResponse: ${error}`); - const errorCode = error?.response?.statusCode; - const errorMessage = error?.response?.body?.message; + const errorCode = error?.response?.statusCode || error?.statusCode; + const errorMessage = error?.response?.body?.message || error?.message || error?.rawMessage; // use custom error message if Maestro is not enabled for the account if (errorCode === 403) { diff --git a/server/package-lock.json b/server/package-lock.json index 44ce19b..887722a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "server", "version": "1.0.0", "dependencies": { + "@docusign/iam-sdk": "1.0.0-beta.3", "axios": "^1.7.7", "body-parser": "^1.20.3", "chalk": "^4.1.2", @@ -62,6 +63,14 @@ "resolved": "https://registry.npmjs.org/@devhigley/parse-proxy/-/parse-proxy-1.0.3.tgz", "integrity": "sha512-ozRQ9CgWF4JXNNae1zUEpb2fbqH61oxtZz2sdR7a0ci5mi9pSP3EvoU7g4idZYi+CXP32gsvH7kTYZJCGW3DKQ==" }, + "node_modules/@docusign/iam-sdk": { + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@docusign/iam-sdk/-/iam-sdk-1.0.0-beta.3.tgz", + "integrity": "sha512-kK3lftOtfc+smFcmjURy9XHwhFQ1aXFj+hpRnWjLWl5juXi5/J79xILJVasJyX25T4GEvJP1946BgfpllNn9xg==", + "peerDependencies": { + "zod": ">= 3" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -4758,6 +4767,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.10.tgz", + "integrity": "sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/server/package.json b/server/package.json index c9370dd..f0700fa 100644 --- a/server/package.json +++ b/server/package.json @@ -11,6 +11,7 @@ }, "author": "", "dependencies": { + "@docusign/iam-sdk": "1.0.0-beta.3", "axios": "^1.7.7", "body-parser": "^1.20.3", "chalk": "^4.1.2", diff --git a/server/routes/workflowsRouter.js b/server/routes/workflowsRouter.js index 080cd0a..a32635a 100644 --- a/server/routes/workflowsRouter.js +++ b/server/routes/workflowsRouter.js @@ -5,6 +5,10 @@ const authMiddleware = require('../middlewares/authMiddleware'); const router = Router(); router.put('/:definitionId/trigger', authMiddleware, workflowsController.triggerWorkflow); +router.post('/:definitionId/pause', authMiddleware, workflowsController.pauseWorkflow); +router.post('/:definitionId/resume', authMiddleware, workflowsController.resumePausedWorkflow); +router.get('/:definitionId/instances', authMiddleware, workflowsController.getInstances); +router.post('/:definitionId/instances/:instanceId/cancel', authMiddleware, workflowsController.cancelWorkflow); router.get('/definitions', authMiddleware, workflowsController.getWorkflowDefinitions); router.get('/:definitionId/requirements', authMiddleware, workflowsController.getWorkflowTriggerRequirements); diff --git a/server/services/workflowsService.js b/server/services/workflowsService.js index 544823f..34eb6f4 100644 --- a/server/services/workflowsService.js +++ b/server/services/workflowsService.js @@ -2,40 +2,82 @@ * @file * This file handles work with docusign maestro and esign services. * Scenarios implemented: - * - Workflow definition creation. - * - Workflow definition publishing. * - Workflow definition triggering, which create workflow instance. - * - Workflow instance cancellation. - * - Workflow instance fetching. + * - Workflow definitions fetching. + * - Workflow trigger requirements fetching. */ -const { initMaestroApi } = require('../api'); +const iam = require('@docusign/iam-sdk'); class WorkflowsService { static getWorkflowDefinitions = async args => { - const api = initMaestroApi(args.accountId, args.basePath, args.accessToken); - const definitions = await api.getWorkflowDefinitions({}); + const client = new iam.IamClient({ accessToken: args.accessToken }); + const definitions = await client.maestro.workflows.getWorkflowsList({ accountId: args.accountId }); return definitions; }; static getWorkflowTriggerRequirements = async args => { - const api = initMaestroApi(args.accountId, args.basePath, args.accessToken); - const triggerRequirements = await api.getTriggerRequirements(args.workflowId); + const client = new iam.IamClient({ accessToken: args.accessToken }); + const triggerRequirements = await client.maestro.workflows.getWorkflowTriggerRequirements({ + accountId: args.accountId, + workflowId: args.workflowId, + }); return triggerRequirements; }; - static triggerWorkflowInstance = async (args, payload, triggerRequirements) => { - const api = initMaestroApi(args.accountId, args.basePath, args.accessToken); + static triggerWorkflowInstance = async (args, payload) => { + const client = new iam.IamClient({ accessToken: args.accessToken }); const triggerPayload = { instance_name: 'test', trigger_inputs: payload, }; - const triggerResponse = await api.triggerWorkflow(triggerPayload, triggerRequirements.trigger_http_config.url); + const triggerResponse = await client.maestro.workflows.triggerWorkflow({ + accountId: args.accountId, + workflowId: args.workflowId, + triggerWorkflow: triggerPayload, + }); return triggerResponse; }; + + static pauseWorkflow = async args => { + const client = new iam.IamClient({ accessToken: args.accessToken }); + + return await client.maestro.workflows.pauseNewWorkflowInstances({ + accountId: args.accountId, + workflowId: args.workflowId, + }); + }; + + static resumePausedWorkflow = async args => { + const client = new iam.IamClient({ accessToken: args.accessToken }); + + return await client.maestro.workflows.resumePausedWorkflow({ + accountId: args.accountId, + workflowId: args.workflowId, + }); + }; + + static getInstances = async args => { + const client = new iam.IamClient({ accessToken: args.accessToken }); + + return await client.maestro.workflowInstanceManagement.getWorkflowInstancesList({ + accountId: args.accountId, + workflowId: args.workflowId, + }); + }; + + static cancelWorkflow = async args => { + const client = new iam.IamClient({ accessToken: args.accessToken }); + + return await client.maestro.workflowInstanceManagement.cancelWorkflowInstance({ + accountId: args.accountId, + workflowId: args.workflowId, + instanceId: args.instanceId, + }); + }; } module.exports = WorkflowsService;