From 1c906a4ab72ea64bf89810cb5122bac9d841b8e7 Mon Sep 17 00:00:00 2001 From: vmkhitaryanscn <120190555+vmkhitaryanscn@users.noreply.github.com> Date: Tue, 20 Feb 2024 13:20:25 +0400 Subject: [PATCH 01/17] Fixed isNoneAbove business logic (#372) --- src/entities/activity/ui/items/CheckboxItem/index.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/entities/activity/ui/items/CheckboxItem/index.tsx b/src/entities/activity/ui/items/CheckboxItem/index.tsx index c33120487..03f7ebd04 100644 --- a/src/entities/activity/ui/items/CheckboxItem/index.tsx +++ b/src/entities/activity/ui/items/CheckboxItem/index.tsx @@ -44,7 +44,7 @@ export const CheckboxItem = ({ item, values, onValueChange, isDisabled, replaceT } if (noneAboveOptionChecked) { - return onValueChange(preparedValues) + return onValueChange([...value]) } if (!isChangedIndexExist && !isNoneAbove) { @@ -66,8 +66,6 @@ export const CheckboxItem = ({ item, values, onValueChange, isDisabled, replaceT const isChecked = values.includes(String(option.value)) const isNoneAbove = option.isNoneAbove - const disabledDueNoneAbove = !isNoneAbove && noneAboveOptionChecked - return ( onHandleValueChange(value, isNoneAbove)} description={option.tooltip} image={option.image} - disabled={isDisabled || disabledDueNoneAbove} + disabled={isDisabled} defaultChecked={isChecked} color={option.color} replaceText={replaceText} @@ -92,8 +90,6 @@ export const CheckboxItem = ({ item, values, onValueChange, isDisabled, replaceT const isChecked = values.includes(String(option.value)) const isNoneAbove = option.isNoneAbove - const disabledDueNoneAbove = !isNoneAbove && noneAboveOptionChecked - return ( onHandleValueChange(value, isNoneAbove)} description={option.tooltip} image={option.image} - disabled={isDisabled || disabledDueNoneAbove} + disabled={isDisabled} defaultChecked={isChecked} color={option.color} replaceText={replaceText} From 9c8da3a1be77ea81f59a6b19157f09d5d56ae06e Mon Sep 17 00:00:00 2001 From: Victor Ryabkov <45964820+moiskillnadne@users.noreply.github.com> Date: Tue, 20 Feb 2024 20:34:59 +0800 Subject: [PATCH 02/17] Merge changes from dev2.5-reverted to dev2.5 (#381) * feat: M2-5117 added data-testIds * M2-5007 Restart resume feature: Added restart/resume buttons to ActivityCard, ui updates, added translations * M2-5007 Restart resume feature: Added data-testIds * bug: M2-5043 updated conditional logic , ignoring all the skipped and hidden items when processing conditionals * bug: M2-5260 Fixed default answers for hidden activities, removing conditionally hidden item answers dynamically * bug: M2-5260 Code improvements * bug: M2-5043 Storing ids of conditionally hidden items to be used for removing answers of those hidden items * M2-5293: mapping time, date and timeRange answers to UserEventsDTO * M2-5293: validating date and time items values before passing to activityProgress, bug fixes * M2-5022: preventing user to skip the audio player if it is not skippable * Update year in the footer * Fix activityCard layout when loading status --------- Co-authored-by: Vardan Mkhitaryan Co-authored-by: vmkhitaryanscn <120190555+vmkhitaryanscn@users.noreply.github.com> Co-authored-by: Viktor Riabkov --- src/assets/activity-restart-icon.svg | 3 + src/entities/activity/lib/types/item.ts | 1 + src/entities/activity/ui/ActivityCardItem.tsx | 6 +- .../activity/ui/items/AudioPlayerItem.tsx | 16 ++- src/entities/activity/ui/items/ItemPicker.tsx | 2 +- .../applet/model/ConditionalLogicBuilder.ts | 29 +++- .../applet/model/hooks/useActivityProgress.ts | 8 +- .../model/hooks/useSaveActivityItemAnswer.ts | 8 ++ .../applet/model/hooks/useStartEntity.ts | 24 +++- src/entities/applet/model/mapper.ts | 36 +++++ src/entities/applet/model/types.ts | 15 ++- src/i18n/en/translation.json | 7 +- src/i18n/fr/translation.json | 7 +- src/shared/api/types/activity.ts | 6 +- src/shared/constants/theme.ts | 1 + src/shared/ui/BaseButton/index.tsx | 3 +- src/shared/ui/CardItem/CardItem.tsx | 5 +- .../ui/Items/AudioPlayer/lib/constants.ts | 1 + src/shared/ui/Items/AudioPlayer/lib/index.ts | 1 + .../Items/AudioPlayer/ui/AudioPlayerBase.tsx | 16 ++- src/shared/ui/Items/Date/index.tsx | 17 ++- src/shared/ui/Items/Time/index.tsx | 17 ++- src/shared/ui/MuiModal/index.tsx | 42 +++++- .../ActivityDetails/model/hooks/useSurvey.ts | 13 +- src/widgets/ActivityDetails/model/mappers.ts | 12 +- .../ActivityDetails/model/validateItem.ts | 15 +++ .../ui/AssessmentPassingScreen.tsx | 18 ++- src/widgets/ActivityGroups/lib/types.ts | 1 + .../model/hooks/useStartEntity.ts | 37 ++--- .../ui/ActivityCard/ActivityCardBase.tsx | 12 +- .../ActivityCardRestartResume.tsx | 126 ++++++++++++++++++ .../ui/ActivityCard/TimeStatusLabel.tsx | 6 +- .../ActivityGroups/ui/ActivityCard/index.tsx | 58 ++++---- src/widgets/Footer/index.tsx | 2 +- 34 files changed, 476 insertions(+), 95 deletions(-) create mode 100644 src/assets/activity-restart-icon.svg create mode 100644 src/shared/ui/Items/AudioPlayer/lib/constants.ts 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/activity/lib/types/item.ts b/src/entities/activity/lib/types/item.ts index 9cab6592c..0e5f83d99 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/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/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/ConditionalLogicBuilder.ts b/src/entities/applet/model/ConditionalLogicBuilder.ts index 4a494b1d5..d7fabf1e0 100644 --- a/src/entities/applet/model/ConditionalLogicBuilder.ts +++ b/src/entities/applet/model/ConditionalLogicBuilder.ts @@ -6,8 +6,31 @@ import { Condition } from "~/shared/api" 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) => this.conditionalLogicFilter(item, index, array)) + return items.filter((item, index, array) => { + const isItemVisible = this.conditionalLogicFilter(item, index, array) + + this.handleItemVisibility(item, isItemVisible) + + return isItemVisible + }) + } + + public getConditionallyHiddenItemIds() { + return this.hiddenOrSkippedItemIds + } + + private handleItemVisibility(item: ItemRecord, isVisible: boolean) { + if (isVisible) { + this.hiddenOrSkippedItemNames.delete(item.name) + this.hiddenOrSkippedItemIds.delete(item.id) + } else { + this.hiddenOrSkippedItemNames.add(item.name) + this.hiddenOrSkippedItemIds.add(item.id) + } } private conditionalLogicFilter(item: ItemRecord, index: number, array: ItemRecord[]): boolean { @@ -41,7 +64,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 +78,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 } 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..8c6537eee 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, []) + }, + [saveItemAnswer], + ) + return { saveItemAnswer, saveItemAdditionalText, + removeItemAnswer, } } 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/entities/applet/model/mapper.ts b/src/entities/applet/model/mapper.ts index ff4642204..2f83b5961 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,40 @@ 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 = itemAnswer[0] ? new Date(itemAnswer[0]) : new Date() + 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, + } + } + + if (responseType === "audioPlayer") { + return { + value: true, + text: item.additionalText ?? undefined, + } + } + return { value: itemAnswer[0], text: item.additionalText ?? undefined, @@ -59,5 +94,6 @@ export function mapSplashScreenToRecord(splashScreen: string): ItemRecord { responseValues: null, answer: [], conditionalLogic: null, + isHidden: false, } } diff --git a/src/entities/applet/model/types.ts b/src/entities/applet/model/types.ts index 39d9b3b5e..8ca5244b4 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: boolean | string | string[] | number[] | DayMonthYearDTO | HourMinuteDTO | TimeRangeUserEventDto text?: string } diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 8b1a47e6c..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", @@ -240,7 +241,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 +269,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..e6f0cfd63 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -16,8 +16,9 @@ "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", - + "question_count_plural": "{{length}} questions", "question_count_singular": "{{length}} question", @@ -247,8 +248,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/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/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/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 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 (