Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## June 10, 2026

- **Feature** Added language switcher to public, preview engagement pages [🎟️ DEP-233](https://citz-gdx.atlassian.net/browse/DEP-233) and [🎟️ DEP-235](https://citz-gdx.atlassian.net/browse/DEP-235)
- Added a common language switcher component that can be used to switch between language views within a language context.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for unifying the language switchers

- Integrated the language switcher into the public engagement details page and the engagement preview page, allowing users to switch between available languages for the engagement content.
- Improved the preview experience with a focus on language switching
- Updated the widget components to support language switching and to display content in the selected language.

## May 25, 2026

- **Feature** Added start/end time to engagement period in engagement configuration. [🎟️ DEP-272](https://citz-gdx.atlassian.net/browse/DEP-272)
Expand Down
3 changes: 1 addition & 2 deletions api/src/api/schemas/schemas/timeline_event_translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@
"$id": "#/properties/time",
"type": "string",
"title": "Time",
"description": "The time associated with the timeline event.",
"pattern": "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})$"
"description": "The time associated with the timeline event."
}
}
}
2 changes: 1 addition & 1 deletion web/src/@types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export {};

declare global {
function snowplow(...args: any[]): Void;
function snowplow(...args: unknown[]): Void;
var _env_: { [key: string]: string };
}

