@databiosphere/findable-ui 51.0.2 → 52.0.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.
@@ -219,7 +219,11 @@ export interface ListViewConfig {
219
219
  rowPreviewView?: ComponentsConfig;
220
220
  rowSelectionView?: ComponentsConfig;
221
221
  }
222
- export interface OAuthProvider<P = any> {
222
+ export declare enum OAUTH_FLOW {
223
+ AUTHORIZATION_CODE = "AUTHORIZATION_CODE",
224
+ IMPLICIT = "IMPLICIT"
225
+ }
226
+ interface OAuthProviderBase<P = any> {
223
227
  authorization: {
224
228
  params: {
225
229
  scope: string;
@@ -232,6 +236,14 @@ export interface OAuthProvider<P = any> {
232
236
  profile: (profile: P) => UserProfile;
233
237
  userinfo: string;
234
238
  }
239
+ export interface ImplicitFlowProvider<P = any> extends OAuthProviderBase<P> {
240
+ flow: OAUTH_FLOW.IMPLICIT;
241
+ }
242
+ export interface AuthorizationCodeFlowProvider<P = any> extends OAuthProviderBase<P> {
243
+ authorize: string;
244
+ flow: OAUTH_FLOW.AUTHORIZATION_CODE;
245
+ }
246
+ export type OAuthProvider<P = any> = ImplicitFlowProvider<P> | AuthorizationCodeFlowProvider<P>;
235
247
  /**
236
248
  * Option Method.
237
249
  */
@@ -1,3 +1,8 @@
1
+ export var OAUTH_FLOW;
2
+ (function (OAUTH_FLOW) {
3
+ OAUTH_FLOW["AUTHORIZATION_CODE"] = "AUTHORIZATION_CODE";
4
+ OAUTH_FLOW["IMPLICIT"] = "IMPLICIT";
5
+ })(OAUTH_FLOW || (OAUTH_FLOW = {}));
1
6
  /**
2
7
  * Sort direction.
3
8
  */
@@ -1,15 +1,17 @@
1
- import { OAuthProvider } from "../config/entities";
2
- import { SessionDispatch } from "./types";
1
+ import { type OAuthProvider } from "../config/entities";
2
+ import { type LoginDispatch } from "./services/common";
3
+ import type { SessionDispatch } from "./types";
3
4
  /**
4
5
  * Google Sign-In service.
5
6
  */
6
7
  export declare const service: {
7
8
  /**
8
9
  * Login with Google OAuth.
10
+ * Dispatches to the configured flow based on `provider.flow`.
9
11
  * @param provider - OAuth provider configuration.
10
12
  * @param dispatch - Dispatch functions for auth state.
11
13
  */
12
- login: (provider: OAuthProvider, dispatch: Pick<SessionDispatch, "authDispatch" | "authenticationDispatch" | "tokenDispatch">) => void;
14
+ login: (provider: OAuthProvider, dispatch: LoginDispatch) => void;
13
15
  /**
14
16
  * Logout and clear all auth state.
15
17
  * @param dispatch - Dispatch functions for auth state.
@@ -1,54 +1,30 @@
1
- import { requestAuth, resetAuthState } from "../auth/dispatch/auth";
2
- import { requestAuthentication, resetAuthenticationState, updateAuthentication, } from "../auth/dispatch/authentication";
3
- import { resetCredentialsState } from "../auth/dispatch/credentials";
4
- import { resetTokenState, updateToken } from "../auth/dispatch/token";
5
- import { AUTHENTICATION_STATUS } from "../auth/types/authentication";
6
- import { fetchProfile, getAuthenticationRequestOptions } from "./utils/auth";
1
+ import { OAUTH_FLOW } from "../config/entities";
2
+ import { login as authorizationCodeFlowLogin } from "./services/authorizationCodeFlow";
3
+ import { logout } from "./services/common";
4
+ import { login as implicitFlowLogin } from "./services/implicitFlow";
7
5
  /**
8
6
  * Google Sign-In service.
9
7
  */
10
8
  export const service = {
11
9
  /**
12
10
  * Login with Google OAuth.
11
+ * Dispatches to the configured flow based on `provider.flow`.
13
12
  * @param provider - OAuth provider configuration.
14
13
  * @param dispatch - Dispatch functions for auth state.
15
14
  */
16
15
  login: (provider, dispatch) => {
17
- const client = google.accounts.oauth2.initTokenClient({
18
- callback: (response) => {
19
- const { id, profile, userinfo } = provider;
20
- const { access_token: token } = response;
21
- dispatch.authDispatch?.(requestAuth());
22
- dispatch.authenticationDispatch?.(requestAuthentication());
23
- dispatch.tokenDispatch?.(updateToken({ providerId: id, token }));
24
- fetchProfile(userinfo, getAuthenticationRequestOptions(token), {
25
- onError: () => {
26
- dispatch.authDispatch?.(resetAuthState());
27
- dispatch.authenticationDispatch?.(updateAuthentication({
28
- profile: undefined,
29
- status: AUTHENTICATION_STATUS.SETTLED,
30
- }));
31
- dispatch.tokenDispatch?.(resetTokenState());
32
- },
33
- onSuccess: (r) => dispatch.authenticationDispatch?.(updateAuthentication({
34
- profile: profile(r),
35
- status: AUTHENTICATION_STATUS.PENDING, // Authentication is pending until session controller is resolved.
36
- })),
37
- });
38
- },
39
- client_id: provider.clientId,
40
- scope: provider.authorization.params.scope,
41
- });
42
- client.requestAccessToken();
16
+ if (provider.flow === OAUTH_FLOW.AUTHORIZATION_CODE) {
17
+ authorizationCodeFlowLogin(provider, dispatch);
18
+ }
19
+ else {
20
+ implicitFlowLogin(provider, dispatch);
21
+ }
43
22
  },
44
23
  /**
45
24
  * Logout and clear all auth state.
46
25
  * @param dispatch - Dispatch functions for auth state.
47
26
  */
48
27
  logout: (dispatch) => {
49
- dispatch.authDispatch?.(resetAuthState());
50
- dispatch.authenticationDispatch?.(resetAuthenticationState());
51
- dispatch.credentialsDispatch?.(resetCredentialsState());
52
- dispatch.tokenDispatch?.(resetTokenState());
28
+ logout(dispatch);
53
29
  },
54
30
  };
@@ -0,0 +1,10 @@
1
+ import type { AuthorizationCodeFlowProvider } from "../../config/entities";
2
+ import { type LoginDispatch } from "./common";
3
+ /**
4
+ * Login using the OAuth 2.0 authorization code flow.
5
+ * Uses Google's initCodeClient to request an authorization code,
6
+ * then exchanges it for an access token via the configured authorize endpoint.
7
+ * @param provider - OAuth provider configuration.
8
+ * @param dispatch - Dispatch functions for auth state.
9
+ */
10
+ export declare function login(provider: AuthorizationCodeFlowProvider, dispatch: LoginDispatch): void;
@@ -0,0 +1,38 @@
1
+ import { createOnAccessToken, createResetSession, } from "./common";
2
+ /**
3
+ * Login using the OAuth 2.0 authorization code flow.
4
+ * Uses Google's initCodeClient to request an authorization code,
5
+ * then exchanges it for an access token via the configured authorize endpoint.
6
+ * @param provider - OAuth provider configuration.
7
+ * @param dispatch - Dispatch functions for auth state.
8
+ */
9
+ export function login(provider, dispatch) {
10
+ const { authorize } = provider;
11
+ const resetSession = createResetSession(dispatch);
12
+ const onAccessToken = createOnAccessToken(provider, dispatch, resetSession);
13
+ const client = google.accounts.oauth2.initCodeClient({
14
+ callback: (response) => {
15
+ fetch(authorize, {
16
+ body: JSON.stringify(response),
17
+ headers: { "Content-Type": "application/json" },
18
+ method: "POST",
19
+ })
20
+ .then((r) => {
21
+ if (!r.ok) {
22
+ throw new Error(`authorize request failed (${r.status})`);
23
+ }
24
+ return r.json();
25
+ })
26
+ .then((tokens) => {
27
+ if (!tokens?.access_token) {
28
+ throw new Error("authorize response missing access_token");
29
+ }
30
+ onAccessToken(tokens.access_token);
31
+ })
32
+ .catch(resetSession);
33
+ },
34
+ client_id: provider.clientId,
35
+ scope: provider.authorization.params.scope,
36
+ });
37
+ client.requestCode();
38
+ }
@@ -0,0 +1,23 @@
1
+ import type { OAuthProvider } from "../../config/entities";
2
+ import type { SessionDispatch } from "../types";
3
+ export type LoginDispatch = Pick<SessionDispatch, "authDispatch" | "authenticationDispatch" | "tokenDispatch">;
4
+ /**
5
+ * Creates a function that resets the session state on auth failure.
6
+ * @param dispatch - Dispatch functions for auth state.
7
+ * @returns reset session function.
8
+ */
9
+ export declare function createResetSession(dispatch: LoginDispatch): () => void;
10
+ /**
11
+ * Creates a function that handles a successful access token.
12
+ * Dispatches auth state updates and fetches the user profile.
13
+ * @param provider - OAuth provider configuration.
14
+ * @param dispatch - Dispatch functions for auth state.
15
+ * @param resetSession - Reset session function.
16
+ * @returns on access token function.
17
+ */
18
+ export declare function createOnAccessToken(provider: OAuthProvider, dispatch: LoginDispatch, resetSession: () => void): (token: string) => void;
19
+ /**
20
+ * Logout and clear all auth state.
21
+ * @param dispatch - Dispatch functions for auth state.
22
+ */
23
+ export declare function logout(dispatch: SessionDispatch): void;
@@ -0,0 +1,54 @@
1
+ import { requestAuth, resetAuthState } from "../../auth/dispatch/auth";
2
+ import { requestAuthentication, resetAuthenticationState, updateAuthentication, } from "../../auth/dispatch/authentication";
3
+ import { resetCredentialsState } from "../../auth/dispatch/credentials";
4
+ import { resetTokenState, updateToken } from "../../auth/dispatch/token";
5
+ import { AUTHENTICATION_STATUS } from "../../auth/types/authentication";
6
+ import { fetchProfile, getAuthenticationRequestOptions } from "../utils/auth";
7
+ /**
8
+ * Creates a function that resets the session state on auth failure.
9
+ * @param dispatch - Dispatch functions for auth state.
10
+ * @returns reset session function.
11
+ */
12
+ export function createResetSession(dispatch) {
13
+ return () => {
14
+ dispatch.authDispatch?.(resetAuthState());
15
+ dispatch.authenticationDispatch?.(updateAuthentication({
16
+ profile: undefined,
17
+ status: AUTHENTICATION_STATUS.SETTLED,
18
+ }));
19
+ dispatch.tokenDispatch?.(resetTokenState());
20
+ };
21
+ }
22
+ /**
23
+ * Creates a function that handles a successful access token.
24
+ * Dispatches auth state updates and fetches the user profile.
25
+ * @param provider - OAuth provider configuration.
26
+ * @param dispatch - Dispatch functions for auth state.
27
+ * @param resetSession - Reset session function.
28
+ * @returns on access token function.
29
+ */
30
+ export function createOnAccessToken(provider, dispatch, resetSession) {
31
+ const { id, profile, userinfo } = provider;
32
+ return (token) => {
33
+ dispatch.authDispatch?.(requestAuth());
34
+ dispatch.authenticationDispatch?.(requestAuthentication());
35
+ dispatch.tokenDispatch?.(updateToken({ providerId: id, token }));
36
+ fetchProfile(userinfo, getAuthenticationRequestOptions(token), {
37
+ onError: resetSession,
38
+ onSuccess: (r) => dispatch.authenticationDispatch?.(updateAuthentication({
39
+ profile: profile(r),
40
+ status: AUTHENTICATION_STATUS.PENDING,
41
+ })),
42
+ });
43
+ };
44
+ }
45
+ /**
46
+ * Logout and clear all auth state.
47
+ * @param dispatch - Dispatch functions for auth state.
48
+ */
49
+ export function logout(dispatch) {
50
+ dispatch.authDispatch?.(resetAuthState());
51
+ dispatch.authenticationDispatch?.(resetAuthenticationState());
52
+ dispatch.credentialsDispatch?.(resetCredentialsState());
53
+ dispatch.tokenDispatch?.(resetTokenState());
54
+ }
@@ -0,0 +1,9 @@
1
+ import type { ImplicitFlowProvider } from "../../config/entities";
2
+ import { type LoginDispatch } from "./common";
3
+ /**
4
+ * Login using the OAuth 2.0 implicit flow.
5
+ * Uses Google's initTokenClient to request an access token directly.
6
+ * @param provider - OAuth provider configuration.
7
+ * @param dispatch - Dispatch functions for auth state.
8
+ */
9
+ export declare function login(provider: ImplicitFlowProvider, dispatch: LoginDispatch): void;
@@ -0,0 +1,17 @@
1
+ import { createOnAccessToken, createResetSession, } from "./common";
2
+ /**
3
+ * Login using the OAuth 2.0 implicit flow.
4
+ * Uses Google's initTokenClient to request an access token directly.
5
+ * @param provider - OAuth provider configuration.
6
+ * @param dispatch - Dispatch functions for auth state.
7
+ */
8
+ export function login(provider, dispatch) {
9
+ const resetSession = createResetSession(dispatch);
10
+ const onAccessToken = createOnAccessToken(provider, dispatch, resetSession);
11
+ const client = google.accounts.oauth2.initTokenClient({
12
+ callback: (response) => onAccessToken(response.access_token),
13
+ client_id: provider.clientId,
14
+ scope: provider.authorization.params.scope,
15
+ });
16
+ client.requestAccessToken();
17
+ }
@@ -3,6 +3,17 @@ import { AuthAction, AuthContextProps } from "../auth/types/auth";
3
3
  import { AuthenticationAction, AuthenticationContextProps } from "../auth/types/authentication";
4
4
  import { CredentialsAction, CredentialsContextProps } from "../auth/types/credentials";
5
5
  import { TokenAction, TokenContextProps, TokenState } from "../auth/types/token";
6
+ /**
7
+ * Authorization code response from Google OAuth.
8
+ */
9
+ export interface CodeResponse {
10
+ code?: string;
11
+ error?: string;
12
+ error_description?: string;
13
+ error_uri?: string;
14
+ scope?: string;
15
+ state?: string;
16
+ }
6
17
  /**
7
18
  * Google Sign-In authentication provider props.
8
19
  */
@@ -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": "52.0.0",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
@@ -44,7 +44,6 @@
44
44
  "@types/jest": "^29.5.14",
45
45
  "@types/react": "^19.2.7",
46
46
  "@types/react-dom": "^19.2.3",
47
- "@types/react-gtm-module": "^2.0.1",
48
47
  "@types/react-window": "^1.8.5",
49
48
  "@typescript-eslint/eslint-plugin": "^8.50.0",
50
49
  "eslint": "^8.57.1",
@@ -88,7 +87,6 @@
88
87
  "react": "^19.2.3",
89
88
  "react-dom": "^19.2.3",
90
89
  "react-dropzone": "^14.3.8",
91
- "react-gtm-module": "^2.0.11",
92
90
  "react-idle-timer": "^5.7.2",
93
91
  "react-window": "^1.8.11",
94
92
  "rehype-raw": "^7.0.0",