diff --git a/package.json b/package.json index ad70b321e..7913d0c3a 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,17 @@ "highlight.js": "^11.6.0", "html2canvas": "^1.4.1", "lodash": "^4.17.21", + "markdown-it": "^13.0.1", "marked": "4.0.3", "moment": "^2.29.3", "moment-timezone": "^0.5.34", "prop-types": "^15.8.1", + "qs": "^6.11.0", "rc-checkbox": "^2.3.2", "react": "^17.0.2", "react-apexcharts": "^1.4.0", "react-app-rewired": "^2.2.1", + "react-contenteditable": "^3.3.6", "react-dom": "^17.0.2", "react-elastic-carousel": "^0.11.5", "react-gtm-module": "^2.0.11", @@ -85,6 +88,7 @@ "@types/highlightjs": "^9.12.2", "@types/jest": "^27.0.1", "@types/lodash": "^4.14.182", + "@types/markdown-it": "^12.2.3", "@types/marked": "4.0.3", "@types/node": "^18.7.13", "@types/reach__router": "^1.3.10", diff --git a/src-ts/lib/functions/xhr-functions/xhr.functions.ts b/src-ts/lib/functions/xhr-functions/xhr.functions.ts index 8df50e55e..2ea1d30f7 100644 --- a/src-ts/lib/functions/xhr-functions/xhr.functions.ts +++ b/src-ts/lib/functions/xhr-functions/xhr.functions.ts @@ -74,6 +74,8 @@ function interceptError(instance: AxiosInstance): void { // if there is server error message, then return it inside `message` property of error error.message = error?.response?.data?.message || error.message + // if there is server errors data, then return it inside `errors` property of error + error.errors = error?.response?.data?.errors return Promise.reject(error) } diff --git a/src-ts/lib/member-autocomplete/InputHandleAutocomplete.module.scss b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.module.scss new file mode 100644 index 000000000..3ffa6b159 --- /dev/null +++ b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.module.scss @@ -0,0 +1,5 @@ +@import "../styles/variables/palette"; + +.memberSelect { + color: $black-60; +} diff --git a/src-ts/lib/member-autocomplete/InputHandleAutocomplete.tsx b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.tsx new file mode 100644 index 000000000..03a8810d5 --- /dev/null +++ b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.tsx @@ -0,0 +1,94 @@ +import { FC, FocusEvent } from 'react' +import { MultiValue, StylesConfig } from 'react-select' +// tslint:disable-next-line: no-submodule-imports +import AsyncSelect from 'react-select/async' + +import { InputWrapper } from '../form/form-groups/form-input/input-wrapper' + +import { membersAutocompete, MembersAutocompeteResult } from './input-handle-functions' +import styles from './InputHandleAutocomplete.module.scss' + +export interface InputHandleAutocompleteProps { + readonly className?: string + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly hideInlineErrors?: boolean + readonly hint?: string + readonly label?: string | JSX.Element + readonly name: string + readonly onBlur?: (event: FocusEvent) => void + readonly onChange: (newValue: Array) => void + readonly placeholder?: string + readonly tabIndex: number + readonly value?: Array +} + +const InputHandleAutocomplete: FC = (props: InputHandleAutocompleteProps) => { + const customStyles: StylesConfig = { + control: (provided) => ({ + ...provided, + border: 'none', + }), + input: (provided) => ({ + ...provided, + color: 'inherit', + fontSize: 16, + }), + multiValue: (provided) => ({ + ...provided, + borderRadius: 50, + }), + multiValueLabel: (provided) => ({ + ...provided, + fontSize: 12, + }), + option: (provided) => ({ + ...provided, + borderBottom: '1px solid #E9E9E9', + color: 'inherit', + fontSize: 16, + fontWeight: 400, + padding: 16, + }), + placeholder: (provided) => ({ + ...provided, + color: 'inherit', + fontSize: 16, + fontWeight: 400, + }), + valueContainer: (provided) => ({ + ...provided, + padding: 0, + }), + } + + return ( + + handle} + getOptionValue={({ userId }) => userId} + isMulti + key={props.value?.length} + loadOptions={membersAutocompete} + styles={customStyles} + placeholder={props.placeholder} + onBlur={props.onBlur} + onChange={(newValue: MultiValue) => props.onChange(newValue as Array)} + value={props.value} + isDisabled={props.disabled} + /> + + ) +} + +export default InputHandleAutocomplete diff --git a/src-ts/lib/member-autocomplete/index.ts b/src-ts/lib/member-autocomplete/index.ts new file mode 100644 index 000000000..92b750f67 --- /dev/null +++ b/src-ts/lib/member-autocomplete/index.ts @@ -0,0 +1 @@ +export { default as InputHandleAutocomplete } from './InputHandleAutocomplete' diff --git a/src-ts/lib/member-autocomplete/input-handle-functions.ts b/src-ts/lib/member-autocomplete/input-handle-functions.ts new file mode 100644 index 000000000..7fbd66afa --- /dev/null +++ b/src-ts/lib/member-autocomplete/input-handle-functions.ts @@ -0,0 +1,23 @@ +import qs from 'qs' + +import { xhrGetAsync } from '..' +import { EnvironmentConfig } from '../../config' + +export interface MembersAutocompeteQuery { + term: string +} + +export interface MembersAutocompeteResult { + firstName: string + handle: string + lastName: string + userId: string +} + +export async function membersAutocompete(term: string): Promise> { + const query: MembersAutocompeteQuery = { + term, + } + + return xhrGetAsync(`${EnvironmentConfig.API.V5}/members/autocomplete?${qs.stringify(query)}`) +} diff --git a/src-ts/tools/gamification-admin/game-config/gamification-config.model.ts b/src-ts/tools/gamification-admin/game-config/gamification-config.model.ts index c397769c1..0024cbf16 100644 --- a/src-ts/tools/gamification-admin/game-config/gamification-config.model.ts +++ b/src-ts/tools/gamification-admin/game-config/gamification-config.model.ts @@ -1,5 +1,6 @@ export interface GamificationConfigModel { ACCEPTED_BADGE_MIME_TYPES: string + CSV_HEADER: Array, MAX_BADGE_IMAGE_FILE_SIZE: number ORG_ID: string PAGE_SIZE: number diff --git a/src-ts/tools/gamification-admin/game-config/gamification.default.config.ts b/src-ts/tools/gamification-admin/game-config/gamification.default.config.ts index b35ada502..05a4f7b51 100644 --- a/src-ts/tools/gamification-admin/game-config/gamification.default.config.ts +++ b/src-ts/tools/gamification-admin/game-config/gamification.default.config.ts @@ -2,6 +2,7 @@ import { GamificationConfigModel } from './gamification-config.model' export const GamificationConfigDefault: GamificationConfigModel = { ACCEPTED_BADGE_MIME_TYPES: 'image/svg+xml,image/svg', + CSV_HEADER: ['tc_handle', 'badge_id'], MAX_BADGE_IMAGE_FILE_SIZE: 5000000, // 5mb in bytes ORG_ID: '6052dd9b-ea80-494b-b258-edd1331e27a3', PAGE_SIZE: 12, diff --git a/src-ts/tools/gamification-admin/game-lib/game-badge.model.ts b/src-ts/tools/gamification-admin/game-lib/game-badge.model.ts index 7c4c719b2..f4de99dd3 100644 --- a/src-ts/tools/gamification-admin/game-lib/game-badge.model.ts +++ b/src-ts/tools/gamification-admin/game-lib/game-badge.model.ts @@ -6,5 +6,11 @@ export interface GameBadge { badge_name: string badge_status: string id: string + member_badges?: Array<{ + awarded_at: string, + awarded_by: string, + user_handle: string, + user_id: string, + }> organization_id: string } diff --git a/src-ts/tools/gamification-admin/game-lib/index.ts b/src-ts/tools/gamification-admin/game-lib/index.ts index 6cb57fe53..9c270733b 100644 --- a/src-ts/tools/gamification-admin/game-lib/index.ts +++ b/src-ts/tools/gamification-admin/game-lib/index.ts @@ -1,3 +1,4 @@ export * from './game-badge.model' export * from './use-get-game-badges-page.hook' export * from './use-gamification-breadcrumb.hook' +export * from './use-get-game-badge-details.hook' diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.module.scss b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.module.scss new file mode 100644 index 000000000..abd8dc340 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.module.scss @@ -0,0 +1,54 @@ +@import "../../../../../lib/styles/variables"; +@import "../../../../../lib/styles/includes"; + +.wrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + + .badge { + display: flex; + align-items: center; + margin-bottom: $space-xxl; + + @include ltemd { + margin-bottom: 0; + } + + .badge-image { + width: 43px; + height: 43px; + margin-right: $space-xl; + } + + .badge-image-disabled { + width: 43px; + height: 43px; + margin-right: $space-xl; + opacity: 0.5; + filter: grayscale(1); + } + + .badge-name { + font-size: 16px; + } + } + + .actions-wrap { + display: flex; + flex-direction: column; + + .actions { + display: flex; + align-items: center; + + @include ltemd { + justify-content: flex-end; + } + + a { + margin-right: $space-md; + } + } + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.tsx b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.tsx new file mode 100644 index 000000000..5cce15d7f --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.tsx @@ -0,0 +1,55 @@ +import { FC } from 'react' + +import { BaseModal, Button, PageDivider, useCheckIsMobile } from '../../../../../lib' +import { GameBadge } from '../../game-badge.model' + +import styles from './BadgeAssignedModal.module.scss' +export interface BadgeAssignedModalProps { + badge: GameBadge + isOpen: boolean + onClose: () => void +} + +const BadgeAssignedModal: FC = (props: BadgeAssignedModalProps) => { + + const isMobile: boolean = useCheckIsMobile() + + function onClose(): void { + props.onClose() + } + + return ( + +
+
+ {props.badge.badge_name} +

{props.badge.badge_name} badge has been sucessfully awarded.

+
+
+ { + isMobile && + } +
+
+
+
+
+ ) +} + +export default BadgeAssignedModal diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/index.ts b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/index.ts new file mode 100644 index 000000000..7f2b04a0b --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/index.ts @@ -0,0 +1 @@ +export { default as BadgeAssignedModal } from './BadgeAssignedModal' diff --git a/src-ts/tools/gamification-admin/game-lib/use-get-game-badge-details.hook.ts b/src-ts/tools/gamification-admin/game-lib/use-get-game-badge-details.hook.ts new file mode 100644 index 000000000..349cf1efa --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/use-get-game-badge-details.hook.ts @@ -0,0 +1,24 @@ +import useSWR, { KeyedMutator, SWRResponse } from 'swr' + +import { EnvironmentConfig } from '../../../config' + +import { GameBadge } from './game-badge.model' + +export interface BadgeDetailPageHandler { + data?: Readonly + error?: Readonly + mutate: KeyedMutator +} + +export function useGetGameBadgeDetails(badgeID: string): BadgeDetailPageHandler { + + const badgeEndpointUrl: URL = new URL(`${EnvironmentConfig.API.V5}/gamification/badges/${badgeID}`) + + const { data, error, mutate }: SWRResponse = useSWR(badgeEndpointUrl.toString()) + + return { + data, + error, + mutate, + } +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.module.scss new file mode 100644 index 000000000..36bd71e18 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.module.scss @@ -0,0 +1,3 @@ +.tabWrap { + display: flex; +} \ No newline at end of file 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 new file mode 100644 index 000000000..eff8fe2ff --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react' + +import { GameBadge } from '../../../game-lib' + +import styles from './AwardedMembersTab.module.scss' + +export interface AwardedMembersTabProps { + badge: GameBadge +} + +const AwardedMembersTab: FC = (props: AwardedMembersTabProps) => { + return ( +
+ +
+ ) +} + +export default AwardedMembersTab diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/index.ts b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/index.ts new file mode 100644 index 000000000..e9063473a --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/index.ts @@ -0,0 +1 @@ +export * from './AwardedMembersTab' diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss index 7da13b030..01e18d2e6 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss @@ -1,17 +1,142 @@ -.contentLayout { - width: 100%; - padding-bottom: 0; +@import "../../../../lib/styles/variables"; +@import "../../../../lib/styles/includes"; - .contentLayout-outer { - width: 100%; +$badgePreview: 130px; +$badgePreviewImage: 72px; - .contentLayout-inner { - width: 100%; - overflow: visible; +.container { + display: flex; + flex-direction: column; + + .error { + background-color: $black-5; + color: red; + padding: $space-sm; } - } -} -.container { - display: flex; -} + .badge { + display: flex; + margin-top: $space-xxxxl; + + @include ltemd { + flex-direction: column; + } + + .badgeImage { + position: relative; + border-radius: $space-sm; + background-color: $black-5; + margin-right: $space-xxxxl; + display: flex; + align-items: center; + justify-content: center; + width: $badgePreview; + height: $badgePreview; + min-width: $badgePreview; + min-height: $badgePreview; + + @include ltemd { + margin-right: 0; + margin-bottom: $space-xxl; + width: 100%; + } + + img { + width: $badgePreviewImage; + height: $badgePreviewImage; + } + + .filePickerPencil { + position: absolute; + top: 0; + right: 0; + color: $turq-160; + } + + .filePickerInput { + display: none; + } + } + + .badgeDetails { + display: flex; + flex-direction: column; + flex: 1; + + .badgeName { + font-family: $font-roboto; + font-weight: $font-weight-bold; + padding: $space-sm; + font-size: 24px; + line-height: 32px; + + @include ltemd { + font-size: 20px; + line-height: 28px; + } + + &:hover { + background-color: $black-5; + cursor: text; + } + + &:focus { + background-color: $tc-white; + outline-color: $turq-160; + } + } + + .badgeDesc { + margin-top: $space-sm; + + .badgeEditWrap { + display: flex; + flex-direction: column; + + .badgeEditable, + .badgeEditableMode { + padding: $space-sm; + border-radius: 3px; + border: 2px solid $tc-white; + + &:hover { + background-color: $black-5; + cursor: text; + } + + &:focus { + background-color: $tc-white; + outline: none; + } + + a { + color: $link-blue-dark; + } + } + + .badgeEditableMode { + border: 2px solid $turq-160; + } + + .badgeEditActions { + display: flex; + justify-content: flex-end; + margin-top: $space-sm; + + button:first-child { + margin-right: $space-sm; + } + } + } + } + } + } + + .activeTabElement { + margin-top: $space-xxxxl; + + @include ltemd { + margin-top: $space-md; + } + } +} \ No newline at end of file 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 5283989c4..1b092176d 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx @@ -1,13 +1,35 @@ -import { FC } from 'react' +import { noop, trim } from 'lodash' +import MarkdownIt from 'markdown-it' +import { createRef, Dispatch, FC, KeyboardEvent, RefObject, SetStateAction, useEffect, useState } from 'react' +import ContentEditable from 'react-contenteditable' +import { Params, useLocation, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' -import { Breadcrumb, BreadcrumbItemModel, ContentLayout } from '../../../../lib' -import { useGamificationBreadcrumb } from '../../game-lib' +import { Breadcrumb, BreadcrumbItemModel, Button, ButtonProps, ContentLayout, IconOutline, LoadingSpinner, PageDivider, TabsNavbar, TabsNavItem } from '../../../../lib' +import { GamificationConfig } from '../../game-config' +import { BadgeDetailPageHandler, GameBadge, useGamificationBreadcrumb, useGetGameBadgeDetails } from '../../game-lib' +import AwardedMembersTab from './AwardedMembersTab/AwardedMembersTab' +import { badgeDetailsTabs, BadgeDetailsTabViews } from './badge-details-tabs.config' +import { submitRequestAsync as updateBadgeAsync } from './badge-details.functions' import styles from './BadgeDetailPage.module.scss' +import BatchAwardTab from './BatchAwardTab/BatchAwardTab' +import ManualAwardTab from './ManualAwardTab/ManualAwardTab' +const md: MarkdownIt = new MarkdownIt({ + html: true, + // TODO: check with PM ig those are needed? + // linkify: true, + // typographer: true, +}) + +/* tslint:disable:cyclomatic-complexity */ const BadgeDetailPage: FC = () => { - // TDOD: use whit GAME-78 - // const { id: badgeID } : { badgeID: string } = useParams() + const [headerButtonConfig, setHeaderButtonConfig]: [ + ButtonProps | undefined, + Dispatch>, + ] + = useState() const breadcrumb: Array = useGamificationBreadcrumb([ { @@ -16,16 +38,257 @@ const BadgeDetailPage: FC = () => { }, ]) + const { id: badgeID }: Readonly> = useParams() + + const { hash }: { hash: string } = useLocation() + + const [activeTab, setActiveTab]: [string, Dispatch>] = useState( + hash === '#award' ? BadgeDetailsTabViews.manualAward : BadgeDetailsTabViews.awardedMembers + ) + + const [tabs]: [ + ReadonlyArray, + Dispatch>>, + ] + = useState>([...badgeDetailsTabs]) + + const badgeDetailsHandler: BadgeDetailPageHandler = useGetGameBadgeDetails(badgeID as string) + + const badgeNameRef: RefObject = createRef() + + const badgeDescRef: RefObject = createRef() + + const fileInputRef: RefObject = createRef() + + // tslint:disable-next-line:no-null-keyword + const [newImageFile, setNewImageFile]: [FileList | null, Dispatch>] = useState(null) + + const [fileDataURL, setFileDataURL]: [string | undefined, Dispatch>] = useState() + + const [isBadgeDescEditingMode, setIsBadgeDescEditingMode]: [boolean, Dispatch>] = useState(false) + + useEffect(() => { + if (newImageFile && newImageFile.length) { + const fileReader: FileReader = new FileReader() + fileReader.onload = e => { + const { result }: any = e.target + if (result) { + setFileDataURL(result) + } + } + fileReader.readAsDataURL(newImageFile[0]) + } else if (fileDataURL) { + setFileDataURL(undefined) + } + }, [ + newImageFile, + fileDataURL, + ]) + + useEffect(() => { + if (newImageFile && newImageFile.length) { + updateBadgeAsync({ + files: newImageFile as FileList, + id: badgeDetailsHandler.data?.id as string, + }) + .then((updatedBadge: GameBadge) => { + toast.success('Badge image file saved.') + badgeDetailsHandler.mutate({ + ...badgeDetailsHandler.data, + badge_image_url: updatedBadge.badge_image_url, + }) + }) + } + }, [ + newImageFile, + ]) + + useEffect(() => { + if (badgeDetailsHandler.data) { + switch (badgeDetailsHandler.data?.active) { + case true: + setHeaderButtonConfig({ + label: 'DeActivate', + onClick: onDisableBadge, + }) + break + case false: + setHeaderButtonConfig({ + label: 'Activate', + onClick: onActivateBadge, + }) + break + } + } + }, [ + badgeDetailsHandler.data, + ]) + + // define the tabs so they can be displayed on various tasks + const tabsElement: JSX.Element = ( + + ) + + function onActivateBadge(): void { + // TODO: implement in GAME-127 + } + + function onDisableBadge(): void { + // TODO: implement in GAME-127 + } + + function onNameEditKeyDown(e: KeyboardEvent): void { + if (e.key === 'Enter') { + e.preventDefault() + badgeNameRef.current?.blur() + } + } + + function onBadgeNameEditFocus(): void { + if (isBadgeDescEditingMode) { + setIsBadgeDescEditingMode(false) + } + } + + function onSaveBadgeName(): any { + const newBadgeName: string | undefined = trim(badgeNameRef.current?.innerHTML) + if (newBadgeName !== badgeDetailsHandler.data?.badge_name) { + // save only if different + updateBadgeAsync({ + badgeName: newBadgeName, + id: badgeDetailsHandler.data?.id as string, + }) + .then(() => { + toast.success('Badge name update saved.') + badgeDetailsHandler.mutate({ + ...badgeDetailsHandler.data, + badge_name: newBadgeName, + }) + }) + } + } + + function onSaveBadgeDesc(): any { + setIsBadgeDescEditingMode(false) + const newBadgeDesc: string | undefined = trim(badgeDescRef.current?.innerHTML) + if (newBadgeDesc !== badgeDetailsHandler.data?.badge_description) { + // save only if different + updateBadgeAsync({ + badgeDesc: newBadgeDesc, + id: badgeDetailsHandler.data?.id as string, + }) + .then(() => { + toast.success('Badge description update saved.') + badgeDetailsHandler.mutate({ + ...badgeDetailsHandler.data, + badge_description: newBadgeDesc, + }) + }) + } + } + + // default tab + let activeTabElement: JSX.Element + = + if (activeTab === BadgeDetailsTabViews.manualAward) { + activeTabElement = + } + if (activeTab === BadgeDetailsTabViews.batchAward) { + activeTabElement = + } + + // show page loader if we fetching results + if (!badgeDetailsHandler.data && !badgeDetailsHandler.error) { + return + } + return (
- + { + badgeDetailsHandler.error ? ( +
+ {badgeDetailsHandler.error.message} +
+ ) : ( + <> +
+
+
+
+ +
+
+ setIsBadgeDescEditingMode(true)} + className={isBadgeDescEditingMode ? styles.badgeEditableMode : styles.badgeEditable} + /> + { + isBadgeDescEditingMode &&
+
+ } +
+
+
+
+ + {tabsElement} +
+ {activeTabElement} +
+ + ) + }
) 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 new file mode 100644 index 000000000..36bd71e18 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss @@ -0,0 +1,3 @@ +.tabWrap { + display: flex; +} \ 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 new file mode 100644 index 000000000..d71def501 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react' + +import styles from './BatchAwardTab.module.scss' + +const BatchAwardTab: FC = () => { + return ( +
+

