Skip to content

Commit a4e791f

Browse files
committed
feat: reorder step across approval branches
1 parent fd80e73 commit a4e791f

File tree

5 files changed

+196
-39
lines changed

5 files changed

+196
-39
lines changed

packages/backend/src/graphql/__tests__/mutations/update-step-positions.itest.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ describe('updateStepPositions mutation', () => {
6969
// Set up flow patch and fetch spy first
7070
flowPatchAndFetchSpy = vi.fn().mockReturnValue({
7171
withGraphFetched: vi.fn().mockReturnValue({
72-
orderBy: vi.fn().mockResolvedValue([]),
72+
orderBy: vi.fn().mockResolvedValue({
73+
steps: MOCK_STEPS,
74+
}),
7375
}),
7476
})
7577
stepPatchSpy = vi.fn().mockResolvedValue({})

packages/backend/src/graphql/mutations/update-step-positions.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { IStep } from '@plumber/types'
22

3+
import { PartialModelObject, raw } from 'objection'
4+
35
import { BadUserInputError } from '@/errors/graphql-errors'
6+
import logger from '@/helpers/logger'
47
import Step from '@/models/step'
58

69
import type { MutationResolvers } from '../__generated__/types.generated'
@@ -77,9 +80,17 @@ const updateStepPositions: MutationResolvers['updateStepPositions'] = async (
7780

7881
// Patch each step individually with its new position
7982
for (const stepPosition of stepPositions) {
80-
await Step.query(trx).findById(stepPosition.id).patch({
83+
const patchData: PartialModelObject<Step> = {
8184
position: stepPosition.position,
82-
})
85+
}
86+
if (stepPosition.config) {
87+
patchData.config = stepPosition.config.approval
88+
? raw(`jsonb_set(config, '{approval}', ?::jsonb, true)`, [
89+
JSON.stringify(stepPosition.config?.approval),
90+
])
91+
: raw(`config - 'approval'`)
92+
}
93+
await Step.query(trx).findById(stepPosition.id).patch(patchData)
8394
}
8495

8596
// Update the flow's lastUpdatedAt timestamp
@@ -91,6 +102,21 @@ const updateStepPositions: MutationResolvers['updateStepPositions'] = async (
91102
.withGraphFetched('steps')
92103
.orderBy('steps.position', 'asc')
93104

105+
// sanity check that all step positions are contiguous
106+
const contiguousPositions = updatedFlow.steps.map((step) => step.position)
107+
if (
108+
!contiguousPositions.every((position, index) => position === index + 1)
109+
) {
110+
logger.error({
111+
message: 'Updated positions are no longer contiguous',
112+
stepPositions,
113+
flowId: flow.id,
114+
})
115+
throw new BadUserInputError(
116+
'Failed to update: updated positions are no longer contiguous',
117+
)
118+
}
119+
94120
return updatedFlow
95121
})
96122

packages/backend/src/graphql/schema.graphql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ input StepPositionInput {
619619
id: String!
620620
position: Int!
621621
type: StepEnumType!
622+
config: StepConfigInput
622623
}
623624

624625
input UpdateStepPositionsInput {
@@ -726,8 +727,8 @@ type StepTemplateConfig {
726727
}
727728

728729
type StepApprovalConfig {
729-
branch: String
730-
stepId: String
730+
branch: String!
731+
stepId: String!
731732
}
732733

733734
input StepConnectionInput {

packages/frontend/src/components/Editor/components/StepsList.tsx

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { IStep } from '@plumber/types'
22

3-
import { useContext } from 'react'
3+
import { useCallback, useContext } from 'react'
44
import { Center, Flex } from '@chakra-ui/react'
55

66
import PrimarySpinner from '@/components/PrimarySpinner'
77
import { SortableList } from '@/components/SortableList'
88
import { EditorContext } from '@/contexts/Editor'
9+
import { MrfContext } from '@/contexts/MrfContext'
910
import { StepsToDisplayContext } from '@/contexts/StepsToDisplay'
1011
import { FlowStepGroup } from '@/exports/components'
11-
import { StepEnumType } from '@/graphql/__generated__/graphql'
1212
import { TOOLBOX_ACTIONS } from '@/helpers/toolbox'
1313
import useReorderSteps from '@/hooks/useReorderSteps'
1414

@@ -30,26 +30,43 @@ export function StepsList({ isNested }: StepsListProps) {
3030
groupingActions,
3131
} = useContext(StepsToDisplayContext)
3232
const { flow, isDrawerOpen, isMobile, readOnly } = useContext(EditorContext)
33+
const { mrfSteps, mrfApprovalSteps, approvalBranches } =
34+
useContext(MrfContext)
3335

34-
const { handleReorderUpdate } = useReorderSteps(flow.id)
36+
const { calculateReorderedSteps, handleReorderUpdate } = useReorderSteps(
37+
flow.id,
38+
)
3539

36-
const handleReorderSteps = async (reorderedSteps: IStep[]) => {
37-
const stepPositions = reorderedSteps.map((step, index) => ({
38-
id: step.id,
39-
position: index + 2, // trigger position is 1
40-
type: step.type as StepEnumType,
41-
}))
40+
const handleReorderSteps = useCallback(
41+
async (reorderedSteps: IStep[]) => {
42+
const allSteps = flow.steps
43+
const allReorderedSteps = calculateReorderedSteps({
44+
reorderedSteps,
45+
allSteps,
46+
mrfSteps,
47+
mrfApprovalSteps,
48+
approvalBranches,
49+
})
4250

43-
try {
44-
await handleReorderUpdate(stepPositions)
45-
} catch (error) {
46-
console.error(
47-
'Error updating step positions: ',
48-
error,
49-
JSON.stringify(stepPositions),
50-
)
51-
}
52-
}
51+
try {
52+
await handleReorderUpdate(allReorderedSteps)
53+
} catch (error) {
54+
console.error(
55+
'Error updating step positions: ',
56+
error,
57+
JSON.stringify(allReorderedSteps),
58+
)
59+
}
60+
},
61+
[
62+
flow.steps,
63+
calculateReorderedSteps,
64+
mrfSteps,
65+
mrfApprovalSteps,
66+
approvalBranches,
67+
handleReorderUpdate,
68+
],
69+
)
5370

5471
const nonIfThenActionSteps = actionStepsBeforeGroup.filter(
5572
(step) => step.key !== TOOLBOX_ACTIONS.IfThen,

packages/frontend/src/hooks/useReorderSteps.ts

Lines changed: 126 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,128 @@
1-
import { IStep } from '@plumber/types'
1+
import { IStep, IStepApprovalBranch } from '@plumber/types'
22

3-
import { useContext } from 'react'
3+
import { useCallback, useContext } from 'react'
44
import { useMutation } from '@apollo/client'
55

66
import { EditorContext } from '@/contexts/Editor'
7-
import { StepEnumType } from '@/graphql/__generated__/graphql'
7+
import {
8+
StepEnumType,
9+
StepPositionInput,
10+
} from '@/graphql/__generated__/graphql'
811
import { UPDATE_STEP_POSITIONS } from '@/graphql/mutations/update-step-positions'
912
import { GET_FLOW } from '@/graphql/queries/get-flow'
1013

11-
interface StepPositionInput {
12-
id: string
13-
position: number
14-
type: StepEnumType
15-
}
16-
1714
const useReorderSteps = (flowId: string) => {
1815
const { flow } = useContext(EditorContext)
1916
const [updateStepPositions] = useMutation(UPDATE_STEP_POSITIONS, {
2017
refetchQueries: [GET_FLOW],
2118
})
2219

20+
// TODO: write unit test for this function
21+
const calculateReorderedSteps = useCallback(
22+
({
23+
reorderedSteps,
24+
allSteps,
25+
mrfSteps,
26+
mrfApprovalSteps,
27+
approvalBranches,
28+
}: {
29+
reorderedSteps: IStep[]
30+
allSteps: IStep[]
31+
mrfSteps: IStep[]
32+
mrfApprovalSteps: IStep[]
33+
approvalBranches: { [stepId: string]: IStepApprovalBranch }
34+
}): StepPositionInput[] => {
35+
// we start from 2 because the first step is the trigger step and not part of the sortable list
36+
let nextPosition = 2
37+
let currentApprovalBranch: {
38+
stepId: string
39+
branch: 'approve' | 'reject'
40+
} | null = null
41+
const allReorderedSteps: StepPositionInput[] = []
42+
reorderedSteps.forEach((reorderingStep) => {
43+
const isMrfStep = mrfSteps.some(
44+
(mrfStep) => mrfStep.id === reorderingStep.id,
45+
)
46+
47+
if (isMrfStep) {
48+
// if the previous approval config is in the approve branch
49+
// we need to add the steps in the reject branch that was hidden
50+
if (currentApprovalBranch?.branch === 'approve') {
51+
const stepsInRejectBranch = allSteps.filter(
52+
(step) =>
53+
step.config?.approval?.branch === 'reject' &&
54+
step.config?.approval?.stepId === currentApprovalBranch?.stepId,
55+
) as IStep[]
56+
stepsInRejectBranch.forEach((step) => {
57+
allReorderedSteps.push({
58+
id: step.id,
59+
position: nextPosition++,
60+
type: step.type as StepEnumType,
61+
})
62+
})
63+
}
64+
currentApprovalBranch = null
65+
}
66+
67+
// add the current step to the list
68+
allReorderedSteps.push({
69+
id: reorderingStep.id,
70+
position: nextPosition++,
71+
type: reorderingStep.type as StepEnumType,
72+
config:
73+
currentApprovalBranch?.branch === 'reject'
74+
? {
75+
approval: currentApprovalBranch,
76+
}
77+
: { approval: null }, // this sets it to approval branch
78+
})
79+
80+
const isMrfApprovalStep = mrfApprovalSteps.some(
81+
(mrfApprovalStep) => mrfApprovalStep.id === reorderingStep.id,
82+
)
83+
if (isMrfApprovalStep) {
84+
currentApprovalBranch = {
85+
branch: approvalBranches[reorderingStep.id],
86+
stepId: reorderingStep.id,
87+
}
88+
// if the current approval config is in the reject branch
89+
// we need to add the steps in the approve branch that was hidden
90+
if (currentApprovalBranch?.branch === 'reject') {
91+
const reorderingStepIndex = allSteps.findIndex(
92+
(step) => step.id === reorderingStep.id,
93+
)
94+
const stepsInApproveBranch = []
95+
// Search for non-mrf steps directly after the approval step
96+
for (let i = reorderingStepIndex + 1; i < allSteps.length; i++) {
97+
const step = allSteps[i]
98+
if (
99+
!step.config?.approval &&
100+
!mrfSteps.some((mrfStep) => mrfStep.id === step.id)
101+
) {
102+
stepsInApproveBranch.push(step)
103+
} else {
104+
break
105+
}
106+
}
107+
stepsInApproveBranch.forEach((step) => {
108+
allReorderedSteps.push({
109+
id: step.id,
110+
position: nextPosition++,
111+
type: step.type as StepEnumType,
112+
})
113+
})
114+
}
115+
}
116+
})
117+
118+
// all later steps not in the sortable list need not be updated since
119+
// reordering of visible steps will not affect them
120+
121+
return allReorderedSteps
122+
},
123+
[],
124+
)
125+
23126
const handleReorderUpdate = async (stepPositions: StepPositionInput[]) => {
24127
try {
25128
await updateStepPositions({
@@ -32,6 +135,7 @@ const useReorderSteps = (flowId: string) => {
32135
steps: stepPositions.map((sp) => ({
33136
id: sp.id,
34137
position: sp.position,
138+
config: sp.config ? { approval: sp.config.approval } : undefined,
35139
__typename: 'Step' as const,
36140
})),
37141
},
@@ -45,15 +149,22 @@ const useReorderSteps = (flowId: string) => {
45149

46150
if (flow) {
47151
// Create a map of step positions for quick lookup
48-
const positionMap = new Map(
49-
stepPositions.map((sp) => [sp.id, sp.position]),
152+
const updatedStepMap = new Map(
153+
stepPositions.map((sp) => [
154+
sp.id,
155+
{ position: sp.position, config: sp.config },
156+
]),
50157
)
51158

52159
// Update steps with new positions
53160
const updatedSteps = flow.steps.map((step: IStep) => {
54-
const newPosition = positionMap.get(step.id)
55-
return newPosition !== undefined
56-
? { ...step, position: newPosition }
161+
const updatedStep = updatedStepMap.get(step.id)
162+
return updatedStep !== undefined
163+
? {
164+
...step,
165+
position: updatedStep.position,
166+
config: { ...step.config, ...updatedStep.config },
167+
}
57168
: step
58169
})
59170

@@ -84,7 +195,7 @@ const useReorderSteps = (flowId: string) => {
84195
}
85196
}
86197

87-
return { handleReorderUpdate }
198+
return { handleReorderUpdate, calculateReorderedSteps }
88199
}
89200

90201
export default useReorderSteps

0 commit comments

Comments
 (0)