Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/common/filters/adapters/tanstack/typeGuards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ColumnFilter } from "@tanstack/react-table";

/**
* Returns true if the value is a valid TanStack ColumnFilter shape.
* @param value - Value to check.
* @returns true if the value has the expected shape.
*/
export function isColumnFilter(value: unknown): value is ColumnFilter {
if (typeof value !== "object" || value === null) return false;
const filter = value as Record<string, unknown>;
return typeof filter.id === "string" && "value" in filter;
}
75 changes: 75 additions & 0 deletions src/common/filters/hooks/UseValidateFilterKeys/hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useRouter } from "next/router";
import { useMemo } from "react";
import { validateFilterParam } from "./utils";

/**
* Validates the filter query parameter shape only (no key validation).
* Throws a DataExplorerError during render if the filter param is present
* but contains malformed JSON or an invalid entry shape.
* @param queryParamKey - URL query parameter key to read (e.g. "filter").
* @param validKeys - Must be undefined to use this overload.
* @param entryValidator - Type guard that validates each parsed entry's shape.
* @returns void; throws DataExplorerError on invalid input.
*/
export function useValidateFilterKeys(
queryParamKey: string,
validKeys: undefined,
entryValidator: (value: unknown) => boolean,
): void;
/**
* Validates the filter query parameter shape and keys against a valid set.
* Throws a DataExplorerError during render if the filter param is present
* but contains malformed JSON, an invalid entry shape, or a key that
* is not in the valid set.
* @param queryParamKey - URL query parameter key to read (e.g. "filter").
* @param validKeys - Set of valid keys.
* @param entryValidator - Type guard that validates each parsed entry's shape.
* @param keyExtractor - Extracts the key string from a validated entry.
* @returns void; throws DataExplorerError on invalid input.
*/
export function useValidateFilterKeys(
queryParamKey: string,
validKeys: Set<string>,
entryValidator: (value: unknown) => boolean,
keyExtractor: (entry: unknown) => string,
): void;
/**
* Validates the filter query parameter shape and, when validKeys is defined,
* keys against the valid set. Use when validKeys is dynamically determined.
* @param queryParamKey - URL query parameter key to read (e.g. "filter").
* @param validKeys - Set of valid keys, or undefined to skip key validation.
* @param entryValidator - Type guard that validates each parsed entry's shape.
* @param keyExtractor - Extracts the key string from a validated entry.
* @returns void; throws DataExplorerError on invalid input.
*/
export function useValidateFilterKeys(
queryParamKey: string,
validKeys: Set<string> | undefined,
entryValidator: (value: unknown) => boolean,
keyExtractor: (entry: unknown) => string,
): void;
export function useValidateFilterKeys(
queryParamKey: string,
validKeys: Set<string> | undefined,
entryValidator: (value: unknown) => boolean,
keyExtractor?: (entry: unknown) => string,
): void {
const { query } = useRouter();
const filterParam = query[queryParamKey];

const validationError = useMemo(() => {
if (validKeys && keyExtractor) {
return validateFilterParam(
filterParam,
validKeys,
entryValidator,
keyExtractor,
);
}
return validateFilterParam(filterParam, undefined, entryValidator);
}, [entryValidator, filterParam, keyExtractor, validKeys]);

if (validationError) {
throw validationError;
}
}
83 changes: 83 additions & 0 deletions src/common/filters/hooks/UseValidateFilterKeys/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { DataExplorerError } from "../../../../types/error";

const INVALID_FILTER_PARAM = "Invalid filter parameter in URL";
const INVALID_FILTER_SHAPE = "Invalid filter entry shape in URL";
const UNKNOWN_FILTER_KEY = "Unknown filter key in URL";

