Skip to content

Commit fefc33a

Browse files
authored
Merge pull request #349 from topcoder-platform/gamification
Gamification QA Fixes
2 parents 1c396e8 + b7b6a4e commit fefc33a

File tree

11 files changed

+265
-15
lines changed

11 files changed

+265
-15
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"redux-logger": "^3.0.6",
6363
"redux-promise-middleware": "^6.1.2",
6464
"redux-thunk": "^2.4.1",
65+
"sanitize-html": "^2.7.2",
6566
"sass": "^1.49.8",
6667
"styled-components": "^5.3.5",
6768
"swr": "^1.3.0",
@@ -98,6 +99,7 @@
9899
"@types/react-gtm-module": "^2.0.1",
99100
"@types/react-redux-toastr": "^7.6.2",
100101
"@types/react-router-dom": "^5.3.3",
102+
"@types/sanitize-html": "^2.6.2",
101103
"@types/segment-analytics": "^0.0.34",
102104
"@types/systemjs": "^6.1.0",
103105
"@types/uuid": "^8.3.4",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { KeyedMutator } from 'swr'
2+
3+
import { InfinitePageDao } from './infinite-page-dao.model'
4+
15
export interface InfinitePageHandler<T> {
26
data?: ReadonlyArray<T>
37
getAndSetNext: () => void
48
hasMore: boolean
9+
mutate: KeyedMutator<Array<InfinitePageDao<T>>>
510
}

src-ts/lib/pagination/use-infinite-page.hook.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { InfinitePageHandler } from './infinite-page-handler.model'
88
export function useGetInfinitePage<T>(getKey: (index: number, previousPageData: InfinitePageDao<T>) => string | undefined):
99
InfinitePageHandler<T> {
1010

11-
const { data, setSize, size }: SWRInfiniteResponse<InfinitePageDao<T>> = useSWRInfinite(getKey, { revalidateFirstPage: false })
11+
const { data, mutate, setSize, size }: SWRInfiniteResponse<InfinitePageDao<T>> = useSWRInfinite(getKey, { revalidateFirstPage: false })
1212

1313
// flatten version of badges paginated data
1414
const outputData: ReadonlyArray<T> = flatten(map(data, dao => dao.rows))
@@ -21,5 +21,6 @@ export function useGetInfinitePage<T>(getKey: (index: number, previousPageData:
2121
data: outputData,
2222
getAndSetNext,
2323
hasMore: outputData.length < (data?.[0]?.count || 0),
24+
mutate,
2425
}
2526
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
@import "../../../../../lib/styles/variables";
2+
@import "../../../../../lib/styles/includes";
3+
4+
.wrapper {
5+
display: flex;
6+
flex-direction: column;
7+
justify-content: space-between;
8+
9+
.badge {
10+
display: flex;
11+
align-items: center;
12+
margin-bottom: $space-xxl;
13+
14+
@include ltemd {
15+
margin-bottom: 0;
16+
}
17+
18+
.badge-image {
19+
width: 43px;
20+
height: 43px;
21+
margin-right: $space-xl;
22+
}
23+
24+
.badge-image-disabled {
25+
width: 43px;
26+
height: 43px;
27+
margin-right: $space-xl;
28+
opacity: 0.5;
29+
filter: grayscale(1);
30+
}
31+
32+
.badge-name {
33+
font-size: 16px;
34+
}
35+
}
36+
37+
.actions-wrap {
38+
display: flex;
39+
flex-direction: column;
40+
41+
.actions {
42+
display: flex;
43+
align-items: center;
44+
45+
@include ltemd {
46+
justify-content: flex-end;
47+
}
48+
49+
a {
50+
margin-right: $space-md;
51+
}
52+
}
53+
}
54+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { FC } from 'react'
2+
3+
import { BaseModal, Button, PageDivider, useCheckIsMobile } from '../../../../../lib'
4+
import { GameBadge } from '../../game-badge.model'
5+
6+
import styles from './BadgeActivatedModal.module.scss'
7+
export interface BadgeActivatedModalProps {
8+
badge: GameBadge
9+
isOpen: boolean
10+
onClose: () => void
11+
}
12+
13+
const BadgeActivatedModal: FC<BadgeActivatedModalProps> = (props: BadgeActivatedModalProps) => {
14+
15+
const isMobile: boolean = useCheckIsMobile()
16+
17+
function onClose(): void {
18+
props.onClose()
19+
}
20+
21+
return (
22+
<BaseModal
23+
onClose={onClose}
24+
open={props.isOpen}
25+
size='md'
26+
title={`Badge updated`}
27+
closeOnOverlayClick={false}
28+
>
29+
<div className={styles.wrapper}>
30+
<div className={styles.badge}>
31+
<img
32+
alt={props.badge.badge_name}
33+
className={styles[props.badge.active ? 'badge-image' : 'badge-image-disabled']}
34+
src={props.badge.badge_image_url}
35+
/>
36+
<p className={styles['badge-name']}>{props.badge.badge_name} badge has been sucessfully {props.badge.active ? 'activated' : 'deactivated'}.</p>
37+
</div>
38+
<div className={styles['actions-wrap']}>
39+
{
40+
isMobile && <PageDivider />
41+
}
42+
<div className={styles.actions}>
43+
<Button
44+
label='Close'
45+
buttonStyle='primary'
46+
onClick={onClose}
47+
/>
48+
</div>
49+
</div>
50+
</div>
51+
</BaseModal>
52+
)
53+
}
54+
55+
export default BadgeActivatedModal
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as BadgeActivatedModal } from './BadgeActivatedModal'

src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
@import "../../../../lib/styles/variables";
22
@import "../../../../lib/styles/includes";
3+
@import "../../../../lib/styles/typography";
4+
$error-line-height: 14px;
35

46
$badgePreview: 130px;
57
$badgePreviewImage: 72px;
@@ -86,6 +88,22 @@ $badgePreviewImage: 72px;
8688
}
8789
}
8890

