@databiosphere/findable-ui 52.0.0 → 52.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ import type { ColumnFilter } from "@tanstack/react-table";
2
+ /**
3
+ * Returns true if the value is a valid TanStack ColumnFilter shape.
4
+ * @param value - Value to check.
5
+ * @returns true if the value has the expected shape.
6
+ */
7
+ export declare function isColumnFilter(value: unknown): value is ColumnFilter;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Returns true if the value is a valid TanStack ColumnFilter shape.
3
+ * @param value - Value to check.
4
+ * @returns true if the value has the expected shape.
5
+ */
6
+ export function isColumnFilter(value) {
7
+ if (typeof value !== "object" || value === null)
8
+ return false;
9
+ const filter = value;
10
+ return typeof filter.id === "string" && "value" in filter;
11
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Validates the filter query parameter shape only (no key validation).
3
+ * Throws a DataExplorerError during render if the filter param is present
4
+ * but contains malformed JSON or an invalid entry shape.
5
+ * @param queryParamKey - URL query parameter key to read (e.g. "filter").
6
+ * @param validKeys - Must be undefined to use this overload.
7
+ * @param entryValidator - Type guard that validates each parsed entry's shape.
8
+ * @returns void; throws DataExplorerError on invalid input.
9
+ */
10
+ export declare function useValidateFilterKeys(queryParamKey: string, validKeys: undefined, entryValidator: (value: unknown) => boolean): void;
11
+ /**
12
+ * Validates the filter query parameter shape and keys against a valid set.
13
+ * Throws a DataExplorerError during render if the filter param is present
14
+ * but contains malformed JSON, an invalid entry shape, or a key that
15
+ * is not in the valid set.
16
+ * @param queryParamKey - URL query parameter key to read (e.g. "filter").
17
+ * @param validKeys - Set of valid keys.
18
+ * @param entryValidator - Type guard that validates each parsed entry's shape.
19
+ * @param keyExtractor - Extracts the key string from a validated entry.
20
+ * @returns void; throws DataExplorerError on invalid input.
21
+ */
22
+ export declare function useValidateFilterKeys(queryParamKey: string, validKeys: Set<string>, entryValidator: (value: unknown) => boolean, keyExtractor: (entry: unknown) => string): void;
23
+ /**
24
+ * Validates the filter query parameter shape and, when validKeys is defined,
25
+ * keys against the valid set. Use when validKeys is dynamically determined.
26
+ * @param queryParamKey - URL query parameter key to read (e.g. "filter").
27
+ * @param validKeys - Set of valid keys, or undefined to skip key validation.
28
+ * @param entryValidator - Type guard that validates each parsed entry's shape.
29
+ * @param keyExtractor - Extracts the key string from a validated entry.
30
+ * @returns void; throws DataExplorerError on invalid input.
31
+ */
32
+ export declare function useValidateFilterKeys(queryParamKey: string, validKeys: Set<string> | undefined, entryValidator: (value: unknown) => boolean, keyExtractor: (entry: unknown) => string): void;
@@ -0,0 +1,16 @@
1
+ import { useRouter } from "next/router";
2
+ import { useMemo } from "react";
3
+ import { validateFilterParam } from "./utils";
4
+ export function useValidateFilterKeys(queryParamKey, validKeys, entryValidator, keyExtractor) {
5
+ const { query } = useRouter();
6
+ const filterParam = query[queryParamKey];
7
+ const validationError = useMemo(() => {
8
+ if (validKeys && keyExtractor) {
9
+ return validateFilterParam(filterParam, validKeys, entryValidator, keyExtractor);
10
+ }
11
+ return validateFilterParam(filterParam, undefined, entryValidator);
12
+ }, [entryValidator, filterParam, keyExtractor, validKeys]);
13
+ if (validationError) {
14
+ throw validationError;
15
+ }
16
+ }
@@ -0,0 +1,18 @@
1
+ import { DataExplorerError } from "../../../../types/error";
2
+ /**
3
+ * Validates a filter query parameter value (shape only, no key validation).
4
+ * @param filterParam - Raw URL query param value.
5
+ * @param validKeys - Must be undefined to use this overload.
6
+ * @param entryValidator - Type guard that validates each parsed entry's shape.
7
+ * @returns DataExplorerError if invalid, undefined if valid.
8
+ */
9
+ export declare function validateFilterParam(filterParam: string | string[] | undefined, validKeys: undefined, entryValidator: (value: unknown) => boolean): DataExplorerError | undefined;
10
+ /**
11
+ * Validates a filter query parameter value against a set of valid keys.
12
+ * @param filterParam - Raw URL query param value.
13
+ * @param validKeys - Set of valid keys.
14
+ * @param entryValidator - Type guard that validates each parsed entry's shape.
15
+ * @param keyExtractor - Extracts the key string from a validated entry.
16
+ * @returns DataExplorerError if invalid, undefined if valid.
17
+ */
18
+ export declare function validateFilterParam(filterParam: string | string[] | undefined, validKeys: Set<string>, entryValidator: (value: unknown) => boolean, keyExtractor: (entry: unknown) => string): DataExplorerError | undefined;
@@ -0,0 +1,47 @@
1
+ import { DataExplorerError } from "../../../../types/error";
2
+ const INVALID_FILTER_PARAM = "Invalid filter parameter in URL";
3
+ const INVALID_FILTER_SHAPE = "Invalid filter entry shape in URL";
4
+ const UNKNOWN_FILTER_KEY = "Unknown filter key in URL";
5
+ export function validateFilterParam(filterParam, validKeys, entryValidator, keyExtractor) {
6
+ if (filterParam === undefined)
7
+ return undefined;
8
+ if (typeof filterParam !== "string") {
9
+ return new DataExplorerError({ message: INVALID_FILTER_PARAM });
10
+ }
11
+ // Try JSON.parse directly first (Next.js router.query already decodes),
12
+ // then fall back to decodeURIComponent + JSON.parse for encoded values.
13
+ let parsed;
14
+ try {
15
+ parsed = JSON.parse(filterParam);
16
+ }
17
+ catch {
18
+ try {
19
+ parsed = JSON.parse(decodeURIComponent(filterParam));
20
+ }
21
+ catch {
22
+ return new DataExplorerError({ message: INVALID_FILTER_PARAM });
23
+ }
24
+ }
25
+ if (!Array.isArray(parsed)) {
26
+ return new DataExplorerError({ message: INVALID_FILTER_PARAM });
27
+ }
28
+ // An empty array (e.g. "[]") is valid — it means no filters selected.
29
+ if (parsed.length === 0)
30
+ return undefined;
31
+ // Validate shape: every entry must match the expected shape.
32
+ if (!parsed.every(entryValidator)) {
33
+ return new DataExplorerError({ message: INVALID_FILTER_SHAPE });
34
+ }
35
+ // Validate keys: every extracted key must be in the valid set.
36
+ if (validKeys && keyExtractor) {
37
+ const invalidKey = parsed
38
+ .map(keyExtractor)
39
+ .find((key) => !validKeys.has(key));
40
+ if (invalidKey !== undefined) {
41
+ return new DataExplorerError({
42
+ message: `${UNKNOWN_FILTER_KEY}: ${invalidKey}`,
43
+ });
44
+ }
45
+ }
46
+ return undefined;
47
+ }
@@ -1,4 +1,4 @@
1
- import { SelectedFilter } from "../entities";
1
+ import type { SelectedFilter } from "../entities";
2
2
  /**
3
3
  * Returns true if the value is a valid SelectedFilter.
4
4
  * @param value - Value to check.
@@ -1,8 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fade } from "@mui/material";
3
+ import { useMemo } from "react";
4
+ import { isColumnFilter } from "../../common/filters/adapters/tanstack/typeGuards";
5
+ import { useValidateFilterKeys } from "../../common/filters/hooks/UseValidateFilterKeys/hook";
3
6
  import { PROPERTY } from "../../hooks/useHtmlStyle/constants";
4
7
  import { useHtmlStyle } from "../../hooks/useHtmlStyle/hook";
5
8
  import { useLayoutSpacing } from "../../hooks/UseLayoutSpacing/hook";
9
+ import { DATA_DICTIONARY_URL_PARAMS } from "../../providers/dataDictionaryState/dictionaries/constants";
6
10
  import { Description } from "./components/Description/description";
7
11
  import { Entities } from "./components/Entities/entities";
8
12
  import { ColumnFilterTags } from "./components/Filters/components/ColumnFilterTags/columnFilterTags";
@@ -18,6 +22,7 @@ import { Title as DefaultTitle } from "./components/Title/title";
18
22
  import { View } from "./dataDictionary.styles";
19
23
  import { useDataDictionaryConfig } from "./hooks/UseDataDictionaryConfig/hook";
20
24
  import { useMeasureFilters } from "./hooks/UseMeasureFilters/hook";
25
+ import { extractColumnId, getValidColumnIds } from "./utils";
21
26
  export const DataDictionary = ({ className, dictionary, EntitiesLayout = DefaultEntitiesLayout, FiltersLayout = DefaultFiltersLayout, Outline = DefaultOutline, OutlineLayout = DefaultOutlineLayout, Title = DefaultTitle, TitleLayout = DefaultTitleLayout, }) => {
22
27
  // Get dictionary configuration.
23
28
  const { classes, description, tableOptions, title } = useDataDictionaryConfig(dictionary);
@@ -30,6 +35,9 @@ export const DataDictionary = ({ className, dictionary, EntitiesLayout = Default
30
35
  const entitiesSpacing = { ...spacing, top: dimensions.height };
31
36
  // Table instance.
32
37
  const table = useTable(dictionary, classes, tableOptions);
38
+ // Validate filter URL param keys against the table's column IDs.
39
+ const validColumnIds = useMemo(() => getValidColumnIds(table), [table]);
40
+ useValidateFilterKeys(DATA_DICTIONARY_URL_PARAMS.COLUMN_FILTERS, validColumnIds, isColumnFilter, extractColumnId);
33
41
  // Dictionary outline.
34
42
  const outline = buildClassesOutline(table);
35
43
  // Update scroll-padding-top on the HTML element.
@@ -0,0 +1,13 @@
1
+ import type { RowData, Table } from "@tanstack/react-table";
2
+ /**
3
+ * Extracts the column ID from a validated ColumnFilter entry.
4
+ * @param entry - Validated entry.
5
+ * @returns column ID.
6
+ */
7
+ export declare function extractColumnId(entry: unknown): string;
8
+ /**
9
+ * Returns the set of valid column IDs from a TanStack table instance.
10
+ * @param table - TanStack table instance.
11
+ * @returns set of valid column IDs.
12
+ */
13
+ export declare function getValidColumnIds<T extends RowData>(table: Table<T>): Set<string>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Extracts the column ID from a validated ColumnFilter entry.
3
+ * @param entry - Validated entry.
4
+ * @returns column ID.
5
+ */
6
+ export function extractColumnId(entry) {
7
+ return entry.id;
8
+ }
9
+ /**
10
+ * Returns the set of valid column IDs from a TanStack table instance.
11
+ * @param table - TanStack table instance.
12
+ * @returns set of valid column IDs.
13
+ */
14
+ export function getValidColumnIds(table) {
15
+ return new Set(table.getAllColumns().map((column) => column.id));
16
+ }
@@ -1,5 +1,12 @@
1
- import { SiteConfig } from "../../../config/entities";
1
+ import { CategoryGroupConfig, EntityConfig, SiteConfig } from "../../../config/entities";
2
2
  import { ExploreState } from "../../exploreState";
3
+ /**
4
+ * Returns entity related configured category group config where entity config takes precedence over site config.
5
+ * @param siteConfig - Site config.
6
+ * @param entityConfig - Entity config.
7
+ * @returns entity related category group config.
8
+ */
9
+ export declare function getEntityCategoryGroupConfig(siteConfig: SiteConfig, entityConfig: EntityConfig): CategoryGroupConfig | undefined;
3
10
  /**
4
11
  * Returns the explore state reducer initial arguments.
5
12
  * @param config - Site config.
@@ -80,7 +80,7 @@ function columnFiltersToSelectedFilters(columnFilters) {
80
80
  * @param entityConfig - Entity config.
81
81
  * @returns entity related category group config.
82
82
  */
83
- function getEntityCategoryGroupConfig(siteConfig, entityConfig) {
83
+ export function getEntityCategoryGroupConfig(siteConfig, entityConfig) {
84
84
  const siteCategoryGroupConfig = siteConfig.categoryGroupConfig;
85
85
  const entityCategoryGroupConfig = entityConfig.categoryGroupConfig;
86
86
  return entityCategoryGroupConfig ?? siteCategoryGroupConfig;
@@ -1,14 +1,20 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isColumnFilter } from "../../common/filters/adapters/tanstack/typeGuards";
3
+ import { useValidateFilterKeys } from "../../common/filters/hooks/UseValidateFilterKeys/hook";
2
4
  import { DataDictionary } from "../../components/DataDictionary/dataDictionary";
3
5
  import { useStateSyncManager } from "../../hooks/stateSyncManager/hook";
4
6
  import { DataDictionaryContext } from "../../providers/dataDictionary/context";
5
7
  import { clearMeta } from "../../providers/dataDictionaryState/actions/clearMeta/dispatch";
6
8
  import { stateToUrl } from "../../providers/dataDictionaryState/actions/stateToUrl/dispatch";
7
9
  import { urlToState } from "../../providers/dataDictionaryState/actions/urlToState/dispatch";
10
+ import { DATA_DICTIONARY_URL_PARAMS } from "../../providers/dataDictionaryState/dictionaries/constants";
8
11
  import { useDataDictionaryState } from "../../providers/dataDictionaryState/hooks/UseDataDictionaryState/hook";
9
12
  import { buildStateSyncManagerContext } from "./utils";
10
13
  export const DataDictionaryView = ({ className, dictionary, }) => {
11
14
  const { dataDictionaryDispatch, dataDictionaryState } = useDataDictionaryState();
15
+ // Shape-only validation (no key check) — must run before useStateSyncManager
16
+ // to prevent malformed entries from being dispatched into state.
17
+ useValidateFilterKeys(DATA_DICTIONARY_URL_PARAMS.COLUMN_FILTERS, undefined, isColumnFilter);
12
18
  useStateSyncManager({
13
19
  actions: { clearMeta, stateToUrl, urlToState },
14
20
  dispatch: dataDictionaryDispatch,
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Validates the filter query parameter from the URL.
2
+ * Validates the filter query parameter from the URL for the ExploreView.
3
3
  * Throws a DataExplorerError during render if the filter param is present
4
- * but contains malformed JSON or an invalid filter shape, allowing the
4
+ * but contains malformed JSON, an invalid filter shape, or a categoryKey
5
+ * that does not exist in the configured categories, allowing the
5
6
  * ErrorBoundary to catch and display the error page.
6
- * @returns Nothing; this hook performs validation and throws on invalid input.
7
+ * @returns void; throws DataExplorerError on invalid input.
7
8
  */
8
9
  export declare function useValidateFilterParam(): void;
@@ -1,54 +1,19 @@
1
- import { useRouter } from "next/router";
2
1
  import { useMemo } from "react";
3
- import { parseFilterParam } from "../../../../common/filters/typeGuards";
2
+ import { useValidateFilterKeys } from "../../../../common/filters/hooks/UseValidateFilterKeys/hook";
3
+ import { isSelectedFilter } from "../../../../common/filters/typeGuards";
4
+ import { useConfig } from "../../../../hooks/useConfig";
4
5
  import { EXPLORE_URL_PARAMS } from "../../../../providers/exploreState/constants";
5
- import { DataExplorerError } from "../../../../types/error";
6
- const INVALID_FILTER_PARAM_ERROR = "Invalid filter parameter in URL";
6
+ import { extractCategoryKey, getValidCategoryKeys } from "./utils";
7
7
  /**
8
- * Validates the filter query parameter from the URL.
8
+ * Validates the filter query parameter from the URL for the ExploreView.
9
9
  * Throws a DataExplorerError during render if the filter param is present
10
- * but contains malformed JSON or an invalid filter shape, allowing the
10
+ * but contains malformed JSON, an invalid filter shape, or a categoryKey
11
+ * that does not exist in the configured categories, allowing the
11
12
  * ErrorBoundary to catch and display the error page.
12
- * @returns Nothing; this hook performs validation and throws on invalid input.
13
+ * @returns void; throws DataExplorerError on invalid input.
13
14
  */
14
15
  export function useValidateFilterParam() {
15
- const { query } = useRouter();
16
- const filterParam = query[EXPLORE_URL_PARAMS.FILTER];
17
- const validationError = useMemo(() => {
18
- if (filterParam === undefined)
19
- return undefined;
20
- if (typeof filterParam !== "string") {
21
- return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
22
- }
23
- let decoded;
24
- try {
25
- decoded = decodeURIComponent(filterParam);
26
- }
27
- catch {
28
- return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
29
- }
30
- // Parse and validate the filter param.
31
- // An empty array (e.g. "[]") is valid — it means no filters selected.
32
- // A non-empty array with no valid entries is invalid.
33
- let parsed;
34
- try {
35
- parsed = JSON.parse(decoded);
36
- }
37
- catch {
38
- return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
39
- }
40
- if (!Array.isArray(parsed)) {
41
- return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
42
- }
43
- if (parsed.length === 0)
44
- return undefined;
45
- const filters = parseFilterParam(decoded);
46
- if (filters.length === 0) {
47
- return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR });
48
- }
49
- return undefined;
50
- }, [filterParam]);
51
- if (validationError) {
52
- throw validationError;
53
- }
16
+ const { config, entityConfig } = useConfig();
17
+ const validKeys = useMemo(() => getValidCategoryKeys(config, entityConfig), [config, entityConfig]);
18
+ useValidateFilterKeys(EXPLORE_URL_PARAMS.FILTER, validKeys, isSelectedFilter, extractCategoryKey);
54
19
  }
@@ -0,0 +1,16 @@
1
+ import type { EntityConfig, SiteConfig } from "../../../../config/entities";
2
+ /**
3
+ * Extracts the categoryKey from a validated SelectedFilter entry.
4
+ * @param entry - Validated entry.
5
+ * @returns category key.
6
+ */
7
+ export declare function extractCategoryKey(entry: unknown): string;
8
+ /**
9
+ * Returns the set of valid category keys for the given entity, derived from
10
+ * the entity's category group config. Returns undefined if no category group
11
+ * config is available (skipping key validation).
12
+ * @param config - Site config.
13
+ * @param entityConfig - Entity config.
14
+ * @returns set of valid category keys, or undefined.
15
+ */
16
+ export declare function getValidCategoryKeys(config: SiteConfig, entityConfig: EntityConfig): Set<string> | undefined;
@@ -0,0 +1,29 @@
1
+ import { getEntityCategoryGroupConfig } from "../../../../providers/exploreState/initializer/utils";
2
+ /**
3
+ * Extracts the categoryKey from a validated SelectedFilter entry.
4
+ * @param entry - Validated entry.
5
+ * @returns category key.
6
+ */
7
+ export function extractCategoryKey(entry) {
8
+ return entry.categoryKey;
9
+ }
10
+ /**
11
+ * Returns the set of valid category keys for the given entity, derived from
12
+ * the entity's category group config. Returns undefined if no category group
13
+ * config is available (skipping key validation).
14
+ * @param config - Site config.
15
+ * @param entityConfig - Entity config.
16
+ * @returns set of valid category keys, or undefined.
17
+ */
18
+ export function getValidCategoryKeys(config, entityConfig) {
19
+ const categoryGroupConfig = getEntityCategoryGroupConfig(config, entityConfig);
20
+ if (!categoryGroupConfig)
21
+ return undefined;
22
+ const keys = new Set();
23
+ for (const group of categoryGroupConfig.categoryGroups) {
24
+ for (const categoryConfig of group.categoryConfigs) {
25
+ keys.add(categoryConfig.key);
26
+ }
27
+ }
28
+ return keys;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databiosphere/findable-ui",
3
- "version": "52.0.0",
3
+ "version": "52.1.0",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",