/**
* Validates a filter query parameter value (shape only, no key validation).
* @param filterParam - Raw URL query param value.
* @param validKeys - Must be undefined to use this overload.
* @param entryValidator - Type guard that validates each parsed entry's shape.
* @returns DataExplorerError if invalid, undefined if valid.
*/
export function validateFilterParam(
filterParam: string | string[] | undefined,
validKeys: undefined,
entryValidator: (value: unknown) => boolean,
): DataExplorerError | undefined;
/**
* Validates a filter query parameter value against a set of valid keys.
* @param filterParam - Raw URL query param value.
* @param validKeys - Set of valid keys.
* @param entryValidator - Type guard that validates each parsed entry's shape.
* @param keyExtractor - Extracts the key string from a validated entry.
* @returns DataExplorerError if invalid, undefined if valid.
*/
export function validateFilterParam(
filterParam: string | string[] | undefined,
validKeys: Set<string>,
entryValidator: (value: unknown) => boolean,
keyExtractor: (entry: unknown) => string,
): DataExplorerError | undefined;
export function validateFilterParam(
filterParam: string | string[] | undefined,
validKeys: Set<string> | undefined,
entryValidator: (value: unknown) => boolean,
keyExtractor?: (entry: unknown) => string,
): DataExplorerError | undefined {
if (filterParam === undefined) return undefined;

if (typeof filterParam !== "string") {
return new DataExplorerError({ message: INVALID_FILTER_PARAM });
}

// Try JSON.parse directly first (Next.js router.query already decodes),
// then fall back to decodeURIComponent + JSON.parse for encoded values.
let parsed: unknown;
try {
parsed = JSON.parse(filterParam);
} catch {
try {
parsed = JSON.parse(decodeURIComponent(filterParam));
} catch {
return new DataExplorerError({ message: INVALID_FILTER_PARAM });
}
}

if (!Array.isArray(parsed)) {
return new DataExplorerError({ message: INVALID_FILTER_PARAM });
}

// An empty array (e.g. "[]") is valid — it means no filters selected.
if (parsed.length === 0) return undefined;

// Validate shape: every entry must match the expected shape.
if (!parsed.every(entryValidator)) {
return new DataExplorerError({ message: INVALID_FILTER_SHAPE });
}

// Validate keys: every extracted key must be in the valid set.
if (validKeys && keyExtractor) {
const invalidKey = parsed
.map(keyExtractor)
.find((key) => !validKeys.has(key));
if (invalidKey !== undefined) {
return new DataExplorerError({
message: `${UNKNOWN_FILTER_KEY}: ${invalidKey}`,
});
}
}
Comment thread
frano-m marked this conversation as resolved.

return undefined;
}
2 changes: 1 addition & 1 deletion src/common/filters/typeGuards.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SelectedFilter } from "../entities";
import type { SelectedFilter } from "../entities";

