diff --git a/packages/pxweb2-api-client/src/models/Dataset.ts b/packages/pxweb2-api-client/src/models/Dataset.ts index 5a50b0e12..463e122c3 100644 --- a/packages/pxweb2-api-client/src/models/Dataset.ts +++ b/packages/pxweb2-api-client/src/models/Dataset.ts @@ -6,7 +6,7 @@ import type { ClassType } from './ClassType'; import type { Dimension } from './Dimension'; import type { extension_root } from './extension_root'; import type { href } from './href'; -import type { jsonstat_link } from './jsonstat_link'; +import type { jsonstat_extension_link } from './jsonstat_extension_link'; import type { jsonstat_note } from './jsonstat_note'; import type { label } from './label'; import type { Role } from './Role'; @@ -29,7 +29,7 @@ export type Dataset = { label?: label; source?: source; updated?: updated; - link?: jsonstat_link; + link?: jsonstat_extension_link; /** * Note for table */ diff --git a/packages/pxweb2-api-client/src/models/OutputFormatParamType.ts b/packages/pxweb2-api-client/src/models/OutputFormatParamType.ts index d0b45f635..4af9fe3f1 100644 --- a/packages/pxweb2-api-client/src/models/OutputFormatParamType.ts +++ b/packages/pxweb2-api-client/src/models/OutputFormatParamType.ts @@ -11,6 +11,7 @@ * * SeparatorTab: Can not be combined with SeparatorSpace and SeparatorSemicolon. And only applicable for csv output format. * * SeparatorSpace: Can not be combined with SeparatorTab and SeparatorSemicolon. And only applicable for csv output format. * * SeparatorSemicolon: Can not be combined with SeparatorTab and SeparatorSpace. And only applicable for csv output format. + * * ExcludeZerosAndMissingValues: Can be used by all formats but only have effect on csv, html and xlsx output format. * */ export enum OutputFormatParamType { @@ -21,4 +22,5 @@ export enum OutputFormatParamType { SEPARATOR_TAB = 'SeparatorTab', SEPARATOR_SPACE = 'SeparatorSpace', SEPARATOR_SEMICOLON = 'SeparatorSemicolon', + EXCLUDE_ZEROS_AND_MISSING_VALUES = 'ExcludeZerosAndMissingValues', } diff --git a/packages/pxweb2-api-client/src/models/jsonstat_extension_link.ts b/packages/pxweb2-api-client/src/models/jsonstat_extension_link.ts index b85284f9e..0c431cff5 100644 --- a/packages/pxweb2-api-client/src/models/jsonstat_extension_link.ts +++ b/packages/pxweb2-api-client/src/models/jsonstat_extension_link.ts @@ -2,12 +2,40 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { href } from './href'; +import type { label } from './label'; export type jsonstat_extension_link = { + /** + * DeprecationWarning, please do not use items from describedby, use items from related instead + * @deprecated + */ describedby?: Array<{ /** * A extension object */ extension?: Record; }>; + related?: Array<{ + extension: { + /** + * What type of information is the link to ( e.g. about-statistics, statistics-homepage, definition). Like the IANA relations, but for px. + */ + relation: string; + /** + * Non-null if the link applies to a spesific category. (Typically each contents variable has it own definition, in these cases category holds the contents variable.) + */ + category?: string | null; + /** + * Metaid that was the source when creating this Link + */ + metaid: string; + }; + href: href; + label: label; + /** + * Content-Type + */ + type: string; + }>; }; diff --git a/packages/pxweb2-api-client/src/models/jsonstat_link.ts b/packages/pxweb2-api-client/src/models/jsonstat_link.ts index e362efe96..c695c6ed6 100644 --- a/packages/pxweb2-api-client/src/models/jsonstat_link.ts +++ b/packages/pxweb2-api-client/src/models/jsonstat_link.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ import type { href } from './href'; +/** + * DeprecationWarning, please do not use jsonstat-link, use jsonstat-extension-link instead + * @deprecated + */ export type jsonstat_link = Record
- {variable.variableName} + { + // Capitalize the first letter of the variable name + variable.variableName.charAt(0).toUpperCase() + + variable.variableName.slice(1) + }
diff --git a/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx b/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx index 0aa41fdd1..5dd8787eb 100644 --- a/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx +++ b/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx @@ -9,9 +9,9 @@ import { mockHTMLDialogElement } from '@pxweb2/pxweb2-ui/src/lib/util/test-utils import { renderWithProviders } from '../../util/testing-utils'; import { AppContext, AppContextType } from '../../context/AppProvider'; import { - TableDataContext, - TableDataContextType, -} from '../../context/TableDataProvider'; + VariablesContext, + VariablesContextType, +} from '../../context/VariablesProvider'; describe('TableInformation', () => { beforeEach(() => { @@ -155,35 +155,38 @@ describe('TableInformation', () => { expect(definitionsTab).not.toBeInTheDocument(); }); - it('should render Definitions tab when definitions exist', () => { - const tableDataContextValue: TableDataContextType = { + it('should render Definitions tab when variables metadata contains definitions', () => { + const variablesContextValue: VariablesContextType = { isInitialized: true, - data: { - metadata: { - definitions: { - statisticsDefinitions: { - href: 'https://example.com/definitions', - label: 'Definitions', - }, + pxTableMetadata: { + definitions: { + statisticsDefinitions: { + href: 'https://example.com/from-metadata-call', + label: 'Definitions from metadata call', + type: 'text/html', }, }, - } as unknown as TableDataContextType['data'], - fetchTableData: vi.fn(), - fetchSavedQuery: vi.fn(), - pivotToMobile: vi.fn(), - pivotToDesktop: vi.fn(), - pivot: vi.fn(), - buildTableTitle: vi.fn().mockReturnValue({ - contentText: '', - firstTitlePart: '', - lastTitlePart: '', - }), - isFadingTable: false, - setIsFadingTable: vi.fn(), + } as unknown as VariablesContextType['pxTableMetadata'], + setPxTableMetadata: vi.fn(), + addSelectedValues: vi.fn(), + getSelectedValuesById: vi.fn().mockReturnValue([]), + getSelectedValuesByIdSorted: vi.fn().mockReturnValue([]), + getSelectedCodelistById: vi.fn().mockReturnValue(undefined), + getNumberOfSelectedValues: vi.fn().mockReturnValue(0), + getSelectedMatrixSize: vi.fn().mockReturnValue(1), + getUniqueIds: vi.fn().mockReturnValue([]), + syncVariablesAndValues: vi.fn(), + hasLoadedInitialSelection: false, + setHasLoadedInitialSelection: vi.fn(), + setSelectedVBValues: vi.fn(), + selectedVBValues: [], + isMatrixSizeAllowed: true, + isLoadingMetadata: false, + setIsLoadingMetadata: vi.fn(), }; renderWithProviders( - + { return; }} /> - , + , ); const definitionsTab = screen.getByRole('tab', { diff --git a/packages/pxweb2/src/app/components/TableInformation/TableInformation.tsx b/packages/pxweb2/src/app/components/TableInformation/TableInformation.tsx index 522984aaa..3ccaaa7cf 100644 --- a/packages/pxweb2/src/app/components/TableInformation/TableInformation.tsx +++ b/packages/pxweb2/src/app/components/TableInformation/TableInformation.tsx @@ -4,6 +4,7 @@ import cl from 'clsx'; import classes from './TableInformation.module.scss'; import useTableData from '../../context/useTableData'; +import useVariables from '../../context/useVariables'; import { ContactTab } from './Contact/ContactTab'; import { DetailsTab } from './Details/DetailsTab'; import useApp from '../../context/useApp'; @@ -32,7 +33,8 @@ export function TableInformation({ }: TableInformationProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(selectedTab ?? ''); - const metadataOrUndefined = useTableData().data?.metadata; + const metadataOrUndefined = useTableData().data?.metadata; // metadata only for chosen values + const definitionsOrUndefined = useVariables().pxTableMetadata?.definitions; // total metadata, narrowed down to definitions const { isMobile } = useApp(); const tabsContentRef = useRef(null); @@ -49,7 +51,7 @@ export function TableInformation({ const tabsVariant = isMobile ? 'scrollable' : 'fixed'; const definitionsMandatoryLinkExists = - metadataOrUndefined?.definitions?.statisticsDefinitions !== undefined; + definitionsOrUndefined?.statisticsDefinitions !== undefined; return ( {definitionsMandatoryLinkExists && ( - + )} diff --git a/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.spec.ts b/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.spec.ts index 073e37940..ee4da1837 100644 --- a/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.spec.ts +++ b/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.spec.ts @@ -238,7 +238,352 @@ describe('JsonStat2ResponseMapper', () => { pxTable.metadata.variables[2].values[1].contentInfo?.decimals, ).equals(1); }); + + it('maps table-level and variable-level definitions from related links', () => { + const datasetWithDefinitions: Dataset = { + class: ClassType.DATASET, + version: Dataset.version._2_0, + label: 'Definitions table', + dimension: { + region: { + label: 'Region', + category: { + index: { N: 0 }, + label: { N: 'North' }, + }, + link: { + related: [ + { + extension: { + relation: 'definition', + metaid: 'var-region-1', + }, + href: 'https://example.com/definitions/region-1', + label: 'Region definition 1', + type: 'text/html', + }, + { + extension: { + relation: 'definition', + metaid: 'var-region-2', + }, + href: 'https://example.com/definitions/region-2', + label: 'Region definition 2', + type: 'text/html', + }, + ], + }, + }, + age: { + category: { + index: { A: 0 }, + label: { A: 'All ages' }, + }, + link: { + related: [ + { + extension: { + relation: 'definition', + metaid: 'var-age-1', + }, + href: 'https://example.com/definitions/age-1', + label: 'Age definition', + type: 'text/html', + }, + ], + }, + }, + }, + id: ['region', 'age'], + size: [1, 1], + value: [1], + link: { + related: [ + { + extension: { + relation: 'statistics-homepage', + metaid: 'table-home', + }, + href: 'https://example.com/statistics-homepage', + label: 'Statistics homepage', + type: 'text/html', + }, + { + extension: { + relation: 'about-statistics', + metaid: 'table-about', + }, + href: 'https://example.com/about-statistics', + label: 'About statistics', + type: 'text/html', + }, + ], + }, + }; + + const mapped = mapJsonStat2Response(datasetWithDefinitions); + + expect(mapped.metadata.definitions).toEqual({ + statisticsHomepage: { + href: 'https://example.com/statistics-homepage', + label: 'Statistics homepage', + type: 'text/html', + metaid: 'table-home', + }, + statisticsDefinitions: { + href: 'https://example.com/about-statistics', + label: 'About statistics', + type: 'text/html', + metaid: 'table-about', + }, + variablesDefinitions: [ + { + variableName: 'Region', + links: [ + { + href: 'https://example.com/definitions/region-1', + label: 'Region definition 1', + type: 'text/html', + metaid: 'var-region-1', + }, + { + href: 'https://example.com/definitions/region-2', + label: 'Region definition 2', + type: 'text/html', + metaid: 'var-region-2', + }, + ], + }, + { + variableName: 'age', + links: [ + { + href: 'https://example.com/definitions/age-1', + label: 'Age definition', + type: 'text/html', + metaid: 'var-age-1', + }, + ], + }, + ], + }); + }); + + it('returns empty definitions safely when related links are missing or empty', () => { + const withoutLinks: Dataset = { + class: ClassType.DATASET, + version: Dataset.version._2_0, + dimension: { + region: { + label: 'Region', + category: { + index: { N: 0 }, + label: { N: 'North' }, + }, + }, + }, + id: ['region'], + size: [1], + value: [1], + }; + + const withEmptyRelated: Dataset = { + ...withoutLinks, + link: { related: [] }, + dimension: { + region: { + ...withoutLinks.dimension.region, + link: { related: [] }, + }, + }, + }; + + expect(mapJsonStat2Response(withoutLinks).metadata.definitions).toEqual( + {}, + ); + expect( + mapJsonStat2Response(withEmptyRelated).metadata.definitions, + ).toEqual({}); + }); + + it('maps definitions independently across sequential calls', () => { + const firstDataset: Dataset = { + class: ClassType.DATASET, + version: Dataset.version._2_0, + dimension: { + region: { + label: 'Region', + category: { + index: { N: 0 }, + label: { N: 'North' }, + }, + link: { + related: [ + { + extension: { relation: 'definition', metaid: 'var-first' }, + href: 'https://example.com/var-first', + label: 'Variable first', + type: 'text/html', + }, + ], + }, + }, + }, + id: ['region'], + size: [1], + value: [1], + link: { + related: [ + { + extension: { + relation: 'about-statistics', + metaid: 'about-first', + }, + href: 'https://example.com/about-first', + label: 'About first', + type: 'text/html', + }, + ], + }, + }; + + const secondDataset: Dataset = { + class: ClassType.DATASET, + version: Dataset.version._2_0, + dimension: { + time: { + label: 'Time', + category: { + index: { '2025': 0 }, + label: { '2025': '2025' }, + }, + }, + }, + id: ['time'], + size: [1], + value: [2], + link: { + related: [ + { + extension: { + relation: 'statistics-homepage', + metaid: 'home-second', + }, + href: 'https://example.com/home-second', + label: 'Home second', + type: 'text/html', + }, + ], + }, + }; + + const firstMapped = mapJsonStat2Response(firstDataset); + const secondMapped = mapJsonStat2Response(secondDataset); + + expect(firstMapped.metadata.definitions).toEqual({ + statisticsDefinitions: { + href: 'https://example.com/about-first', + label: 'About first', + type: 'text/html', + metaid: 'about-first', + }, + variablesDefinitions: [ + { + variableName: 'Region', + links: [ + { + href: 'https://example.com/var-first', + label: 'Variable first', + type: 'text/html', + metaid: 'var-first', + }, + ], + }, + ], + }); + + expect(secondMapped.metadata.definitions).toEqual({ + statisticsHomepage: { + href: 'https://example.com/home-second', + label: 'Home second', + type: 'text/html', + metaid: 'home-second', + }, + }); + }); + + it('maps definitions for both mapData=true and mapData=false', () => { + const definitionsDataset: Dataset = { + class: ClassType.DATASET, + version: Dataset.version._2_0, + dimension: { + contentsCode: { + label: 'Contents', + category: { + index: { C1: 0 }, + label: { C1: 'Count' }, + }, + link: { + related: [ + { + extension: { relation: 'definition', metaid: 'var-content' }, + href: 'https://example.com/var-content', + label: 'Content definition', + type: 'text/html', + }, + ], + }, + }, + }, + role: { metric: ['contentsCode'] }, + id: ['contentsCode'], + size: [1], + value: [42], + link: { + related: [ + { + extension: { + relation: 'about-statistics', + metaid: 'about-both', + }, + href: 'https://example.com/about-both', + label: 'About both', + type: 'text/html', + }, + ], + }, + }; + + const mappedWithData = mapJsonStat2Response(definitionsDataset, true); + const mappedWithoutData = mapJsonStat2Response(definitionsDataset, false); + + expect(mappedWithData.metadata.definitions).toEqual( + mappedWithoutData.metadata.definitions, + ); + expect(mappedWithData.metadata.definitions).toEqual({ + statisticsDefinitions: { + href: 'https://example.com/about-both', + label: 'About both', + type: 'text/html', + metaid: 'about-both', + }, + variablesDefinitions: [ + { + variableName: 'Contents', + links: [ + { + href: 'https://example.com/var-content', + label: 'Content definition', + type: 'text/html', + metaid: 'var-content', + }, + ], + }, + ], + }); + }); }); + describe('createDataAndStatus', () => { it('should map values and statuses correctly when both are provided', () => { // Arrange @@ -383,6 +728,7 @@ describe('JsonStat2ResponseMapper', () => { ], contacts: [], notes: [], + definitions: {}, }; const data: PxTableData = { @@ -462,6 +808,7 @@ describe('JsonStat2ResponseMapper', () => { ], contacts: [], notes: [], + definitions: {}, }; const data: PxTableData = { @@ -528,6 +875,7 @@ describe('JsonStat2ResponseMapper', () => { ], contacts: [], notes: [], + definitions: {}, }; const data: PxTableData = { diff --git a/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.ts b/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.ts index 6862306a0..20d3e2cc0 100644 --- a/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.ts +++ b/packages/pxweb2/src/mappers/JsonStat2ResponseMapper.ts @@ -31,146 +31,59 @@ import { } from '@pxweb2/pxweb2-ui'; import { getLabelText } from '../app/util/utils'; -// NOSONAR: Example temporary data for definitions mapping, remove when real data is available from API -// TODO: Remove when real data is available from API -//const tempMetaidLinksDataEmpty = {}; -// TODO: Remove temporary data when real data is available from API -// const tempMetaidLinksData = { -// 'about-statistics': { -// // currently "definisjoner og forklaringer" -// 'dataset-links': [ -// { -// metaid: 'KORTNAVN:aku', -// href: 'https://www.ssb.no/befolkning/folketall/statistikk/befolkning#om-statistikken', -// label: 'About the statistics', -// type: 'text/html', -// }, -// ], -// }, -// }; -// const tempMetaidLinksDataExtended = { -// // TODO: Do these two links only contain one item each? They are arrays in the temp data -// // which ones should be the "main" link that all tables should have (if they have anything in Definitions)? -// 'statistics-homepage': { -// //currently "statistikkside" -// 'dataset-links': [ -// { -// metaid: 'KORTNAVN:aku', -// href: 'https://www.ssb.no/befolkning/folketall/statistikk/befolkning', -// label: 'Statistics homepage', -// type: 'text/html', -// }, -// ], -// }, -// 'about-statistics': { -// // currently "definisjoner og forklaringer" -// 'dataset-links': [ -// { -// metaid: 'KORTNAVN:aku', -// href: 'https://www.ssb.no/befolkning/folketall/statistikk/befolkning#om-statistikken', -// label: 'About the statistics', -// type: 'text/html', -// }, -// ], -// }, -// definitions: { -// KOKkommuneregion0000: { -// 'dimension-links': [ -// { -// metaid: 'urn:ssb:classification:klass:231', -// href: 'https://www.ssb.no/klass/klassifikasjoner/231', -// label: 'Classification for region.', -// type: 'text/html', -// }, -// ], -// }, -// ContentsCode: { -// 'category-links': { -// KOSKBDU0000: [ -// { -// href: 'https://www.ssb.no/contextvariable/KOSKBDU0000', -// label: 'Korrigerte brutto driftsutgifter (1000 kr)', -// type: 'text/html', -// metaid: -// 'urn:ssb:contextvariable:common:8c42e415-e5dc-4a47-93bf-c9c515b39aa6:104549:KOSKBDU0000', -// }, -// ], -// KOSKBDUperelev0000: [ -// { -// href: 'https://www.ssb.no/contextvariable/KOSKBDUperelev0000', -// label: 'Korrigerte brutto driftsutgifter per elev (kr)', -// type: 'text/html', -// metaid: -// 'urn:ssb:contextvariable:common:8c42e415-e5dc-4a47-93bf-c9c515b39aa6:104549:KOSKBDUperelev0000', -// }, -// ], -// KOSKBDUperskyss0000: [ -// { -// href: 'https://www.ssb.no/contextvariable/KOSKBDUperskyss0000', -// label: -// 'Korrigerte brutto driftsutgifter per elev som får skoleskyss (223) (kr)', -// type: 'text/html', -// metaid: -// 'urn:ssb:contextvariable:common:8c42e415-e5dc-4a47-93bf-c9c515b39aa6:104549:KOSKBDUperskyss0000', -// }, -// ], -// }, -// }, -// }, -// }; - -// TODO: Remove TEMPORARY function to map raw JSON definitions data to Definitions type -// when real data is available from API -// TODO: Use the correct Response type from the API when available -// TODO: This needs a refactor when real data is available from API, quick and dirty for now -function mapTableDefinitions() { +function mapDefinitions( + tableLinks: jsonstat_extension_link['related'] | undefined, + dimensions: Dataset['dimension'] | undefined, +): Definitions { const definitions: Definitions = {}; - // NOSONAR: Disabled sonar warning for unused code below, as this is temporary code - // until real data is available from the API - // definitionsJson['statistics-homepage'] && - // (definitions.statisticsHomepage = - // definitionsJson['statistics-homepage']['dataset-links'][0] || []); - - // NOSONAR: Disabled sonar warning for unused code below, as this is temporary code - // definitionsJson['about-statistics'] && - // (definitions.statisticsDefinitions = - // definitionsJson['about-statistics']['dataset-links'][0] || []); - - // NOSONAR: Disabled sonar warning for unused code below, as this is temporary code - // Object.keys(definitionsJson.definitions || {}).forEach((dimensionKey) => { - // const dimensionData = definitionsJson.definitions[dimensionKey]; - // const variableDefinition: VariableDefinition = { - // variableName: dimensionKey, - // links: [], - // }; - - // NOSONAR: Disabled sonar warning for unused code below, as this is temporary code - // if (dimensionData['dimension-links']) { - // variableDefinition.links.push(...dimensionData['dimension-links']); - // } - - // NOSONAR: Disabled sonar warning for unused code below, as this is temporary code - // if (dimensionData['category-links']) { - // Object.values(dimensionData['category-links']).forEach( - // (categoryLinks: DefinitionLink[]) => { - // variableDefinition.links.push(...categoryLinks); - // }, - // ); - // } - - // NOSONAR: Disabled sonar warning for unused code below, as this is temporary code - // if (!definitions.variablesDefinitions) { - // definitions.variablesDefinitions = []; - // } - - // NOSONAR: Disabled sonar warning for unused code below, as this is temporary code - // definitions.variablesDefinitions.push(variableDefinition); - // }); + for (const relatedLink of tableLinks ?? []) { + const relation = relatedLink.extension.relation; + if (relation === 'statistics-homepage') { + definitions.statisticsHomepage = mapDefinitionLink(relatedLink); + } + if (relation === 'about-statistics') { + definitions.statisticsDefinitions = mapDefinitionLink(relatedLink); + } + } + + const variablesDefinitions = Object.entries(dimensions ?? {}).reduce< + NonNullable + >((acc, [dimensionId, dimension]) => { + const definitionLinks = (dimension.link?.related ?? []).map( + mapDefinitionLink, + ); + + if (definitionLinks.length > 0) { + const variableName = dimension.label ?? dimensionId; + + acc.push({ + variableName: variableName, + links: definitionLinks, + }); + } + + return acc; + }, []); + + if (variablesDefinitions.length > 0) { + definitions.variablesDefinitions = variablesDefinitions; + } return definitions; } +function mapDefinitionLink( + relatedLink: NonNullable[number], +) { + return { + href: relatedLink.href, + label: relatedLink.label, + type: relatedLink.type, + metaid: relatedLink.extension.metaid, + }; +} + /** * Internal type. Used to keep track of index in json-stat2 value array * Need to be an object to be passed by reference @@ -230,7 +143,7 @@ export function mapJsonStat2Response( subjectArea: response.extension?.px?.['subject-area'] ?? '', variables: mapVariables(response, mapData), contacts: mapContacts(response.extension?.contact), - definitions: mapTableDefinitions(), // TODO: Use real data from API response when available + definitions: mapDefinitions(response.link?.related, response.dimension), notes: mapNotes(response.note, response.extension?.noteMandatory), pathElements: undefined, };