From 39ed48ca1359fe301cb4d5e3fb589b004d3497f1 Mon Sep 17 00:00:00 2001 From: scespinoza Date: Mon, 3 Nov 2025 15:44:53 -0300 Subject: [PATCH 1/4] adds required dimensions restriction to initial view, columns and drawer --- src/api/tesseract/schema.ts | 1 + src/components/DrawerMenu.tsx | 15 ++++++++- src/components/TableView.tsx | 57 ++++++++++++++++++++++++++++------- src/hooks/useQueryApi.ts | 22 +++++++------- src/state/utils.ts | 11 ++++++- src/utils/structs.ts | 2 +- 6 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/api/tesseract/schema.ts b/src/api/tesseract/schema.ts index 01faebc..cd49bbf 100644 --- a/src/api/tesseract/schema.ts +++ b/src/api/tesseract/schema.ts @@ -183,6 +183,7 @@ export interface TesseractDimension { type: DimensionType; hierarchies: TesseractHierarchy[]; default_hierarchy: string; + required?: boolean; } export interface TesseractHierarchy { diff --git a/src/components/DrawerMenu.tsx b/src/components/DrawerMenu.tsx index fd13f0b..84910e5 100644 --- a/src/components/DrawerMenu.tsx +++ b/src/components/DrawerMenu.tsx @@ -314,13 +314,17 @@ function LevelItem({ activeItem => activeItem.dimension === dimension.name && activeItem.hierarchy !== hierarchy.name ); + const isLastLevelInRequiredDimension = + dimension.required && + activeItems.filter(item => item.dimension === dimension.name).length === 1; + const cut = cutItems.find(cut => cut.level === level.name); const checked = activeItems.map(i => i.level).includes(level.name); const disableUncheck = activeItems.length === 1 && checked; // If another hierarchy in the same dimension is selected, this level is disabled - const isDisabled = isOtherHierarchySelected && !checked; + const isDisabled = isLastLevelInRequiredDimension && checked; if (!currentDrilldown) return; @@ -336,6 +340,15 @@ function LevelItem({ { + if (isOtherHierarchySelected && !checked) { + activeItems + .filter( + item => item.dimension === dimension.name && item.hierarchy !== hierarchy.name + ) + .forEach(item => { + actions.updateDrilldown({...item, active: false}); + }); + } actions.updateDrilldown({ ...currentDrilldown, active: !currentDrilldown.active diff --git a/src/components/TableView.tsx b/src/components/TableView.tsx index 5379004..6dd148c 100644 --- a/src/components/TableView.tsx +++ b/src/components/TableView.tsx @@ -42,13 +42,18 @@ import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState, useRe import {useSelector} from "react-redux"; import {useNavigate} from "react-router-dom"; import {Comparison} from "../api"; -import type {TesseractLevel, TesseractMeasure, TesseractProperty} from "../api/tesseract/schema"; +import type { + TesseractLevel, + TesseractMeasure, + TesseractProperty, + TesseractDimension +} from "../api/tesseract/schema"; import type {TesseractCube} from "../api/tesseract/schema"; import {useFormatter, useidFormatters} from "../hooks/formatter"; import {serializePermalink, useUpdateUrl} from "../hooks/permalink"; import {type ExplorerBoundActionMap, useActions} from "../hooks/settings"; import {useTranslation} from "../hooks/translation"; -import {useFetchQuery, useMeasureItems} from "../hooks/useQueryApi"; +import {useDimensionItems, useFetchQuery, useMeasureItems} from "../hooks/useQueryApi"; import { selectCutItems, selectDrilldownItems, @@ -95,6 +100,21 @@ function isColumnSorted(column: string, key: string) { return column == key; } +function isRequiredColumn( + column: AnyResultColumn, + finalKeys: AnyResultColumn[], + dimensions: TesseractDimension[] +) { + if (column.entityType !== "level") return false; + const dimCount = finalKeys.filter( + c => c.entityType === "level" && c.entity.dimension === column.entity.dimension + ).length; + const dimension = dimensions.find(dim => dim.name === column.entity.dimension); + const isRequired = dimension ? dimension.required && dimCount <= 1 : false; + console.log({column, dimCount, isRequired, dimension}); + return isRequired; +} + const removeColumn = ( queryItem: QueryItem, entity: TesseractMeasure | TesseractProperty | TesseractLevel @@ -139,9 +159,9 @@ const removeColumn = ( const isProperty = (entity: EntityTypes) => entity === "property"; -function showTrashIcon(columns: AnyResultColumn[], type: EntityTypes) { - const result = columns.filter(c => c.entityType === type); - return result.length > 1 || isProperty(type); +function showTrashIcon(entity: AnyResultColumn, columns: AnyResultColumn[]) { + const result = columns.filter(c => c.entityType === entity.entityType); + return result.length > 1 || isProperty(entity.entityType); } const getActionIcon = (entityType: EntityTypes) => { @@ -315,6 +335,7 @@ export function useTable({ const drilldowns = useSelector(selectDrilldownItems); const {code: locale} = useSelector(selectLocale); const measures = useSelector(selectMeasureItems); + const dimensions = useDimensionItems(); const actions = useActions(); const {limit, offset} = useSelector(selectPaginationParams); const queryItem = useSelector(selectCurrentQueryItem); @@ -401,6 +422,18 @@ export function useTable({ * and its contents, for later use. */ const finalKeys = Object.values(tableTypes) + .map(d => { + if (d.entityType === "level") { + return { + ...d, + entity: { + ...d.entity, + dimension: drilldowns.find(dd => dd.key === d.entity.name)?.dimension || "" + } + }; + } + return d; + }) .filter(t => !t.isId) .filter(columnFilter) .sort(columnSorting); @@ -438,7 +471,7 @@ export function useTable({ size: 50 }; - const columnsDef = finalKeys.map(column => { + const columnsDef = finalKeys.map(keyCol => { const { entity, entityType, @@ -447,7 +480,7 @@ export function useTable({ valueType, range, isId - } = column; + } = keyCol; const isNumeric = valueType === "number" && columnKey !== "Year"; @@ -477,6 +510,8 @@ export function useTable({ }, Header: ({column}) => { const isSorted = isColumnSorted(entity.name, sortKey); + const showTrash = + showTrashIcon(keyCol, finalKeys) && !isRequiredColumn(keyCol, finalKeys, dimensions); const isMobile = useMediaQuery( `(max-width: ${theme.breakpoints.sm}${ /(?:px|em|rem|vh|vw|%)$/.test(theme.breakpoints.xs) ? "" : "px" @@ -550,11 +585,11 @@ export function useTable({ {isMobile && actionSort} - {showTrashIcon(finalKeys, entityType) && ( + {showTrash && ( { const nextQueryItem = removeColumn(queryItem, entity); if (nextQueryItem) { @@ -562,7 +597,7 @@ export function useTable({ updateURL(nextQueryItem); } }} - showTooltip={!showTrashIcon(finalKeys, entityType)} + showTooltip={!showTrash} size={25} ml={rem(5)} > @@ -590,7 +625,7 @@ export function useTable({ const cellValue = cell.getValue(); const row = cell.row; const cellId = row.original[`${cell.column.id} ID`]; - const idFormatter = idFormatters[`${column.localeLabel} ID`]; + const idFormatter = idFormatters[`${keyCol.localeLabel} ID`]; return ( diff --git a/src/hooks/useQueryApi.ts b/src/hooks/useQueryApi.ts index dd8d6f7..c916a44 100644 --- a/src/hooks/useQueryApi.ts +++ b/src/hooks/useQueryApi.ts @@ -1,17 +1,9 @@ import {useQuery, useMutation, keepPreviousData} from "@tanstack/react-query"; -import type { - TesseractCube, - TesseractDataResponse, - TesseractFormat, -} from "../api"; +import type {TesseractCube, TesseractDataResponse, TesseractFormat} from "../api"; import {queryParamsToRequest} from "../api/tesseract/parse"; import {filterMap} from "../utils/array"; import {describeData, getOrderValue, getValues} from "../utils/object"; -import { - buildDrilldown, - buildProperty, - QueryParams -} from "../utils/structs"; +import {buildDrilldown, buildProperty, QueryParams} from "../utils/structs"; import {keyBy} from "../utils/transform"; import type {FileDescriptor} from "../utils/types"; import {isValidQuery} from "../utils/validation"; @@ -79,6 +71,8 @@ export const useDimensionItems = () => { const {data: schema} = useServerSchema(); const {params} = useSelector(selectCurrentQueryItem); const dimensions = schema?.cubeMap[params.cube]?.dimensions || []; + const requiredDimensions = + schema?.cubeMap[params.cube]?.annotations.required_dimensions || ([] as string[]); return dimensions .map(dim => ({ @@ -90,7 +84,8 @@ export const useDimensionItems = () => { hierarchy.levels.slice().sort((a, b) => getOrderValue(a) - getOrderValue(b)); return hierarchy; }) - .sort((a, b) => getOrderValue(a) - getOrderValue(b)) + .sort((a, b) => getOrderValue(a) - getOrderValue(b)), + required: requiredDimensions.includes(dim.name) }, count: dim.hierarchies.reduce((acc, hie) => acc + hie.levels.length, 0), alpha: dim.hierarchies.reduce((acc, hie) => acc.concat(hie.name, "-"), "") @@ -104,6 +99,11 @@ export const useDimensionItems = () => { .map(i => i.item); }; +export const useRequiredDimensions = () => { + const dimensions = useDimensionItems(); + return dimensions.filter(dim => dim.required); +}; + // include to download query ISO 3 by default for drilldowns that has that property. function getISO3Drilldowns({cube, search}: {cube?: TesseractCube; search: URLSearchParams}) { diff --git a/src/state/utils.ts b/src/state/utils.ts index ba2e6dd..f07b4a3 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -11,17 +11,26 @@ export function pickDefaultDrilldowns(dimensions: TesseractDimension[], cube: Te const levels: TesseractLevel[] = []; let timeComplete; let suggestedLevels: string[] = []; + let requiredDimensions: string[] = []; + for (const key in cube.annotations) { if (key === "suggested_levels") { suggestedLevels = cube.annotations[key]?.split(",") || []; } + if (key === "required_dimensions") { + requiredDimensions = cube.annotations[key]?.split(",") || []; + } } const findDefaultHierarchy = (dim: TesseractDimension) => dim.hierarchies.find(h => h.name === dim.default_hierarchy) || dim.hierarchies[0]; for (const dimension of dimensions) { - if (dimension.type === "time" || levels.length < 4) { + if ( + dimension.type === "time" || + requiredDimensions.includes(dimension.name) || + levels.length < 4 + ) { const hierarchy = findDefaultHierarchy(dimension); const hierarchyDepth = Math.max(...hierarchy.levels.map(l => l.depth)); // uses deepest level for geo dimensions diff --git a/src/utils/structs.ts b/src/utils/structs.ts index 87125e2..ed11949 100644 --- a/src/utils/structs.ts +++ b/src/utils/structs.ts @@ -45,7 +45,7 @@ export interface QueryResult> { } interface ResultEntityType { - level: TesseractLevel; + level: TesseractLevel & {dimension?: string}; property: TesseractProperty; measure: TesseractMeasure; } From 7ba5d42448efd1df370733ffa365983bbe12812c Mon Sep 17 00:00:00 2001 From: scespinoza Date: Tue, 4 Nov 2025 12:28:10 -0300 Subject: [PATCH 2/4] refactor: improve numeric column detection and enhance filter components --- src/components/TableView.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/components/TableView.tsx b/src/components/TableView.tsx index 6dd148c..8f871fe 100644 --- a/src/components/TableView.tsx +++ b/src/components/TableView.tsx @@ -482,7 +482,7 @@ export function useTable({ isId } = keyCol; - const isNumeric = valueType === "number" && columnKey !== "Year"; + const isNumeric = valueType === "number" && !/Year/i.test(columnKey); const formatterKey = getFormat( "aggregator" in entity ? entity : columnKey, @@ -838,7 +838,7 @@ export function TableView({ {headerGroup.headers.map(header => { const column = table.getColumn(header.id); - + console.log(column.id); const isNumeric = (column.columnDef as any).dataType === "number"; const isRowIndex = column.id === "#"; const base = (theme: MantineTheme) => ({ @@ -892,7 +892,12 @@ export function TableView({ )} {!isRowIndex && ( - + )} @@ -952,17 +957,19 @@ export function TableView({ const ColumnFilterCell = ({ header, - isNumeric + isNumeric, + columnId }: { header: MRT_Header; table?: MRT_TableInstance; + columnId: string; isNumeric: boolean; }) => { const filterVariant = header.column.columnDef.filterVariant; const isMulti = filterVariant === "multi-select"; if (isMulti) { - return ; + return ; } if (isNumeric) { @@ -999,7 +1006,7 @@ const NumericFilter = ({header}: {header: MRT_Header}) => { return null; }; -const MultiFilter = ({header}: {header: MRT_Header}) => { +const MultiFilter = ({header, columnId}: {header: MRT_Header; columnId: string}) => { const {translate: t} = useTranslation(); const cutItems = useSelector(selectCutItems); const drilldownItems = useSelector(selectDrilldownItems); @@ -1010,7 +1017,7 @@ const MultiFilter = ({header}: {header: MRT_Header}) => { const actions = useActions(); const {idFormatters} = useidFormatters(); const navigate = useNavigate(); - + console.log({cutItems}); const debouncedUpdateUrl = useMemo( () => debounce((query: QueryItem) => { @@ -1041,7 +1048,7 @@ const MultiFilter = ({header}: {header: MRT_Header}) => { ); const query = useSelector(selectCurrentQueryItem); - + console.log({cut, drilldown}); if (!drilldown || !cut) return null; return ( From 4d1563528a57c60e1e0806954137d04a6532cf9f Mon Sep 17 00:00:00 2001 From: scespinoza Date: Tue, 4 Nov 2025 12:34:42 -0300 Subject: [PATCH 3/4] refactor: remove console logs and simplify ColumnFilterCell props --- src/components/TableView.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/components/TableView.tsx b/src/components/TableView.tsx index 8f871fe..e8e3c9e 100644 --- a/src/components/TableView.tsx +++ b/src/components/TableView.tsx @@ -111,7 +111,6 @@ function isRequiredColumn( ).length; const dimension = dimensions.find(dim => dim.name === column.entity.dimension); const isRequired = dimension ? dimension.required && dimCount <= 1 : false; - console.log({column, dimCount, isRequired, dimension}); return isRequired; } @@ -838,7 +837,6 @@ export function TableView({ {headerGroup.headers.map(header => { const column = table.getColumn(header.id); - console.log(column.id); const isNumeric = (column.columnDef as any).dataType === "number"; const isRowIndex = column.id === "#"; const base = (theme: MantineTheme) => ({ @@ -892,12 +890,7 @@ export function TableView({ )} {!isRowIndex && ( - + )} @@ -957,19 +950,17 @@ export function TableView({ const ColumnFilterCell = ({ header, - isNumeric, - columnId + isNumeric }: { header: MRT_Header; table?: MRT_TableInstance; - columnId: string; isNumeric: boolean; }) => { const filterVariant = header.column.columnDef.filterVariant; const isMulti = filterVariant === "multi-select"; if (isMulti) { - return ; + return ; } if (isNumeric) { @@ -1006,7 +997,7 @@ const NumericFilter = ({header}: {header: MRT_Header}) => { return null; }; -const MultiFilter = ({header, columnId}: {header: MRT_Header; columnId: string}) => { +const MultiFilter = ({header}: {header: MRT_Header}) => { const {translate: t} = useTranslation(); const cutItems = useSelector(selectCutItems); const drilldownItems = useSelector(selectDrilldownItems); @@ -1017,7 +1008,6 @@ const MultiFilter = ({header, columnId}: {header: MRT_Header; columnId: s const actions = useActions(); const {idFormatters} = useidFormatters(); const navigate = useNavigate(); - console.log({cutItems}); const debouncedUpdateUrl = useMemo( () => debounce((query: QueryItem) => { @@ -1048,7 +1038,6 @@ const MultiFilter = ({header, columnId}: {header: MRT_Header; columnId: s ); const query = useSelector(selectCurrentQueryItem); - console.log({cut, drilldown}); if (!drilldown || !cut) return null; return ( From 99bc29ccd6ca0759cfc3766c3f34bf8112c899b4 Mon Sep 17 00:00:00 2001 From: scespinoza Date: Tue, 4 Nov 2025 12:45:29 -0300 Subject: [PATCH 4/4] fixes TS --- src/vizbuilder/components/VizdebuggerView.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/vizbuilder/components/VizdebuggerView.tsx b/src/vizbuilder/components/VizdebuggerView.tsx index ac35bfa..e768bf2 100644 --- a/src/vizbuilder/components/VizdebuggerView.tsx +++ b/src/vizbuilder/components/VizdebuggerView.tsx @@ -1,5 +1,6 @@ import {buildColumn} from "@datawheel/vizbuilder"; import {Vizdebugger} from "@datawheel/vizbuilder/react"; +// @ts-ignore import type {TesseractCube} from "@datawheel/vizbuilder/schema"; import {LoadingOverlay} from "@mantine/core"; import React, {useMemo} from "react"; @@ -31,11 +32,11 @@ export function VizdebuggerView(props: {cube: TesseractCube; params: QueryParams return [ { columns: Object.fromEntries( - columns.map(columnName => [columnName, buildColumn(cube, columnName, columns)]), + columns.map(columnName => [columnName, buildColumn(cube, columnName, columns)]) ), data: data.filter(row => measureNames.every(measure => row[measure] !== null)), - locale: params.locale || "en", - }, + locale: params.locale || "en" + } ]; }, [cube, result, params.locale, params.measures]);