91+
.error {
92+
display: flex;
93+
align-items: center;
94+
color: $red-100;
95+
// extend body ultra small and override it
96+
@extend .ultra-small;
97+
line-height: $error-line-height;
98+
margin-top: $space-xs;
99+
100+
svg {
101+
@include icon-md;
102+
fill: $red-100;
103+
margin-right: $space-xs;
104+
}
105+
}
106+
89107
.badgeDesc {
90108
margin-top: $space-sm;
91109

src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { noop, trim } from 'lodash'
22
import MarkdownIt from 'markdown-it'
3-
import { createRef, Dispatch, FC, KeyboardEvent, RefObject, SetStateAction, useEffect, useState } from 'react'
3+
import { ChangeEvent, createRef, Dispatch, FC, KeyboardEvent, RefObject, SetStateAction, useEffect, useState } from 'react'
44
import ContentEditable from 'react-contenteditable'
55
import { Params, useLocation, useParams } from 'react-router-dom'
66
import { toast } from 'react-toastify'
7+
import sanitizeHtml from 'sanitize-html'
8+
import { KeyedMutator } from 'swr'
79

8-
import { Breadcrumb, BreadcrumbItemModel, Button, ButtonProps, ContentLayout, IconOutline, LoadingSpinner, PageDivider, TabsNavbar, TabsNavItem } from '../../../../lib'
10+
import { Breadcrumb, BreadcrumbItemModel, Button, ButtonProps, ContentLayout, IconOutline, IconSolid, LoadingSpinner, PageDivider, Sort, tableGetDefaultSort, TabsNavbar, TabsNavItem } from '../../../../lib'
911
import { GamificationConfig } from '../../game-config'
10-
import { BadgeDetailPageHandler, GameBadge, useGamificationBreadcrumb, useGetGameBadgeDetails } from '../../game-lib'
12+
import { BadgeDetailPageHandler, GameBadge, useGamificationBreadcrumb, useGetGameBadgeDetails, useGetGameBadgesPage } from '../../game-lib'
13+
import { badgeListingColumns } from '../badge-listing/badge-listing-table'
1114

1215
import AwardedMembersTab from './AwardedMembersTab/AwardedMembersTab'
1316
import { badgeDetailsTabs, BadgeDetailsTabViews } from './badge-details-tabs.config'
@@ -67,6 +70,12 @@ const BadgeDetailPage: FC = () => {
6770

6871
const [isBadgeDescEditingMode, setIsBadgeDescEditingMode]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false)
6972

73+
// badgeListingMutate will reset badge listing page cache when called
74+
const sort: Sort = tableGetDefaultSort(badgeListingColumns)
75+
const { mutate: badgeListingMutate }: { mutate: KeyedMutator<any> } = useGetGameBadgesPage(sort)
76+
77+
const [badgeNameErrorText, setBadgeNameErrorText]: [string | undefined, Dispatch<SetStateAction<string | undefined>>] = useState<string | undefined>()
78+
7079
useEffect(() => {
7180
if (newImageFile && newImageFile.length) {
7281
const fileReader: FileReader = new FileReader()
@@ -97,6 +106,7 @@ const BadgeDetailPage: FC = () => {
97106
...badgeDetailsHandler.data,
98107
badge_image_url: updatedBadge.badge_image_url,
99108
})
109+
onBadgeUpdated()
100110
})
101111
}
102112
}, [
@@ -134,17 +144,42 @@ const BadgeDetailPage: FC = () => {
134144
)
135145

136146
function onActivateBadge(): void {
137-
// TODO: implement in GAME-127
147+
updateBadgeAsync({
148+
badgeActive: true,
149+
id: badgeDetailsHandler.data?.id as string,
150+
})
151+
.then(() => {
152+
badgeDetailsHandler.mutate({
153+
...badgeDetailsHandler.data,
154+
active: true,
155+
})
156+
setShowActivatedModal(true)
157+
})
158+
.catch((e) => alert(`onActivateBadge error: ${e.message}`))
138159
}
139160

140161
function onDisableBadge(): void {
141-
// TODO: implement in GAME-127
162+
updateBadgeAsync({
163+
badgeActive: false,
164+
id: badgeDetailsHandler.data?.id as string,
165+
})
166+
.then(() => {
167+
badgeDetailsHandler.mutate({
168+
...badgeDetailsHandler.data,
169+
active: false,
170+
})
171+
setShowActivatedModal(true)
172+
})
173+
.catch((e) => alert(`onDisableBadge error: ${e.message}`))
142174
}
143175

144176
function onNameEditKeyDown(e: KeyboardEvent): void {
145177
if (e.key === 'Enter') {
146178
e.preventDefault()
147179
badgeNameRef.current?.blur()
180+
} else if (/[`'<>]+/.test(e.key)) {
181+
// restrict those characters
182+
e.preventDefault()
148183
}
149184
}
150185

@@ -154,8 +189,19 @@ const BadgeDetailPage: FC = () => {
154189
}
155190
}
156191

192+
function sanitazeBadgeName(innerHTML: string): string {
193+
const clean: string = sanitizeHtml(innerHTML, {
194+
allowedTags: [],
195+
})
196+
return trim(clean)
197+
}
198+
157199
function onSaveBadgeName(): any {
158-
const newBadgeName: string | undefined = trim(badgeNameRef.current?.innerHTML)
200+
const newBadgeName: string = sanitazeBadgeName(badgeNameRef.current?.innerHTML as string)
201+
if (!newBadgeName) {
202+
setBadgeNameErrorText('Update rejected due to invalid title string.')
203+
return
204+
}
159205
if (newBadgeName !== badgeDetailsHandler.data?.badge_name) {
160206
// save only if different
161207
updateBadgeAsync({
@@ -168,6 +214,10 @@ const BadgeDetailPage: FC = () => {
168214
...badgeDetailsHandler.data,
169215
badge_name: newBadgeName,
170216
})
217+
onBadgeUpdated()
218+
})
219+
.catch(e => {
220+
setBadgeNameErrorText(e.message)
171221
})
172222
}
173223
}
@@ -187,10 +237,25 @@ const BadgeDetailPage: FC = () => {
187237
...badgeDetailsHandler.data,
188238
badge_description: newBadgeDesc,
189239
})
240+
onBadgeUpdated()
190241
})
191242
}
192243
}
193244

245+
function onBadgeUpdated(): void {
246+
badgeListingMutate()
247+
}
248+
249+
function validateFilePicked(e: ChangeEvent<HTMLInputElement>): void {
250+
if (e.target.files?.length) {
251+
if (GamificationConfig.ACCEPTED_BADGE_MIME_TYPES.includes(e.target.files[0].type)) {
252+
setNewImageFile(e.target.files)
253+
} else {
254+
toast.error(`Not allowed file type: ${e.target.files[0].type}`)
255+
}
256+
}
257+
}
258+
194259
// default tab
195260
let activeTabElement: JSX.Element
196261
= <AwardedMembersTab badge={badgeDetailsHandler.data as GameBadge} />
@@ -236,19 +301,25 @@ const BadgeDetailPage: FC = () => {
236301
className={styles.filePickerInput}
237302
accept={GamificationConfig.ACCEPTED_BADGE_MIME_TYPES}
238303
size={GamificationConfig.MAX_BADGE_IMAGE_FILE_SIZE}
239-
onChange={e => setNewImageFile(e.target.files)}
304+
onChange={validateFilePicked}
240305
/>
241306
</div>
242307
<div className={styles.badgeDetails}>
243308
<ContentEditable
244309
innerRef={badgeNameRef}
245310
html={badgeDetailsHandler.data?.badge_name as string}
246-
onChange={noop}
311+
onChange={() => badgeNameErrorText ? setBadgeNameErrorText(undefined) : ''}
247312
onKeyDown={onNameEditKeyDown}
248313
onBlur={onSaveBadgeName}
249314
onFocus={onBadgeNameEditFocus}
250315
className={styles.badgeName}
251316
/>
317+
{
318+
badgeNameErrorText && <div className={styles.error}>
319+
<IconSolid.ExclamationIcon />
320+
{badgeNameErrorText}
321+
</div>
322+
}
252323
<div className={styles.badgeDesc}>
253324
<div className={styles.badgeEditWrap}>
254325
<ContentEditable
@@ -290,6 +361,14 @@ const BadgeDetailPage: FC = () => {
290361
)
291362
}
292363
</div>
364+
{
365+
badgeDetailsHandler.data &&
366+
<BadgeActivatedModal
367+
isOpen={showActivatedModal}
368+
onClose={() => setShowActivatedModal(false)}
369+
badge={badgeDetailsHandler.data}
370+
/>
371+
}
293372
</ContentLayout>
294373
)
295374
}

src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.module.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
.badge-actions {
55
display: flex;
66
align-items: center;
7-
justify-content: center;
7+
justify-content: flex-end;
88
padding-top: $space-lg;
9+
padding-right: $space-sm;
910

1011
@include ltemd {
1112
flex-direction: column;

src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ function BadgeActionRenderer(badge: GameBadge): JSX.Element {
2020
{
2121
label: 'View',
2222
},
23-
{
24-
label: 'Edit',
25-
view: 'edit',
26-
},
2723
{
2824
label: 'Award',
2925
view: 'award',

0 commit comments

Comments
 (0)