Skip to content

Commit 6744420

Browse files
committed
feat: reorder step across approval branches
1 parent c678ba5 commit 6744420

File tree

5 files changed

+196
-38
lines changed

5 files changed

+196
-38
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'
@@ -76,9 +79,17 @@ const updateStepPositions: MutationResolvers['updateStepPositions'] = async (
7679

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

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

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

packages/backend/src/graphql/schema.graphql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ input StepPositionInput {
575575
id: String!
576576
position: Int!
577577
type: StepEnumType!
578+
config: StepConfigInput
578579
}
579580

580581
input UpdateStepPositionsInput {
@@ -680,8 +681,8 @@ type StepTemplateConfig {
680681
}
681682

682683
type StepApprovalConfig {
683-
branch: String
684-
stepId: String
684+
branch: String!
685+
stepId: String!
685686
}
686687

687688
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 & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,126 @@
1-
import { IStep } from '@plumber/types'
1+
import { IStep, IStepApprovalBranch } from '@plumber/types'
22

3+
import { useCallback } from 'react'
34
import { useMutation } from '@apollo/client'
45
import { useToast } from '@opengovsg/design-system-react'
56

6-
import { StepEnumType } from '@/graphql/__generated__/graphql'
7+
import {
8+
StepEnumType,
9+
StepPositionInput,
10+
} from '@/graphql/__generated__/graphql'
711
import { UPDATE_STEP_POSITIONS } from '@/graphql/mutations/update-step-positions'
812
import { GET_FLOW } from '@/graphql/queries/get-flow'
913

10-
interface StepPositionInput {
11-
id: string
12-
position: number
13-
type: StepEnumType
14-
}
15-
1614
const useReorderSteps = (flowId: string) => {
1715
const toast = useToast()
1816
const [updateStepPositions] = useMutation(UPDATE_STEP_POSITIONS)
1917

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

38143
if (flow) {
39144
// Create a map of step positions for quick lookup
40-
const positionMap = new Map(
41-
stepPositions.map((sp) => [sp.id, sp.position]),
145+
const updatedStepMap = new Map(
146+
stepPositions.map((sp) => [
147+
sp.id,
148+
{ position: sp.position, config: sp.config },
149+
]),
42150
)
43151

44152
// Update steps with new positions
45153
const updatedSteps = flow.steps.map((step: IStep) => {
46-
const newPosition = positionMap.get(step.id)
47-
return newPosition !== undefined
48-
? { ...step, position: newPosition }
154+
const updatedStep = updatedStepMap.get(step.id)
155+
return updatedStep !== undefined
156+
? {
157+
...step,
158+
position: updatedStep.position,
159+
config: { ...step.config, ...updatedStep.config },
160+
}
49161
: step
50162
})
51163

@@ -91,7 +203,7 @@ const useReorderSteps = (flowId: string) => {
91203
}
92204
}
93205

94-
return { handleReorderUpdate }
206+
return { handleReorderUpdate, calculateReorderedSteps }
95207
}
96208

97209
export default useReorderSteps

0 commit comments

Comments
 (0)