-
Notifications
You must be signed in to change notification settings - Fork 0
feat: render error for invalid categorykey in filter url param (cs filtering) (#901) #902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+521
−53
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
b30c720
feat: render error for invalid categorykey in filter url param (cs fi…
frano-m dd95fe5
fix: address pr review - add function overloads and @returns tags (#901)
frano-m 20180e2
fix: try json.parse directly before decodeuricomponent to avoid doubl…
frano-m File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import type { ColumnFilter } from "@tanstack/react-table"; | ||
|
|
||
| /** | ||
| * Returns true if the value is a valid TanStack ColumnFilter shape. | ||
| * @param value - Value to check. | ||
| * @returns true if the value has the expected shape. | ||
| */ | ||
| export function isColumnFilter(value: unknown): value is ColumnFilter { | ||
| if (typeof value !== "object" || value === null) return false; | ||
| const filter = value as Record<string, unknown>; | ||
| return typeof filter.id === "string" && "value" in filter; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { useRouter } from "next/router"; | ||
| import { useMemo } from "react"; | ||
| import { validateFilterParam } from "./utils"; | ||
|
|
||
| /** | ||
| * Validates the filter query parameter shape only (no key validation). | ||
| * Throws a DataExplorerError during render if the filter param is present | ||
| * but contains malformed JSON or an invalid entry shape. | ||
| * @param queryParamKey - URL query parameter key to read (e.g. "filter"). | ||
| * @param validKeys - Must be undefined to use this overload. | ||
| * @param entryValidator - Type guard that validates each parsed entry's shape. | ||
| * @returns void; throws DataExplorerError on invalid input. | ||
| */ | ||
| export function useValidateFilterKeys( | ||
| queryParamKey: string, | ||
| validKeys: undefined, | ||
| entryValidator: (value: unknown) => boolean, | ||
| ): void; | ||
| /** | ||
| * Validates the filter query parameter shape and keys against a valid set. | ||
| * Throws a DataExplorerError during render if the filter param is present | ||
| * but contains malformed JSON, an invalid entry shape, or a key that | ||
| * is not in the valid set. | ||
| * @param queryParamKey - URL query parameter key to read (e.g. "filter"). | ||
| * @param validKeys - Set of valid keys. | ||
| * @param entryValidator - Type guard that validates each parsed entry's shape. | ||
| * @param keyExtractor - Extracts the key string from a validated entry. | ||
| * @returns void; throws DataExplorerError on invalid input. | ||
| */ | ||
| export function useValidateFilterKeys( | ||
| queryParamKey: string, | ||
| validKeys: Set<string>, | ||
| entryValidator: (value: unknown) => boolean, | ||
| keyExtractor: (entry: unknown) => string, | ||
| ): void; | ||
| /** | ||
| * Validates the filter query parameter shape and, when validKeys is defined, | ||
| * keys against the valid set. Use when validKeys is dynamically determined. | ||
| * @param queryParamKey - URL query parameter key to read (e.g. "filter"). | ||
| * @param validKeys - Set of valid keys, or undefined to skip key validation. | ||
| * @param entryValidator - Type guard that validates each parsed entry's shape. | ||
| * @param keyExtractor - Extracts the key string from a validated entry. | ||
| * @returns void; throws DataExplorerError on invalid input. | ||
| */ | ||
| export function useValidateFilterKeys( | ||
| queryParamKey: string, | ||
| validKeys: Set<string> | undefined, | ||
| entryValidator: (value: unknown) => boolean, | ||
| keyExtractor: (entry: unknown) => string, | ||
| ): void; | ||
| export function useValidateFilterKeys( | ||
| queryParamKey: string, | ||
| validKeys: Set<string> | undefined, | ||
| entryValidator: (value: unknown) => boolean, | ||
| keyExtractor?: (entry: unknown) => string, | ||
| ): void { | ||
| const { query } = useRouter(); | ||
| const filterParam = query[queryParamKey]; | ||
|
|
||
| const validationError = useMemo(() => { | ||
| if (validKeys && keyExtractor) { | ||
| return validateFilterParam( | ||
| filterParam, | ||
| validKeys, | ||
| entryValidator, | ||
| keyExtractor, | ||
| ); | ||
| } | ||
| return validateFilterParam(filterParam, undefined, entryValidator); | ||
| }, [entryValidator, filterParam, keyExtractor, validKeys]); | ||
|
|
||
| if (validationError) { | ||
| throw validationError; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import { DataExplorerError } from "../../../../types/error"; | ||
|
|
||
| const INVALID_FILTER_PARAM = "Invalid filter parameter in URL"; | ||
| const INVALID_FILTER_SHAPE = "Invalid filter entry shape in URL"; | ||
| const UNKNOWN_FILTER_KEY = "Unknown filter key in URL"; | ||
|
|
||
| /** | ||
| * Validates a filter query parameter value (shape only, no key validation). | ||
| * @param filterParam - Raw URL query param value. | ||
| * @param validKeys - Must be undefined to use this overload. | ||
| * @param entryValidator - Type guard that validates each parsed entry's shape. | ||
| * @returns DataExplorerError if invalid, undefined if valid. | ||
| */ | ||
| export function validateFilterParam( | ||
| filterParam: string | string[] | undefined, | ||
| validKeys: undefined, | ||
| entryValidator: (value: unknown) => boolean, | ||
| ): DataExplorerError | undefined; | ||
| /** | ||
| * Validates a filter query parameter value against a set of valid keys. | ||
| * @param filterParam - Raw URL query param value. | ||
| * @param validKeys - Set of valid keys. | ||
| * @param entryValidator - Type guard that validates each parsed entry's shape. | ||
| * @param keyExtractor - Extracts the key string from a validated entry. | ||
| * @returns DataExplorerError if invalid, undefined if valid. | ||
| */ | ||
| export function validateFilterParam( | ||
| filterParam: string | string[] | undefined, | ||
| validKeys: Set<string>, | ||
| entryValidator: (value: unknown) => boolean, | ||
| keyExtractor: (entry: unknown) => string, | ||
| ): DataExplorerError | undefined; | ||
| export function validateFilterParam( | ||
| filterParam: string | string[] | undefined, | ||
| validKeys: Set<string> | undefined, | ||
| entryValidator: (value: unknown) => boolean, | ||
| keyExtractor?: (entry: unknown) => string, | ||
| ): DataExplorerError | undefined { | ||
| if (filterParam === undefined) return undefined; | ||
|
|
||
| if (typeof filterParam !== "string") { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM }); | ||
| } | ||
|
|
||
| // Try JSON.parse directly first (Next.js router.query already decodes), | ||
| // then fall back to decodeURIComponent + JSON.parse for encoded values. | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(filterParam); | ||
| } catch { | ||
| try { | ||
| parsed = JSON.parse(decodeURIComponent(filterParam)); | ||
| } catch { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM }); | ||
| } | ||
| } | ||
|
|
||
| if (!Array.isArray(parsed)) { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM }); | ||
| } | ||
|
|
||
| // An empty array (e.g. "[]") is valid — it means no filters selected. | ||
| if (parsed.length === 0) return undefined; | ||
|
|
||
| // Validate shape: every entry must match the expected shape. | ||
| if (!parsed.every(entryValidator)) { | ||
| return new DataExplorerError({ message: INVALID_FILTER_SHAPE }); | ||
| } | ||
|
|
||
| // Validate keys: every extracted key must be in the valid set. | ||
| if (validKeys && keyExtractor) { | ||
| const invalidKey = parsed | ||
| .map(keyExtractor) | ||
| .find((key) => !validKeys.has(key)); | ||
| if (invalidKey !== undefined) { | ||
| return new DataExplorerError({ | ||
| message: `${UNKNOWN_FILTER_KEY}: ${invalidKey}`, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import type { ColumnFilter, RowData, Table } from "@tanstack/react-table"; | ||
|
|
||
| /** | ||
| * Extracts the column ID from a validated ColumnFilter entry. | ||
| * @param entry - Validated entry. | ||
| * @returns column ID. | ||
| */ | ||
| export function extractColumnId(entry: unknown): string { | ||
| return (entry as ColumnFilter).id; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the set of valid column IDs from a TanStack table instance. | ||
| * @param table - TanStack table instance. | ||
| * @returns set of valid column IDs. | ||
| */ | ||
| export function getValidColumnIds<T extends RowData>( | ||
| table: Table<T>, | ||
| ): Set<string> { | ||
| return new Set(table.getAllColumns().map((column) => column.id)); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
70 changes: 20 additions & 50 deletions
70
src/views/ExploreView/hooks/UseValidateFilterParam/hook.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,59 +1,29 @@ | ||
| import { useRouter } from "next/router"; | ||
| import { useMemo } from "react"; | ||
| import { parseFilterParam } from "../../../../common/filters/typeGuards"; | ||
| import { useValidateFilterKeys } from "../../../../common/filters/hooks/UseValidateFilterKeys/hook"; | ||
| import { isSelectedFilter } from "../../../../common/filters/typeGuards"; | ||
| import { useConfig } from "../../../../hooks/useConfig"; | ||
| import { EXPLORE_URL_PARAMS } from "../../../../providers/exploreState/constants"; | ||
| import { DataExplorerError } from "../../../../types/error"; | ||
|
|
||
| const INVALID_FILTER_PARAM_ERROR = "Invalid filter parameter in URL"; | ||
| import { extractCategoryKey, getValidCategoryKeys } from "./utils"; | ||
|
|
||
| /** | ||
| * Validates the filter query parameter from the URL. | ||
| * Validates the filter query parameter from the URL for the ExploreView. | ||
| * Throws a DataExplorerError during render if the filter param is present | ||
| * but contains malformed JSON or an invalid filter shape, allowing the | ||
| * but contains malformed JSON, an invalid filter shape, or a categoryKey | ||
| * that does not exist in the configured categories, allowing the | ||
| * ErrorBoundary to catch and display the error page. | ||
|
frano-m marked this conversation as resolved.
|
||
| * @returns Nothing; this hook performs validation and throws on invalid input. | ||
| * @returns void; throws DataExplorerError on invalid input. | ||
| */ | ||
| export function useValidateFilterParam(): void { | ||
| const { query } = useRouter(); | ||
| const filterParam = query[EXPLORE_URL_PARAMS.FILTER]; | ||
|
|
||
| const validationError = useMemo((): DataExplorerError | undefined => { | ||
| if (filterParam === undefined) return undefined; | ||
|
|
||
| if (typeof filterParam !== "string") { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); | ||
| } | ||
|
|
||
| let decoded: string; | ||
|
|
||
| try { | ||
| decoded = decodeURIComponent(filterParam); | ||
| } catch { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); | ||
| } | ||
|
|
||
| // Parse and validate the filter param. | ||
| // An empty array (e.g. "[]") is valid — it means no filters selected. | ||
| // A non-empty array with no valid entries is invalid. | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(decoded); | ||
| } catch { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); | ||
| } | ||
| if (!Array.isArray(parsed)) { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); | ||
| } | ||
| if (parsed.length === 0) return undefined; | ||
| const filters = parseFilterParam(decoded); | ||
| if (filters.length === 0) { | ||
| return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); | ||
| } | ||
|
|
||
| return undefined; | ||
| }, [filterParam]); | ||
|
|
||
| if (validationError) { | ||
| throw validationError; | ||
| } | ||
| const { config, entityConfig } = useConfig(); | ||
| const validKeys = useMemo( | ||
| () => getValidCategoryKeys(config, entityConfig), | ||
| [config, entityConfig], | ||
| ); | ||
|
|
||
| useValidateFilterKeys( | ||
| EXPLORE_URL_PARAMS.FILTER, | ||
| validKeys, | ||
| isSelectedFilter, | ||
| extractCategoryKey, | ||
| ); | ||
| } | ||
38 changes: 38 additions & 0 deletions
38
src/views/ExploreView/hooks/UseValidateFilterParam/utils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import type { SelectedFilter } from "../../../../common/entities"; | ||
| import type { EntityConfig, SiteConfig } from "../../../../config/entities"; | ||
| import { getEntityCategoryGroupConfig } from "../../../../providers/exploreState/initializer/utils"; | ||
|
|
||
| /** | ||
| * Extracts the categoryKey from a validated SelectedFilter entry. | ||
| * @param entry - Validated entry. | ||
| * @returns category key. | ||
| */ | ||
| export function extractCategoryKey(entry: unknown): string { | ||
| return (entry as SelectedFilter).categoryKey; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the set of valid category keys for the given entity, derived from | ||
| * the entity's category group config. Returns undefined if no category group | ||
| * config is available (skipping key validation). | ||
| * @param config - Site config. | ||
| * @param entityConfig - Entity config. | ||
| * @returns set of valid category keys, or undefined. | ||
| */ | ||
| export function getValidCategoryKeys( | ||
| config: SiteConfig, | ||
| entityConfig: EntityConfig, | ||
| ): Set<string> | undefined { | ||
| const categoryGroupConfig = getEntityCategoryGroupConfig( | ||
| config, | ||
| entityConfig, | ||
| ); | ||
| if (!categoryGroupConfig) return undefined; | ||
| const keys = new Set<string>(); | ||
| for (const group of categoryGroupConfig.categoryGroups) { | ||
| for (const categoryConfig of group.categoryConfigs) { | ||
| keys.add(categoryConfig.key); | ||
| } | ||
| } | ||
| return keys; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.