Loading backend @ 853c3bef Compare 85e90b22 to 853c3bef Original line number Diff line number Diff line Subproject commit 85e90b22ee898d1af75775bc22ab4399c979b808 Subproject commit 853c3bef385f62f219ff43dc397a710785def111 codegen/package.json +1 −1 Original line number Diff line number Diff line { "name": "@inject/codegen", "version": "2.24.0", "version": "2.25.0", "description": "GraphQL API Codegen Setup for the Inject Backend", "main": "index.js", "license": "MIT", Loading frontend/package.json +1 −1 Original line number Diff line number Diff line { "name": "@inject/frontend", "version": "2.24.0", "version": "2.25.0", "description": "Main wrapper for rendering INJECT Frontend", "main": "index.js", "license": "MIT", Loading frontend/src/actionlog/InjectMessage/Content/TraineeQuestionnaireContent.tsx +42 −68 Original line number Diff line number Diff line Loading @@ -4,13 +4,10 @@ import type { QuestionAndAnswer } from '@/components/Questionnaire/types' import { INVALID_VALUE } from '@/components/Questionnaire/utilities' import type { ButtonProps } from '@blueprintjs/core' import { Button, Callout, NonIdealState } from '@blueprintjs/core' import type { Question, QuestionnaireDetails, } from '@inject/graphql/fragment-types' import type { TeamQuestionnaireStateWithQuestionnaire } from '@inject/graphql/fragment-types' import { useTypedMutation, useTypedQuery } from '@inject/graphql/graphql' import { AnswerQuestionnaire } from '@inject/graphql/mutations' import { GetTeam, GetTeamQuestionnaireState } from '@inject/graphql/queries' import { GetTeam } from '@inject/graphql/queries' import { useLoopStatus } from '@inject/graphql/utils/useExerciseLoopStatusSubscription' import Keys from '@inject/shared/localstorage/keys' import { useLocalStorageState } from 'ahooks' Loading @@ -18,18 +15,12 @@ import type { FC } from 'react' import { useCallback, useEffect, useMemo } from 'react' interface TraineeQuestionnaireContentProps { details: QuestionnaireDetails teamState: TeamQuestionnaireStateWithQuestionnaire teamId: string exerciseId: string onClose?: () => void } const getDefaultAnswers = (questions: Question[]): QuestionAndAnswer[] => questions.map(question => ({ question, answer: INVALID_VALUE, })) const MultipleUsersWarning = () => ( <Callout intent='warning'> <b>Answers can be submitted only once per team!</b> Loading @@ -40,28 +31,46 @@ const MultipleUsersWarning = () => ( // TODO: when questionnaire is answered, remove data from local storage and use questionnaireState instead const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ details, teamState, teamId, exerciseId, onClose, }) => { const { running } = useLoopStatus() const defaultAnswers: QuestionAndAnswer[] = useMemo( () => getDefaultAnswers(details.questions), [details.questions] ) const { answers, status, questionnaire } = teamState const { id, questions, content } = questionnaire const defaultValue: QuestionAndAnswer[] = useMemo( () => questions.map(question => ({ question, answer: answers.find(answer => answer.question.id === question.id)?.answer || INVALID_VALUE, })), [answers, questions] ) const [questionsAndAnswers, setQuestionsAndAnswers] = useLocalStorageState< QuestionAndAnswer[] >(Keys.getQuestionnaireAnswersKey(details.id, teamId), { defaultValue: defaultAnswers, >(Keys.getQuestionnaireAnswersKey(id, teamId), { defaultValue, listenStorageChange: true, }) // reset state when questions change useEffect(() => { setQuestionsAndAnswers(getDefaultAnswers(details.questions)) }, [details.questions, setQuestionsAndAnswers, teamId, exerciseId]) setQuestionsAndAnswers(prev => { if (!prev) { return defaultValue } return prev.map(questionAndAnswer => ({ question: questionAndAnswer.question, answer: answers.find( answer => answer.question.id === questionAndAnswer.question.id )?.answer || questionAndAnswer.answer, })) }) }, [answers, defaultValue, questions, setQuestionsAndAnswers]) const [{ fetching: loading }, mutate] = useTypedMutation(AnswerQuestionnaire) Loading @@ -83,41 +92,6 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ [] ), }) const [{ data }] = useTypedQuery({ query: GetTeamQuestionnaireState, variables: { teamId, questionnaireId: details.id, }, context: useMemo( () => ({ suspense: true, }), [] ), }) // reset state when answers change useEffect(() => { if (!data || !data.questionnaireState) { return } const teamAnswers = data.questionnaireState.answers setQuestionsAndAnswers(prev => (prev || defaultAnswers).map(questionAndAnswer => ({ ...questionAndAnswer, answer: teamAnswers.find( teamAnswer => teamAnswer.question.id === questionAndAnswer.question.id )?.answer || questionAndAnswer.answer, })) ) }, [data, defaultAnswers, details.questions, setQuestionsAndAnswers]) const status = useMemo( () => data?.questionnaireState.status, [data?.questionnaireState.status] ) const { title, disabled } = useMemo<ButtonProps>(() => { switch (status) { Loading @@ -139,7 +113,7 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ } } if ( (questionsAndAnswers || defaultAnswers).some( (questionsAndAnswers || defaultValue).some( questionAndAnswer => questionAndAnswer.answer === INVALID_VALUE ) ) { Loading @@ -155,29 +129,29 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ default: throw new Error(`Unknown status: ${status}`) } }, [defaultAnswers, questionsAndAnswers, status]) }, [defaultValue, questionsAndAnswers, status]) const handleClick = useCallback(() => { mutate({ questInput: { answers: (questionsAndAnswers || defaultAnswers).map( answers: (questionsAndAnswers || defaultValue).map( questionAndAnswer => ({ questionId: questionAndAnswer.question.id, value: questionAndAnswer.answer, }) ), questionnaireId: details.id, questionnaireId: id, teamId, }, }).then(() => { onClose?.() }) }, [defaultAnswers, details.id, mutate, onClose, questionsAndAnswers, teamId]) }, [defaultValue, id, mutate, onClose, questionsAndAnswers, teamId]) const handleChange = useCallback( (value: string, questionId: string) => { setQuestionsAndAnswers(prev => { const newValue = prev || defaultAnswers const newValue = prev || defaultValue const index = newValue.findIndex( questionAndAnswer => questionAndAnswer.question.id === questionId ) Loading @@ -191,7 +165,7 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ ] }) }, [defaultAnswers, setQuestionsAndAnswers] [defaultValue, setQuestionsAndAnswers] ) const done = useMemo<boolean>(() => { Loading @@ -207,7 +181,7 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ } }, [status]) if (!data?.questionnaireState || !teamData?.team) { if (!teamData?.team) { return ( <NonIdealState icon='low-voltage-pole' Loading @@ -225,8 +199,8 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ exerciseId={exerciseId} type='answering' callout={status === 'SENT' && numUsers > 1 && <MultipleUsersWarning />} questionsAndAnswers={questionsAndAnswers || defaultAnswers} content={details.content} questionsAndAnswers={questionsAndAnswers || defaultValue} content={content} disabled={() => status !== 'SENT' || !running} onChange={questionId => value => handleChange(value, questionId)} actions={ Loading frontend/src/actionlog/InjectMessage/Content/index.tsx +13 −6 Original line number Diff line number Diff line import InstructorQuestionnaireContent from '@/instructor/InstructorQuestionnaire/InstructorQuestionnaireContent' import InstructorQuestionnaire from '@/instructor/InstructorQuestionnaire' import type { ActionLog } from '@inject/graphql/fragment-types' import { type FC } from 'react' import EmailContent from './EmailContent' Loading Loading @@ -52,21 +52,28 @@ const Content: FC<ContentProps> = ({ inInstructor={inInstructor} /> ) case 'QuestionnaireType': case 'TeamQuestionnaireStateType': { const teamState = actionLog.details return inInstructor ? ( <InstructorQuestionnaireContent details={actionLog.details} teamId={teamId} <InstructorQuestionnaire exerciseId={exerciseId} questionnaireId={teamState.questionnaire.id} teamId={teamId} status={teamState.status} content={teamState.questionnaire.content} questions={teamState.questionnaire.questions} teamAnswers={teamState.answers} relatedMilestones={teamState.relatedMilestones} /> ) : ( <TraineeQuestionnaireContent details={actionLog.details} teamState={actionLog.details} teamId={teamId} exerciseId={exerciseId} onClose={onClose} /> ) } case 'EmailType': return ( <EmailContent Loading Loading
backend @ 853c3bef Compare 85e90b22 to 853c3bef Original line number Diff line number Diff line Subproject commit 85e90b22ee898d1af75775bc22ab4399c979b808 Subproject commit 853c3bef385f62f219ff43dc397a710785def111
codegen/package.json +1 −1 Original line number Diff line number Diff line { "name": "@inject/codegen", "version": "2.24.0", "version": "2.25.0", "description": "GraphQL API Codegen Setup for the Inject Backend", "main": "index.js", "license": "MIT", Loading
frontend/package.json +1 −1 Original line number Diff line number Diff line { "name": "@inject/frontend", "version": "2.24.0", "version": "2.25.0", "description": "Main wrapper for rendering INJECT Frontend", "main": "index.js", "license": "MIT", Loading
frontend/src/actionlog/InjectMessage/Content/TraineeQuestionnaireContent.tsx +42 −68 Original line number Diff line number Diff line Loading @@ -4,13 +4,10 @@ import type { QuestionAndAnswer } from '@/components/Questionnaire/types' import { INVALID_VALUE } from '@/components/Questionnaire/utilities' import type { ButtonProps } from '@blueprintjs/core' import { Button, Callout, NonIdealState } from '@blueprintjs/core' import type { Question, QuestionnaireDetails, } from '@inject/graphql/fragment-types' import type { TeamQuestionnaireStateWithQuestionnaire } from '@inject/graphql/fragment-types' import { useTypedMutation, useTypedQuery } from '@inject/graphql/graphql' import { AnswerQuestionnaire } from '@inject/graphql/mutations' import { GetTeam, GetTeamQuestionnaireState } from '@inject/graphql/queries' import { GetTeam } from '@inject/graphql/queries' import { useLoopStatus } from '@inject/graphql/utils/useExerciseLoopStatusSubscription' import Keys from '@inject/shared/localstorage/keys' import { useLocalStorageState } from 'ahooks' Loading @@ -18,18 +15,12 @@ import type { FC } from 'react' import { useCallback, useEffect, useMemo } from 'react' interface TraineeQuestionnaireContentProps { details: QuestionnaireDetails teamState: TeamQuestionnaireStateWithQuestionnaire teamId: string exerciseId: string onClose?: () => void } const getDefaultAnswers = (questions: Question[]): QuestionAndAnswer[] => questions.map(question => ({ question, answer: INVALID_VALUE, })) const MultipleUsersWarning = () => ( <Callout intent='warning'> <b>Answers can be submitted only once per team!</b> Loading @@ -40,28 +31,46 @@ const MultipleUsersWarning = () => ( // TODO: when questionnaire is answered, remove data from local storage and use questionnaireState instead const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ details, teamState, teamId, exerciseId, onClose, }) => { const { running } = useLoopStatus() const defaultAnswers: QuestionAndAnswer[] = useMemo( () => getDefaultAnswers(details.questions), [details.questions] ) const { answers, status, questionnaire } = teamState const { id, questions, content } = questionnaire const defaultValue: QuestionAndAnswer[] = useMemo( () => questions.map(question => ({ question, answer: answers.find(answer => answer.question.id === question.id)?.answer || INVALID_VALUE, })), [answers, questions] ) const [questionsAndAnswers, setQuestionsAndAnswers] = useLocalStorageState< QuestionAndAnswer[] >(Keys.getQuestionnaireAnswersKey(details.id, teamId), { defaultValue: defaultAnswers, >(Keys.getQuestionnaireAnswersKey(id, teamId), { defaultValue, listenStorageChange: true, }) // reset state when questions change useEffect(() => { setQuestionsAndAnswers(getDefaultAnswers(details.questions)) }, [details.questions, setQuestionsAndAnswers, teamId, exerciseId]) setQuestionsAndAnswers(prev => { if (!prev) { return defaultValue } return prev.map(questionAndAnswer => ({ question: questionAndAnswer.question, answer: answers.find( answer => answer.question.id === questionAndAnswer.question.id )?.answer || questionAndAnswer.answer, })) }) }, [answers, defaultValue, questions, setQuestionsAndAnswers]) const [{ fetching: loading }, mutate] = useTypedMutation(AnswerQuestionnaire) Loading @@ -83,41 +92,6 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ [] ), }) const [{ data }] = useTypedQuery({ query: GetTeamQuestionnaireState, variables: { teamId, questionnaireId: details.id, }, context: useMemo( () => ({ suspense: true, }), [] ), }) // reset state when answers change useEffect(() => { if (!data || !data.questionnaireState) { return } const teamAnswers = data.questionnaireState.answers setQuestionsAndAnswers(prev => (prev || defaultAnswers).map(questionAndAnswer => ({ ...questionAndAnswer, answer: teamAnswers.find( teamAnswer => teamAnswer.question.id === questionAndAnswer.question.id )?.answer || questionAndAnswer.answer, })) ) }, [data, defaultAnswers, details.questions, setQuestionsAndAnswers]) const status = useMemo( () => data?.questionnaireState.status, [data?.questionnaireState.status] ) const { title, disabled } = useMemo<ButtonProps>(() => { switch (status) { Loading @@ -139,7 +113,7 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ } } if ( (questionsAndAnswers || defaultAnswers).some( (questionsAndAnswers || defaultValue).some( questionAndAnswer => questionAndAnswer.answer === INVALID_VALUE ) ) { Loading @@ -155,29 +129,29 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ default: throw new Error(`Unknown status: ${status}`) } }, [defaultAnswers, questionsAndAnswers, status]) }, [defaultValue, questionsAndAnswers, status]) const handleClick = useCallback(() => { mutate({ questInput: { answers: (questionsAndAnswers || defaultAnswers).map( answers: (questionsAndAnswers || defaultValue).map( questionAndAnswer => ({ questionId: questionAndAnswer.question.id, value: questionAndAnswer.answer, }) ), questionnaireId: details.id, questionnaireId: id, teamId, }, }).then(() => { onClose?.() }) }, [defaultAnswers, details.id, mutate, onClose, questionsAndAnswers, teamId]) }, [defaultValue, id, mutate, onClose, questionsAndAnswers, teamId]) const handleChange = useCallback( (value: string, questionId: string) => { setQuestionsAndAnswers(prev => { const newValue = prev || defaultAnswers const newValue = prev || defaultValue const index = newValue.findIndex( questionAndAnswer => questionAndAnswer.question.id === questionId ) Loading @@ -191,7 +165,7 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ ] }) }, [defaultAnswers, setQuestionsAndAnswers] [defaultValue, setQuestionsAndAnswers] ) const done = useMemo<boolean>(() => { Loading @@ -207,7 +181,7 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ } }, [status]) if (!data?.questionnaireState || !teamData?.team) { if (!teamData?.team) { return ( <NonIdealState icon='low-voltage-pole' Loading @@ -225,8 +199,8 @@ const TraineeQuestionnaireContent: FC<TraineeQuestionnaireContentProps> = ({ exerciseId={exerciseId} type='answering' callout={status === 'SENT' && numUsers > 1 && <MultipleUsersWarning />} questionsAndAnswers={questionsAndAnswers || defaultAnswers} content={details.content} questionsAndAnswers={questionsAndAnswers || defaultValue} content={content} disabled={() => status !== 'SENT' || !running} onChange={questionId => value => handleChange(value, questionId)} actions={ Loading
frontend/src/actionlog/InjectMessage/Content/index.tsx +13 −6 Original line number Diff line number Diff line import InstructorQuestionnaireContent from '@/instructor/InstructorQuestionnaire/InstructorQuestionnaireContent' import InstructorQuestionnaire from '@/instructor/InstructorQuestionnaire' import type { ActionLog } from '@inject/graphql/fragment-types' import { type FC } from 'react' import EmailContent from './EmailContent' Loading Loading @@ -52,21 +52,28 @@ const Content: FC<ContentProps> = ({ inInstructor={inInstructor} /> ) case 'QuestionnaireType': case 'TeamQuestionnaireStateType': { const teamState = actionLog.details return inInstructor ? ( <InstructorQuestionnaireContent details={actionLog.details} teamId={teamId} <InstructorQuestionnaire exerciseId={exerciseId} questionnaireId={teamState.questionnaire.id} teamId={teamId} status={teamState.status} content={teamState.questionnaire.content} questions={teamState.questionnaire.questions} teamAnswers={teamState.answers} relatedMilestones={teamState.relatedMilestones} /> ) : ( <TraineeQuestionnaireContent details={actionLog.details} teamState={actionLog.details} teamId={teamId} exerciseId={exerciseId} onClose={onClose} /> ) } case 'EmailType': return ( <EmailContent Loading