From 7986acde4264ae15563ae7086a73d73ac3749734 Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Fri, 9 Feb 2024 16:34:18 +0400 Subject: [PATCH 01/13] feat: M2-5117 added data-testIds --- src/entities/activity/ui/ActivityCardItem.tsx | 6 ++++-- src/shared/ui/CardItem/CardItem.tsx | 5 +++-- src/shared/ui/MuiModal/index.tsx | 3 +++ src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx | 1 + .../ActivityGroups/ui/ActivityCard/TimeStatusLabel.tsx | 6 +++--- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/entities/activity/ui/ActivityCardItem.tsx b/src/entities/activity/ui/ActivityCardItem.tsx index b340ced8c..aa7137a77 100644 --- a/src/entities/activity/ui/ActivityCardItem.tsx +++ b/src/entities/activity/ui/ActivityCardItem.tsx @@ -47,14 +47,16 @@ export const ActivityCardItem = ({ + isOptional={!isOptionalFlagHidden && (item.config.skippableItem || allowToSkipAllItems)} + testId="active-item"> {hasAdditionalResponse(item) && ( + isOptional={!requiresAdditionalResponse(item)} + testId="additional-text"> )} diff --git a/src/shared/ui/CardItem/CardItem.tsx b/src/shared/ui/CardItem/CardItem.tsx index d505b1e47..59eca3c7b 100644 --- a/src/shared/ui/CardItem/CardItem.tsx +++ b/src/shared/ui/CardItem/CardItem.tsx @@ -15,16 +15,17 @@ interface CardItemProps extends PropsWithChildren { isInvalid?: boolean isOptional?: boolean markdown: string + testId?: string } -export const CardItem = ({ children, markdown, isOptional }: CardItemProps) => { +export const CardItem = ({ children, markdown, isOptional, testId }: CardItemProps) => { const { greaterThanSM } = useCustomMediaQuery() const { t } = useCustomTranslation() return ( void isSecondaryButtonLoading?: boolean + testId?: string } export const MuiModal = (props: Props) => { @@ -38,10 +39,12 @@ export const MuiModal = (props: Props) => { footerSecondaryButton, onSecondaryButtonClick, + testId, } = props return ( { isPrimaryButtonLoading={isLoading} footerSecondaryButton={canGoBack ? t("goBack") : undefined} onSecondaryButtonClick={canGoBack ? () => setIsModalOpen(false) : undefined} + testId="submit-response-modal" /> ) diff --git a/src/widgets/ActivityGroups/ui/ActivityCard/TimeStatusLabel.tsx b/src/widgets/ActivityGroups/ui/ActivityCard/TimeStatusLabel.tsx index 25cceae46..fe7fcd7f9 100644 --- a/src/widgets/ActivityGroups/ui/ActivityCard/TimeStatusLabel.tsx +++ b/src/widgets/ActivityGroups/ui/ActivityCard/TimeStatusLabel.tsx @@ -56,7 +56,7 @@ const TimeStatusLabel = ({ activity }: TimeStatusLabelProps) => { if (hasAvailableFromTo) { return ( - + {`${t("activity_due_date.available")} ${formatDate(activity.availableFrom!)} ${t( @@ -69,7 +69,7 @@ const TimeStatusLabel = ({ activity }: TimeStatusLabelProps) => { if (hasTimeToComplete) { return ( - + {`${t( @@ -81,7 +81,7 @@ const TimeStatusLabel = ({ activity }: TimeStatusLabelProps) => { } return ( - + {`${t("activity_due_date.to")} ${formatDate( activity.availableTo!, From 58d424a5af150d22bed0ee5e76bb4441aa3957f7 Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Fri, 9 Feb 2024 17:28:47 +0400 Subject: [PATCH 02/13] M2-5007 Restart resume feature: Added restart/resume buttons to ActivityCard, ui updates, added translations --- src/assets/activity-restart-icon.svg | 3 + .../applet/model/hooks/useStartEntity.ts | 24 +++- src/i18n/en/translation.json | 6 +- src/i18n/fr/translation.json | 6 +- src/shared/constants/theme.ts | 1 + src/shared/ui/BaseButton/index.tsx | 3 +- src/shared/ui/MuiModal/index.tsx | 38 +++++- src/widgets/ActivityGroups/lib/types.ts | 1 + .../model/hooks/useStartEntity.ts | 37 +++--- .../ui/ActivityCard/ActivityCardBase.tsx | 11 +- .../ActivityCardRestartResume.tsx | 123 ++++++++++++++++++ .../ActivityGroups/ui/ActivityCard/index.tsx | 58 +++++---- 12 files changed, 252 insertions(+), 59 deletions(-) create mode 100644 src/assets/activity-restart-icon.svg create mode 100644 src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardRestartResume.tsx diff --git a/src/assets/activity-restart-icon.svg b/src/assets/activity-restart-icon.svg new file mode 100644 index 000000000..b7958f378 --- /dev/null +++ b/src/assets/activity-restart-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/entities/applet/model/hooks/useStartEntity.ts b/src/entities/applet/model/hooks/useStartEntity.ts index 2d5788393..5ff2b2dc1 100644 --- a/src/entities/applet/model/hooks/useStartEntity.ts +++ b/src/entities/applet/model/hooks/useStartEntity.ts @@ -23,6 +23,15 @@ export const useStartEntity = () => { ) } + function removeActivityProgress(activityId: string, eventId: string): void { + dispatch( + actions.removeActivityProgress({ + activityId, + eventId, + }), + ) + } + function flowStarted(flowId: string, activityId: string, eventId: string, pipelineActivityOrder: number): void { dispatch( actions.flowStarted({ @@ -44,20 +53,31 @@ export const useStartEntity = () => { return activityStarted(activityId, eventId) } - function startFlow(flowId: string, eventId: string, flows: ActivityFlowDTO[]): void { + function startFlow(flowId: string, eventId: string, flows: ActivityFlowDTO[], shouldRestart: boolean): void { const isFlowInProgress = isInProgress(getProgress(flowId, eventId)) - if (isFlowInProgress) { + if (isFlowInProgress && !shouldRestart) { return } const flow = flows.find(x => x.id === flowId)! + const flowActivities: string[] = flow.activityIds const firstActivityId: string = flowActivities[0] + if (shouldRestart) { + removeFlowActivitiesProgress(flowActivities, eventId) + } + return flowStarted(flowId, firstActivityId, eventId, 0) } + function removeFlowActivitiesProgress(actividyIds: string[], eventId: string): void { + for (const activityId of actividyIds) { + removeActivityProgress(activityId, eventId) + } + } + return { startActivity, startFlow } } diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 8b1a47e6c..753d1fe3e 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -240,7 +240,9 @@ "saved_answers": "We've saved your answers!", "close": "Close", "resume_activity": "Resume activity", - "activity_resume_restart":"Would you like to resume this activity in progress or restart?", + "restart_activity": "Restart activity", + "cancel": "Cancel", + "activity_resume_restart": "Are you sure you want clear all progress and restart", "restart": "Restart", "resume": "Resume", "in_progress": "In Progress", @@ -266,4 +268,4 @@ "no_markdown": "The authors of this applet have not provided any information!", "no_applets": "You currently do not have any applets." } -} \ No newline at end of file +} diff --git a/src/i18n/fr/translation.json b/src/i18n/fr/translation.json index 8dbc72b1c..5d6d69646 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -17,7 +17,7 @@ "pleaseAnswerTheQuestion": "Veuillez répondre à la question.", "pleaseProvideAdditionalText": "Veuillez fournir un texte supplémentaire", "onlyNumbersAllowed": "Seuls les numéros sont autorisés", - + "question_count_plural": "{{length}} questions", "question_count_singular": "{{length}} question", @@ -247,8 +247,10 @@ "thanks": "Merci!", "saved_answers": "Réponses sauvegardées !", "close": "Fermer", + "restart_activity": "Redémarrer l'activité", + "cancel": "Annuler", + "activity_resume_restart": "Êtes-vous sûr de vouloir effacer toute la progression et redémarrer", "resume_activity": "Reprendre l'activité", - "activity_resume_restart":"Souhaitez-vous reprendre cette activité en cours ou la recommencer?", "restart": "Recommencer", "resume": "Reprendre", "in_progress": "En cours", diff --git a/src/shared/constants/theme.ts b/src/shared/constants/theme.ts index 05c986486..38ca7becb 100644 --- a/src/shared/constants/theme.ts +++ b/src/shared/constants/theme.ts @@ -16,6 +16,7 @@ export const Theme = { surface: "#FCFCFF", surfaceVariant: "#DEE3EB", surface1: "#EFF4FA", + surface2: "#E0EBF4", accentGreen: "#0F7B6C", accentGreen30: "rgba(15, 123, 108, 0.30)", accentYellow: "#DFAC03", diff --git a/src/shared/ui/BaseButton/index.tsx b/src/shared/ui/BaseButton/index.tsx index 9684ecee6..1374acc6b 100644 --- a/src/shared/ui/BaseButton/index.tsx +++ b/src/shared/ui/BaseButton/index.tsx @@ -10,6 +10,7 @@ import { Theme } from "../../constants" type Props = PropsWithChildren<{ type: "button" | "submit" isLoading?: boolean + disabled?: boolean variant: "text" | "contained" | "outlined" borderColor?: string @@ -50,7 +51,7 @@ export const BaseButton = forwardRef((props, ref) => { ref={ref} type={props.type} variant={props.variant} - disabled={props.isLoading} + disabled={props.isLoading || props.disabled} onClick={props.onClick} color={props.color ?? undefined} sx={{ diff --git a/src/shared/ui/MuiModal/index.tsx b/src/shared/ui/MuiModal/index.tsx index 7002be290..f2fbb240a 100644 --- a/src/shared/ui/MuiModal/index.tsx +++ b/src/shared/ui/MuiModal/index.tsx @@ -1,9 +1,12 @@ +import CloseIcon from "@mui/icons-material/Close" +import { Breakpoint } from "@mui/material" import Box from "@mui/material/Box" import Dialog from "@mui/material/Dialog" import DialogActions from "@mui/material/DialogActions" import DialogContent from "@mui/material/DialogContent" import DialogTitle from "@mui/material/DialogTitle" -import Typography from "@mui/material/Typography" +import { SxProps } from "@mui/material/styles" +import Typography, { TypographyProps } from "@mui/material/Typography" import { BaseButton } from "../BaseButton" @@ -23,6 +26,11 @@ type Props = { onSecondaryButtonClick?: () => void isSecondaryButtonLoading?: boolean testId?: string + showCloseIcon?: boolean + titleProps?: TypographyProps + labelComponent?: JSX.Element + footerWrapperSXProps?: SxProps + maxWidth?: Breakpoint } export const MuiModal = (props: Props) => { @@ -40,6 +48,11 @@ export const MuiModal = (props: Props) => { footerSecondaryButton, onSecondaryButtonClick, testId, + showCloseIcon, + titleProps, + labelComponent, + footerWrapperSXProps, + maxWidth = "xs", } = props return ( @@ -47,13 +60,14 @@ export const MuiModal = (props: Props) => { data-testid={testId} open={isOpen} onClose={onHide} - maxWidth="xs" + maxWidth={maxWidth} fullWidth aria-labelledby="customized-dialog-title" sx={{ "& .MuiPaper-root": { borderRadius: "16px", padding: "24px", + backgroundColor: Theme.colors.light.surface2, }, "& .MuiDialogTitle-root": { padding: "0", @@ -66,6 +80,17 @@ export const MuiModal = (props: Props) => { paddingTop: "24px", }, }}> + {showCloseIcon && ( + + )} + {title && ( { letterSpacing="0.1px" textTransform="none" paddingBottom="8px" - color={Theme.colors.light.onSurface}> + color={Theme.colors.light.onSurface} + {...titleProps}> {title} @@ -97,8 +123,10 @@ export const MuiModal = (props: Props) => { )} + + {labelComponent && {labelComponent}} {(footerPrimaryButton || footerSecondaryButton) && ( - + {footerSecondaryButton && ( { )} {footerPrimaryButton && ( - + { function startActivityOrFlow(params: OnActivityCardClickProps) { Mixpanel.track("Assessment Started") - const { flowId, eventId, activity, status } = params - const activityId = activity.id + const { flowId, eventId, activity, status, shouldRestart } = params - const isFlow = Boolean(flowId) + const activityId = activity.id const isActivityInProgress = status === ActivityStatus.InProgress - if (!isActivityInProgress && !isFlow && params.activity) { - setInitialProgress({ activity, eventId: params.eventId }) - } - if (flowId) { - startFlow(flowId, eventId, flows) + const flow = flows.find(x => x.id === flowId)! + const firstActivityId: string = flow.activityIds[0] + const activityIdToNavigate = shouldRestart ? firstActivityId : activityId + + startFlow(flowId, eventId, flows, shouldRestart) return navigateToEntity({ - activityId, + activityId: activityIdToNavigate, entityType: "flow", eventId, flowId, }) - } else { - startActivity(activityId, eventId) + } - return navigateToEntity({ - activityId, - entityType: "regular", - eventId, - flowId: null, - }) + if ((!isActivityInProgress || shouldRestart) && params.activity) { + setInitialProgress({ activity, eventId: params.eventId }) } + + startActivity(activityId, eventId) + + return navigateToEntity({ + activityId, + entityType: "regular", + eventId, + flowId: null, + }) } return { diff --git a/src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardBase.tsx b/src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardBase.tsx index 5a8693ff7..e8306a1a2 100644 --- a/src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardBase.tsx +++ b/src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardBase.tsx @@ -1,22 +1,19 @@ import { PropsWithChildren } from "react" -import Button from "@mui/material/Button" +import Box from "@mui/material/Box" import { Theme } from "~/shared/constants" type Props = PropsWithChildren<{ isFlow: boolean isLoading?: boolean - isDisabled?: boolean - onClick?: () => void + isDisabled: boolean }> export const ActivityCardBase = (props: Props) => { return ( - + ) } diff --git a/src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardRestartResume.tsx b/src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardRestartResume.tsx new file mode 100644 index 000000000..e278c2fc9 --- /dev/null +++ b/src/widgets/ActivityGroups/ui/ActivityCard/ActivityCardRestartResume.tsx @@ -0,0 +1,123 @@ +import { useState } from "react" + +import Box from "@mui/material/Box" +import ButtonBase from "@mui/material/ButtonBase" +import Typography from "@mui/material/Typography" + +import ActivityRestartIcon from "~/assets/activity-restart-icon.svg" +import { Theme } from "~/shared/constants" +import { BaseButton, MuiModal, Text } from "~/shared/ui" +import { useCustomMediaQuery, useCustomTranslation } from "~/shared/utils" +import { ActivityStatus } from "~/widgets/ActivityGroups/lib" + +type Props = { + activityStatus: ActivityStatus + activityName: string + onResumeClick: () => void + onRestartClick: () => void + isDisabled: boolean +} + +export const ActivityCardRestartResume = ({ + activityStatus, + onRestartClick, + onResumeClick, + activityName, + isDisabled, +}: Props) => { + const [isRestartConfirmationModalOpen, setIsRestartConfirmationModalOpen] = useState(false) + const { lessThanSM } = useCustomMediaQuery() + const { t } = useCustomTranslation() + const closeModal = () => setIsRestartConfirmationModalOpen(false) + const openModal = () => setIsRestartConfirmationModalOpen(true) + + return ( + <> + {activityStatus === ActivityStatus.InProgress ? ( + + + + + Activity Restart Icon + + + {t("additional.restart")} + + + + + ) : ( + + + + )} + + + {t("additional.activity_resume_restart")} {activityName}? + + } + footerWrapperSXProps={{ + marginLeft: "auto", + marginTop: 2, + }} + maxWidth="sm" + /> + + ) +} diff --git a/src/widgets/ActivityGroups/ui/ActivityCard/index.tsx b/src/widgets/ActivityGroups/ui/ActivityCard/index.tsx index fd8bbe28f..f4cd0e978 100644 --- a/src/widgets/ActivityGroups/ui/ActivityCard/index.tsx +++ b/src/widgets/ActivityGroups/ui/ActivityCard/index.tsx @@ -6,6 +6,7 @@ import { ActivityCardBase } from "./ActivityCardBase" import { ActivityCardDescription } from "./ActivityCardDescription" import { ActivityCardIcon } from "./ActivityCardIcon" import { ActivityCardProgressBar } from "./ActivityCardProgressBar" +import { ActivityCardRestartResume } from "./ActivityCardRestartResume" import { ActivityCardTitle } from "./ActivityCardTitle" import { ActivityLabel } from "./ActivityLabel" import TimeStatusLabel from "./TimeStatusLabel" @@ -54,25 +55,7 @@ export const ActivityCard = ({ activityListItem }: Props) => { publicAppletKey: context.isPublic ? context.publicAppletKey : null, }) - const { mutate: getActivityById, isLoading } = useActivityByIdMutation( - { isPublic: context.isPublic }, - { - onSuccess(data) { - const activity = data.data.result - - if (!activity) { - throw new Error("[useActivityByIdMutation]: Activity not found") - } - - return startActivityOrFlow({ - activity, - eventId: activityListItem.eventId, - status: activityListItem.status, - flowId: activityListItem.flowId, - }) - }, - }, - ) + const { mutate: getActivityById, isLoading } = useActivityByIdMutation({ isPublic: context.isPublic }) const getCompletedActivitiesFromPosition = (position: number) => position - 1 @@ -84,26 +67,48 @@ export const ActivityCard = ({ activityListItem }: Props) => { const flowProgress = (countOfCompletedActivities / numberOfActivitiesInFlow) * 100 - function onActivityCardClickHandler() { + function onStartActivity(shouldRestart: boolean) { if (isDisabled || !activityListItem) return if (!isEntitySupported) { return openStoreLink() } - return getActivityById({ activityId: activityListItem.activityId }) + return getActivityById( + { activityId: activityListItem.activityId }, + { + onSuccess(data) { + const activity = data.data.result + + if (!activity) { + throw new Error("[useActivityByIdMutation]: Activity not found") + } + + return startActivityOrFlow({ + activity, + eventId: activityListItem.eventId, + status: activityListItem.status, + flowId: activityListItem.flowId, + shouldRestart, + }) + }, + }, + ) } + const restartActivity = () => onStartActivity(true) + const resumeActivity = () => onStartActivity(false) + if (isLoading) { return ( - + ) } return ( - + { {isEntitySupported && } + ) From bdd5ac8f7692d290bd8d627708d1bca6232af9e8 Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Fri, 9 Feb 2024 17:38:57 +0400 Subject: [PATCH 03/13] M2-5007 Restart resume feature: Added data-testIds --- src/shared/ui/MuiModal/index.tsx | 1 + .../ui/ActivityCard/ActivityCardRestartResume.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/MuiModal/index.tsx b/src/shared/ui/MuiModal/index.tsx index f2fbb240a..4eac8dca7 100644 --- a/src/shared/ui/MuiModal/index.tsx +++ b/src/shared/ui/MuiModal/index.tsx @@ -83,6 +83,7 @@ export const MuiModal = (props: Props) => { {showCloseIcon && ( ) : ( - + )} Date: Wed, 14 Feb 2024 14:38:10 +0400 Subject: [PATCH 04/13] bug: M2-5043 updated conditional logic , ignoring all the skipped and hidden items when processing conditionals --- .../applet/model/ConditionalLogicBuilder.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/entities/applet/model/ConditionalLogicBuilder.ts b/src/entities/applet/model/ConditionalLogicBuilder.ts index 4a494b1d5..5e08972b6 100644 --- a/src/entities/applet/model/ConditionalLogicBuilder.ts +++ b/src/entities/applet/model/ConditionalLogicBuilder.ts @@ -6,8 +6,24 @@ import { Condition } from "~/shared/api" export type ItemMapByName = Record class ConditionalLogicBuilder { + private hiddenOrSkippedItemNames: Set = new Set() + public process(items: ItemRecord[]): ItemRecord[] { - return items.filter((item, index, array) => this.conditionalLogicFilter(item, index, array)) + return items.filter((item, index, array) => { + const isItemVisible = this.conditionalLogicFilter(item, index, array) + + this.handleItemVisibility(item.name, isItemVisible) + + return isItemVisible + }) + } + + private handleItemVisibility(itemName: string, isVisible: boolean) { + if (isVisible) { + this.hiddenOrSkippedItemNames.delete(itemName) + } else { + this.hiddenOrSkippedItemNames.add(itemName) + } } private conditionalLogicFilter(item: ItemRecord, index: number, array: ItemRecord[]): boolean { @@ -41,7 +57,7 @@ class ConditionalLogicBuilder { const checkResult = rules.every(rule => { const answer = itemsMap[rule.itemName].answer - if (!answer.length) { + if (!answer.length || this.hiddenOrSkippedItemNames.has(rule.itemName)) { return false } @@ -55,7 +71,7 @@ class ConditionalLogicBuilder { const checkResult = rules.some(rule => { const answer = itemsMap[rule.itemName].answer - if (!answer.length) { + if (!answer.length || this.hiddenOrSkippedItemNames.has(rule.itemName)) { return false } From 890d5501bf9aa01f67eb226cfdc3b40c55911a9f Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Thu, 15 Feb 2024 21:49:46 +0400 Subject: [PATCH 05/13] bug: M2-5260 Fixed default answers for hidden activities, removing conditionally hidden item answers dynamically --- src/entities/activity/lib/types/item.ts | 1 + .../applet/model/hooks/useActivityProgress.ts | 8 +++----- .../model/hooks/useSaveActivityItemAnswer.ts | 8 ++++++++ src/entities/applet/model/mapper.ts | 1 + .../ActivityDetails/model/hooks/useSurvey.ts | 16 ++++++++++++---- .../ui/AssessmentPassingScreen.tsx | 17 ++++++++++++++--- 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/entities/activity/lib/types/item.ts b/src/entities/activity/lib/types/item.ts index ccac82496..59f6f0207 100644 --- a/src/entities/activity/lib/types/item.ts +++ b/src/entities/activity/lib/types/item.ts @@ -50,6 +50,7 @@ export interface ActivityItemBase { answer: Answer additionalText?: string | null conditionalLogic: ConditionalLogic | null + isHidden: boolean } export type Config = diff --git a/src/entities/applet/model/hooks/useActivityProgress.ts b/src/entities/applet/model/hooks/useActivityProgress.ts index 03f083fce..3730792c4 100644 --- a/src/entities/applet/model/hooks/useActivityProgress.ts +++ b/src/entities/applet/model/hooks/useActivityProgress.ts @@ -31,11 +31,9 @@ export const useActivityProgress = () => { splashScreenItem = mapSplashScreenToRecord(props.activity.splashScreen) } - const preparedActivityItemProgressRecords = props.activity.items - .filter(x => !x.isHidden) - .map(item => { - return mapItemToRecord(item) - }) + const preparedActivityItemProgressRecords = props.activity.items.map(item => { + return mapItemToRecord(item) + }) const items = splashScreenItem ? [splashScreenItem, ...preparedActivityItemProgressRecords] diff --git a/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts b/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts index ee60ba425..7c51267c4 100644 --- a/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts +++ b/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts @@ -40,8 +40,16 @@ export const useSaveItemAnswer = ({ activityId, eventId }: Props) => { [dispatch, activityId, eventId], ) + const removeItemAnswer = useCallback( + (itemId: string) => { + saveItemAnswer(itemId, []) + }, + [saveItemAdditionalText, saveItemAnswer], + ) + return { saveItemAnswer, saveItemAdditionalText, + removeItemAnswer, } } diff --git a/src/entities/applet/model/mapper.ts b/src/entities/applet/model/mapper.ts index ff4642204..ccd516fa2 100644 --- a/src/entities/applet/model/mapper.ts +++ b/src/entities/applet/model/mapper.ts @@ -59,5 +59,6 @@ export function mapSplashScreenToRecord(splashScreen: string): ItemRecord { responseValues: null, answer: [], conditionalLogic: null, + isHidden: false, } } diff --git a/src/widgets/ActivityDetails/model/hooks/useSurvey.ts b/src/widgets/ActivityDetails/model/hooks/useSurvey.ts index 31822154c..650dfac11 100644 --- a/src/widgets/ActivityDetails/model/hooks/useSurvey.ts +++ b/src/widgets/ActivityDetails/model/hooks/useSurvey.ts @@ -5,7 +5,14 @@ import { appletModel } from "~/entities/applet" export const useSurvey = (activityProgress: appletModel.ActivityProgress) => { const items = useMemo(() => activityProgress?.items ?? [], [activityProgress.items]) - const processedItems = appletModel.conditionalLogicBuilder.process(items) + const visibleItems = items.filter(x => !x.isHidden) + + const processedItems = appletModel.conditionalLogicBuilder.process(visibleItems) + + const visibleItemIds = visibleItems.map(x => x.id) + const processedItemIds = processedItems.map(x => x.id) + + const conditionallyHiddenItemIds = visibleItemIds.filter(id => !processedItemIds.includes(id)) const step = activityProgress?.step ?? 0 @@ -18,15 +25,16 @@ export const useSurvey = (activityProgress: appletModel.ActivityProgress) => { const progress = useMemo(() => { const defaultProgressPercentage = 0 - if (!items) { + if (!visibleItems) { return defaultProgressPercentage } - return ((step + 1) / items.length) * 100 - }, [items, step]) + return ((step + 1) / visibleItems.length) * 100 + }, [visibleItems, step]) return { item, + conditionallyHiddenItemIds, hasNextStep, hasPrevStep, diff --git a/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx b/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx index 9fa20dc9f..a2a74646a 100644 --- a/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx +++ b/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx @@ -58,12 +58,12 @@ export const AssessmentPassingScreen = (props: Props) => { eventId, }) - const { saveItemAnswer, saveItemAdditionalText } = appletModel.hooks.useSaveItemAnswer({ + const { saveItemAnswer, saveItemAdditionalText, removeItemAnswer } = appletModel.hooks.useSaveItemAnswer({ activityId, eventId, }) - const { step, item, hasPrevStep, hasNextStep, progress } = useSurvey(activityProgress) + const { step, item, hasPrevStep, hasNextStep, progress, conditionallyHiddenItemIds } = useSurvey(activityProgress) const canGoBack = !item?.config.removeBackButton && props.activityDetails.responseIsEditable @@ -149,12 +149,23 @@ export const AssessmentPassingScreen = (props: Props) => { return } + conditionallyHiddenItemIds?.forEach(id => removeItemAnswer(id)) + if (!hasNextStep) { return setIsModalOpen(true) } return onNext() - }, [hasNextStep, item, onNext, props.activityDetails, showWarningNotification, t]) + }, [ + conditionallyHiddenItemIds, + removeItemAnswer, + hasNextStep, + item, + onNext, + props.activityDetails, + showWarningNotification, + t, + ]) const onItemValueChange = (value: string[]) => { saveItemAnswer(item.id, value) From 76a1e2ad53917b15e434f9de64ae9010a269737b Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Thu, 15 Feb 2024 22:25:09 +0400 Subject: [PATCH 06/13] bug: M2-5260 Code improvements --- src/widgets/ActivityDetails/model/hooks/useSurvey.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/widgets/ActivityDetails/model/hooks/useSurvey.ts b/src/widgets/ActivityDetails/model/hooks/useSurvey.ts index 650dfac11..40e4ade30 100644 --- a/src/widgets/ActivityDetails/model/hooks/useSurvey.ts +++ b/src/widgets/ActivityDetails/model/hooks/useSurvey.ts @@ -9,10 +9,7 @@ export const useSurvey = (activityProgress: appletModel.ActivityProgress) => { const processedItems = appletModel.conditionalLogicBuilder.process(visibleItems) - const visibleItemIds = visibleItems.map(x => x.id) - const processedItemIds = processedItems.map(x => x.id) - - const conditionallyHiddenItemIds = visibleItemIds.filter(id => !processedItemIds.includes(id)) + const conditionallyHiddenItemIds = appletModel.conditionalLogicBuilder.getConditionallyHiddenItemIds() const step = activityProgress?.step ?? 0 From 79d750836dc0095025302e2865b5ce1b634e3bcc Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Thu, 15 Feb 2024 22:27:12 +0400 Subject: [PATCH 07/13] bug: M2-5043 Storing ids of conditionally hidden items to be used for removing answers of those hidden items --- .../applet/model/ConditionalLogicBuilder.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/entities/applet/model/ConditionalLogicBuilder.ts b/src/entities/applet/model/ConditionalLogicBuilder.ts index 5e08972b6..d7fabf1e0 100644 --- a/src/entities/applet/model/ConditionalLogicBuilder.ts +++ b/src/entities/applet/model/ConditionalLogicBuilder.ts @@ -7,22 +7,29 @@ export type ItemMapByName = Record class ConditionalLogicBuilder { private hiddenOrSkippedItemNames: Set = new Set() + private hiddenOrSkippedItemIds: Set = new Set() public process(items: ItemRecord[]): ItemRecord[] { return items.filter((item, index, array) => { const isItemVisible = this.conditionalLogicFilter(item, index, array) - this.handleItemVisibility(item.name, isItemVisible) + this.handleItemVisibility(item, isItemVisible) return isItemVisible }) } - private handleItemVisibility(itemName: string, isVisible: boolean) { + public getConditionallyHiddenItemIds() { + return this.hiddenOrSkippedItemIds + } + + private handleItemVisibility(item: ItemRecord, isVisible: boolean) { if (isVisible) { - this.hiddenOrSkippedItemNames.delete(itemName) + this.hiddenOrSkippedItemNames.delete(item.name) + this.hiddenOrSkippedItemIds.delete(item.id) } else { - this.hiddenOrSkippedItemNames.add(itemName) + this.hiddenOrSkippedItemNames.add(item.name) + this.hiddenOrSkippedItemIds.add(item.id) } } From 69918b883e1149c22a8129b01fc704a24b068587 Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Fri, 16 Feb 2024 15:58:00 +0400 Subject: [PATCH 08/13] M2-5293: mapping time, date and timeRange answers to UserEventsDTO --- src/entities/applet/model/mapper.ts | 28 ++++++++++++++++++++++++++++ src/entities/applet/model/types.ts | 15 ++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/entities/applet/model/mapper.ts b/src/entities/applet/model/mapper.ts index ff4642204..ad7924f7a 100644 --- a/src/entities/applet/model/mapper.ts +++ b/src/entities/applet/model/mapper.ts @@ -1,6 +1,7 @@ import { ItemRecord, UserEventResponse } from "./types" import { ActivityItemDetailsDTO } from "~/shared/api" +import { dateToDayMonthYear, dateToHourMinute } from "~/shared/utils" export const mapItemAnswerToUserEventResponse = (item: ItemRecord): UserEventResponse => { const responseType = item.responseType @@ -20,6 +21,33 @@ export const mapItemAnswerToUserEventResponse = (item: ItemRecord): UserEventRes } } + if (responseType === "date") { + return { + value: dateToDayMonthYear(new Date(itemAnswer[0])), + text: item.additionalText ?? undefined, + } + } + + if (responseType === "time") { + return { + value: dateToHourMinute(new Date(itemAnswer[0])), + text: item.additionalText ?? undefined, + } + } + + if (responseType === "timeRange") { + const fromDate = new Date(itemAnswer[0]) + const toDate = itemAnswer[1] ? new Date(itemAnswer[1]) : new Date() + + return { + value: { + from: { hour: fromDate.getHours(), minute: fromDate.getMinutes() }, + to: { hour: toDate.getHours(), minute: toDate.getMinutes() }, + }, + text: item.additionalText ?? undefined, + } + } + return { value: itemAnswer[0], text: item.additionalText ?? undefined, diff --git a/src/entities/applet/model/types.ts b/src/entities/applet/model/types.ts index 39d9b3b5e..a9779e4d0 100644 --- a/src/entities/applet/model/types.ts +++ b/src/entities/applet/model/types.ts @@ -12,13 +12,26 @@ import { TimeItem, TimeRangeItem, } from "~/entities/activity/lib" +import { DayMonthYearDTO, HourMinuteDTO } from "~/shared/utils" export type UserEventTypes = "SET_ANSWER" | "PREV" | "NEXT" | "SKIP" | "DONE" +export type TimeRangeUserEventDto = { + from: { + hour: number + minute: number + } + + to: { + hour: number + minute: number + } +} + export type UserEventResponse = | string | { - value: string | string[] | number[] + value: string | string[] | number[] | DayMonthYearDTO | HourMinuteDTO | TimeRangeUserEventDto text?: string } From a4a5b249cb86f869c7aa8d7851a9cd1f2dd10282 Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Fri, 16 Feb 2024 20:58:00 +0400 Subject: [PATCH 09/13] M2-5293: validating date and time items values before passing to activityProgress, bug fixes --- src/entities/applet/model/mapper.ts | 2 +- src/shared/ui/Items/Date/index.tsx | 17 +++++++++++++++-- src/shared/ui/Items/Time/index.tsx | 17 +++++++++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/entities/applet/model/mapper.ts b/src/entities/applet/model/mapper.ts index 8593e004d..864cda084 100644 --- a/src/entities/applet/model/mapper.ts +++ b/src/entities/applet/model/mapper.ts @@ -36,7 +36,7 @@ export const mapItemAnswerToUserEventResponse = (item: ItemRecord): UserEventRes } if (responseType === "timeRange") { - const fromDate = new Date(itemAnswer[0]) + const fromDate = itemAnswer[0] ? new Date(itemAnswer[0]) : new Date() const toDate = itemAnswer[1] ? new Date(itemAnswer[1]) : new Date() return { diff --git a/src/shared/ui/Items/Date/index.tsx b/src/shared/ui/Items/Date/index.tsx index d4b751fcd..a9eac9537 100644 --- a/src/shared/ui/Items/Date/index.tsx +++ b/src/shared/ui/Items/Date/index.tsx @@ -1,12 +1,25 @@ +import { useCallback } from "react" + import { DatePicker } from "@mui/x-date-pickers/DatePicker" type Props = { label?: string value: Date | null - onChange: (value: Date | null) => void + onChange: (value: Date) => void } export const DateItemBase = ({ label, value, onChange }: Props) => { - return label={label} value={value ?? null} onChange={onChange} /> + const handleChange = useCallback( + (value: Date | null) => { + const isValidDate = value !== null && !isNaN(value.getTime()) + + if (isValidDate) { + onChange(value) + } + }, + [onChange], + ) + + return label={label} value={value ?? null} onChange={handleChange} /> } diff --git a/src/shared/ui/Items/Time/index.tsx b/src/shared/ui/Items/Time/index.tsx index 6d51461ca..6dd1d525a 100644 --- a/src/shared/ui/Items/Time/index.tsx +++ b/src/shared/ui/Items/Time/index.tsx @@ -1,19 +1,32 @@ +import { useCallback } from "react" + import { DesktopTimePicker } from "@mui/x-date-pickers/DesktopTimePicker" type Props = { label?: string value?: string - onChange: (value: Date | null) => void + onChange: (value: Date) => void } export const TimeItemBase = ({ label, value, onChange }: Props) => { const formatedValue = value ? new Date(value) : null + const handleChange = useCallback( + (value: Date | null) => { + const isValidDate = value !== null && !isNaN(value.getTime()) + + if (isValidDate) { + onChange(value) + } + }, + [onChange], + ) + return ( label={label} value={formatedValue} - onChange={onChange} + onChange={handleChange} slotProps={{ textField: { placeholder: "HH:MM AM/PM" } }} /> ) From 91c285748aeea11c474654ce63582ea2a4506511 Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Mon, 19 Feb 2024 17:37:47 +0400 Subject: [PATCH 10/13] M2-5022: preventing user to skip the audio player if it is not skippable --- .../activity/ui/items/AudioPlayerItem.tsx | 16 ++++++++++++++-- src/entities/activity/ui/items/ItemPicker.tsx | 2 +- src/entities/applet/model/mapper.ts | 7 +++++++ src/entities/applet/model/types.ts | 2 +- src/i18n/en/translation.json | 1 + src/i18n/fr/translation.json | 1 + src/shared/api/types/activity.ts | 6 +++++- src/shared/ui/Items/AudioPlayer/lib/constants.ts | 1 + src/shared/ui/Items/AudioPlayer/lib/index.ts | 1 + .../ui/Items/AudioPlayer/ui/AudioPlayerBase.tsx | 16 +++++++++++++--- src/widgets/ActivityDetails/model/mappers.ts | 12 +++++++++++- .../ActivityDetails/model/validateItem.ts | 15 +++++++++++++++ 12 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 src/shared/ui/Items/AudioPlayer/lib/constants.ts diff --git a/src/entities/activity/ui/items/AudioPlayerItem.tsx b/src/entities/activity/ui/items/AudioPlayerItem.tsx index 9efd10bf9..6dc73ee7d 100644 --- a/src/entities/activity/ui/items/AudioPlayerItem.tsx +++ b/src/entities/activity/ui/items/AudioPlayerItem.tsx @@ -1,19 +1,31 @@ +import { useCallback } from "react" + import Box from "@mui/material/Box" import { AudioPlayerItem as AudioPlayerItemType } from "../../lib/types/item" import { AudioPlayerItemBase } from "~/shared/ui" +import { AudioPlayerFinished } from "~/shared/ui/Items/AudioPlayer/lib" type Props = { item: AudioPlayerItemType + + value: string + onValueChange: (value: string[]) => void } -export const AudioPlayerItem = ({ item }: Props) => { +export const AudioPlayerItem = ({ item, onValueChange, value }: Props) => { const isAbleToPlayOnce = item.config.playOnce + const onFinish = useCallback(() => { + if (!value) { + onValueChange([AudioPlayerFinished]) + } + }, [value, onValueChange]) + return ( - + ) } diff --git a/src/entities/activity/ui/items/ItemPicker.tsx b/src/entities/activity/ui/items/ItemPicker.tsx index f561f264d..aceb35704 100644 --- a/src/entities/activity/ui/items/ItemPicker.tsx +++ b/src/entities/activity/ui/items/ItemPicker.tsx @@ -70,7 +70,7 @@ export const ItemPicker = ({ item, onValueChange, isDisabled, replaceText }: Ite return case "audioPlayer": - return + return default: return <> diff --git a/src/entities/applet/model/mapper.ts b/src/entities/applet/model/mapper.ts index 8593e004d..308f2d351 100644 --- a/src/entities/applet/model/mapper.ts +++ b/src/entities/applet/model/mapper.ts @@ -48,6 +48,13 @@ export const mapItemAnswerToUserEventResponse = (item: ItemRecord): UserEventRes } } + if (responseType === "audioPlayer") { + return { + value: true, + text: item.additionalText ?? undefined, + } + } + return { value: itemAnswer[0], text: item.additionalText ?? undefined, diff --git a/src/entities/applet/model/types.ts b/src/entities/applet/model/types.ts index a9779e4d0..8ca5244b4 100644 --- a/src/entities/applet/model/types.ts +++ b/src/entities/applet/model/types.ts @@ -31,7 +31,7 @@ export type TimeRangeUserEventDto = { export type UserEventResponse = | string | { - value: string | string[] | number[] | DayMonthYearDTO | HourMinuteDTO | TimeRangeUserEventDto + value: boolean | string | string[] | number[] | DayMonthYearDTO | HourMinuteDTO | TimeRangeUserEventDto text?: string } diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 753d1fe3e..6806ac7f1 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -16,6 +16,7 @@ "optional": "Optional", "pleaseAnswerTheQuestion": "Please answer the question.", "pleaseProvideAdditionalText": "Please provide additional text", + "pleaseListenToAudio": "Please listen to the audio until the end.", "onlyNumbersAllowed": "Only numbers are allowed", "question_count_plural": "{{length}} questions", diff --git a/src/i18n/fr/translation.json b/src/i18n/fr/translation.json index 5d6d69646..e6f0cfd63 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -16,6 +16,7 @@ "optional": "Facultatif", "pleaseAnswerTheQuestion": "Veuillez répondre à la question.", "pleaseProvideAdditionalText": "Veuillez fournir un texte supplémentaire", + "pleaseListenToAudio": "Veuillez écouter l'audio jusqu'à la fin.", "onlyNumbersAllowed": "Seuls les numéros sont autorisés", diff --git a/src/shared/api/types/activity.ts b/src/shared/api/types/activity.ts index 587474bf4..1e4ef5714 100644 --- a/src/shared/api/types/activity.ts +++ b/src/shared/api/types/activity.ts @@ -103,6 +103,7 @@ export type AnswerTypesPayload = | DateAnswerPayload | TimeAnswerPayload | TimeRangeAnswerPayload + | AudioPlayerAnswerPayload export type EmptyAnswerPayload = null @@ -158,7 +159,10 @@ export type TimeRangeAnswerPayload = { text: string | null } -export type AudioPlayerAnswerPayload = null +export type AudioPlayerAnswerPayload = { + value: boolean + text: string | null +} export type CompletedEntityDTO = { id: string diff --git a/src/shared/ui/Items/AudioPlayer/lib/constants.ts b/src/shared/ui/Items/AudioPlayer/lib/constants.ts new file mode 100644 index 000000000..039af9772 --- /dev/null +++ b/src/shared/ui/Items/AudioPlayer/lib/constants.ts @@ -0,0 +1 @@ +export const AudioPlayerFinished = "finished" diff --git a/src/shared/ui/Items/AudioPlayer/lib/index.ts b/src/shared/ui/Items/AudioPlayer/lib/index.ts index 44d033147..66234fd59 100644 --- a/src/shared/ui/Items/AudioPlayer/lib/index.ts +++ b/src/shared/ui/Items/AudioPlayer/lib/index.ts @@ -1,3 +1,4 @@ export * from "./useAudioControls" export * from "./useAudioDuration" export * from "./useAudioVolume" +export * from "./constants" diff --git a/src/shared/ui/Items/AudioPlayer/ui/AudioPlayerBase.tsx b/src/shared/ui/Items/AudioPlayer/ui/AudioPlayerBase.tsx index 48010e05b..d0b89769e 100644 --- a/src/shared/ui/Items/AudioPlayer/ui/AudioPlayerBase.tsx +++ b/src/shared/ui/Items/AudioPlayer/ui/AudioPlayerBase.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react" +import { useEffect, useRef, useState } from "react" import Box from "@mui/material/Box" @@ -13,13 +13,15 @@ import { useCustomMediaQuery } from "~/shared/utils" type Props = { src: string playOnce?: boolean + onFinish: () => void onHandlePlay?: () => void onHandlePause?: () => void onHandleEnded?: () => void } -export const AudioPlayerItemBase = ({ src, playOnce }: Props) => { +export const AudioPlayerItemBase = ({ src, playOnce, onFinish }: Props) => { + const [hasFinishedAtLeastOnce, setHasFinishedAtLeastOnce] = useState(false) const audioRef = useRef(null) const mediaQuery = useCustomMediaQuery() @@ -48,6 +50,14 @@ export const AudioPlayerItemBase = ({ src, playOnce }: Props) => { return setCurrentDuration(progress) } + useEffect(() => { + if (progress === 100) { + pause() + setHasFinishedAtLeastOnce(true) + onFinish() + } + }, [pause, progress, onFinish]) + return ( From 65b4376f92a182f410e2b1063241d7097379d822 Mon Sep 17 00:00:00 2001 From: Vardan Mkhitaryan Date: Wed, 21 Feb 2024 18:01:10 +0400 Subject: [PATCH 13/13] M2-5308: Fixed DONE User-event is not being sent bug --- src/entities/applet/model/hooks/useUserEvents.ts | 14 +++++++++----- .../ActivityDetails/ui/AssessmentPassingScreen.tsx | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/entities/applet/model/hooks/useUserEvents.ts b/src/entities/applet/model/hooks/useUserEvents.ts index d60eee0b0..7881e72f2 100644 --- a/src/entities/applet/model/hooks/useUserEvents.ts +++ b/src/entities/applet/model/hooks/useUserEvents.ts @@ -25,18 +25,22 @@ export const useUserEvents = (props: Props) => { (type: UserEventTypes, item: ItemRecord) => { const activityItemScreenId = getActivityItemScreenId(props.activityId, item.id) + const newUserEvent = { + type, + screen: activityItemScreenId, + time: Date.now(), + } + dispatch( actions.saveUserEvent({ entityId: props.activityId, eventId: props.eventId, itemId: item.id, - userEvent: { - type, - screen: activityItemScreenId, - time: Date.now(), - }, + userEvent: newUserEvent, }), ) + + return newUserEvent }, [dispatch, props.activityId, props.eventId], ) diff --git a/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx b/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx index a2a74646a..f49ab9ece 100644 --- a/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx +++ b/src/widgets/ActivityDetails/ui/AssessmentPassingScreen.tsx @@ -100,11 +100,11 @@ export const AssessmentPassingScreen = (props: Props) => { }) const onSubmit = useCallback(() => { - saveUserEventByType("DONE", item) + const doneUserEvent = saveUserEventByType("DONE", item) const answer = processAnswers({ items, - userEvents, + userEvents: [...userEvents, doneUserEvent], isPublic: context.isPublic, })