/**
* Returns true if the value is a valid SelectedFilter.
Expand Down
16 changes: 15 additions & 1 deletion src/components/DataDictionary/dataDictionary.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Fade } from "@mui/material";
import { RowData } from "@tanstack/react-table";
import { JSX } from "react";
import { JSX, useMemo } from "react";
import { Attribute } from "../../common/entities";
import { isColumnFilter } from "../../common/filters/adapters/tanstack/typeGuards";
import { useValidateFilterKeys } from "../../common/filters/hooks/UseValidateFilterKeys/hook";
import { PROPERTY } from "../../hooks/useHtmlStyle/constants";
import { useHtmlStyle } from "../../hooks/useHtmlStyle/hook";
import { useLayoutSpacing } from "../../hooks/UseLayoutSpacing/hook";
import { DATA_DICTIONARY_URL_PARAMS } from "../../providers/dataDictionaryState/dictionaries/constants";
import { Description } from "./components/Description/description";
import { Entities } from "./components/Entities/entities";
import { ColumnFilterTags } from "./components/Filters/components/ColumnFilterTags/columnFilterTags";
Expand All @@ -21,6 +24,7 @@ import { View } from "./dataDictionary.styles";
import { useDataDictionaryConfig } from "./hooks/UseDataDictionaryConfig/hook";
import { useMeasureFilters } from "./hooks/UseMeasureFilters/hook";
import { DataDictionaryProps } from "./types";
import { extractColumnId, getValidColumnIds } from "./utils";

export const DataDictionary = <T extends RowData = Attribute>({
className,
Expand All @@ -47,6 +51,16 @@ export const DataDictionary = <T extends RowData = Attribute>({
// Table instance.
const table = useTable<T>(dictionary, classes, tableOptions);

// Validate filter URL param keys against the table's column IDs.
const validColumnIds = useMemo(() => getValidColumnIds(table), [table]);

useValidateFilterKeys(
DATA_DICTIONARY_URL_PARAMS.COLUMN_FILTERS,
validColumnIds,
isColumnFilter,
extractColumnId,
);

// Dictionary outline.
const outline = buildClassesOutline<T>(table);

Expand Down
21 changes: 21 additions & 0 deletions src/components/DataDictionary/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ColumnFilter, RowData, Table } from "@tanstack/react-table";

/**
* Extracts the column ID from a validated ColumnFilter entry.
* @param entry - Validated entry.
* @returns column ID.
*/
export function extractColumnId(entry: unknown): string {
return (entry as ColumnFilter).id;
}

