Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Constrain Take Now to availability schedule (M2-7180) #1868

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
7 changes: 4 additions & 3 deletions src/modules/Dashboard/api/api.types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { User } from 'modules/Auth/state';
import { ActivityId, AppletId } from 'shared/api';
import { Activity, Item, SingleApplet, SubscaleSetting } from 'shared/state';
import { ParticipantTag, Roles } from 'shared/consts';
import { RetentionPeriods, EncryptedAnswerSharedProps, ExportActivity } from 'shared/types';
import { Activity, Item, SingleApplet, SubscaleSetting } from 'shared/state';
import { EncryptedAnswerSharedProps, ExportActivity, RetentionPeriods } from 'shared/types';
import { Encryption } from 'shared/utils';
import { User } from 'modules/Auth/state';

export type GetAppletsParams = {
params: {
Expand Down Expand Up @@ -273,6 +273,7 @@ export type DatavizEntity = {
hasAnswer: boolean;
lastAnswerDate: string | null;
isPerformanceTask?: boolean;
periodicity?: string;
};

export type SubmitDates = {
Expand Down
184 changes: 181 additions & 3 deletions src/modules/Dashboard/components/ActivityGrid/ActivityGrid.utils.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { t } from 'i18next';
import {
isWithinInterval,
getDay,
getDate,
getYear,
getMonth,
getHours,
getMinutes,
getSeconds,
format,
isWeekend,
isAfter,
setDate,
setMonth,
setYear,
} from 'date-fns';

import { Event } from 'modules/Dashboard/state';
import { Svg } from 'shared/components/Svg';
import { MenuItem, MenuItemType } from 'shared/components';
import {
Expand All @@ -13,6 +30,163 @@ import { EditablePerformanceTasks } from 'modules/Builder/features/Activities/Ac

import { ActivityActions, ActivityActionProps } from './ActivityGrid.types';

/**
* this function checks if the current date is within the interval passed as arguments
* @param start Date
* @param end Date
* @returns boolean
*/
function datetimeIsWithinInterval(start: Date, end: Date) {
const today = new Date();
if (isAfter(start, end)) {
console.error('The start date is after the end date, be sure to check the dates');

// using the isWithinInterval function with the start and end dates swapped
// in case the start date is after the end date
return isWithinInterval(today, { start: end, end: start });
}

return isWithinInterval(today, { start, end });
}

/**
* this function returns the values of the date represented as an object
* @param date Date
* @returns object
*/
function getDateValues(date: Date) {
if (!date || !(date instanceof Date)) {
console.error('The date is not valid, be sure to pass a valid date');

return {
day: 0,
month: 0,
year: 0,
hour: 0,
minute: 0,
second: 0,
dayInWeek: 0,
date: new Date(),
};
}

return {
day: getDate(date),
month: getMonth(date) + 1,
year: getYear(date),
hour: getHours(date),
minute: getMinutes(date),
second: getSeconds(date),
dayInWeek: getDay(date),
date,
};
}

const PERIODICITY_VALUES = {
ONCE: 'ONCE',
ALWAYS: 'ALWAYS',
DAILY: 'DAILY',
WEEKLY: 'WEEKLY',
WEEKDAYS: 'WEEKDAYS',
MONTHLY: 'MONTHLY',
};

/**
* this function formats the date as YYYY-MM-DD
* @param date Date
* @returns string
*/
function formatDateAsYYYYMMDD(date: Date) {
if (!date || !(date instanceof Date)) {
console.error('The date is not valid, be sure to pass a valid date');
}

return format(date, 'yyyy-MM-dd');
}

/**
* this function validates if the date is in range based on the PeriodicityTypes object
* the options of validation are:
* - if the periodicity is always, which means that the activity is always available
* - if the periodicity is once, which means that the activity is available only once
* - if the periodicity is daily, which means that the activity is available every day
* - if the periodicity is weekly, which means that the activity is available every week at the same week day
* - if the periodicity is weekdays, which means that the activity is available every weekday and not in weekends
* - if the periodicity is monthly, which means that the activity is available every month at the same day
* - if the periodicity is not set
* @param scheduleEvent PeriodicityType
* @returns boolean
*/
function validateIfDateIsInRange(scheduleEvent: Event) {
if (!scheduleEvent) {
console.error('The periodicity is not set, be sure to pass a valid periodicity');

return false;
}

if (
scheduleEvent.accessBeforeSchedule ||
scheduleEvent.periodicity.type === PERIODICITY_VALUES.ALWAYS
) {
return true;
}

const currentDate = new Date();
const currentTimeValues = getDateValues(currentDate);
const startDate = new Date(
`${
scheduleEvent.periodicity.startDate
? scheduleEvent.periodicity.startDate
: formatDateAsYYYYMMDD(currentDate)
}T${scheduleEvent.startTime}`,
);
const endDate = new Date(
`${
scheduleEvent?.periodicity.endDate
? scheduleEvent.periodicity.endDate
: formatDateAsYYYYMMDD(currentDate)
}T${scheduleEvent.endTime}`,
);

const startTimeValues = getDateValues(startDate);

if (
scheduleEvent.periodicity.type === PERIODICITY_VALUES.ONCE ||
scheduleEvent.periodicity.type === PERIODICITY_VALUES.DAILY
) {
const todayEndDateWithEndTime = setYear(
setMonth(setDate(endDate, currentTimeValues.day), currentTimeValues.month - 1),
currentTimeValues.year,
);

return datetimeIsWithinInterval(startDate, todayEndDateWithEndTime);
} else if (scheduleEvent.periodicity.type === PERIODICITY_VALUES.WEEKLY) {
if (currentTimeValues.dayInWeek !== startTimeValues.dayInWeek) {
return false;
}

return datetimeIsWithinInterval(startDate, endDate);
} else if (scheduleEvent.periodicity.type === PERIODICITY_VALUES.WEEKDAYS) {
if (isWeekend(currentDate)) {
return false;
}

const todayEndDateWithEndTime = setYear(
setMonth(setDate(endDate, currentTimeValues.day), currentTimeValues.month - 1),
currentTimeValues.year,
);

return datetimeIsWithinInterval(startDate, todayEndDateWithEndTime);
} else if (
scheduleEvent.periodicity.type === PERIODICITY_VALUES.MONTHLY &&
currentTimeValues.day === startTimeValues.day
) {
return datetimeIsWithinInterval(startDate, endDate);
}

return false;
}

export const getActivityActions = ({
actions: { editActivity, exportData, assignActivity, takeNow },
appletId,
Expand All @@ -34,7 +208,9 @@ export const getActivityActions = ({
const { id: activityId } = activity;
const isWebUnsupported = !getIsWebSupported(activity.items);

if (!activityId) return [];
if (!activityId || !activity?.event) return [];

const isInRange = validateIfDateIsInRange(activity?.event);

return [
{
Expand Down Expand Up @@ -68,8 +244,10 @@ export const getActivityActions = ({
title: t('takeNow.menuItem'),
context: { appletId, activityId },
isDisplayed: canDoTakeNow,
disabled: isWebUnsupported,
tooltip: isWebUnsupported && t('activityIsMobileOnly'),
disabled: isWebUnsupported || !isInRange,
tooltip:
(!isInRange && t('activityIsUnavailableAtThisTime')) ||
(isWebUnsupported && t('activityIsMobileOnly')),
'data-testid': `${dataTestId}-activity-take-now`,
},
];
Expand Down
34 changes: 28 additions & 6 deletions src/modules/Dashboard/features/Applet/Activities/Activities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';

import { getAppletActivitiesApi } from 'api';
import { getAppletActivitiesApi, getEventsApi } from 'api';
import { Spinner } from 'shared/components';
import { useAsync } from 'shared/hooks';
import { ActivityGrid, useActivityGrid } from 'modules/Dashboard/components/ActivityGrid';
Expand All @@ -17,27 +17,48 @@ import { ActivitiesToolbar } from './ActivitiesToolbar';

const dataTestId = 'dashboard-applet-activities';

function mergeInfo(activities: Activity[], valueEvents: any) {
return activities.map((activity) => {
const event = valueEvents?.data?.result?.find((event: any) => event.activityId === activity.id);

return { ...activity, event };
});
}

export const Activities = () => {
const [activityId, setActivityId] = useState<string>();
const [showExportPopup, setShowExportPopup] = useState(false);
const { result: appletData } = applet.useAppletData() ?? {};
const { appletId } = useParams();
const { t } = useTranslation('app');
const { execute, isLoading, value, previousValue } = useAsync(getAppletActivitiesApi);
const {
execute: executeEvents,
isLoading: loadingEvents,
value: valueEvents,
} = useAsync(getEventsApi);

const activities: Activity[] = useMemo(
() => (value ?? previousValue)?.data?.result?.activitiesDetails ?? [],
[value, previousValue],
);
const flows: ActivityFlow[] =
(value ?? previousValue)?.data?.result?.appletDetail?.activityFlows ?? [];

const showContent =
(isLoading && previousValue?.data?.result?.activitiesDetails?.length > 0) || !isLoading;
(isLoading && previousValue?.data?.result?.activitiesDetails?.length > 0) ||
!isLoading ||
!loadingEvents;

const useMemoizedActivities = useMemo(
() => mergeInfo(activities, valueEvents),
[activities, valueEvents],
);

const { formatRow, TakeNowModal } = useActivityGrid(
dataTestId,
{
result: activities,
result: useMemoizedActivities,
count: activities.length,
},
useCallback((activityId: string) => {
Expand All @@ -47,15 +68,16 @@ export const Activities = () => {
);

const formattedActivities = useMemo(
() => activities.map((activity) => formatRow(activity)),
[activities, formatRow],
() => useMemoizedActivities.map((activity) => formatRow(activity)),
[useMemoizedActivities, formatRow],
);

useEffect(() => {
if (!appletId) return;

execute({ params: { appletId } });
}, [appletId, execute]);
executeEvents({ appletId });
}, [appletId, execute, executeEvents]);

return (
<StyledFlexColumn sx={{ gap: 2.4, height: '100%' }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { FormState } from 'react-hook-form';
import * as yup from 'yup';

import {
TimerType,
Periodicity,
EventNotifications,
EventReminder,
NotificationType,
Periodicity,
TimerType,
} from 'modules/Dashboard/api';
import { CalendarEvent } from 'modules/Dashboard/state';

Expand Down
24 changes: 12 additions & 12 deletions src/modules/Dashboard/state/Applets/Applets.schema.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import { EventNotifications, EventReminder, Periodicity, TimerType } from 'api';
import { BaseSchema } from 'shared/state/Base';
import { Periodicity, TimerType, EventNotifications, EventReminder } from 'api';

export type Event = {
startTime: string | null;
endTime: string | null;
accessBeforeSchedule: boolean | null;
oneTimeCompletion: boolean | null;
timer: number | null;
timerType: TimerType;
activityId: string | null;
endTime: string | null;
flowId: string | null;
id: string;
notification: {
notifications: EventNotifications;
reminder: EventReminder;
} | null;
oneTimeCompletion: boolean | null;
periodicity: {
type: Periodicity;
startDate: string | null;
endDate: string | null;
selectedDate: string | null;
};
respondentId: string | null;
activityId: string | null;
flowId: string | null;
notification: {
notifications: EventNotifications;
reminder: EventReminder;
} | null;
startTime: string | null;
timer: number | null;
timerType: TimerType;
};

export type AppletsSchema = {
Expand Down
1 change: 1 addition & 0 deletions src/resources/app-en.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"activityImg": "Activity Image",
"activityIncomplete": "Activity incomplete",
"activityIsMobileOnly": "This activity must be completed on the Mindlogger mobile app.",
"activityIsUnavailableAtThisTime" : "This activity is currently unavailable for completion based on its schedule.",
"activityIsRequired": "At least 1 activity is required.",
"activityItemsFlow": "Item Flow",
"activityItemsFlowDescription": "To determine the order of transition from one Item to another, an Item Flow can be created.",
Expand Down
1 change: 1 addition & 0 deletions src/resources/app-fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"activityIncomplete": "Activité incomplète",
"activityIsRequired": "Au moins 1 activité est requise.",
"activityIsMobileOnly": "Cette activité doit être réalisée sur l'application mobile Mindlogger.",
"activityIsUnavailableAtThisTime" : "Cette activité est actuellement indisponible pour être complétée en fonction de son calendrier.",
"activityItemsFlow": "Flux d'éléments",
"activityItemsFlowDescription": "Pour déterminer l'ordre de transition d'un élément à un autre, un flux d'éléments peut être créé.",
"activityItemsFlowItemTitle": "Conditionnel {{- index}}",
Expand Down
Loading
Loading