Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## May 19, 2026

- **Feature** Multi-language authoring support [🎟️ DEP-234](https://citz-gdx.atlassian.net/browse/DEP-234)
- Authoring now supports engagement-level multilingual content across sections with translation-aware loading and save paths.
- In multilingual engagements, the language selector reflects all languages configured during setup and supports switching between authoring languages while preserving page context.
- Improved language switching UX in authoring to reduce remount/layout shift: state hydration now avoids clearing forms before translated content arrives, preserves details-tab selection, and keeps scroll position during language changes.
- Added/expanded translation infrastructure for engagement and widget content (including timeline, image, contact, and related widget translation resources), plus supporting migrations and tests.
- Publishing behavior aligns with requirements: English required sections can be completed/published independently; incomplete non-English content does not block publishing, and incomplete languages are not exposed on the public view.
- English-only engagements now keep language controls effectively non-interactive/disabled in authoring contexts where no alternate language is configured.

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.

Very cool stuff Nat, great job on this ticket.

## May 7, 2026

- **Feature** Updated/added links to BC Gov logo in header/footer. [🎟️ DEP-271](https://citz-gdx.atlassian.net/browse/DEP-271)
Expand Down
13 changes: 13 additions & 0 deletions web/src/apiManager/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const Endpoints = {
},
EngagementTranslations: {
GET_TRANSLATION_LANGUAGES: `${AppConfig.apiUrl}/engagement/engagement_id/translations/languages`,
GET_BY_LANGUAGE: `${AppConfig.apiUrl}/engagement/engagement_id/translations/language/language_id`,
CREATE: `${AppConfig.apiUrl}/engagement/engagement_id/translations/`,
PATCH: `${AppConfig.apiUrl}/engagement/engagement_id/translations/engagement_translation_id`,
},
EngagementContentTranslations: {
GET_BY_LANGUAGE: `${AppConfig.apiUrl}/engagement/engagement_id/content/translations/language/language_id`,
UPDATE_BY_LANGUAGE: `${AppConfig.apiUrl}/engagement/engagement_id/content/translations/language/language_id`,
},
EngagementMetadata: {
GET_BY_ENG: `${AppConfig.apiUrl}/engagements/engagement_id/metadata`,
Expand Down Expand Up @@ -126,6 +133,9 @@ const Endpoints = {
GET_LIST: `${AppConfig.apiUrl}/contacts/`,
CREATE: `${AppConfig.apiUrl}/contacts/`,
UPDATE: `${AppConfig.apiUrl}/contacts/`,
GET_TRANSLATION: `${AppConfig.apiUrl}/contact_translations/contact/contact_id/language/language_id`,
CREATE_TRANSLATION: `${AppConfig.apiUrl}/contact_translations/`,
UPDATE_TRANSLATION: `${AppConfig.apiUrl}/contact_translations/translation_id`,
},
Documents: {
GET_LIST: `${AppConfig.apiUrl}/widgets/widget_id/documents`,
Expand All @@ -146,6 +156,9 @@ const Endpoints = {
UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/events/event_id/item/item_id`,
DELETE: `${AppConfig.apiUrl}/widgets/widget_id/events/event_id`,
SORT: `${AppConfig.apiUrl}/widgets/widget_id/events/sort_index`,
GET_ITEM_TRANSLATION: `${AppConfig.apiUrl}/events/event_id/translations/item/event_item_id/language/language_id`,

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.

It could be good to have a method that gets an entire list of file translations.

CREATE_ITEM_TRANSLATION: `${AppConfig.apiUrl}/events/event_id/translations/`,
UPDATE_ITEM_TRANSLATION: `${AppConfig.apiUrl}/events/event_id/translations/translation_id`,
},
Maps: {
GET_LIST: `${AppConfig.apiUrl}/widgets/widget_id/maps`,
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/common/Input/RichTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useId, useLayoutEffect, useRef, useState } from 'react';
import { ContentBlock, ContentState, Editor, EditorProps } from 'react-draft-wysiwyg';
import { SxProps, Theme } from '@mui/material/styles';
import { Box } from '@mui/material';
import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css';
import '../RichTextEditor/RichEditorStyles.css';
import { Button } from './Button';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronUp, faChevronDown } from '@fortawesome/pro-regular-svg-icons';
Expand Down
12 changes: 8 additions & 4 deletions web/src/components/common/Layout/SystemMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Grid2 as Grid, Grid2Props as GridProps } from '@mui/material';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { StatusIcon } from '../Communication/StatusIcon';
import { colors } from 'styles/Theme';
import { faClose } from '@fortawesome/pro-regular-svg-icons';
import { faClose, IconDefinition } from '@fortawesome/pro-regular-svg-icons';
import { Button } from 'components/common/Input/Button';

/**
Expand Down Expand Up @@ -52,14 +52,14 @@ export const SystemMessage = ({
onDismiss?: () => void;
color?: string;
coloredBackground?: boolean;
icon?: string;
icon?: IconDefinition;
} & GridProps) => {
return (
<Grid
container
direction="row"
justifyContent="flex-start"
alignItems="center"
alignItems="flex-start"
sx={{
maxWidth: { xs: '100%', md: '700px' },
borderRadius: '8px',
Expand All @@ -73,7 +73,11 @@ export const SystemMessage = ({
}}
>
<Grid sx={{ pr: 1, fontSize: '18px' }}>
<StatusIcon status={status} color={color} />
{icon ? (
<FontAwesomeIcon icon={icon} color={color || colors.notification[status].icon} />
) : (
<StatusIcon status={status} color={color} />
)}
</Grid>
<Grid sx={{ width: '100%', maxWidth: { xs: '100%', md: '600px', fontSize: '14px' } }}>{children}</Grid>
<Grid justifySelf="flex-end" sx={{ mt: -0.75, opacity: onDismiss ? 1 : 0 }}>
Expand Down
20 changes: 14 additions & 6 deletions web/src/components/common/Navigation/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,19 @@ export const AutoBreadcrumbs: React.FC<{ smallScreenOnly?: boolean }> = ({ small
const matches = (useMatches() as UIMatchWithCrumb[]).filter((match) => match.handle?.crumb);
const matchKey = matches.map((m) => m.pathname).join('-');
const crumbs = useMemo(() => {
return matches.map((match) => {
const data = match.loaderData;
const handle = match.handle;
return handle?.crumb?.(data) ?? Promise.resolve({ name: '', link: '' });
});
return matches
.filter((match) => Boolean(match.handle?.crumb))
.map((match) => {
const crumb = match.handle?.crumb;
if (!crumb) {
return null;
}

// Always pass an object to prevent route crumb handlers that destructure
// their first argument from throwing when loader data is undefined.
return crumb(match.loaderData ?? {});
})
.filter(Boolean);
}, [matchKey]); // Recompute only when matches change

return (
Expand All @@ -92,7 +100,7 @@ export const AutoBreadcrumbs: React.FC<{ smallScreenOnly?: boolean }> = ({ small
{crumbs.map((unresolvedCrumb, index) => (
<Suspense key={`breadcrumb-${matches[index].pathname}`}>
<Await resolve={unresolvedCrumb}>
{(resolvedCrumb: BreadcrumbProps) => {
{(resolvedCrumb: BreadcrumbProps | null) => {
const name = resolvedCrumb?.name;
const link =
index < matches.length - 1
Expand Down
2 changes: 0 additions & 2 deletions web/src/components/common/RichTextEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import React, { useEffect } from 'react';
import { EditorState, convertToRaw } from 'draft-js';
import { RichTextArea } from '../Input/RichTextArea';
import { FormControl, Paper } from '@mui/material';
import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css';
import './RichEditorStyles.css';
import { getEditorStateFromHtml, getEditorStateFromRaw } from './utils';
import { ErrorMessage } from '../Typography';

Expand Down
23 changes: 20 additions & 3 deletions web/src/components/common/RichTextEditor/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { ContentState, EditorState, convertFromHTML, convertFromRaw } from 'draft-js';
import { ContentState, EditorState, convertFromHTML, convertFromRaw, convertToRaw } from 'draft-js';

// For draft-js to convert raw text to editor state
export const getEditorStateFromRaw = (rawTextToConvert: string) => {
if (!rawTextToConvert) {
return EditorState.createEmpty();
}
const rawContentFromStore = convertFromRaw(JSON.parse(rawTextToConvert));
return EditorState.createWithContent(rawContentFromStore);
try {
const rawContentFromStore = convertFromRaw(JSON.parse(rawTextToConvert));
return EditorState.createWithContent(rawContentFromStore);
} catch {
// Invalid or corrupted JSON (e.g. '[object Object]') — start empty
return EditorState.createEmpty();
}
};

// For draft-js to convert html to editor state
Expand All @@ -22,3 +27,15 @@ export const getTextFromDraftJsContentState = (contentJSON: string): string => {
const contentState = JSON.parse(contentJSON);
return contentState.blocks.map((block: { text: string }) => block.text).join(' ');
};

// Convert EditorState to raw JSON string for storage
export const getRawFromEditorState = (editorState: EditorState | null | undefined): string => {
if (!editorState) return '';
try {
const contentState = editorState.getCurrentContent();
const raw = convertToRaw(contentState);
return JSON.stringify(raw);
} catch {
return '';
}
};
26 changes: 19 additions & 7 deletions web/src/components/engagement/admin/EngagementLoaderAdmin.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Params } from 'react-router';
import { getEngagement } from 'services/engagementService';
import { getAvailableTranslationLanguages, getEngagement } from 'services/engagementService';
import { getEngagementIdBySlug, getSlugByEngagementId } from 'services/engagementSlugService';
import { getWidgets } from 'services/widgetService';
import { getEngagementMetadata, getMetadataTaxa } from 'services/engagementMetadataService';
Expand All @@ -9,7 +9,6 @@ import { getTeamMembers } from 'services/membershipService';
import { EngagementTeamMember } from 'models/engagementTeamMember';
import { EngagementDetailsTab } from 'models/engagementDetailsTab';
import { getDetailsTabs } from 'services/engagementDetailsTabService';
import { getTenantLanguages } from 'services/languageService';
import { Language } from 'models/language';

export type EngagementLoaderAdminData = {
Expand All @@ -26,17 +25,30 @@ export type EngagementLoaderAdminData = {
export const engagementLoaderAdmin = async ({ params }: { params: Params<string> }) => {
const { slug: slugParam, engagementId } = params;

const tenantId =
typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'
? window.sessionStorage.getItem('tenantId')
: null;
const languages = tenantId ? getTenantLanguages(tenantId) : Promise.resolve([]);
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 languages = engagement.then((response) =>

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.

Nice safety net for the English language here. Is it for legacy support or could new engagements be missing this value as well?

getAvailableTranslationLanguages(response.id).then((availableLanguages) => {
const hasEnglish = availableLanguages.some((language) => language.code === 'en');
if (hasEnglish) {
return availableLanguages;
}

return [
{
id: 0,
code: 'en',
name: 'English',
right_to_left: false,
},
...availableLanguages,
];
}),
);
const widgets = engagement.then((response) => getWidgets(Number(response.id)));
const details = engagement.then((response) => getDetailsTabs(response.id));
const engagementMetadata = engagement.then((response) => getEngagementMetadata(Number(response.id)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,22 @@ import { patchEngagementSlug } from 'services/engagementSlugService';
import { addTeamMemberToEngagement } from 'services/membershipService';
import { ROUTES, getPath } from 'routes/routes';

const getLanguageCodes = (formData: FormData): string[] => {
const values = formData.getAll('languages').map(String);
return values
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean);
};

export const engagementCreateAction: ActionFunction = async ({ request }) => {
const formData = (await request.formData()) as FormData;
const formData = await request.formData();
const engagement = await createEngagement({
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',
languages: getLanguageCodes(formData),
description: '',
rich_description: '',
description_title: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,28 @@ import {
import { store } from 'store';
import { ROUTES, getPath } from 'routes/routes';

const getUpdateFailureMessage = (error: unknown): string => {
if (!axios.isAxiosError<ApiErrorBody>(error)) {
return 'Failed to update engagement';
}

if (typeof error.response?.data === 'string') {
return error.response.data;
}

return error.response?.data?.message ?? 'Failed to update engagement';
};

const getLanguageCodes = (formData: FormData): string[] => {
const values = formData.getAll('languages').map(String);
return values
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean);
};

export const engagementUpdateAction: ActionFunction = async ({ request, params }) => {
const formData = (await request.formData()) as FormData;
const formData = await request.formData();
const engagementId = Number(params.engagementId);
try {
await patchEngagement({
Expand All @@ -23,13 +43,10 @@ export const engagementUpdateAction: ActionFunction = async ({ request, params }
start_date: formData.get('start_date') as string,
end_date: formData.get('end_date') as string,
is_internal: formData.get('is_internal') === 'true',
languages: getLanguageCodes(formData),
});
} catch (e) {
const message = axios.isAxiosError<ApiErrorBody>(e)
? typeof e.response?.data === 'string'
? e.response.data
: (e.response?.data?.message ?? 'Failed to update engagement')
: 'Failed to update engagement';
const message = getUpdateFailureMessage(e);
console.error('Error updating engagement:', e);
store.dispatch(openNotification({ severity: 'error', text: message }));
return { status: 'failure' };
Expand Down
12 changes: 7 additions & 5 deletions web/src/components/engagement/admin/config/LanguageManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { colors } from 'styles/Theme';
import { useFormContext } from 'react-hook-form';
import { Language } from 'models/language';
import MultiSelect from './MultiSelect';
import { SystemMessage } from 'components/common/Layout/SystemMessage';
import { LanguageLoaderData } from './LanguageLoader';
import { Awaited } from 'utils';
import { ROUTES, getPath } from 'routes/routes';
Expand Down Expand Up @@ -38,6 +37,12 @@ export const LanguageManager = () => {
return false;
};
const isSingleLanguage = determineSingleLanguage(selectedLanguages);
let languageTypeValue = '';
if (isSingleLanguage === true) {
languageTypeValue = 'single';
} else if (isSingleLanguage === false) {
languageTypeValue = 'multi';
Comment on lines +41 to +44

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.

Suggested change
if (isSingleLanguage === true) {
languageTypeValue = 'single';
} else if (isSingleLanguage === false) {
languageTypeValue = 'multi';
languageTypeValue = isSingleValue === true ? 'single' : 'multi';

}

useEffect(() => {
fetcher.load(`${getPath(ROUTES.LANGUAGES)}/`);
Expand All @@ -62,15 +67,12 @@ export const LanguageManager = () => {
}}
aria-label="Select Engagement's Language Type"
name="languageType"
value={isSingleLanguage && (isSingleLanguage ? 'single' : 'multi')}
value={languageTypeValue}
>
<FormControlLabel value={'single'} control={<Radio />} label="English Only" />
<FormControlLabel value={'multi'} control={<Radio />} label="Multi-language" />
</RadioGroup>
<When condition={isSingleLanguage === false}>
<SystemMessage status="warning">
Under construction - this setting currently has no effect
</SystemMessage>
<MultiSelect<Language>
containerProps={{ sx: { mt: 2 } }}
onChange={(_, language, reason) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import { Grid2 as Grid, Skeleton } from '@mui/material';
import { ROUTES, getPath } from 'routes/routes';

const EngagementConfigurationWizard = () => {
const { engagement, teamMembers, slug } = useRouteLoaderData('single-engagement') as EngagementLoaderAdminData;
const eng = React.use(engagement);
const tm = React.use(teamMembers);
const sl = React.use(slug);
const loaderData = useRouteLoaderData('single-engagement') as EngagementLoaderAdminData;
const engagement = React.use(loaderData.engagement);
const teamMembers = React.use(loaderData.teamMembers);
const slug = React.use(loaderData.slug);
const languages = React.use(loaderData.languages);
return (
<ResponsiveContainer>
<AutoBreadcrumbs />
Expand All @@ -36,7 +37,7 @@ const EngagementConfigurationWizard = () => {
</Grid>
<Grid size={12} mt={4}>
<Suspense fallback={<Heading2 decorated>Edit Configuration</Heading2>}>
<ConfigForm engagement={eng} teamMembers={tm} slug={sl} />
<ConfigForm engagement={engagement} teamMembers={teamMembers} slug={slug} languages={languages} />
</Suspense>
</Grid>
</ResponsiveContainer>
Expand All @@ -47,10 +48,12 @@ const ConfigForm = ({
engagement,
teamMembers,
slug,
languages,
}: {
engagement: Engagement;
teamMembers: EngagementTeamMember[];
slug: string;
languages: Language[];
}) => {
const fetcher = useFetcher();

Expand All @@ -61,7 +64,7 @@ const ConfigForm = ({
start_date: dayjs(engagement.start_date),
end_date: dayjs(engagement.end_date),
_dateConfirmed: true,
languages: [{ code: 'en', name: 'English' }] as Language[],
languages,
is_internal: engagement.is_internal,
_visibilityConfirmed: true,
slug: slug,
Expand Down
Loading