/**
* Returns the set of valid column IDs from a TanStack table instance.
* @param table - TanStack table instance.
* @returns set of valid column IDs.
*/
export function getValidColumnIds<T extends RowData>(
table: Table<T>,
): Set<string> {
return new Set(table.getAllColumns().map((column) => column.id));
}
2 changes: 1 addition & 1 deletion src/providers/exploreState/initializer/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ function columnFiltersToSelectedFilters(
* @param entityConfig - Entity config.
* @returns entity related category group config.
*/
function getEntityCategoryGroupConfig(
export function getEntityCategoryGroupConfig(
siteConfig: SiteConfig,
entityConfig: EntityConfig,
): CategoryGroupConfig | undefined {
Expand Down
11 changes: 11 additions & 0 deletions src/views/DataDictionaryView/dataDictionaryView.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { JSX } from "react";
import { isColumnFilter } from "../../common/filters/adapters/tanstack/typeGuards";
import { useValidateFilterKeys } from "../../common/filters/hooks/UseValidateFilterKeys/hook";
import { DataDictionary } from "../../components/DataDictionary/dataDictionary";
import { useStateSyncManager } from "../../hooks/stateSyncManager/hook";
import { DataDictionaryContext } from "../../providers/dataDictionary/context";
import { clearMeta } from "../../providers/dataDictionaryState/actions/clearMeta/dispatch";
import { stateToUrl } from "../../providers/dataDictionaryState/actions/stateToUrl/dispatch";
import { urlToState } from "../../providers/dataDictionaryState/actions/urlToState/dispatch";
import { DATA_DICTIONARY_URL_PARAMS } from "../../providers/dataDictionaryState/dictionaries/constants";
import { useDataDictionaryState } from "../../providers/dataDictionaryState/hooks/UseDataDictionaryState/hook";
import { DataDictionaryViewProps } from "./types";
import { buildStateSyncManagerContext } from "./utils";
Expand All @@ -16,6 +19,14 @@ export const DataDictionaryView = ({
const { dataDictionaryDispatch, dataDictionaryState } =
useDataDictionaryState();

// Shape-only validation (no key check) — must run before useStateSyncManager
// to prevent malformed entries from being dispatched into state.
useValidateFilterKeys(
DATA_DICTIONARY_URL_PARAMS.COLUMN_FILTERS,
undefined,
isColumnFilter,
);

useStateSyncManager({
actions: { clearMeta, stateToUrl, urlToState },
dispatch: dataDictionaryDispatch,
Expand Down
70 changes: 20 additions & 50 deletions src/views/ExploreView/hooks/UseValidateFilterParam/hook.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,29 @@
import { useRouter } from "next/router";
import { useMemo } from "react";
import { parseFilterParam } from "../../../../common/filters/typeGuards";
import { useValidateFilterKeys } from "../../../../common/filters/hooks/UseValidateFilterKeys/hook";
import { isSelectedFilter } from "../../../../common/filters/typeGuards";
import { useConfig } from "../../../../hooks/useConfig";
import { EXPLORE_URL_PARAMS } from "../../../../providers/exploreState/constants";
import { DataExplorerError } from "../../../../types/error";

const INVALID_FILTER_PARAM_ERROR = "Invalid filter parameter in URL";
import { extractCategoryKey, getValidCategoryKeys } from "./utils";

/**
* Validates the filter query parameter from the URL.
* Validates the filter query parameter from the URL for the ExploreView.
* Throws a DataExplorerError during render if the filter param is present
* but contains malformed JSON or an invalid filter shape, allowing the
* but contains malformed JSON, an invalid filter shape, or a categoryKey
* that does not exist in the configured categories, allowing the
* ErrorBoundary to catch and display the error page.
Comment thread
frano-m marked this conversation as resolved.
* @returns Nothing; this hook performs validation and throws on invalid input.
* @returns void; throws DataExplorerError on invalid input.
*/
export function useValidateFilterParam(): void {
const { query } = useRouter();
const filterParam = query[EXPLORE_URL_PARAMS.FILTER];

const validationError = useMemo((): DataExplorerError | undefined => {
if (filterParam === undefined) return undefined;

if (typeof filterParam !== "string") {
return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
}

let decoded: string;

try {
decoded = decodeURIComponent(filterParam);
} catch {
return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
}

// Parse and validate the filter param.
// An empty array (e.g. "[]") is valid — it means no filters selected.
// A non-empty array with no valid entries is invalid.
let parsed: unknown;
try {
parsed = JSON.parse(decoded);
} catch {
return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
}
if (!Array.isArray(parsed)) {
return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
}
if (parsed.length === 0) return undefined;
const filters = parseFilterParam(decoded);
if (filters.length === 0) {
return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
}

return undefined;
}, [filterParam]);

if (validationError) {
throw validationError;
}
const { config, entityConfig } = useConfig();
const validKeys = useMemo(
() => getValidCategoryKeys(config, entityConfig),
[config, entityConfig],
);

useValidateFilterKeys(
EXPLORE_URL_PARAMS.FILTER,
validKeys,
isSelectedFilter,
extractCategoryKey,
);
}
38 changes: 38 additions & 0 deletions src/views/ExploreView/hooks/UseValidateFilterParam/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { SelectedFilter } from "../../../../common/entities";
import type { EntityConfig, SiteConfig } from "../../../../config/entities";
import { getEntityCategoryGroupConfig } from "../../../../providers/exploreState/initializer/utils";

/**
* Extracts the categoryKey from a validated SelectedFilter entry.
* @param entry - Validated entry.
* @returns category key.
*/
export function extractCategoryKey(entry: unknown): string {
return (entry as SelectedFilter).categoryKey;
}

/**
* Returns the set of valid category keys for the given entity, derived from
* the entity's category group config. Returns undefined if no category group
* config is available (skipping key validation).
* @param config - Site config.
* @param entityConfig - Entity config.
* @returns set of valid category keys, or undefined.
*/
export function getValidCategoryKeys(
config: SiteConfig,
entityConfig: EntityConfig,
): Set<string> | undefined {
const categoryGroupConfig = getEntityCategoryGroupConfig(
config,
entityConfig,
);
if (!categoryGroupConfig) return undefined;
const keys = new Set<string>();
for (const group of categoryGroupConfig.categoryGroups) {
for (const categoryConfig of group.categoryConfigs) {
keys.add(categoryConfig.key);
}
}
return keys;
}
Loading
Loading