@databiosphere/findable-ui 51.0.2 → 51.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,19 @@
1
+ import { SelectedFilter } from "../entities";
2
+ /**
3
+ * Returns true if the value is a valid SelectedFilter.
4
+ * @param value - Value to check.
5
+ * @returns true if the value has the expected shape.
6
+ */
7
+ export declare function isSelectedFilter(value: unknown): value is SelectedFilter;
8
+ /**
9
+ * Parses a filter parameter string into an array of SelectedFilter.
10
+ * Filters out invalid entries — a mixed array of valid and invalid
11
+ * objects returns only the valid ones. Returns an empty array if the
12
+ * string is not valid JSON or contains no valid entries. This function
13
+ * must not throw, as it is called from reducers above the ErrorBoundary.
14
+ * The useValidateFilterParam hook in ExploreView handles surfacing
15
+ * invalid filter errors to the user from inside the ErrorBoundary.
16
+ * @param paramValue - URL-decoded filter parameter string.
17
+ * @returns valid filters from the parsed input, or empty array.
18
+ */
19
+ export declare function parseFilterParam(paramValue: string): SelectedFilter[];
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Returns true if the value is a valid SelectedFilter.
3
+ * @param value - Value to check.
4
+ * @returns true if the value has the expected shape.
5
+ */
6
+ export function isSelectedFilter(value) {
7
+ if (typeof value !== "object" || value === null)
8
+ return false;
9
+ const filter = value;
10
+ return typeof filter.categoryKey === "string" && Array.isArray(filter.value);
11
+ }
12
+ /**
13
+ * Parses a filter parameter string into an array of SelectedFilter.
14
+ * Filters out invalid entries — a mixed array of valid and invalid
15
+ * objects returns only the valid ones. Returns an empty array if the
16
+ * string is not valid JSON or contains no valid entries. This function
17
+ * must not throw, as it is called from reducers above the ErrorBoundary.
18
+ * The useValidateFilterParam hook in ExploreView handles surfacing
19
+ * invalid filter errors to the user from inside the ErrorBoundary.
20
+ * @param paramValue - URL-decoded filter parameter string.
21
+ * @returns valid filters from the parsed input, or empty array.
22
+ */
23
+ export function parseFilterParam(paramValue) {
24
+ try {
25
+ const parsed = JSON.parse(paramValue);
26
+ if (!Array.isArray(parsed))
27
+ return [];
28
+ return parsed.filter(isSelectedFilter);
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
@@ -13,7 +13,7 @@ export class ErrorBoundary extends Component {
13
13
  }
14
14
  render() {
15
15
  if (this.state.error) {
16
- const fallbackProps = { error: this.state.error, reset: this.render };
16
+ const fallbackProps = { error: this.state.error, reset: this.reset };
17
17
  return this.props.fallbackRender(fallbackProps);
18
18
  }
19
19
  return this.props.children;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import Link from "next/link";
3
+ import { isSelectedFilter } from "../../../../../../common/filters/typeGuards";
3
4
  import { useExploreState } from "../../../../../../hooks/useExploreState";
4
5
  import { ExploreActionKind, } from "../../../../../../providers/exploreState";
5
6
  import { ANCHOR_TARGET, REL_ATTRIBUTE, } from "../../../../common/entities";
@@ -61,17 +62,6 @@ function getSorting(query) {
61
62
  const parsedQuery = JSON.parse(decodedQuery);
62
63
  return parsedQuery[PARAM_SORTING];
63
64
  }
64
- /**
65
- * Returns true if the given value is a SelectedFilter.
66
- * @param value - Value.
67
- * @returns true if the given value is a SelectedFilter.
68
- */
69
- function isSelectedFilter(value) {
70
- return (typeof value === "object" &&
71
- value !== null &&
72
- "categoryKey" in value &&
73
- "value" in value);
74
- }
75
65
  /**
76
66
  * Returns true if the given query string is a valid JSON string.
77
67
  * @param query - Query string.
@@ -23,7 +23,7 @@ export const useAsync = (state = { status: "idle" }) => {
23
23
  if (!promise || !promise.then) {
24
24
  throw new Error(`The argument passed to useAsync().run must be a promise.`);
25
25
  }
26
- safeSetState({ status: "pending" });
26
+ safeSetState({ error: undefined, status: "pending" });
27
27
  return promise.then((data) => {
28
28
  setData(data);
29
29
  return data;
@@ -3,5 +3,6 @@ import { SelectedFilter } from "../../../../common/entities";
3
3
  * Returns the selected filters from the filter parameter value.
4
4
  * @param paramValue - The filter parameter value.
5
5
  * @returns The selected filters or an empty array.
6
+ * @see parseFilterParam for validation details and ErrorBoundary constraints.
6
7
  */
7
8
  export declare function decodeFilterParamValue(paramValue: string | string[] | undefined): SelectedFilter[];
@@ -1,13 +1,18 @@
1
+ import { parseFilterParam } from "../../../../common/filters/typeGuards";
1
2
  /**
2
3
  * Returns the selected filters from the filter parameter value.
3
4
  * @param paramValue - The filter parameter value.
4
5
  * @returns The selected filters or an empty array.
6
+ * @see parseFilterParam for validation details and ErrorBoundary constraints.
5
7
  */
6
8
  export function decodeFilterParamValue(paramValue) {
7
9
  if (typeof paramValue === "string") {
8
- // Return decoded filter param value if it is a string.
9
- return JSON.parse(decodeURIComponent(paramValue));
10
+ try {
11
+ return parseFilterParam(decodeURIComponent(paramValue));
12
+ }
13
+ catch {
14
+ return [];
15
+ }
10
16
  }
11
- // Default to an empty array.
12
17
  return [];
13
18
  }
@@ -14,12 +14,15 @@ export function buildNextEntities(state, entityListType, nextEntityState) {
14
14
  for (const [entityPath, entity] of Object.entries(state.entities)) {
15
15
  // Grab the entity key for the entity.
16
16
  const entityKey = entity.entityKey;
17
+ if (entityKey !== key) {
18
+ // For entities that do not share the same key, leave the context unchanged.
19
+ entities[entityPath] = entity;
20
+ continue;
21
+ }
17
22
  // Clone the entity context.
18
23
  const entityContext = { ...entity };
19
24
  // Build the params object.
20
25
  // All entities share the same catalog and feature flag state.
21
- // Filter state is default to an empty array and updated below,
22
- // if the entity key matches the current entity key.
23
26
  const params = {
24
27
  catalogState: state.catalogState,
25
28
  featureFlagState: state.featureFlagState,
@@ -1,7 +1,8 @@
1
1
  import { getFilterSortType } from "../../../common/filters/sort/config/utils";
2
+ import { parseFilterParam } from "../../../common/filters/typeGuards";
2
3
  import { getInitialColumnVisibilityState } from "../../../components/TableCreator/options/initialState/columnVisibility";
3
4
  import { SELECT_CATEGORY_KEY } from "../constants";
4
- import { buildNextEntities } from "../entities/state";
5
+ import { buildQuery } from "../entities/query/buildQuery";
5
6
  import { getEntityCategoryGroupConfigKey, getFilterCount } from "../utils";
6
7
  import { DEFAULT_CATEGORY_GROUP_SAVED_FILTERS, DEFAULT_ENTITY_STATE, INITIAL_STATE, } from "./constants";
7
8
  /**
@@ -55,6 +56,24 @@ function buildSavedFilterByCategoryValueKey(savedFilters) {
55
56
  }
56
57
  return savedFilterByCategoryValueKey;
57
58
  }
59
+ /**
60
+ * Converts TanStack column filters to findable-ui SelectedFilter format.
61
+ * Throws if a column filter value is not an array, since SelectedFilterValue
62
+ * expects an array of CategoryValueKey.
63
+ * @param columnFilters - TanStack column filters.
64
+ * @returns selected filters.
65
+ */
66
+ function columnFiltersToSelectedFilters(columnFilters) {
67
+ return columnFilters.map(({ id, value }) => {
68
+ if (!Array.isArray(value)) {
69
+ throw new Error(`columnFilters entry "${id}" has a non-array value. Expected an array e.g. ["value1", "value2"].`);
70
+ }
71
+ return {
72
+ categoryKey: id,
73
+ value: value,
74
+ };
75
+ });
76
+ }
58
77
  /**
59
78
  * Returns entity related configured category group config where entity config takes precedence over site config.
60
79
  * @param siteConfig - Site config.
@@ -103,6 +122,18 @@ function initColumnVisibility(entityConfig) {
103
122
  const { list: { tableOptions = {} }, } = entityConfig;
104
123
  return getInitialColumnVisibilityState(tableOptions);
105
124
  }
125
+ /**
126
+ * Returns the default filter state for the specified entity list configuration,
127
+ * converted from TanStack columnFilters format to SelectedFilter format.
128
+ * @param entityConfig - Entity configuration.
129
+ * @returns default filter state, or empty array if none configured.
130
+ */
131
+ function initDefaultFilterState(entityConfig) {
132
+ if (!entityConfig)
133
+ return [];
134
+ const { list: { tableOptions: { initialState: { columnFilters = [] } = {} } = {} }, } = entityConfig;
135
+ return columnFiltersToSelectedFilters(columnFilters);
136
+ }
106
137
  /**
107
138
  * Returns the initial `enableRowSelection` option for the specified entity list configuration.
108
139
  * @param entityConfig - Entity configuration.
@@ -133,26 +164,38 @@ function initEntityPageState(config) {
133
164
  }, {});
134
165
  }
135
166
  /**
136
- * Initializes entities context.
167
+ * Initializes entities context with queries that include default filter state
168
+ * from each entity's config (tableOptions.initialState.columnFilters).
137
169
  * @param entityPageState - Entity page state.
170
+ * @param entityStateByCategoryGroupConfigKey - Entity state by category group config key.
171
+ * @param state - Partial explore state for building queries.
138
172
  * @returns Entities context.
139
173
  */
140
- function initEntities(entityPageState) {
174
+ function initEntities(entityPageState, entityStateByCategoryGroupConfigKey, state) {
141
175
  return Object.keys(entityPageState).reduce((acc, entityPath) => {
176
+ const entityKey = entityPageState[entityPath].categoryGroupConfigKey;
177
+ const entityState = entityStateByCategoryGroupConfigKey.get(entityKey);
178
+ const query = buildQuery(entityPath, {
179
+ catalogState: state.catalogState,
180
+ featureFlagState: state.featureFlagState,
181
+ filterState: entityState?.filterState ?? [],
182
+ });
142
183
  return {
143
184
  ...acc,
144
185
  [entityPath]: {
145
- entityKey: entityPageState[entityPath].categoryGroupConfigKey,
146
- query: { entityListType: entityPath },
186
+ entityKey,
187
+ query,
147
188
  },
148
189
  };
149
190
  }, {});
150
191
  }
151
192
  /**
152
193
  * Initializes entity state by category group config key.
194
+ * For the current entity, uses the pre-resolved filter state (URL filters or config defaults).
195
+ * For other entities, reads default filters from their tableOptions.initialState.columnFilters.
153
196
  * @param config - Site config.
154
- * @param categoryGroupConfigKey - Category group config key.
155
- * @param filterState - Filter state.
197
+ * @param categoryGroupConfigKey - Category group config key for the current entity.
198
+ * @param filterState - Pre-resolved filter state for the current entity.
156
199
  * @returns entity state by category group config key.
157
200
  */
158
201
  function initEntityStateByCategoryGroupConfigKey(config, categoryGroupConfigKey, filterState) {
@@ -167,11 +210,14 @@ function initEntityStateByCategoryGroupConfigKey(config, categoryGroupConfigKey,
167
210
  const categoryGroups = buildCategoryGroups(categoryGroupConfig);
168
211
  const savedSelectCategories = buildSavedSelectCategories(savedFilters);
169
212
  const savedFilterByCategoryValueKey = buildSavedFilterByCategoryValueKey(savedFilters);
213
+ const entityFilterState = key === categoryGroupConfigKey
214
+ ? filterState
215
+ : initDefaultFilterState(entity);
170
216
  entityStateByCategoryGroupConfigKey.set(key, {
171
217
  ...DEFAULT_ENTITY_STATE,
172
218
  categoryConfigs: flattenCategoryGroups(categoryGroups),
173
219
  categoryGroups,
174
- filterState: key === categoryGroupConfigKey ? filterState : [],
220
+ filterState: entityFilterState,
175
221
  savedFilterByCategoryValueKey,
176
222
  savedSelectCategories,
177
223
  });
@@ -181,18 +227,11 @@ function initEntityStateByCategoryGroupConfigKey(config, categoryGroupConfigKey,
181
227
  /**
182
228
  * Initializes filter state from URL "filter" parameter.
183
229
  * @param decodedFilterParam - Decoded filter parameter.
184
- * @returns filter state.
230
+ * @returns filter state, or empty array if invalid.
231
+ * @see parseFilterParam for validation details and ErrorBoundary constraints.
185
232
  */
186
233
  function initFilterState(decodedFilterParam) {
187
- // Define filter state, from URL "filter" parameter, if present and valid.
188
- let filterState = [];
189
- try {
190
- filterState = JSON.parse(decodedFilterParam);
191
- }
192
- catch {
193
- // do nothing
194
- }
195
- return filterState;
234
+ return parseFilterParam(decodedFilterParam);
196
235
  }
197
236
  /**
198
237
  * Returns the initial table grouping state for the specified entity list configuration.
@@ -222,14 +261,21 @@ function initSorting(entityConfig) {
222
261
  * @returns explore state reducer initial arguments.
223
262
  */
224
263
  export function initReducerArguments(config, entityListType, decodedFilterParam, decodedCatalogParam, decodedFeatureFlagParam) {
225
- const filterState = initFilterState(decodedFilterParam);
264
+ const urlFilterState = initFilterState(decodedFilterParam);
265
+ const hasUrlFilters = urlFilterState.length > 0;
226
266
  const entityPageState = initEntityPageState(config);
227
267
  const categoryGroupConfigKey = getEntityCategoryGroupConfigKey(entityListType, entityPageState);
268
+ // URL filters take precedence over config-defined defaults.
269
+ const filterState = hasUrlFilters
270
+ ? urlFilterState
271
+ : initDefaultFilterState(config.entities.find(({ route }) => route === entityListType));
228
272
  const entityStateByCategoryGroupConfigKey = initEntityStateByCategoryGroupConfigKey(config, categoryGroupConfigKey, filterState);
229
273
  const categoryGroups = initCategoryGroups(entityStateByCategoryGroupConfigKey, categoryGroupConfigKey);
230
- const entities = initEntities(entityPageState);
231
- // Initialize state.
232
- const state = {
274
+ const entities = initEntities(entityPageState, entityStateByCategoryGroupConfigKey, {
275
+ catalogState: decodedCatalogParam,
276
+ featureFlagState: decodedFeatureFlagParam,
277
+ });
278
+ return {
233
279
  ...INITIAL_STATE,
234
280
  catalogState: decodedCatalogParam,
235
281
  categoryGroups,
@@ -242,9 +288,4 @@ export function initReducerArguments(config, entityListType, decodedFilterParam,
242
288
  filterState,
243
289
  tabValue: entityListType,
244
290
  };
245
- // Return state with entities (updated by initial state).
246
- return {
247
- ...state,
248
- entities: buildNextEntities(state, entityListType, { filterState }),
249
- };
250
291
  }
@@ -26,6 +26,7 @@ import { TEST_IDS } from "../../tests/testIds";
26
26
  import { ToggleButtonGroup } from "./entityList/filters/components/ToggleButtonGroup/toggleButtonGroup";
27
27
  import { StyledGrid, StyledStack } from "./entityList/filters/filters.styles";
28
28
  import { useUpdateFilterSort } from "./hooks/UseUpdateFilterSort/hook";
29
+ import { useValidateFilterParam } from "./hooks/UseValidateFilterParam/hook";
29
30
  import { buildStateSyncManagerContext } from "./utils";
30
31
  export const ExploreView = (props) => {
31
32
  const { mdDown } = useBreakpoint();
@@ -34,6 +35,7 @@ export const ExploreView = (props) => {
34
35
  const { trackingConfig } = config;
35
36
  const { label } = entityConfig;
36
37
  const { categoryGroups, categoryViews, loading } = exploreState;
38
+ useValidateFilterParam();
37
39
  useEntityList(props); // Fetch entities.
38
40
  const { entityListType } = props;
39
41
  const categoryFilters = useMemo(() => buildCategoryFilters(categoryViews, categoryGroups), [categoryGroups, categoryViews]);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Validates the filter query parameter from the URL.
3
+ * Throws a DataExplorerError during render if the filter param is present
4
+ * but contains malformed JSON or an invalid filter shape, allowing the
5
+ * ErrorBoundary to catch and display the error page.
6
+ * @returns Nothing; this hook performs validation and throws on invalid input.
7
+ */
8
+ export declare function useValidateFilterParam(): void;
@@ -0,0 +1,54 @@
1
+ import { useRouter } from "next/router";
2
+ import { useMemo } from "react";
3
+ import { parseFilterParam } from "../../../../common/filters/typeGuards";
4
+ 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";
7
+ /**
8
+ * Validates the filter query parameter from the URL.
9
+ * Throws a DataExplorerError during render if the filter param is present
10
+ * but contains malformed JSON or an invalid filter shape, allowing the
11
+ * ErrorBoundary to catch and display the error page.
12
+ * @returns Nothing; this hook performs validation and throws on invalid input.
13
+ */
14
+ 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
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databiosphere/findable-ui",
3
- "version": "51.0.2",
3
+ "version": "51.1.0",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",