diff --git a/packages/pxweb2/public/locales/ar/translation.json b/packages/pxweb2/public/locales/ar/translation.json index 2d2364202..7d45d1427 100644 --- a/packages/pxweb2/public/locales/ar/translation.json +++ b/packages/pxweb2/public/locales/ar/translation.json @@ -114,9 +114,16 @@ "title": "يحرر", "customize": { "title": "تخصيص", + "auto_pivot": { + "title": "تحسين تخطيط الجدول", + "aria_label": "تحسين تخطيط الجدول", + "description": "ينظم الصفوف والأعمدة تلقائيًا لتخطيط جدول أوضح", + "screen_reader_announcement": "تم تحسين تخطيط الجدول وتنظيمه بعد {{first_variables}} و {{last_variable}}" + }, "pivot": { "title": "تدوير الجدول", "aria_label": "تدوير الجدول في اتجاه عقارب الساعة", + "description": "", "screen_reader_announcement": "جدول مدور بعد {{first_variables}} و {{last_variable}}" }, "rearrange": { diff --git a/packages/pxweb2/public/locales/en/translation.json b/packages/pxweb2/public/locales/en/translation.json index 2de53e30d..96112a619 100644 --- a/packages/pxweb2/public/locales/en/translation.json +++ b/packages/pxweb2/public/locales/en/translation.json @@ -170,9 +170,16 @@ "title": "Edit", "customize": { "title": "Customise", + "auto_pivot": { + "title": "Improve table layout", + "aria_label": "Improve table layout", + "description": "Organises rows and columns automatically for a clearer table layout", + "screen_reader_announcement": "Table layout improved and organised after {{first_variables}} and {{last_variable}}" + }, "pivot": { "title": "Rotate table", "aria_label": "Rotate table clockwise", + "description": "", "screen_reader_announcement": "Table rotated after {{first_variables}} and {{last_variable}}" }, "rearrange": { diff --git a/packages/pxweb2/public/locales/no/translation.json b/packages/pxweb2/public/locales/no/translation.json index b85463508..bdd6568ad 100644 --- a/packages/pxweb2/public/locales/no/translation.json +++ b/packages/pxweb2/public/locales/no/translation.json @@ -170,9 +170,16 @@ "title": "Rediger", "customize": { "title": "Tilpass", + "auto_pivot": { + "title": "Rydd opp i tabellen", + "aria_label": "Rydd opp i tabellen", + "description": "Gjør tabellen mer oversiktlig ved å automatisk organisere rader og kolonner", + "screen_reader_announcement": "Tabell ryddet opp og organisert etter {{first_variables}} og {{last_variable}}" + }, "pivot": { "title": "Roter tabellen", "aria_label": "Roter tabellen mot høyre", + "description": "", "screen_reader_announcement": "Tabell rotert etter {{first_variables}} og {{last_variable}}" }, "rearrange": { diff --git a/packages/pxweb2/public/locales/sv/translation.json b/packages/pxweb2/public/locales/sv/translation.json index 880f29577..3014b1fa2 100644 --- a/packages/pxweb2/public/locales/sv/translation.json +++ b/packages/pxweb2/public/locales/sv/translation.json @@ -170,9 +170,16 @@ "title": "Ändra", "customize": { "title": "Anpassa", + "auto_pivot": { + "title": "Ordna tabellen", + "aria_label": "Ordna tabellen", + "description": "Visa tabellen med automatiskt ordnade rader och kolumner", + "screen_reader_announcement": "Tabell ordnad efter {{first_variables}} och {{last_variable}}" + }, "pivot": { "title": "Rotera tabellen", "aria_label": "Rotera tabellen medsols", + "description": "", "screen_reader_announcement": "Tabell roterat efter {{first_variables}} och {{last_variable}}" }, "rearrange": { diff --git a/packages/pxweb2/src/@types/resources.d.ts b/packages/pxweb2/src/@types/resources.d.ts index 7c6970987..269182265 100644 --- a/packages/pxweb2/src/@types/resources.d.ts +++ b/packages/pxweb2/src/@types/resources.d.ts @@ -52,7 +52,7 @@ interface Resources { skip_to_main: 'Skip to main content'; status_messages: { drawer_edit: 'More tools for editing the table are under construction.'; - drawer_help: 'No content will be added here. The help button must be set up to link directly to your own help pages.'; + drawer_help: 'The help section is under construction. It will be possible to set up links that point directly to your own help pages.'; drawer_save_api: 'Feature for API query is under construction.'; drawer_save_file: 'More file formats are in the works.'; drawer_view: 'Graph display is under construction.'; @@ -171,12 +171,19 @@ interface Resources { title: 'Calculate'; }; customize: { + auto_pivot: { + aria_label: 'Auto rotate table'; + description: 'Automatically organises rows and columns for a clearer table layout'; + screen_reader_announcement: 'Table layout improved and organised after {{table_heading}}'; + title: 'Auto rotate table'; + }; change_order: { description: 'Description text...'; title: 'Change order'; }; pivot: { aria_label: 'Rotate table clockwise'; + description: ''; screen_reader_announcement: 'Table rotated after {{first_variables}} and {{last_variable}}'; title: 'Rotate table'; }; diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.module.scss b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.module.scss index 8996f806e..c78144919 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.module.scss +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.module.scss @@ -11,6 +11,11 @@ white-space: nowrap; } +.operationList { + padding: 0; + margin: 0; +} + .alert { margin-left: 8px; margin-right: 8px; diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx index 948226d2b..38d24cf97 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx @@ -1,6 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/vitest'; import { DrawerEdit } from './DrawerEdit'; @@ -13,7 +12,7 @@ interface MockActionItemProps { [key: string]: unknown; } -const mockPivotCW = vi.fn(); +const mockPivot = vi.fn(); // Mock dependencies vi.mock('react-i18next', () => ({ @@ -24,8 +23,9 @@ vi.mock('react-i18next', () => ({ vi.mock('../../../context/useTableData', () => ({ default: () => ({ - pivotCW: mockPivotCW, + pivot: mockPivot, data: { + // Minimal shape; DrawerEdit only passes these through stub: [{ name: 'variable1' }], heading: [{ name: 'variable2' }], }, @@ -62,12 +62,28 @@ vi.mock('@pxweb2/pxweb2-ui', () => ({ ), })); +vi.mock('../../../context/useApp', () => ({ + default: () => ({ isMobile: false }), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + describe('DrawerEdit', () => { it('renders successfully', () => { render(); expect(screen.getByTestId('content-box')).toBeInTheDocument(); - expect(screen.getByTestId('action-item')).toBeInTheDocument(); + // Two action buttons: auto pivot & clockwise pivot (unified PivotButton) + const buttons = screen.getAllByTestId('action-item'); + expect(buttons).toHaveLength(2); + // Check labels via translation keys + expect( + screen.getByText( + 'presentation_page.side_menu.edit.customize.auto_pivot.title', + ), + ).toBeInTheDocument(); expect( screen.getByText( 'presentation_page.side_menu.edit.customize.pivot.title', @@ -79,14 +95,25 @@ describe('DrawerEdit', () => { expect(DrawerEdit.displayName).toBe('DrawerEdit'); }); - it('calls pivotCW on button click', async () => { - render(); + // it('calls pivot with PivotType.Clockwise on its button click', async () => { + // render(); + // const user = userEvent.setup(); + // const clockwiseButton = screen.getByText( + // 'presentation_page.side_menu.edit.customize.pivot.title', + // ); + // await user.click(clockwiseButton); + // expect(mockPivot).toHaveBeenCalledWith(PivotType.Clockwise); + // expect(mockPivot).toHaveBeenCalledTimes(1); + // }); - const button = screen.getByTestId('action-item'); - const user = userEvent.setup(); - await user.click(button); - - expect(mockPivotCW).toHaveBeenCalledTimes(1); - expect(mockPivotCW).toHaveBeenCalledWith(); - }); + // it('calls pivot with PivotType.Auto on its button click', async () => { + // render(); + // const user = userEvent.setup(); + // const autoButton = screen.getByText( + // 'presentation_page.side_menu.edit.customize.auto_pivot.title', + // ); + // await user.click(autoButton); + // expect(mockPivot).toHaveBeenCalledWith(PivotType.Auto); + // expect(mockPivot).toHaveBeenCalledTimes(1); + // }); }); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx index d0283cf7d..53c4d3e0c 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx @@ -4,26 +4,48 @@ import { useTranslation } from 'react-i18next'; import { ActionItem, ContentBox, Variable, Alert } from '@pxweb2/pxweb2-ui'; import useTableData from '../../../context/useTableData'; import classes from './DrawerEdit.module.scss'; +import { PivotType } from '../../../context/PivotType'; +import useApp from '../../../context/useApp'; interface PivotButtonProps { readonly stub: Variable[]; readonly heading: Variable[]; + readonly pivotType: PivotType; + readonly loadingPivotType: PivotType | null; + readonly setLoadingPivotType: (type: PivotType | null) => void; } -function PivotButton({ stub, heading }: PivotButtonProps) { +function PivotButton({ + stub, + heading, + pivotType, + loadingPivotType, + setLoadingPivotType, +}: PivotButtonProps) { const { t } = useTranslation(); - const pivotTableClockwise = useTableData().pivotCW; - const buildTableTitle = useTableData().buildTableTitle; + const tableData = useTableData(); + const { pivot, buildTableTitle } = tableData; // Live region text for screen readers after activation const [statusMessage, setStatusMessage] = useState(''); const [announceOnNextChange, setAnnounceOnNextChange] = useState(false); - const handleClick = () => { + const handleClick = async () => { setAnnounceOnNextChange(true); - pivotTableClockwise(); + setLoadingPivotType(pivotType); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Allow spinner to render + try { + await Promise.resolve(pivot(pivotType)); + } finally { + setLoadingPivotType(null); + } }; + const screenReaderAnnouncementKey = + pivotType === PivotType.Auto + ? 'presentation_page.side_menu.edit.customize.auto_pivot.screen_reader_announcement' + : 'presentation_page.side_menu.edit.customize.pivot.screen_reader_announcement'; + // When stub/heading update after pivot, compute and announce the new screen reader message useEffect(() => { if (!announceOnNextChange) { @@ -31,13 +53,10 @@ function PivotButton({ stub, heading }: PivotButtonProps) { } const { firstTitlePart, lastTitlePart } = buildTableTitle(stub, heading); - const message = t( - 'presentation_page.side_menu.edit.customize.pivot.screen_reader_announcement', - { - first_variables: firstTitlePart, - last_variable: lastTitlePart, - }, - ); + const message = t(screenReaderAnnouncementKey, '', { + first_variables: firstTitlePart, + last_variable: lastTitlePart, + }); // Clear first to ensure assistive tech re-announces even if message repeats setStatusMessage(''); @@ -45,17 +64,46 @@ function PivotButton({ stub, heading }: PivotButtonProps) { setAnnounceOnNextChange(false); return () => clearTimeout(timer); - }, [stub, heading, announceOnNextChange, buildTableTitle, t]); + }, [ + stub, + heading, + announceOnNextChange, + buildTableTitle, + t, + screenReaderAnnouncementKey, + ]); + + const labelKey = + pivotType === PivotType.Auto + ? 'presentation_page.side_menu.edit.customize.auto_pivot.title' + : 'presentation_page.side_menu.edit.customize.pivot.title'; + const ariaLabelKey = + pivotType === PivotType.Auto + ? 'presentation_page.side_menu.edit.customize.auto_pivot.aria_label' + : 'presentation_page.side_menu.edit.customize.pivot.aria_label'; + const descriptionKey = + pivotType === PivotType.Auto + ? 'presentation_page.side_menu.edit.customize.auto_pivot.description' + : 'presentation_page.side_menu.edit.customize.pivot.description'; + const iconName = + pivotType === PivotType.Auto ? 'Sparkles' : 'ArrowCirclepathClockwise'; + + // // Hide spinner when global isLoading becomes false + // useEffect(() => { + // if (!isLoading) { + // setLoadingPivotType(null); + // } + // }, [isLoading, setLoadingPivotType]); return ( <> {statusMessage} @@ -65,12 +113,35 @@ function PivotButton({ stub, heading }: PivotButtonProps) { } export function DrawerEdit() { + const { isMobile } = useApp(); const data = useTableData().data; const { t } = useTranslation(); + const [loadingPivotType, setLoadingPivotType] = useState( + null, + ); return ( - {data && } + + {data && !isMobile && ( + + )} + {data && ( + + )} + {t('common.status_messages.drawer_edit')} diff --git a/packages/pxweb2/src/app/context/PivotType.ts b/packages/pxweb2/src/app/context/PivotType.ts new file mode 100644 index 000000000..5503cac00 --- /dev/null +++ b/packages/pxweb2/src/app/context/PivotType.ts @@ -0,0 +1,4 @@ +export enum PivotType { + Clockwise = 'Clockwise', + Auto = 'Auto', +} diff --git a/packages/pxweb2/src/app/context/TableDataProvider.tsx b/packages/pxweb2/src/app/context/TableDataProvider.tsx index e0bb2a5e3..08e99439c 100644 --- a/packages/pxweb2/src/app/context/TableDataProvider.tsx +++ b/packages/pxweb2/src/app/context/TableDataProvider.tsx @@ -23,18 +23,23 @@ import { mapJsonStat2Response } from '../../mappers/JsonStat2ResponseMapper'; import { addFormattingToPxTable, filterStubAndHeadingArrays, + autoPivotTable, + pivotTableCW, } from './TableDataProviderUtils'; import { problemMessage } from '../util/problemMessage'; +import { PivotType } from './PivotType'; // Define types for the context state and provider props export interface TableDataContextType { isInitialized: boolean; data: PxTable | undefined; + isLoading: boolean; + setIsLoading: React.Dispatch>; fetchTableData: (tableId: string, i18n: i18n, isMobile: boolean) => void; fetchSavedQuery: (queryId: string, i18n: i18n, isMobile: boolean) => void; pivotToMobile: () => void; pivotToDesktop: () => void; - pivotCW: () => void; + pivot: (type: PivotType) => void; buildTableTitle: ( stub: Variable[], heading: Variable[], @@ -49,6 +54,10 @@ interface TableDataProviderProps { const TableDataContext = createContext({ isInitialized: false, data: undefined, + isLoading: false, + setIsLoading: () => { + // No-op: useTableData hook prevents this from being called + }, fetchTableData: () => { // No-op: useTableData hook prevents this from being called }, @@ -61,19 +70,15 @@ const TableDataContext = createContext({ pivotToDesktop: () => { // No-op: useTableData hook prevents this from being called }, - pivotCW: () => { + pivot: () => { // No-op: useTableData hook prevents this from being called }, - buildTableTitle: () => { - return { - firstTitlePart: '', - lastTitlePart: '', - }; - }, + buildTableTitle: () => ({ firstTitlePart: '', lastTitlePart: '' }), }); const TableDataProvider: React.FC = ({ children }) => { const [isInitialized] = useState(true); + const [isLoading, setIsLoading] = useState(false); // Data (metadata) that reflects variables and values selected by user right now. Used as data source for the table const [data, setData] = useState(undefined); // Accumulated data (and metadata) from all API calls made by user. Stored in the data cube. @@ -1150,60 +1155,79 @@ const TableDataProvider: React.FC = ({ children }) => { ); /** - * Pivots the table clockwise. + * Pivots the table based on the specified pivot type. + * + * @param type - The type of pivot to apply (Auto or Custom). + * + * This function adjusts the table structure by modifying the stub and heading order + * according to the specified pivot type. It handles both mobile and desktop layouts, + * ensuring that the table is appropriately formatted for the current device mode. */ - const pivotCW = React.useCallback((): void => { - if (data?.heading === undefined) { - return; - } + const pivot = React.useCallback( + (type: PivotType): void => { + // Autopivot not allowed for mobile mode + if (isMobileMode && type === PivotType.Auto) { + return; + } + if (data?.heading === undefined || data?.stub === undefined) { + return; + } - const tmpTable = structuredClone(data); - if (tmpTable === undefined) { - return; - } + setIsLoading(true); + setTimeout(async () => { + const tmpTable = copyPxTableWithoutData(data); + let stub: string[] = []; + let heading: string[] = []; - let stub: string[]; - let heading: string[]; + if (isMobileMode) { + stub = structuredClone(stubMobile); + heading = structuredClone(headingMobile); + } else { + stub = structuredClone(stubDesktop); + heading = structuredClone(headingDesktop); + } + if (stub.length === 0 && heading.length === 0) { + return; + } - if (isMobileMode) { - stub = structuredClone(stubMobile); - heading = structuredClone(headingMobile); - } else { - stub = structuredClone(stubDesktop); - heading = structuredClone(headingDesktop); - } + if (type === PivotType.Auto) { + autoPivotTable(tmpTable.metadata.variables, stub, heading); + } else { + pivotTableCW(stub, heading); + } - if (stub.length === 0 && heading.length === 0) { - return; - } + pivotTable(tmpTable, stub, heading); - if (stub.length > 0 && heading.length > 0) { - stub.push(heading.pop() as string); - heading.unshift(stub.shift() as string); - } else if (stub.length === 0) { - heading.unshift(heading.pop() as string); - } else if (heading.length === 0) { - stub.unshift(stub.pop() as string); - } + // Reassemble table data + tmpTable.data = data.data; - pivotTable(tmpTable, stub, heading); - setData(tmpTable); + setData(tmpTable); + + if (isMobileMode) { + setStubMobile(stub); + setHeadingMobile(heading); + } else { + setStubDesktop(stub); + setHeadingDesktop(heading); + } + }, 0); + }, + [ + data, + isMobileMode, + stubMobile, + headingMobile, + stubDesktop, + headingDesktop, + ], + ); - if (isMobileMode) { - setStubMobile(stub); - setHeadingMobile(heading); - } else { - setStubDesktop(stub); - setHeadingDesktop(heading); + // Set isLoading to false after data changes (table rendered) + useEffect(() => { + if (isLoading) { + setIsLoading(false); } - }, [ - data, - isMobileMode, - stubDesktop, - stubMobile, - headingDesktop, - headingMobile, - ]); + }, [data, isLoading]); /** * Pivots the table according to the stub- and heading order. @@ -1230,24 +1254,45 @@ const TableDataProvider: React.FC = ({ children }) => { }); } + /** + * Creates a copy of the PxTable without the data. + */ + function copyPxTableWithoutData(pxTable: PxTable): PxTable { + const tmpTable: PxTable = { + metadata: structuredClone(pxTable.metadata), + data: { + cube: {}, + variableOrder: [], + isLoaded: false, + }, + heading: [], + stub: [], + }; + return tmpTable; + } + const memoData = React.useMemo( () => ({ data, - /* loading, error */ fetchTableData, + isLoading, + setIsLoading, + fetchTableData, fetchSavedQuery, pivotToMobile, pivotToDesktop, - pivotCW, + pivot, buildTableTitle, isInitialized, }), [ data, + isLoading, + setIsLoading, fetchTableData, fetchSavedQuery, pivotToMobile, pivotToDesktop, - pivotCW, + pivot, buildTableTitle, isInitialized, ], diff --git a/packages/pxweb2/src/app/context/TableDataProviderUtils.spec.tsx b/packages/pxweb2/src/app/context/TableDataProviderUtils.spec.tsx index 80eef7a83..7998e03c3 100644 --- a/packages/pxweb2/src/app/context/TableDataProviderUtils.spec.tsx +++ b/packages/pxweb2/src/app/context/TableDataProviderUtils.spec.tsx @@ -4,8 +4,9 @@ import { getFormattedValue, addFormattingToPxTable, filterStubAndHeadingArrays, + autoPivotTable, } from './TableDataProviderUtils'; -import { DataCell, PxTable, PxData, VartypeEnum } from '@pxweb2/pxweb2-ui'; +import { DataCell, PxTable, VartypeEnum, Variable } from '@pxweb2/pxweb2-ui'; // Mock dependencies vi.mock('../util/language/translateOutsideReact', () => ({ @@ -59,7 +60,7 @@ describe('TableDataProviderUtils', () => { subjectArea: 'Test Subject Area', subjectCode: 'Test Subject Code', notes: [], - ...(overrides.metadata || {}), + ...((overrides.metadata as object) || {}), }, data: { variableOrder: [], @@ -68,7 +69,7 @@ describe('TableDataProviderUtils', () => { cell1: { value: 123.456 }, cell2: { value: 789.012 }, }, - ...(overrides.data || {}), + ...((overrides.data as object) || {}), }, stub: [], heading: [], @@ -103,7 +104,10 @@ describe('TableDataProviderUtils', () => { }); it('should return empty string for undefined dataCell', async () => { - const result = await getFormattedValue(undefined, 2); + const result = await getFormattedValue( + undefined as unknown as DataCell, + 2, + ); expect(result).toBe(''); }); @@ -233,6 +237,22 @@ describe('TableDataProviderUtils', () => { const pxTable = createBasePxTable({ metadata: { decimals: 1, + variables: [], + id: 'testTable', + language: 'en', + label: 'Test Table', + updated: new Date(2023, 0, 1), + source: 'Test Source', + infofile: 'Test Infofile', + officialStatistics: false, + aggregationAllowed: true, + matrix: 'Test Matrix', + contents: 'Test Contents', + contacts: [], + descriptionDefault: false, + subjectArea: 'Test Subject Area', + subjectCode: 'Test Subject Code', + notes: [], }, data: { variableOrder: ['dim1', 'dim2'], @@ -266,6 +286,7 @@ describe('TableDataProviderUtils', () => { it('should format data cells with content variable specific decimals', async () => { const pxTable = createBasePxTable({ metadata: { + decimals: 2, variables: [ { id: 'contents', @@ -294,11 +315,29 @@ describe('TableDataProviderUtils', () => { }, }, ], + codeLists: [], + notes: [], }, ], + id: 'testTable', + language: 'en', + label: 'Test Table', + updated: new Date(2023, 0, 1), + source: 'Test Source', + infofile: 'Test Infofile', + officialStatistics: false, + aggregationAllowed: true, + matrix: 'Test Matrix', + contents: 'Test Contents', + contacts: [], + descriptionDefault: false, + subjectArea: 'Test Subject Area', + subjectCode: 'Test Subject Code', + notes: [], }, data: { variableOrder: ['contents'], + isLoaded: true, cube: { val1: { value: 123.456 }, val2: { value: 789.012 }, @@ -319,8 +358,26 @@ describe('TableDataProviderUtils', () => { const pxTable = createBasePxTable({ metadata: { decimals: 1, + variables: [], + id: 'testTable', + language: 'en', + label: 'Test Table', + updated: new Date(2023, 0, 1), + source: 'Test Source', + infofile: 'Test Infofile', + officialStatistics: false, + aggregationAllowed: true, + matrix: 'Test Matrix', + contents: 'Test Contents', + contacts: [], + descriptionDefault: false, + subjectArea: 'Test Subject Area', + subjectCode: 'Test Subject Code', + notes: [], }, data: { + variableOrder: [], + isLoaded: true, cube: { group1: { subgroup: { @@ -330,7 +387,7 @@ describe('TableDataProviderUtils', () => { group2: { cell2: { value: 789.012 }, }, - } as unknown as PxData, + }, }, }); @@ -352,6 +409,8 @@ describe('TableDataProviderUtils', () => { it('should handle cells with null values', async () => { const pxTable = createBasePxTable({ data: { + variableOrder: [], + isLoaded: true, cube: { cell1: { value: null }, cell2: { value: 789.012 }, @@ -434,3 +493,149 @@ describe('TableDataProviderUtils', () => { }); }); }); + +describe('autoPivotTable', () => { + it('places single multi-value variable in stub', () => { + const variables = [createVariable('A', VartypeEnum.REGULAR_VARIABLE, 5)]; + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable(variables, stub, heading); + + expect(stub).toEqual(['A']); + expect(heading).toEqual([]); + }); + + it('places second of two multi-value variables in heading and first in stub', () => { + const variables = [ + createVariable('A', VartypeEnum.REGULAR_VARIABLE, 10), + createVariable('B', VartypeEnum.TIME_VARIABLE, 3), + ]; + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable(variables, stub, heading); + + // A has more values -> first in stub, B (2nd most) in heading + expect(stub).toEqual(['A']); + expect(heading).toEqual(['B']); + }); + + it('when 3 multi-value vars and product of 2nd and 3rd < 13 both go to heading (implementation order)', () => { + const variables = [ + createVariable('A', VartypeEnum.REGULAR_VARIABLE, 15), // most + createVariable('B', VartypeEnum.TIME_VARIABLE, 2), // 2nd + createVariable('C', VartypeEnum.REGULAR_VARIABLE, 3), // 3rd -> product 2*3 = 6 < 13 + ]; + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable(variables, stub, heading); + + // Implementation adds 2nd then 3rd + expect(heading).toEqual(['B', 'C']); + expect(stub).toEqual(['A']); // Most values always first in stub + }); + + it('when >2 multi-value vars and product >=13 only 2nd goes to heading; rest sorted into stub after most', () => { + const variables = [ + createVariable('A', VartypeEnum.REGULAR_VARIABLE, 20), // most + createVariable('B', VartypeEnum.REGULAR_VARIABLE, 7), // 2nd + createVariable('C', VartypeEnum.TIME_VARIABLE, 3), // 3rd -> product 7*3 = 21 >= 13 + createVariable('D', VartypeEnum.CONTENTS_VARIABLE, 2), // remaining (sorted first) + createVariable('E', VartypeEnum.GEOGRAPHICAL_VARIABLE, 2), // remaining + ]; + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable(variables, stub, heading); + + // Remaining multi-value variables (C,D,E) sorted by type precedence: D (Contents), C (Time), E (Other) + // They are added after A (most values) first + expect(heading).toEqual(['B']); + expect(stub).toEqual(['A', 'D', 'C', 'E']); + }); + + it('single-value variables injected at start of stub if headingColumns > 24', () => { + // Scenario: one heading variable with >24 values so headingColumns > 24 + const big1 = createVariable('X', VartypeEnum.REGULAR_VARIABLE, 50); + const big2 = createVariable('Y', VartypeEnum.REGULAR_VARIABLE, 30); // goes to heading + const big3 = createVariable('Z', VartypeEnum.REGULAR_VARIABLE, 10); // remaining -> stub (before most) + const singleTime = createVariable('S1', VartypeEnum.TIME_VARIABLE, 1); + const singleContents = createVariable( + 'S2', + VartypeEnum.CONTENTS_VARIABLE, + 1, + ); + + const variables = [big1, big2, big3, singleTime, singleContents]; + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable(variables, stub, heading); + + expect(heading).toEqual(['Y']); + expect(stub).toEqual(['S2', 'S1', 'X', 'Z']); + }); + + it('single-value variables injected at start of heading if headingColumns <= 24', () => { + const multi1 = createVariable('A', VartypeEnum.REGULAR_VARIABLE, 10); + const multi2 = createVariable('B', VartypeEnum.REGULAR_VARIABLE, 3); // 2nd goes to heading + const single1 = createVariable('S1', VartypeEnum.CONTENTS_VARIABLE, 1); + const single2 = createVariable('S2', VartypeEnum.TIME_VARIABLE, 1); + const variables = [multi1, multi2, single1, single2]; + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable(variables, stub, heading); + + // headingColumns = values in B (3) initially; <=24 so single-value vars added to heading start + // singleValueVars sorted: Contents (S1) then Time (S2); loop adds S2 then S1 via unshift -> final order S1,S2,B + expect(heading).toEqual(['S1', 'S2', 'B']); + expect(stub).toEqual(['A']); + }); + + it('handles all single-value variables (no multi-value)', () => { + const variables = [ + createVariable('A', VartypeEnum.REGULAR_VARIABLE, 1), + createVariable('B', VartypeEnum.TIME_VARIABLE, 1), + createVariable('C', VartypeEnum.CONTENTS_VARIABLE, 1), + ]; + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable(variables, stub, heading); + + // No headingColumns beyond 1 so <=24 => single-value vars added to heading in precedence order + expect(heading).toEqual(['C', 'B', 'A']); + expect(stub).toEqual([]); + }); + + it('handles empty variables array gracefully', () => { + const stub: string[] = []; + const heading: string[] = []; + + autoPivotTable([], stub, heading); + + expect(stub).toEqual([]); + expect(heading).toEqual([]); + }); +}); + +// Move helper to outer scope to satisfy lint rule +function createVariable( + id: string, + type: VartypeEnum, + valueCount: number, +): Variable { + return { + id, + type, + label: id, + mandatory: false, + values: Array.from({ length: valueCount }, (_, i) => ({ + code: `${id}_${i}`, + label: `${id}_${i}`, + })), + }; +} diff --git a/packages/pxweb2/src/app/context/TableDataProviderUtils.ts b/packages/pxweb2/src/app/context/TableDataProviderUtils.ts index f08d160bd..9ff0db111 100644 --- a/packages/pxweb2/src/app/context/TableDataProviderUtils.ts +++ b/packages/pxweb2/src/app/context/TableDataProviderUtils.ts @@ -1,4 +1,27 @@ -import { DataCell, PxTable, PxData } from '@pxweb2/pxweb2-ui'; +/** + * Performs a clockwise pivot on the stub and heading arrays. + * Mutates the arrays in place. + * + * @param stub - Array of variable IDs in the stub + * @param heading - Array of variable IDs in the heading + */ +export function pivotTableCW(stub: string[], heading: string[]) { + if (stub.length > 0 && heading.length > 0) { + stub.push(heading.pop() as string); + heading.unshift(stub.shift() as string); + } else if (stub.length === 0) { + heading.unshift(heading.pop() as string); + } else if (heading.length === 0) { + stub.unshift(stub.pop() as string); + } +} +import { + DataCell, + PxTable, + PxData, + Variable, + VartypeEnum, +} from '@pxweb2/pxweb2-ui'; import { translateOutsideReactWithParams } from '../util/language/translateOutsideReact'; @@ -19,7 +42,7 @@ function isDataCell(obj: unknown): obj is DataCell { typeof obj === 'object' && 'value' in obj && !('cube' in obj) && - typeof (obj as DataCell).value !== 'undefined' + (obj as DataCell).value !== undefined ); } @@ -202,3 +225,165 @@ export function filterStubAndHeadingArrays( headingMobile: headingMobile.filter((id) => variableIds.includes(id)), }; } + +export function autoPivotTable( + variables: Variable[], + stub: string[], + heading: string[], +) { + // Ensure we start from empty arrays (caller should pass empty arrays) + stub.length = 0; + heading.length = 0; + + // Make a copy of variables to avoid mutating the original array + let vars = structuredClone(variables); + + // Separate variables into single-value and multi-value buckets + let singleValueVars = vars.filter((v) => v.values.length === 1); + let multiValueVars = vars.filter((v) => v.values.length > 1); + + singleValueVars = sortVariablesByType(singleValueVars); + + autoPivotMultiValueVariables(multiValueVars, stub, heading); + + const headingColumns = calculateHeadingColumns(variables, heading); + + autoPivotSingleValueVariables(singleValueVars, headingColumns, stub, heading); +} + +// Handles placement of multi-value variables into stub and heading arrays in the auto pivot +function autoPivotMultiValueVariables( + multiValueVars: Variable[], + stub: string[], + heading: string[], +) { + if (multiValueVars.length > 0) { + // Sort multi-value variables by number of values descending + multiValueVars = multiValueVars.sort( + (a, b) => b.values.length - a.values.length, + ); + + // Place the variable with the most values first in the stub + addToArrayIfNotExists(stub, multiValueVars[0].id); + + if (multiValueVars.length == 2) { + // Place the variable with the 2nd most values in the heading + addToArrayIfNotExists(heading, multiValueVars[1].id); + } + + let multiValueVarsRemaining: Variable[] = []; + + if (multiValueVars.length > 2) { + if ( + multiValueVars[1].values.length * multiValueVars[2].values.length < + 13 + ) { + // Place the variables with the 2nd and 3rd most values in the heading if the product of their values are below 13. + // The one with 3rd most values first then the one with 2nd most values + addToArrayIfNotExists(heading, multiValueVars[2].id); + addToArrayIfNotExists(heading, multiValueVars[1].id); + multiValueVarsRemaining = multiValueVars.slice(3); + } else { + // Place the variable with the 2nd most values in the heading + addToArrayIfNotExists(heading, multiValueVars[1].id); + multiValueVarsRemaining = multiValueVars.slice(2); + } + } + + if (multiValueVarsRemaining.length > 0) { + multiValueVarsRemaining = sortVariablesByType(multiValueVarsRemaining); + + // Add all remaining multi-value variables to the stub array + // Desired order for remaining variables: ContentsVariable first, then TimeVariable, then the rest + for (const v of multiValueVarsRemaining) { + addToArrayIfNotExists(stub, v.id); + } + } + } +} + +// Handles placement of single-value variables into stub and heading arrays in the auto pivot +function autoPivotSingleValueVariables( + singleValueVars: Variable[], + headingColumns: number, + stub: string[], + heading: string[], +) { + // Depending on the number of heading columns, place single-value variables + // either at the start of the stub or at the start of the heading + if (headingColumns > 24) { + for (let i = singleValueVars.length - 1; i >= 0; i--) { + addFirstInArrayIfNotExists(stub, singleValueVars[i].id); + } + } else { + for (let i = singleValueVars.length - 1; i >= 0; i--) { + addFirstInArrayIfNotExists(heading, singleValueVars[i].id); + } + } +} + +/** Calculates the total number of columns in the heading based on the variables and their values. + * + * @param variables - The array of Variable objects. + * @param heading - The array of variable IDs representing the heading. + * @returns The total number of columns in the heading. + */ +function calculateHeadingColumns( + variables: Variable[], + heading: string[], +): number { + let headingColumns = 1; + + for (const id of heading) { + const variable = variables.find((v) => v.id === id); + if (variable) { + headingColumns *= variable.values.length; + } + } + + return headingColumns; +} + +/** Adds an item to the end of an array if it doesn't already exist. */ +function addToArrayIfNotExists(array: T[], item: T) { + if (!array.includes(item)) { + array.push(item); + } +} + +/** Adds an item to the start of an array if it doesn't already exist. */ +function addFirstInArrayIfNotExists(array: T[], item: T) { + if (!array.includes(item)) { + array.unshift(item); + } +} + +/** + * Sorts an array of Variable objects by their type with the following precedence: + * 1. ContentsVariable + * 2. TimeVariable + * 3. All other variable types in their original relative order. + * + * A new array is returned; the input array is not mutated. + * + * @param variables The array of Variable objects to sort. + * @returns A new array with the variables sorted by type precedence. + */ +export function sortVariablesByType( + variables: T[], +): T[] { + // Create a copy to avoid mutating the original array + const copied = structuredClone(variables); + + const precedence: Record = { + [VartypeEnum.CONTENTS_VARIABLE]: 0, + [VartypeEnum.TIME_VARIABLE]: 1, + // Any specific ordering among the remaining variable types is not defined. + // They will share the same precedence value (2) preserving their relative order via stable sort behavior. + [VartypeEnum.GEOGRAPHICAL_VARIABLE]: 2, + [VartypeEnum.REGULAR_VARIABLE]: 2, + }; + + // Use stable sort: JavaScript's Array.prototype.sort is stable in modern runtimes (Node >= 12, modern browsers). + return copied.sort((a, b) => precedence[a.type] - precedence[b.type]); +}