/** * URL ↔ Variables Converter for URL State Management * * Bidirectional conversion between URL parameters and GraphQL variables. * Handles type coercion, nested paths, and array parameters. */ import { positiveInt } from '../../utils/search-params'; import type { JSType } from '../../utils/search-params'; import { type FlattenedParam, shouldIncludeInUrl } from './flatten-schema'; /** A single URL-decodable value. */ export type UrlScalar = string | number | boolean | null; /** What `coerceValue` can produce for one URL parameter. */ export type UrlParamValue = UrlScalar | UrlScalar[]; /** GraphQL variables object — nested, values are opaque to this module. */ export type GraphQLVariables = Record; /** Narrow an unknown to something indexable by string key. Arrays qualify * (path segments may address array indices), `null` does not. */ function isIndexable(value: unknown): value is Record { return typeof value === 'object' && value !== null; } /** * Convert URL search params to GraphQL variables * * Reads URL parameters and reconstructs the nested GraphQL variables object * based on the flattened schema mapping. * * @param searchParams - URLSearchParams from window.location or Next.js * @param schema - Flattened parameter schema * @returns GraphQL variables object ready for Apollo Client * * @example * URL: ?search=error&severity=critical&severity=error&cursor=abc * Schema: { * search: { graphqlPath: 'search', type: 'string' }, * severity: { graphqlPath: 'filter.severity', type: 'array' }, * cursor: { graphqlPath: 'cursor', type: 'string' } * } * Result: { * search: 'error', * filter: { severity: ['critical', 'error'] }, * cursor: 'abc' * } */ export function urlParamsToVariables( searchParams: URLSearchParams, schema: Record, ): GraphQLVariables { const variables: GraphQLVariables = {}; for (const [paramName, paramConfig] of Object.entries(schema)) { // Read value from URL const rawValue = paramConfig.type === 'array' || paramConfig.isArray ? searchParams.getAll(paramName) : searchParams.get(paramName); // Skip if no value in URL if (!rawValue || (Array.isArray(rawValue) && rawValue.length === 0)) { // Use default value if available if (paramConfig.defaultValue !== undefined) { setNestedValue(variables, paramConfig.graphqlPath, paramConfig.defaultValue); } continue; } // Coerce value to correct type const value = coerceValue(rawValue, paramConfig.type); // Set value at nested path setNestedValue(variables, paramConfig.graphqlPath, value); } return variables; } /** * Convert GraphQL variables to URL search params * * Flattens nested GraphQL variables to URL parameters based on schema mapping. * Excludes null/undefined/default values to keep URLs clean. * * @param variables - GraphQL variables object * @param schema - Flattened parameter schema * @returns URLSearchParams ready for router.push() * * @example * Variables: { * search: 'error', * filter: { severity: ['critical'], toolType: [] }, * cursor: null * } * Result URL: ?search=error&severity=critical * (empty arrays and null values excluded) */ export function variablesToUrlParams( variables: GraphQLVariables, schema: Record, ): URLSearchParams { const params = new URLSearchParams(); for (const [paramName, paramConfig] of Object.entries(schema)) { // Get value from nested path const value = getNestedValue(variables, paramConfig.graphqlPath); // Skip if should not include in URL if (!shouldIncludeInUrl(value, paramConfig)) { continue; } // Add to URL params if (Array.isArray(value)) { // Array: Use repeated params (e.g., ?tag=foo&tag=bar) value.forEach(v => { if (v !== null && v !== undefined && v !== '') { params.append(paramName, String(v)); } }); } else { // Single value: Use set params.set(paramName, String(value)); } } return params; } /** * Coerce URL parameter value to correct JavaScript type * * @param value - Raw value from URLSearchParams (string or string[]) * @param type - Target JavaScript type * @returns Coerced value */ export function coerceValue(value: string | string[], type: JSType): UrlParamValue { // Array handling — each element is coerced as a scalar ('array' degrades // to 'string', same as before). if (Array.isArray(value)) { return value.map(v => coerceScalar(v, type === 'array' ? 'string' : type)); } // Single value under an array-typed param becomes a one-element array. if (type === 'array') return [value]; return coerceScalar(value, type); } /** Scalar half of {@link coerceValue}. `'array'` never reaches here. */ function coerceScalar(value: string, type: JSType): UrlScalar { switch (type) { case 'number': { const num = parseFloat(value); return isNaN(num) ? null : num; } case 'int': // THE integer grammar has ONE owner. This used to inline its own copy, // which then drifted (the shared one honours a configured `max`). // `positiveInt` returns the fallback for anything unparseable, and this // path has no schema config to read a default from, so `null` it is. return positiveInt(value, null); case 'boolean': return value === 'true' || value === '1'; case 'string': default: return value; } } /** * Set value at nested path in object * * Creates intermediate objects as needed. * * @param obj - Target object to modify * @param path - Dot-separated path (e.g., "filter.severity") * @param value - Value to set * * @example * setNestedValue({}, 'filter.severity', ['error']) * // Result: { filter: { severity: ['error'] } } */ export function setNestedValue(obj: GraphQLVariables, path: string, value: unknown): void { const parts = path.split('.'); let current = obj; // Navigate/create intermediate objects for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; const existing = current[part]; // `null` counts as "not an object" here — the old `typeof x === 'object'` // check let a null segment through and then dereferenced it. const next = isIndexable(existing) ? existing : {}; if (next !== existing) current[part] = next; current = next; } // Set final value const lastPart = parts[parts.length - 1]; current[lastPart] = value; } /** * Get value from nested path in object * * Returns undefined if path doesn't exist. * * @param obj - Source object * @param path - Dot-separated path (e.g., "filter.severity") * @returns Value at path or undefined * * @example * getNestedValue({ filter: { severity: ['error'] } }, 'filter.severity') * // Result: ['error'] */ export function getNestedValue(obj: unknown, path: string): unknown { return path.split('.').reduce((current, key) => { return isIndexable(current) ? current[key] : undefined; }, obj); } /** * Merge URL parameters into existing variables * * Useful for updating specific parameters without losing others. * * @param currentVariables - Current GraphQL variables * @param updates - Parameter updates (flat object) * @param schema - Flattened parameter schema * @returns Updated variables object */ export function mergeVariables( currentVariables: GraphQLVariables, updates: Record, schema: Record, ): GraphQLVariables { const merged = { ...currentVariables }; for (const [paramName, value] of Object.entries(updates)) { const paramConfig = schema[paramName]; if (!paramConfig) continue; setNestedValue(merged, paramConfig.graphqlPath, value); } return merged; } /** * Clear specific parameters from variables * * @param variables - Current GraphQL variables * @param paramNames - Parameter names to clear * @param schema - Flattened parameter schema * @returns Variables with specified params removed */ export function clearParams( variables: GraphQLVariables, paramNames: string[], schema: Record, ): GraphQLVariables { const cleared = { ...variables }; for (const paramName of paramNames) { const paramConfig = schema[paramName]; if (!paramConfig) continue; // Set to undefined (will be excluded from URL) setNestedValue(cleared, paramConfig.graphqlPath, undefined); } return cleared; } /** * Validate GraphQL variables against schema * * Checks for required parameters and type consistency. * * @param variables - GraphQL variables to validate * @param schema - Flattened parameter schema * @returns Validation errors (empty array if valid) */ export function validateVariables(variables: GraphQLVariables, schema: Record): string[] { const errors: string[] = []; for (const [paramName, paramConfig] of Object.entries(schema)) { const value = getNestedValue(variables, paramConfig.graphqlPath); // Check required parameters if (paramConfig.required && (value === null || value === undefined)) { errors.push(`Required parameter "${paramName}" is missing`); } // Check type consistency if (value !== null && value !== undefined) { const actualType = Array.isArray(value) ? 'array' : typeof value; const expectedType = paramConfig.type === 'array' ? 'array' : paramConfig.type; if (actualType !== expectedType && actualType !== 'object') { errors.push(`Parameter "${paramName}" has wrong type: expected ${expectedType}, got ${actualType}`); } } } return errors; }