diff --git a/src/config/selectors.ts b/src/config/selectors.ts index 01065037..40fe51ca 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -22,6 +22,11 @@ export const makeUserAnswerCorrectCellCy = (index: number | string): string => export const QUESTION_CY = 'question'; export const ANSWER_CY = 'answer'; export const ANSWER_SUBMIT_BUTTON_CY = 'answer-submit-button'; +export const RESET_BTN_CY = 'reset-button'; + +export const REQUIRED_CHIP_CY = 'required-chip'; +export const SAVED_CHIP_CY = 'saved-chip'; +export const SUBMITTED_CHIP_CY = 'submitted-chip'; export const buildDataCy = (selector: string): string => `[data-cy=${selector}]`; diff --git a/src/config/sentry.ts b/src/config/sentry.ts index 100fdee7..34078b5c 100644 --- a/src/config/sentry.ts +++ b/src/config/sentry.ts @@ -10,26 +10,37 @@ type SentryConfigType = { }; export const generateSentryConfig = (): SentryConfigType => { + let SENTRY_ENVIRONMENT = SENTRY_ENV || 'development'; + let SENTRY_TRACE_SAMPLE_RATE = 1.0; // This sets the sample rate to be 10%. You may want this to be 100% while // in development and sample at a lower rate in production - const DEV_TRACE_SAMPLE_RATE = 1.0; - const DEV_REPLAY_SAMPLE_RATE = 0.1; - const PROD_TRACE_SAMPLE_RATE = 0.1; - const PROD_REPLAY_SAMPLE_RATE = 0.1; + let SENTRY_REPLAY_SAMPLE_RATE = 0.1; + switch (process.env.NODE_ENV) { + case 'production': + SENTRY_ENVIRONMENT = 'production'; + SENTRY_TRACE_SAMPLE_RATE = 0.1; + SENTRY_REPLAY_SAMPLE_RATE = 0.1; + break; + case 'test': + SENTRY_TRACE_SAMPLE_RATE = 0.0; + SENTRY_REPLAY_SAMPLE_RATE = 0.0; + break; + case 'development': + SENTRY_TRACE_SAMPLE_RATE = 1.0; + SENTRY_REPLAY_SAMPLE_RATE = 1.0; + break; + default: + } return { // dsn is set only when not running inside cypress dsn: (!window.Cypress && SENTRY_DSN) || '', - environment: SENTRY_ENV, - tracesSampleRate: import.meta.env.PROD - ? PROD_TRACE_SAMPLE_RATE - : DEV_TRACE_SAMPLE_RATE, + environment: SENTRY_ENV || SENTRY_ENVIRONMENT, + tracesSampleRate: SENTRY_TRACE_SAMPLE_RATE, // release is set only when building for production - release: VERSION, + release: SENTRY_ENVIRONMENT === 'production' ? VERSION : '', - replaysSessionSampleRate: import.meta.env.PROD - ? PROD_REPLAY_SAMPLE_RATE - : DEV_REPLAY_SAMPLE_RATE, + replaysSessionSampleRate: SENTRY_REPLAY_SAMPLE_RATE, // If the entire session is not sampled, use the below sample rate to sample // sessions when an error occurs. replaysOnErrorSampleRate: 1.0, diff --git a/src/interfaces/userAnswer.ts b/src/interfaces/userAnswer.ts index d57082b5..432a2de2 100644 --- a/src/interfaces/userAnswer.ts +++ b/src/interfaces/userAnswer.ts @@ -1,3 +1,9 @@ +export enum UserAnswerStatus { + Saved = 'saved', + Submitted = 'submitted', +} + export type UserAnswer = { answer?: string; + status?: UserAnswerStatus; }; diff --git a/src/langs/en.json b/src/langs/en.json index eb58e64b..4fe3dc54 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -40,7 +40,16 @@ }, "PLAYER": { "SAVED_MESSAGE": "Saved", - "SAVE_BUTTON": "Save" + "SAVE_BUTTON": "Save", + "SUBMIT_OK_TOOLTIP": "Your answer has been submitted.", + "SUBMIT_OK_HELPER": "Submitted", + "SAVED_TOOLTIP": "Your answer has been saved.", + "SAVED_HELPER": "Saved", + "RESET_ANSWER": "Reset your answer.", + "REQUIRED_CHIP": "Required", + "REQUIRED_TOOLTIP": "This question requires an answer.", + "SUBMIT": "Submit", + "RESET": "Reset" } } } diff --git a/src/main.tsx b/src/main.tsx index 7641939b..414250e6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,11 +12,10 @@ import buildDatabase, { defaultMockContext, mockMembers } from './mocks/db'; import Root from './modules/Root'; Sentry.init({ - integrations: [new Sentry.BrowserTracing(), new Sentry.Replay()], - - // Set tracesSampleRate to 1.0 to capture 100% - // of transactions for performance monitoring. - // We recommend adjusting this value in production + integrations: [ + Sentry.replayIntegration(), + Sentry.browserTracingIntegration(), + ], ...generateSentryConfig(), }); diff --git a/src/mocks/db.ts b/src/mocks/db.ts index efbba54c..34dbe038 100644 --- a/src/mocks/db.ts +++ b/src/mocks/db.ts @@ -2,23 +2,16 @@ import type { Database, LocalContext } from '@graasp/apps-query-client'; import { AppItemFactory, CompleteMember, + Context, MemberFactory, PermissionLevel, } from '@graasp/sdk'; import { API_HOST } from '@/config/env'; -export const defaultMockContext: LocalContext = { - apiHost: API_HOST, - permission: PermissionLevel.Admin, - context: 'builder', - itemId: '1234-1234-123456-8123-123456', - memberId: 'mock-member-id', -}; - export const mockMembers: CompleteMember[] = [ MemberFactory({ - id: defaultMockContext.memberId || '', + id: '1', name: 'current-member', email: 'a@graasp.org', type: 'individual', @@ -36,33 +29,23 @@ export const mockMembers: CompleteMember[] = [ ]; export const mockItem = AppItemFactory({ - id: defaultMockContext.itemId, name: 'app-short-answer', creator: mockMembers[0], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); +export const defaultMockContext: LocalContext = { + apiHost: API_HOST, + permission: PermissionLevel.Admin, + context: Context.Builder, + itemId: mockItem.id, + memberId: mockMembers[0].id, +}; + const buildDatabase = (members?: CompleteMember[]): Database => ({ appData: [], - appActions: [ - { - id: 'cecc1671-6c9d-4604-a3a2-6d7fad4a5996', - type: 'admin-action', - member: mockMembers[0], - createdAt: new Date().toISOString(), - item: mockItem, - data: { content: 'hello' }, - }, - { - id: '0c11a63a-f333-47e1-8572-b8f99fe883b0', - type: 'other-action', - member: mockMembers[1], - createdAt: new Date().toISOString(), - item: mockItem, - data: { content: 'other member' }, - }, - ], + appActions: [], members: members ?? mockMembers, appSettings: [], items: [mockItem], diff --git a/src/modules/context/UserAnswersContext.tsx b/src/modules/context/UserAnswersContext.tsx new file mode 100644 index 00000000..4c53a7a4 --- /dev/null +++ b/src/modules/context/UserAnswersContext.tsx @@ -0,0 +1,157 @@ +import { + FC, + ReactElement, + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { useLocalContext } from '@graasp/apps-query-client'; +import { AppData, PermissionLevel, PermissionLevelCompare } from '@graasp/sdk'; + +import sortBy from 'lodash.sortby'; + +import { + AppDataType, + UserAnswerAppData, + getDefaultUserAnswerAppData, +} from '@/config/appData'; +import { hooks, mutations } from '@/config/queryClient'; +import { UserAnswer, UserAnswerStatus } from '@/interfaces/userAnswer'; + +import { useSettings } from './SettingsContext'; + +type UserAnswersContextType = { + userAnswer?: UserAnswer; + setAnswer: (answer: string) => void; + submitAnswer: () => void; + deleteAnswer: (id?: UserAnswerAppData['id']) => void; + allAnswersAppData?: UserAnswerAppData[]; +}; + +const defaultContextValue: UserAnswersContextType = { + setAnswer: () => null, + submitAnswer: () => null, + deleteAnswer: () => null, +}; + +const UserAnswersContext = + createContext(defaultContextValue); + +export const UserAnswersProvider: FC<{ + children: ReactElement | ReactElement[]; +}> = ({ children }) => { + const { data, isSuccess } = hooks.useAppData({ + type: AppDataType.UserAnswer, + }); + const [userAnswerAppData, setUserAnswerAppData] = + useState(); + const [allAnswersAppData, setAllAnswersAppData] = + useState(); + const { mutate: postAppData } = mutations.usePostAppData(); + const { mutate: patchAppData } = mutations.usePatchAppData(); + const { mutate: deleteAppData } = mutations.useDeleteAppData(); + const { permission, memberId } = useLocalContext(); + + const { general } = useSettings(); + const { autosubmit } = general; + + const isAdmin = useMemo( + () => PermissionLevelCompare.gte(permission, PermissionLevel.Admin), + [permission], + ); + useEffect(() => { + if (isSuccess) { + const allAns = data.filter( + (d: AppData) => d.type === AppDataType.UserAnswer, + ) as UserAnswerAppData[]; + setAllAnswersAppData(allAns); + setUserAnswerAppData( + sortBy(allAns, ['createdAt']) + .reverse() + .find((d) => d.member.id === memberId), + ); + } + }, [isSuccess, data, memberId]); + + const setAnswer = useMemo( + () => + (answer: string): void => { + const payloadData = { + answer, + status: autosubmit + ? UserAnswerStatus.Submitted + : UserAnswerStatus.Saved, + }; + if (userAnswerAppData?.id) { + patchAppData({ + ...userAnswerAppData, + data: payloadData, + }); + } else { + postAppData(getDefaultUserAnswerAppData(payloadData)); + } + }, + [autosubmit, patchAppData, postAppData, userAnswerAppData], + ); + + const submitAnswer = useMemo( + () => (): void => { + if (userAnswerAppData?.id) { + const payloadData = { + ...userAnswerAppData.data, + status: UserAnswerStatus.Submitted, + }; + patchAppData({ + ...userAnswerAppData, + data: payloadData, + }); + } else { + throw new Error('No answer to submit.'); + } + }, + [patchAppData, userAnswerAppData], + ); + + const deleteAnswer = useMemo( + () => + (id?: UserAnswerAppData['id']): void => { + if (id) { + deleteAppData({ id }); + } else if (userAnswerAppData) { + deleteAppData({ id: userAnswerAppData?.id }); + } + }, + [deleteAppData, userAnswerAppData], + ); + const contextValue = useMemo( + () => ({ + userAnswer: userAnswerAppData?.data, + setAnswer, + submitAnswer, + allAnswersAppData: isAdmin ? allAnswersAppData : undefined, + deleteAnswer, + }), + [ + allAnswersAppData, + deleteAnswer, + isAdmin, + setAnswer, + submitAnswer, + userAnswerAppData?.data, + ], + ); + + return ( + + {children} + + ); +}; + +const useUserAnswers = (): UserAnswersContextType => + useContext(UserAnswersContext); + +export default useUserAnswers; diff --git a/src/modules/main/PlayerView.tsx b/src/modules/main/PlayerView.tsx index 29b09da8..765515ec 100644 --- a/src/modules/main/PlayerView.tsx +++ b/src/modules/main/PlayerView.tsx @@ -1,113 +1,13 @@ -import { ChangeEvent, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Box, Grid, TextField, Typography } from '@mui/material'; - -import { useLocalContext } from '@graasp/apps-query-client'; -import { AppData } from '@graasp/sdk'; - -import isEqual from 'lodash.isequal'; -import sortBy from 'lodash.sortby'; - -import { AppDataType } from '@/config/appData'; -import { hooks, mutations } from '@/config/queryClient'; -import { ANSWER_CY, PLAYER_VIEW_CY, QUESTION_CY } from '@/config/selectors'; -import { UserAnswer } from '@/interfaces/userAnswer'; -import SubmitButton from '@/modules/common/SubmitButton'; -import { useSettings } from '@/modules/context/SettingsContext'; - -function isAnswer(appData: AppData): boolean { - return appData.type === AppDataType.UserAnswer; -} - -const PlayerView = (): JSX.Element => { - const { t } = useTranslation('translations', { keyPrefix: 'PLAYER' }); - const { memberId } = useLocalContext(); - const { - question, - // answer: answerSavedState, - } = useSettings(); - const { data: appData } = hooks.useAppData(); - const { mutate: postAppData } = mutations.usePostAppData(); - - const [answer, setAnswer] = useState(''); - const [savedAnswer, setSavedAnswer] = useState(''); - - // use effect to get required app data - useEffect(() => { - if (appData) { - // only show the last answer - const savedAnswerObject = sortBy(appData, ['createdAt']) - .reverse() - .find(isAnswer) as AppData; - if (savedAnswerObject) { - const savedAnswerText = savedAnswerObject.data.answer ?? ''; - setAnswer(savedAnswerText); - setSavedAnswer(savedAnswerText); - } - } - }, [appData]); - - const disableSave = useMemo(() => { - // disable if there is no user (logged out or anonymous) - if (!memberId) { - return true; - } - // disable if answer is equal - return isEqual(savedAnswer, answer); - }, [answer, savedAnswer, memberId]); - - const disabledMessage = useMemo(() => { - // disable if there is no user (logged out or anonymous) - if (!memberId) { - return t('SAVE_BUTTON'); - } - return t('SAVED_MESSAGE'); - }, [memberId, t]); - - const handleChangeAnswer = (event: ChangeEvent): void => { - const { value } = event.target; - setAnswer(value); - }; - - const handleSubmitAnswer = (): void => { - postAppData({ - data: { answer }, - type: AppDataType.UserAnswer, - visibility: 'member', - }); - }; - - return ( -
- - - {question?.content} - - - - - - - - {disableSave ? disabledMessage : t('SAVE_BUTTON')} - - - - -
- ); -}; +import { PLAYER_VIEW_CY } from '@/config/selectors'; + +import { UserAnswersProvider } from '../context/UserAnswersContext'; +import QuestionView from '../question-view/QuestionView'; + +const PlayerView = (): JSX.Element => ( +
+ + + +
+); export default PlayerView; diff --git a/src/modules/question-view/QuestionView.tsx b/src/modules/question-view/QuestionView.tsx new file mode 100644 index 00000000..7e2fa51a --- /dev/null +++ b/src/modules/question-view/QuestionView.tsx @@ -0,0 +1,139 @@ +import { ChangeEvent, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import ReplayIcon from '@mui/icons-material/Replay'; +import SendIcon from '@mui/icons-material/Send'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; + +import { + QuestionLabel, + RequiredChip, + SavedChip, + SubmittedChip, +} from '@graasp/ui/apps'; + +import { + ANSWER_CY, + ANSWER_SUBMIT_BUTTON_CY, + QUESTION_CY, + REQUIRED_CHIP_CY, + RESET_BTN_CY, + SAVED_CHIP_CY, + SUBMITTED_CHIP_CY, +} from '@/config/selectors'; +import { UserAnswerStatus } from '@/interfaces/userAnswer'; +import { useSettings } from '@/modules/context/SettingsContext'; + +import useUserAnswers from '../context/UserAnswersContext'; + +const QuestionView = (): JSX.Element => { + const { t } = useTranslation('translations', { keyPrefix: 'PLAYER' }); + const { question, general } = useSettings(); + const { required } = general; + + const { + userAnswer, + deleteAnswer, + submitAnswer, + setAnswer: setSavedAnswer, + } = useUserAnswers(); + + const [answer, setAnswer] = useState(''); + + // Update the answer if the stored value change + useEffect(() => { + setAnswer(userAnswer?.answer ?? ''); + }, [userAnswer]); + const answerStatus = useMemo(() => userAnswer?.status, [userAnswer?.status]); + + const showSubmitButton = useMemo( + () => userAnswer?.status === UserAnswerStatus.Saved, + [userAnswer], + ); + const showResetButton = useMemo( + () => typeof userAnswer !== 'undefined', + [userAnswer], + ); + + const handleAnswerChange = (e: ChangeEvent): void => { + const newAns = e.target.value; + setAnswer(newAns); + setSavedAnswer(newAns); + }; + + return ( + + + + <> + {question?.content} + {required && question?.content.length > 0 && *} + + + + + + {answerStatus === UserAnswerStatus.Submitted && ( + + )} + {answerStatus === UserAnswerStatus.Saved && ( + + )} + {answer.length === 0 && required && ( + + )} + + + + + + + + + + + + + ); +}; +export default QuestionView; diff --git a/tsconfig.json b/tsconfig.json index 6a925fa3..bf8798e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,13 +10,13 @@ "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", - "moduleResolution": "Node", + "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "typeRoots": ["node_modules", "node_modules/@types", "./src/@types"], - "types": ["vite/client", "cypress"], + "types": ["vite/client", "cypress", "node"], "baseUrl": "./src", "paths": { "@/*": ["./*"]