Batch Award

+
+ ) +} + +export default BatchAwardTab diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/index.ts b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/index.ts new file mode 100644 index 000000000..1cb3ea691 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/index.ts @@ -0,0 +1 @@ +export * from './BatchAwardTab' diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.module.scss new file mode 100644 index 000000000..9e069b176 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.module.scss @@ -0,0 +1,23 @@ +@import "../../../../../lib/styles/variables/palette"; +@import "../../../../../lib/styles/includes"; + +.tabWrap { + display: flex; + flex-direction: column; + + .manualFormWrap { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-xxxxl; + margin-top: $space-xxl; + + @include ltemd { + grid-template-columns: 1fr; + } + + .manualForm { + display: flex; + flex-direction: column; + } + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx new file mode 100644 index 000000000..284ad8f8d --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx @@ -0,0 +1,86 @@ +import { find } from 'lodash' +import { Dispatch, FC, SetStateAction, useState } from 'react' + +import { Button } from '../../../../../lib' +import { InputHandleAutocomplete } from '../../../../../lib/member-autocomplete' +import { MembersAutocompeteResult } from '../../../../../lib/member-autocomplete/input-handle-functions' +import { GameBadge } from '../../../game-lib' +import { BadgeAssignedModal } from '../../../game-lib/modals/badge-assigned-modal' +import { generateCSV, manualAssignRequestAsync } from '../badge-details.functions' + +import styles from './ManualAwardTab.module.scss' + +export interface ManualAwardTabProps { + badge: GameBadge +} + +const ManualAwardTab: FC = (props: ManualAwardTabProps) => { + + const [selectedMembers, setSelectedMembers]: [Array, Dispatch>>] + = useState>([]) + + const [showBadgeAssigned, setShowBadgeAssigned]: [boolean, Dispatch>] = useState(false) + + const [badgeAssignError, setBadgeAssignError]: [string | undefined, Dispatch>] = useState() + + function onAward(): void { + const csv: string = generateCSV( + selectedMembers.map(m => [m.handle, props.badge?.id as string]) + ) + setBadgeAssignError(undefined) + manualAssignRequestAsync(csv) + .then(() => { + setShowBadgeAssigned(true) + setSelectedMembers([]) + }) + .catch(e => { + let message: string = e.message + if (e.errors && e.errors[0] && e.errors[0].path === 'user_id') { + const handleOrId: string = find(selectedMembers, { userId: e.errors[0].value })?.handle || e.errors[0].value + message = `Member ${handleOrId} alredy owns this badge.` + } + setBadgeAssignError(message) + }) + } + + return ( +
+

Manual Award

+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Neque ullamcorper neque sed orci, enim amet, sed.

+
+ +
+
+
+
+ { + showBadgeAssigned && { + setShowBadgeAssigned(false) + }} + /> + } +
+ ) +} + +export default ManualAwardTab diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/index.ts b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/index.ts new file mode 100644 index 000000000..750a3f7a4 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/index.ts @@ -0,0 +1 @@ +export * from './ManualAwardTab' diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/badge-details-tabs.config.ts b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details-tabs.config.ts new file mode 100644 index 000000000..9c9e0ec72 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details-tabs.config.ts @@ -0,0 +1,22 @@ +import { TabsNavItem } from '../../../../lib' + +export enum BadgeDetailsTabViews { + awardedMembers = 'Awarded Members', + manualAward = 'Manual Award', + batchAward = 'Batch Award', +} + +export const badgeDetailsTabs: ReadonlyArray = [ + { + id: BadgeDetailsTabViews.awardedMembers, + title: 'Awarded Members', + }, + { + id: BadgeDetailsTabViews.manualAward, + title: 'Manual Award', + }, + { + id: BadgeDetailsTabViews.batchAward, + title: 'Batch Award', + }, +] 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 new file mode 100644 index 000000000..27b8ba585 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts @@ -0,0 +1,20 @@ +import { GamificationConfig } from '../../game-config' +import { GameBadge } from '../../game-lib' + +import { submitRequestAsync as submitBadgeAssingRequestAsync } from './manual-assign-badge.store' +import { submitRequestAsync as submitBadgeUpdateRequestAsync } from './update-badge.store' +import { UpdateBadgeRequest } from './updated-badge-request.model' + +export async function submitRequestAsync(request: UpdateBadgeRequest): Promise { + return submitBadgeUpdateRequestAsync(request) +} + +export function generateCSV(input: Array>): string { + input.unshift(GamificationConfig.CSV_HEADER) + + return input.map(row => row.join(',')).join('\n') +} + +export async function manualAssignRequestAsync(csv: string): Promise { + return submitBadgeAssingRequestAsync(csv) +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/manual-assign-badge.store.ts b/src-ts/tools/gamification-admin/pages/badge-detail/manual-assign-badge.store.ts new file mode 100644 index 000000000..ae0cbc545 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/manual-assign-badge.store.ts @@ -0,0 +1,13 @@ +import { EnvironmentConfig } from '../../../../config' +import { xhrPostAsync } from '../../../../lib' + +export async function submitRequestAsync(csv: string): Promise { + const url: string = `${EnvironmentConfig.API.V5}/gamification/badges/assign` + + const form: any = new FormData() + + // fill the form + form.append('file', new Blob([csv], { type: 'text/csv' }), 'data.csv') + + return xhrPostAsync(url, form) +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/update-badge.store.ts b/src-ts/tools/gamification-admin/pages/badge-detail/update-badge.store.ts new file mode 100644 index 000000000..15c934e4f --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/update-badge.store.ts @@ -0,0 +1,28 @@ +import { EnvironmentConfig } from '../../../../config' +import { xhrPostAsync } from '../../../../lib' +import { GameBadge } from '../../game-lib' + +import { UpdateBadgeRequest } from './updated-badge-request.model' + +export async function submitRequestAsync(request: UpdateBadgeRequest): Promise { + const url: string = `${EnvironmentConfig.API.V5}/gamification/badges/${request.id}` + + const form: any = new FormData() + + // fill the form, all fields optional + if (request.files) { + form.append('file', request.files[0]) + } + if (request.badgeActive !== undefined) { + form.append('active', request.badgeActive) + } + if (request.badgeName) { ( + form.append('badge_name', request.badgeName) + ) + } + if (request.badgeDesc) { + form.append('badge_description', request.badgeDesc) + } + + return xhrPostAsync(url, form) +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/updated-badge-request.model.ts b/src-ts/tools/gamification-admin/pages/badge-detail/updated-badge-request.model.ts new file mode 100644 index 000000000..d4712174d --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/updated-badge-request.model.ts @@ -0,0 +1,7 @@ +export interface UpdateBadgeRequest { + badgeActive?: boolean + badgeDesc?: string + badgeName?: string + files?: FileList + id: string +} diff --git a/yarn.lock b/yarn.lock index f49791ede..1a8c31613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2542,16 +2542,34 @@ dependencies: "@types/node" "*" +"@types/linkify-it@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.2.tgz#fd2cd2edbaa7eaac7e7f3c1748b52a19143846c9" + integrity sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA== + "@types/lodash@^4.14.182": version "4.14.184" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.184.tgz#23f96cd2a21a28e106dc24d825d4aa966de7a9fe" integrity sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q== +"@types/markdown-it@^12.2.3": + version "12.2.3" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51" + integrity sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ== + dependencies: + "@types/linkify-it" "*" + "@types/mdurl" "*" + "@types/marked@4.0.3": version "4.0.3" resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.3.tgz#2098f4a77adaba9ce881c9e0b6baf29116e5acc4" integrity sha512-HnMWQkLJEf/PnxZIfbm0yGJRRZYYMhb++O9M36UCTA9z53uPvVoSlAwJr3XOpDEryb7Hwl1qAx/MV6YIW1RXxg== +"@types/mdurl@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" + integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== + "@types/mime@*": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" @@ -6448,6 +6466,11 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" + integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== + errno@^0.1.3, errno@~0.1.7: version "0.1.8" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" @@ -10323,6 +10346,13 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +linkify-it@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" + integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw== + dependencies: + uc.micro "^1.0.1" + lint-staged@^13.0.3: version "13.0.3" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.0.3.tgz#d7cdf03a3830b327a2b63c6aec953d71d9dc48c6" @@ -10669,6 +10699,17 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +markdown-it@^13.0.1: + version "13.0.1" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.1.tgz#c6ecc431cacf1a5da531423fc6a42807814af430" + integrity sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q== + dependencies: + argparse "^2.0.1" + entities "~3.0.1" + linkify-it "^4.0.1" + mdurl "^1.0.1" + uc.micro "^1.0.5" + marked@4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.3.tgz#986760a428d8fd666251ec578429bf9a239a34bc" @@ -10693,6 +10734,11 @@ mdn-data@2.0.4: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== +mdurl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -12649,7 +12695,7 @@ prompts@^2.0.1, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.1, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -12752,7 +12798,7 @@ qs@6.10.3: dependencies: side-channel "^1.0.4" -qs@^6.9.4: +qs@^6.11.0, qs@^6.9.4: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== @@ -12875,6 +12921,14 @@ react-app-rewired@^2.2.1: dependencies: semver "^5.6.0" +react-contenteditable@^3.3.6: + version "3.3.6" + resolved "https://registry.yarnpkg.com/react-contenteditable/-/react-contenteditable-3.3.6.tgz#4dac0eeaff268ca1614b52d4290d7b21f3bfb997" + integrity sha512-61+Anbmzggel1sP7nwvxq3d2woD3duR5R89RoLGqKan1A+nruFIcmLjw2F+qqk70AyABls0BDKzE1vqS1UIF1g== + dependencies: + fast-deep-equal "^3.1.3" + prop-types "^15.7.1" + react-dev-utils@^12.0.0: version "12.0.1" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73" @@ -15312,6 +15366,11 @@ typescript@^4.6.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"