Expand Down
107 changes: 0 additions & 107 deletions web/src/components/common/LanguageSelector.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -175,25 +175,33 @@ const AuthoringBottomNav = ({

useEffect(() => {
syncPreviewWindowUrl(pageName);
const interval = globalThis.setInterval(() => {

const syncIfPreviewLeftEngagement = () => {
const previewWindow = getActivePreviewWindow();
if (!previewWindow || previewWindow.closed) return;

const expectedPathPrefix = `${getBasePathPrefix()}/${getPath(ROUTES.ADMIN_ENGAGEMENT_PREVIEW, {
engagementId: engagementId ?? '',
languageCode: languageCode ?? defaultLanguageCode,
})}`;
const engagementPreviewPrefix = `${getBasePathPrefix()}/manage/engagements/${engagementId ?? ''}/preview/`;
try {
if (!previewWindow.location.pathname.startsWith(expectedPathPrefix)) {
if (!previewWindow.location.pathname.startsWith(engagementPreviewPrefix)) {
syncPreviewWindowUrl(pageName);
}
} catch {
syncPreviewWindowUrl(pageName);
}
}, 10000);
};

const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
syncIfPreviewLeftEngagement();
}
};

globalThis.addEventListener('focus', syncIfPreviewLeftEngagement);
document.addEventListener('visibilitychange', handleVisibilityChange);

return () => {
globalThis.clearInterval(interval);
globalThis.removeEventListener('focus', syncIfPreviewLeftEngagement);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [engagementId, languageCode, pageName]);

Expand Down
107 changes: 93 additions & 14 deletions web/src/components/engagement/preview/EngagementPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { useLoaderData, useRevalidator } from 'react-router';
import { Box } from '@mui/material';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useBlocker, useLoaderData, useParams, useRevalidator } from 'react-router';
import { Box, Modal } from '@mui/material';
import PreviewControlBar from './PreviewControlBar';
import PreviewContent from './PreviewContent';
import { SubmissionStatusTypes } from './PreviewStateTabs';
Expand All @@ -9,9 +9,79 @@ import { Engagement } from 'models/engagement';
import { EngagementLoaderPublicData } from '../public/view/EngagementLoaderPublic';
import { SubmissionStatus } from 'constants/engagementStatus';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowRight } from '@fortawesome/pro-regular-svg-icons';
import { faArrowLeft, faArrowRight, faGlobePointer } from '@fortawesome/pro-regular-svg-icons';
import { PreviewLoaderDataProvider } from './PreviewLoaderDataContext';
import PublicHeader from 'components/layout/Header/PublicHeader';
import ConfirmModal from 'components/common/Modals/ConfirmModal';

interface PreviewNavigationGuardProps {
previewPathPrefix: string;
}

const PreviewNavigationGuard: React.FC<PreviewNavigationGuardProps> = ({ previewPathPrefix }) => {
const [blockedDestinationHref, setBlockedDestinationHref] = useState<string | null>(null);
const previewBlocker = useBlocker(({ currentLocation, nextLocation }) => {
const leavingPreview = !nextLocation.pathname.startsWith(previewPathPrefix);
const pathChanged =
nextLocation.pathname !== currentLocation.pathname ||
nextLocation.search !== currentLocation.search ||
nextLocation.hash !== currentLocation.hash;

if (leavingPreview && pathChanged) {
const nextHref = `${nextLocation.pathname}${nextLocation.search}${nextLocation.hash}`;
setBlockedDestinationHref((previousHref) => (previousHref === nextHref ? previousHref : nextHref));
return true;
}

return false;
});

const blockedLocationHref = previewBlocker.location
? `${previewBlocker.location.pathname}${previewBlocker.location.search}${previewBlocker.location.hash}`
: null;

const closeBlockedNavigationModal = () => {
if (previewBlocker.state === 'blocked') {
previewBlocker.reset();
}
setBlockedDestinationHref(null);
};

const handleOpenBlockedDestinationInNewTab = () => {
const destinationHref = blockedDestinationHref ?? blockedLocationHref;
if (!destinationHref) {
return;
}

globalThis.open(destinationHref, '_blank', 'noopener,noreferrer');
closeBlockedNavigationModal();
};

return (
<Modal
open={Boolean(blockedDestinationHref)}
onClose={(_event) => {
closeBlockedNavigationModal();
}}
>
<ConfirmModal
style="warning"
header="Open Outside of Preview?"
icon={faGlobePointer}
subHeader="This link can't be opened in the current preview tab."
subText={[
{
text: 'To ensure this preview works correctly, links to other pages must be opened in a new tab.',
},
]}
cancelButtonText="Back to Preview"
confirmButtonText="Open in New Tab"
handleClose={closeBlockedNavigationModal}
handleConfirm={handleOpenBlockedDestinationInNewTab}
/>
</Modal>
);
};

const MeasurementBar: React.FC = () => {
const [viewportWidth, setViewportWidth] = useState(globalThis.innerWidth);
Expand Down Expand Up @@ -117,12 +187,15 @@ const MeasurementBar: React.FC = () => {
*/
export const EngagementPreview: React.FC = () => {
const loaderData = useLoaderData() as EngagementLoaderPublicData;
const { engagementId, languageCode } = useParams();
const revalidator = useRevalidator();
const [previewState, setPreviewState] = useState<SubmissionStatusTypes>('Upcoming');
const [isReloading, setIsReloading] = useState(false);
const [contentVersion, setContentVersion] = useState(0);
const [resolvedEngagement, setResolvedEngagement] = useState<Engagement | null>(null);

const previewPathPrefix = `/manage/engagements/${engagementId ?? ''}/preview`;

const getPreviewStateFromEngagement = (engagement: Engagement): SubmissionStatusTypes => {
switch (engagement.submission_status) {
case SubmissionStatus.Open:
Expand Down Expand Up @@ -296,18 +369,24 @@ export const EngagementPreview: React.FC = () => {

const isComplete = resolvedEngagement ? checkEngagementCompleteness(resolvedEngagement) : false;

const previewLoaderData: EngagementLoaderPublicData = {
...loaderData,
engagement: resolvedEngagement
? Promise.resolve({
...resolvedEngagement,
submission_status: getSubmissionStatusId(previewState),
})
: loaderData.engagement,
};
const previewLoaderData: EngagementLoaderPublicData = useMemo(
() => ({
...loaderData,
engagement: resolvedEngagement
? Promise.resolve({
...resolvedEngagement,
submission_status: getSubmissionStatusId(previewState),
})
: loaderData.engagement,
}),
[loaderData, resolvedEngagement, previewState],
);

const previewContentKey = `${engagementId ?? 'engagement'}-${languageCode ?? 'language'}-${contentVersion}`;

return (
<PreviewLoaderDataProvider loaderData={previewLoaderData}>
<PreviewNavigationGuard previewPathPrefix={previewPathPrefix} />
<PreviewControlBar
engagement={resolvedEngagement ?? undefined}
previewState={previewState}
Expand All @@ -317,7 +396,7 @@ export const EngagementPreview: React.FC = () => {
isComplete={isComplete}
/>
<PublicHeader />
<PreviewContent key={contentVersion} previewStateType={previewState} />
<PreviewContent key={previewContentKey} previewStateType={previewState} />
<MeasurementBar />
</PreviewLoaderDataProvider>
);
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/engagement/preview/PreviewContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { EngagementSurveyBlock } from '../public/view/EngagementSurveyBlock';
import { EngagementSubscribeBlock } from '../public/view/EngagementSubscribeBlock';
import { SuggestedEngagements } from 'engagements/public/view/SuggestedEngagements';
import { SubmissionStatusTypes } from 'constants/engagementStatus';
import { Box } from '@mui/material';
import { PreviewLanguageSwitcher } from './PreviewLanguageSwitcher';

interface PreviewContentProps {
previewStateType: SubmissionStatusTypes;
Expand All @@ -19,14 +21,15 @@ interface PreviewContentProps {
export const PreviewContent: React.FC<PreviewContentProps> = ({ previewStateType }) => {
return (
<PreviewProvider isPreviewMode={true} showPlaceholders={true} previewStateType={previewStateType}>
<main>
<Box component="main" sx={{ position: 'relative' }}>
<PreviewLanguageSwitcher />
<EngagementHero />
<EngagementDescription />
<EngagementDetailsTabs />
<EngagementSurveyBlock />
<EngagementSubscribeBlock />
<SuggestedEngagements />
</main>
</Box>
</PreviewProvider>
);
};
Expand Down
34 changes: 34 additions & 0 deletions web/src/components/engagement/preview/PreviewLanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router';
import { useEngagementLoaderData } from './PreviewLoaderDataContext';
import { ROUTES, getPath } from 'routes/routes';
import EngagementLanguageSwitcher from 'components/engagement/shared/EngagementLanguageSwitcher';

export const PreviewLanguageSwitcher = () => {
const navigate = useNavigate();
const { engagementId, languageCode } = useParams();
const { translationLanguages } = useEngagementLoaderData();

if (!engagementId || !translationLanguages) {
return null;
}

return (
<EngagementLanguageSwitcher
menuName="Preview Language Menu"
translationLanguages={translationLanguages}
currentLanguageCode={languageCode}
onLanguageSelect={(nextLanguageCode) => {
globalThis.sessionStorage.setItem('languageId', nextLanguageCode);
navigate(
getPath(ROUTES.ADMIN_ENGAGEMENT_PREVIEW, {
engagementId,
languageCode: nextLanguageCode,
}),
);
}}
/>
);
};

export default PreviewLanguageSwitcher;
Loading