diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03e9828a9..f5cd502f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,22 @@ jobs: ~/node_modules key: ${{ runner.os }}-node-modules-${{ hashFiles('**/yarn.lock') }} - - run: yarn install --frozen-lockfile + - name: Remove .yarnrc file to avoid conflicts + run: rm -rf .yarnrc + + - name: Enable Corepack + run: corepack enable + + - name: Prepare and activate Yarn version 1.22.22 + run: corepack prepare yarn@1.22.22 --activate + + - name: Verify Yarn version + run: yarn --version + + - name: Install dependencies with frozen lockfile + run: yarn install --frozen-lockfile - - uses: wearerequired/lint-action@v2 - with: - eslint: true - eslint_extensions: mjs,js,jsx,mts,ts,tsx - prettier: true - prettier_extensions: mjs,js,jsx,mts,ts,tsx - continue_on_error: false + - run: yarn eslint src tests: runs-on: ubuntu-latest steps: @@ -39,7 +46,19 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - - name: Install deps + - name: Remove .yarnrc file to avoid conflicts + run: rm -rf .yarnrc + + - name: Enable Corepack + run: corepack enable + + - name: Prepare and activate Yarn version 1.22.22 + run: corepack prepare yarn@1.22.22 --activate + + - name: Verify Yarn version + run: yarn --version + + - name: Install dependencies with frozen lockfile run: yarn install --frozen-lockfile - uses: actions/cache@v4 diff --git a/.gitignore b/.gitignore index 1be8c838a..bd639a4ca 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ coverage .env .env* !.env.example + + +.yarn \ No newline at end of file diff --git a/package.json b/package.json index a0cd6559d..21e576655 100644 --- a/package.json +++ b/package.json @@ -90,5 +90,6 @@ "vite-plugin-node-stdlib-browser": "^0.2.1", "vitest": "^1.2.0", "vitest-canvas-mock": "^0.3.3" - } + }, + "packageManager": "yarn@1.22.22+sha256.c17d3797fb9a9115bf375e31bfd30058cac6bc9c3b8807a3d8cb2094794b51ca" } diff --git a/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.test.ts b/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.test.ts index 73607c090..9ee45f07a 100644 --- a/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.test.ts +++ b/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.test.ts @@ -50,6 +50,7 @@ const getActivity = (): Entity => { order: 0, type: ActivityType.NotDefined, image: null, + autoAssign: true, }; return result; }; @@ -98,6 +99,7 @@ const getExpectedItem = (): ActivityListItem => { timeLeftToComplete: null, isInActivityFlow: false, image: null, + targetSubject: null, }; return expectedItem; }; @@ -152,6 +154,7 @@ const getScheduledEventEntity = (settings: { timer: null, }, }, + targetSubject: null, }; return result; }; @@ -175,6 +178,7 @@ const getAlwaysAvailableEventEntity = (settings: { scheduledAt: Date }): EventEn timer: null, }, }, + targetSubject: null, }; return result; @@ -903,8 +907,6 @@ describe('ActivityGroupsBuilder', () => { progress, }; - let builder = createActivityGroupsBuilder(input); - const eventEntity: EventEntity = getScheduledEventEntity({ scheduledAt, startDate: subDays(startOfDay(scheduledAt), 2), @@ -929,7 +931,7 @@ describe('ActivityGroupsBuilder', () => { progress, }; - builder = createActivityGroupsBuilder(input); + const builder = createActivityGroupsBuilder(input); const now = addMinutes(scheduledAt, 10); @@ -939,18 +941,9 @@ describe('ActivityGroupsBuilder', () => { expect(result).toEqual(expectedResult); }); - it('5Should not return group-item for scheduled event and periodicity is Weekly and allowAccessBeforeFromTime is false when started yesterday, but not completed yet', () => { + it('Should not return group-item for scheduled event and periodicity is Weekly and allowAccessBeforeFromTime is false when started yesterday, but not completed yet', () => { const scheduledAt = new Date(2023, 8, 1, 15, 0, 0); - let progress: GroupProgressState = getEmptyProgress(); - - let input: GroupsBuildContext = { - allAppletActivities: [], - progress, - }; - - let builder = createActivityGroupsBuilder(input); - const eventEntity: EventEntity = getScheduledEventEntity({ scheduledAt, startDate: subDays(startOfDay(scheduledAt), 2), @@ -968,14 +961,14 @@ describe('ActivityGroupsBuilder', () => { activities: [], }; - progress = getProgress(subDays(scheduledAt, 1), null); + const progress = getProgress(subDays(scheduledAt, 1), null); - input = { + const input = { allAppletActivities: [], progress, }; - builder = createActivityGroupsBuilder(input); + const builder = createActivityGroupsBuilder(input); const now = addMinutes(scheduledAt, 10); @@ -1108,8 +1101,6 @@ describe('ActivityGroupsBuilder', () => { eventEntity.event.availability.startDate = subMonths(now, 3); eventEntity.event.availability.endDate = subMonths(now, 2); - let result = builder.buildAvailable([eventEntity]); - const expectedItem = getExpectedAvailableItem(); expectedItem.availableTo = new Date(startOfDay(scheduledAt)); expectedItem.availableTo.setHours(16); @@ -1121,7 +1112,7 @@ describe('ActivityGroupsBuilder', () => { activities: [], }; - result = builder.buildAvailable([eventEntity]); + const result = builder.buildAvailable([eventEntity]); expect(result).toEqual(expectedEmptyResult); }); @@ -1154,8 +1145,6 @@ describe('ActivityGroupsBuilder', () => { eventEntity.event.availability.startDate = subMonths(now, 2); eventEntity.event.availability.endDate = addMonths(now, 2); - let result = builder.buildAvailable([eventEntity]); - const expectedItem = getExpectedAvailableItem(); expectedItem.availableTo = new Date(startOfDay(scheduledAt)); expectedItem.availableTo.setHours(16); @@ -1170,7 +1159,7 @@ describe('ActivityGroupsBuilder', () => { activities: [expectedItem], }; - result = builder.buildAvailable([eventEntity]); + const result = builder.buildAvailable([eventEntity]); expect(result).toEqual(expectedResult); }); @@ -1375,8 +1364,6 @@ describe('ActivityGroupsBuilder', () => { eventEntity.event.availability.startDate = addMonths(now, 2); eventEntity.event.availability.endDate = addMonths(now, 3); - let result = builder.buildScheduled([eventEntity]); - const expectedItem: ActivityListItem = getExpectedScheduledItem(); expectedItem.availableFrom = startOfDay(scheduledAt); expectedItem.availableFrom.setHours(15); @@ -1390,7 +1377,7 @@ describe('ActivityGroupsBuilder', () => { activities: [expectedItem], }; - result = builder.buildScheduled([eventEntity]); + const result = builder.buildScheduled([eventEntity]); expect(result).toEqual(expectedEmptyResult); }); @@ -1424,8 +1411,6 @@ describe('ActivityGroupsBuilder', () => { eventEntity.event.availability.startDate = subMonths(now, 3); eventEntity.event.availability.endDate = subMonths(now, 2); - let result = builder.buildScheduled([eventEntity]); - const expectedItem: ActivityListItem = getExpectedScheduledItem(); expectedItem.availableFrom = startOfDay(scheduledAt); expectedItem.availableFrom.setHours(15); @@ -1439,7 +1424,7 @@ describe('ActivityGroupsBuilder', () => { activities: [expectedItem], }; - result = builder.buildScheduled([eventEntity]); + const result = builder.buildScheduled([eventEntity]); expect(result).toEqual(expectedEmptyResult); }); @@ -1471,8 +1456,6 @@ describe('ActivityGroupsBuilder', () => { eventEntity.event.availability.timeFrom = { hours: 15, minutes: 0 }; eventEntity.event.availability.timeTo = { hours: 16, minutes: 30 }; - let result = builder.buildScheduled([eventEntity]); - const expectedItem: ActivityListItem = getExpectedScheduledItem(); expectedItem.availableFrom = startOfDay(scheduledAt); expectedItem.availableFrom.setHours(15); @@ -1500,7 +1483,7 @@ describe('ActivityGroupsBuilder', () => { eventEntity.event.availability.startDate = subMonths(now, 2); eventEntity.event.availability.endDate = addMonths(now, 2); - result = builder.buildScheduled([eventEntity]); + const result = builder.buildScheduled([eventEntity]); expect(result).toEqual(expectedResult); }); @@ -1611,6 +1594,7 @@ describe('ActivityGroupsBuilder', () => { type: ActivityType.NotDefined, order: 0, image: null, + autoAssign: true, }, { description: 'test-description-2', @@ -1621,6 +1605,7 @@ describe('ActivityGroupsBuilder', () => { type: ActivityType.NotDefined, order: 1, image: null, + autoAssign: true, }, ], progress, @@ -1638,6 +1623,7 @@ describe('ActivityGroupsBuilder', () => { isHidden: false, order: 0, image: null, + autoAssign: true, }; const eventEntity: EventEntity = { @@ -1656,6 +1642,7 @@ describe('ActivityGroupsBuilder', () => { timer: null, }, }, + targetSubject: null, }; let result = builder.buildInProgress([eventEntity]); @@ -1684,6 +1671,7 @@ describe('ActivityGroupsBuilder', () => { numberOfActivitiesInFlow: 2, activityPositionInFlow: 1, }, + targetSubject: null, }, ], name: 'additional.in_progress', @@ -1725,6 +1713,7 @@ describe('ActivityGroupsBuilder', () => { numberOfActivitiesInFlow: 2, activityPositionInFlow: 2, }, + targetSubject: null, }, ], name: 'additional.in_progress', diff --git a/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.ts b/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.ts index 414c80889..0b088718b 100644 --- a/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.ts +++ b/src/abstract/lib/GroupBuilder/ActivityGroupsBuilder.ts @@ -32,13 +32,13 @@ export class ActivityGroupsBuilder implements IActivityGroupsBuilder { this.utility = new GroupUtility(inputParams); } - public buildInProgress(eventsActivities: Array): ActivityListGroup { - const filtered = eventsActivities.filter((x) => this.utility.isInProgress(x)); + public buildInProgress(eventEntities: Array): ActivityListGroup { + const filtered = eventEntities.filter((x) => this.utility.isInProgress(x)); const activityItems: Array = []; - for (const eventActivity of filtered) { - const item = this.itemsFactory.createProgressItem(eventActivity); + for (const eventEntity of filtered) { + const item = this.itemsFactory.createProgressItem(eventEntity); activityItems.push(item); } @@ -52,13 +52,13 @@ export class ActivityGroupsBuilder implements IActivityGroupsBuilder { return result; } - public buildAvailable(eventsEntities: Array): ActivityListGroup { - const filtered = this.availableEvaluator.evaluate(eventsEntities); + public buildAvailable(eventEntities: Array): ActivityListGroup { + const filtered = this.availableEvaluator.evaluate(eventEntities); const activityItems: Array = []; - for (const eventActivity of filtered) { - const item = this.itemsFactory.createAvailableItem(eventActivity); + for (const eventEntity of filtered) { + const item = this.itemsFactory.createAvailableItem(eventEntity); activityItems.push(item); } @@ -72,13 +72,13 @@ export class ActivityGroupsBuilder implements IActivityGroupsBuilder { return result; } - public buildScheduled(eventsEntities: Array): ActivityListGroup { - const filtered = this.scheduledEvaluator.evaluate(eventsEntities); + public buildScheduled(eventEntities: Array): ActivityListGroup { + const filtered = this.scheduledEvaluator.evaluate(eventEntities); const activityItems: Array = []; - for (const eventActivity of filtered) { - const item = this.itemsFactory.createScheduledItem(eventActivity); + for (const eventEntity of filtered) { + const item = this.itemsFactory.createScheduledItem(eventEntity); activityItems.push(item); } diff --git a/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts b/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts index a132db70a..e38b81cf1 100644 --- a/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts +++ b/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts @@ -44,6 +44,7 @@ const getActivity = (): Entity => { order: 0, type: ActivityType.NotDefined, image: null, + autoAssign: true, }; return result; }; @@ -104,6 +105,7 @@ const getScheduledEventEntity = (settings: { timer: null, }, }, + targetSubject: null, }; return result; diff --git a/src/abstract/lib/GroupBuilder/GroupUtility.ts b/src/abstract/lib/GroupBuilder/GroupUtility.ts index 8455cb37d..6f651cf57 100644 --- a/src/abstract/lib/GroupBuilder/GroupUtility.ts +++ b/src/abstract/lib/GroupBuilder/GroupUtility.ts @@ -45,11 +45,11 @@ export class GroupUtility { } private getAllowedTimeInterval( - eventActivity: EventEntity, + eventEntity: EventEntity, scheduledWhen: 'today' | 'yesterday', isAccessBeforeStartTime = false, ): DatesFromTo { - const { event } = eventActivity; + const { event } = eventEntity; if (event.availability.timeFrom === null) { throw new Error('[getAllowedTimeInterval] timeFrom is null'); @@ -117,19 +117,22 @@ export class GroupUtility { return isEqual(this.getYesterday(), startOfDay(date)); } - public getProgressRecord(eventActivity: EventEntity): GroupProgress | null { - const record = this.progress[getProgressId(eventActivity.entity.id, eventActivity.event.id)]; + public getProgressRecord(eventEntity: EventEntity): GroupProgress | null { + const record = + this.progress[ + getProgressId(eventEntity.entity.id, eventEntity.event.id, eventEntity.targetSubject?.id) + ]; return record ?? null; } - public getCompletedAt(eventActivity: EventEntity): Date | null { - const progressRecord = this.getProgressRecord(eventActivity); + public getCompletedAt(eventEntity: EventEntity): Date | null { + const progressRecord = this.getProgressRecord(eventEntity); return progressRecord?.endAt ? new Date(progressRecord.endAt) : null; } - public isInProgress(eventActivity: EventEntity): boolean { - const record = this.getProgressRecord(eventActivity); + public isInProgress(eventEntity: EventEntity): boolean { + const record = this.getProgressRecord(eventEntity); if (!record) { return false; } @@ -210,17 +213,17 @@ export class GroupUtility { } public isCompletedInAllowedTimeInterval( - eventActivity: EventEntity, + eventEntity: EventEntity, scheduledWhen: 'today' | 'yesterday', isAccessBeforeStartTime = false, ): boolean { const { from: allowedFrom, to: allowedTo } = this.getAllowedTimeInterval( - eventActivity, + eventEntity, scheduledWhen, isAccessBeforeStartTime, ); - const completedAt = this.getCompletedAt(eventActivity); + const completedAt = this.getCompletedAt(eventEntity); if (!completedAt) { return false; @@ -276,19 +279,19 @@ export class GroupUtility { return false; } - public isCompletedToday(eventActivity: EventEntity): boolean { - const date = this.getCompletedAt(eventActivity); + public isCompletedToday(eventEntity: EventEntity): boolean { + const date = this.getCompletedAt(eventEntity); return !!date && this.isToday(date); } public isInAllowedTimeInterval( - eventActivity: EventEntity, + eventEntity: EventEntity, scheduledWhen: 'today' | 'yesterday', isAccessBeforeStartTime = false, ): boolean { const { from: allowedFrom, to: allowedTo } = this.getAllowedTimeInterval( - eventActivity, + eventEntity, scheduledWhen, isAccessBeforeStartTime, ); @@ -302,15 +305,15 @@ export class GroupUtility { } } - public getTimeToComplete(eventActivity: EventEntity): HourMinute | null { - const { event } = eventActivity; + public getTimeToComplete(eventEntity: EventEntity): HourMinute | null { + const { event } = eventEntity; const timer = event.timers.timer; if (timer === null) { throw new Error('[getTimeToComplete] Timer is null'); } - const startedTime = this.getStartedAt(eventActivity); + const startedTime = this.getStartedAt(eventEntity); const activityDuration: number = getMsFromHours(timer.hours) + getMsFromMinutes(timer.minutes); diff --git a/src/abstract/lib/GroupBuilder/ListItemsFactory.ts b/src/abstract/lib/GroupBuilder/ListItemsFactory.ts index 7338e241b..0fa5142fa 100644 --- a/src/abstract/lib/GroupBuilder/ListItemsFactory.ts +++ b/src/abstract/lib/GroupBuilder/ListItemsFactory.ts @@ -52,15 +52,16 @@ export class ListItemsFactory { item.image = activity.image; } - private createListItem(eventActivity: EventEntity) { - const { entity, event } = eventActivity; - const { pipelineType } = eventActivity.entity; + private createListItem(eventEntity: EventEntity) { + const { entity, event, targetSubject } = eventEntity; + const { pipelineType } = eventEntity.entity; const isFlow = pipelineType === ActivityPipelineType.Flow; const item: ActivityListItem = { activityId: isFlow ? '' : entity.id, flowId: isFlow ? entity.id : null, eventId: event.id, + targetSubject, name: isFlow ? '' : entity.name, description: isFlow ? '' : entity.description, type: isFlow ? ActivityType.NotDefined : (entity as Activity).type, @@ -76,17 +77,17 @@ export class ListItemsFactory { }; if (isFlow) { - this.populateActivityFlowFields(item, eventActivity); + this.populateActivityFlowFields(item, eventEntity); } return item; } - public createAvailableItem(eventActivity: EventEntity): ActivityListItem { - const item = this.createListItem(eventActivity); + public createAvailableItem(eventEntity: EventEntity): ActivityListItem { + const item = this.createListItem(eventEntity); item.status = ActivityStatus.Available; - const { event } = eventActivity; + const { event } = eventEntity; if (event.availability.availabilityType === AvailabilityLabelType.ScheduledAccess) { const isSpread = this.utility.isSpreadToNextDay(event); @@ -116,12 +117,12 @@ export class ListItemsFactory { return item; } - public createScheduledItem(eventActivity: EventEntity): ActivityListItem { - const item = this.createListItem(eventActivity); + public createScheduledItem(eventEntity: EventEntity): ActivityListItem { + const item = this.createListItem(eventEntity); item.status = ActivityStatus.Scheduled; - const { event } = eventActivity; + const { event } = eventEntity; const from = this.utility.getNow(); @@ -153,12 +154,12 @@ export class ListItemsFactory { return item; } - public createProgressItem(eventActivity: EventEntity): ActivityListItem { - const item = this.createListItem(eventActivity); + public createProgressItem(eventEntity: EventEntity): ActivityListItem { + const item = this.createListItem(eventEntity); item.status = ActivityStatus.InProgress; - const { event } = eventActivity; + const { event } = eventEntity; if (event.availability.availabilityType === AvailabilityLabelType.ScheduledAccess) { const isSpread = this.utility.isSpreadToNextDay(event); @@ -181,7 +182,7 @@ export class ListItemsFactory { item.isTimerSet = !!event.timers?.timer; if (item.isTimerSet) { - const timeLeft = this.utility.getTimeToComplete(eventActivity); + const timeLeft = this.utility.getTimeToComplete(eventEntity); item.timeLeftToComplete = timeLeft; if (timeLeft === null) { diff --git a/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts b/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts index 322cb17c7..9ca0fc2cf 100644 --- a/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts +++ b/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts @@ -43,6 +43,7 @@ const getActivity = (): Entity => { order: 0, type: ActivityType.NotDefined, image: null, + autoAssign: true, }; return result; }; @@ -103,6 +104,7 @@ const getScheduledEventEntity = (settings: { timer: null, }, }, + targetSubject: null, }; return result; diff --git a/src/abstract/lib/GroupBuilder/activityGroups.types.ts b/src/abstract/lib/GroupBuilder/activityGroups.types.ts index 61b6d295c..943fc4133 100644 --- a/src/abstract/lib/GroupBuilder/activityGroups.types.ts +++ b/src/abstract/lib/GroupBuilder/activityGroups.types.ts @@ -1,6 +1,7 @@ import { ActivityPipelineType } from '~/abstract/lib'; import { ActivityType } from '~/abstract/lib/GroupBuilder'; import { ScheduleEvent } from '~/entities/event'; +import { SubjectDTO } from '~/shared/api/types/subject'; export type EntityBase = { id: string; @@ -9,6 +10,7 @@ export type EntityBase = { image: string | null; isHidden: boolean; order: number; + autoAssign: boolean; }; export type Activity = EntityBase & { @@ -27,6 +29,8 @@ export type Entity = Activity | ActivityFlow; export type EventEntity = { entity: Entity; event: ScheduleEvent; + /** Target subject of assignment if not self-report, else null */ + targetSubject: SubjectDTO | null; }; export type EntityType = 'regular' | 'flow'; diff --git a/src/abstract/lib/GroupBuilder/types.ts b/src/abstract/lib/GroupBuilder/types.ts index 45ecd0c4b..8551a473c 100644 --- a/src/abstract/lib/GroupBuilder/types.ts +++ b/src/abstract/lib/GroupBuilder/types.ts @@ -1,10 +1,12 @@ import { AvailabilityLabelType } from '~/entities/event'; +import { SubjectDTO } from '~/shared/api/types/subject'; import { HourMinute } from '~/shared/utils'; export type ActivityListItem = { activityId: string; flowId: string | null; eventId: string; + targetSubject: SubjectDTO | null; name: string; description: string; diff --git a/src/abstract/lib/getProgressId.ts b/src/abstract/lib/getProgressId.ts index 7fe5c6ea9..f05acd2c2 100644 --- a/src/abstract/lib/getProgressId.ts +++ b/src/abstract/lib/getProgressId.ts @@ -1,11 +1,17 @@ -export const getProgressId = (entityId: string, eventId: string): string => { - return `${entityId}/${eventId}`; +import { GroupProgressId } from './types'; + +export const getProgressId = ( + entityId: string, + eventId: string, + targetSubjectId?: string | null, +): GroupProgressId => { + return targetSubjectId ? `${entityId}/${eventId}/${targetSubjectId}` : `${entityId}/${eventId}`; }; export const getDataFromProgressId = ( - progressId: string, -): { entityId: string; eventId: string } => { - const [entityId, eventId] = progressId.split('/'); + progressId: GroupProgressId, +): { entityId: string; eventId: string; targetSubjectId: string | null } => { + const [entityId, eventId, targetSubjectId = null] = progressId.split('/'); - return { entityId, eventId }; + return { entityId, eventId, targetSubjectId }; }; diff --git a/src/abstract/lib/types/entityProgress.ts b/src/abstract/lib/types/entityProgress.ts index 58634ba22..3a0554d6e 100644 --- a/src/abstract/lib/types/entityProgress.ts +++ b/src/abstract/lib/types/entityProgress.ts @@ -55,6 +55,12 @@ type EventProgressTimestampState = { export type GroupProgress = ActivityOrFlowProgress & EventProgressTimestampState; -type GroupProgressId = string; // Group progress id is a combination of entityId and eventId (EntityId = ActivityId or FlowId) +/** + * Combination of: + * - entityId (= activityId/flowId), + * - eventId + * - targetSubjectId (optional; only if not self-report) + */ +export type GroupProgressId = `${string}/${string}` | `${string}/${string}/${string}`; export type GroupProgressState = Record; diff --git a/src/app/providers/theme-provider.tsx b/src/app/providers/theme-provider.tsx index 8d213d3ba..fdd338fe5 100644 --- a/src/app/providers/theme-provider.tsx +++ b/src/app/providers/theme-provider.tsx @@ -38,13 +38,10 @@ const theme = createTheme({ }), }), '.MuiAlert-action': { + marginLeft: 0, marginRight: 0, paddingTop: 0, alignItems: 'center', - '.MuiIconButton-root': { - padding: theme.spacing(1), - margin: theme.spacing(0.4), - }, }, '.MuiAlert-icon': { marginLeft: 'auto', @@ -52,6 +49,7 @@ const theme = createTheme({ '.MuiAlert-message': { padding: 0, maxWidth: theme.spacing(80), + marginRight: 'auto', }, a: { textDecoration: 'underline', diff --git a/src/assets/subject-icon.svg b/src/assets/subject-icon.svg new file mode 100644 index 000000000..fc37943d3 --- /dev/null +++ b/src/assets/subject-icon.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/entities/applet/model/hooks/useActivityProgress.ts b/src/entities/applet/model/hooks/useActivityProgress.ts index 1ae971a67..3735f0883 100644 --- a/src/entities/applet/model/hooks/useActivityProgress.ts +++ b/src/entities/applet/model/hooks/useActivityProgress.ts @@ -12,11 +12,13 @@ import { useAppDispatch, useAppSelector } from '~/shared/utils'; type SaveProgressProps = { activity: ActivityDTO; eventId: string; + targetSubjectId: string | null; }; type DefaultProps = { activityId: string; eventId: string; + targetSubjectId: string | null; }; export const useActivityProgress = () => { @@ -26,7 +28,10 @@ export const useActivityProgress = () => { const getActivityProgress = useCallback( (props: DefaultProps): ActivityProgress | null => { - const progress = activitiesProgressState[getProgressId(props.activityId, props.eventId)]; + const progress = + activitiesProgressState[ + getProgressId(props.activityId, props.eventId, props.targetSubjectId) + ]; return progress ?? null; }, @@ -61,6 +66,7 @@ export const useActivityProgress = () => { actions.saveActivityProgress({ activityId: props.activity.id, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, progress: { items: preparedActivityItemProgressRecords, step: initialStep, @@ -81,6 +87,7 @@ export const useActivityProgress = () => { actions.changeSummaryScreenVisibility({ activityId: props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, isSummaryScreenOpen: true, }), ); @@ -94,6 +101,7 @@ export const useActivityProgress = () => { actions.changeSummaryScreenVisibility({ activityId: props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, isSummaryScreenOpen: false, }), ); diff --git a/src/entities/applet/model/hooks/useEntityComplete.ts b/src/entities/applet/model/hooks/useEntityComplete.ts index cd33dd01d..175ac6227 100644 --- a/src/entities/applet/model/hooks/useEntityComplete.ts +++ b/src/entities/applet/model/hooks/useEntityComplete.ts @@ -13,6 +13,7 @@ type CompletionType = 'regular' | 'autoCompletion'; type Props = { activityId: string; eventId: string; + targetSubjectId: string | null; flowId: string | null; publicAppletKey: string | null; @@ -40,6 +41,7 @@ export const useEntityComplete = (props: Props) => { entityCompleted({ entityId: props.flowId ? props.flowId : props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, }); const isAutoCompletion = completionType === 'autoCompletion'; @@ -73,6 +75,7 @@ export const useEntityComplete = (props: Props) => { props.eventId, props.flowId, props.publicAppletKey, + props.targetSubjectId, ], ); @@ -97,13 +100,21 @@ export const useEntityComplete = (props: Props) => { appletId: props.appletId, activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, entityType: 'flow', flowId: props.flowId, }), { replace: true }, ); }, - [navigator, props.appletId, props.eventId, props.flowId, props.publicAppletKey], + [ + navigator, + props.appletId, + props.eventId, + props.flowId, + props.publicAppletKey, + props.targetSubjectId, + ], ); const completeFlow = useCallback( @@ -113,6 +124,7 @@ export const useEntityComplete = (props: Props) => { const groupProgress = getGroupProgress({ entityId: props.flowId ? props.flowId : props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, }); if (!groupProgress) { @@ -137,10 +149,15 @@ export const useEntityComplete = (props: Props) => { activityId: nextActivityId ? nextActivityId : props.flow.activityIds[0], flowId: props.flow.id, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, pipelineActivityOrder: nextActivityId ? currentPipelineActivityOrder + 1 : 0, }); - removeActivityProgress({ activityId: props.activityId, eventId: props.eventId }); + removeActivityProgress({ + activityId: props.activityId, + eventId: props.eventId, + targetSubjectId: props.targetSubjectId, + }); if (nextActivityId && !isAutoCompletion) { return redirectToNextActivity(nextActivityId); @@ -158,6 +175,7 @@ export const useEntityComplete = (props: Props) => { props.eventId, props.flow, props.flowId, + props.targetSubjectId, redirectToNextActivity, removeActivityProgress, ], @@ -165,11 +183,21 @@ export const useEntityComplete = (props: Props) => { const completeActivity = useCallback( (input?: CompleteOptions) => { - removeActivityProgress({ activityId: props.activityId, eventId: props.eventId }); + removeActivityProgress({ + activityId: props.activityId, + eventId: props.eventId, + targetSubjectId: props.targetSubjectId, + }); return completeEntityAndRedirect(input?.type || 'regular'); }, - [completeEntityAndRedirect, props.activityId, props.eventId, removeActivityProgress], + [ + completeEntityAndRedirect, + props.activityId, + props.eventId, + props.targetSubjectId, + removeActivityProgress, + ], ); return { diff --git a/src/entities/applet/model/hooks/useEntityStart.ts b/src/entities/applet/model/hooks/useEntityStart.ts index d4f6902a0..ea6f9d8cc 100644 --- a/src/entities/applet/model/hooks/useEntityStart.ts +++ b/src/entities/applet/model/hooks/useEntityStart.ts @@ -9,16 +9,24 @@ export const useEntityStart = () => { const dispatch = useAppDispatch(); const groupProgress = useAppSelector(groupProgressSelector); - const getProgress = (entityId: string, eventId: string): GroupProgress => - groupProgress[getProgressId(entityId, eventId)]; + const getProgress = ( + entityId: string, + eventId: string, + targetSubjectId: string | null, + ): GroupProgress => groupProgress[getProgressId(entityId, eventId, targetSubjectId)]; const isInProgress = (payload: GroupProgress): boolean => payload && !payload.endAt; - function activityStarted(activityId: string, eventId: string): void { + function activityStarted( + activityId: string, + eventId: string, + targetSubjectId: string | null, + ): void { dispatch( actions.activityStarted({ activityId, eventId, + targetSubjectId, }), ); } @@ -27,6 +35,7 @@ export const useEntityStart = () => { flowId: string, activityId: string, eventId: string, + targetSubjectId: string | null, pipelineActivityOrder: number, ): void { dispatch( @@ -34,25 +43,30 @@ export const useEntityStart = () => { flowId, activityId, eventId, + targetSubjectId, pipelineActivityOrder, }), ); } - function startActivity(activityId: string, eventId: string): void { - const isActivityInProgress = isInProgress(getProgress(activityId, eventId)); + function startActivity( + activityId: string, + eventId: string, + targetSubjectId: string | null, + ): void { + const isActivityInProgress = isInProgress(getProgress(activityId, eventId, targetSubjectId)); if (isActivityInProgress) { return; } - return activityStarted(activityId, eventId); + return activityStarted(activityId, eventId, targetSubjectId); } - function startFlow(eventId: string, flow: ActivityFlowDTO): void { + function startFlow(eventId: string, flow: ActivityFlowDTO, targetSubjectId: string | null): void { const flowId = flow.id; - const isFlowInProgress = isInProgress(getProgress(flowId, eventId)); + const isFlowInProgress = isInProgress(getProgress(flowId, eventId, targetSubjectId)); if (isFlowInProgress) { return; @@ -68,7 +82,7 @@ export const useEntityStart = () => { const firstActivityId: string = flowActivities[0]; - return flowStarted(flowId, firstActivityId, eventId, 0); + return flowStarted(flowId, firstActivityId, eventId, targetSubjectId, 0); } return { startActivity, startFlow }; diff --git a/src/entities/applet/model/hooks/useGroupProgressRecord.ts b/src/entities/applet/model/hooks/useGroupProgressRecord.ts index 7a76bbddf..aabdf99c6 100644 --- a/src/entities/applet/model/hooks/useGroupProgressRecord.ts +++ b/src/entities/applet/model/hooks/useGroupProgressRecord.ts @@ -6,11 +6,16 @@ import { useAppSelector } from '~/shared/utils'; type Props = { entityId: string; eventId: string; + targetSubjectId: string | null; }; -export const useGroupProgressRecord = ({ entityId, eventId }: Props): GroupProgress | null => { +export const useGroupProgressRecord = ({ + entityId, + eventId, + targetSubjectId, +}: Props): GroupProgress | null => { const record = useAppSelector((state) => - selectGroupProgress(state, getProgressId(entityId, eventId)), + selectGroupProgress(state, getProgressId(entityId, eventId, targetSubjectId)), ); return record ?? null; diff --git a/src/entities/applet/model/hooks/useGroupProgressStateManager.ts b/src/entities/applet/model/hooks/useGroupProgressStateManager.ts index 86c186f3f..49f5e30fa 100644 --- a/src/entities/applet/model/hooks/useGroupProgressStateManager.ts +++ b/src/entities/applet/model/hooks/useGroupProgressStateManager.ts @@ -33,7 +33,10 @@ export const useGroupProgressStateManager = (): Return => { return null; } - return groupProgresses[getProgressId(params.entityId, params.eventId)] ?? null; + return ( + groupProgresses[getProgressId(params.entityId, params.eventId, params.targetSubjectId)] ?? + null + ); }, [groupProgresses], ); diff --git a/src/entities/applet/model/hooks/useItemTimerState.ts b/src/entities/applet/model/hooks/useItemTimerState.ts index ed2677572..8e1c788dc 100644 --- a/src/entities/applet/model/hooks/useItemTimerState.ts +++ b/src/entities/applet/model/hooks/useItemTimerState.ts @@ -10,6 +10,7 @@ import { useAppDispatch, useAppSelector } from '~/shared/utils'; type Props = { activityId: string; eventId: string; + targetSubjectId: string | null; itemId: string; }; @@ -24,11 +25,16 @@ type InitializeTimerProps = { duration: number; }; -export const useItemTimerState = ({ activityId, eventId, itemId }: Props): Return => { +export const useItemTimerState = ({ + activityId, + eventId, + targetSubjectId, + itemId, +}: Props): Return => { const dispatch = useAppDispatch(); const timerSettingsState = useAppSelector((state) => - selectActivityProgress(state, getProgressId(activityId, eventId)), + selectActivityProgress(state, getProgressId(activityId, eventId, targetSubjectId)), ); const timerSettings = timerSettingsState?.itemTimer[itemId] ?? null; @@ -39,6 +45,7 @@ export const useItemTimerState = ({ activityId, eventId, itemId }: Props): Retur actions.setItemTimerStatus({ activityId, eventId, + targetSubjectId, itemId, timerStatus: { duration, @@ -47,7 +54,7 @@ export const useItemTimerState = ({ activityId, eventId, itemId }: Props): Retur }), ); }, - [activityId, dispatch, eventId, itemId], + [activityId, dispatch, eventId, itemId, targetSubjectId], ); const removeTimer = useCallback(() => { @@ -55,20 +62,22 @@ export const useItemTimerState = ({ activityId, eventId, itemId }: Props): Retur actions.removeItemTimerStatus({ activityId, eventId, + targetSubjectId, itemId, }), ); - }, [activityId, dispatch, eventId, itemId]); + }, [activityId, dispatch, eventId, itemId, targetSubjectId]); const timerTick = useCallback(() => { return dispatch( actions.itemTimerTick({ activityId, eventId, + targetSubjectId, itemId, }), ); - }, [activityId, dispatch, eventId, itemId]); + }, [activityId, dispatch, eventId, itemId, targetSubjectId]); return { timerSettings, diff --git a/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts b/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts index 2de0447b0..f29096e53 100644 --- a/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts +++ b/src/entities/applet/model/hooks/useSaveActivityItemAnswer.ts @@ -8,9 +8,10 @@ import { useAppDispatch } from '~/shared/utils'; type Props = { activityId: string; eventId: string; + targetSubjectId: string | null; }; -export const useSaveItemAnswer = ({ activityId, eventId }: Props) => { +export const useSaveItemAnswer = ({ activityId, eventId, targetSubjectId }: Props) => { const dispatch = useAppDispatch(); const saveItemAnswer = useCallback( @@ -19,12 +20,13 @@ export const useSaveItemAnswer = ({ activityId, eventId }: Props) => { actions.saveItemAnswer({ entityId: activityId, eventId, + targetSubjectId, itemId, answer, }), ); }, - [dispatch, activityId, eventId], + [dispatch, activityId, eventId, targetSubjectId], ); const saveItemAdditionalText = useCallback( @@ -33,12 +35,13 @@ export const useSaveItemAnswer = ({ activityId, eventId }: Props) => { actions.saveAdditionalText({ entityId: activityId, eventId, + targetSubjectId, itemId, additionalText, }), ); }, - [dispatch, activityId, eventId], + [dispatch, activityId, eventId, targetSubjectId], ); const removeItemAnswer = useCallback( diff --git a/src/entities/applet/model/hooks/useUserEvents.ts b/src/entities/applet/model/hooks/useUserEvents.ts index 4190e7347..5603c58bf 100644 --- a/src/entities/applet/model/hooks/useUserEvents.ts +++ b/src/entities/applet/model/hooks/useUserEvents.ts @@ -12,12 +12,13 @@ import { useAppDispatch, useAppSelector } from '~/shared/utils'; type Props = { activityId: string; eventId: string; + targetSubjectId: string | null; }; export const useUserEvents = (props: Props) => { const dispatch = useAppDispatch(); - const progressId = getProgressId(props.activityId, props.eventId); + const progressId = getProgressId(props.activityId, props.eventId, props.targetSubjectId); const activityProgress = useAppSelector((state) => selectActivityProgress(state, progressId)); @@ -35,6 +36,7 @@ export const useUserEvents = (props: Props) => { actions.saveUserEvent({ entityId: props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, itemId: item.id, userEvent: newUserEvent, }), @@ -42,7 +44,7 @@ export const useUserEvents = (props: Props) => { return newUserEvent; }, - [dispatch, props.activityId, props.eventId], + [dispatch, props.activityId, props.eventId, props.targetSubjectId], ); const saveSetAnswerUserEvent = useCallback( @@ -65,6 +67,7 @@ export const useUserEvents = (props: Props) => { actions.updateUserEventByIndex({ entityId: props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, userEventIndex: userEvents.length - 1, userEvent: { type: 'SET_ANSWER', @@ -83,6 +86,7 @@ export const useUserEvents = (props: Props) => { actions.saveUserEvent({ entityId: props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, itemId: item.id, userEvent: { type: 'SET_ANSWER', @@ -93,7 +97,7 @@ export const useUserEvents = (props: Props) => { }), ); }, - [activityProgress, dispatch, props.activityId, props.eventId], + [activityProgress, dispatch, props.activityId, props.eventId, props.targetSubjectId], ); const saveSetAdditionalTextUserEvent = useCallback( @@ -128,6 +132,7 @@ export const useUserEvents = (props: Props) => { actions.updateUserEventByIndex({ entityId: props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, userEventIndex: userEvents.length - 1, userEvent: { type: 'SET_ANSWER', @@ -148,6 +153,7 @@ export const useUserEvents = (props: Props) => { actions.saveUserEvent({ entityId: props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, itemId: item.id, userEvent: { type: 'SET_ANSWER', @@ -158,7 +164,7 @@ export const useUserEvents = (props: Props) => { }), ); }, - [activityProgress, dispatch, props.activityId, props.eventId], + [activityProgress, dispatch, props.activityId, props.eventId, props.targetSubjectId], ); return { saveUserEventByType, saveSetAnswerUserEvent, saveSetAdditionalTextUserEvent }; diff --git a/src/entities/applet/model/selectors.ts b/src/entities/applet/model/selectors.ts index a533a55f2..6b6717ea2 100644 --- a/src/entities/applet/model/selectors.ts +++ b/src/entities/applet/model/selectors.ts @@ -1,8 +1,9 @@ import { createSelector } from '@reduxjs/toolkit'; +import { GroupProgressId } from '~/abstract/lib'; import { RootState } from '~/shared/utils'; -const selectEntityId = (_: unknown, entityId: string) => entityId; +const selectGroupProgressId = (_: unknown, groupProgressId: GroupProgressId) => groupProgressId; export const appletsSelector = (state: RootState) => state.applets; @@ -12,8 +13,8 @@ export const groupProgressSelector = createSelector( ); export const selectGroupProgress = createSelector( - [groupProgressSelector, selectEntityId], - (groupProgress, entityId) => groupProgress[entityId], + [groupProgressSelector, selectGroupProgressId], + (groupProgress, groupProgressId) => groupProgress[groupProgressId], ); export const activityProgressSelector = createSelector( @@ -22,7 +23,7 @@ export const activityProgressSelector = createSelector( ); export const selectActivityProgress = createSelector( - [activityProgressSelector, selectEntityId], + [activityProgressSelector, selectGroupProgressId], (progress, entityId) => progress[entityId], ); diff --git a/src/entities/applet/model/slice.ts b/src/entities/applet/model/slice.ts index 81fe1c67c..d9acf721b 100644 --- a/src/entities/applet/model/slice.ts +++ b/src/entities/applet/model/slice.ts @@ -60,60 +60,63 @@ const appletsSlice = createSlice({ return initialState; }, - removeActivityProgress: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + removeActivityProgress: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); delete state.progress[id]; }, - saveGroupProgress: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + saveGroupProgress: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const groupProgress = state.groupProgress[id] ?? {}; const updatedProgress = { ...groupProgress, - ...action.payload.progressPayload, + ...payload.progressPayload, }; state.groupProgress[id] = updatedProgress; }, - removeGroupProgress: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.entityId, action.payload.eventId); + removeGroupProgress: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.entityId, payload.eventId, payload.targetSubjectId); delete state.groupProgress[id]; }, - saveSummaryDataInGroupContext: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.entityId, action.payload.eventId); + saveSummaryDataInGroupContext: ( + state, + { payload }: PayloadAction, + ) => { + const id = getProgressId(payload.entityId, payload.eventId, payload.targetSubjectId); const groupProgress = state.groupProgress[id] ?? {}; const groupContext = groupProgress.context ?? {}; - groupContext.summaryData[action.payload.activityId] = action.payload.summaryData; + groupContext.summaryData[payload.activityId] = payload.summaryData; }, - saveActivityProgress: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + saveActivityProgress: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); - state.progress[id] = action.payload.progress; + state.progress[id] = payload.progress; }, changeSummaryScreenVisibility: ( state, - action: PayloadAction, + { payload }: PayloadAction, ) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const activityProgress = state.progress[id]; - activityProgress.isSummaryScreenOpen = action.payload.isSummaryScreenOpen; + activityProgress.isSummaryScreenOpen = payload.isSummaryScreenOpen; }, - setItemTimerStatus: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + setItemTimerStatus: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const progress = state.progress[id]; @@ -121,31 +124,31 @@ const appletsSlice = createSlice({ progress.itemTimer = {}; } - progress.itemTimer[action.payload.itemId] = action.payload.timerStatus; + progress.itemTimer[payload.itemId] = payload.timerStatus; }, - removeItemTimerStatus: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + removeItemTimerStatus: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const progress = state.progress[id]; if (!progress) return state; - const timer = progress.itemTimer[action.payload.itemId]; + const timer = progress.itemTimer[payload.itemId]; if (timer) { - delete progress.itemTimer[action.payload.itemId]; + delete progress.itemTimer[payload.itemId]; } }, - itemTimerTick: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + itemTimerTick: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const progress = state.progress[id]; if (!progress) return state; - const timer = progress.itemTimer[action.payload.itemId]; + const timer = progress.itemTimer[payload.itemId]; if (!timer) return state; @@ -154,21 +157,21 @@ const appletsSlice = createSlice({ } }, - saveItemAnswer: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.entityId, action.payload.eventId); + saveItemAnswer: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.entityId, payload.eventId, payload.targetSubjectId); const activityProgress = state.progress[id]; if (!activityProgress) { return state; } - const itemIndex = activityProgress.items.findIndex(({ id }) => id === action.payload.itemId); + const itemIndex = activityProgress.items.findIndex(({ id }) => id === payload.itemId); if (itemIndex === -1) { return state; } - activityProgress.items[itemIndex].answer = action.payload.answer; + activityProgress.items[itemIndex].answer = payload.answer; }, /** @@ -177,66 +180,66 @@ const appletsSlice = createSlice({ * @param action * @returns */ - saveAdditionalText: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.entityId, action.payload.eventId); + saveAdditionalText: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.entityId, payload.eventId, payload.targetSubjectId); const activityProgress = state.progress[id]; if (!activityProgress) { return state; } - const itemIndex = activityProgress.items.findIndex(({ id }) => id === action.payload.itemId); + const itemIndex = activityProgress.items.findIndex(({ id }) => id === payload.itemId); if (itemIndex === -1) { return state; } - activityProgress.items[itemIndex].additionalText = action.payload.additionalText; + activityProgress.items[itemIndex].additionalText = payload.additionalText; }, - saveUserEvent: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.entityId, action.payload.eventId); + saveUserEvent: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.entityId, payload.eventId, payload.targetSubjectId); const activityProgress = state.progress[id]; if (!activityProgress) { return state; } - activityProgress.userEvents.push(action.payload.userEvent); + activityProgress.userEvents.push(payload.userEvent); }, - updateUserEventByIndex: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.entityId, action.payload.eventId); + updateUserEventByIndex: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.entityId, payload.eventId, payload.targetSubjectId); const activityProgress = state.progress[id]; if (!activityProgress) { return state; } - if (!activityProgress.userEvents[action.payload.userEventIndex]) { + if (!activityProgress.userEvents[payload.userEventIndex]) { return state; } - activityProgress.userEvents[action.payload.userEventIndex] = action.payload.userEvent; + activityProgress.userEvents[payload.userEventIndex] = payload.userEvent; }, - incrementStep: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + incrementStep: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const activityProgress = state.progress[id]; activityProgress.step += 1; }, - decrementStep: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + decrementStep: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const activityProgress = state.progress[id]; activityProgress.step -= 1; }, - activityStarted: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.activityId, action.payload.eventId); + activityStarted: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.activityId, payload.eventId, payload.targetSubjectId); const activityEvent: GroupProgress = { type: ActivityPipelineType.Regular, @@ -250,17 +253,17 @@ const appletsSlice = createSlice({ state.groupProgress[id] = activityEvent; }, - flowStarted: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.flowId, action.payload.eventId); + flowStarted: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.flowId, payload.eventId, payload.targetSubjectId); const flowEvent: GroupProgress = { type: ActivityPipelineType.Flow, - currentActivityId: action.payload.activityId, + currentActivityId: payload.activityId, startAt: new Date().getTime(), currentActivityStartAt: new Date().getTime(), endAt: null, executionGroupKey: uuidV4(), - pipelineActivityOrder: action.payload.pipelineActivityOrder, + pipelineActivityOrder: payload.pipelineActivityOrder, context: { summaryData: {}, }, @@ -269,18 +272,18 @@ const appletsSlice = createSlice({ state.groupProgress[id] = flowEvent; }, - flowUpdated: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.flowId, action.payload.eventId); + flowUpdated: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.flowId, payload.eventId, payload.targetSubjectId); const groupProgress = state.groupProgress[id] as FlowProgress; - groupProgress.currentActivityId = action.payload.activityId; - groupProgress.pipelineActivityOrder = action.payload.pipelineActivityOrder; + groupProgress.currentActivityId = payload.activityId; + groupProgress.pipelineActivityOrder = payload.pipelineActivityOrder; groupProgress.currentActivityStartAt = new Date().getTime(); }, - entityCompleted: (state, action: PayloadAction) => { - const id = getProgressId(action.payload.entityId, action.payload.eventId); + entityCompleted: (state, { payload }: PayloadAction) => { + const id = getProgressId(payload.entityId, payload.eventId, payload.targetSubjectId); state.groupProgress[id].endAt = new Date().getTime(); @@ -290,18 +293,18 @@ const appletsSlice = createSlice({ const now = new Date().getTime(); - completedEntities[action.payload.entityId] = now; + completedEntities[payload.entityId] = now; - if (!completions[action.payload.entityId]) { - completions[action.payload.entityId] = {}; + if (!completions[payload.entityId]) { + completions[payload.entityId] = {}; } - const entityCompletions = completions[action.payload.entityId]; + const entityCompletions = completions[payload.entityId]; - if (!entityCompletions[action.payload.eventId]) { - entityCompletions[action.payload.eventId] = []; + if (!entityCompletions[payload.eventId]) { + entityCompletions[payload.eventId] = []; } - entityCompletions[action.payload.eventId].push(now); + entityCompletions[payload.eventId].push(now); }, initiateTakeNow: (state, action: PayloadAction) => { diff --git a/src/entities/applet/model/types.ts b/src/entities/applet/model/types.ts index 17222a2e5..395d96bb6 100644 --- a/src/entities/applet/model/types.ts +++ b/src/entities/applet/model/types.ts @@ -118,18 +118,21 @@ export type ProgressState = Record; export type SaveActivityProgressPayload = { activityId: string; eventId: string; + targetSubjectId: string | null; progress: ActivityProgress; }; export type ChangeSummaryScreenVisibilityPayload = { activityId: string; eventId: string; + targetSubjectId: string | null; isSummaryScreenOpen: boolean; }; export type SetItemTimerPayload = { activityId: string; eventId: string; + targetSubjectId: string | null; itemId: string; timerStatus: ItemTimerProgress; }; @@ -137,28 +140,33 @@ export type SetItemTimerPayload = { export type ItemTimerTickPayload = { activityId: string; eventId: string; + targetSubjectId: string | null; itemId: string; }; export type RemoveActivityProgressPayload = { activityId: string; eventId: string; + targetSubjectId: string | null; }; export type RemoveGroupProgressPayload = { entityId: string; eventId: string; + targetSubjectId: string | null; }; export type SaveGroupProgressPayload = { activityId: string; eventId: string; + targetSubjectId: string | null; progressPayload: GroupProgress; }; export type SaveSummaryDataInContext = { entityId: string; eventId: string; + targetSubjectId: string | null; activityId: string; summaryData: FlowSummaryData; @@ -167,6 +175,7 @@ export type SaveSummaryDataInContext = { export type SaveItemAnswerPayload = { entityId: string; eventId: string; + targetSubjectId: string | null; itemId: string; answer: Answer; }; @@ -174,6 +183,7 @@ export type SaveItemAnswerPayload = { export type SaveItemAdditionalTextPayload = { entityId: string; eventId: string; + targetSubjectId: string | null; itemId: string; additionalText: string; }; @@ -181,11 +191,13 @@ export type SaveItemAdditionalTextPayload = { export type UpdateStepPayload = { activityId: string; eventId: string; + targetSubjectId: string | null; }; export type SaveUserEventPayload = { entityId: string; eventId: string; + targetSubjectId: string | null; itemId: string; userEvent: UserEvent; }; @@ -193,6 +205,7 @@ export type SaveUserEventPayload = { export type UpdateUserEventByIndexPayload = { entityId: string; eventId: string; + targetSubjectId: string | null; userEventIndex: number; userEvent: UserEvent; }; @@ -208,17 +221,20 @@ export type CompletedEventEntities = Record; export type InProgressEntity = { entityId: string; eventId: string; + targetSubjectId: string | null; }; export type InProgressActivity = { activityId: string; eventId: string; + targetSubjectId: string | null; }; export type InProgressFlow = { flowId: string; activityId: string; eventId: string; + targetSubjectId: string | null; pipelineActivityOrder: number; }; diff --git a/src/entities/assignment/api/index.ts b/src/entities/assignment/api/index.ts new file mode 100644 index 000000000..54d136bb6 --- /dev/null +++ b/src/entities/assignment/api/index.ts @@ -0,0 +1 @@ +export * from './useMyAssignmentsQuery'; diff --git a/src/entities/assignment/api/useMyAssignmentsQuery.ts b/src/entities/assignment/api/useMyAssignmentsQuery.ts new file mode 100644 index 000000000..e0116cf77 --- /dev/null +++ b/src/entities/assignment/api/useMyAssignmentsQuery.ts @@ -0,0 +1,15 @@ +import { assignmentService, QueryOptions, ReturnAwaited, useBaseQuery } from '~/shared/api'; + +type FetchFn = typeof assignmentService.getMyAssignments; +type Options = QueryOptions; + +export const useMyAssignmentsQuery = >( + appletId?: string, + options?: Options, +) => { + return useBaseQuery( + ['myAssignments', { appletId }], + () => assignmentService.getMyAssignments({ appletId: String(appletId) }), + options, + ); +}; diff --git a/src/entities/assignment/index.ts b/src/entities/assignment/index.ts new file mode 100644 index 000000000..b1c13e734 --- /dev/null +++ b/src/entities/assignment/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/src/entities/subject/api/useSubjectQuery.ts b/src/entities/subject/api/useSubjectQuery.ts index dda864f24..63d0d8db5 100644 --- a/src/entities/subject/api/useSubjectQuery.ts +++ b/src/entities/subject/api/useSubjectQuery.ts @@ -4,12 +4,12 @@ type FetchFn = typeof subjectService.getSubjectById; type Options = QueryOptions; export const useSubjectQuery = >( - subjectId: string, + subjectId: string | null, options?: Options, ) => { return useBaseQuery( ['subjectDetails', { subjectId }], - () => subjectService.getSubjectById({ subjectId }), + () => subjectService.getSubjectById({ subjectId: String(subjectId) }), options, ); }; diff --git a/src/features/AutoCompletion/model/hooks/useAutoCompletionRecord.ts b/src/features/AutoCompletion/model/hooks/useAutoCompletionRecord.ts index c81d3a0d3..0eba10f6d 100644 --- a/src/features/AutoCompletion/model/hooks/useAutoCompletionRecord.ts +++ b/src/features/AutoCompletion/model/hooks/useAutoCompletionRecord.ts @@ -7,11 +7,16 @@ import { useAppSelector } from '~/shared/utils'; type Props = { entityId: string; eventId: string; + targetSubjectId: string | null; }; -export const useAutoCompletionRecord = ({ entityId, eventId }: Props): AutoCompletion | null => { +export const useAutoCompletionRecord = ({ + entityId, + eventId, + targetSubjectId, +}: Props): AutoCompletion | null => { const state = useAppSelector((state) => - selectAutoCompletionRecord(state, getProgressId(entityId, eventId)), + selectAutoCompletionRecord(state, getProgressId(entityId, eventId, targetSubjectId)), ); return state ?? null; diff --git a/src/features/AutoCompletion/model/hooks/useAutoCompletionStateManager.ts b/src/features/AutoCompletion/model/hooks/useAutoCompletionStateManager.ts index 3c1301bfb..0adbe0414 100644 --- a/src/features/AutoCompletion/model/hooks/useAutoCompletionStateManager.ts +++ b/src/features/AutoCompletion/model/hooks/useAutoCompletionStateManager.ts @@ -7,6 +7,7 @@ import { useAppDispatch } from '~/shared/utils'; export type ActivitySuccessfullySubmitted = { entityId: string; eventId: string; + targetSubjectId: string | null; activityId: string; }; @@ -26,7 +27,7 @@ export const useAutoCompletionStateManager = () => { ); const removeAutoCompletion = useCallback( - (payload: { entityId: string; eventId: string }) => { + (payload: { entityId: string; eventId: string; targetSubjectId: string | null }) => { dispatch(actions.removeAutoCompletion(payload)); }, [dispatch], diff --git a/src/features/AutoCompletion/model/slice.ts b/src/features/AutoCompletion/model/slice.ts index 123d19c82..7beb700f8 100644 --- a/src/features/AutoCompletion/model/slice.ts +++ b/src/features/AutoCompletion/model/slice.ts @@ -5,6 +5,7 @@ import { getProgressId } from '~/abstract/lib'; type DefaultProps = { entityId: string; eventId: string; + targetSubjectId: string | null; }; export type SetAutoCompletionPayload = DefaultProps & { @@ -32,8 +33,8 @@ const slice = createSlice({ }, setAutoCompletion(state, action: PayloadAction) { - const { entityId, eventId, autoCompletion } = action.payload; - const progressId = getProgressId(entityId, eventId); + const { entityId, eventId, targetSubjectId, autoCompletion } = action.payload; + const progressId = getProgressId(entityId, eventId, targetSubjectId); const record = state[progressId]; @@ -43,8 +44,8 @@ const slice = createSlice({ }, removeAutoCompletion(state, action: PayloadAction) { - const { entityId, eventId } = action.payload; - const progressId = getProgressId(entityId, eventId); + const { entityId, eventId, targetSubjectId } = action.payload; + const progressId = getProgressId(entityId, eventId, targetSubjectId); delete state[progressId]; }, @@ -53,8 +54,8 @@ const slice = createSlice({ state, action: PayloadAction, ) { - const { entityId, eventId, activityId } = action.payload; - const progressId = getProgressId(entityId, eventId); + const { entityId, eventId, activityId, targetSubjectId } = action.payload; + const progressId = getProgressId(entityId, eventId, targetSubjectId); state[progressId].successfullySubmittedActivityIds.push(activityId); }, diff --git a/src/features/PassSurvey/hooks/useAnswers.ts b/src/features/PassSurvey/hooks/useAnswers.ts index 355b8ff28..46cbb68f0 100644 --- a/src/features/PassSurvey/hooks/useAnswers.ts +++ b/src/features/PassSurvey/hooks/useAnswers.ts @@ -31,6 +31,7 @@ export const useAnswerBuilder = (): AnswerBuilder => { const groupProgress = appletModel.hooks.useGroupProgressRecord({ entityId: context.entityId, eventId: context.eventId, + targetSubjectId: context.targetSubject?.id ?? null, }); const { getMultiInformantState, isInMultiInformantFlow } = @@ -73,21 +74,26 @@ export const useAnswerBuilder = (): AnswerBuilder => { const multiInformantState = getMultiInformantState(); if (isInMultiInformantFlow()) { + // Take Now flow answer.sourceSubjectId = multiInformantState?.sourceSubject?.id; answer.targetSubjectId = multiInformantState?.targetSubject?.id; + } else if (context.targetSubject) { + // Activity assignment + answer.targetSubjectId = context.targetSubject.id; } return answer; }, [ groupProgress, + context.encryption, context.event, context.appletId, context.appletVersion, context.flow, - context.encryption, context.publicAppletKey, context.integrations, + context.targetSubject, getMultiInformantState, isInMultiInformantFlow, ], diff --git a/src/features/PassSurvey/hooks/useItemTimer.ts b/src/features/PassSurvey/hooks/useItemTimer.ts index 369208b47..4742b70e9 100644 --- a/src/features/PassSurvey/hooks/useItemTimer.ts +++ b/src/features/PassSurvey/hooks/useItemTimer.ts @@ -11,6 +11,7 @@ const ONE_SEC = 1000; type Props = { activityId: string; eventId: string; + targetSubjectId: string | null; item: appletModel.ItemRecord; isSubmitModalOpen: boolean; onTimerEnd: () => void; @@ -27,6 +28,7 @@ export const useItemTimer = ({ onTimerEnd, activityId, eventId, + targetSubjectId, isSubmitModalOpen, }: Props): TimerSettings => { const prevItem = usePrevious(item); @@ -35,6 +37,7 @@ export const useItemTimer = ({ appletModel.hooks.useItemTimerState({ activityId, eventId, + targetSubjectId, itemId: item.id, }); diff --git a/src/features/PassSurvey/hooks/useStartSurvey.ts b/src/features/PassSurvey/hooks/useStartSurvey.ts index 66332f929..be8747547 100644 --- a/src/features/PassSurvey/hooks/useStartSurvey.ts +++ b/src/features/PassSurvey/hooks/useStartSurvey.ts @@ -13,6 +13,7 @@ import { type NavigateToEntityProps = { flowId: string | null; activityId: string; + targetSubjectId: string | null; entityType: EntityType; eventId: string; }; @@ -20,6 +21,7 @@ type NavigateToEntityProps = { type OnActivityCardClickProps = { activityId: string; eventId: string; + targetSubjectId: string | null; flowId: string | null; status: ActivityStatus; shouldRestart: boolean; @@ -45,7 +47,7 @@ export const useStartSurvey = (props: Props) => { appletModel.hooks.useMultiInformantState(); function navigateToEntity(params: NavigateToEntityProps) { - const { activityId, flowId, eventId, entityType } = params; + const { activityId, flowId, eventId, targetSubjectId, entityType } = params; if (props.isPublic && props.publicAppletKey) { return navigator.navigate( @@ -65,13 +67,20 @@ export const useStartSurvey = (props: Props) => { appletId, activityId, eventId, + targetSubjectId, entityType, flowId, }), ); } - function startSurvey({ activityId, flowId, eventId, shouldRestart }: OnActivityCardClickProps) { + function startSurvey({ + activityId, + flowId, + eventId, + targetSubjectId, + shouldRestart, + }: OnActivityCardClickProps) { const analyticsPayload: MixpanelPayload = { [MixpanelProps.AppletId]: props.applet.id, [MixpanelProps.ActivityId]: activityId, @@ -103,8 +112,8 @@ export const useStartSurvey = (props: Props) => { } if (shouldRestart) { - removeActivityProgress({ activityId, eventId }); - removeGroupProgress({ entityId: flowId, eventId }); + removeActivityProgress({ activityId, eventId, targetSubjectId }); + removeGroupProgress({ entityId: flowId, eventId, targetSubjectId }); } const activityIdToNavigate = shouldRestart ? firstActivityId : activityId; @@ -114,12 +123,13 @@ export const useStartSurvey = (props: Props) => { entityType: 'flow', eventId, flowId, + targetSubjectId, }); } if (shouldRestart) { - removeActivityProgress({ activityId, eventId }); - removeGroupProgress({ entityId: activityId, eventId }); + removeActivityProgress({ activityId, eventId, targetSubjectId }); + removeGroupProgress({ entityId: activityId, eventId, targetSubjectId }); } return navigateToEntity({ @@ -127,6 +137,7 @@ export const useStartSurvey = (props: Props) => { entityType: 'regular', eventId, flowId: null, + targetSubjectId, }); } diff --git a/src/features/PassSurvey/hooks/useSummaryData.ts b/src/features/PassSurvey/hooks/useSummaryData.ts index f97c6a47f..9d211563c 100644 --- a/src/features/PassSurvey/hooks/useSummaryData.ts +++ b/src/features/PassSurvey/hooks/useSummaryData.ts @@ -12,6 +12,7 @@ type Props = { activityName: string; eventId: string; flowId: string | null; + targetSubjectId: string | null; scoresAndReports?: ScoreAndReports; }; @@ -36,9 +37,10 @@ export const useSummaryData = (props: Props) => { const groupProgress = appletModel.hooks.useGroupProgressRecord({ entityId: props.flowId ? props.flowId : props.activityId, eventId: props.eventId, + targetSubjectId: props.targetSubjectId, }); - const progressId = getProgressId(props.activityId, props.eventId); + const progressId = getProgressId(props.activityId, props.eventId, props.targetSubjectId); const activityProgress = useAppSelector((state) => appletModel.selectors.selectActivityProgress(state, progressId), diff --git a/src/features/PassSurvey/hooks/useSurveyDataQuery.ts b/src/features/PassSurvey/hooks/useSurveyDataQuery.ts index 58c96b494..325deff4f 100644 --- a/src/features/PassSurvey/hooks/useSurveyDataQuery.ts +++ b/src/features/PassSurvey/hooks/useSurveyDataQuery.ts @@ -1,5 +1,8 @@ +import { UseQueryResult } from '@tanstack/react-query'; + import { useActivityByIdQuery } from '~/entities/activity'; import { useAppletByIdQuery } from '~/entities/applet'; +import { useMyAssignmentsQuery } from '~/entities/assignment'; import { useEventsbyAppletIdQuery } from '~/entities/event'; import { ActivityDTO, @@ -8,12 +11,15 @@ import { BaseError, RespondentMetaDTO, } from '~/shared/api'; +import { SubjectDTO } from '~/shared/api/types/subject'; +import { useFeatureFlags } from '~/shared/utils'; type Return = { appletDTO: AppletDTO | null; respondentMeta?: RespondentMetaDTO; activityDTO: ActivityDTO | null; eventsDTO: AppletEventsResponse | null; + targetSubject: SubjectDTO | null; isError: boolean; isLoading: boolean; error: BaseError | null; @@ -23,10 +29,14 @@ type Props = { publicAppletKey: string | null; appletId: string; activityId: string; + targetSubjectId: string | null; }; export const useSurveyDataQuery = (props: Props): Return => { - const { appletId, activityId, publicAppletKey } = props; + const { appletId, activityId, publicAppletKey, targetSubjectId } = props; + const { featureFlags } = useFeatureFlags(); + const isAssignmentsEnabled = + !!featureFlags?.enableActivityAssign && !!appletId && !!targetSubjectId; const { data: appletById, @@ -35,7 +45,7 @@ export const useSurveyDataQuery = (props: Props): Return => { error: appletError, } = useAppletByIdQuery( publicAppletKey ? { isPublic: true, publicAppletKey } : { isPublic: false, appletId }, - { select: (data) => data?.data }, + { select: ({ data }) => data }, ); const { @@ -45,7 +55,7 @@ export const useSurveyDataQuery = (props: Props): Return => { error: activityError, } = useActivityByIdQuery( { isPublic: !!publicAppletKey, activityId }, - { select: (data) => data?.data?.result }, + { select: ({ data }) => data.result }, ); const { @@ -55,16 +65,36 @@ export const useSurveyDataQuery = (props: Props): Return => { error: eventsError, } = useEventsbyAppletIdQuery( publicAppletKey ? { isPublic: true, publicAppletKey } : { isPublic: false, appletId }, - { select: (data) => data?.data?.result }, + { select: ({ data }) => data.result }, + ); + + // Details of targetSubject are only guaranteed available from /users/me/assignments endpoint + // (Unprivileged users do not have access to the /subjects endpoint directly) + const assignmentsResult = useMyAssignmentsQuery( + isAssignmentsEnabled ? props.appletId : undefined, + { + select: ({ data }) => + data.result.assignments.find(({ targetSubject: { id } }) => id === targetSubjectId) + ?.targetSubject, + enabled: isAssignmentsEnabled, + }, ); + const { + data: targetSubject, + isError: isSubjectError, + isLoading: isSubjectLoading, + error: subjectError, + } = isAssignmentsEnabled ? assignmentsResult : ({} as UseQueryResult); + return { appletDTO: appletById?.result ?? null, respondentMeta: appletById?.respondentMeta, activityDTO: activityById ?? null, eventsDTO: eventsByIdData ?? null, - isError: isAppletError || isActivityError || isEventsError, - isLoading: isAppletLoading || isActivityLoading || isEventsLoading, - error: appletError ?? activityError ?? eventsError, + targetSubject: targetSubject ?? null, + isError: isAppletError || isActivityError || isEventsError || isSubjectError, + isLoading: isAppletLoading || isActivityLoading || isEventsLoading || isSubjectLoading, + error: appletError ?? activityError ?? eventsError ?? subjectError, }; }; diff --git a/src/features/PassSurvey/lib/SurveyContext.ts b/src/features/PassSurvey/lib/SurveyContext.ts index 5b72041bb..9511d3a71 100644 --- a/src/features/PassSurvey/lib/SurveyContext.ts +++ b/src/features/PassSurvey/lib/SurveyContext.ts @@ -7,6 +7,7 @@ import { RespondentMetaDTO, ScheduleEventDto, } from '~/shared/api'; +import { SubjectDTO } from '~/shared/api/types/subject'; export type SurveyContext = { appletId: string; @@ -15,6 +16,7 @@ export type SurveyContext = { activityId: string; eventId: string; + targetSubject: SubjectDTO | null; entityId: string; diff --git a/src/features/PassSurvey/lib/mappers.ts b/src/features/PassSurvey/lib/mappers.ts index 4fe869b74..e598f6295 100644 --- a/src/features/PassSurvey/lib/mappers.ts +++ b/src/features/PassSurvey/lib/mappers.ts @@ -7,12 +7,14 @@ import { AppletEventsResponse, RespondentMetaDTO, } from '~/shared/api'; +import { SubjectDTO } from '~/shared/api/types/subject'; type Props = { appletDTO: AppletDTO | null; eventsDTO: AppletEventsResponse | null; respondentMeta?: RespondentMetaDTO; activityDTO: ActivityDTO | null; + targetSubject: SubjectDTO | null; currentEventId: string; flowId: string | null; @@ -20,7 +22,7 @@ type Props = { }; export const mapRawDataToSurveyContext = (props: Props): SurveyContext => { - const { appletDTO, eventsDTO, activityDTO, currentEventId, flowId } = props; + const { appletDTO, eventsDTO, activityDTO, currentEventId, flowId, targetSubject } = props; if (!appletDTO || !eventsDTO || !activityDTO) { throw new Error('[MapRawDataToSurveyContext] Missing required data'); @@ -51,6 +53,7 @@ export const mapRawDataToSurveyContext = (props: Props): SurveyContext => { activity: activityDTO, event, + targetSubject, respondentMeta: props.respondentMeta, encryption: appletDTO.encryption, diff --git a/src/features/PassSurvey/model/ConditionalLogicBuilder.ts b/src/features/PassSurvey/model/ConditionalLogicFilter.ts similarity index 100% rename from src/features/PassSurvey/model/ConditionalLogicBuilder.ts rename to src/features/PassSurvey/model/ConditionalLogicFilter.ts diff --git a/src/features/PassSurvey/model/ConditionalLogicValidator.test.ts b/src/features/PassSurvey/model/ConditionalLogicValidator.test.ts new file mode 100644 index 000000000..9df97bc8b --- /dev/null +++ b/src/features/PassSurvey/model/ConditionalLogicValidator.test.ts @@ -0,0 +1,2658 @@ +import { ConditionalLogicValidator } from './ConditionalLogicValidator'; + +import { + CheckboxItem, + DateItem, + RadioItem, + SelectorItem, + SliderItem, + TimeItem, + TimeRangeItem, + SliderRowsItem, + SingleSelectionRowsItem, + MultiSelectionRowsItem, +} from '~/entities/activity'; +import { + EqualToOptionCondition, + NotEqualToOptionCondition, + IncludesOptionCondition, + NotIncludesOptionCondition, + EqualCondition, + NotEqualCondition, + GreaterThanCondition, + LessThanCondition, + BetweenCondition, + OutsideOfCondition, + EqualToDateCondition, + NotEqualToDateCondition, + GreaterThanDateCondition, + LessThanDateCondition, + BetweenDatesCondition, + OutsideOfDatesCondition, + GreaterThanTimeCondition, + LessThanTimeCondition, + EqualToTimeCondition, + NotEqualToTimeCondition, + BetweenTimesCondition, + OutsideOfTimesCondition, + GreaterThanTimeRangeCondition, + LessThanTimeRangeCondition, + EqualToTimeRangeCondition, + NotEqualToTimeRangeCondition, + BetweenTimeRangeCondition, + OutsideOfTimeRangeCondition, + GreaterThanSliderRowsCondition, + LessThanSliderRowsCondition, + EqualToSliderRowsCondition, + NotEqualToSliderRowsCondition, + BetweenSliderRowsCondition, + OutsideOfSliderRowsCondition, + EqualToRowOptionCondition, + NotEqualToRowOptionCondition, + IncludesRowOptionCondition, + NotIncludesRowOptionCondition, +} from '~/shared/api'; + +describe('ConditionalLogicValidator', () => { + it('should validate EQUAL_TO_OPTION correctly -> the answers match', () => { + const optionValue = 'option-value-1'; + + const condition: EqualToOptionCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'singleSelect', + answer: [optionValue], + } as RadioItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_OPTION correctly -> the answers don`t match', () => { + const optionValue = 'option-value-1'; + + const condition: EqualToOptionCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'singleSelect', + answer: ['wrong-answer'], + } as RadioItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_OPTION correctly -> the answers don`t match', () => { + const optionValue = 'option-value-1'; + + const condition: NotEqualToOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'singleSelect', + answer: ['wrong-answer'], + } as RadioItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_OPTION correctly -> the answers match', () => { + const optionValue = 'option-value-1'; + + const condition: NotEqualToOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'singleSelect', + answer: [optionValue], + } as RadioItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate INCLUDES_OPTION correctly for multiSelect -> the answers includes', () => { + const optionValue = 'option-value-1'; + + const condition: IncludesOptionCondition = { + itemName: 'random-item-name', + type: 'INCLUDES_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'multiSelect', + answer: [optionValue], + } as CheckboxItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate INCLUDES_OPTION correctly for multiSelect -> the answers don`t includes', () => { + const optionValue = 'option-value-1'; + + const condition: IncludesOptionCondition = { + itemName: 'random-item-name', + type: 'INCLUDES_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'multiSelect', + answer: ['wrong-answer'], + } as CheckboxItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_INCLUDES_OPTION correctly for multiSelect -> the answers don`t includes', () => { + const optionValue = 'option-value-1'; + + const condition: NotIncludesOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_INCLUDES_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'multiSelect', + answer: ['wrong-answer'], + } as CheckboxItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_INCLUDES_OPTION correctly for multiSelect -> the answers includes', () => { + const optionValue = 'option-value-1'; + + const condition: NotIncludesOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_INCLUDES_OPTION', + payload: { optionValue }, + }; + + const item = { + responseType: 'multiSelect', + answer: [optionValue], + } as CheckboxItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL correctly for numberSelect -> value equal', () => { + const condition: EqualCondition = { + itemName: 'random-item-name', + type: 'EQUAL', + payload: { value: 25 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['25'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL correctly for numberSelect -> value not equal', () => { + const condition: EqualCondition = { + itemName: 'random-item-name', + type: 'EQUAL', + payload: { value: 25 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['20'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL correctly for numberSelect - value not equal', () => { + const condition: NotEqualCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL', + payload: { value: 5 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['10'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL correctly for numberSelect - value equal', () => { + const condition: NotEqualCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL', + payload: { value: 5 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['5'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL correctly for slider -> value equal', () => { + const condition: EqualCondition = { + itemName: 'random-item-name', + type: 'EQUAL', + payload: { value: 25 }, + }; + + const item = { + responseType: 'slider', + answer: ['25'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL correctly for slider -> value not equal', () => { + const condition: EqualCondition = { + itemName: 'random-item-name', + type: 'EQUAL', + payload: { value: 25 }, + }; + + const item = { + responseType: 'slider', + answer: ['20'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL correctly for slider - value not equal', () => { + const condition: NotEqualCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL', + payload: { value: 5 }, + }; + + const item = { + responseType: 'slider', + answer: ['10'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL correctly for slider - value equal', () => { + const condition: NotEqualCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL', + payload: { value: 5 }, + }; + + const item = { + responseType: 'slider', + answer: ['5'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN correctly for numberSelect - value greater', () => { + const condition: GreaterThanCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN', + payload: { value: 5 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['6'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate GREATER_THAN correctly for numberSelect - value not greater', () => { + const condition: GreaterThanCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN', + payload: { value: 5 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['4'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN correctly for numberSelect - value equal', () => { + const condition: GreaterThanCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN', + payload: { value: 5 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['5'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN correctly for slider - value greater', () => { + const condition: GreaterThanCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN', + payload: { value: 5 }, + }; + + const item = { + responseType: 'slider', + answer: ['6'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate GREATER_THAN correctly for slider - value not greater', () => { + const condition: GreaterThanCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN', + payload: { value: 5 }, + }; + + const item = { + responseType: 'slider', + answer: ['4'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN correctly for slider - value equal', () => { + const condition: GreaterThanCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN', + payload: { value: 5 }, + }; + + const item = { + responseType: 'slider', + answer: ['5'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN correctly for numberSelect - value less', () => { + const condition: LessThanCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN', + payload: { value: 30 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['5'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate LESS_THAN correctly for numberSelect - value not less', () => { + const condition: LessThanCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN', + payload: { value: 30 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['35'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN correctly for numberSelect - value equal', () => { + const condition: LessThanCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN', + payload: { value: 30 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['30'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN correctly for slider - value less', () => { + const condition: LessThanCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN', + payload: { value: 30 }, + }; + + const item = { + responseType: 'slider', + answer: ['5'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate LESS_THAN correctly for slider - value not less', () => { + const condition: LessThanCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN', + payload: { value: 30 }, + }; + + const item = { + responseType: 'slider', + answer: ['35'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN correctly for slider - value equal', () => { + const condition: LessThanCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN', + payload: { value: 30 }, + }; + + const item = { + responseType: 'slider', + answer: ['30'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN correctly for numberSelect - value between', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['6'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate BETWEEN correctly for numberSelect - value not between', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['15'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN correctly for numberSelect - value equal to minValue', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['5'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN correctly for numberSelect - value equal to maxValue', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['10'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN correctly for slider - value between', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['6'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate BETWEEN correctly for slider - value not between', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['15'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN correctly for slider - value equal to minValue', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['5'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN correctly for slider - value equal to maxValue', () => { + const condition: BetweenCondition = { + itemName: 'random-item-name', + type: 'BETWEEN', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['10'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF correctly for numberSelect - value outside', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['15'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate OUTSIDE_OF correctly for numberSelect - value not outside', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['6'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF correctly for numberSelect - value equal to minValue', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['5'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF correctly for numberSelect - value equal to maxValue', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'numberSelect', + answer: ['10'], + } as SelectorItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF correctly for slider - value outside', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['15'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate OUTSIDE_OF correctly for slider - value not outside', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['6'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF correctly for slider - value equal to minValue', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['5'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF correctly for slider - value equal to maxValue', () => { + const condition: OutsideOfCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF', + payload: { minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'slider', + answer: ['10'], + } as SliderItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_DATE correctly -> the dates match', () => { + const date = '2021-01-01'; + + const condition: EqualToDateCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: [date], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_DATE correctly -> the dates don`t match', () => { + const date = '2021-01-01'; + + const condition: EqualToDateCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-02'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_DATE correctly -> the dates don`t match', () => { + const date = '2021-01-01'; + + const condition: NotEqualToDateCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-02'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_DATE correctly -> the dates match', () => { + const date = '2021-01-01'; + + const condition: NotEqualToDateCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: [date], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_DATE correctly -> the date is greater', () => { + const date = '2021-01-01'; + + const condition: GreaterThanDateCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-02'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate GREATER_THAN_DATE correctly -> the date is equal', () => { + const date = '2021-01-01'; + + const condition: GreaterThanDateCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-01'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_DATE correctly -> the date is less', () => { + const date = '2021-01-01'; + + const condition: GreaterThanDateCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2020-12-31'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_DATE correctly -> the date is less', () => { + const date = '2021-01-01'; + + const condition: LessThanDateCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2020-12-31'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate LESS_THAN_DATE correctly -> the date is equal', () => { + const date = '2021-01-01'; + + const condition: LessThanDateCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-01'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_DATE correctly -> the date is greater', () => { + const date = '2021-01-01'; + + const condition: LessThanDateCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-02'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_DATE correctly -> the dates match with different formats', () => { + const date = '2021-01-01'; + + const condition: EqualToDateCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['01/01/2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_DATE correctly -> the dates don`t match with different formats', () => { + const date = '2021-01-01'; + + const condition: EqualToDateCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['02/01/2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_DATE correctly -> the dates match with different formats and separators', () => { + const date = '2021-01-01'; + + const condition: EqualToDateCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['01.01.2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_DATE correctly -> the dates don`t match with different formats and separators', () => { + const date = '2021-01-01'; + + const condition: EqualToDateCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['02.01.2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_DATE correctly -> the dates don`t match with different formats', () => { + const date = '2021-01-01'; + + const condition: NotEqualToDateCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['02/01/2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_DATE correctly -> the dates match with different formats', () => { + const date = '2021-01-01'; + + const condition: NotEqualToDateCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['01/01/2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_DATE correctly -> the dates don`t match with different formats and separators', () => { + const date = '2021-01-01'; + + const condition: NotEqualToDateCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['02.01.2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_DATE correctly -> the dates match with different formats and separators', () => { + const date = '2021-01-01'; + + const condition: NotEqualToDateCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_DATE', + payload: { date }, + }; + + const item = { + responseType: 'date', + answer: ['01.01.2021'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_DATES correctly -> the date is between', () => { + const condition: BetweenDatesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-05'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate BETWEEN_DATES correctly -> the date is not between', () => { + const condition: BetweenDatesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-15'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_DATES correctly -> the date is equal to minDate', () => { + const condition: BetweenDatesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-01'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_DATES correctly -> the date is equal to maxDate', () => { + const condition: BetweenDatesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-10'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_DATES correctly -> the date is outside', () => { + const condition: OutsideOfDatesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-15'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate OUTSIDE_OF_DATES correctly -> the date is not outside', () => { + const condition: OutsideOfDatesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-05'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_DATES correctly -> the date is equal to minDate', () => { + const condition: OutsideOfDatesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-01'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_DATES correctly -> the date is equal to maxDate', () => { + const condition: OutsideOfDatesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_DATES', + payload: { minDate: '2021-01-01', maxDate: '2021-01-10' }, + }; + + const item = { + responseType: 'date', + answer: ['2021-01-10'], + } as DateItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_TIME correctly -> the times match', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: EqualToTimeCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_TIME correctly -> the times don`t match', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: EqualToTimeCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:31:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_TIME correctly -> the times don`t match', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: NotEqualToTimeCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:31:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_TIME correctly -> the times match', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: NotEqualToTimeCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_TIME correctly -> the time is greater', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: GreaterThanTimeCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:31:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate GREATER_THAN_TIME correctly -> the time is equal', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: GreaterThanTimeCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_TIME correctly -> the time is less', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: GreaterThanTimeCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:29:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_TIME correctly -> the time is less', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: LessThanTimeCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:29:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate LESS_THAN_TIME correctly -> the time is equal', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: LessThanTimeCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_TIME correctly -> the time is greater', () => { + const time = { + hours: 10, + minutes: 30, + }; + + const condition: LessThanTimeCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_TIME', + payload: { time }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:31:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIMES correctly -> the time is between', () => { + const condition: BetweenTimesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate BETWEEN_TIMES correctly -> the time is not between', () => { + const condition: BetweenTimesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIMES correctly -> the time is equal to minTime', () => { + const condition: BetweenTimesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIMES correctly -> the time is equal to maxTime', () => { + const condition: BetweenTimesCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIMES correctly -> the time is outside', () => { + const condition: OutsideOfTimesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate OUTSIDE_OF_TIMES correctly -> the time is not outside', () => { + const condition: OutsideOfTimesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIMES correctly -> the time is equal to minTime', () => { + const condition: OutsideOfTimesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIMES correctly -> the time is equal to maxTime', () => { + const condition: OutsideOfTimesCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIMES', + payload: { minTime: { hours: 10, minutes: 0 }, maxTime: { hours: 11, minutes: 0 } }, + }; + + const item = { + responseType: 'time', + answer: ['Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)'], + } as TimeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_TIME_RANGE correctly -> the from times match', () => { + const condition: EqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'from', + time: { hours: 10, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_TIME_RANGE correctly -> the from times don`t match', () => { + const condition: EqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'from', + time: { hours: 10, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 12:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_TIME_RANGE correctly -> the to times match', () => { + const condition: EqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'to', + time: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_TIME_RANGE correctly -> the to times don`t match', () => { + const condition: EqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'to', + time: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 12:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_TIME_RANGE correctly -> the from times don`t match', () => { + const condition: NotEqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'from', + time: { hours: 10, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 12:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_TIME_RANGE correctly -> the from times match', () => { + const condition: NotEqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'from', + time: { hours: 10, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_TIME_RANGE correctly -> the to times don`t match', () => { + const condition: NotEqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'to', + time: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 12:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_TIME_RANGE correctly -> the to times match', () => { + const condition: NotEqualToTimeRangeCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_TIME_RANGE', + payload: { + fieldName: 'to', + time: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_TIME_RANGE correctly -> the from time is greater', () => { + const condition: GreaterThanTimeRangeCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_TIME_RANGE', + payload: { + fieldName: 'from', + time: { hours: 10, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 12:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate GREATER_THAN_TIME_RANGE correctly -> the from time is equal', () => { + const condition: GreaterThanTimeRangeCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_TIME_RANGE', + payload: { + fieldName: 'from', + time: { hours: 10, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_TIME_RANGE correctly -> the from time is less', () => { + const condition: GreaterThanTimeRangeCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_TIME_RANGE', + payload: { + fieldName: 'from', + time: { hours: 10, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 09:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_TIME_RANGE correctly -> the to time is less', () => { + const condition: LessThanTimeRangeCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_TIME_RANGE', + payload: { + fieldName: 'to', + time: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate LESS_THAN_TIME_RANGE correctly -> the to time is equal', () => { + const condition: LessThanTimeRangeCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_TIME_RANGE', + payload: { + fieldName: 'to', + time: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_TIME_RANGE correctly -> the to time is greater', () => { + const condition: LessThanTimeRangeCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_TIME_RANGE', + payload: { + fieldName: 'to', + time: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 12:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the time range is between', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the time range is not between', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 09:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the time range is equal to minTime', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the time range is equal to maxTime', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the to time range is between', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 9:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the to time range is not between', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the to time range is equal to minTime', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 30 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_TIME_RANGES correctly -> the to time range is equal to maxTime', () => { + const condition: BetweenTimeRangeCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 30 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the from time range is outside', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 09:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the from time range is not outside', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the from time range is equal to minTime', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the from time range is equal to maxTime', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'from', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the to time range is outside', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the to time range is not outside', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the to time range is equal to minTime', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:00:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_TIME_RANGES correctly -> the to time range is equal to maxTime', () => { + const condition: OutsideOfTimeRangeCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_TIME_RANGE', + payload: { + fieldName: 'to', + minTime: { hours: 10, minutes: 0 }, + maxTime: { hours: 11, minutes: 0 }, + }, + }; + + const item = { + responseType: 'timeRange', + answer: [ + 'Wed Aug 28 2024 10:30:00 GMT+0200 (Central European Summer Time)', + 'Wed Aug 28 2024 11:00:00 GMT+0200 (Central European Summer Time)', + ], + } as TimeRangeItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_SLIDER_ROWS correctly -> the slider rows are greater', () => { + const condition: GreaterThanSliderRowsCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [6], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate GREATER_THAN_SLIDER_ROWS correctly -> the slider rows are equal', () => { + const condition: GreaterThanSliderRowsCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [5], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate GREATER_THAN_SLIDER_ROWS correctly -> the slider rows are less', () => { + const condition: GreaterThanSliderRowsCondition = { + itemName: 'random-item-name', + type: 'GREATER_THAN_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [4], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_SLIDER_ROWS correctly -> the slider rows are less', () => { + const condition: LessThanSliderRowsCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [4], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate LESS_THAN_SLIDER_ROWS correctly -> the slider rows are equal', () => { + const condition: LessThanSliderRowsCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [5], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate LESS_THAN_SLIDER_ROWS correctly -> the slider rows are greater', () => { + const condition: LessThanSliderRowsCondition = { + itemName: 'random-item-name', + type: 'LESS_THAN_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [6], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_SLIDER_ROWS correctly -> the slider rows are equal', () => { + const condition: EqualToSliderRowsCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [5], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_SLIDER_ROWS correctly -> the slider rows are not equal', () => { + const condition: EqualToSliderRowsCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [4], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_SLIDER_ROWS correctly -> the slider rows are not equal', () => { + const condition: NotEqualToSliderRowsCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [4], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_SLIDER_ROWS correctly -> the slider rows are equal', () => { + const condition: NotEqualToSliderRowsCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_SLIDER_ROWS', + payload: { rowIndex: 0, value: 5 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [5], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_SLIDER_ROWS correctly -> the slider rows are between', () => { + const condition: BetweenSliderRowsCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [6], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate BETWEEN_SLIDER_ROWS correctly -> the slider rows are not between', () => { + const condition: BetweenSliderRowsCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [4], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_SLIDER_ROWS correctly -> the slider rows are equal to minValue', () => { + const condition: BetweenSliderRowsCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [5], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate BETWEEN_SLIDER_ROWS correctly -> the slider rows are equal to maxValue', () => { + const condition: BetweenSliderRowsCondition = { + itemName: 'random-item-name', + type: 'BETWEEN_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [10], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_SLIDER_ROWS correctly -> the slider rows are outside', () => { + const condition: OutsideOfSliderRowsCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [4], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate OUTSIDE_OF_SLIDER_ROWS correctly -> the slider rows are not outside', () => { + const condition: OutsideOfSliderRowsCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [6], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_SLIDER_ROWS correctly -> the slider rows are equal to minValue', () => { + const condition: OutsideOfSliderRowsCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [5], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate OUTSIDE_OF_SLIDER_ROWS correctly -> the slider rows are equal to maxValue', () => { + const condition: OutsideOfSliderRowsCondition = { + itemName: 'random-item-name', + type: 'OUTSIDE_OF_SLIDER_ROWS', + payload: { rowIndex: 0, minValue: 5, maxValue: 10 }, + }; + + const item = { + responseType: 'sliderRows', + answer: [10], + } as SliderRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate EQUAL_TO_ROW_OPTION correctly -> the row option is equal', () => { + const condition: EqualToRowOptionCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'singleSelectRows', + answer: ['option1'], + } as SingleSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate EQUAL_TO_ROW_OPTION correctly -> the row option is not equal', () => { + const condition: EqualToRowOptionCondition = { + itemName: 'random-item-name', + type: 'EQUAL_TO_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'singleSelectRows', + answer: ['option2'], + } as SingleSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_EQUAL_TO_ROW_OPTION correctly -> the row option is not equal', () => { + const condition: NotEqualToRowOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'singleSelectRows', + answer: ['option2'], + } as SingleSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_EQUAL_TO_ROW_OPTION correctly -> the row option is equal', () => { + const condition: NotEqualToRowOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_EQUAL_TO_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'singleSelectRows', + answer: ['option1'], + } as SingleSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate INCLUDES_ROW_OPTION correctly -> the row option is included', () => { + const condition: IncludesRowOptionCondition = { + itemName: 'random-item-name', + type: 'INCLUDES_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'multiSelectRows', + answer: [['option1']], + } as MultiSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate INCLUDES_ROW_OPTION correctly -> the row option is not included', () => { + const condition: IncludesRowOptionCondition = { + itemName: 'random-item-name', + type: 'INCLUDES_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'multiSelectRows', + answer: [['option2']], + } as MultiSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); + + it('should validate NOT_INCLUDES_ROW_OPTION correctly -> the row option is not included', () => { + const condition: NotIncludesRowOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_INCLUDES_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'multiSelectRows', + answer: [['option2']], + } as MultiSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(true); + }); + + it('should validate NOT_INCLUDES_ROW_OPTION correctly -> the row option is included', () => { + const condition: NotIncludesRowOptionCondition = { + itemName: 'random-item-name', + type: 'NOT_INCLUDES_ROW_OPTION', + payload: { rowIndex: 0, optionValue: 'option1' }, + }; + + const item = { + responseType: 'multiSelectRows', + answer: [['option1']], + } as MultiSelectionRowsItem; + + const validator = new ConditionalLogicValidator(item, condition); + expect(validator.validate()).toBe(false); + }); +}); diff --git a/src/features/PassSurvey/model/ConditionalLogicValidator.ts b/src/features/PassSurvey/model/ConditionalLogicValidator.ts index 60675a3ed..a376fb15a 100644 --- a/src/features/PassSurvey/model/ConditionalLogicValidator.ts +++ b/src/features/PassSurvey/model/ConditionalLogicValidator.ts @@ -1,21 +1,55 @@ import { isSameDay } from 'date-fns'; -import { DefaultAnswer } from '~/entities/activity'; import { ItemRecord } from '~/entities/applet/model/types'; import { BetweenCondition, + BetweenDatesCondition, + BetweenSliderRowsCondition, + BetweenTimeRangeCondition, + BetweenTimesCondition, Condition, EqualCondition, + EqualToDateCondition, EqualToOptionCondition, + EqualToRowOptionCondition, + EqualToSliderRowsCondition, + EqualToTimeCondition, + EqualToTimeRangeCondition, GreaterThanCondition, + GreaterThanDateCondition, + GreaterThanSliderRowsCondition, + GreaterThanTimeCondition, + GreaterThanTimeRangeCondition, IncludesOptionCondition, + IncludesRowOptionCondition, LessThanCondition, + LessThanDateCondition, + LessThanSliderRowsCondition, + LessThanTimeCondition, + LessThanTimeRangeCondition, NotEqualCondition, + NotEqualToDateCondition, NotEqualToOptionCondition, + NotEqualToRowOptionCondition, + NotEqualToSliderRowsCondition, + NotEqualToTimeCondition, + NotEqualToTimeRangeCondition, NotIncludesOptionCondition, + NotIncludesRowOptionCondition, OutsideOfCondition, + OutsideOfDatesCondition, + OutsideOfSliderRowsCondition, + OutsideOfTimeRangeCondition, + OutsideOfTimesCondition, } from '~/shared/api'; -import { isFirstDateEarlier, isFirstDateLater } from '~/shared/utils'; +import { + dateStringToHourMinuteRaw, + isFirstDateEarlier, + isFirstDateLater, + isFirstTimeEarlier, + isFirstTimeLater, + isTimesEqual, +} from '~/shared/utils'; interface IConditionalLogicValidator { validate: () => boolean; @@ -59,120 +93,483 @@ export class ConditionalLogicValidator implements IConditionalLogicValidator { case 'OUTSIDE_OF': return this.validateOutsideOf(this.rule, this.item); + case 'GREATER_THAN_DATE': + return this.validateGreaterThanDate(this.rule, this.item); + + case 'LESS_THAN_DATE': + return this.validateLessThanDate(this.rule, this.item); + + case 'EQUAL_TO_DATE': + return this.validateEqualToDate(this.rule, this.item); + + case 'NOT_EQUAL_TO_DATE': + return this.validateNotEqualToDate(this.rule, this.item); + + case 'BETWEEN_DATES': + return this.validateBetweenDates(this.rule, this.item); + + case 'OUTSIDE_OF_DATES': + return this.validateOutsideOfDates(this.rule, this.item); + + case 'GREATER_THAN_TIME': + return this.validateGreaterThanTime(this.rule, this.item); + + case 'LESS_THAN_TIME': + return this.validateLessThanTime(this.rule, this.item); + + case 'EQUAL_TO_TIME': + return this.validateEqualToTime(this.rule, this.item); + + case 'NOT_EQUAL_TO_TIME': + return this.validateNotEqualToTime(this.rule, this.item); + + case 'BETWEEN_TIMES': + return this.validateBetweenTimes(this.rule, this.item); + + case 'OUTSIDE_OF_TIMES': + return this.validateOutsideOfTimes(this.rule, this.item); + + case 'GREATER_THAN_TIME_RANGE': + return this.validateGreaterThanTimeRange(this.rule, this.item); + + case 'LESS_THAN_TIME_RANGE': + return this.validateLessThanTimeRange(this.rule, this.item); + + case 'EQUAL_TO_TIME_RANGE': + return this.validateEqualToTimeRange(this.rule, this.item); + + case 'NOT_EQUAL_TO_TIME_RANGE': + return this.validateNotEqualToTimeRange(this.rule, this.item); + + case 'BETWEEN_TIME_RANGE': + return this.validateBetweenTimeRange(this.rule, this.item); + + case 'OUTSIDE_OF_TIME_RANGE': + return this.validateOutsideOfTimeRange(this.rule, this.item); + + case 'GREATER_THAN_SLIDER_ROWS': + return this.validateGreaterThanSliderRows(this.rule, this.item); + + case 'LESS_THAN_SLIDER_ROWS': + return this.validateLessThanSliderRows(this.rule, this.item); + + case 'EQUAL_TO_SLIDER_ROWS': + return this.validateEqualToSliderRows(this.rule, this.item); + + case 'NOT_EQUAL_TO_SLIDER_ROWS': + return this.validateNotEqualToSliderRows(this.rule, this.item); + + case 'BETWEEN_SLIDER_ROWS': + return this.validateBetweenSliderRows(this.rule, this.item); + + case 'OUTSIDE_OF_SLIDER_ROWS': + return this.validateOutsideOfSliderRows(this.rule, this.item); + + case 'EQUAL_TO_ROW_OPTION': + return this.validateEqualToRowOption(this.rule, this.item); + + case 'NOT_EQUAL_TO_ROW_OPTION': + return this.validateNotEqualToRowOption(this.rule, this.item); + + case 'INCLUDES_ROW_OPTION': + return this.validateIncludesRowOption(this.rule, this.item); + + case 'NOT_INCLUDES_ROW_OPTION': + return this.validateNotIncludesRowOption(this.rule, this.item); + default: return true; } } private validateEqualToOption(rule: EqualToOptionCondition, item: ItemRecord): boolean { - return rule.payload.optionValue === item.answer[0]; + if (item.responseType === 'singleSelect') { + return rule.payload.optionValue === item.answer[0]; + } + + return true; } private validateNotEqualToOption(rule: NotEqualToOptionCondition, item: ItemRecord): boolean { - return rule.payload.optionValue !== item.answer[0]; + if (item.responseType === 'singleSelect') { + return rule.payload.optionValue !== item.answer[0]; + } + + return true; } private validateIncludesOption(rule: IncludesOptionCondition, item: ItemRecord): boolean { - return (item.answer as DefaultAnswer).includes(rule.payload.optionValue); + if (item.responseType === 'multiSelect') { + return item.answer.includes(rule.payload.optionValue); + } + + return true; } private validateNotIncludesOption(rule: NotIncludesOptionCondition, item: ItemRecord): boolean { - return !(item.answer as DefaultAnswer).includes(rule.payload.optionValue); + if (item.responseType === 'multiSelect') { + return !item.answer.includes(rule.payload.optionValue); + } + + return true; } private validateEqual(rule: EqualCondition, item: ItemRecord): boolean { - switch (item.responseType) { - case 'slider': - case 'numberSelect': - return Number(rule.payload.value) === Number(item.answer[0]); + if (item.responseType === 'slider' || item.responseType === 'numberSelect') { + return rule.payload.value === Number(item.answer[0]); + } - case 'date': - return isSameDay(new Date(item.answer[0]), new Date(rule.payload.value)); + return true; + } - default: - return true; + private validateNotEqual(rule: NotEqualCondition, item: ItemRecord): boolean { + if (item.responseType === 'slider' || item.responseType === 'numberSelect') { + return rule.payload.value !== Number(item.answer[0]); } + + return true; } - private validateNotEqual(rule: NotEqualCondition, item: ItemRecord): boolean { - switch (item.responseType) { - case 'slider': - case 'numberSelect': - return Number(rule.payload.value) !== Number(item.answer[0]); + private validateGreaterThan(rule: GreaterThanCondition, item: ItemRecord): boolean { + if (item.responseType === 'slider' || item.responseType === 'numberSelect') { + return rule.payload.value < Number(item.answer[0]); + } - case 'date': - return !isSameDay(new Date(item.answer[0]), new Date(rule.payload.value)); + return true; + } - default: - return true; + private validateLessThan(rule: LessThanCondition, item: ItemRecord): boolean { + if (item.responseType === 'slider' || item.responseType === 'numberSelect') { + return rule.payload.value > Number(item.answer[0]); } + + return true; } - private validateGreaterThan(rule: GreaterThanCondition, item: ItemRecord): boolean { - switch (item.responseType) { - case 'slider': - case 'numberSelect': - return Number(rule.payload.value) < Number(item.answer[0]); + private validateBetween(rule: BetweenCondition, item: ItemRecord): boolean { + if (item.responseType === 'slider' || item.responseType === 'numberSelect') { + return ( + Number(item.answer[0]) > rule.payload.minValue && + Number(item.answer[0]) < rule.payload.maxValue + ); + } - case 'date': - return isFirstDateLater(new Date(item.answer[0]), new Date(rule.payload.value)); + return true; + } - default: - return true; + private validateOutsideOf(rule: OutsideOfCondition, item: ItemRecord): boolean { + if (item.responseType === 'slider' || item.responseType === 'numberSelect') { + return ( + Number(item.answer[0]) < rule.payload.minValue || + Number(item.answer[0]) > rule.payload.maxValue + ); } + + return true; } - private validateLessThan(rule: LessThanCondition, item: ItemRecord): boolean { - switch (item.responseType) { - case 'slider': - case 'numberSelect': - return Number(rule.payload.value) > Number(item.answer[0]); + private validateGreaterThanDate(rule: GreaterThanDateCondition, item: ItemRecord): boolean { + if (item.responseType === 'date') { + return isFirstDateEarlier(new Date(rule.payload.date), new Date(item.answer[0])); + } - case 'date': - return isFirstDateEarlier(new Date(item.answer[0]), new Date(rule.payload.value)); + return true; + } - default: - return true; + private validateLessThanDate(rule: LessThanDateCondition, item: ItemRecord): boolean { + if (item.responseType === 'date') { + return isFirstDateLater(new Date(rule.payload.date), new Date(item.answer[0])); } + + return true; } - private validateBetween(rule: BetweenCondition, item: ItemRecord): boolean { - switch (item.responseType) { - case 'slider': - case 'numberSelect': - return ( - Number(item.answer[0]) > Number(rule.payload.minValue) && - Number(item.answer[0]) < Number(rule.payload.maxValue) - ); - - case 'date': - return ( - isFirstDateLater(new Date(item.answer[0]), new Date(rule.payload.minValue)) && - isFirstDateEarlier(new Date(item.answer[0]), new Date(rule.payload.maxValue)) - ); + private validateEqualToDate(rule: EqualToDateCondition, item: ItemRecord): boolean { + if (item.responseType === 'date') { + return isSameDay(new Date(rule.payload.date), new Date(item.answer[0])); + } - default: - return true; + return true; + } + + private validateNotEqualToDate(rule: NotEqualToDateCondition, item: ItemRecord): boolean { + if (item.responseType === 'date') { + return !isSameDay(new Date(rule.payload.date), new Date(item.answer[0])); } + + return true; } - private validateOutsideOf(rule: OutsideOfCondition, item: ItemRecord): boolean { - switch (item.responseType) { - case 'slider': - case 'numberSelect': - return ( - Number(item.answer[0]) < Number(rule.payload.minValue) || - Number(item.answer[0]) > Number(rule.payload.maxValue) - ); - - case 'date': - return ( - isFirstDateEarlier(new Date(item.answer[0]), new Date(rule.payload.minValue)) || - isFirstDateLater(new Date(item.answer[0]), new Date(rule.payload.maxValue)) - ); + private validateBetweenDates(rule: BetweenDatesCondition, item: ItemRecord): boolean { + if (item.responseType === 'date') { + return ( + isFirstDateEarlier(new Date(rule.payload.minDate), new Date(item.answer[0])) && + isFirstDateLater(new Date(rule.payload.maxDate), new Date(item.answer[0])) + ); + } - default: - return true; + return true; + } + + private validateOutsideOfDates(rule: OutsideOfDatesCondition, item: ItemRecord): boolean { + if (item.responseType === 'date') { + return ( + isFirstDateLater(new Date(rule.payload.minDate), new Date(item.answer[0])) || + isFirstDateEarlier(new Date(rule.payload.maxDate), new Date(item.answer[0])) + ); + } + + return true; + } + + private validateGreaterThanTime(rule: GreaterThanTimeCondition, item: ItemRecord): boolean { + if (item.responseType === 'time') { + const time = dateStringToHourMinuteRaw(item.answer[0]); + + return isFirstTimeLater(time, rule.payload.time); + } + + return true; + } + + private validateLessThanTime(rule: LessThanTimeCondition, item: ItemRecord): boolean { + if (item.responseType === 'time') { + const time = dateStringToHourMinuteRaw(item.answer[0]); + + return isFirstTimeEarlier(time, rule.payload.time); + } + + return true; + } + + private validateEqualToTime(rule: EqualToTimeCondition, item: ItemRecord): boolean { + if (item.responseType === 'time') { + const time = dateStringToHourMinuteRaw(item.answer[0]); + + return isTimesEqual(rule.payload.time, time); } + + return true; + } + + private validateNotEqualToTime(rule: NotEqualToTimeCondition, item: ItemRecord): boolean { + if (item.responseType === 'time') { + const time = dateStringToHourMinuteRaw(item.answer[0]); + + return !isTimesEqual(rule.payload.time, time); + } + + return true; + } + + private validateBetweenTimes(rule: BetweenTimesCondition, item: ItemRecord): boolean { + if (item.responseType === 'time') { + const time = dateStringToHourMinuteRaw(item.answer[0]); + + return ( + isFirstTimeEarlier(rule.payload.minTime, time) && + isFirstTimeLater(rule.payload.maxTime, time) + ); + } + + return true; + } + + private validateOutsideOfTimes(rule: OutsideOfTimesCondition, item: ItemRecord): boolean { + if (item.responseType === 'time') { + const time = dateStringToHourMinuteRaw(item.answer[0]); + + return ( + isFirstTimeLater(rule.payload.minTime, time) || + isFirstTimeEarlier(rule.payload.maxTime, time) + ); + } + + return true; + } + + private validateGreaterThanTimeRange( + rule: GreaterThanTimeRangeCondition, + item: ItemRecord, + ): boolean { + if (item.responseType === 'timeRange') { + const timeToValidate = rule.payload.fieldName === 'from' ? item.answer[0] : item.answer[1]; + + const time = dateStringToHourMinuteRaw(timeToValidate); + + return isFirstTimeLater(time, rule.payload.time); + } + + return true; + } + + private validateLessThanTimeRange(rule: LessThanTimeRangeCondition, item: ItemRecord): boolean { + if (item.responseType === 'timeRange') { + const timeToValidate = rule.payload.fieldName === 'from' ? item.answer[0] : item.answer[1]; + + const time = dateStringToHourMinuteRaw(timeToValidate); + + return isFirstTimeEarlier(time, rule.payload.time); + } + + return true; + } + + private validateEqualToTimeRange(rule: EqualToTimeRangeCondition, item: ItemRecord): boolean { + if (item.responseType === 'timeRange') { + const timeToValidate = rule.payload.fieldName === 'from' ? item.answer[0] : item.answer[1]; + + const time = dateStringToHourMinuteRaw(timeToValidate); + + return isTimesEqual(rule.payload.time, time); + } + + return true; + } + + private validateNotEqualToTimeRange( + rule: NotEqualToTimeRangeCondition, + item: ItemRecord, + ): boolean { + if (item.responseType === 'timeRange') { + const timeToValidate = rule.payload.fieldName === 'from' ? item.answer[0] : item.answer[1]; + + const time = dateStringToHourMinuteRaw(timeToValidate); + + return !isTimesEqual(rule.payload.time, time); + } + + return true; + } + + private validateBetweenTimeRange(rule: BetweenTimeRangeCondition, item: ItemRecord): boolean { + if (item.responseType === 'timeRange') { + const timeToValidate = rule.payload.fieldName === 'from' ? item.answer[0] : item.answer[1]; + + const time = dateStringToHourMinuteRaw(timeToValidate); + + return ( + isFirstTimeEarlier(rule.payload.minTime, time) && + isFirstTimeLater(rule.payload.maxTime, time) + ); + } + + return true; + } + + private validateOutsideOfTimeRange(rule: OutsideOfTimeRangeCondition, item: ItemRecord): boolean { + if (item.responseType === 'timeRange') { + const timeToValidate = rule.payload.fieldName === 'from' ? item.answer[0] : item.answer[1]; + + const time = dateStringToHourMinuteRaw(timeToValidate); + + return ( + isFirstTimeLater(rule.payload.minTime, time) || + isFirstTimeEarlier(rule.payload.maxTime, time) + ); + } + + return true; + } + + private validateGreaterThanSliderRows( + rule: GreaterThanSliderRowsCondition, + item: ItemRecord, + ): boolean { + if (item.responseType === 'sliderRows') { + return rule.payload.value < Number(item.answer[rule.payload.rowIndex]); + } + + return true; + } + + private validateLessThanSliderRows(rule: LessThanSliderRowsCondition, item: ItemRecord): boolean { + if (item.responseType === 'sliderRows') { + return rule.payload.value > Number(item.answer[rule.payload.rowIndex]); + } + + return true; + } + + private validateEqualToSliderRows(rule: EqualToSliderRowsCondition, item: ItemRecord): boolean { + if (item.responseType === 'sliderRows') { + return rule.payload.value === Number(item.answer[rule.payload.rowIndex]); + } + + return true; + } + + private validateNotEqualToSliderRows( + rule: NotEqualToSliderRowsCondition, + item: ItemRecord, + ): boolean { + if (item.responseType === 'sliderRows') { + return rule.payload.value !== Number(item.answer[rule.payload.rowIndex]); + } + + return true; + } + + private validateBetweenSliderRows(rule: BetweenSliderRowsCondition, item: ItemRecord): boolean { + if (item.responseType === 'sliderRows') { + return ( + Number(item.answer[rule.payload.rowIndex]) > rule.payload.minValue && + Number(item.answer[rule.payload.rowIndex]) < rule.payload.maxValue + ); + } + + return true; + } + + private validateOutsideOfSliderRows( + rule: OutsideOfSliderRowsCondition, + item: ItemRecord, + ): boolean { + if (item.responseType === 'sliderRows') { + return ( + Number(item.answer[rule.payload.rowIndex]) < rule.payload.minValue || + Number(item.answer[rule.payload.rowIndex]) > rule.payload.maxValue + ); + } + + return true; + } + + private validateEqualToRowOption(rule: EqualToRowOptionCondition, item: ItemRecord): boolean { + if (item.responseType === 'singleSelectRows') { + return rule.payload.optionValue === item.answer[rule.payload.rowIndex]; + } + + return true; + } + + private validateNotEqualToRowOption( + rule: NotEqualToRowOptionCondition, + item: ItemRecord, + ): boolean { + if (item.responseType === 'singleSelectRows') { + return rule.payload.optionValue !== item.answer[rule.payload.rowIndex]; + } + + return true; + } + + private validateIncludesRowOption(rule: IncludesRowOptionCondition, item: ItemRecord): boolean { + if (item.responseType === 'multiSelectRows') { + return item.answer[rule.payload.rowIndex].includes(rule.payload.optionValue); + } + + return true; + } + + private validateNotIncludesRowOption( + rule: NotIncludesRowOptionCondition, + item: ItemRecord, + ): boolean { + if (item.responseType === 'multiSelectRows') { + return !item.answer[rule.payload.rowIndex].includes(rule.payload.optionValue); + } + + return true; } } diff --git a/src/features/PassSurvey/model/index.ts b/src/features/PassSurvey/model/index.ts index 39b015923..b90864292 100644 --- a/src/features/PassSurvey/model/index.ts +++ b/src/features/PassSurvey/model/index.ts @@ -1,4 +1,4 @@ export { default as AlertsExtractor } from './AlertsExtractor'; export { default as ScoresExtractor } from './ScoresExtractor'; export { default as AnswersConstructService } from './AnswersConstructService'; -export * from './ConditionalLogicBuilder'; +export * from './ConditionalLogicFilter'; diff --git a/src/features/PassSurvey/ui/EntityTimer.tsx b/src/features/PassSurvey/ui/EntityTimer.tsx index 0fe939a40..b3c426e44 100644 --- a/src/features/PassSurvey/ui/EntityTimer.tsx +++ b/src/features/PassSurvey/ui/EntityTimer.tsx @@ -29,7 +29,7 @@ export const EntityTimer = ({ entityTimerSettings }: Props) => { const group = useAppSelector((state) => appletModel.selectors.selectGroupProgress( state, - getProgressId(context.entityId, context.eventId), + getProgressId(context.entityId, context.eventId, context.targetSubject?.id), ), ); diff --git a/src/features/PassSurvey/ui/SurveyLayout.tsx b/src/features/PassSurvey/ui/SurveyLayout.tsx index ed0388d0a..1227b6e77 100644 --- a/src/features/PassSurvey/ui/SurveyLayout.tsx +++ b/src/features/PassSurvey/ui/SurveyLayout.tsx @@ -32,6 +32,8 @@ const SurveyLayout = (props: Props) => { title={props.title} /> + + { flexDirection="column" overflow="scroll" > - {props.children} diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index ad16dcd47..98bcfaa19 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -22,12 +22,17 @@ "answerTooLarge": "Please provide a shorter answer", "question_count_plural": "{{length}} questions", "question_count_singular": "{{length}} question", + "questionCount_one": "{{count}} Question", + "questionCount_other": "{{count}} Questions", "timedActivityTitle": "is a Timed Activity.", "youWillHaveToCompleteIt": "You will have {{hours}} hours {{minutes}} minutes to complete it.", "yourWorkWillBeSubmitted": "Your work will be auto-submitted when time runs out.", - "countOfCompletedQuestions": "{{countOfCompletedQuestions}} of {{length}} questions completed", - "activityFlowLength": "{{length}} activities", - "countOfCompletedActivities": "{{countOfCompletedActivities}} of {{length}} activities completed", + "countOfCompletedQuestions_one": "{{countOfCompletedQuestions}} of {{count}} question completed", + "countOfCompletedQuestions_other": "{{countOfCompletedQuestions}} of {{count}} questions completed", + "activityFlowLength_one": "{{count}} Activity", + "activityFlowLength_other": "{{count}} Activities", + "countOfCompletedActivities_one": "{{countOfCompletedActivities}} of {{count}} activity completed", + "countOfCompletedActivities_other": "{{countOfCompletedActivities}} of {{count}} activities completed", "pleaseCompleteOnThe": "Please complete on the", "mindloggerMobileApp": "MindLogger mobile app", "mustBeCompletedUsingMobileApp": "Please use the MindLogger mobile app", @@ -306,6 +311,9 @@ "successModalPrimaryAction": "Return to Admin App", "successModalSecondaryAction": "Dismiss" }, - "charactersCount": "{{numCharacters}}/{{maxCharacters}} characters" + "charactersCount": "{{numCharacters}}/{{maxCharacters}} characters", + "targetSubjectLabel": "About {{name}}", + "targetSubjectBanner": "Please ensure all your responses are about {{name}}", + "loading": "Loading…" } } diff --git a/src/i18n/fr/translation.json b/src/i18n/fr/translation.json index ba6df939b..a090852b8 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -24,11 +24,14 @@ "timedActivityTitle": "est une activité chronométrée.", "youWillHaveToCompleteIt": "Vous aurez {{hours}} heures {{minutes}} minutes pour le terminer.", "yourWorkWillBeSubmitted": "Votre travail sera automatiquement soumis lorsque le temps imparti sera écoulé.", - "question_count_plural": "{{length}} questions", - "question_count_singular": "{{length}} question", - "countOfCompletedQuestions": "{{countOfCompletedQuestions}} of {{length}} questions completed", - "activityFlowLength": "{{length}} activities", - "countOfCompletedActivities": "{{countOfCompletedActivities}} of {{length}} activities completed", + "questionCount_one": "{{count}} question", + "questionCount_other": "{{count}} questions", + "countOfCompletedQuestions_one": "{{countOfCompletedQuestions}} sur {{count}} question complétée", + "countOfCompletedQuestions_other": "{{countOfCompletedQuestions}} sur {{count}} questions complétées", + "activityFlowLength_one": "{{count}} activité", + "activityFlowLength_other": "{{count}} activités", + "countOfCompletedActivities_one": "{{countOfCompletedActivities}} sur {{count}} activité complétée", + "countOfCompletedActivities_other": "{{countOfCompletedActivities}} sur {{count}} activités complétées", "pleaseCompleteOnThe": "Please complete on the", "mindloggerMobileApp": "MindLogger mobile app", @@ -325,6 +328,9 @@ "successModalPrimaryAction": "Revenir à l'application d'administration", "successModalSecondaryAction": "Rejeter" }, - "charactersCount": "{{numCharacters}}/{{maxCharacters}} caractères" + "charactersCount": "{{numCharacters}}/{{maxCharacters}} caractères", + "targetSubjectLabel": "À propos de {{name}}", + "targetSubjectBanner": "Assurez-vous que toutes vos réponses concernent {{name}}", + "loading": "Chargement en cours..." } } diff --git a/src/pages/AutoCompletion/index.tsx b/src/pages/AutoCompletion/index.tsx index 348c29e76..b93be0caa 100644 --- a/src/pages/AutoCompletion/index.tsx +++ b/src/pages/AutoCompletion/index.tsx @@ -9,6 +9,7 @@ function AutoCompletion() { const activityId = searchParams.get('activityId'); const eventId = searchParams.get('eventId'); const flowId = searchParams.get('flowId'); + const targetSubjectId = searchParams.get('targetSubjectId'); if (!appletId || !activityId || !eventId) { return
Invalid URL
; @@ -20,6 +21,7 @@ function AutoCompletion() { activityId={activityId} eventId={eventId} flowId={flowId} + targetSubjectId={targetSubjectId} publicAppletKey={null} /> ); diff --git a/src/pages/PublicAutoCompletion/index.tsx b/src/pages/PublicAutoCompletion/index.tsx index ae636747b..c39eb8ba9 100644 --- a/src/pages/PublicAutoCompletion/index.tsx +++ b/src/pages/PublicAutoCompletion/index.tsx @@ -21,6 +21,7 @@ function PublicAutoCompletion() { activityId={activityId} eventId={eventId} flowId={flowId} + targetSubjectId={null} publicAppletKey={publicAppletKey} /> ); diff --git a/src/pages/PublicSurvey/index.tsx b/src/pages/PublicSurvey/index.tsx index 26155f77a..ed9391b56 100644 --- a/src/pages/PublicSurvey/index.tsx +++ b/src/pages/PublicSurvey/index.tsx @@ -30,6 +30,7 @@ function PublicSurvey() { activityId={activityId} eventId={eventId} flowId={flowId} + targetSubjectId={null} publicAppletKey={publicAppletKey} />
diff --git a/src/pages/Survey/index.tsx b/src/pages/Survey/index.tsx index 831bc2f1a..461d6e58a 100644 --- a/src/pages/Survey/index.tsx +++ b/src/pages/Survey/index.tsx @@ -12,12 +12,8 @@ function SurveyPage() { const [searchParams] = useSearchParams(); const isFlow = entityType === 'flow'; - - let flowId: string | null = null; - - if (isFlow) { - flowId = searchParams.get('flowId'); - } + const flowId = isFlow ? searchParams.get('flowId') : null; + const targetSubjectId = searchParams.get('targetSubjectId'); if (!appletId || !activityId || !eventId) { return
{t('wrondLinkParametrError')}
; @@ -30,6 +26,7 @@ function SurveyPage() { activityId={activityId} eventId={eventId} flowId={flowId} + targetSubjectId={targetSubjectId} publicAppletKey={null} /> diff --git a/src/shared/api/services/assignment.service.ts b/src/shared/api/services/assignment.service.ts new file mode 100644 index 000000000..fbde0c834 --- /dev/null +++ b/src/shared/api/services/assignment.service.ts @@ -0,0 +1,12 @@ +import { axiosService } from '~/shared/api'; +import { GetMyAssignmentsPayload, MyAssignmentsSuccessResponse } from '~/shared/api/types'; + +function subjectService() { + return { + getMyAssignments({ appletId }: GetMyAssignmentsPayload) { + return axiosService.get(`/users/me/assignments/${appletId}`); + }, + }; +} + +export default subjectService(); diff --git a/src/shared/api/services/index.ts b/src/shared/api/services/index.ts index da150889f..72ecf878d 100644 --- a/src/shared/api/services/index.ts +++ b/src/shared/api/services/index.ts @@ -8,3 +8,4 @@ export { default as eventsService } from './events.service'; export { default as subjectService } from './subject.service'; export { default as answerService } from './answer.service'; export { default as workspaceService } from './workspace.service'; +export { default as assignmentService } from './assignment.service'; diff --git a/src/shared/api/types/activity.ts b/src/shared/api/types/activity.ts index 0ea51a4e6..67d711bb8 100644 --- a/src/shared/api/types/activity.ts +++ b/src/shared/api/types/activity.ts @@ -1,5 +1,5 @@ import { BaseSuccessResponse } from './base'; -import { ScoreConditionalLogic } from './conditionalLogiс'; +import { ScoreConditionalLogic } from './conditionalLogic'; import { AudioPlayerItemDTO, CheckboxItemDTO, @@ -222,6 +222,7 @@ export type CompletedEntityDTO = { id: string; answerId: string; submitId: string; + targetSubjectId: string | null; scheduledEventId: string; localEndDate: string; localEndTime: string; diff --git a/src/shared/api/types/applet.ts b/src/shared/api/types/applet.ts index 9bb516053..90dda1d66 100644 --- a/src/shared/api/types/applet.ts +++ b/src/shared/api/types/applet.ts @@ -77,6 +77,7 @@ export type ActivityBaseDTO = { order: number; containsResponseTypes: Array; itemCount: number; + autoAssign: boolean; }; type Integration = 'loris'; @@ -105,6 +106,7 @@ export type ActivityFlowDTO = { order: number; isHidden: boolean; activityIds: Array; + autoAssign: boolean; }; export type EventAvailabilityDto = { diff --git a/src/shared/api/types/assignment.ts b/src/shared/api/types/assignment.ts new file mode 100644 index 000000000..707697fec --- /dev/null +++ b/src/shared/api/types/assignment.ts @@ -0,0 +1,28 @@ +import { SubjectDTO } from './subject'; + +import { BaseSuccessResponse } from '~/shared/api/types/base'; +export interface GetMyAssignmentsPayload { + appletId: string; +} + +type AssignmentWithActivity = { + activityId: string; + activityFlowId: null; +}; + +type AssignmentWithFlow = { + activityId: null; + activityFlowId: string; +}; + +export type HydratedAssignmentDTO = (AssignmentWithActivity | AssignmentWithFlow) & { + respondentSubject: SubjectDTO; + targetSubject: SubjectDTO; +}; + +export type MyAssignmentsDTO = { + appletId: string; + assignments: HydratedAssignmentDTO[]; +}; + +export type MyAssignmentsSuccessResponse = BaseSuccessResponse; diff --git a/src/shared/api/types/conditionalLogic.ts b/src/shared/api/types/conditionalLogic.ts new file mode 100644 index 000000000..6910f411d --- /dev/null +++ b/src/shared/api/types/conditionalLogic.ts @@ -0,0 +1,440 @@ +export type Match = 'any' | 'all'; + +export type ScoreConditionalLogic = { + id: string; + name: string; + flagScore: boolean; + match: Match; + conditions: Array; +}; + +export type ConditionalLogic = { + match: Match; + conditions: Array; +}; + +export type Condition = + | IncludesOptionCondition + | NotIncludesOptionCondition + | EqualToOptionCondition + | NotEqualToOptionCondition + | GreaterThanCondition + | LessThanCondition + | EqualCondition + | NotEqualCondition + | BetweenCondition + | OutsideOfCondition + | GreaterThanDateCondition + | LessThanDateCondition + | EqualToDateCondition + | NotEqualToDateCondition + | BetweenDatesCondition + | OutsideOfDatesCondition + | GreaterThanTimeCondition + | LessThanTimeCondition + | EqualToTimeCondition + | NotEqualToTimeCondition + | BetweenTimesCondition + | OutsideOfTimesCondition + | GreaterThanTimeRangeCondition + | LessThanTimeRangeCondition + | EqualToTimeRangeCondition + | NotEqualToTimeRangeCondition + | BetweenTimeRangeCondition + | OutsideOfTimeRangeCondition + | GreaterThanSliderRowsCondition + | LessThanSliderRowsCondition + | EqualToSliderRowsCondition + | NotEqualToSliderRowsCondition + | BetweenSliderRowsCondition + | OutsideOfSliderRowsCondition + | EqualToRowOptionCondition + | NotEqualToRowOptionCondition + | IncludesRowOptionCondition + | NotIncludesRowOptionCondition; + +export type ReportCondition = + | GreaterThanCondition + | LessThanCondition + | EqualCondition + | NotEqualCondition + | BetweenCondition + | OutsideOfCondition; + +export type IncludesOptionCondition = { + itemName: string; + type: 'INCLUDES_OPTION'; + payload: { + optionValue: string; + }; +}; + +export type NotIncludesOptionCondition = { + itemName: string; + type: 'NOT_INCLUDES_OPTION'; + payload: { + optionValue: string; + }; +}; + +export type EqualToOptionCondition = { + itemName: string; + type: 'EQUAL_TO_OPTION'; + payload: { + optionValue: string; + }; +}; + +export type NotEqualToOptionCondition = { + itemName: string; + type: 'NOT_EQUAL_TO_OPTION'; + payload: { + optionValue: string; + }; +}; + +export type GreaterThanCondition = { + itemName: string; + type: 'GREATER_THAN'; + payload: { + value: number; + }; +}; + +export type LessThanCondition = { + itemName: string; + type: 'LESS_THAN'; + payload: { + value: number; + }; +}; + +export type EqualCondition = { + itemName: string; + type: 'EQUAL'; + payload: { + value: number; + }; +}; + +export type NotEqualCondition = { + itemName: string; + type: 'NOT_EQUAL'; + payload: { + value: number; + }; +}; + +export type BetweenCondition = { + itemName: string; + type: 'BETWEEN'; + payload: { + minValue: number; + maxValue: number; + }; +}; + +export type OutsideOfCondition = { + itemName: string; + type: 'OUTSIDE_OF'; + payload: { + minValue: number; + maxValue: number; + }; +}; + +export type GreaterThanDateCondition = { + itemName: string; + type: 'GREATER_THAN_DATE'; + payload: { + date: string; // Date here + }; +}; + +export type LessThanDateCondition = { + itemName: string; + type: 'LESS_THAN_DATE'; + payload: { + date: string; // Date here + }; +}; + +export type EqualToDateCondition = { + itemName: string; + type: 'EQUAL_TO_DATE'; + payload: { + date: string; // Date here + }; +}; + +export type NotEqualToDateCondition = { + itemName: string; + type: 'NOT_EQUAL_TO_DATE'; + payload: { + date: string; // Date here + }; +}; + +export type BetweenDatesCondition = { + itemName: string; + type: 'BETWEEN_DATES'; + payload: { + minDate: string; // Date here + maxDate: string; // Date here + }; +}; + +export type OutsideOfDatesCondition = { + itemName: string; + type: 'OUTSIDE_OF_DATES'; + payload: { + minDate: string; // Date here + maxDate: string; // Date here + }; +}; + +export type GreaterThanTimeCondition = { + itemName: string; + type: 'GREATER_THAN_TIME'; + payload: { + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type LessThanTimeCondition = { + itemName: string; + type: 'LESS_THAN_TIME'; + payload: { + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type EqualToTimeCondition = { + itemName: string; + type: 'EQUAL_TO_TIME'; + payload: { + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type NotEqualToTimeCondition = { + itemName: string; + type: 'NOT_EQUAL_TO_TIME'; + payload: { + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type BetweenTimesCondition = { + itemName: string; + type: 'BETWEEN_TIMES'; + payload: { + minTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + maxTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type OutsideOfTimesCondition = { + itemName: string; + type: 'OUTSIDE_OF_TIMES'; + payload: { + minTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + maxTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type GreaterThanTimeRangeCondition = { + itemName: string; + type: 'GREATER_THAN_TIME_RANGE'; + payload: { + fieldName: 'from' | 'to'; + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type LessThanTimeRangeCondition = { + itemName: string; + type: 'LESS_THAN_TIME_RANGE'; + payload: { + fieldName: 'from' | 'to'; + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type EqualToTimeRangeCondition = { + itemName: string; + type: 'EQUAL_TO_TIME_RANGE'; + payload: { + fieldName: 'from' | 'to'; + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type NotEqualToTimeRangeCondition = { + itemName: string; + type: 'NOT_EQUAL_TO_TIME_RANGE'; + payload: { + fieldName: 'from' | 'to'; + time: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type BetweenTimeRangeCondition = { + itemName: string; + type: 'BETWEEN_TIME_RANGE'; + payload: { + fieldName: 'from' | 'to'; + minTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + maxTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type OutsideOfTimeRangeCondition = { + itemName: string; + type: 'OUTSIDE_OF_TIME_RANGE'; + payload: { + fieldName: 'from' | 'to'; + minTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + maxTime: { + hours: number; // 0 - 23 + minutes: number; // 0 - 59 + }; + }; +}; + +export type GreaterThanSliderRowsCondition = { + itemName: string; + type: 'GREATER_THAN_SLIDER_ROWS'; + payload: { + rowIndex: number; + value: number; + }; +}; + +export type LessThanSliderRowsCondition = { + itemName: string; + type: 'LESS_THAN_SLIDER_ROWS'; + payload: { + rowIndex: number; + value: number; + }; +}; + +export type EqualToSliderRowsCondition = { + itemName: string; + type: 'EQUAL_TO_SLIDER_ROWS'; + payload: { + rowIndex: number; + value: number; + }; +}; + +export type NotEqualToSliderRowsCondition = { + itemName: string; + type: 'NOT_EQUAL_TO_SLIDER_ROWS'; + payload: { + rowIndex: number; + value: number; + }; +}; + +export type BetweenSliderRowsCondition = { + itemName: string; + type: 'BETWEEN_SLIDER_ROWS'; + payload: { + rowIndex: number; + minValue: number; + maxValue: number; + }; +}; + +export type OutsideOfSliderRowsCondition = { + itemName: string; + type: 'OUTSIDE_OF_SLIDER_ROWS'; + payload: { + rowIndex: number; + minValue: number; + maxValue: number; + }; +}; + +export type EqualToRowOptionCondition = { + itemName: string; + type: 'EQUAL_TO_ROW_OPTION'; + payload: { + rowIndex: number; + optionValue: string; + }; +}; + +export type NotEqualToRowOptionCondition = { + itemName: string; + type: 'NOT_EQUAL_TO_ROW_OPTION'; + payload: { + rowIndex: number; + optionValue: string; + }; +}; + +export type IncludesRowOptionCondition = { + itemName: string; + type: 'INCLUDES_ROW_OPTION'; + payload: { + rowIndex: number; + optionValue: string; + }; +}; + +export type NotIncludesRowOptionCondition = { + itemName: string; + type: 'NOT_INCLUDES_ROW_OPTION'; + payload: { + rowIndex: number; + optionValue: string; + }; +}; diff --git "a/src/shared/api/types/conditionalLogi\321\201.ts" "b/src/shared/api/types/conditionalLogi\321\201.ts" deleted file mode 100644 index d520b2695..000000000 --- "a/src/shared/api/types/conditionalLogi\321\201.ts" +++ /dev/null @@ -1,116 +0,0 @@ -export type Match = 'any' | 'all'; - -export type ScoreConditionalLogic = { - id: string; - name: string; - flagScore: boolean; - match: Match; - conditions: Array; -}; - -export type ConditionalLogic = { - match: Match; - conditions: Array; -}; - -export type Condition = - | IncludesOptionCondition - | NotIncludesOptionCondition - | EqualToOptionCondition - | NotEqualToOptionCondition - | GreaterThanCondition - | LessThanCondition - | EqualCondition - | NotEqualCondition - | BetweenCondition - | OutsideOfCondition; - -export type ReportCondition = - | GreaterThanCondition - | LessThanCondition - | EqualCondition - | NotEqualCondition - | BetweenCondition - | OutsideOfCondition; - -export type IncludesOptionCondition = { - itemName: string; - type: 'INCLUDES_OPTION'; - payload: { - optionValue: string; - }; -}; - -export type NotIncludesOptionCondition = { - itemName: string; - type: 'NOT_INCLUDES_OPTION'; - payload: { - optionValue: string; - }; -}; - -export type EqualToOptionCondition = { - itemName: string; - type: 'EQUAL_TO_OPTION'; - payload: { - optionValue: string; - }; -}; - -export type NotEqualToOptionCondition = { - itemName: string; - type: 'NOT_EQUAL_TO_OPTION'; - payload: { - optionValue: string; - }; -}; - -export type GreaterThanCondition = { - itemName: string; - type: 'GREATER_THAN'; - payload: { - value: number | string; - }; -}; - -export type LessThanCondition = { - itemName: string; - type: 'LESS_THAN'; - payload: { - value: number | string; - }; -}; - -export type EqualCondition = { - itemName: string; - type: 'EQUAL'; - payload: { - value: number | string; - }; -}; - -export type NotEqualCondition = { - itemName: string; - type: 'NOT_EQUAL'; - payload: { - value: number | string; - }; -}; - -export type BetweenCondition = { - itemName: string; - type: 'BETWEEN'; - payload: { - minValue: number | string; - maxValue: number | string; - }; -}; - -export type OutsideOfCondition = { - itemName: string; - type: 'OUTSIDE_OF'; - payload: { - minValue: number | string; - maxValue: number | string; - }; -}; diff --git a/src/shared/api/types/index.ts b/src/shared/api/types/index.ts index 34c1e5cd9..a5617f0bf 100644 --- a/src/shared/api/types/index.ts +++ b/src/shared/api/types/index.ts @@ -5,4 +5,5 @@ export * from './applet'; export * from './activity'; export * from './events'; export * from './item'; -export * from './conditionalLogiс'; +export * from './conditionalLogic'; +export * from './assignment'; diff --git a/src/shared/api/types/item.ts b/src/shared/api/types/item.ts index 3e3d8c64a..413e99f07 100644 --- a/src/shared/api/types/item.ts +++ b/src/shared/api/types/item.ts @@ -1,4 +1,4 @@ -import { ConditionalLogic } from './conditionalLogiс'; +import { ConditionalLogic } from './conditionalLogic'; export type ItemResponseTypeDTO = | 'text' diff --git a/src/shared/api/types/subject.ts b/src/shared/api/types/subject.ts index 3cf65bd5c..7fca2a366 100644 --- a/src/shared/api/types/subject.ts +++ b/src/shared/api/types/subject.ts @@ -16,6 +16,8 @@ export type SubjectDTO = { lastSeen: string | null; id: string; userId: string | null; + firstName: string; + lastName: string; }; export type GetSubjectSuccessResponse = BaseSuccessResponse; diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index 0af64c084..dec08e833 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -86,16 +86,21 @@ const ROUTES = { entityType, eventId, flowId, + targetSubjectId, }: { appletId: string; activityId: string; eventId: string; entityType: 'regular' | 'flow'; flowId: string | null; - }) => - `/protected/applets/${appletId}/activityId/${activityId}/event/${eventId}/entityType/${entityType}?${ - flowId ? `flowId=${flowId}` : '' - }`, + targetSubjectId: string | null; + }) => { + const params = new URLSearchParams(); + if (flowId) params.append('flowId', flowId); + if (targetSubjectId) params.append('targetSubjectId', targetSubjectId); + + return `/protected/applets/${appletId}/activityId/${activityId}/event/${eventId}/entityType/${entityType}?${params.toString()}`; + }, }, invitationAccept: { path: '/protected/invite/accepted', @@ -112,14 +117,25 @@ const ROUTES = { activityId, flowId, publicAppletKey, + targetSubjectId, }: { appletId: string; activityId: string; eventId: string; flowId: string | null; publicAppletKey: string | null; - }) => - `${ROUTES.autoCompletion.path}?appletId=${appletId}&eventId=${eventId}&activityId=${activityId}${flowId ? `&flowId=${flowId}` : ''}${publicAppletKey ? `&publicAppletKey=${publicAppletKey}` : ''}`, + targetSubjectId: string | null; + }) => { + const params = new URLSearchParams(); + params.append('appletId', appletId); + params.append('eventId', eventId); + params.append('activityId', activityId); + if (flowId) params.append('flowId', flowId); + if (publicAppletKey) params.append('publicAppletKey', publicAppletKey); + if (targetSubjectId) params.append('targetSubjectId', targetSubjectId); + + return `${ROUTES.autoCompletion.path}?${params.toString()}`; + }, }, }; diff --git a/src/shared/ui/Banners/Banner/Banner.tsx b/src/shared/ui/Banners/Banner/Banner.tsx index e0e83146c..ae6bdbb32 100644 --- a/src/shared/ui/Banners/Banner/Banner.tsx +++ b/src/shared/ui/Banners/Banner/Banner.tsx @@ -15,6 +15,7 @@ export const Banner = ({ onClose, hasCloseButton = !!onClose, severity = 'success', + ...rest }: BannerProps) => { const [isHovering, setIsHovering] = useState(false); const isWindowFocused = useWindowFocus(); @@ -39,6 +40,7 @@ export const Banner = ({ onMouseLeave={() => setIsHovering(false)} severity={severity} data-testid={`${severity}-banner`} + {...rest} > {typeof children === 'string' ? : children} diff --git a/src/shared/ui/CardItem/TargetSubjectLine.tsx b/src/shared/ui/CardItem/TargetSubjectLine.tsx new file mode 100644 index 000000000..b34192d88 --- /dev/null +++ b/src/shared/ui/CardItem/TargetSubjectLine.tsx @@ -0,0 +1,22 @@ +import { SubjectDTO } from '~/shared/api/types/subject'; +import { Box } from '~/shared/ui'; +import { TargetSubjectLabel } from '~/widgets/TargetSubjectLabel'; + +type Props = { + subject: SubjectDTO | null; +}; + +export const TargetSubjectLine = ({ subject }: Props) => { + if (!subject) return null; + + return ( + + + + ); +}; diff --git a/src/shared/ui/CardItem/index.tsx b/src/shared/ui/CardItem/index.tsx index bb3433255..0581825fe 100644 --- a/src/shared/ui/CardItem/index.tsx +++ b/src/shared/ui/CardItem/index.tsx @@ -1,11 +1,11 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useContext, useMemo } from 'react'; -import { Theme } from '../../constants'; -import { useCustomMediaQuery, useCustomTranslation } from '../../utils'; +import { TargetSubjectLine } from './TargetSubjectLine'; -import { Markdown } from '~/shared/ui'; -import Box from '~/shared/ui/Box'; -import Text from '~/shared/ui/Text'; +import { SurveyContext } from '~/features/PassSurvey'; +import { Theme } from '~/shared/constants'; +import { Box, Markdown, Text } from '~/shared/ui'; +import { insertAfterMedia, useCustomMediaQuery, useCustomTranslation } from '~/shared/utils'; interface CardItemProps extends PropsWithChildren { watermark?: string; @@ -20,6 +20,14 @@ export const CardItem = ({ children, markdown, isOptional, testId }: CardItemPro const { t } = useCustomTranslation(); + const context = useContext(SurveyContext); + + const processedMarkdown = useMemo(() => { + if (!context.targetSubject) return markdown; + + return insertAfterMedia(markdown, '
'); + }, [markdown, context.targetSubject]); + return ( - + + props.id === 'target-subject' ? ( + + ) : ( +
+ ), + }} + /> {isOptional && ( & { markdown: string; -} +}; -export const Markdown = (props: MarkdownProps) => { - const { isLoading, markdown } = useMarkdownExtender(props.markdown); +export const Markdown = ({ markdown: markdownProp, ...rest }: MarkdownProps) => { + const { t } = useCustomTranslation(); + const { isLoading, markdown } = useMarkdownExtender(markdownProp); if (isLoading) { - return
Loading...
; + return
{t('loading')}
; } return (
- {markdown} + + {markdown} +
); }; diff --git a/src/shared/utils/convert/date/toHourMinute.ts b/src/shared/utils/convert/date/toHourMinute.ts index 14df273d7..9430a179c 100644 --- a/src/shared/utils/convert/date/toHourMinute.ts +++ b/src/shared/utils/convert/date/toHourMinute.ts @@ -3,6 +3,17 @@ export type HourMinuteDTO = { minutes: number; }; +/** + * Converts a Date object to an object containing the local hours and minutes. + * + * @param {Date} date - The date from which to extract the local hours and minutes. + * @returns {HourMinuteDTO} An object containing the extracted local hours and minutes. + * + * @example + * const date = new Date('2024-08-28T10:30:00Z'); + * const time = dateToHourMinute(date); + * // time: { hours: 12, minutes: 30 } (depending on the local timezone) + */ export const dateToHourMinute = (date: Date): HourMinuteDTO => { return { hours: date.getHours(), @@ -10,6 +21,31 @@ export const dateToHourMinute = (date: Date): HourMinuteDTO => { }; }; +/** + * Converts a Date string to an object containing the hours and minutes as they appear in the string representation of the date, ignoring any time zone conversions. + * + * @param {String} dateStr - The date string from which to extract the raw hours and minutes. + * @returns {HourMinuteDTO} An object containing the extracted raw hours and minutes. + * @throws {Error} Throws an error if the date string format is invalid. + * + * @example + * const dateStr = new Date('Wed Aug 28 2024 10:30:00 GMT+0000 (Central European Summer Time)').toString(); + * const time = dateToHourMinuteRaw(dateStr); + * // time: { hours: 10, minutes: 30 } + */ +export const dateStringToHourMinuteRaw = (dateStr: string): HourMinuteDTO => { + const timeMatch = dateStr.match(/(\d{2}):(\d{2}):(\d{2})/); + + if (!timeMatch) { + throw new Error('[dateToHourMinuteRaw] Invalid date string format'); + } + + return { + hours: parseInt(timeMatch[1], 10), + minutes: parseInt(timeMatch[2], 10), + }; +}; + export const validateTime = (date: Date): boolean => { const hourMinute = dateToHourMinute(date); diff --git a/src/shared/utils/helpers/getSubjectName.ts b/src/shared/utils/helpers/getSubjectName.ts new file mode 100644 index 000000000..6ee1b0e9f --- /dev/null +++ b/src/shared/utils/helpers/getSubjectName.ts @@ -0,0 +1,7 @@ +import { SubjectDTO } from '~/shared/api/types/subject'; + +export const getSubjectName = ({ firstName, lastName }: SubjectDTO) => { + const lastInitial = lastName[0] ? ` ${lastName[0]}.` : ''; + + return `${firstName}${lastInitial}`; +}; diff --git a/src/shared/utils/helpers/index.ts b/src/shared/utils/helpers/index.ts index b56839b20..5511c0113 100644 --- a/src/shared/utils/helpers/index.ts +++ b/src/shared/utils/helpers/index.ts @@ -6,3 +6,5 @@ export * from './splitList'; export * from './getInitials'; export * from './delay'; export * from './cutString'; +export * from './getSubjectName'; +export * from './insertAfterMedia'; diff --git a/src/shared/utils/helpers/insertAfterMedia.test.ts b/src/shared/utils/helpers/insertAfterMedia.test.ts new file mode 100644 index 000000000..53563b232 --- /dev/null +++ b/src/shared/utils/helpers/insertAfterMedia.test.ts @@ -0,0 +1,116 @@ +import { insertAfterMedia } from './insertAfterMedia'; + +describe('insertAfterMedia', () => { + it('should insert the string before the first line containing content that does not contain solely media', () => { + const markdown = `![Image](image.jpg) +Image + + +This line does not contain media. +This line also does not contain media.`; + const inserted = 'Inserted string'; + + const result = insertAfterMedia(markdown, inserted); + + expect(result).toBe(`![Image](image.jpg) +Image + + + +Inserted string + +This line does not contain media. +This line also does not contain media.`); + }); + + it('should append the string to the end if there is no line containing content that does not contain solely media', () => { + const markdown = `![Image](image.jpg) +Image + +`; + const inserted = 'Inserted string'; + + const result = insertAfterMedia(markdown, inserted); + + expect(result).toBe(`![Image](image.jpg) +Image + + + +Inserted string`); + }); + + it('should insert the string before a line containing both media and text content', () => { + const markdown = `![Image](image.jpg) +Image + + +![Image](image.jpg) This line contains text and media. +This line does not contain media.`; + const inserted = 'Inserted string'; + + const result = insertAfterMedia(markdown, inserted); + + expect(result).toBe(`![Image](image.jpg) +Image + + + +Inserted string + +![Image](image.jpg) This line contains text and media. +This line does not contain media.`); + }); + + it('should insert the string after a line that contains media wrapped in an alignment block', () => { + const markdown = `::: hljs-center +![Image](image.jpg) +Image + + +::: +::: hljs-center Image ::: +::: hljs-right This line does not contain media. ::: +This line also does not contain media.`; + const inserted = 'Inserted string'; + + const result = insertAfterMedia(markdown, inserted); + + expect(result).toBe(`::: hljs-center +![Image](image.jpg) +Image + + +::: +::: hljs-center Image ::: + +Inserted string + +::: hljs-right This line does not contain media. ::: +This line also does not contain media.`); + }); + + it('should preserve adjacent paragraphs when inserted', () => { + const markdown = `![Image](image.jpg) + +This line does not contain media. + + + +This line also does not contain media.`; + const inserted = 'Inserted string'; + + const result = insertAfterMedia(markdown, inserted); + + expect(result).toBe(`![Image](image.jpg) + + +Inserted string + +This line does not contain media. + + + +This line also does not contain media.`); + }); +}); diff --git a/src/shared/utils/helpers/insertAfterMedia.ts b/src/shared/utils/helpers/insertAfterMedia.ts new file mode 100644 index 000000000..4e99d0550 --- /dev/null +++ b/src/shared/utils/helpers/insertAfterMedia.ts @@ -0,0 +1,34 @@ +/** + * Inserts the given string into the given markdown after the first line containing content + * that does not contain solely media. + * + * @param markdown Markdown content to parse + * @param inserted Inserted string + * @returns Processed markdown with string inserted + */ +export const insertAfterMedia = (markdown: string, inserted: string) => { + const lines = markdown.split('\n'); + // Stop at first line containing content that is not: + // - solely a media element (/