Skip to content

Commit e4c7732

Browse files
committed
Badge editing via contenteditable
1 parent f7721e6 commit e4c7732

File tree

8 files changed

+359
-511
lines changed

8 files changed

+359
-511
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"highlight.js": "^11.6.0",
3535
"html2canvas": "^1.4.1",
3636
"lodash": "^4.17.21",
37+
"markdown-it": "^13.0.1",
3738
"marked": "4.0.3",
3839
"moment": "^2.29.3",
3940
"moment-timezone": "^0.5.34",
@@ -42,10 +43,10 @@
4243
"react": "^17.0.2",
4344
"react-apexcharts": "^1.4.0",
4445
"react-app-rewired": "^2.2.1",
46+
"react-contenteditable": "^3.3.6",
4547
"react-dom": "^17.0.2",
4648
"react-elastic-carousel": "^0.11.5",
4749
"react-gtm-module": "^2.0.11",
48-
"react-markdown": "^8.0.3",
4950
"react-redux": "^8.0.2",
5051
"react-redux-toastr": "^7.6.8",
5152
"react-responsive-modal": "^6.2.0",
@@ -86,6 +87,7 @@
8687
"@types/highlightjs": "^9.12.2",
8788
"@types/jest": "^27.0.1",
8889
"@types/lodash": "^4.14.182",
90+
"@types/markdown-it": "^12.2.3",
8991
"@types/marked": "4.0.3",
9092
"@types/node": "^18.7.13",
9193
"@types/reach__router": "^1.3.10",
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import useSWR, { SWRResponse } from 'swr'
1+
import useSWR, { KeyedMutator, SWRResponse } from 'swr'
22

33
import { EnvironmentConfig } from '../../../config'
44

@@ -7,16 +7,18 @@ import { GameBadge } from './game-badge.model'
77
export interface BadgeDetailPageHandler<T> {
88
data?: Readonly<T>
99
error?: Readonly<any>
10+
mutate: KeyedMutator<any>
1011
}
1112

1213
export function useGetGameBadgeDetails(badgeID: string): BadgeDetailPageHandler<GameBadge> {
1314

1415
const badgeEndpointUrl: URL = new URL(`${EnvironmentConfig.API.V5}/gamification/badges/${badgeID}`)
1516

16-
const { data, error }: SWRResponse = useSWR(badgeEndpointUrl.toString())
17+
const { data, error, mutate }: SWRResponse = useSWR(badgeEndpointUrl.toString())
1718

1819
return {
1920
data,
2021
error,
22+
mutate,
2123
}
2224
}

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

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,88 @@ $badgePreviewImage: 72px;
4545
width: $badgePreviewImage;
4646
height: $badgePreviewImage;
4747
}
48+
49+
.filePickerPencil {
50+
position: absolute;
51+
top: 0;
52+
right: 0;
53+
color: $turq-160;
54+
}
55+
56+
.filePickerInput {
57+
display: none;
58+
}
4859
}
4960

