diff --git a/android/app/build.gradle b/android/app/build.gradle index 76aeaccc8..6c3c35cd9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,8 +100,8 @@ android { applicationId "lab.childmindinstitute.data" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1563 - versionName "2.0.1" + versionCode 1565 + versionName "2.0.2" resValue "string", "app_name", "Mindlogger" resValue "string", "build_config_package", "lab.childmindinstitute.data" manifestPlaceholders = [ diff --git a/ios/MindloggerMobile.xcodeproj/project.pbxproj b/ios/MindloggerMobile.xcodeproj/project.pbxproj index 6037d3835..2953da049 100644 --- a/ios/MindloggerMobile.xcodeproj/project.pbxproj +++ b/ios/MindloggerMobile.xcodeproj/project.pbxproj @@ -1643,7 +1643,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", @@ -1655,7 +1655,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "-ObjC", "-lc++", @@ -1674,7 +1674,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; INFOPLIST_FILE = MindloggerMobileTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.4; LD_RUNPATH_SEARCH_PATHS = ( @@ -1682,7 +1682,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "-ObjC", "-lc++", @@ -1704,7 +1704,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; ENABLE_BITCODE = NO; @@ -1715,7 +1715,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1745,7 +1745,7 @@ CODE_SIGN_ENTITLEMENTS = MindloggerMobile/MindloggerMobileRelease.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; INFOPLIST_FILE = MindloggerMobile/Info.plist; @@ -1755,7 +1755,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1955,7 +1955,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; ENABLE_BITCODE = NO; @@ -1966,7 +1966,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1995,7 +1995,7 @@ CODE_SIGN_ENTITLEMENTS = MindloggerMobileDevRelease.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; INFOPLIST_FILE = "MindloggerMobile dev-Info.plist"; @@ -2005,7 +2005,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2034,7 +2034,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; ENABLE_BITCODE = NO; @@ -2045,7 +2045,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2075,7 +2075,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; INFOPLIST_FILE = "MindloggerMobile qa-Info.plist"; @@ -2085,7 +2085,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2112,7 +2112,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = MindloggerMobileStagingDebug.entitlements; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; ENABLE_BITCODE = NO; @@ -2123,7 +2123,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2152,7 +2152,7 @@ CODE_SIGN_ENTITLEMENTS = MindloggerMobileStagingRelease.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; INFOPLIST_FILE = "MindloggerMobile staging-Info.plist"; @@ -2162,7 +2162,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2191,7 +2191,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; ENABLE_BITCODE = NO; @@ -2202,7 +2202,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2232,7 +2232,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1563; + CURRENT_PROJECT_VERSION = 1565; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8RHKE85KB6; INFOPLIST_FILE = "MindloggerMobile uat-Info.plist"; @@ -2242,7 +2242,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/package.json b/package.json index 237245ad2..8b1211959 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mindlogger-mobile", - "version": "2.0.1", + "version": "2.0.2", "private": true, "scripts": { "android": "yarn android:dev", diff --git a/src/entities/activity/lib/services/AnswersQueueService.ts b/src/entities/activity/lib/services/AnswersQueueService.ts index 7ba0e2ec0..2f5297bd3 100644 --- a/src/entities/activity/lib/services/AnswersQueueService.ts +++ b/src/entities/activity/lib/services/AnswersQueueService.ts @@ -1,7 +1,7 @@ import { ChangeQueueObservable, + IObservable, createSecureStorage, - IChangeQueueNotify, } from '@app/shared/lib'; import { SendAnswersInput } from '../types'; @@ -23,7 +23,7 @@ const storage = createSecureStorage('upload_queue-storage'); const StartKey = '1'; class AnswersQueueService implements IAnswersQueueService { - constructor(changeObservable: IChangeQueueNotify) { + constructor(changeObservable: IObservable) { storage.addOnValueChangedListener(() => { changeObservable.notify(); }); diff --git a/src/entities/activity/lib/services/AnswersUploadService.ts b/src/entities/activity/lib/services/AnswersUploadService.ts index 9505c3138..9c9291c4a 100644 --- a/src/entities/activity/lib/services/AnswersUploadService.ts +++ b/src/entities/activity/lib/services/AnswersUploadService.ts @@ -339,6 +339,10 @@ class AnswersUploadService implements IAnswersUploadService { throw new Error( '[UploadAnswersService.uploadAnswers] Answers were not uploaded', ); + } else { + this.logger.log( + '[UploadAnswersService.uploadAnswers]: Check result: uploaded successfully', + ); } } diff --git a/src/entities/drawer/ui/DrawingTest/DrawingTestNewLayout.tsx b/src/entities/drawer/ui/DrawingTest/DrawingTestNewLayout.tsx index 0436382c9..355f83ff0 100644 --- a/src/entities/drawer/ui/DrawingTest/DrawingTestNewLayout.tsx +++ b/src/entities/drawer/ui/DrawingTest/DrawingTestNewLayout.tsx @@ -41,7 +41,7 @@ const DrawingTest: FC = props => { } = useImageDimensions(imageUrl); const { exampleImageHeight, canvasContainerHeight, canvasSize } = - getElementsDimensions(props.dimensions, exampleImageDimensions); + getElementsDimensions(dimensions, exampleImageDimensions); const containerHeight = exampleImageHeight + canvasSize; diff --git a/src/features/pass-survey/lib/types/payload.ts b/src/features/pass-survey/lib/types/payload.ts index f35e3ede5..9a5a30a81 100644 --- a/src/features/pass-survey/lib/types/payload.ts +++ b/src/features/pass-survey/lib/types/payload.ts @@ -405,8 +405,8 @@ export type AudioResponse = MediaFile; export type AudioPlayerResponse = boolean; export type TimeRangeResponse = { - startTime: HourMinute; - endTime: HourMinute; + startTime: HourMinute | null; + endTime: HourMinute | null; }; export type RadioResponse = RadioOption; diff --git a/src/features/pass-survey/model/AnswerValidator.ts b/src/features/pass-survey/model/AnswerValidator.ts index 76a76a051..c4ff3939b 100644 --- a/src/features/pass-survey/model/AnswerValidator.ts +++ b/src/features/pass-survey/model/AnswerValidator.ts @@ -1,7 +1,12 @@ import { Item } from '@app/shared/ui'; import { ConditionalLogicModel } from '@entities/conditional-logic'; -import { Answers, PipelineItem, RadioResponse } from '../lib'; +import { + Answers, + PipelineItem, + RadioResponse, + TimeRangeResponse, +} from '../lib'; type AnswerValidatorArgs = { items: PipelineItem[]; @@ -19,6 +24,7 @@ export interface IAnswerValidator { isLessThen(value: number): boolean; includesOption(optionValue: string): boolean; notIncludesOption(optionValue: string): boolean; + isValidAnswer(): boolean; } function AnswerValidator(params?: AnswerValidatorArgs): IAnswerValidator { @@ -89,6 +95,21 @@ function AnswerValidator(params?: AnswerValidatorArgs): IAnswerValidator { return ConditionalLogicModel.doesNotIncludeValue(answer, optionValue); }, + isValidAnswer() { + switch (currentPipelineItem?.type) { + case 'TimeRange': { + const answer = currentAnswer?.answer as TimeRangeResponse; + + if (answer) { + return !!answer?.startTime && !!answer?.endTime; + } + + return true; + } + default: + return true; + } + }, }; } diff --git a/src/features/pass-survey/model/hooks/useActivityStepper.ts b/src/features/pass-survey/model/hooks/useActivityStepper.ts index a2380104d..490e40569 100644 --- a/src/features/pass-survey/model/hooks/useActivityStepper.ts +++ b/src/features/pass-survey/model/hooks/useActivityStepper.ts @@ -12,6 +12,7 @@ const ConditionalLogicItems: ActivityItemType[] = [ ]; function useActivityStepper(state: ActivityState | undefined) { + const answerValidator = AnswerValidator(state); const step = state?.step ?? 0; const items = state?.items ?? []; const answers = state?.answers ?? {}; @@ -36,12 +37,16 @@ function useActivityStepper(state: ActivityState | undefined) { const canSkip = !!currentPipelineItem?.isSkippable && !hasAnswer && !isSplashStep; + const canMoveNext = isTutorialStep || isMessageStep || isAbTestStep || currentPipelineItem?.isSkippable || - (hasAnswer && (!additionalAnswerRequired || hasAdditionalAnswer)); + (hasAnswer && + (!additionalAnswerRequired || hasAdditionalAnswer) && + answerValidator.isValidAnswer()); + const canMoveBack = currentPipelineItem?.isAbleToMoveBack; const canReset = currentPipelineItem?.canBeReset && (hasAnswer || hasAdditionalAnswer); @@ -53,8 +58,6 @@ function useActivityStepper(state: ActivityState | undefined) { currentPipelineItem!?.type, ); - const answerValidator = AnswerValidator(state); - function isValid() { const valid = answerValidator.isCorrect(); diff --git a/src/features/pass-survey/model/hooks/useIdleTimer.ts b/src/features/pass-survey/model/hooks/useIdleTimer.ts index 073b6360a..ade6bebff 100644 --- a/src/features/pass-survey/model/hooks/useIdleTimer.ts +++ b/src/features/pass-survey/model/hooks/useIdleTimer.ts @@ -41,9 +41,7 @@ const useIdleTimer = (input: UseIdleTimerInput): UseIdleTimerResult => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const onIdleElapsed = () => { - onFinish(); - }; + const onIdleElapsed = () => onFinish(); const restart = () => { timer?.restart(); diff --git a/src/features/pass-survey/model/tests/AnswerValidator.test.ts b/src/features/pass-survey/model/tests/AnswerValidator.test.ts index 8550e719b..08e8a4687 100644 --- a/src/features/pass-survey/model/tests/AnswerValidator.test.ts +++ b/src/features/pass-survey/model/tests/AnswerValidator.test.ts @@ -1,6 +1,6 @@ import { ConditionType } from '@app/entities/activity'; -import { getEmptySliderItem } from './testHelpers'; +import { getEmptySliderItem, getTimeRangeItem } from './testHelpers'; import { Answers } from '../../lib'; import AnswerValidator, { IAnswerValidator } from '../AnswerValidator'; @@ -208,4 +208,135 @@ describe('AnswerValidator tests', () => { expect(result).toEqual(false); }); + + it('Should return true when validationOptions is not present', () => { + const sliderItem1 = getEmptySliderItem('mock-slider-name-1'); + + const items = [sliderItem1]; + + const answer1 = { answer: '1' }; + + const answers: Answers = { + '0': answer1, + }; + + const validator = AnswerValidator({ + items: items as any, + answers: answers as any, + step: 1, + }); + + const result = validator.isCorrect(); + + expect(result).toEqual(true); + }); + + it('Should return true if slider item has (any) answer ', () => { + const sliderItem = getEmptySliderItem('mock-slider-name-2'); + + const items = [sliderItem]; + + const answer = { answer: '2' }; + + const answers: Answers = { + '0': answer, + }; + + const validator = AnswerValidator({ + items: items as any, + answers: answers as any, + step: 0, + }); + + const result = validator.isValidAnswer(); + + expect(result).toEqual(true); + }); + + it('Should return true when timeRange answer has both startTime and endTime set ', () => { + const timeRangeItem = getTimeRangeItem(); + + const items = [timeRangeItem]; + + const answer = { + answer: { + startTime: { + hours: 1, + minutes: 2, + }, + endTime: { + hours: 1, + minutes: 2, + }, + }, + }; + + const answers: Answers = { + '0': answer, + }; + + const validator = AnswerValidator({ + items: items as any, + answers: answers as any, + step: 0, + }); + + const result = validator.isValidAnswer(); + + expect(result).toEqual(true); + }); + + it('Should return false when timeRange answer does not have both startTime and endTime set', () => { + const timeRangeItem = getTimeRangeItem(); + + const items = [timeRangeItem]; + + const answer = { + answer: { + startTime: { + hours: 1, + minutes: 2, + }, + endTime: null, + }, + }; + + const answers: Answers = { + '0': answer, + }; + + const validator = AnswerValidator({ + items: items as any, + answers: answers as any, + step: 0, + }); + + const result = validator.isValidAnswer(); + + expect(result).toEqual(false); + }); + + it('Should return true when timeRange item answer is null', () => { + const timeRangeItem = getTimeRangeItem(); + + const items = [timeRangeItem]; + + const answer = { + answer: null, + }; + + const answers: Answers = { + '0': answer, + }; + + const validator = AnswerValidator({ + items: items as any, + answers: answers as any, + step: 0, + }); + + const result = validator.isValidAnswer(); + + expect(result).toEqual(true); + }); }); diff --git a/src/features/pass-survey/model/tests/testHelpers.ts b/src/features/pass-survey/model/tests/testHelpers.ts index 362ef6dd0..06900091d 100644 --- a/src/features/pass-survey/model/tests/testHelpers.ts +++ b/src/features/pass-survey/model/tests/testHelpers.ts @@ -14,6 +14,7 @@ import { StackedRadioResponse, StackedSliderPipelineItem, TextInputPipelineItem, + TimeRangePipelineItem, TutorialPipelineItem, } from '../../lib'; @@ -531,3 +532,15 @@ export const getStackedSliderItem = (): StackedSliderPipelineItem => { }; return result; }; + +export const getTimeRangeItem = (): TimeRangePipelineItem => { + const result: TimeRangePipelineItem = { + id: 'mock-timerange-id', + name: 'mock-timerange-name', + timer: null, + payload: null, + type: 'TimeRange', + }; + + return result; +}; diff --git a/src/shared/api/services/answerService.ts b/src/shared/api/services/answerService.ts index d776f4b36..45be5cfc6 100644 --- a/src/shared/api/services/answerService.ts +++ b/src/shared/api/services/answerService.ts @@ -84,8 +84,8 @@ export type AbTestAnswerDto = { }; export type TimeRangeAnswerDto = { - from: { hour: number; minute: number }; - to: { hour: number; minute: number }; + from: { hour: number; minute: number } | null; + to: { hour: number; minute: number } | null; }; export type TimeAnswerDto = HourMinute; diff --git a/src/shared/lib/constants/dateTime.ts b/src/shared/lib/constants/dateTime.ts index fab34996d..c253b167e 100644 --- a/src/shared/lib/constants/dateTime.ts +++ b/src/shared/lib/constants/dateTime.ts @@ -12,3 +12,6 @@ date.setSeconds(0); export const MIDNIGHT_DATE = date; export const TIMEZONE = getTimeZone(); + +export const TIME_PICKER_FORMAT_PLACEHOLDER = 'HH:MM AM/PM'; +export const DATE_PICKER_FORMAT_PLACEHOLDER = 'MM/DD/YYYY'; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index fd77b0f00..5b038884b 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -20,3 +20,4 @@ export * from './analytics'; export * from './redux-state'; export * from './observables'; export * from './featureFlags'; +export * from './mutexes'; diff --git a/src/shared/lib/mutexes/index.ts b/src/shared/lib/mutexes/index.ts new file mode 100644 index 000000000..e8f1ebaf5 --- /dev/null +++ b/src/shared/lib/mutexes/index.ts @@ -0,0 +1 @@ +export { default as InterimSubmitMutex } from './interimSubmitMutex'; diff --git a/src/shared/lib/mutexes/interimSubmitMutex.ts b/src/shared/lib/mutexes/interimSubmitMutex.ts new file mode 100644 index 000000000..ef0403ff2 --- /dev/null +++ b/src/shared/lib/mutexes/interimSubmitMutex.ts @@ -0,0 +1,19 @@ +import { IMutex } from '../utils'; + +class InterimSubmitMutex implements IMutex { + private _processing: boolean = false; + + public setBusy() { + this._processing = true; + } + + public release() { + this._processing = false; + } + + public isBusy() { + return this._processing; + } +} + +export default new InterimSubmitMutex(); diff --git a/src/shared/lib/observables/changeQueueObservable.ts b/src/shared/lib/observables/changeQueueObservable.ts index 8fb06810f..ad7362614 100644 --- a/src/shared/lib/observables/changeQueueObservable.ts +++ b/src/shared/lib/observables/changeQueueObservable.ts @@ -1,13 +1,6 @@ import { CommonObservable } from '../utils'; -export interface IChangeQueueNotify { - notify: () => void; -} - -class ChangeQueueObservable - extends CommonObservable - implements IChangeQueueNotify -{ +class ChangeQueueObservable extends CommonObservable { constructor() { super(); } diff --git a/src/shared/lib/observables/index.ts b/src/shared/lib/observables/index.ts index 4a83e98ca..e6a63c479 100644 --- a/src/shared/lib/observables/index.ts +++ b/src/shared/lib/observables/index.ts @@ -1,4 +1,3 @@ export { default as ChangeQueueObservable } from './changeQueueObservable'; export { default as UploadObservable } from './uploadObservable'; -export type { IChangeQueueNotify } from './changeQueueObservable'; export type { IUploadObservableSetters } from './uploadObservable'; diff --git a/src/shared/lib/utils/observable.ts b/src/shared/lib/utils/observable.ts index 67849d7a2..17259cc6c 100644 --- a/src/shared/lib/utils/observable.ts +++ b/src/shared/lib/utils/observable.ts @@ -1,8 +1,13 @@ type ObserverFunctionBase = (...args: any[]) => void; +export interface IObservable { + notify: () => void; +} + export class CommonObservable< TObserver extends ObserverFunctionBase = ObserverFunctionBase, -> { +> implements IObservable +{ protected _observers: Array; constructor() { diff --git a/src/shared/lib/utils/survey/survey.ts b/src/shared/lib/utils/survey/survey.ts index dd967656a..08ffb17ab 100644 --- a/src/shared/lib/utils/survey/survey.ts +++ b/src/shared/lib/utils/survey/survey.ts @@ -5,6 +5,8 @@ import { } from '@app/abstract/lib'; import { colors } from '@shared/lib/constants'; +import { getNow } from '../dateTime'; + export const invertColor = (hex: string) => { const RED_RATIO = 299; const GREEN_RATIO = 587; @@ -51,4 +53,4 @@ export function isReadyForAutocompletion( } export const isEntityExpired = (availableTo: number | null | undefined) => - !!availableTo && Date.now() > availableTo; + !!availableTo && getNow().getTime() > availableTo; diff --git a/src/shared/ui/DateTimePicker.tsx b/src/shared/ui/DateTimePicker.tsx index f7d144dd7..6babc99bc 100644 --- a/src/shared/ui/DateTimePicker.tsx +++ b/src/shared/ui/DateTimePicker.tsx @@ -10,11 +10,12 @@ import { XStack, Text } from '.'; type Props = { onChange: (value: Date) => void; - value: Date; + value: Date | null; iconAfter: JSX.Element; label?: string; mode?: 'date' | 'time' | 'datetime'; dateDisplayFormat?: string; + placeholder: string; }; const DatePickerButton = styled(Button, { @@ -25,11 +26,12 @@ const DatePickerButton = styled(Button, { }); const DateTimePicker: FC = ({ - value = new Date(), + value, accessibilityLabel, onChange, label, iconAfter, + placeholder, mode = 'date', dateDisplayFormat = 'MMMM d, yyyy', }) => { @@ -62,13 +64,13 @@ const DateTimePicker: FC = ({ iconAfter={iconAfter} > - {format(value, dateDisplayFormat)} + {value ? format(value, dateDisplayFormat) : placeholder} = ({ value, onChange }) => { onChange(formattedDate); }; - const valueAsDate = useMemo(() => { - if (!value) { - return new Date(); - } - - return getDateFromString(value); - }, [value]); + const valueAsDate = value ? getDateFromString(value) : null; return ( = ({ value, onChange }) => { onChange={onChangeDate} value={valueAsDate} iconAfter={} + placeholder={DATE_PICKER_FORMAT_PLACEHOLDER} /> ); }; diff --git a/src/shared/ui/survey/TimePickerItem.tsx b/src/shared/ui/survey/TimePickerItem.tsx index 14bd208f3..0cd241994 100644 --- a/src/shared/ui/survey/TimePickerItem.tsx +++ b/src/shared/ui/survey/TimePickerItem.tsx @@ -7,6 +7,7 @@ import { getMidnightDateInMs, getMsFromMinutes, getNow, + TIME_PICKER_FORMAT_PLACEHOLDER, } from '@shared/lib'; import { AlarmIcon, DateTimePicker } from '@shared/ui'; @@ -28,7 +29,7 @@ const TimePickerItem: FC = ({ value, onChange }) => { ? getMidnightDateInMs(getNow()) + getMsFromHours(value.hours) + getMsFromMinutes(value.minutes) - : getNow(), + : null, [value], ); @@ -36,10 +37,11 @@ const TimePickerItem: FC = ({ value, onChange }) => { } + placeholder={TIME_PICKER_FORMAT_PLACEHOLDER} /> ); }; diff --git a/src/shared/ui/survey/TimeRangeItem.tsx b/src/shared/ui/survey/TimeRangeItem.tsx index e3ee93622..58a262476 100644 --- a/src/shared/ui/survey/TimeRangeItem.tsx +++ b/src/shared/ui/survey/TimeRangeItem.tsx @@ -6,13 +6,13 @@ import { getMidnightDateInMs, getMsFromHours, getMsFromMinutes, - getNow, + TIME_PICKER_FORMAT_PLACEHOLDER, } from '@app/shared/lib'; import { YStack, DateTimePicker, AlarmIcon, BedIcon } from '@shared/ui'; type TimeRangeValue = { - endTime: HourMinute; - startTime: HourMinute; + endTime: HourMinute | null; + startTime: HourMinute | null; }; type Props = { @@ -36,26 +36,30 @@ const TimeRangeItem: FC = ({ value, onChange }) => { }); const startTimeAsDate = useMemo( - () => (value?.startTime ? transformToDate(value.startTime) : getNow()), + () => (value?.startTime ? transformToDate(value.startTime) : null), [value], ); const endTimeAsDate = useMemo( - () => (value?.endTime ? transformToDate(value.endTime) : getNow()), + () => (value?.endTime ? transformToDate(value.endTime) : null), [value], ); - const onChangeStartTime = (time: Date) => + const onChangeStartTime = (time: Date) => { onChange({ - endTime: transformToHourMinute(endTimeAsDate), + endTime: endTimeAsDate ? transformToHourMinute(endTimeAsDate) : null, startTime: transformToHourMinute(time), }); + }; - const onChangeEndTime = (time: Date) => + const onChangeEndTime = (time: Date) => { onChange({ - startTime: transformToHourMinute(startTimeAsDate), + startTime: startTimeAsDate + ? transformToHourMinute(startTimeAsDate) + : null, endTime: transformToHourMinute(time), }); + }; return ( @@ -67,6 +71,7 @@ const TimeRangeItem: FC = ({ value, onChange }) => { value={startTimeAsDate} mode="time" iconAfter={} + placeholder={TIME_PICKER_FORMAT_PLACEHOLDER} /> = ({ value, onChange }) => { mode="time" value={endTimeAsDate} iconAfter={} + placeholder={TIME_PICKER_FORMAT_PLACEHOLDER} /> ); diff --git a/src/shared/ui/survey/tests/DatePickerItem.test.tsx b/src/shared/ui/survey/tests/DatePickerItem.test.tsx index 3862ebcfc..1553ddd01 100644 --- a/src/shared/ui/survey/tests/DatePickerItem.test.tsx +++ b/src/shared/ui/survey/tests/DatePickerItem.test.tsx @@ -5,7 +5,7 @@ import TamaguiProvider from '@app/app/ui/AppProvider/TamaguiProvider'; import { DatePickerItem } from '@shared/ui'; describe('Test DatePickerItem', () => { - it('Should render now date when value is null', () => { + it('Should render a MM/DD/YYYY placeholder when value is null', () => { const datePickerComponent = renderer.create( @@ -16,11 +16,10 @@ describe('Test DatePickerItem', () => { accessibilityLabel: 'date-picker', }); - const resultProp = format(datePicker.props.value, 'yyyy-MM-dd'); - - const expectedDate = format(new Date(), 'yyyy-MM-dd'); + const placeholder = datePicker.props.placeholder as string; + const expected = 'MM/DD/YYYY'; - expect(resultProp).toBe(expectedDate); + expect(placeholder).toBe(expected); }); it('Should render new Date(0) when value is 1970-01-01', () => { diff --git a/src/shared/ui/survey/tests/TimePickerItem.test.tsx b/src/shared/ui/survey/tests/TimePickerItem.test.tsx index c11edbf31..1008b2c19 100644 --- a/src/shared/ui/survey/tests/TimePickerItem.test.tsx +++ b/src/shared/ui/survey/tests/TimePickerItem.test.tsx @@ -14,8 +14,6 @@ describe('Test TimePickerItem', () => { it('Should be rendered with the value equal to undefined', () => { const mockNowDate = new Date(2024, 3, 8, 14, 15, 16); - const mockMinutes = mockNowDate.getMinutes(); - jest.spyOn(dateTimeUtils, 'getNow').mockReturnValue(mockNowDate); const timePicker = renderer.create( @@ -28,11 +26,9 @@ describe('Test TimePickerItem', () => { accessibilityLabel: 'time-picker', }); - const pickerValueDate = new Date(pickerButton.props.value); - - const pickerValueMinutes = pickerValueDate.getMinutes(); + const pickerValue = pickerButton.props.value; - expect(pickerValueMinutes).toBe(mockMinutes); + expect(pickerValue).toBe(null); }); it('Should be rendered with the specified value', () => { diff --git a/src/shared/ui/survey/tests/TimeRangeItem.test.tsx b/src/shared/ui/survey/tests/TimeRangeItem.test.tsx index 3eb9f9905..c61ce9c37 100644 --- a/src/shared/ui/survey/tests/TimeRangeItem.test.tsx +++ b/src/shared/ui/survey/tests/TimeRangeItem.test.tsx @@ -30,9 +30,9 @@ describe('Test TimeRangeItem', () => { const endDateValue = endDatePicker.props.value; - expect(startDateValue).toBe(mockNowDate); + expect(startDateValue).toBe(null); - expect(endDateValue).toBe(mockNowDate); + expect(endDateValue).toBe(null); }); it('Should render start/end date pickers if initial value specified', () => { diff --git a/src/widgets/survey/lib/useFlowStorageRecord.ts b/src/widgets/survey/lib/useFlowStorageRecord.ts index c8f46fd3b..8dda4eb24 100644 --- a/src/widgets/survey/lib/useFlowStorageRecord.ts +++ b/src/widgets/survey/lib/useFlowStorageRecord.ts @@ -44,6 +44,7 @@ export type FlowState = { scheduledDate: number | null; pipeline: FlowPipelineItem[]; isCompletedDueToTimer: boolean; + interruptionStep: number | null; context: Record; }; diff --git a/src/widgets/survey/model/hooks/useFlowRecordInitialization.ts b/src/widgets/survey/model/hooks/useFlowRecordInitialization.ts index 0c2492111..4f800aa01 100644 --- a/src/widgets/survey/model/hooks/useFlowRecordInitialization.ts +++ b/src/widgets/survey/model/hooks/useFlowRecordInitialization.ts @@ -120,6 +120,7 @@ export function useFlowRecordInitialization({ step: 0, pipeline: buildPipeline(), isCompletedDueToTimer: false, + interruptionStep: null, context: {}, flowName: flow?.name ?? null, scheduledDate: scheduledDate ?? null, diff --git a/src/widgets/survey/model/hooks/useFlowState.ts b/src/widgets/survey/model/hooks/useFlowState.ts index d1a31f0d1..2fcaf9ca2 100644 --- a/src/widgets/survey/model/hooks/useFlowState.ts +++ b/src/widgets/survey/model/hooks/useFlowState.ts @@ -41,6 +41,7 @@ export function useFlowState({ appletId, eventId, flowId }: UseFlowStateArgs) { return { step, isTimerElapsed: record?.isCompletedDueToTimer ?? false, + interruptionStep: record?.interruptionStep ?? null, pipeline, flowSummaryData, remainingActivityIds, diff --git a/src/widgets/survey/model/hooks/useFlowStateActions.ts b/src/widgets/survey/model/hooks/useFlowStateActions.ts index 4256a3fb4..eaf080812 100644 --- a/src/widgets/survey/model/hooks/useFlowStateActions.ts +++ b/src/widgets/survey/model/hooks/useFlowStateActions.ts @@ -1,3 +1,5 @@ +import { Logger } from '@app/shared/lib'; + import { ActivitySummaryData, FlowState, @@ -128,9 +130,13 @@ export function useFlowStateActions({ } } - function completeByTimer() { + function completeByTimer(): void { const record: FlowState = getCurrentFlowStorageRecord()!; + Logger.log( + `[useFlowStateActions.completeByTimer] Executing, current step is: ${record.step}`, + ); + if (isLastStep(record) || isSummaryStep(record)) { return; } @@ -141,6 +147,7 @@ export function useFlowStateActions({ ...record, step: pipeline.length - 1, isCompletedDueToTimer: true, + interruptionStep: record.step, }); } diff --git a/src/widgets/survey/model/hooks/useTimer.ts b/src/widgets/survey/model/hooks/useTimer.ts index a901a152b..a7711d96a 100644 --- a/src/widgets/survey/model/hooks/useTimer.ts +++ b/src/widgets/survey/model/hooks/useTimer.ts @@ -4,6 +4,8 @@ import { useRef } from 'react'; import { HourMinute, getMsFromHours, getMsFromMinutes } from '@app/shared/lib'; import { AppTimer } from '@app/shared/lib'; +import InterimInActionPostponer from '../services/InterimInActionPostponer'; + type UseTimerInput = { onFinish: () => void; entityStartedAt: number; @@ -24,6 +26,10 @@ const useTimer = (input: UseTimerInput) => { finishRef.current = onFinish; + const postponer = useRef( + new InterimInActionPostponer(() => finishRef.current()), + ).current; + useEffect(() => { if (!timerLogicIsUsed) { return; @@ -40,12 +46,20 @@ const useTimer = (input: UseTimerInput) => { const durationLeft = durationBySettings - alreadyElapsed; - const timer = new AppTimer(() => finishRef.current(), false, durationLeft); + const timer = new AppTimer( + () => { + postponer.tryExecute(); + timer.stop(); + }, + false, + durationLeft, + ); timer.start(); return () => { timer.stop(); + postponer.reset(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/widgets/survey/model/mappers.ts b/src/widgets/survey/model/mappers.ts index 4a95f294d..c839eb46c 100644 --- a/src/widgets/survey/model/mappers.ts +++ b/src/widgets/survey/model/mappers.ts @@ -43,6 +43,7 @@ import { AbLogLineDto, AbLogPointDto, AnswerAlertsDto, + TimeRangeAnswerDto, } from '@shared/api'; import { HourMinute, @@ -58,8 +59,8 @@ import { canItemHaveAnswer } from './operations'; type Answer = PipelineItemAnswer['value']; type TimeRange = { - endTime: HourMinute; - startTime: HourMinute; + endTime: HourMinute | null; + startTime: HourMinute | null; }; type StackedRadioAnswerValue = Array>; @@ -214,19 +215,28 @@ function convertToTimeRangeAnswer(answer: Answer): AnswerDto { const timeRangeItem = answer.answer as TimeRange; const { startTime, endTime } = timeRangeItem ?? {}; + const timeRangeValue = { + value: { + from: null, + to: null, + } as TimeRangeAnswerDto, + }; + + if (startTime) { + timeRangeValue.value.from = { + hour: startTime.hours, + minute: startTime.minutes, + }; + } + if (endTime) { + timeRangeValue.value.to = { + hour: endTime?.hours, + minute: endTime?.minutes, + }; + } + return { - ...(timeRangeItem && { - value: { - from: { - hour: startTime.hours, - minute: startTime.minutes, - }, - to: { - hour: endTime.hours, - minute: endTime.minutes, - }, - }, - }), + ...timeRangeValue, ...(answer.additionalAnswer && { text: answer.additionalAnswer, }), diff --git a/src/widgets/survey/model/services/ConstructCompletionsService.ts b/src/widgets/survey/model/services/ConstructCompletionsService.ts index 98725e435..660b3da9f 100644 --- a/src/widgets/survey/model/services/ConstructCompletionsService.ts +++ b/src/widgets/survey/model/services/ConstructCompletionsService.ts @@ -1,5 +1,5 @@ import { QueryClient } from '@tanstack/react-query'; -import { addMilliseconds } from 'date-fns'; +import { addMilliseconds, subSeconds } from 'date-fns'; import { StoreProgress } from '@app/abstract/lib'; import { IPushToQueue } from '@app/entities/activity'; @@ -17,6 +17,7 @@ import { getEntityProgress, getNow, getTimezoneOffset, + isEntityExpired, Logger, MixEvents, MixProperties, @@ -106,10 +107,10 @@ export class ConstructCompletionsService { ? 'not set' : new Date(availableTo).toUTCString(); - return `evaluatedEndAt = ${logEndAt}, availableTo = ${logAvailableTo}`; + return `evaluatedEndAt: "${logEndAt}|${evaluatedEndAt}", availableTo: ${logAvailableTo}`; } - private logCompletion( + private logFinish( activityName: string, activityId: string, flowId: string | undefined, @@ -117,16 +118,17 @@ export class ConstructCompletionsService { appletId: string, evaluatedEndAt: number, availableTo: number | null, + submitId: string, ) { const logDates = this.getLogDates(evaluatedEndAt, availableTo); Logger.log( - `[ConstructCompletionsService]: Activity "${activityName}|${activityId}" completed, applet "${appletName}|${appletId}, ${logDates}"`, + `[ConstructCompletionsService.logFinish]: Activity: "${activityName}|${activityId}", applet: "${appletName}|${appletId}", submitId: ${submitId}, ${logDates}`, ); if (flowId) { Logger.log( - `[ConstructCompletionsService]: Flow "${flowId}" completed, applet "${appletName}|${appletId}, ${logDates}"`, + `[ConstructCompletionsService.logFinish]: Flow "${flowId}", applet "${appletName}|${appletId}", submitId: ${submitId}, ${logDates}`, ); } } @@ -142,11 +144,12 @@ export class ConstructCompletionsService { appletName: string, evaluatedEndAt: number, availableTo: number | null, + submitId: string, ) { const logDates = this.getLogDates(evaluatedEndAt, availableTo); Logger.log( - `[ConstructCompletionsService]: Activity "${activityName}|${activityId}" within flow "${flowName}|${flowId}" completed, applet "${appletName}|${appletId}, ${logDates}"`, + `[ConstructCompletionsService.logIntermediate]: Activity: "${activityName}|${activityId}", flow: "${flowName}|${flowId}", applet: "${appletName}|${appletId}", submitId: ${submitId}, ${logDates}`, ); } @@ -159,9 +162,20 @@ export class ConstructCompletionsService { return getNow().getTime(); } + if (!isEntityExpired(availableTo)) { + return completionType === 'intermediate' + ? getNow().getTime() + : addMilliseconds(getNow(), DistinguishInterimAndFinishLag).getTime(); + } + + const aSecondBeforeAvailableTo = subSeconds(availableTo, 1); + return completionType === 'intermediate' - ? availableTo - : addMilliseconds(availableTo, DistinguishInterimAndFinishLag).getTime(); + ? aSecondBeforeAvailableTo.getTime() + : addMilliseconds( + aSecondBeforeAvailableTo, + DistinguishInterimAndFinishLag, + ).getTime(); } private getAppletProperties(appletId: string): { @@ -182,17 +196,9 @@ export class ConstructCompletionsService { }; } - private validate( - activityStorageRecord: ActivityState | null | undefined, + private validateEncryption( appletEncryption: AppletEncryptionDTO | null | undefined, ) { - if (!activityStorageRecord) { - const error = - '[ConstructCompletionsService] activityStorageRecord does not exist'; - Logger.warn(error); - throw new Error(error); - } - if (!appletEncryption) { const error = '[ConstructCompletionsService] Encryption params is undefined'; @@ -201,6 +207,18 @@ export class ConstructCompletionsService { } } + private isRecordExist( + activityStorageRecord: ActivityState | null | undefined, + ): boolean { + if (!activityStorageRecord) { + Logger.warn( + '[ConstructCompletionsService] activityStorageRecord does not exist', + ); + return false; + } + return true; + } + private addSummaryData( activityStorageRecord: ActivityState, { activityName, activityId, order }: ConstructForIntermediateInput, @@ -259,9 +277,13 @@ export class ConstructCompletionsService { order, )!; + if (!this.isRecordExist(activityStorageRecord)) { + return; + } + const { appletEncryption, appletName } = this.getAppletProperties(appletId); - this.validate(activityStorageRecord, appletEncryption); + this.validateEncryption(appletEncryption); const { items, answers: recordAnswers, actions } = activityStorageRecord; @@ -299,16 +321,17 @@ export class ConstructCompletionsService { isAutocompletion, ); + const submitId = getExecutionGroupKey(progressRecord); + this.logIntermediate( input, flowName!, appletName, evaluatedEndAt, progressRecord.availableTo, + submitId, ); - const submitId = getExecutionGroupKey(progressRecord); - this.pushToQueueService.push({ appletId, createdAt: evaluatedEndAt, @@ -339,6 +362,8 @@ export class ConstructCompletionsService { [MixProperties.AppletId]: appletId, [MixProperties.SubmitId]: submitId, }); + + Logger.log(`[ConstructCompletionsService.constructForIntermediate] Done`); } private async constructForFinish( @@ -361,8 +386,6 @@ export class ConstructCompletionsService { const entityId = flowId ? flowId : activityId; - const { appletEncryption, appletName } = this.getAppletProperties(appletId); - const activityStorageRecord = getActivityRecord( appletId, activityId, @@ -370,7 +393,13 @@ export class ConstructCompletionsService { order, )!; - this.validate(activityStorageRecord, appletEncryption); + if (!this.isRecordExist(activityStorageRecord)) { + return; + } + + const { appletEncryption, appletName } = this.getAppletProperties(appletId); + + this.validateEncryption(appletEncryption); const { items, answers: recordAnswers, actions } = activityStorageRecord; @@ -406,7 +435,9 @@ export class ConstructCompletionsService { isAutocompletion, ); - this.logCompletion( + const submitId = getExecutionGroupKey(progressRecord); + + this.logFinish( activityName, activityId, flowId, @@ -414,12 +445,11 @@ export class ConstructCompletionsService { appletId, evaluatedEndAt, progressRecord.availableTo, + submitId, ); const { scheduledDate } = getFlowRecord(flowId, appletId, eventId)!; - const submitId = getExecutionGroupKey(progressRecord); - this.pushToQueueService.push({ appletId, createdAt: evaluatedEndAt, @@ -459,18 +489,26 @@ export class ConstructCompletionsService { [MixProperties.AppletId]: appletId, [MixProperties.SubmitId]: submitId, }); + + Logger.log(`[ConstructCompletionsService.constructForFinish] Done`); } public async construct(input: ConstructInput): Promise { - if (input.completionType === 'intermediate') { - await this.constructForIntermediate({ - ...input, - flowId: input.flowId!, - }); - } - - if (input.completionType === 'finish') { - await this.constructForFinish(input); + try { + if (input.completionType === 'intermediate') { + await this.constructForIntermediate({ + ...input, + flowId: input.flowId!, + }); + } + + if (input.completionType === 'finish') { + await this.constructForFinish(input); + } + } catch (error) { + Logger.warn( + `[ConstructCompletionsService.construct] Error occurred: \n${error}`, + ); } } } diff --git a/src/widgets/survey/model/services/InterimInActionPostponer.ts b/src/widgets/survey/model/services/InterimInActionPostponer.ts new file mode 100644 index 000000000..4f0d5611c --- /dev/null +++ b/src/widgets/survey/model/services/InterimInActionPostponer.ts @@ -0,0 +1,45 @@ +import { InterimSubmitMutex, Logger } from '@app/shared/lib'; + +const PostponeDuration = 2000; + +class InterimInActionPostponer { + private action: () => void; + + private timeoutId: NodeJS.Timeout | null; + + constructor(actionToPostpone: () => void) { + this.action = actionToPostpone; + this.timeoutId = null; + } + + private shouldBePostponed(): boolean { + return InterimSubmitMutex.isBusy(); + } + + private postpone() { + this.timeoutId = setTimeout(() => { + this.try(); + }, PostponeDuration); + } + + private try() { + if (this.shouldBePostponed()) { + Logger.log('[InterimInActionPostponer.try] Postponed'); + this.postpone(); + } else { + this.action(); + } + } + + public tryExecute() { + this.try(); + } + + public reset() { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + } +} + +export default InterimInActionPostponer; diff --git a/src/widgets/survey/model/services/tests/ConstructCompletionsService.test.ts b/src/widgets/survey/model/services/tests/ConstructCompletionsService.test.ts index 9594a7cc6..f1972e569 100644 --- a/src/widgets/survey/model/services/tests/ConstructCompletionsService.test.ts +++ b/src/widgets/survey/model/services/tests/ConstructCompletionsService.test.ts @@ -1,8 +1,10 @@ -import { addMilliseconds, subHours } from 'date-fns'; +import { QueryClient } from '@tanstack/react-query'; +import { addHours, addMilliseconds, subHours, subSeconds } from 'date-fns'; import { StoreProgress } from '@app/abstract/lib'; import { Answers, PipelineItem } from '@app/features/pass-survey'; import { getSliderItem } from '@app/features/pass-survey/model/tests/testHelpers'; +import { AppletEncryptionDTO } from '@app/shared/api'; import { createGetActivityRecordMock, @@ -462,70 +464,91 @@ describe('Test ConstructCompletionsService: edge cases', () => { ); }); - const tests = [ - { - activityStorageRecord: null, - appletEncryption: {}, - expectedError: - '[ConstructCompletionsService] activityStorageRecord does not exist', - }, - { - activityStorageRecord: undefined, - appletEncryption: {}, - expectedError: - '[ConstructCompletionsService] activityStorageRecord does not exist', - }, + const validateEncryptionTests = [ { - activityStorageRecord: {}, appletEncryption: null, expectedError: '[ConstructCompletionsService] Encryption params is undefined', }, { - activityStorageRecord: {}, appletEncryption: undefined, expectedError: '[ConstructCompletionsService] Encryption params is undefined', }, ]; - tests.forEach( - ({ activityStorageRecord, appletEncryption, expectedError }) => { - const activityStorageRecordAsText = activityStorageRecord - ? 'fullfilled' - : activityStorageRecord; - const appletEncryptionAsText = appletEncryption - ? 'fullfilled' - : appletEncryption; - - it(`"validate" should throw error when activityStorageRecord is ${activityStorageRecordAsText} and appletEncryption is ${appletEncryptionAsText}`, () => { - const progress: StoreProgress = getFlowProgressMock(); + validateEncryptionTests.forEach(({ appletEncryption, expectedError }) => { + const appletEncryptionAsText = appletEncryption + ? 'fullfilled' + : String(appletEncryption); - const { saveSummaryMock } = - mockConstructionServiceExternals(mockNowDate); + it(`"validateEncryption" should throw error when appletEncryption is ${appletEncryptionAsText}`, () => { + const progress: StoreProgress = getFlowProgressMock(); - const pushMock = jest.fn(); + const { saveSummaryMock } = mockConstructionServiceExternals(mockNowDate); - const pushToQueueMock = { push: pushMock }; + const pushMock = jest.fn(); - const service = new ConstructCompletionsService( - saveSummaryMock, - {} as any, - progress, - pushToQueueMock, - jest.fn(), - ); + const pushToQueueMock = { push: pushMock }; - expect(() => - //@ts-expect-error - service.validate( - activityStorageRecord as any, - appletEncryption as any, - ), - ).toThrow(new Error(expectedError)); - }); + const service = new ConstructCompletionsService( + saveSummaryMock, + {} as QueryClient, + progress, + pushToQueueMock, + jest.fn(), + ); + + expect(() => + // @ts-expect-error + service.validateEncryption(appletEncryption as AppletEncryptionDTO), + ).toThrow(new Error(expectedError)); + }); + }); + + const checkRecordTests = [ + { + activityStorageRecord: null, + expectedResult: false, }, - ); + { + activityStorageRecord: undefined, + expectedResult: false, + }, + { + activityStorageRecord: {}, + expectedResult: true, + }, + ]; + + checkRecordTests.forEach(({ activityStorageRecord, expectedResult }) => { + const activityStorageRecordAsText = activityStorageRecord + ? 'fullfilled' + : String(activityStorageRecord); + + it(`isRecordExist should return ${expectedResult} when activityStorageRecord is ${activityStorageRecordAsText}`, () => { + const progress: StoreProgress = getFlowProgressMock(); + + const { saveSummaryMock } = mockConstructionServiceExternals(mockNowDate); + + const pushMock = jest.fn(); + + const pushToQueueMock = { push: pushMock }; + + const service = new ConstructCompletionsService( + saveSummaryMock, + {} as QueryClient, + progress, + pushToQueueMock, + jest.fn(), + ); + + //@ts-expect-error + const result = service.isRecordExist(activityStorageRecord); + + expect(result).toEqual(expectedResult); + }); + }); }); describe('Test ConstructCompletionsService: evaluateEndAt', () => { @@ -557,8 +580,8 @@ describe('Test ConstructCompletionsService: evaluateEndAt', () => { availableTo: subHours(now, 1).getTime(), logAvailableTo: 'subHours(now, 1)', isAutocompletion: true, - expectedResult: subHours(now, 1).getTime(), - expectedResultLog: 'subHours(now, 1)', + expectedResult: subSeconds(subHours(now, 1), 1).getTime(), + expectedResultLog: 'subSeconds(subHours(now, 1), 1)', }, { completionType: 'finish', @@ -581,8 +604,27 @@ describe('Test ConstructCompletionsService: evaluateEndAt', () => { availableTo: subHours(now, 1).getTime(), logAvailableTo: 'subHours(now, 1)', isAutocompletion: true, - expectedResult: addMilliseconds(subHours(now, 1), 1).getTime(), - expectedResultLog: 'addMilliseconds(subHours(now, 1), 1)', + expectedResult: addMilliseconds( + subSeconds(subHours(now, 1), 1), + 1, + ).getTime(), + expectedResultLog: 'addMilliseconds(subSeconds(subHours(now, 1), 1), 1)', + }, + { + completionType: 'finish', + availableTo: addHours(now, 1).getTime(), + logAvailableTo: 'addHours(now, 1)', + isAutocompletion: true, + expectedResult: addMilliseconds(now, 1).getTime(), + expectedResultLog: 'addMilliseconds(now, 1)', + }, + { + completionType: 'intermediate', + availableTo: addHours(now, 1).getTime(), + logAvailableTo: 'addHours(now, 1)', + isAutocompletion: true, + expectedResult: now, + expectedResultLog: ' now', }, ]; @@ -605,7 +647,7 @@ describe('Test ConstructCompletionsService: evaluateEndAt', () => { const service = new ConstructCompletionsService( saveSummaryMock, - {} as any, + {} as QueryClient, progress, pushToQueueMock, jest.fn(), diff --git a/src/widgets/survey/model/services/tests/testHelpers.ts b/src/widgets/survey/model/services/tests/testHelpers.ts index 27f34d61b..1e70b5d7e 100644 --- a/src/widgets/survey/model/services/tests/testHelpers.ts +++ b/src/widgets/survey/model/services/tests/testHelpers.ts @@ -57,6 +57,7 @@ export const getSingleActivityFlowState = (path: EntityPath): FlowState => { step: 0, scheduledDate: null, isCompletedDueToTimer: false, + interruptionStep: null, context: {}, pipeline: [ { @@ -91,6 +92,7 @@ export const getMultipleActivityFlowState = (path: EntityPath): FlowState => { step: 0, scheduledDate: null, isCompletedDueToTimer: false, + interruptionStep: null, context: {}, pipeline: [ { diff --git a/src/widgets/survey/ui/Finish.tsx b/src/widgets/survey/ui/Finish.tsx index 4cdc07d01..3d063d0c8 100644 --- a/src/widgets/survey/ui/Finish.tsx +++ b/src/widgets/survey/ui/Finish.tsx @@ -10,9 +10,15 @@ import { } from '@app/entities/activity/lib'; import useQueueProcessing from '@app/entities/activity/lib/hooks/useQueueProcessing'; import { AppletModel } from '@entities/applet'; -import { UploadObservable, useAppDispatch, useAppSelector } from '@shared/lib'; +import { + Logger, + UploadObservable, + useAppDispatch, + useAppSelector, +} from '@shared/lib'; import { Center, ImageBackground, Text, Button } from '@shared/ui'; +import { useFlowStorageRecord } from '../'; import { FinishReason, useAutoCompletion } from '../model'; import { ConstructCompletionsService } from '../model/services/ConstructCompletionsService'; @@ -24,7 +30,7 @@ type Props = { flowId?: string; order: number; isTimerElapsed: boolean; - + interruptionStep: number | null; onClose: () => void; }; @@ -36,6 +42,7 @@ function FinishItem({ eventId, order, isTimerElapsed, + interruptionStep, onClose, }: Props) { const { t } = useTranslation(); @@ -48,6 +55,12 @@ function FinishItem({ AppletModel.selectors.selectInProgressApplets, ); + const { flowStorageRecord: flowState } = useFlowStorageRecord({ + appletId, + eventId, + flowId, + }); + const { isCompleted, isPostponed, @@ -64,6 +77,43 @@ function FinishItem({ const finishReason: FinishReason = isTimerElapsed ? 'time-is-up' : 'regular'; + const isCompletedAutomatically = finishReason === 'time-is-up'; + + const isFlow = !!flowId; + + async function completeInterruptedActivity( + constructCompletionService: ConstructCompletionsService, + ) { + Logger.log( + `[Finish.completeInterruptedActivity] interruptionStep=${interruptionStep}`, + ); + + const { + order: interruptedOrder, + activityId: interruptedActivityId, + activityName: interruptedActivityName, + } = flowState!.pipeline[interruptionStep!].payload; + + const isInterruptedActivityLast = interruptedOrder === order; + + Logger.log( + `[Finish.completeInterruptedActivity] Interrupted activityId=${interruptedActivityId}, name=${interruptedActivityName} order=${interruptedOrder}, isLast=${isInterruptedActivityLast}`, + ); + + if (!isInterruptedActivityLast) { + await constructCompletionService.construct({ + activityId: interruptedActivityId, + activityName: interruptedActivityName, + appletId, + eventId, + flowId, + order: interruptedOrder, + completionType: 'intermediate', + isAutocompletion: isCompletedAutomatically, + }); + } + } + async function completeActivity() { const constructCompletionService = new ConstructCompletionsService( null, @@ -73,6 +123,10 @@ function FinishItem({ dispatch, ); + if (isCompletedAutomatically && isFlow) { + await completeInterruptedActivity(constructCompletionService); + } + await constructCompletionService.construct({ activityId, activityName, @@ -81,7 +135,7 @@ function FinishItem({ flowId, order, completionType: 'finish', - isAutocompletion: false, + isAutocompletion: isCompletedAutomatically, }); const exclude: EntityPathParams = { diff --git a/src/widgets/survey/ui/FlowElementSwitch.tsx b/src/widgets/survey/ui/FlowElementSwitch.tsx index 381008952..b0ecadb63 100644 --- a/src/widgets/survey/ui/FlowElementSwitch.tsx +++ b/src/widgets/survey/ui/FlowElementSwitch.tsx @@ -26,6 +26,7 @@ type Props = { onComplete: (reason: 'regular' | 'idle') => void; event: ScheduleEvent; isTimerElapsed: boolean; + interruptionStep: number | null; entityStartedAt: number; } & FlowPipelineItem; @@ -37,6 +38,7 @@ function FlowElementSwitch({ onClose, onComplete, isTimerElapsed, + interruptionStep, entityStartedAt, }: Props) { const context = useMemo( @@ -111,6 +113,7 @@ function FlowElementSwitch({ ); diff --git a/src/widgets/survey/ui/FlowSurvey.tsx b/src/widgets/survey/ui/FlowSurvey.tsx index b15fa6059..1ea3fb18e 100644 --- a/src/widgets/survey/ui/FlowSurvey.tsx +++ b/src/widgets/survey/ui/FlowSurvey.tsx @@ -22,7 +22,7 @@ function FlowSurvey({ eventId, onClose, }: Props) { - const { step, pipeline, isTimerElapsed } = useFlowState({ + const { step, pipeline, isTimerElapsed, interruptionStep } = useFlowState({ appletId, eventId, flowId: entityType === 'flow' ? entityId : undefined, @@ -95,6 +95,7 @@ function FlowSurvey({ event={event} entityStartedAt={entityStartedAt} isTimerElapsed={isTimerElapsed} + interruptionStep={interruptionStep} onClose={closeFlow} onBack={back} onComplete={complete} diff --git a/src/widgets/survey/ui/Intermediate.tsx b/src/widgets/survey/ui/Intermediate.tsx index d41622806..968423422 100644 --- a/src/widgets/survey/ui/Intermediate.tsx +++ b/src/widgets/survey/ui/Intermediate.tsx @@ -11,6 +11,7 @@ import useQueueProcessing from '@app/entities/activity/lib/hooks/useQueueProcess import { AppletModel } from '@app/entities/applet'; import { QueryDataUtils } from '@app/shared/api'; import { + InterimSubmitMutex, Logger, UploadObservable, useAppDispatch, @@ -119,7 +120,7 @@ function Intermediate({ const appletName = getAppletName(); Logger.log( - `[Intermediate.completeActivity]: Activity "${activityName}|${activityId}" within flow "${flowName}|${flowId}" changed to next activity "${nextActivityPayload.activityName}|${nextActivityPayload.activityId}", applet "${appletName}|${appletId}"`, + `[Intermediate.changeActivity]: Activity "${activityName}|${activityId}" within flow "${flowName}|${flowId}" changed to next activity "${nextActivityPayload.activityName}|${nextActivityPayload.activityId}", applet "${appletName}|${appletId}"`, ); dispatch( @@ -153,6 +154,8 @@ function Intermediate({ ]); async function completeActivity() { + InterimSubmitMutex.setBusy(); + const constructCompletionService = new ConstructCompletionsService( saveActivitySummary, queryClient, @@ -191,6 +194,7 @@ function Intermediate({ useEffect(() => { UploadObservable.reset(); + return () => InterimSubmitMutex.release(); }, []); return (