Skip to content

Commit d67cbad

Browse files
authored
Merge pull request #1224 from topcoder-platform/pm-1722_1
feat(PM-1722): Drag and drop implementation in created/edit scorecards
2 parents 2811837 + 6988cda commit d67cbad

File tree

13 files changed

+536
-252
lines changed

13 files changed

+536
-252
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ workflows:
223223
- CORE-635
224224
- feat/review
225225
- feat/system-admin
226-
- pm-1503_2
226+
- pm-1722_1
227227

228228
- deployQa:
229229
context: org-global

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@datadog/browser-logs": "^4.21.2",
23+
"@hello-pangea/dnd": "^18.0.1",
2324
"@heroicons/react": "^1.0.6",
2425
"@hookform/resolvers": "^4.1.2",
2526
"@popperjs/core": "^2.11.8",

src/apps/review/src/lib/components/ScorecardDetails/ScorecardDetails.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export const ScorecardDetails: FC<Props> = (props: Props) => {
165165
(questionResult, question) => {
166166
let questionPoint = 0
167167
const initialAnswer
168-
= mapingResult[question.id]
168+
= mapingResult[question.id as string]
169169
if (
170170
question.type === 'YES_NO'
171171
&& initialAnswer === 'Yes'
@@ -338,7 +338,7 @@ export const ScorecardDetails: FC<Props> = (props: Props) => {
338338
) => {
339339
const reviewItemInfo
340340
= mappingReviewInfo[
341-
question.id
341+
question.id as string
342342
]
343343
if (
344344
!reviewItemInfo

src/apps/review/src/lib/models/ScorecardQuestion.model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
* Scorecard question info
33
*/
44
export interface ScorecardQuestion {
5-
id: string
5+
id?: string
66
type: 'SCALE' | 'YES_NO' | 'TEST_CASE'
77
description: string
88
guidelines: string
99
weight: number
1010
scaleMin: number
1111
scaleMax: number
1212
requiresUpload: boolean
13+
sortOrder: number
1314
}

src/apps/review/src/lib/models/ScorecardSection.model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ScorecardQuestion } from './ScorecardQuestion.model'
44
* Scorcard section info
55
*/
66
export interface ScorecardSection {
7-
id: string
7+
id?: string
88
name: string
99
weight: number
1010
sortOrder: number

src/apps/review/src/mock-datas/MockScorecard.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const MockScorecard: ScorecardInfo = {
3636
requiresUpload: true,
3737
scaleMax: 9,
3838
scaleMin: 1,
39+
sortOrder: 0,
3940
type: 'YES_NO',
4041
weight: 25,
4142
},
@@ -47,6 +48,7 @@ export const MockScorecard: ScorecardInfo = {
4748
requiresUpload: true,
4849
scaleMax: 9,
4950
scaleMin: 1,
51+
sortOrder: 1,
5052
type: 'YES_NO',
5153
weight: 25,
5254
},
@@ -57,6 +59,7 @@ export const MockScorecard: ScorecardInfo = {
5759
requiresUpload: true,
5860
scaleMax: 9,
5961
scaleMin: 1,
62+
sortOrder: 2,
6063
type: 'SCALE',
6164
weight: 25,
6265
},
@@ -67,6 +70,7 @@ export const MockScorecard: ScorecardInfo = {
6770
requiresUpload: true,
6871
scaleMax: 9,
6972
scaleMin: 1,
73+
sortOrder: 3,
7074
type: 'SCALE',
7175
weight: 25,
7276
},
@@ -86,6 +90,7 @@ export const MockScorecard: ScorecardInfo = {
8690
requiresUpload: true,
8791
scaleMax: 9,
8892
scaleMin: 1,
93+
sortOrder: 0,
8994
type: 'SCALE',
9095
weight: 50,
9196
},
@@ -97,6 +102,7 @@ export const MockScorecard: ScorecardInfo = {
97102
requiresUpload: true,
98103
scaleMax: 9,
99104
scaleMin: 1,
105+
sortOrder: 1,
100106
type: 'SCALE',
101107
weight: 50,
102108
},
@@ -124,6 +130,7 @@ export const MockScorecard: ScorecardInfo = {
124130
requiresUpload: true,
125131
scaleMax: 9,
126132
scaleMin: 1,
133+
sortOrder: 0,
127134
type: 'SCALE',
128135
weight: 50,
129136
},
@@ -135,6 +142,7 @@ export const MockScorecard: ScorecardInfo = {
135142
requiresUpload: true,
136143
scaleMax: 9,
137144
scaleMin: 1,
145+
sortOrder: 1,
138146
type: 'SCALE',
139147
weight: 50,
140148
},
@@ -154,6 +162,7 @@ export const MockScorecard: ScorecardInfo = {
154162
requiresUpload: true,
155163
scaleMax: 9,
156164
scaleMin: 1,
165+
sortOrder: 0,
157166
type: 'SCALE',
158167
weight: 50,
159168
},
@@ -166,6 +175,7 @@ export const MockScorecard: ScorecardInfo = {
166175
requiresUpload: true,
167176
scaleMax: 9,
168177
scaleMin: 1,
178+
sortOrder: 1,
169179
type: 'SCALE',
170180
weight: 50,
171181
},

src/apps/review/src/pages/scorecards/EditScorecardPage/EditScorecardPage.module.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@
8888
.headerArea {
8989
background: #0F172A;
9090
color: $tc-white;
91+
.title {
92+
display: flex;
93+
align-items: center;
94+
}
9195

9296
:global(.input-error) {
9397
font-family: "Nunito Sans", sans-serif;
@@ -116,6 +120,11 @@
116120
background: $teal-160;
117121
color: $tc-white;
118122

123+
.title {
124+
display: flex;
125+
align-items: center;
126+
}
127+
119128
:global(.input-error) {
120129
font-family: "Nunito Sans", sans-serif;
121130
background: #E2CBC0;
@@ -147,6 +156,11 @@
147156
grid-template-columns: 1fr 7.85% 3.5%;
148157
gap: $sp-4;
149158

159+
.title {
160+
display: flex;
161+
align-items: center;
162+
}
163+
150164
:global(.main-group) {
151165
grid-column: 1;
152166
}

src/apps/review/src/pages/scorecards/EditScorecardPage/EditScorecardPage.tsx

Lines changed: 142 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
/* eslint-disable import/no-extraneous-dependencies */
12
import * as yup from 'yup'
23
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
34
import { FormProvider, useForm } from 'react-hook-form'
45
import { useNavigate, useParams } from 'react-router-dom'
56
import { toast } from 'react-toastify'
67

7-
import { yupResolver } from '@hookform/resolvers/yup'
88
import { Button, LinkButton } from '~/libs/ui'
9+
import { DragDropContext, DropResult } from '@hello-pangea/dnd'
10+
import { yupResolver } from '@hookform/resolvers/yup'
911

12+
import { PageWrapper } from '../../../lib'
1013
import { useFetchScorecard } from '../../../lib/hooks/useFetchScorecard'
1114
import { saveScorecard } from '../../../lib/services'
1215
import { rootRoute } from '../../../config/routes.config'
13-
import { PageWrapper } from '../../../lib'
16+
import type { ScorecardQuestion, ScorecardSection } from '../../../lib/models'
1417

1518
import { getEmptyScorecard } from './utils'
1619
import { EditScorecardPageContextProvider } from './EditScorecardPage.context'
@@ -50,7 +53,7 @@ const EditScorecardPage: FC = () => {
5053
}
5154
}, [scorecardQuery.scorecard, scorecardQuery.isValidating])
5255

53-
const handleSubmit = useCallback(async (value: any) => {
56+
const handleSubmit = useCallback(async (value: any): Promise<void> => {
5457
setSaving(true)
5558
try {
5659
const response = await saveScorecard(value)
@@ -60,12 +63,141 @@ const EditScorecardPage: FC = () => {
6063
}
6164
} catch (e: any) {
6265
toast.error(`Couldn't save scorecard! ${e.message}`)
63-
console.error('Couldn\'t save scorecard!', e)
66+
console.error("Couldn't save scorecard!", e)
6467
} finally {
6568
setSaving(false)
6669
}
6770
}, [params.scorecardId, navigate])
6871

72+
const reorder = (list: any[], startIndex: number, endIndex: number): any[] => {
73+
const result = Array.from(list)
74+
const [removed] = result.splice(startIndex, 1)
75+
result.splice(endIndex, 0, removed)
76+
return result
77+
}
78+
79+
const move = (
80+
source: any[],
81+
destination: any[],
82+
sourceIndex: number,
83+
destinationIndex: number,
84+
): { source: any[]; destination: any[] } => {
85+
const sourceClone = Array.from(source)
86+
const destClone = Array.from(destination)
87+
const [removed] = sourceClone.splice(sourceIndex, 1)
88+
destClone.splice(destinationIndex, 0, removed)
89+
return {
90+
destination: destClone,
91+
source: sourceClone,
92+
}
93+
}
94+
95+
function onDragEnd(result: DropResult): void {
96+
if (!result.destination) return
97+
98+
const { source, destination, type }: DropResult<string> = result
99+
100+
if (type === 'group') {
101+
const newGroups = reorder(editForm.getValues('scorecardGroups'), source.index, destination.index)
102+
editForm.setValue('scorecardGroups', newGroups, { shouldDirty: true, shouldValidate: true })
103+
} else if (type === 'section') {
104+
const groups = editForm.getValues('scorecardGroups')
105+
const sourceGroupIndex = Number(source.droppableId.split('.')[1])
106+
const destGroupIndex = Number(destination.droppableId.split('.')[1])
107+
108+
if (sourceGroupIndex === destGroupIndex) {
109+
const newSections = reorder(groups[sourceGroupIndex].sections, source.index, destination.index)
110+
newSections.forEach((section, index) => {
111+
section.sortOrder = index
112+
})
113+
groups[sourceGroupIndex].sections = newSections
114+
} else {
115+
const {
116+
source: newSourceSections,
117+
destination: newDestSections,
118+
}: { source: ScorecardSection[], destination: ScorecardSection[]} = move(
119+
groups[sourceGroupIndex].sections,
120+
groups[destGroupIndex].sections,
121+
source.index,
122+
destination.index,
123+
)
124+
125+
const movedSection = newDestSections[destination.index]
126+
if (movedSection) {
127+
delete movedSection.id
128+
129+
movedSection.questions.forEach((question: any) => {
130+
delete question.id
131+
})
132+
}
133+
134+
newSourceSections.forEach((section, index) => {
135+
section.sortOrder = index
136+
})
137+
138+
newDestSections.forEach((section, index) => {
139+
section.sortOrder = index
140+
})
141+
142+
groups[sourceGroupIndex].sections = newSourceSections
143+
groups[destGroupIndex].sections = newDestSections
144+
}
145+
146+
editForm.setValue('scorecardGroups', groups, { shouldDirty: true, shouldValidate: true })
147+
} else if (type === 'question') {
148+
const groups = editForm.getValues('scorecardGroups')
149+
const parseDroppableId = (id: string): { groupIndex: number; sectionIndex: number } => {
150+
const parts = id.split('.')
151+
return {
152+
groupIndex: Number(parts[1]),
153+
sectionIndex: Number(parts[3]),
154+
}
155+
}
156+
157+
const sourceIds = parseDroppableId(source.droppableId)
158+
const destIds = parseDroppableId(destination.droppableId)
159+
160+
if (sourceIds.groupIndex === destIds.groupIndex && sourceIds.sectionIndex === destIds.sectionIndex) {
161+
const questions = groups[sourceIds.groupIndex].sections[sourceIds.sectionIndex].questions
162+
const newQuestions = reorder(questions, source.index, destination.index)
163+
newQuestions.forEach((question, index) => {
164+
question.sortOrder = index
165+
})
166+
groups[sourceIds.groupIndex].sections[sourceIds.sectionIndex].questions = newQuestions
167+
} else {
168+
const sourceQuestions = groups[sourceIds.groupIndex].sections[sourceIds.sectionIndex].questions
169+
const destQuestions = groups[destIds.groupIndex].sections[destIds.sectionIndex].questions
170+
const {
171+
source: newSourceQuestions,
172+
destination: newDestQuestions,
173+
}: { source: ScorecardQuestion[], destination: ScorecardQuestion[]} = move(
174+
sourceQuestions,
175+
destQuestions,
176+
source.index,
177+
destination.index,
178+
)
179+
180+
const movedQuestion = newDestQuestions[destination.index]
181+
if (movedQuestion) {
182+
delete movedQuestion.id
183+
}
184+
185+
newSourceQuestions.forEach((question, index) => {
186+
question.sortOrder = index
187+
})
188+
189+
newDestQuestions.forEach((question, index) => {
190+
question.sortOrder = index
191+
})
192+
193+
groups[sourceIds.groupIndex].sections[sourceIds.sectionIndex].questions = newSourceQuestions
194+
groups[destIds.groupIndex].sections[destIds.sectionIndex].questions = newDestQuestions
195+
}
196+
197+
editForm.setValue('scorecardGroups', groups, { shouldDirty: true, shouldValidate: true })
198+
}
199+
}
200+
69201
if (scorecardQuery.isValidating) {
70202
return <></>
71203
}
@@ -78,12 +210,13 @@ const EditScorecardPage: FC = () => {
78210
>
79211
<form className={styles.pageContentWrap} onSubmit={editForm.handleSubmit(handleSubmit)}>
80212
<FormProvider {...editForm}>
213+
<DragDropContext onDragEnd={onDragEnd}>
214+
<h3 className={styles.sectionTitle}>1. Scorecard Information</h3>
215+
<ScorecardInfoForm />
81216

82-
<h3 className={styles.sectionTitle}>1. Scorecard Information</h3>
83-
<ScorecardInfoForm />
84-
85-
<h3 className={styles.sectionTitle}>2. Evaluation Structure</h3>
86-
<ScorecardGroupForm />
217+
<h3 className={styles.sectionTitle}>2. Evaluation Structure</h3>
218+
<ScorecardGroupForm />
219+
</DragDropContext>
87220

88221
<div className={styles.bottomContainer}>
89222
<hr />
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react'
2+
3+
const DragIcon: React.FC = () => (
4+
<svg
5+
width='32'
6+
height='32'
7+
viewBox='0 0 32 32'
8+
fill='currentColor'
9+
xmlns='http://www.w3.org/2000/svg'
10+
role='img'
11+
aria-label='Drag handle'
12+
style={{ cursor: 'grab' }}
13+
>
14+
<title>Draggable</title>
15+
<rect x='10' y='6' width='4' height='4' />
16+
<rect x='18' y='6' width='4' height='4' />
17+
<rect x='10' y='14' width='4' height='4' />
18+
<rect x='18' y='14' width='4' height='4' />
19+
<rect x='10' y='22' width='4' height='4' />
20+
<rect x='18' y='22' width='4' height='4' />
21+
</svg>
22+
)
23+
24+
export default DragIcon

0 commit comments

Comments
 (0)