From 4a4853f11ed21c43fa4f66f6c4573ca7a957bc7e Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 7 May 2026 15:54:08 +0200 Subject: [PATCH 1/7] feat: add availableLanguages to PxTableMetadata and implement mapping utility --- .../src/lib/shared-types/pxTableMetadata.ts | 4 ++ .../app/components/Selection/Selection.tsx | 10 ++++ .../Selection/selectionUtils.spec.ts | 47 +++++++++++++++++++ .../components/Selection/selectionUtils.ts | 15 ++++++ 4 files changed, 76 insertions(+) diff --git a/packages/pxweb2-ui/src/lib/shared-types/pxTableMetadata.ts b/packages/pxweb2-ui/src/lib/shared-types/pxTableMetadata.ts index c8e3351d8..007838933 100644 --- a/packages/pxweb2-ui/src/lib/shared-types/pxTableMetadata.ts +++ b/packages/pxweb2-ui/src/lib/shared-types/pxTableMetadata.ts @@ -16,6 +16,10 @@ export type PxTableMetadata = { * Table language. */ language: string; + /** + * The available languages for the table. + */ + availableLanguages: string[]; /** * A title for the table that describes the content of it. */ diff --git a/packages/pxweb2/src/app/components/Selection/Selection.tsx b/packages/pxweb2/src/app/components/Selection/Selection.tsx index 77a1b3183..69a02c53e 100644 --- a/packages/pxweb2/src/app/components/Selection/Selection.tsx +++ b/packages/pxweb2/src/app/components/Selection/Selection.tsx @@ -38,6 +38,7 @@ import useAccessibility from '../../context/useAccessibility'; import { problemMessage } from '../../util/problemMessage'; import { getSelectedCodelists, + mapAvailableLanguagesFromLinks, updateSelectedCodelistForVariable, } from './selectionUtils'; @@ -307,6 +308,10 @@ export function Selection({ .then(([Dataset, TableData]) => { const pxTable: PxTable = mapJsonStat2Response(Dataset, false); + pxTable.metadata.availableLanguages = mapAvailableLanguagesFromLinks( + TableData.links, + ); + const firstMatchingPathArray = TableData.paths?.find( (pathArr: PathElement[]) => pathArr[0]?.id === TableData.subjectCode, ); @@ -455,6 +460,11 @@ export function Selection({ pxTable.metadata.pathElements = preservedPathElements; } + // Preserve available languages because codelist metadata response + // does not include table links. + pxTable.metadata.availableLanguages = + pxTableMetaToRender?.availableLanguages ?? []; + setPxTableMetadata(pxTable.metadata); if (pxTableMetaToRender !== null) { diff --git a/packages/pxweb2/src/app/components/Selection/selectionUtils.spec.ts b/packages/pxweb2/src/app/components/Selection/selectionUtils.spec.ts index f51fd4803..e67faf8b2 100644 --- a/packages/pxweb2/src/app/components/Selection/selectionUtils.spec.ts +++ b/packages/pxweb2/src/app/components/Selection/selectionUtils.spec.ts @@ -4,6 +4,7 @@ import { updateSelectedCodelistForVariable, addSelectedCodeListToVariable, getSelectedCodelists, + mapAvailableLanguagesFromLinks, } from './selectionUtils'; import { SelectedVBValues, @@ -356,4 +357,50 @@ describe('selectionUtils', () => { }); }); }); + + describe('mapAvailableLanguagesFromLinks', () => { + it('maps hreflang from self and alternate links only', () => { + const result = mapAvailableLanguagesFromLinks([ + { + rel: 'self', + hreflang: 'en', + href: 'https://example.org/table?lang=en', + }, + { + rel: 'alternate', + hreflang: 'sv', + href: 'https://example.org/table?lang=sv', + }, + { + rel: 'metadata', + hreflang: 'no', + href: 'https://example.org/table/metadata?lang=no', + }, + ]); + + expect(result).toEqual(['en', 'sv']); + }); + + it('removes duplicate hreflang values', () => { + const result = mapAvailableLanguagesFromLinks([ + { + rel: 'self', + hreflang: 'en', + href: 'https://example.org/table?lang=en', + }, + { + rel: 'alternate', + hreflang: 'en', + href: 'https://example.org/table?lang=en', + }, + ]); + + expect(result).toEqual(['en']); + }); + + it('returns empty array when links are missing', () => { + expect(mapAvailableLanguagesFromLinks(undefined)).toEqual([]); + expect(mapAvailableLanguagesFromLinks(null)).toEqual([]); + }); + }); }); diff --git a/packages/pxweb2/src/app/components/Selection/selectionUtils.ts b/packages/pxweb2/src/app/components/Selection/selectionUtils.ts index 77fe98324..388dfbf70 100644 --- a/packages/pxweb2/src/app/components/Selection/selectionUtils.ts +++ b/packages/pxweb2/src/app/components/Selection/selectionUtils.ts @@ -5,6 +5,21 @@ import { Variable, PxTableMetadata, } from '@pxweb2/pxweb2-ui'; +import { Link } from '@pxweb2/pxweb2-api-client'; + +export function mapAvailableLanguagesFromLinks( + links: Link[] | null | undefined, +): string[] { + const relevantRels = new Set(['self', 'alternate']); + + return Array.from( + new Set( + (links ?? []) + .filter((link) => relevantRels.has(link.rel) && link.hreflang) + .map((link) => link.hreflang), + ), + ); +} export function updateSelectedCodelistForVariable( selectedItem: SelectOption, From cdf75c750e5d9870b61a47543ee4a82cf7b7a310 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 7 May 2026 15:54:47 +0200 Subject: [PATCH 2/7] feat: enhance language filtering in LanguageSwitcher and update AppProvider context --- .../LanguageSwitcher/LanguageSwitcher.tsx | 31 +++++++++++++------ .../app/components/Selection/Selection.tsx | 2 ++ .../pxweb2/src/app/context/AppProvider.tsx | 12 ++++++- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx index e516388eb..068886de5 100644 --- a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx +++ b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx @@ -11,7 +11,7 @@ import classes from './LanguageSwitcher.module.scss'; export const LanguageSwitcher = () => { const { t, i18n } = useTranslation(); - const isMobile = useApp().isMobile; + const { isMobile, languageFilter: appLanguageFilter } = useApp(); const config = getConfig(); const navigate = useNavigate(); const location = useLocation(); @@ -26,6 +26,15 @@ export const LanguageSwitcher = () => { setCurrentLang(i18n.language); }, [location.pathname, i18n.language]); + const languageFilter = new Set( + (appLanguageFilter.length > 0 + ? config.language.supportedLanguages.filter((language) => + appLanguageFilter.includes(language.shorthand), + ) + : config.language.supportedLanguages + ).map((language) => language.shorthand), + ); + const handleLanguageChange = ( event: React.ChangeEvent, ) => { @@ -108,15 +117,17 @@ export const LanguageSwitcher = () => { }} onChange={(event) => handleLanguageChange(event)} > - {config.language.supportedLanguages.map((language) => ( - - ))} + {config.language.supportedLanguages + .filter((language) => languageFilter.has(language.shorthand)) + .map((language) => ( + + ))} ) diff --git a/packages/pxweb2/src/app/components/Selection/Selection.tsx b/packages/pxweb2/src/app/components/Selection/Selection.tsx index 69a02c53e..124895f68 100644 --- a/packages/pxweb2/src/app/components/Selection/Selection.tsx +++ b/packages/pxweb2/src/app/components/Selection/Selection.tsx @@ -312,6 +312,8 @@ export function Selection({ TableData.links, ); + app.setLanguageFilter(pxTable.metadata.availableLanguages); + const firstMatchingPathArray = TableData.paths?.find( (pathArr: PathElement[]) => pathArr[0]?.id === TableData.subjectCode, ); diff --git a/packages/pxweb2/src/app/context/AppProvider.tsx b/packages/pxweb2/src/app/context/AppProvider.tsx index 9b15dacc5..3ee94899d 100644 --- a/packages/pxweb2/src/app/context/AppProvider.tsx +++ b/packages/pxweb2/src/app/context/AppProvider.tsx @@ -19,6 +19,8 @@ export type AppContextType = { setSkipToMainFocused: (focused: boolean) => void; title: string; setTitle: (title: string) => void; + languageFilter: string[]; + setLanguageFilter: (languages: string[]) => void; }; // Create the context with default values @@ -37,6 +39,10 @@ export const AppContext = createContext({ setTitle: () => { return; }, + languageFilter: [], + setLanguageFilter: () => { + return; + }, }); // Provider component @@ -46,7 +52,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ const [isInitialized] = useState(true); const [skipToMainFocused, setSkipToMainFocused] = useState(false); const [title, setTitle] = useState(''); - + const [languageFilter, setLanguageFilter] = useState([]); /** * Keep state if window screen size is mobile, pad or desktop. */ @@ -106,6 +112,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ setSkipToMainFocused, title, setTitle, + languageFilter, + setLanguageFilter, }), [ getSavedQueryId, @@ -118,6 +126,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ setSkipToMainFocused, title, setTitle, + languageFilter, + setLanguageFilter, ], ); From 01335317a4dd02536a4c6c183e5209e85b5ce783 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 7 May 2026 16:05:31 +0200 Subject: [PATCH 3/7] fix: ensure appLanguageFilter defaults to an empty array in LanguageSwitcher --- .../src/app/components/LanguageSwitcher/LanguageSwitcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx index 068886de5..0be02dcbf 100644 --- a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx +++ b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx @@ -11,7 +11,7 @@ import classes from './LanguageSwitcher.module.scss'; export const LanguageSwitcher = () => { const { t, i18n } = useTranslation(); - const { isMobile, languageFilter: appLanguageFilter } = useApp(); + const { isMobile, languageFilter: appLanguageFilter = [] } = useApp(); const config = getConfig(); const navigate = useNavigate(); const location = useLocation(); From de5079cda689aae435d41139bc5fe6b667c16795 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 7 May 2026 16:30:58 +0200 Subject: [PATCH 4/7] feat: update LanguageSwitcher to handle language filtering based on appLanguageFilter state --- .../LanguageSwitcher.spec.tsx | 31 ++++++++++++++++++- .../LanguageSwitcher/LanguageSwitcher.tsx | 15 ++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx index ff0f34b3a..6163a8eba 100644 --- a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx +++ b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx @@ -11,6 +11,7 @@ import classes from './LanguageSwitcher.module.scss'; let currentPathname = '/en/tables'; const navigateMock = vi.fn(); let isMobile = false; +let appLanguageFilter: string[] = []; let mockI18nLanguage = 'en'; const changeLanguageMock = vi.fn(); @@ -43,7 +44,7 @@ vi.mock('@pxweb2/pxweb2-ui', () => ({ })); vi.mock('../../context/useApp', () => ({ - default: () => ({ isMobile }), + default: () => ({ isMobile, languageFilter: appLanguageFilter }), })); // Override the global i18n mock for this test file to add controllable behavior @@ -64,6 +65,7 @@ describe('LanguageSwitcher', () => { changeLanguageMock.mockClear(); currentPathname = '/en/tables'; isMobile = false; + appLanguageFilter = []; mockI18nLanguage = 'en'; }); @@ -256,4 +258,31 @@ describe('LanguageSwitcher', () => { expect(select.value).toBe('sv'); }); + + it('enables all languages when app language filter is empty', () => { + appLanguageFilter = []; + + render(); + + const options = screen.getAllByRole('option') as HTMLOptionElement[]; + + expect(options).toHaveLength(4); + expect(options.every((option) => !option.disabled)).toBe(true); + }); + + it('disables non-filtered languages when app language filter has values', () => { + appLanguageFilter = ['en', 'sv']; + + render(); + + const options = screen.getAllByRole('option') as HTMLOptionElement[]; + const optionByValue = Object.fromEntries( + options.map((option) => [option.value, option]), + ); + + expect(optionByValue.en.disabled).toBe(false); + expect(optionByValue.sv.disabled).toBe(false); + expect(optionByValue.no.disabled).toBe(true); + expect(optionByValue.ar.disabled).toBe(true); + }); }); diff --git a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx index 0be02dcbf..1970449f3 100644 --- a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx +++ b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx @@ -26,13 +26,11 @@ export const LanguageSwitcher = () => { setCurrentLang(i18n.language); }, [location.pathname, i18n.language]); + const hasLanguageFilter = appLanguageFilter.length > 0; const languageFilter = new Set( - (appLanguageFilter.length > 0 - ? config.language.supportedLanguages.filter((language) => - appLanguageFilter.includes(language.shorthand), - ) - : config.language.supportedLanguages - ).map((language) => language.shorthand), + config.language.supportedLanguages + .filter((language) => appLanguageFilter.includes(language.shorthand)) + .map((language) => language.shorthand), ); const handleLanguageChange = ( @@ -117,13 +115,12 @@ export const LanguageSwitcher = () => { }} onChange={(event) => handleLanguageChange(event)} > - {config.language.supportedLanguages - .filter((language) => languageFilter.has(language.shorthand)) - .map((language) => ( + {config.language.supportedLanguages.map((language) => ( From e5d19a943ac1d076b729b0033639873efe1a825e Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 7 May 2026 16:31:29 +0200 Subject: [PATCH 5/7] prettier code --- .../LanguageSwitcher/LanguageSwitcher.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx index 1970449f3..59d1d53d4 100644 --- a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx +++ b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx @@ -116,15 +116,17 @@ export const LanguageSwitcher = () => { onChange={(event) => handleLanguageChange(event)} > {config.language.supportedLanguages.map((language) => ( - - ))} + + ))} ) From 964b5b353aa162c764c263082fe82fd62c04dc2e Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Mon, 11 May 2026 15:25:28 +0200 Subject: [PATCH 6/7] feat: refactor LanguageSwitcher to use languageOptions for rendering supported languages. No options are disabled. --- .../LanguageSwitcher/LanguageSwitcher.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx index 59d1d53d4..0aabfe691 100644 --- a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx +++ b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.tsx @@ -27,11 +27,11 @@ export const LanguageSwitcher = () => { }, [location.pathname, i18n.language]); const hasLanguageFilter = appLanguageFilter.length > 0; - const languageFilter = new Set( - config.language.supportedLanguages - .filter((language) => appLanguageFilter.includes(language.shorthand)) - .map((language) => language.shorthand), - ); + const languageOptions = hasLanguageFilter + ? config.language.supportedLanguages.filter((language) => + appLanguageFilter.includes(language.shorthand), + ) + : config.language.supportedLanguages; const handleLanguageChange = ( event: React.ChangeEvent, @@ -115,14 +115,11 @@ export const LanguageSwitcher = () => { }} onChange={(event) => handleLanguageChange(event)} > - {config.language.supportedLanguages.map((language) => ( + {languageOptions.map((language) => ( From 40545f51a51d39214b1a62b972dfc812a9e6cf71 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Mon, 11 May 2026 15:30:05 +0200 Subject: [PATCH 7/7] feat: update LanguageSwitcher tests to only display filtered languages based on appLanguageFilter values --- .../LanguageSwitcher/LanguageSwitcher.spec.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx index 6163a8eba..2bc158145 100644 --- a/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx +++ b/packages/pxweb2/src/app/components/LanguageSwitcher/LanguageSwitcher.spec.tsx @@ -270,7 +270,7 @@ describe('LanguageSwitcher', () => { expect(options.every((option) => !option.disabled)).toBe(true); }); - it('disables non-filtered languages when app language filter has values', () => { + it('only displays filtered languages when app language filter has values', () => { appLanguageFilter = ['en', 'sv']; render(); @@ -280,9 +280,10 @@ describe('LanguageSwitcher', () => { options.map((option) => [option.value, option]), ); - expect(optionByValue.en.disabled).toBe(false); - expect(optionByValue.sv.disabled).toBe(false); - expect(optionByValue.no.disabled).toBe(true); - expect(optionByValue.ar.disabled).toBe(true); + expect(options).toHaveLength(2); + expect(optionByValue.en).toBeDefined(); + expect(optionByValue.sv).toBeDefined(); + expect(optionByValue.no).toBeUndefined(); + expect(optionByValue.ar).toBeUndefined(); }); });