5061
.badgeDetails {
5162
display: flex;
5263
flex-direction: column;
64+
flex: 1;
65+
66+
.badgeName {
67+
font-family: $font-roboto;
68+
font-weight: $font-weight-bold;
69+
padding: $space-sm;
70+
font-size: 24px;
71+
line-height: 32px;
72+
73+
@include ltemd {
74+
font-size: 20px;
75+
line-height: 28px;
76+
}
77+
78+
&:hover {
79+
background-color: $black-5;
80+
cursor: text;
81+
}
82+
83+
&:focus {
84+
background-color: $tc-white;
85+
outline-color: $turq-160;
86+
}
87+
}
5388

5489
.badgeDesc {
5590
margin-top: $space-sm;
5691

57-
a {
58-
color: $link-blue-dark;
92+
.badgeEditWrap {
93+
display: flex;
94+
flex-direction: column;
95+
96+
.badgeEditable,
97+
.badgeEditableMode {
98+
padding: $space-sm;
99+
border-radius: 3px;
100+
border: 2px solid $tc-white;
101+
102+
&:hover {
103+
background-color: $black-5;
104+
cursor: text;
105+
}
106+
107+
&:focus {
108+
background-color: $tc-white;
109+
outline: none;
110+
}
111+
112+
a {
113+
color: $link-blue-dark;
114+
}
115+
}
116+
117+
.badgeEditableMode {
118+
border: 2px solid $turq-160;
119+
}
120+
121+
.badgeEditActions {
122+
display: flex;
123+
justify-content: flex-end;
124+
margin-top: $space-sm;
125+
126+
button:first-child {
127+
margin-right: $space-sm;
128+
}
129+
}
59130
}
60131
}
61132
}

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

Lines changed: 173 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1-
import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react'
2-
import ReactMarkdown from 'react-markdown'
1+
import { noop, trim } from 'lodash'
2+
import MarkdownIt from 'markdown-it'
3+
import { createRef, Dispatch, FC, KeyboardEvent, RefObject, SetStateAction, useEffect, useState } from 'react'
4+
import ContentEditable from 'react-contenteditable'
35
import { Params, useLocation, useParams } from 'react-router-dom'
6+
import { toast } from 'react-toastify'
47

5-
import { Breadcrumb, BreadcrumbItemModel, ButtonProps, ContentLayout, LoadingSpinner, PageDivider, TabsNavbar, TabsNavItem } from '../../../../lib'
8+
import { Breadcrumb, BreadcrumbItemModel, Button, ButtonProps, ContentLayout, IconOutline, LoadingSpinner, PageDivider, TabsNavbar, TabsNavItem } from '../../../../lib'
9+
import { GamificationConfig } from '../../game-config'
610
import { BadgeDetailPageHandler, GameBadge, useGamificationBreadcrumb, useGetGameBadgeDetails } from '../../game-lib'
711

812
import { badgeDetailsTabs, BadgeDetailsTabViews } from './badge-details-tabs.config'
13+
import { submitRequestAsync as updateBadgeAsync } from './badge-details.functions'
914
import styles from './BadgeDetailPage.module.scss'
1015

16+
const md: MarkdownIt = new MarkdownIt({
17+
html: true,
18+
linkify: true,
19+
typographer: true,
20+
})
21+
22+
/* tslint:disable:cyclomatic-complexity */
1123
const BadgeDetailPage: FC = () => {
1224
const [headerButtonConfig, setHeaderButtonConfig]: [
1325
ButtonProps | undefined,
@@ -36,6 +48,55 @@ const BadgeDetailPage: FC = () => {
3648

3749
const badgeDetailsHandler: BadgeDetailPageHandler<GameBadge> = useGetGameBadgeDetails(badgeID as string)
3850

51+
const badgeNameRef: RefObject<HTMLDivElement> = createRef<HTMLDivElement>()
52+
53+
const badgeDescRef: RefObject<HTMLDivElement> = createRef<HTMLDivElement>()
54+
55+
const fileInputRef: RefObject<HTMLInputElement> = createRef<HTMLInputElement>()
56+
57+
// tslint:disable-next-line:no-null-keyword
58+
const [newImageFile, setNewImageFile]: [FileList | null, Dispatch<SetStateAction<FileList | null>>] = useState<FileList | null>(null)
59+
60+
const [fileDataURL, setFileDataURL]: [string | undefined, Dispatch<SetStateAction<string | undefined>>] = useState<string | undefined>()
61+
62+
const [isBadgeDescEditingMode, setIsBadgeDescEditingMode]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false)
63+
64+
useEffect(() => {
65+
if (newImageFile && newImageFile.length) {
66+
const fileReader: FileReader = new FileReader()
67+
fileReader.onload = e => {
68+
const { result }: any = e.target
69+
if (result) {
70+
setFileDataURL(result)
71+
}
72+
}
73+
fileReader.readAsDataURL(newImageFile[0])
74+
} else if (fileDataURL) {
75+
setFileDataURL(undefined)
76+
}
77+
}, [
78+
newImageFile,
79+
fileDataURL,
80+
])
81+
82+
useEffect(() => {
83+
if (newImageFile && newImageFile.length) {
84+
updateBadgeAsync({
85+
files: newImageFile as FileList,
86+
id: badgeDetailsHandler.data?.id as string,
87+
})
88+
.then((updatedBadge: GameBadge) => {
89+
toast.success('Badge image file saved.')
90+
badgeDetailsHandler.mutate({
91+
...badgeDetailsHandler.data,
92+
badge_image_url: updatedBadge.badge_image_url,
93+
})
94+
})
95+
}
96+
}, [
97+
newImageFile,
98+
])
99+
39100
useEffect(() => {
40101
if (badgeDetailsHandler.data) {
41102
switch (badgeDetailsHandler.data?.active) {
@@ -57,6 +118,15 @@ const BadgeDetailPage: FC = () => {
57118
badgeDetailsHandler.data,
58119
])
59120

121+
// define the tabs so they can be displayed on various results
122+
const tabsElement: JSX.Element = (
123+
<TabsNavbar
124+
tabs={tabs}
125+
defaultActive={activeTab}
126+
onChange={onChangeTab}
127+
/>
128+
)
129+
60130
function onChangeTab(active: string): void {
61131
// TODO: implement in GAME-129
62132
}
@@ -69,14 +139,55 @@ const BadgeDetailPage: FC = () => {
69139
// TODO: implement in GAME-127
70140
}
71141

72-
// define the tabs so they can be displayed on various results
73-
const tabsElement: JSX.Element = (
74-
<TabsNavbar
75-
tabs={tabs}
76-
defaultActive={activeTab}
77-
onChange={onChangeTab}
78-
/>
79-
)
142+
function onNameEditKeyDown(e: KeyboardEvent): void {
143+
if (e.key === 'Enter') {
144+
e.preventDefault()
145+
badgeNameRef.current?.blur()
146+
}
147+
}
148+
149+
function onBadgeNameEditFocus(): void {
150+
if (isBadgeDescEditingMode) {
151+
setIsBadgeDescEditingMode(false)
152+
}
153+
}
154+
155+
function onSaveBadgeName(): any {
156+
const newBadgeName: string | undefined = trim(badgeNameRef.current?.innerHTML)
157+
if (newBadgeName !== badgeDetailsHandler.data?.badge_name) {
158+
// save only if different
159+
updateBadgeAsync({
160+
badgeName: newBadgeName,
161+
id: badgeDetailsHandler.data?.id as string,
162+
})
163+
.then(() => {
164+
toast.success('Badge name update saved.')
165+
badgeDetailsHandler.mutate({
166+
...badgeDetailsHandler.data,
167+
badge_name: newBadgeName,
168+
})
169+
})
170+
}
171+
}
172+
173+
function onSaveBadgeDesc(): any {
174+
setIsBadgeDescEditingMode(false)
175+
const newBadgeDesc: string | undefined = trim(badgeDescRef.current?.innerHTML)
176+
if (newBadgeDesc !== badgeDetailsHandler.data?.badge_description) {
177+
// save only if different
178+
updateBadgeAsync({
179+
badgeDesc: newBadgeDesc,
180+
id: badgeDetailsHandler.data?.id as string,
181+
})
182+
.then(() => {
183+
toast.success('Badge description update saved.')
184+
badgeDetailsHandler.mutate({
185+
...badgeDetailsHandler.data,
186+
badge_description: newBadgeDesc,
187+
})
188+
})
189+
}
190+
}
80191

81192
if (!badgeDetailsHandler.data && !badgeDetailsHandler.error) {
82193
return <LoadingSpinner />
@@ -98,12 +209,60 @@ const BadgeDetailPage: FC = () => {
98209
<>
99210
<div className={styles.badge}>
100211
<div className={styles.badgeImage}>
101-
<img src={badgeDetailsHandler.data?.badge_image_url} alt='badge media preview' />
212+
<Button
213+
buttonStyle='icon'
214+
icon={IconOutline.PencilIcon}
215+
className={styles.filePickerPencil}
216+
onClick={() => fileInputRef.current?.click()} />
217+
<img src={fileDataURL || badgeDetailsHandler.data?.badge_image_url} alt='badge media preview' />
218+
<input
219+
type={'file'}
220+
ref={fileInputRef}
221+
className={styles.filePickerInput}
222+
accept={GamificationConfig.ACCEPTED_BADGE_MIME_TYPES}
223+
size={GamificationConfig.MAX_BADGE_IMAGE_FILE_SIZE}
224+
onChange={e => setNewImageFile(e.target.files)}
225+
/>
102226
</div>
103227
<div className={styles.badgeDetails}>
104-
<h2>{badgeDetailsHandler.data?.badge_name}</h2>
228+
<ContentEditable
229+
innerRef={badgeNameRef}
230+
html={badgeDetailsHandler.data?.badge_name as string}
231+
onChange={noop}
232+
onKeyDown={onNameEditKeyDown}
233+
onBlur={onSaveBadgeName}
234+
onFocus={onBadgeNameEditFocus}
235+
className={styles.badgeName}
236+
/>
105237
<div className={styles.badgeDesc}>
106-
<ReactMarkdown children={badgeDetailsHandler.data?.badge_description as string} />
238+
<div className={styles.badgeEditWrap}>
239+
<ContentEditable
240+
innerRef={badgeDescRef}
241+
html={
242+
isBadgeDescEditingMode
243+
? badgeDetailsHandler.data?.badge_description as string
244+
: md.render(badgeDetailsHandler.data?.badge_description as string)
245+
}
246+
onChange={noop}
247+
onFocus={() => setIsBadgeDescEditingMode(true)}
248+
className={isBadgeDescEditingMode ? styles.badgeEditableMode : styles.badgeEditable}
249+
/>
250+
{
251+
isBadgeDescEditingMode && <div className={styles.badgeEditActions}>
252+
<Button
253+
label='Cancel'
254+
buttonStyle='secondary'
255+
size='xs'
256+
onClick={() => setIsBadgeDescEditingMode(false)}
257+
/>
258+
<Button
259+
label='Save'
260+
size='xs'
261+
onClick={onSaveBadgeDesc}
262+
/>
263+
</div>
264+
}
265+
</div>
107266
</div>
108267
</div>
109268
</div>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { GameBadge } from '../../game-lib'
2+
3+
import { submitRequestAsync as submitBadgeUpdateRequestAsync } from './update-badge.store'
4+
import { UpdateBadgeRequest } from './updated-badge-request.model'
5+
6+
export async function submitRequestAsync(request: UpdateBadgeRequest): Promise<GameBadge> {
7+
return submitBadgeUpdateRequestAsync(request)
8+
}

0 commit comments

Comments
 (0)