@databiosphere/findable-ui 51.0.1 → 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.
- package/lib/common/categories/views/range/utils.d.ts +14 -0
- package/lib/common/categories/views/range/utils.js +19 -1
- package/lib/common/filters/typeGuards.d.ts +19 -0
- package/lib/common/filters/typeGuards.js +33 -0
- package/lib/components/ErrorBoundary/errorBoundary.js +1 -1
- package/lib/components/Filter/components/adapters/tanstack/ColumnFiltersAdapter/utils.js +3 -2
- package/lib/components/Links/components/Link/components/ExploreViewLink/exploreViewLink.js +1 -11
- package/lib/components/Table/common/utils.js +3 -2
- package/lib/hooks/useAsync.js +1 -1
- package/lib/providers/exploreState/actions/urlToState/utils.d.ts +1 -0
- package/lib/providers/exploreState/actions/urlToState/utils.js +8 -3
- package/lib/providers/exploreState/entities/state.js +5 -2
- package/lib/providers/exploreState/initializer/utils.js +68 -27
- package/lib/views/ExploreView/exploreView.js +2 -0
- package/lib/views/ExploreView/hooks/UseValidateFilterParam/hook.d.ts +8 -0
- package/lib/views/ExploreView/hooks/UseValidateFilterParam/hook.js +54 -0
- package/package.json +1 -1
|
@@ -10,3 +10,17 @@ import { RangeCategoryView } from "../../views/range/types";
|
|
|
10
10
|
* @returns Full built range category view, ready for display.
|
|
11
11
|
*/
|
|
12
12
|
export declare function buildRangeCategoryView(category: RangeCategory, categoryConfigs: CategoryConfig[], categorySelectedFilter?: SelectedFilter): RangeCategoryView;
|
|
13
|
+
/**
|
|
14
|
+
* Returns the maximum value from a faceted min/max tuple, falling back to Infinity.
|
|
15
|
+
* Uses nullish coalescing to correctly handle a max of 0.
|
|
16
|
+
* @param minMax - Faceted min/max values, or undefined.
|
|
17
|
+
* @returns The maximum value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getRangeMax(minMax: [number, number] | undefined): number;
|
|
20
|
+
/**
|
|
21
|
+
* Returns the minimum value from a faceted min/max tuple, falling back to -Infinity.
|
|
22
|
+
* Uses nullish coalescing to correctly handle a min of 0.
|
|
23
|
+
* @param minMax - Faceted min/max values, or undefined.
|
|
24
|
+
* @returns The minimum value.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getRangeMin(minMax: [number, number] | undefined): number;
|
|
@@ -12,7 +12,7 @@ export function buildRangeCategoryView(category, categoryConfigs, categorySelect
|
|
|
12
12
|
const [selectedMin, selectedMax] = getRangeSelectedValue(categorySelectedFilter);
|
|
13
13
|
return {
|
|
14
14
|
annotation: categoryConfig?.annotation,
|
|
15
|
-
isDisabled:
|
|
15
|
+
isDisabled: !Number.isFinite(category.min) && !Number.isFinite(category.max),
|
|
16
16
|
key: category.key,
|
|
17
17
|
label: categoryConfig?.label || category.key,
|
|
18
18
|
max: category.max,
|
|
@@ -22,3 +22,21 @@ export function buildRangeCategoryView(category, categoryConfigs, categorySelect
|
|
|
22
22
|
unit: categoryConfig?.unit,
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns the maximum value from a faceted min/max tuple, falling back to Infinity.
|
|
27
|
+
* Uses nullish coalescing to correctly handle a max of 0.
|
|
28
|
+
* @param minMax - Faceted min/max values, or undefined.
|
|
29
|
+
* @returns The maximum value.
|
|
30
|
+
*/
|
|
31
|
+
export function getRangeMax(minMax) {
|
|
32
|
+
return minMax?.[1] ?? Infinity;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Returns the minimum value from a faceted min/max tuple, falling back to -Infinity.
|
|
36
|
+
* Uses nullish coalescing to correctly handle a min of 0.
|
|
37
|
+
* @param minMax - Faceted min/max values, or undefined.
|
|
38
|
+
* @returns The minimum value.
|
|
39
|
+
*/
|
|
40
|
+
export function getRangeMin(minMax) {
|
|
41
|
+
return minMax?.[0] ?? -Infinity;
|
|
42
|
+
}
|
|
@@ -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.
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import { isRangeCategoryConfig } from "../../../../../../common/categories/config/range/typeGuards";
|
|
2
|
+
import { getRangeMax, getRangeMin, } from "../../../../../../common/categories/views/range/utils";
|
|
2
3
|
import { FILTER_SORT } from "../../../../../../common/filters/sort/config/types";
|
|
3
4
|
import { sortCategoryValueViews } from "../../../../../../common/filters/sort/models/utils";
|
|
4
5
|
import { getSelectCategoryValue } from "../../../../../../hooks/useCategoryFilter";
|
|
@@ -117,8 +118,8 @@ function mapColumnToRangeCategoryView(column, categoryConfig) {
|
|
|
117
118
|
isDisabled,
|
|
118
119
|
key: column.id,
|
|
119
120
|
label: getColumnHeader(column),
|
|
120
|
-
max: minMax
|
|
121
|
-
min: minMax
|
|
121
|
+
max: getRangeMax(minMax),
|
|
122
|
+
min: getRangeMin(minMax),
|
|
122
123
|
selectedMax: filterValue[1],
|
|
123
124
|
selectedMin: filterValue[0],
|
|
124
125
|
...categoryConfig,
|
|
@@ -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.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { memo, sortingFns, } from "@tanstack/react-table";
|
|
2
|
+
import { getRangeMax, getRangeMin, } from "../../../common/categories/views/range/utils";
|
|
2
3
|
import { EXPLORE_MODE } from "../../../hooks/useExploreMode/types";
|
|
3
4
|
/**
|
|
4
5
|
* Build view-specific models from react table faceted values function.
|
|
@@ -20,8 +21,8 @@ export function buildCategoryViews(columns, columnFilters) {
|
|
|
20
21
|
categoryViews.push({
|
|
21
22
|
key,
|
|
22
23
|
label,
|
|
23
|
-
max: minMax
|
|
24
|
-
min: minMax
|
|
24
|
+
max: getRangeMax(minMax),
|
|
25
|
+
min: getRangeMin(minMax),
|
|
25
26
|
selectedMax: null, // Selected state updated in reducer.
|
|
26
27
|
selectedMin: null, // Selected state updated in reducer.
|
|
27
28
|
});
|
package/lib/hooks/useAsync.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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 {
|
|
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
|
|
146
|
-
query
|
|
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 -
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
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
|
+
}
|