From 009f768820024fa09e99e89ef21e0a195d36c3e5 Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Thu, 15 Aug 2024 17:06:54 -0700 Subject: [PATCH 1/8] DESENG-667: Add engagement config summary section to admin view --- CHANGELOG.MD | 9 + .../common/Communication/StatusIcon.tsx | 10 +- .../common/Indicators/StatusChip.tsx | 10 +- .../common/Layout/SystemMessage.tsx | 4 +- .../DateRangePickerWithCalculation.tsx | 8 +- .../EngagementCreateAction.tsx} | 2 +- .../admin/config/EngagementUpdateAction.tsx | 65 +++++ .../EngagementVisibilityControl.tsx | 10 +- .../FeedbackMethodSelector.tsx | 4 +- .../LanguageLoader.tsx} | 0 .../{create => config}/LanguageManager.tsx | 84 +++--- .../admin/{create => config}/MultiSelect.tsx | 0 .../admin/{create => config}/UserManager.tsx | 3 +- .../admin/config/wizard/ConfigWizard.tsx | 147 +++++++++++ .../wizard/CreationWizard.tsx} | 16 +- .../{create/form => config/wizard}/index.tsx | 25 +- .../engagement/admin/view/ConfigSummary.tsx | 243 ++++++++++++++++++ .../engagement/admin/view/StatusChip.tsx | 36 +++ .../engagement/admin/view/index.tsx | 87 ++++++- .../public/view/EngagementLoader.tsx | 45 +++- .../engagement/public/view/index.tsx | 1 + met-web/src/routes/AuthenticatedRoutes.tsx | 47 ++-- .../src/services/engagementService/types.ts | 1 + met-web/src/styles/Theme.ts | 32 +-- 24 files changed, 755 insertions(+), 134 deletions(-) rename met-web/src/components/engagement/admin/{create => config}/DateRangePickerWithCalculation.tsx (98%) rename met-web/src/components/engagement/admin/{create/engagementCreateAction.tsx => config/EngagementCreateAction.tsx} (95%) create mode 100644 met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx rename met-web/src/components/engagement/admin/{create => config}/EngagementVisibilityControl.tsx (97%) rename met-web/src/components/engagement/admin/{create => config}/FeedbackMethodSelector.tsx (92%) rename met-web/src/components/engagement/admin/{create/languageLoader.tsx => config/LanguageLoader.tsx} (100%) rename met-web/src/components/engagement/admin/{create => config}/LanguageManager.tsx (70%) rename met-web/src/components/engagement/admin/{create => config}/MultiSelect.tsx (100%) rename met-web/src/components/engagement/admin/{create => config}/UserManager.tsx (97%) create mode 100644 met-web/src/components/engagement/admin/config/wizard/ConfigWizard.tsx rename met-web/src/components/engagement/admin/{create/index.tsx => config/wizard/CreationWizard.tsx} (69%) rename met-web/src/components/engagement/admin/{create/form => config/wizard}/index.tsx (85%) create mode 100644 met-web/src/components/engagement/admin/view/ConfigSummary.tsx create mode 100644 met-web/src/components/engagement/admin/view/StatusChip.tsx diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 6f79d05a4..79048db4f 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,12 @@ +## August 15, 2024 + +- **Feature** Add engagement configuration summary [🎟️ DESENG-667](https://citz-gdx.atlassian.net/browse/DESENG-667) + - Added a tabbed layout to the new engagement view page + - Added a new "Configuration" tab to display the engagement's configuration details + - The other tabs are blank for now, but will be filled in future tickets + - The configuration tab allows navigating back to the engagement configuration page + - Improvements to how engagements and related resources are fetched and saved + ## August 8, 2024 - **Feature** New engagement details page [🎟️ DESENG-666](https://citz-gdx.atlassian.net/browse/DESENG-666) diff --git a/met-web/src/components/common/Communication/StatusIcon.tsx b/met-web/src/components/common/Communication/StatusIcon.tsx index 1a3c5f343..68fe3d522 100644 --- a/met-web/src/components/common/Communication/StatusIcon.tsx +++ b/met-web/src/components/common/Communication/StatusIcon.tsx @@ -26,24 +26,24 @@ type IconWeight = 'solid' | 'regular' | 'light'; export const StatusIcon = ({ status, color, - weight = 'solid', + weight = 'regular', ...props }: { - status: 'success' | 'warning' | 'error' | 'info'; + status: 'success' | 'warning' | 'danger' | 'info'; color?: string; weight?: IconWeight; } & Partial<FontAwesomeIconProps>) => { let iconMap = { success: faCheckCircle, warning: faExclamationTriangle, - error: faExclamationCircle, + danger: faExclamationCircle, info: faInfoCircle, }; if (weight === 'regular') { iconMap = { success: faCheckCircleRegular, warning: faExclamationTriangleRegular, - error: faExclamationCircleRegular, + danger: faExclamationCircleRegular, info: faInfoCircleRegular, }; } @@ -51,7 +51,7 @@ export const StatusIcon = ({ iconMap = { success: faCheckCircleLight, warning: faExclamationTriangleLight, - error: faExclamationCircleLight, + danger: faExclamationCircleLight, info: faInfoCircleLight, }; } diff --git a/met-web/src/components/common/Indicators/StatusChip.tsx b/met-web/src/components/common/Indicators/StatusChip.tsx index 3ae08476c..3ae31ff15 100644 --- a/met-web/src/components/common/Indicators/StatusChip.tsx +++ b/met-web/src/components/common/Indicators/StatusChip.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Chip as MuiChip, Skeleton, useTheme } from '@mui/material'; +import { ChipProps as MuiChipProps, Chip as MuiChip, Skeleton, useTheme } from '@mui/material'; import { colors } from '..'; import { SubmissionStatus } from 'constants/engagementStatus'; @@ -23,12 +23,17 @@ export const getStatusFromStatusId = (statusId: SubmissionStatus): StatusText => } }; -export const EngagementStatusChip: React.FC<ChipProps> = ({ label: customLabel, statusId: status }) => { +export const EngagementStatusChip: React.FC<ChipProps & Partial<MuiChipProps>> = ({ + label: customLabel, + statusId: status, + ...props +}) => { const statusText = getStatusFromStatusId(status); const theme = useTheme(); const invert = theme.palette.mode === 'dark'; return ( <MuiChip + {...props} label={customLabel || statusText} className={`status-chip status-chip-${statusText.toLowerCase()} ${invert ? 'status-chip-invert' : ''}`} sx={{ @@ -74,6 +79,7 @@ export const EngagementStatusChip: React.FC<ChipProps> = ({ label: customLabel, borderColor: colors.surface.gray[100], color: colors.surface.gray[40], }, + ...props.sx, }} /> ); diff --git a/met-web/src/components/common/Layout/SystemMessage.tsx b/met-web/src/components/common/Layout/SystemMessage.tsx index 85bea6435..6b33cf220 100644 --- a/met-web/src/components/common/Layout/SystemMessage.tsx +++ b/met-web/src/components/common/Layout/SystemMessage.tsx @@ -14,7 +14,7 @@ export const SystemMessage = ({ children, ...props }: { - status: 'success' | 'warning' | 'error' | 'info'; + status: 'success' | 'warning' | 'danger' | 'info'; onDismiss?: () => void; color?: string; coloredBackground?: boolean; @@ -30,7 +30,7 @@ export const SystemMessage = ({ maxWidth: { xs: '100%', md: '700px' }, borderRadius: '8px', backgroundColor: coloredBackground ? colors.notification[status].tint : 'transparent', - color: 'type.primary', + color: 'text.primary', padding: '0.8rem 1rem', paddingLeft: { xs: '0.5rem', md: '1rem' }, border: `1px solid ${colors.notification[status].shade}`, diff --git a/met-web/src/components/engagement/admin/create/DateRangePickerWithCalculation.tsx b/met-web/src/components/engagement/admin/config/DateRangePickerWithCalculation.tsx similarity index 98% rename from met-web/src/components/engagement/admin/create/DateRangePickerWithCalculation.tsx rename to met-web/src/components/engagement/admin/config/DateRangePickerWithCalculation.tsx index 83e617355..104caea8f 100644 --- a/met-web/src/components/engagement/admin/create/DateRangePickerWithCalculation.tsx +++ b/met-web/src/components/engagement/admin/config/DateRangePickerWithCalculation.tsx @@ -44,12 +44,16 @@ export const DateRangePickerWithCalculation = () => { if (name === 'end_date') { trigger('end_date'); } - if (!value?.end_date) return; - setNumberOfDays(value.end_date.clone().add(1, 'second').diff(value.start_date, 'days')); }); return () => subscription.unsubscribe(); }, [watch]); + useEffect(() => { + if (startDate && endDate) { + setNumberOfDays(endDate.clone().add(1, 'second').diff(startDate, 'days')); + } + }, [startDate, endDate]); + const getDayStyle = (props: PickersDayProps<Dayjs | null>) => { const standardStyle = { margin: 0, diff --git a/met-web/src/components/engagement/admin/create/engagementCreateAction.tsx b/met-web/src/components/engagement/admin/config/EngagementCreateAction.tsx similarity index 95% rename from met-web/src/components/engagement/admin/create/engagementCreateAction.tsx rename to met-web/src/components/engagement/admin/config/EngagementCreateAction.tsx index 8a122c7c8..bfc8a3f05 100644 --- a/met-web/src/components/engagement/admin/create/engagementCreateAction.tsx +++ b/met-web/src/components/engagement/admin/config/EngagementCreateAction.tsx @@ -26,7 +26,7 @@ export const engagementCreateAction: ActionFunction = async ({ request }) => { formData.getAll('users').forEach((user_id) => { addTeamMemberToEngagement({ user_id: user_id.toString(), engagement_id: engagement.id }); }); - return redirect(`/engagements/${engagement.id}/form`); + return redirect(`/engagements/${engagement.id}/view`); }; export default engagementCreateAction; diff --git a/met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx b/met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx new file mode 100644 index 000000000..a560ad13b --- /dev/null +++ b/met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx @@ -0,0 +1,65 @@ +import { ENGAGEMENT_MEMBERSHIP_STATUS } from 'models/engagementTeamMember'; +import { ActionFunction, redirect } from 'react-router-dom'; +import { patchEngagement } from 'services/engagementService'; +import { patchEngagementSlug } from 'services/engagementSlugService'; +import { + addTeamMemberToEngagement, + revokeMembership, + reinstateMembership, + getTeamMembers, +} from 'services/membershipService'; + +export const engagementUpdateAction: ActionFunction = async ({ request, params }) => { + const formData = (await request.formData()) as FormData; + const engagementId = Number(params.engagementId); + await patchEngagement({ + id: engagementId, + name: formData.get('name') as string, + start_date: formData.get('start_date') as string, + end_date: formData.get('end_date') as string, + is_internal: formData.get('is_internal') === 'true', + }); + try { + await patchEngagementSlug({ + engagement_id: engagementId, + slug: formData.get('slug') as string, + }); + } catch (e) { + console.error('Error updating engagement slug', e); + } + + const currentTeamMembers = await getTeamMembers({ engagement_id: engagementId }); + const users = formData.getAll('users') as string[]; + const usersSet = new Set(users); + + try { + // Process deactivated users for reinstatement (and active users for revocation) + // Caution - headaches ahead! There is a big difference between user_id and user.external_id + for (const member of currentTeamMembers) { + const isUserInForm = usersSet.has(String(member.user.external_id)); + if (member.status !== ENGAGEMENT_MEMBERSHIP_STATUS.Active) { + if (isUserInForm) { + // If the user was previously deactivated, reinstate them + reinstateMembership(engagementId, member.user_id); + } + } else { + if (!isUserInForm) { + // If the user was previously active but is not in the form, revoke their membership + revokeMembership(engagementId, member.user_id); + } + } + // Remove all known users from the set so we can add new members in the next step + usersSet.delete(String(member.user.external_id)); + } + // Add new members that weren't in the current team members list + for (const user of usersSet) { + addTeamMemberToEngagement({ user_id: user, engagement_id: engagementId }); + } + } catch (e) { + console.error('Error updating team members', e); + } + + return redirect(`/engagements/${engagementId}/view`); +}; + +export default engagementUpdateAction; diff --git a/met-web/src/components/engagement/admin/create/EngagementVisibilityControl.tsx b/met-web/src/components/engagement/admin/config/EngagementVisibilityControl.tsx similarity index 97% rename from met-web/src/components/engagement/admin/create/EngagementVisibilityControl.tsx rename to met-web/src/components/engagement/admin/config/EngagementVisibilityControl.tsx index 2ab6ddfc4..a5850c250 100644 --- a/met-web/src/components/engagement/admin/create/EngagementVisibilityControl.tsx +++ b/met-web/src/components/engagement/admin/config/EngagementVisibilityControl.tsx @@ -21,11 +21,11 @@ const EngagementVisibilityControl = () => { const isInternal = watch('is_internal'); const formSlug = watch('slug'); const isConfirmed = watch('_visibilityConfirmed'); - const setIsConfirmed = (value: boolean) => setValue('_visibilityConfirmed', value); + const setIsConfirmed = (value: boolean) => setValue('_visibilityConfirmed', value, { shouldDirty: true }); const [isEditing, setIsEditing] = React.useState(false); const [currentSlug, setCurrentSlug] = React.useState(formSlug); - const [hasBeenEdited, setHasBeenEdited] = React.useState(false); + const [hasBeenEdited, setHasBeenEdited] = React.useState(isConfirmed); useEffect(() => { const subscription = watch((value, { name, type }) => { @@ -41,7 +41,7 @@ const EngagementVisibilityControl = () => { }) .join('') .toLowerCase(); - setValue('slug', newSlug); + setValue('slug', newSlug, { shouldDirty: true }); setCurrentSlug(newSlug); } }); @@ -125,7 +125,7 @@ const EngagementVisibilityControl = () => { onClick={() => { setIsConfirmed(true); setHasBeenEdited(true); - setValue('slug', currentSlug); + setValue('slug', currentSlug, { shouldDirty: true }); }} > Confirm @@ -178,7 +178,7 @@ const EngagementVisibilityControl = () => { disabled={!currentSlug} variant="primary" onClick={() => { - setValue('slug', currentSlug); + setValue('slug', currentSlug, { shouldDirty: true }); setHasBeenEdited(true); setIsConfirmed(true); setIsEditing(false); diff --git a/met-web/src/components/engagement/admin/create/FeedbackMethodSelector.tsx b/met-web/src/components/engagement/admin/config/FeedbackMethodSelector.tsx similarity index 92% rename from met-web/src/components/engagement/admin/create/FeedbackMethodSelector.tsx rename to met-web/src/components/engagement/admin/config/FeedbackMethodSelector.tsx index dff2c1415..9d8c3e9b4 100644 --- a/met-web/src/components/engagement/admin/create/FeedbackMethodSelector.tsx +++ b/met-web/src/components/engagement/admin/config/FeedbackMethodSelector.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Grid, Checkbox, FormControlLabel } from '@mui/material'; import { SystemMessage } from 'components/common/Layout/SystemMessage'; -import { EngagementConfigurationData } from './form'; +import { EngagementConfigurationData } from './wizard'; import { useFormContext } from 'react-hook-form'; export const FeedbackMethodSelector = () => { @@ -26,6 +26,7 @@ export const FeedbackMethodSelector = () => { checked ? [...watch('feedback_methods'), 'survey'] : watch('feedback_methods').filter((m) => m !== 'survey'), + { shouldDirty: true }, ); }} /> @@ -40,6 +41,7 @@ export const FeedbackMethodSelector = () => { checked ? [...watch('feedback_methods'), '3rd_party'] : watch('feedback_methods').filter((m) => m !== '3rd_party'), + { shouldDirty: true }, ); }} /> diff --git a/met-web/src/components/engagement/admin/create/languageLoader.tsx b/met-web/src/components/engagement/admin/config/LanguageLoader.tsx similarity index 100% rename from met-web/src/components/engagement/admin/create/languageLoader.tsx rename to met-web/src/components/engagement/admin/config/LanguageLoader.tsx diff --git a/met-web/src/components/engagement/admin/create/LanguageManager.tsx b/met-web/src/components/engagement/admin/config/LanguageManager.tsx similarity index 70% rename from met-web/src/components/engagement/admin/create/LanguageManager.tsx rename to met-web/src/components/engagement/admin/config/LanguageManager.tsx index 6bcc04dde..d334800e3 100644 --- a/met-web/src/components/engagement/admin/create/LanguageManager.tsx +++ b/met-web/src/components/engagement/admin/config/LanguageManager.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { Box, FormControlLabel, Grid, Radio, RadioGroup, TextField } from '@mui/material'; import { textInputStyles } from 'components/common/Input/TextInput'; -import { useAsyncValue } from 'react-router-dom'; +import { useAsyncValue, useFetcher } from 'react-router-dom'; import { BodyText } from 'components/common/Typography'; import { When } from 'react-if'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -13,50 +13,58 @@ import MultiSelect from './MultiSelect'; import { SystemMessage } from 'components/common/Layout/SystemMessage'; export const LanguageManager = () => { + const SINGLE_LANGUAGE = [{ code: 'en', name: 'English' }] as Language[]; + const REQUIRED_LANGUAGES = [ + { code: 'en', name: 'English' }, + { code: 'fr', name: 'French' }, + ] as Language[]; + const requiredLanguageCodes = REQUIRED_LANGUAGES.map((l) => l.code); + const engagementForm = useFormContext(); const { setValue, watch } = engagementForm; const selectedLanguages = watch('languages') as Language[]; - const [isSingleLanguage, setIsSingleLanguage] = React.useState<boolean | null>(null); - const requiredLanguages = isSingleLanguage !== false ? ['en'] : ['en', 'fr']; - const availableLanguages = useAsyncValue() as Language[]; - const requiredLanguagesAvailable = requiredLanguages.filter((l) => - availableLanguages.map((l) => l.code).includes(l), - ); + // const [isSingleLanguage, setIsSingleLanguage] = React.useState<boolean | null>(null); + const fetcher = useFetcher(); + const fetcherData = fetcher.data as { languages: Language[] } | undefined; + const { languages: availableLanguages } = fetcherData ?? { languages: [] }; const [searchTerm, setSearchTerm] = React.useState(''); - useEffect(() => { - // Don't do anything if language multiplicity has not been indicated - if (isSingleLanguage === null) return; + const determineSingleLanguage = (languages: Language[]) => { + if (languages.length === 0) return null; + if (languages.length === 1) return true; + return false; + }; + const isSingleLanguage = determineSingleLanguage(selectedLanguages); - // If it's english only, remove any other languages - if (isSingleLanguage) { - setValue('languages', [{ code: 'en', name: 'English' }]); - return; - } - // If the required languages are not included, add them - if (requiredLanguagesAvailable.length) { - const languagesToAdd = availableLanguages.filter( - (l) => - requiredLanguagesAvailable.includes(l.code) && - !watch('languages') - .map((l: Language) => l.code) - .includes(l.code), - ); - setValue('languages', [...watch('languages'), ...languagesToAdd]); - } - }, [watch, isSingleLanguage]); + useEffect(() => { + fetcher.load('/languages/'); + }, []); + if (!fetcherData) return null; return ( <Box width="100%"> <RadioGroup - onChange={(e) => setIsSingleLanguage(e.target.value === 'true')} + onChange={(e) => { + if (e.target.value === 'single') { + setValue('languages', SINGLE_LANGUAGE, { shouldDirty: true }); + } + if (e.target.value === 'multi') { + const optionalLanguages = selectedLanguages.filter( + (l) => !requiredLanguageCodes.includes(l.code), + ); + setValue('languages', [...REQUIRED_LANGUAGES, ...optionalLanguages], { + shouldDirty: true, + shouldValidate: true, + }); + } + }} aria-label="Select Engagement's Language Type" name="languageType" - value={isSingleLanguage} + value={isSingleLanguage && (isSingleLanguage ? 'single' : 'multi')} > - <FormControlLabel value={true} control={<Radio />} label="English Only" /> - <FormControlLabel value={false} control={<Radio />} label="Multi-language" /> + <FormControlLabel value={'single'} control={<Radio />} label="English Only" /> + <FormControlLabel value={'multi'} control={<Radio />} label="Multi-language" /> </RadioGroup> <When condition={isSingleLanguage === false}> <SystemMessage status="warning"> @@ -68,11 +76,15 @@ export const LanguageManager = () => { if (reason === 'removeOption' && language) { setValue( 'languages', - selectedLanguages.filter((l) => l.code !== language.code), + selectedLanguages.filter((l) => l.code !== language.code, { + shouldDirty: true, + }), ); } if (reason === 'selectOption' && language) { - setValue('languages', [...selectedLanguages, language]); + setValue('languages', [...selectedLanguages, language], { + shouldDirty: true, + }); } }} options={availableLanguages ?? []} @@ -98,9 +110,9 @@ export const LanguageManager = () => { return ( <Grid container direction="row" spacing={1} alignItems="center"> <Grid item> - <BodyText bold={requiredLanguagesAvailable.includes(option.code)}> + <BodyText bold={requiredLanguageCodes.includes(option.code)}> {`${option.name}`} - {requiredLanguagesAvailable.includes(option.code) && ' (Default)'} + {requiredLanguageCodes.includes(option.code) && ' (Default)'} </BodyText> </Grid> </Grid> @@ -142,7 +154,7 @@ export const LanguageManager = () => { selectedLabel={{ singular: 'Language Added', plural: 'Languages Added' }} searchPlaceholder="Select Language" getOptionDisabled={(option) => selectedLanguages.filter((l) => l.code === option.code).length > 0} - getOptionRequired={(option) => requiredLanguagesAvailable.includes(option.code)} + getOptionRequired={(option) => requiredLanguageCodes.includes(option.code)} /> </When> </Box> diff --git a/met-web/src/components/engagement/admin/create/MultiSelect.tsx b/met-web/src/components/engagement/admin/config/MultiSelect.tsx similarity index 100% rename from met-web/src/components/engagement/admin/create/MultiSelect.tsx rename to met-web/src/components/engagement/admin/config/MultiSelect.tsx diff --git a/met-web/src/components/engagement/admin/create/UserManager.tsx b/met-web/src/components/engagement/admin/config/UserManager.tsx similarity index 97% rename from met-web/src/components/engagement/admin/create/UserManager.tsx rename to met-web/src/components/engagement/admin/config/UserManager.tsx index 53d793a6a..e47b7ed4b 100644 --- a/met-web/src/components/engagement/admin/create/UserManager.tsx +++ b/met-web/src/components/engagement/admin/config/UserManager.tsx @@ -47,7 +47,7 @@ export const UserManager = () => { const handleAddUser = (user: User) => { if (!selectedUsers.filter((u) => u.id === user.id).length) { - setValue('users', [...selectedUsers, user]); + setValue('users', [...selectedUsers, user], { shouldDirty: true }); } }; @@ -55,6 +55,7 @@ export const UserManager = () => { setValue( 'users', selectedUsers.filter((u) => u !== user), + { shouldDirty: true }, ); }; diff --git a/met-web/src/components/engagement/admin/config/wizard/ConfigWizard.tsx b/met-web/src/components/engagement/admin/config/wizard/ConfigWizard.tsx new file mode 100644 index 000000000..3929b4ab1 --- /dev/null +++ b/met-web/src/components/engagement/admin/config/wizard/ConfigWizard.tsx @@ -0,0 +1,147 @@ +import React, { Suspense } from 'react'; +import { ResponsiveContainer } from 'components/common/Layout'; +import { + useFetcher, + createSearchParams, + useRouteLoaderData, + Await, + useAsyncValue, + useNavigation, +} from 'react-router-dom'; +import { FormProvider, useForm } from 'react-hook-form'; +import { AutoBreadcrumbs } from 'components/common/Navigation/Breadcrumb'; +import EngagementForm, { EngagementConfigurationData } from '.'; +import { EngagementLoaderData } from 'components/engagement/public/view'; +import { Engagement } from 'models/engagement'; +import { ENGAGEMENT_MEMBERSHIP_STATUS, EngagementTeamMember } from 'models/engagementTeamMember'; +import { BodyText, Header1, Header2 } from 'components/common/Typography'; +import dayjs from 'dayjs'; +import { Language } from 'models/language'; +import { CircularProgress, Grid, Modal, Skeleton } from '@mui/material'; +import { modalStyle } from 'components/common'; + +const EngagementConfigurationWizard = () => { + const { engagement, teamMembers, slug } = useRouteLoaderData('single-engagement') as EngagementLoaderData; + return ( + <ResponsiveContainer> + <AutoBreadcrumbs /> + <Suspense + fallback={ + <Skeleton variant="text"> + <Header1 sx={{ mb: 0 }}>Example Engagement</Header1> + </Skeleton> + } + > + <Await resolve={engagement}> + {(resolvedEngagement) => <Header1 sx={{ mb: 0 }}>{resolvedEngagement.name}</Header1>} + </Await> + </Suspense> + <br /> + <Suspense fallback={<Header2 decorated>Edit Configuration</Header2>}> + <Await resolve={Promise.all([engagement, teamMembers, slug])}> + <ConfigForm /> + </Await> + </Suspense> + </ResponsiveContainer> + ); +}; + +const ConfigForm = () => { + const [engagement, teamMembers, slug] = useAsyncValue() as [Engagement, EngagementTeamMember[], string]; + const fetcher = useFetcher(); + const navigation = useNavigation(); + + const engagementConfigForm = useForm<EngagementConfigurationData>({ + defaultValues: { + name: engagement.name, + feedback_methods: [], + start_date: dayjs(engagement.start_date), + end_date: dayjs(engagement.end_date), + _dateConfirmed: true, + languages: [{ code: 'en', name: 'English' }] as Language[], + is_internal: engagement.is_internal, + _visibilityConfirmed: true, + slug: slug, + users: teamMembers.filter((tm) => tm.status == ENGAGEMENT_MEMBERSHIP_STATUS.Active).map((tm) => tm.user), + }, + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + + const onSubmit = async (data: EngagementConfigurationData) => { + fetcher.submit( + createSearchParams({ + name: data.name, + feedback_methods: data.feedback_methods, + start_date: data.start_date.format('YYYY-MM-DD'), + end_date: data.end_date.format('YYYY-MM-DD'), + languages: data.languages.map((l) => l.code), + is_internal: data.is_internal ? 'true' : 'false', + slug: data.slug, + users: data.users.map((u) => u.external_id), + }), + { + method: 'patch', + action: `/engagements/${engagement.id}/config/`, + }, + ); + }; + + const { + formState: { isSubmitting, isSubmitted }, + } = engagementConfigForm; + + return ( + <FormProvider {...engagementConfigForm}> + <EngagementForm onSubmit={onSubmit} /> + <Modal + open={ + isSubmitting || isSubmitted || fetcher.state === 'submitting' || navigation.state === 'submitting' + } + > + <Grid + container + direction="row" + justifyContent="flex-start" + alignItems="flex-start" + sx={{ ...modalStyle, borderColor: 'notification.default.shade' }} + > + <Grid item xs={1} sx={{ pt: 1.25, fontSize: '16px' }}> + <CircularProgress + variant="indeterminate" + sx={{ + color: 'notification.default.shade', + width: '24px', + height: '24px', + animationDuration: '550ms', + '& .MuiCircularProgress-circle': { + strokeLinecap: 'round', + }, + }} + /> + </Grid> + <Grid + item + xs={11} + container + direction="row" + justifyContent="flex-start" + alignItems="space-between" + rowSpacing={1} + > + <Grid container direction="row" item xs={12}> + <Grid xs={12}> + <Header2 sx={{ mb: 0 }}>We're just looking over your configuration.</Header2> + </Grid> + </Grid> + <Grid container direction="row" item xs={12}> + <BodyText bold>This should only take a few seconds.</BodyText> + </Grid> + </Grid> + </Grid> + </Modal> + </FormProvider> + ); +}; + +export default EngagementConfigurationWizard; diff --git a/met-web/src/components/engagement/admin/create/index.tsx b/met-web/src/components/engagement/admin/config/wizard/CreationWizard.tsx similarity index 69% rename from met-web/src/components/engagement/admin/create/index.tsx rename to met-web/src/components/engagement/admin/config/wizard/CreationWizard.tsx index c84d9a2c2..92af7f1c5 100644 --- a/met-web/src/components/engagement/admin/create/index.tsx +++ b/met-web/src/components/engagement/admin/config/wizard/CreationWizard.tsx @@ -3,7 +3,10 @@ import { ResponsiveContainer } from 'components/common/Layout'; import { useFetcher, createSearchParams } from 'react-router-dom'; import { FormProvider, useForm } from 'react-hook-form'; import { AutoBreadcrumbs } from 'components/common/Navigation/Breadcrumb'; -import EngagementForm, { EngagementConfigurationData } from './form'; +import EngagementForm, { EngagementConfigurationData } from '.'; +import { Header1, Header2 } from 'components/common/Typography'; +import { SystemMessage } from 'components/common/Layout/SystemMessage'; +import { Link } from 'components/common/Navigation'; const EngagementCreationWizard = () => { const fetcher = useFetcher(); @@ -47,6 +50,17 @@ const EngagementCreationWizard = () => { return ( <ResponsiveContainer> <AutoBreadcrumbs /> + <Header1 sx={{ mb: 0 }}>New Engagement</Header1> + <Header2 weight="thin">Create a new engagement in six easy configuration steps.</Header2> + <SystemMessage status="info"> + You will be able to modify the configuration of your engagement later in the case the parameters of your + engagement change. If you prefer, you can use{' '} + <Link size="small" to="../form"> + the old form + </Link> + . + </SystemMessage> + <br /> <FormProvider {...engagementCreationForm}> <EngagementForm isNewEngagement onSubmit={onSubmit} /> </FormProvider> diff --git a/met-web/src/components/engagement/admin/create/form/index.tsx b/met-web/src/components/engagement/admin/config/wizard/index.tsx similarity index 85% rename from met-web/src/components/engagement/admin/create/form/index.tsx rename to met-web/src/components/engagement/admin/config/wizard/index.tsx index 9c4f4fdd6..e9ccefd4b 100644 --- a/met-web/src/components/engagement/admin/create/form/index.tsx +++ b/met-web/src/components/engagement/admin/config/wizard/index.tsx @@ -13,8 +13,6 @@ import { LanguageManager } from '../LanguageManager'; import { UserManager } from '../UserManager'; import { User } from 'models/user'; import { Language } from 'models/language'; -import { SystemMessage } from 'components/common/Layout/SystemMessage'; -import { Link } from 'components/common/Navigation'; import { FormStep } from 'components/common/Layout/FormStep'; export interface EngagementConfigurationData { @@ -43,8 +41,6 @@ const EngagementForm = ({ onSubmit: (data: EngagementConfigurationData) => void; isNewEngagement?: boolean; }) => { - const { languages } = useLoaderData() as { languages: Language[] }; - const engagementForm = useFormContext<EngagementConfigurationData>(); const { @@ -59,18 +55,7 @@ const EngagementForm = ({ return ( <Form onSubmit={handleSubmit(onSubmit)}> <Box sx={{ maxWidth: '788px' }}> - <Header1 sx={{ mb: 0 }}>New Engagement</Header1> - <Header2 weight="thin">Create a new engagement in six easy configuration steps.</Header2> - <SystemMessage status="info"> - You will be able to modify the configuration of your engagement later in the case the parameters of - your engagement change. If you prefer, you can use{' '} - <Link size="small" to="../form"> - the old form - </Link> - . - </SystemMessage> - <br /> - <Header2 decorated>Configure Engagement</Header2> + <Header2 decorated>{isNewEngagement ? 'Configure Engagement' : 'Edit Configuration'}</Header2> <br /> <Controller control={control} @@ -128,11 +113,7 @@ const EngagementForm = ({ details="All engagements must be offered in English, but you may also add content in additional languages. If you select multi-language, you must include French." isGroup > - <Suspense fallback={<Skeleton variant="rectangular" sx={{ width: '100%', height: '288px' }} />}> - <Await resolve={languages}> - <LanguageManager /> - </Await> - </Suspense> + <LanguageManager /> </FormStep> <FormStep step={5} @@ -171,7 +152,7 @@ const EngagementForm = ({ > {isNewEngagement ? 'Create Engagement' : 'Save Changes'} </Button> - <Button href="/engagements">Cancel</Button> + <Button href={isNewEngagement ? '/engagements' : '../view'}>Cancel</Button> </Box> <UnsavedWorkConfirmation blockNavigationWhen={isDirty && !isSubmitting} /> </Form> diff --git a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx new file mode 100644 index 000000000..46bee0a24 --- /dev/null +++ b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx @@ -0,0 +1,243 @@ +import React, { Suspense, useEffect } from 'react'; +import { Avatar, Box, Grid, IconButton, Skeleton, Tooltip } from '@mui/material'; +import { BodyText, Header2 } from '../../../common/Typography'; +import { OutlineBox } from 'components/common/Layout'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCopy } from '@fortawesome/pro-light-svg-icons'; +import { globalFocusVisible } from 'components/common'; +import { getBaseUrl } from 'helper'; +import { Await, useAsyncValue, useRouteLoaderData } from 'react-router-dom'; +import { Engagement } from 'models/engagement'; +import { EngagementStatusChip } from 'components/common/Indicators'; +import { SubmissionStatus } from 'constants/engagementStatus'; +import dayjs from 'dayjs'; +import { EngagementLoaderData } from 'components/engagement/public/view'; +import { ENGAGEMENT_MEMBERSHIP_STATUS, EngagementTeamMember } from 'models/engagementTeamMember'; +import { Button } from 'components/common/Input'; +import { faPen } from '@fortawesome/pro-regular-svg-icons'; +import { SystemMessage } from 'components/common/Layout/SystemMessage'; + +export const ConfigSummary = () => { + const siteUrl = getBaseUrl(); + const [engagement, teamMembers, slug] = useAsyncValue() as [Engagement, EngagementTeamMember[], string]; + const [tooltipOpen, setTooltipOpen] = React.useState(false); + + useEffect(() => { + if (tooltipOpen) { + const timer = setTimeout(() => { + setTooltipOpen(false); + }, 2000); + return () => { + clearTimeout(timer); + }; + } + }, [tooltipOpen]); + + return ( + <Box> + <Header2 decorated>Configuration</Header2> + <Grid container spacing={2} width="624px" maxWidth="100%" direction="column"> + <Grid item> + <OutlineBox> + <Grid container spacing={1} direction="column"> + <Grid item> + <BodyText bold color="primary.main"> + Engagement URL + </BodyText> + </Grid> + <Grid item> + <Tooltip arrow open={tooltipOpen} title="Copied!" placement="top"> + <IconButton + size="small" + sx={{ + backgroundColor: 'primary.light', + color: 'white', + width: '32px', + height: '32px', + '&:hover': { + backgroundColor: 'primary.main', + }, + ...globalFocusVisible, + display: 'inline-block', + marginRight: '0.5rem', + }} + onClick={() => { + navigator.clipboard.writeText(`${siteUrl}/${slug}`); + setTooltipOpen(true); + }} + > + <FontAwesomeIcon + fontSize={16} + icon={faCopy} + style={{ position: 'relative', bottom: '4px' }} + /> + </IconButton> + </Tooltip> + <BodyText sx={{ display: 'inline' }}> + <span style={{ fontWeight: 'bold' }}>{siteUrl}/</span> + {slug} + </BodyText> + </Grid> + </Grid> + </OutlineBox> + </Grid> + <Grid item> + <OutlineBox> + <Grid container direction="row" spacing={1}> + <Grid item xs={12}> + <BodyText bold color="primary.main"> + Engagement Feedback Dates + </BodyText> + </Grid> + <Grid item xs="auto" container direction="column" spacing={2}> + <Grid item container spacing={1}> + <Grid item width={{ xs: '100%', sm: '82px' }}> + <EngagementStatusChip + statusId={SubmissionStatus.Open} + sx={{ '&>span.MuiChip-label': { padding: '4px 12px' } }} + /> + </Grid> + <Grid item> + <BodyText bold sx={{ display: 'inline' }}> + {dayjs(engagement.start_date).format('MMMM D, YYYY')} + </BodyText>{' '} + <BodyText sx={{ display: { xs: 'block', sm: 'inline' } }}>(12:01 am)</BodyText> + </Grid> + </Grid> + <Grid item container spacing={1}> + <Grid item width={{ xs: '100%', sm: '82px' }}> + <EngagementStatusChip + statusId={SubmissionStatus.Closed} + sx={{ '&>span.MuiChip-label': { padding: '4px 12px' } }} + /> + </Grid> + <Grid item> + <BodyText bold sx={{ display: 'inline' }}> + {dayjs(engagement.end_date).format('MMMM D, YYYY')} + </BodyText>{' '} + <BodyText sx={{ display: { xs: 'block', sm: 'inline' } }}>(11:59 pm)</BodyText> + </Grid> + </Grid> + </Grid> + <Grid + item + xs="auto" + sx={{ + width: '100%', + mb: 1, + mt: { xs: 1, sm: 0 }, + maxWidth: { xs: '100%', sm: 'fit-content' }, + }} + > + <BodyText bold size="large" sx={{ color: 'primary.light', lineHeight: 0.8 }}> + <span style={{ fontSize: '72px' }}> + {dayjs(engagement.end_date) + .clone() + .add(1, 'second') + .diff(dayjs(engagement.start_date), 'days')} + </span> + <span style={{ position: 'relative', bottom: '16px', fontSize: '24px' }}> + {' '} + days + </span> + </BodyText> + </Grid> + </Grid> + </OutlineBox> + </Grid> + <Grid item> + <OutlineBox> + <Grid container direction="column" spacing={1}> + <Grid item> + <BodyText bold color="primary.main"> + Language(s) Included (1) + </BodyText> + </Grid> + <Grid item> + <BodyText bold>English (Default)</BodyText> + </Grid> + </Grid> + </OutlineBox> + </Grid> + <Grid item> + <OutlineBox> + <Grid container direction="column" spacing={1}> + <Grid item> + <BodyText bold color="primary.main"> + Team Member(s) Added + </BodyText> + </Grid> + <Suspense fallback={<TeamMemberListSkeleton />}> + <Await resolve={teamMembers}> + <TeamMemberList /> + </Await> + </Suspense> + </Grid> + </OutlineBox> + </Grid> + <Grid item sx={{ pt: '40px' }}> + <Button href="../config" variant="secondary" icon={<FontAwesomeIcon icon={faPen} />}> + Edit Configuration + </Button> + </Grid> + </Grid> + </Box> + ); +}; + +const TeamMemberList = () => { + const teamMembers = (useAsyncValue() as EngagementTeamMember[]).filter( + (teamMember) => teamMember.status === ENGAGEMENT_MEMBERSHIP_STATUS.Active, + ); + if (!teamMembers.length) { + return ( + <Grid item> + <BodyText>No team members added</BodyText> + </Grid> + ); + } + return ( + <> + {teamMembers.map((teamMember) => ( + <Grid item container spacing={2} alignItems="center" key={teamMember.id}> + <Grid item> + <Avatar + sx={{ + backgroundColor: 'primary.light', + height: 32, + width: 32, + fontSize: '16px', + }} + > + {teamMember.user.first_name[0]} + {teamMember.user.last_name[0]} + </Avatar> + </Grid> + <Grid item> + <BodyText> + {teamMember.user.first_name} {teamMember.user.last_name} + </BodyText> + <BodyText>{teamMember.user.main_role}</BodyText> + </Grid> + </Grid> + ))} + </> + ); +}; + +const TeamMemberListSkeleton = () => { + return ( + <> + {Array.from({ length: 3 }).map((_, index) => ( + <Grid item container spacing={2} alignItems="center" key={index}> + <Grid item> + <Skeleton variant="circular" width={32} height={32} /> + </Grid> + <Grid item> + <Skeleton variant="text" width={100} /> + </Grid> + </Grid> + ))} + </> + ); +}; diff --git a/met-web/src/components/engagement/admin/view/StatusChip.tsx b/met-web/src/components/engagement/admin/view/StatusChip.tsx new file mode 100644 index 000000000..e373b7df3 --- /dev/null +++ b/met-web/src/components/engagement/admin/view/StatusChip.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Chip, Box } from '@mui/material'; +import { useAsyncValue } from 'react-router-dom'; +import { Engagement } from 'models/engagement'; +import { EngagementStatus } from 'constants/engagementStatus'; + +export const StatusChip = ({ + status, + children, +}: { + status: 'success' | 'warning' | 'danger' | 'info'; + children: React.ReactNode; +}) => { + return ( + <Chip + label={children} + sx={{ + backgroundColor: `notification.${status}.shade`, + color: 'primary.contrastText', + borderRadius: '8px', + }} + /> + ); +}; + +export const AutoEngagementStatusChip = () => { + const engagement = useAsyncValue() as Engagement; + const statusName = EngagementStatus[engagement?.status_id]; + let status = 'danger' as 'success' | 'warning' | 'danger' | 'info'; + if (statusName === 'Scheduled') { + status = 'info'; + } else if (statusName === 'Published') { + status = 'success'; + } + return <StatusChip status={status}>{engagement?.status_id}</StatusChip>; +}; diff --git a/met-web/src/components/engagement/admin/view/index.tsx b/met-web/src/components/engagement/admin/view/index.tsx index c8cd3aac7..10f960fb3 100644 --- a/met-web/src/components/engagement/admin/view/index.tsx +++ b/met-web/src/components/engagement/admin/view/index.tsx @@ -1,16 +1,29 @@ -import React, { Suspense } from 'react'; -import { useLoaderData, Await } from 'react-router-dom'; +import React, { Suspense, useState } from 'react'; +import { useRouteLoaderData, Await } from 'react-router-dom'; import { Engagement } from 'models/engagement'; import { AutoBreadcrumbs } from 'components/common/Navigation/Breadcrumb'; import { EngagementStatus } from 'constants/engagementStatus'; -import { Theme, useMediaQuery } from '@mui/material'; +import { Tab } from '@mui/material'; +import { ResponsiveContainer } from 'components/common/Layout'; +import { ConfigSummary } from './ConfigSummary'; +import { TabContext, TabList, TabPanel } from '@mui/lab'; +import { EngagementLoaderData } from 'components/engagement/public/view'; export const AdminEngagementView = () => { - const { engagement } = useLoaderData() as { engagement: Promise<Engagement> }; - const isMediumScreenOrLarger: boolean = useMediaQuery((theme: Theme) => theme.breakpoints.up('md')); + const { engagement, teamMembers, slug } = useRouteLoaderData('single-engagement') as EngagementLoaderData; + + const EngagementViewTabs = { + config: 'Configuration', + author: 'Authoring', + activity: 'Activity', + results: 'Results', + publish: 'Publishing', + }; + + const [currentTab, setCurrentTab] = useState(EngagementViewTabs.config); return ( - <div style={{ marginTop: '3.125rem', padding: isMediumScreenOrLarger ? '0' : '0 1rem' }}> + <ResponsiveContainer> <AutoBreadcrumbs /> <Suspense> <Await resolve={engagement}> @@ -32,6 +45,66 @@ export const AdminEngagementView = () => { )} </Await> </Suspense> - </div> + <TabContext value={currentTab}> + <TabList + variant="scrollable" + onChange={(e, newValue) => setCurrentTab(newValue)} + aria-label="Admin Engagement View Tabs" + TabIndicatorProps={{ sx: { display: 'none' } }} + sx={{ + '& .MuiTabs-flexContainer': { + justifyContent: 'flex-start', + width: 'max-content', + }, + }} + > + {Object.entries(EngagementViewTabs).map(([key, value]) => ( + <Tab + key={key} + value={value} + label={value} + disableFocusRipple + sx={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '48px', + padding: '4px 24px 2px 18px', + fontSize: '14px', + borderRadius: '0px 16px 0px 0px', + borderBottom: '2px solid', + borderBottomColor: 'gray.60', + boxShadow: + '0px 1px 5px 0px rgba(0, 0, 0, 0.12), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.20)', + backgroundColor: 'gray.10', + color: 'text.secondary', + fontWeight: 'normal', + '&.Mui-selected': { + backgroundColor: 'primary.main', + borderColor: 'primary.main', + color: 'white', + fontWeight: 'bold', + }, + outlineOffset: '-4px', + '&:focus-visible': { + outline: `2px solid`, + outlineColor: 'focus.inner', + border: '4px solid', + borderColor: 'focus.outer', + padding: '0px 20px 0px 14px', + }, + }} + /> + ))} + </TabList> + <Suspense> + <TabPanel value={EngagementViewTabs.config}> + <Await resolve={Promise.all([engagement, teamMembers, slug])}> + <ConfigSummary /> + </Await> + </TabPanel> + </Suspense> + </TabContext> + </ResponsiveContainer> ); }; diff --git a/met-web/src/components/engagement/public/view/EngagementLoader.tsx b/met-web/src/components/engagement/public/view/EngagementLoader.tsx index 05f3a3a46..792fea0d0 100644 --- a/met-web/src/components/engagement/public/view/EngagementLoader.tsx +++ b/met-web/src/components/engagement/public/view/EngagementLoader.tsx @@ -8,19 +8,38 @@ import { getEngagementIdBySlug, getSlugByEngagementId } from 'services/engagemen import { getSummaryContent } from 'services/engagementSummaryService'; import { getWidgets } from 'services/widgetService'; import { getEngagementMetadata, getMetadataTaxa } from 'services/engagementMetadataService'; +import { Engagement, EngagementMetadata } from 'models/engagement'; +import { Widget } from 'models/widget'; +import { EngagementContent } from 'models/engagementContent'; +import { TaxonType } from 'components/metadataManagement/types'; +import { getTeamMembers } from 'services/membershipService'; +import { EngagementTeamMember } from 'models/engagementTeamMember'; + +export type EngagementLoaderData = { + engagement: Promise<Engagement>; + slug: Promise<string>; + widgets: Promise<Widget[]>; + content: Promise<EngagementContent[]>; + contentSummary: Promise<EngagementSummaryContent[]>; + metadata: Promise<EngagementMetadata[]>; + taxa: Promise<TaxonType[]>; + customContent: Promise<EngagementCustomContent[]>; + teamMembers: Promise<EngagementTeamMember[]>; +}; export const engagementLoader = async ({ params }: { params: Params<string> }) => { - let { slug } = params; - const { engagementId } = params; - if (!slug && engagementId) { - const response = await getSlugByEngagementId(Number(engagementId)); - slug = response.slug; - } - const engagement = getEngagementIdBySlug(slug ?? '').then((response) => getEngagement(response.engagement_id)); + const { slug: slugParam, engagementId } = params; + const slug = slugParam + ? Promise.resolve(slugParam) + : getSlugByEngagementId(Number(engagementId)).then((response) => response.slug); + const engagement = slugParam + ? getEngagementIdBySlug(slugParam).then((response) => getEngagement(response.engagement_id)) + : getEngagement(Number(engagementId)); const widgets = engagement.then((response) => getWidgets(Number(response.id))); const content = engagement.then((response) => getEngagementContent(response.id)); const engagementMetadata = engagement.then((response) => getEngagementMetadata(Number(response.id))); const taxaData = getMetadataTaxa(); + const teamMembers = engagement.then((response) => getTeamMembers({ engagement_id: response.id })); const metadata = engagementMetadata.then((metaResponse) => { taxaData.then((taxaResponse) => { @@ -75,5 +94,15 @@ export const engagementLoader = async ({ params }: { params: Params<string> }) = return finishedContent; }; - return defer({ engagement, slug, widgets, content, contentSummary, metadata, taxa, customContent }); + return defer({ + engagement, + slug, + widgets, + content, + contentSummary, + metadata, + taxa, + customContent, + teamMembers, + }); }; diff --git a/met-web/src/components/engagement/public/view/index.tsx b/met-web/src/components/engagement/public/view/index.tsx index f59f39267..812d5bfc9 100644 --- a/met-web/src/components/engagement/public/view/index.tsx +++ b/met-web/src/components/engagement/public/view/index.tsx @@ -20,4 +20,5 @@ export const PublicEngagementView = () => { export default PublicEngagementView; export { engagementLoader } from './EngagementLoader'; +export type { EngagementLoaderData } from './EngagementLoader'; export { engagementListLoader } from './EngagementListLoader'; diff --git a/met-web/src/routes/AuthenticatedRoutes.tsx b/met-web/src/routes/AuthenticatedRoutes.tsx index 8179d4dfc..043b0f2a8 100644 --- a/met-web/src/routes/AuthenticatedRoutes.tsx +++ b/met-web/src/routes/AuthenticatedRoutes.tsx @@ -34,10 +34,12 @@ import { Engagement } from 'models/engagement'; import { getAllTenants, getTenant } from 'services/tenantService'; import { engagementLoader, engagementListLoader } from 'components/engagement/public/view'; import { SurveyLoader } from 'components/survey/building/SurveyLoader'; -import { languageLoader } from 'components/engagement/admin/create/languageLoader'; +import { languageLoader } from 'components/engagement/admin/config/LanguageLoader'; import { userSearchLoader } from 'components/userManagement/userSearchLoader'; -import EngagementCreationWizard from 'components/engagement/admin/create'; -import engagementCreateAction from 'components/engagement/admin/create/engagementCreateAction'; +import EngagementCreationWizard from 'components/engagement/admin/config/wizard/CreationWizard'; +import engagementCreateAction from 'components/engagement/admin/config/EngagementCreateAction'; +import EngagementConfigurationWizard from 'components/engagement/admin/config/wizard/ConfigWizard'; +import engagementUpdateAction from 'components/engagement/admin/config/EngagementUpdateAction'; const AuthenticatedRoutes = () => { return ( @@ -79,7 +81,6 @@ const AuthenticatedRoutes = () => { path="wizard" handle={{ crumb: () => ({ name: 'New Engagement' }) }} element={<EngagementCreationWizard />} - loader={languageLoader} /> </Route> <Route @@ -87,26 +88,32 @@ const AuthenticatedRoutes = () => { id="single-engagement" errorElement={<NotFound />} loader={engagementLoader} + handle={{ + crumb: async (data: { engagement: Promise<Engagement> }) => { + return data.engagement.then((engagement) => { + return { + link: `/engagements/${engagement.id}/view`, + name: engagement.name, + }; + }); + }, + }} > <Route element={<AuthGate allowedRoles={[USER_ROLES.EDIT_ENGAGEMENT]} />}> <Route path="form" element={<EngagementForm />} /> + <Route + path="config" + element={<EngagementConfigurationWizard />} + action={engagementUpdateAction} + handle={{ + crumb: () => ({ + name: 'Configure', + }), + }} + /> </Route> <Route path="old-view" element={<OldEngagementView />} /> - <Route - path="view" - loader={engagementLoader} - handle={{ - crumb: async (data: { engagement: Promise<Engagement> }) => { - return data.engagement.then((engagement) => { - return { - link: `/engagements/${engagement.id}/view`, - name: engagement.name, - }; - }); - }, - }} - element={<AdminEngagementView />} - /> + <Route path="view" element={<AdminEngagementView />} /> <Route path="comments/:dashboardType" element={<EngagementComments />} /> <Route path="dashboard/:dashboardType" element={<PublicDashboard />} /> </Route> @@ -117,7 +124,7 @@ const AuthenticatedRoutes = () => { </Route> </Route> <Route path="/metadatamanagement" element={<MetadataManagement />} /> - <Route path="/languages" element={<Language />} /> + <Route path="/languages" element={<Language />} loader={languageLoader} /> <Route id="tenant-admin" path="/tenantadmin" diff --git a/met-web/src/services/engagementService/types.ts b/met-web/src/services/engagementService/types.ts index 69417207a..cc7b596de 100644 --- a/met-web/src/services/engagementService/types.ts +++ b/met-web/src/services/engagementService/types.ts @@ -38,4 +38,5 @@ export interface PatchEngagementRequest { rich_description?: string; banner_filename?: string; status_block?: unknown[]; + is_internal?: boolean; } diff --git a/met-web/src/styles/Theme.ts b/met-web/src/styles/Theme.ts index 2530908ed..edaf729dd 100644 --- a/met-web/src/styles/Theme.ts +++ b/met-web/src/styles/Theme.ts @@ -128,6 +128,8 @@ export const colors = { }; export const Palette = { + ...colors.surface, + notification: colors.notification, primary: { main: colors.surface.blue[90], light: colors.surface.blue[80], @@ -147,6 +149,7 @@ export const Palette = { }, text: { primary: colors.type.regular.primary, + secondary: colors.type.regular.secondary, }, action: { active: colors.type.regular.link, @@ -154,6 +157,10 @@ export const Palette = { info: { main: colors.surface.gray[90], }, + focus: { + outer: colors.focus.regular.outer, + inner: colors.focus.regular.inner, + }, internalHeader: { backgroundColor: colors.surface.white, color: colors.type.regular.primary, @@ -187,27 +194,7 @@ export const Palette = { }; export const BaseTheme = createTheme({ - palette: { - primary: { - main: Palette.primary.main, - light: Palette.primary.light, - dark: Palette.primary.dark, - }, - secondary: { - main: Palette.secondary.main, - dark: Palette.secondary.dark, - light: Palette.secondary.light, - }, - text: { - primary: Palette.text.primary, - }, - action: { - active: Palette.action.active, - }, - info: { - main: Palette.info.main, - }, - }, + palette: Palette, components: { MuiPaper: { styleOverrides: { @@ -322,6 +309,8 @@ export const BaseTheme = createTheme({ export const DarkPalette = { mode: 'dark' as PaletteMode, + ...colors.surface, + notification: colors.notification, primary: { main: colors.surface.white, light: colors.surface.blue[90], @@ -343,6 +332,7 @@ export const DarkPalette = { }, text: { primary: colors.type.inverted.primary, + secondary: colors.type.inverted.secondary, }, action: { active: colors.type.inverted.link, From ecfb917d8624a9fef011bc92c9cb269b6ced1f66 Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Thu, 15 Aug 2024 17:18:16 -0700 Subject: [PATCH 2/8] Merge cleanup + lint --- .../admin/config/LanguageManager.tsx | 2 +- .../engagement/admin/config/wizard/index.tsx | 8 ++--- .../engagement/admin/view/AuthoringTab.tsx | 4 +-- .../engagement/admin/view/ConfigSummary.tsx | 4 +-- .../engagement/admin/view/StatusChip.tsx | 36 ------------------- 5 files changed, 8 insertions(+), 46 deletions(-) delete mode 100644 met-web/src/components/engagement/admin/view/StatusChip.tsx diff --git a/met-web/src/components/engagement/admin/config/LanguageManager.tsx b/met-web/src/components/engagement/admin/config/LanguageManager.tsx index d334800e3..faa558088 100644 --- a/met-web/src/components/engagement/admin/config/LanguageManager.tsx +++ b/met-web/src/components/engagement/admin/config/LanguageManager.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { Box, FormControlLabel, Grid, Radio, RadioGroup, TextField } from '@mui/material'; import { textInputStyles } from 'components/common/Input/TextInput'; -import { useAsyncValue, useFetcher } from 'react-router-dom'; +import { useFetcher } from 'react-router-dom'; import { BodyText } from 'components/common/Typography'; import { When } from 'react-if'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; diff --git a/met-web/src/components/engagement/admin/config/wizard/index.tsx b/met-web/src/components/engagement/admin/config/wizard/index.tsx index e9ccefd4b..3be10278e 100644 --- a/met-web/src/components/engagement/admin/config/wizard/index.tsx +++ b/met-web/src/components/engagement/admin/config/wizard/index.tsx @@ -1,8 +1,8 @@ -import React, { Suspense, useState } from 'react'; -import { Header1, Header2 } from 'components/common/Typography'; +import React, { useState } from 'react'; +import { Header2 } from 'components/common/Typography'; import { Button, TextField } from 'components/common/Input'; -import { Form, useLoaderData, Await } from 'react-router-dom'; -import { Box, Skeleton } from '@mui/material'; +import { Form } from 'react-router-dom'; +import { Box } from '@mui/material'; import { Dayjs } from 'dayjs'; import { Controller, useFormContext } from 'react-hook-form'; import EngagementVisibilityControl from '../EngagementVisibilityControl'; diff --git a/met-web/src/components/engagement/admin/view/AuthoringTab.tsx b/met-web/src/components/engagement/admin/view/AuthoringTab.tsx index 8f81689fa..4ada78d1c 100644 --- a/met-web/src/components/engagement/admin/view/AuthoringTab.tsx +++ b/met-web/src/components/engagement/admin/view/AuthoringTab.tsx @@ -159,7 +159,7 @@ export const AuthoringTab = () => { <Header2 decorated>Authoring</Header2> <MetHeader3 style={metHeaderStyles}>Page Section Authoring</MetHeader3> <When condition={!requiredSectionsCompleted}> - <SystemMessage sx={systemMessageStyles} status="error"> + <SystemMessage sx={systemMessageStyles} status="danger"> There are incomplete or missing sections of required content in your engagement. Please complete all required content in all of the languages included in your engagement. </SystemMessage> @@ -191,7 +191,7 @@ export const AuthoringTab = () => { <Grid container direction="column" id="feedback-container" sx={{ ...anchorContainerStyles }}> <MetHeader3 style={metHeaderStyles}>Feedback Configuration</MetHeader3> <When condition={!feedbackCompleted}> - <SystemMessage sx={systemMessageStyles} status="error"> + <SystemMessage sx={systemMessageStyles} status="danger"> There are feedback methods included in your engagement that are incomplete. Please complete configuration for all of the feedback methods included in your engagement. </SystemMessage> diff --git a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx index 46bee0a24..12031d1bf 100644 --- a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx +++ b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx @@ -6,16 +6,14 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCopy } from '@fortawesome/pro-light-svg-icons'; import { globalFocusVisible } from 'components/common'; import { getBaseUrl } from 'helper'; -import { Await, useAsyncValue, useRouteLoaderData } from 'react-router-dom'; +import { Await, useAsyncValue } from 'react-router-dom'; import { Engagement } from 'models/engagement'; import { EngagementStatusChip } from 'components/common/Indicators'; import { SubmissionStatus } from 'constants/engagementStatus'; import dayjs from 'dayjs'; -import { EngagementLoaderData } from 'components/engagement/public/view'; import { ENGAGEMENT_MEMBERSHIP_STATUS, EngagementTeamMember } from 'models/engagementTeamMember'; import { Button } from 'components/common/Input'; import { faPen } from '@fortawesome/pro-regular-svg-icons'; -import { SystemMessage } from 'components/common/Layout/SystemMessage'; export const ConfigSummary = () => { const siteUrl = getBaseUrl(); diff --git a/met-web/src/components/engagement/admin/view/StatusChip.tsx b/met-web/src/components/engagement/admin/view/StatusChip.tsx deleted file mode 100644 index e373b7df3..000000000 --- a/met-web/src/components/engagement/admin/view/StatusChip.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { Chip, Box } from '@mui/material'; -import { useAsyncValue } from 'react-router-dom'; -import { Engagement } from 'models/engagement'; -import { EngagementStatus } from 'constants/engagementStatus'; - -export const StatusChip = ({ - status, - children, -}: { - status: 'success' | 'warning' | 'danger' | 'info'; - children: React.ReactNode; -}) => { - return ( - <Chip - label={children} - sx={{ - backgroundColor: `notification.${status}.shade`, - color: 'primary.contrastText', - borderRadius: '8px', - }} - /> - ); -}; - -export const AutoEngagementStatusChip = () => { - const engagement = useAsyncValue() as Engagement; - const statusName = EngagementStatus[engagement?.status_id]; - let status = 'danger' as 'success' | 'warning' | 'danger' | 'info'; - if (statusName === 'Scheduled') { - status = 'info'; - } else if (statusName === 'Published') { - status = 'success'; - } - return <StatusChip status={status}>{engagement?.status_id}</StatusChip>; -}; From 57fdc0c0a8ca4bd607a9706336512657b58c2e74 Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Thu, 15 Aug 2024 17:31:12 -0700 Subject: [PATCH 3/8] Fix tab styles (accidentally overridden in merge) --- .../engagement/admin/view/index.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/met-web/src/components/engagement/admin/view/index.tsx b/met-web/src/components/engagement/admin/view/index.tsx index fb02c822b..c02056bba 100644 --- a/met-web/src/components/engagement/admin/view/index.tsx +++ b/met-web/src/components/engagement/admin/view/index.tsx @@ -65,23 +65,35 @@ export const AdminEngagementView = () => { key={key} value={value} label={value} + disableFocusRipple sx={{ display: 'flex', - height: '48px', - padding: '0px 24px 0px 18px', justifyContent: 'center', alignItems: 'center', + height: '48px', + padding: '4px 24px 2px 18px', + fontSize: '14px', borderRadius: '0px 16px 0px 0px', - borderBottom: '1px solid', - borderColor: 'gray.60', - backgroundColor: 'gray.10', - color: 'text.secondary', + borderBottom: '2px solid', + borderBottomColor: 'gray.60', boxShadow: '0px 1px 5px 0px rgba(0, 0, 0, 0.12), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.20)', + backgroundColor: 'gray.10', + color: 'text.secondary', + fontWeight: 'normal', '&.Mui-selected': { backgroundColor: 'primary.main', borderColor: 'primary.main', color: 'white', + fontWeight: 'bold', + }, + outlineOffset: '-4px', + '&:focus-visible': { + outline: `2px solid`, + outlineColor: 'focus.inner', + border: '4px solid', + borderColor: 'focus.outer', + padding: '0px 20px 0px 14px', }, }} /> From 0c67821669e6ea68027955d44d3f60d1b003d475 Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Thu, 15 Aug 2024 17:34:26 -0700 Subject: [PATCH 4/8] Fix SonarCloud issues --- .../engagement/admin/config/EngagementUpdateAction.tsx | 9 ++++----- .../engagement/admin/config/LanguageManager.tsx | 1 - .../components/engagement/admin/view/ConfigSummary.tsx | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx b/met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx index a560ad13b..b1f6df846 100644 --- a/met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx +++ b/met-web/src/components/engagement/admin/config/EngagementUpdateAction.tsx @@ -42,12 +42,11 @@ export const engagementUpdateAction: ActionFunction = async ({ request, params } // If the user was previously deactivated, reinstate them reinstateMembership(engagementId, member.user_id); } - } else { - if (!isUserInForm) { - // If the user was previously active but is not in the form, revoke their membership - revokeMembership(engagementId, member.user_id); - } + } else if (!isUserInForm) { + // If the user was previously active but is not in the form, revoke their membership + revokeMembership(engagementId, member.user_id); } + // Remove all known users from the set so we can add new members in the next step usersSet.delete(String(member.user.external_id)); } diff --git a/met-web/src/components/engagement/admin/config/LanguageManager.tsx b/met-web/src/components/engagement/admin/config/LanguageManager.tsx index faa558088..74dc49f37 100644 --- a/met-web/src/components/engagement/admin/config/LanguageManager.tsx +++ b/met-web/src/components/engagement/admin/config/LanguageManager.tsx @@ -23,7 +23,6 @@ export const LanguageManager = () => { const engagementForm = useFormContext(); const { setValue, watch } = engagementForm; const selectedLanguages = watch('languages') as Language[]; - // const [isSingleLanguage, setIsSingleLanguage] = React.useState<boolean | null>(null); const fetcher = useFetcher(); const fetcherData = fetcher.data as { languages: Language[] } | undefined; const { languages: availableLanguages } = fetcherData ?? { languages: [] }; diff --git a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx index 12031d1bf..7098d95ad 100644 --- a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx +++ b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx @@ -227,7 +227,7 @@ const TeamMemberListSkeleton = () => { return ( <> {Array.from({ length: 3 }).map((_, index) => ( - <Grid item container spacing={2} alignItems="center" key={index}> + <Grid item container spacing={2} alignItems="center" key={`static-${index}`}> <Grid item> <Skeleton variant="circular" width={32} height={32} /> </Grid> From d3d3862c63128e1f6d0743c86cc05957e60e29ea Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Thu, 15 Aug 2024 17:42:57 -0700 Subject: [PATCH 5/8] sonarcloud pls --- met-web/src/components/engagement/admin/view/ConfigSummary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx index 7098d95ad..d1528bc75 100644 --- a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx +++ b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx @@ -227,7 +227,7 @@ const TeamMemberListSkeleton = () => { return ( <> {Array.from({ length: 3 }).map((_, index) => ( - <Grid item container spacing={2} alignItems="center" key={`static-${index}`}> + <Grid item container spacing={2} alignItems="center"> <Grid item> <Skeleton variant="circular" width={32} height={32} /> </Grid> From 0112a7c21ec298cb18b6ba098e26d3d3ab603fde Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Thu, 15 Aug 2024 17:47:21 -0700 Subject: [PATCH 6/8] >:( --- .../src/components/engagement/admin/view/ConfigSummary.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx index d1528bc75..68bbdd1c8 100644 --- a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx +++ b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx @@ -226,8 +226,8 @@ const TeamMemberList = () => { const TeamMemberListSkeleton = () => { return ( <> - {Array.from({ length: 3 }).map((_, index) => ( - <Grid item container spacing={2} alignItems="center"> + {[1, 2, 3].map((value) => ( + <Grid item container spacing={2} alignItems="center" key={value}> <Grid item> <Skeleton variant="circular" width={32} height={32} /> </Grid> From 758567f3048f4bd6f699130c92043e461d5413de Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Thu, 15 Aug 2024 18:11:38 -0700 Subject: [PATCH 7/8] remove custom panel styling --- met-web/src/components/engagement/admin/view/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/met-web/src/components/engagement/admin/view/index.tsx b/met-web/src/components/engagement/admin/view/index.tsx index c02056bba..6455e998f 100644 --- a/met-web/src/components/engagement/admin/view/index.tsx +++ b/met-web/src/components/engagement/admin/view/index.tsx @@ -106,7 +106,7 @@ export const AdminEngagementView = () => { </Await> </TabPanel> </Suspense> - <TabPanel value={EngagementViewTabs.author} style={{ paddingLeft: '0', paddingRight: '0' }}> + <TabPanel value={EngagementViewTabs.author}> <Await resolve={engagement}> <AuthoringTab /> </Await> From 993d964dff473196c6a396931c356d0fc8dd9d6f Mon Sep 17 00:00:00 2001 From: NatSquared <nat.k.weiland@gmail.com> Date: Mon, 19 Aug 2024 09:54:28 -0700 Subject: [PATCH 8/8] Wrap engagement URL copy button in LiveAnnouncer for accessibility, add index route --- .../engagement/admin/view/ConfigSummary.tsx | 67 ++++++++++--------- met-web/src/routes/AuthenticatedRoutes.tsx | 1 + 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx index 68bbdd1c8..d24a50906 100644 --- a/met-web/src/components/engagement/admin/view/ConfigSummary.tsx +++ b/met-web/src/components/engagement/admin/view/ConfigSummary.tsx @@ -14,6 +14,7 @@ import dayjs from 'dayjs'; import { ENGAGEMENT_MEMBERSHIP_STATUS, EngagementTeamMember } from 'models/engagementTeamMember'; import { Button } from 'components/common/Input'; import { faPen } from '@fortawesome/pro-regular-svg-icons'; +import { LiveAnnouncer, LiveMessage } from 'react-aria-live'; export const ConfigSummary = () => { const siteUrl = getBaseUrl(); @@ -44,37 +45,41 @@ export const ConfigSummary = () => { </BodyText> </Grid> <Grid item> - <Tooltip arrow open={tooltipOpen} title="Copied!" placement="top"> - <IconButton - size="small" - sx={{ - backgroundColor: 'primary.light', - color: 'white', - width: '32px', - height: '32px', - '&:hover': { - backgroundColor: 'primary.main', - }, - ...globalFocusVisible, - display: 'inline-block', - marginRight: '0.5rem', - }} - onClick={() => { - navigator.clipboard.writeText(`${siteUrl}/${slug}`); - setTooltipOpen(true); - }} - > - <FontAwesomeIcon - fontSize={16} - icon={faCopy} - style={{ position: 'relative', bottom: '4px' }} - /> - </IconButton> - </Tooltip> - <BodyText sx={{ display: 'inline' }}> - <span style={{ fontWeight: 'bold' }}>{siteUrl}/</span> - {slug} - </BodyText> + <LiveAnnouncer> + <LiveMessage aria-live="assertive" message={tooltipOpen ? 'Copied!' : ''} /> + <Tooltip arrow open={tooltipOpen} title="Copied!" placement="top"> + <IconButton + size="small" + sx={{ + backgroundColor: 'primary.light', + color: 'white', + width: '32px', + height: '32px', + '&:hover': { + backgroundColor: 'primary.main', + }, + ...globalFocusVisible, + display: 'inline-block', + marginRight: '0.5rem', + }} + onClick={() => { + navigator.clipboard.writeText(`${siteUrl}/${slug}`); + setTooltipOpen(true); + }} + aria-label="Press enter to copy engagement URL to clipboard" + > + <FontAwesomeIcon + fontSize={16} + icon={faCopy} + style={{ position: 'relative', bottom: '4px' }} + /> + </IconButton> + </Tooltip> + <BodyText sx={{ display: 'inline' }}> + <span style={{ fontWeight: 'bold' }}>{siteUrl}/</span> + {slug} + </BodyText> + </LiveAnnouncer> </Grid> </Grid> </OutlineBox> diff --git a/met-web/src/routes/AuthenticatedRoutes.tsx b/met-web/src/routes/AuthenticatedRoutes.tsx index 043b0f2a8..fcf94f828 100644 --- a/met-web/src/routes/AuthenticatedRoutes.tsx +++ b/met-web/src/routes/AuthenticatedRoutes.tsx @@ -99,6 +99,7 @@ const AuthenticatedRoutes = () => { }, }} > + <Route index element={<Navigate to="view" />} /> <Route element={<AuthGate allowedRoles={[USER_ROLES.EDIT_ENGAGEMENT]} />}> <Route path="form" element={<EngagementForm />} /> <Route