From a1656b18649e5a009ecc605e8b3115faaed4c898 Mon Sep 17 00:00:00 2001 From: farmerpaul Date: Mon, 7 Oct 2024 20:28:38 -0300 Subject: [PATCH] feat: add By Participant view Add support to call new API endpoint for By Participant view, `/activities/applet/${appletId}/respondent/${subjectId}`. Add empty states to By Participant view and make adjustments to `ActivityListItem` component to support the new view. Fix some menu item logic to detect assignments correctly from the new view and handle disabled menu items properly for hidden activities/flows. --- src/modules/Dashboard/api/api.ts | 8 ++ .../ActivityListItem.styles.ts | 18 ++- .../ActivityListItem/ActivityListItem.tsx | 2 +- .../AssignmentsTab/AssignmentsTab.hooks.tsx | 46 +++++--- .../ByParticipant/ByParticipant.tsx | 105 +++++++++++++++++- .../ByParticipant/EmptyState/EmptyState.tsx | 27 +++++ .../EmptyState/EmptyState.types.ts | 4 + .../ByParticipant/EmptyState/index.ts | 1 + src/resources/app-en.json | 2 + src/resources/app-fr.json | 2 + 10 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.tsx create mode 100644 src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.types.ts create mode 100644 src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/index.ts diff --git a/src/modules/Dashboard/api/api.ts b/src/modules/Dashboard/api/api.ts index 30a2157729..6374522a2d 100644 --- a/src/modules/Dashboard/api/api.ts +++ b/src/modules/Dashboard/api/api.ts @@ -941,6 +941,14 @@ export const getAppletTargetSubjectActivitiesApi = ( signal, }); +export const getAppletRespondentSubjectActivitiesApi = ( + { appletId, subjectId }: GetSubjectActivitiesParams, + signal?: AbortSignal, +): Promise> => + authApiClient.get(`/activities/applet/${appletId}/respondent/${subjectId}`, { + signal, + }); + export const createTemporaryMultiInformantRelationApi = ( { subjectId, sourceSubjectId }: CreateTemporaryMultiInformantRelation, signal?: AbortSignal, diff --git a/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.styles.ts b/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.styles.ts index 925bd98fd4..2b6c7f80c0 100644 --- a/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.styles.ts +++ b/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.styles.ts @@ -8,7 +8,8 @@ import { variables, } from 'shared/styles'; -export const StyledActivityListItem = styled(StyledFlexTopCenter)` +export const StyledActivityListItem = styled(StyledFlexTopCenter)( + ({ onClick }: { onClick?: () => void }) => ` flex-wrap: wrap; padding: ${theme.spacing(1.5)}; gap: ${theme.spacing(0.8, 4.8)}; @@ -16,13 +17,18 @@ export const StyledActivityListItem = styled(StyledFlexTopCenter)` border-radius: ${variables.borderRadius.lg2}; background-color: ${variables.palette.surface}; transition: ${variables.transitions.bgColor}; - cursor: pointer; - &:hover, - &:focus { - background-color: ${variables.palette.on_surface_variant_alfa8}; + ${ + onClick && + `cursor: pointer; + + &:hover, + &:focus { + background-color: ${variables.palette.on_surface_variant_alfa8}; + }` } -`; +`, +); export const StyledActivityName = styled(StyledTitleLargish)` ${ellipsisTextCss} diff --git a/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.tsx b/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.tsx index 8d946a146a..9034928c39 100644 --- a/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.tsx +++ b/src/modules/Dashboard/features/Participant/Assignments/ActivityListItem/ActivityListItem.tsx @@ -14,7 +14,7 @@ export const ActivityListItem = ({ onClick, children, }: ActivityListItemProps) => ( - + {isFlow ? ( diff --git a/src/modules/Dashboard/features/Participant/Assignments/AssignmentsTab/AssignmentsTab.hooks.tsx b/src/modules/Dashboard/features/Participant/Assignments/AssignmentsTab/AssignmentsTab.hooks.tsx index c859a67dd7..58587fb4c1 100644 --- a/src/modules/Dashboard/features/Participant/Assignments/AssignmentsTab/AssignmentsTab.hooks.tsx +++ b/src/modules/Dashboard/features/Participant/Assignments/AssignmentsTab/AssignmentsTab.hooks.tsx @@ -22,6 +22,7 @@ import { DataExportPopup } from 'modules/Dashboard/features/Respondents/Popups'; import { useTakeNowModal } from 'modules/Dashboard/components/TakeNowModal/TakeNowModal'; import { ActivityAssignDrawer, ActivityUnassignDrawer } from 'modules/Dashboard/components'; import { EditablePerformanceTasks } from 'modules/Builder/features/Activities/Activities.const'; +import { RespondentDetails } from 'modules/Dashboard/types'; import { UseAssignmentsTabProps } from './AssignmentsTab.types'; @@ -77,6 +78,14 @@ export const useAssignmentsTab = ({ const handleCloseDrawer = (shouldRefetch?: boolean) => { setShowActivityAssign(false); setShowActivityUnassign(false); + + // Allow drawer to transition out before clearing activity/flow to prevent unintended + // rendering of possible empty state before drawer closes + setTimeout(() => { + setSelectedActivityOrFlow(undefined); + setSelectedTargetSubjectId(undefined); + }, 300); + if (shouldRefetch) handleRefetch?.(); }; @@ -146,10 +155,15 @@ export const useAssignmentsTab = ({ [appletId, navigate], ); - const onClickAssign = useCallback((activityOrFlow?: ParticipantActivityOrFlow) => { - if (activityOrFlow) setSelectedActivityOrFlow(activityOrFlow); - setShowActivityAssign(true); - }, []); + const onClickAssign = useCallback( + (activityOrFlow?: ParticipantActivityOrFlow, targetSubjectArg?: RespondentDetails) => { + if (activityOrFlow) setSelectedActivityOrFlow(activityOrFlow); + setSelectedTargetSubjectId(targetSubjectArg?.id ?? targetSubject?.id); + + setShowActivityAssign(true); + }, + [targetSubject], + ); const getActionsMenu = useCallback( (activityOrFlow: ParticipantActivityOrFlow) => { @@ -174,17 +188,23 @@ export const useAssignmentsTab = ({ const isAssignable = status === ActivityAssignmentStatus.Active || status === ActivityAssignmentStatus.Inactive; const isTargetTeamMember = targetSubject?.tag === 'Team'; - const isAssigned = !!assignments?.some((a) => a.targetSubject.id === targetSubject?.id); - const isAssignDisplayed = canAssign && !autoAssign; + const isAssigned = !!assignments?.some( + (a) => + a.targetSubject.id === targetSubject?.id || + a.respondentSubject.id === respondentSubject?.id, + ); + const isAssignDisplayed = + canAssign && (!autoAssign || status === ActivityAssignmentStatus.Hidden); + const isAssignDisabled = !isAssignable || isTargetTeamMember; const isUnassignDisplayed = canAssign && isAssignable && (autoAssign || isAssigned); let assignTooltip: string | undefined; - if (isTargetTeamMember) { - assignTooltip = t('assignToTeamMemberTooltip'); - } else if (!isAssignable) { + if (status === ActivityAssignmentStatus.Hidden) { assignTooltip = isFlow ? t('assignFlowDisabledTooltip') : t('assignActivityDisabledTooltip'); + } else if (isTargetTeamMember) { + assignTooltip = t('assignToTeamMemberTooltip'); } const showDivider = @@ -208,15 +228,15 @@ export const useAssignmentsTab = ({ disabled: !id, icon: , title: t('exportData'), - isDisplayed: canAccessData, + isDisplayed: canAccessData && !!targetSubject, }, { 'data-testid': `${dataTestId}-assign`, action: () => onClickAssign(activityOrFlow), icon: , title: isFlow ? t('assignFlow') : t('assignActivity'), - isDisplayed: canAssign && !autoAssign, - disabled: !isAssignable || isTargetTeamMember, + isDisplayed: isAssignDisplayed, + disabled: isAssignDisabled, tooltip: assignTooltip, }, { @@ -312,7 +332,7 @@ export const useAssignmentsTab = ({ } activityFlowId={selectedActivityOrFlow?.isFlow ? selectedActivityOrFlow.id : undefined} respondentSubjectId={respondentSubject?.id} - targetSubjectId={targetSubject?.id} + targetSubjectId={selectedTargetSubjectId} /> ; +import { useAsync } from 'shared/hooks'; +import { getAppletRespondentSubjectActivitiesApi } from 'api'; +import { users } from 'redux/modules'; +import { ActionsMenu, Spinner } from 'shared/components'; + +import { AssignmentsTab, useAssignmentsTab } from '../AssignmentsTab'; +import { EmptyState } from './EmptyState'; +import { ActivitiesList } from '../ActivitiesList'; +import { ActivityListItem } from '../ActivityListItem'; + +const dataTestId = 'participant-details-about-participant'; + +const ByParticipant = () => { + const { t } = useTranslation('app'); + const { appletId, subjectId } = useParams(); + const { useSubject, useSubjectStatus } = users; + const isLoadingSubject = useSubjectStatus() !== 'success'; + const { result: respondentSubject } = useSubject() ?? {}; + + const { + execute: fetchActivities, + isLoading: isLoadingActivities, + value: fetchedActivities, + } = useAsync(getAppletRespondentSubjectActivitiesApi, { retainValue: true }); + + const activities = fetchedActivities?.data.result ?? []; + + const handleRefetch = useCallback(() => { + // Avoid fetching activities for respondent if respondent is a limited account + if (!appletId || !subjectId || !respondentSubject?.userId) return; + + fetchActivities({ appletId, subjectId }); + }, [appletId, fetchActivities, respondentSubject?.userId, subjectId]); + + const { + getActionsMenu, + onClickAssign, + isLoading: isLoadingHook, + modals, + } = useAssignmentsTab({ appletId, respondentSubject, handleRefetch, dataTestId }); + + /* + TODO: Handler for navigating to data when card is expanded + https://mindlogger.atlassian.net/browse/M2-7921 + const handleClickNavigateToData = (activityOrFlow: ParticipantActivityOrFlow, targetSubject: RespondentDetails) => { + if (!respondentSubject) return; + + onClickNavigateToData(activityOrFlow, targetSubject.id); + }; + */ + + useEffect(() => { + handleRefetch(); + }, [handleRefetch]); + + const isLoading = isLoadingSubject || isLoadingActivities || isLoadingHook; + + return ( + + {isLoading && } + + {!isLoading && !activities.length && ( + + )} + + {!!activities.length && ( + + {activities.map((activity, index) => ( + + + + {/* TODO: Add expand/collapse button + https://mindlogger.atlassian.net/browse/M2-7921 */} + + ))} + + {/* TODO: Add lazy load button + https://mindlogger.atlassian.net/browse/M2-7827 */} + + )} + + {/* TODO: Add deleted entries + https://mindlogger.atlassian.net/browse/M2-7827 + + {deleted.map(…)} + */} + + {modals} + + ); +}; export default ByParticipant; diff --git a/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.tsx b/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.tsx new file mode 100644 index 0000000000..4c3f134069 --- /dev/null +++ b/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.tsx @@ -0,0 +1,27 @@ +import { Button } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +import { Svg } from 'shared/components'; +import { StyledFlexAllCenter, StyledFlexColumn, StyledHeadline, variables } from 'shared/styles'; + +import { EmptyStateProps } from './EmptyState.types'; + +export const EmptyState = ({ onClickAssign, isLimitedAccount }: EmptyStateProps) => { + const { t } = useTranslation('app', { keyPrefix: 'participantDetails' }); + + return ( + + + + + {isLimitedAccount ? t('byParticipantEmptyLimitedAccount') : t('byParticipantEmpty')} + + + {!isLimitedAccount && ( + + )} + + ); +}; diff --git a/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.types.ts b/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.types.ts new file mode 100644 index 0000000000..0958402e3e --- /dev/null +++ b/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/EmptyState.types.ts @@ -0,0 +1,4 @@ +export type EmptyStateProps = { + onClickAssign: () => void; + isLimitedAccount: boolean; +}; diff --git a/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/index.ts b/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/index.ts new file mode 100644 index 0000000000..c54a68a9de --- /dev/null +++ b/src/modules/Dashboard/features/Participant/Assignments/ByParticipant/EmptyState/index.ts @@ -0,0 +1 @@ +export * from './EmptyState'; diff --git a/src/resources/app-en.json b/src/resources/app-en.json index 0fe0f18a77..9b24b2a5dd 100644 --- a/src/resources/app-en.json +++ b/src/resources/app-en.json @@ -1050,6 +1050,8 @@ "byParticipant": "By Participant", "aboutParticipant": "About Participant", "aboutParticipantEmpty": "No Activities are assigned to be completed about this Participant.", + "byParticipantEmpty": "No Activities are assigned for this Participant to complete.", + "byParticipantEmptyLimitedAccount": "This Participant has a Limited Account and cannot be assigned to complete Activities.", "aboutParticipantEmptyTeamMember": "This Participant is a Team Member and Activities cannot be completed about them.", "assignActivityButton": "Assign Activity", "activitiesAndFlows": "Activities & Flows" diff --git a/src/resources/app-fr.json b/src/resources/app-fr.json index aea518a1a4..4db0aee03f 100644 --- a/src/resources/app-fr.json +++ b/src/resources/app-fr.json @@ -1050,6 +1050,8 @@ "aboutParticipant": "Concernant le participant", "aboutParticipantEmpty": "Aucune activité n'est attribuée à être complétée à propos de ce participant.", "aboutParticipantEmptyTeamMember": "Ce participant est un membre de l'équipe et les activités ne peuvent pas être complétées à son sujet.", + "byParticipantEmpty": "Aucune activité n'est attribuée à ce participant pour être complétée.", + "byParticipantEmptyLimitedAccount": "Ce participant a un compte limité et ne peut pas être assigné pour compléter des activités.", "assignActivityButton": "Attribuer une activité", "activitiesAndFlows": "Activités & Flux" },