diff --git a/public/gamification-admin/bulk.sample.csv b/public/gamification-admin/bulk.sample.csv new file mode 100644 index 000000000..e09c450ed --- /dev/null +++ b/public/gamification-admin/bulk.sample.csv @@ -0,0 +1,4 @@ +tc_handle,badge_id,badge_name,awarded_by,awarded_at +kirildev,858dafad-5fcd-4bc3-ab7c-849453d139ad,,kirildev,2022-08-15T07:25:43.187Z +jcori,bc39b152-e0e3-4984-962b-f1eba67229dd,TCO22 Development Finalist,,2022-08-15T07:25:43.187Z +amy_admin,bc39b152-e0e3-4984-962b-f1eba67229dd,TCO22 Development Finalist,, diff --git a/src-ts/lib/form/form-groups/form-input/index.ts b/src-ts/lib/form/form-groups/form-input/index.ts index 4e43914cb..55de8809e 100644 --- a/src-ts/lib/form/form-groups/form-input/index.ts +++ b/src-ts/lib/form/form-groups/form-input/index.ts @@ -1,3 +1,4 @@ +export * from './input-file-picker' export * from './input-image-picker' export * from './form-input-autcomplete-option.enum' export * from './input-rating' diff --git a/src-ts/lib/form/form-groups/form-input/input-file-picker/InputFilePicker.module.scss b/src-ts/lib/form/form-groups/form-input/input-file-picker/InputFilePicker.module.scss new file mode 100644 index 000000000..4ee129767 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-file-picker/InputFilePicker.module.scss @@ -0,0 +1,31 @@ +@import "../../../../styles/includes"; +@import "../../../../styles/variables"; + +.filePicker { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: $black-5; + border-radius: 4px; + width: 100%; + padding: $space-xxxxl; + position: relative; + + @include ltemd { + width: 100%; + } + + .fileName { + margin-bottom: $space-sm; + text-align: center; + } + + .filePickerButton { + color: $turq-160; + } + + .filePickerInput { + display: none; + } +} \ No newline at end of file diff --git a/src-ts/lib/form/form-groups/form-input/input-file-picker/InputFilePicker.tsx b/src-ts/lib/form/form-groups/form-input/input-file-picker/InputFilePicker.tsx new file mode 100644 index 000000000..0ff3ba70d --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-file-picker/InputFilePicker.tsx @@ -0,0 +1,74 @@ +// tslint:disable:no-null-keyword +import { createRef, Dispatch, FC, RefObject, SetStateAction, useEffect, useState } from 'react' + +import { Button, useCheckIsMobile } from '../../../..' +import { InputValue } from '../../../form-input.model' + +import styles from './InputFilePicker.module.scss' + +interface InputFilePickerProps { + readonly fileConfig?: { + readonly acceptFileType?: string + readonly maxFileSize?: number + } + readonly name: string + readonly onChange: (fileList: FileList | null) => void + readonly value?: InputValue +} + +const InputFilePicker: FC = (props: InputFilePickerProps) => { + + const isMobile: boolean = useCheckIsMobile() + + const fileInputRef: RefObject = createRef() + + const [files, setFiles]: [FileList | null, Dispatch>] = useState(null) + const [fileName, setFileName]: [string | undefined, Dispatch>] = useState() + + useEffect(() => { + if (files && files.length) { + setFileName(files[0].name) + } else if (fileName) { + setFileName(undefined) + } + }, [ + files, + fileName, + ]) + + return ( +
+ { + fileName &&

{fileName}

+ } +
+ ) +} + +export default InputFilePicker diff --git a/src-ts/lib/form/form-groups/form-input/input-file-picker/index.ts b/src-ts/lib/form/form-groups/form-input/input-file-picker/index.ts new file mode 100644 index 000000000..9a1a26f3c --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-file-picker/index.ts @@ -0,0 +1 @@ +export { default as InputFilePicker } from './InputFilePicker' diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx index c1a24147d..f781167b0 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx +++ b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx @@ -24,7 +24,7 @@ const AwardedMembersTab: FC = (props: AwardedMembersTabP const pageHandler: InfinitePageHandler = useGetGameBadgeAssigneesPage(props.badge, sort) useEffect(() => { - if (props.forceRefresh && pageHandler) { + if (props.forceRefresh && pageHandler && !pageHandler.isValidating) { pageHandler.mutate() } }, [ diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx index 9b7961833..88ed03921 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx @@ -264,7 +264,7 @@ const BadgeDetailPage: FC = () => { } } - function onManualAssign(): void { + function onAssign(): void { // refresh awardedMembers data setForceAwardedMembersTabRefresh(true) setActiveTab(BadgeDetailsTabViews.awardedMembers) @@ -279,11 +279,14 @@ const BadgeDetailPage: FC = () => { if (activeTab === BadgeDetailsTabViews.manualAward) { activeTabElement = } if (activeTab === BadgeDetailsTabViews.batchAward) { - activeTabElement = + activeTabElement = } // show page loader if we fetching results diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss index 36bd71e18..61beede7d 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss @@ -1,3 +1,55 @@ +@use '../../../../../lib/styles/typography'; +@import "../../../../../lib/styles/variables/palette"; +@import "../../../../../lib/styles/includes"; + +$error-line-height: 14px; + .tabWrap { display: flex; + flex-direction: column; + padding-bottom: 260px; + + .batchFormWrap { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-xxxxl; + margin-top: $space-xxl; + + @include ltemd { + grid-template-columns: 1fr; + } + + .templateLink { + text-transform: uppercase; + color: $turq-160; + font-weight: $font-weight-bold; + margin-top: $space-lg; + display: inline-block; + } + + .batchForm { + display: flex; + flex-direction: column; + + .error { + display: flex; + align-items: center; + color: $red-100; + // extend body ultra small and override it + @extend .ultra-small; + line-height: $error-line-height; + margin-top: $space-xs; + + svg { + @include icon-md; + fill: $red-100; + margin-right: $space-xs; + } + } + + .actionsWrap { + margin-top: $space-xxl; + } + } + } } \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx index d71def501..3e9e9998e 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx @@ -1,11 +1,92 @@ -import { FC } from 'react' +// tslint:disable:no-null-keyword +import { Dispatch, FC, SetStateAction, useState } from 'react' + +import { Button, IconSolid, InputFilePicker } from '../../../../../lib' +import { GameBadge } from '../../../game-lib' +import { BadgeAssignedModal } from '../../../game-lib/modals/badge-assigned-modal' +import { batchAssignRequestAsync } from '../badge-details.functions' import styles from './BatchAwardTab.module.scss' +interface BatchAwardTabProps { + badge: GameBadge, + onBatchAssign: () => void +} + +const BatchAwardTab: FC = (props: BatchAwardTabProps) => { + + const [showBadgeAssigned, setShowBadgeAssigned]: [boolean, Dispatch>] = useState(false) + + const [files, setFiles]: [FileList | null, Dispatch>] = useState(null) + + const [errorText, setErrorText]: [string, Dispatch>] = useState('') + + function onFilePick(fileList: FileList | null): void { + if (fileList && fileList[0] && fileList[0].type !== 'text/csv') { + setErrorText('Only CSV files are allowed.') + } else { + setFiles(fileList) + setErrorText('') + } + } + + function onAward(): void { + batchAssignRequestAsync(files?.item(0) as File) + .then(() => { + setShowBadgeAssigned(true) + setFiles(null) + }) + .catch(e => { + let message: string = e.message + if (e.errors && e.errors[0] && e.errors[0].path === 'user_id') { + message = `CSV file contains duplicate data. There are members included already owning this badge.` + } + setErrorText(message) + }) + } -const BatchAwardTab: FC = () => { return (

Batch Award

+
+
+

If you would like to assign multiple people to multiple badges, this area is for you. Download the template below, populate the file with your data, and upload that file to the right once completed.

+ Download template CSV +
+
+ + {errorText && ( +
+ + {errorText} +
+ )} +
+
+
+
+ { + showBadgeAssigned && { + setShowBadgeAssigned(false) + props.onBatchAssign() + }} + /> + }
) } diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts index 27b8ba585..d86f98b8f 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts +++ b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts @@ -1,6 +1,7 @@ import { GamificationConfig } from '../../game-config' import { GameBadge } from '../../game-lib' +import { submitRequestAsync as submitBatchAssignRequestAsync } from './batch-assign-badge.store' import { submitRequestAsync as submitBadgeAssingRequestAsync } from './manual-assign-badge.store' import { submitRequestAsync as submitBadgeUpdateRequestAsync } from './update-badge.store' import { UpdateBadgeRequest } from './updated-badge-request.model' @@ -18,3 +19,7 @@ export function generateCSV(input: Array>): string { export async function manualAssignRequestAsync(csv: string): Promise { return submitBadgeAssingRequestAsync(csv) } + +export async function batchAssignRequestAsync(batchFile: File): Promise { + return submitBatchAssignRequestAsync(batchFile) +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/batch-assign-badge.store.ts b/src-ts/tools/gamification-admin/pages/badge-detail/batch-assign-badge.store.ts new file mode 100644 index 000000000..f15b0c58d --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/batch-assign-badge.store.ts @@ -0,0 +1,17 @@ +import { EnvironmentConfig } from '../../../../config' +import { xhrPostAsync } from '../../../../lib' + +export async function submitRequestAsync(batchFile: File): Promise { + const url: string = `${EnvironmentConfig.API.V5}/gamification/badges/assign` + + const form: any = new FormData() + + // fill the form + form.append('file', batchFile) + + return xhrPostAsync(url, form, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) +}