Skip to content

Commit

Permalink
feat: add By Participant view
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
farmerpaul committed Oct 8, 2024
1 parent 322646b commit 3088de6
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 22 deletions.
8 changes: 8 additions & 0 deletions src/modules/Dashboard/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,14 @@ export const getAppletTargetSubjectActivitiesApi = (
signal,
});

export const getAppletRespondentSubjectActivitiesApi = (
{ appletId, subjectId }: GetSubjectActivitiesParams,
signal?: AbortSignal,
): Promise<AxiosResponse<AppletParticipantActivitiesResponse>> =>
authApiClient.get(`/activities/applet/${appletId}/respondent/${subjectId}`, {
signal,
});

export const createTemporaryMultiInformantRelationApi = (
{ subjectId, sourceSubjectId }: CreateTemporaryMultiInformantRelation,
signal?: AbortSignal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,27 @@ 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)};
border: ${variables.borderWidth.md} solid ${variables.palette.surface_variant};
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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const ActivityListItem = ({
onClick,
children,
}: ActivityListItemProps) => (
<StyledActivityListItem as="button" onClick={onClick}>
<StyledActivityListItem as={onClick ? 'a' : 'div'} onClick={onClick}>
<StyledFlexTopCenter sx={{ gap: 0.8 }}>
<StyledActivityThumbnailContainer sx={{ width: '5.6rem', height: '5.6rem', mr: 0.8 }}>
{isFlow ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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?.();
};

Expand Down Expand Up @@ -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) => {
Expand All @@ -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 =
Expand All @@ -208,15 +228,15 @@ export const useAssignmentsTab = ({
disabled: !id,
icon: <Svg id="export" />,
title: t('exportData'),
isDisplayed: canAccessData,
isDisplayed: canAccessData && !!targetSubject,
},
{
'data-testid': `${dataTestId}-assign`,
action: () => onClickAssign(activityOrFlow),
icon: <Svg id="file-plus" />,
title: isFlow ? t('assignFlow') : t('assignActivity'),
isDisplayed: canAssign && !autoAssign,
disabled: !isAssignable || isTargetTeamMember,
isDisplayed: isAssignDisplayed,
disabled: isAssignDisabled,
tooltip: assignTooltip,
},
{
Expand Down Expand Up @@ -312,7 +332,7 @@ export const useAssignmentsTab = ({
}
activityFlowId={selectedActivityOrFlow?.isFlow ? selectedActivityOrFlow.id : undefined}
respondentSubjectId={respondentSubject?.id}
targetSubjectId={targetSubject?.id}
targetSubjectId={selectedTargetSubjectId}
/>

<ActivityUnassignDrawer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,106 @@
import { AssignmentsTab } from '../AssignmentsTab';
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';

const ByParticipant = () => <AssignmentsTab />;
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 (
<AssignmentsTab>
{isLoading && <Spinner />}

{!isLoading && !activities.length && (
<EmptyState onClickAssign={onClickAssign} isLimitedAccount={!respondentSubject?.userId} />
)}

{!!activities.length && (
<ActivitiesList
title={t('participantDetails.activitiesAndFlows')}
count={fetchedActivities?.data.count ?? 0}
>
{activities.map((activity, index) => (
<ActivityListItem key={activity.id} activityOrFlow={activity}>
<ActionsMenu
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: -6, horizontal: 'right' }}
buttonColor="secondary"
menuItems={getActionsMenu(activity)}
data-testid={`${dataTestId}-${index}`}
/>

{/* TODO: Add expand/collapse button
https://mindlogger.atlassian.net/browse/M2-7921 */}
</ActivityListItem>
))}

{/* TODO: Add lazy load button
https://mindlogger.atlassian.net/browse/M2-7827 */}
</ActivitiesList>
)}

{/* TODO: Add deleted entries
https://mindlogger.atlassian.net/browse/M2-7827
<ActivitiesList title={t('deleted')} count={deleted.length}>
{deleted.map(…)}
</ActivitiesList> */}

{modals}
</AssignmentsTab>
);
};

export default ByParticipant;
Original file line number Diff line number Diff line change
@@ -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 (
<StyledFlexAllCenter sx={{ flexDirection: 'column', flex: 1, m: 'auto', textAlign: 'center' }}>
<StyledFlexColumn sx={{ alignItems: 'center', gap: 1.6, maxWidth: '50.7rem' }}>
<Svg id="by-participant" width="80" height="80" fill={variables.palette.outline} />
<StyledHeadline as="h2" sx={{ color: variables.palette.outline, m: 0 }}>
{isLimitedAccount ? t('byParticipantEmptyLimitedAccount') : t('byParticipantEmpty')}
</StyledHeadline>
</StyledFlexColumn>
{!isLimitedAccount && (
<Button variant="contained" color="primary" onClick={onClickAssign} sx={{ mt: 2.4 }}>
{t('assignActivityButton')}
</Button>
)}
</StyledFlexAllCenter>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type EmptyStateProps = {
onClickAssign: () => void;
isLimitedAccount: boolean;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './EmptyState';
2 changes: 2 additions & 0 deletions src/resources/app-en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/resources/app-fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down

0 comments on commit 3088de6

Please sign in to comment.