type safe routing update

This commit is contained in:
Nate Kelley 2025-08-14 23:09:55 -06:00
parent 776be1a68a
commit 3073cdb9e6
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
11 changed files with 207 additions and 373 deletions

View File

@ -1,176 +0,0 @@
# Asset Route Builder
A type-safe route builder for TanStack Start/Router that dynamically generates routes based on asset types and parameters.
## Overview
The Asset Route Builder provides a clean, type-safe way to generate routes for different asset types (chats, metrics, dashboards, reports) with various parameter combinations. It ensures that the generated routes match the available route files in your TanStack Start application.
## Features
- **Type Safety**: All routes are validated against `FileRouteTypes['id']` from the generated route tree
- **Fluent API**: Clean builder pattern for constructing routes
- **Dynamic Route Generation**: Automatically determines the correct route based on provided parameters
- **Parameter Extraction**: Provides params object for navigation
## Available Routes
The system supports the following route patterns:
### Single Asset Routes
- `/app/chats/$chatId`
- `/app/dashboards/$dashboardId`
- `/app/metrics/$metricId`
- `/app/reports/$reportId`
### Chat Context Routes
- `/app/chats/$chatId/dashboard/$dashboardId`
- `/app/chats/$chatId/metrics/$metricId`
- `/app/chats/$chatId/report/$reportId`
### Nested Context Routes
- `/app/chats/$chatId/dashboard/$dashboardId/metrics/$metricId`
- `/app/chats/$chatId/report/$reportId/metrics/$metricId`
## Usage
### Basic Usage with `assetParamsToRoute`
```typescript
import { assetParamsToRoute } from '@/lib/assets/assetParamsToRoute';
// Navigate to a single asset
const chatRoute = assetParamsToRoute({
assetType: 'chat',
assetId: 'chat-123',
});
// Returns: '/app/chats/$chatId'
// Navigate to a metric within a chat
const metricInChatRoute = assetParamsToRoute({
assetType: 'metric',
assetId: 'metric-456',
chatId: 'chat-123',
});
// Returns: '/app/chats/$chatId/metrics/$metricId'
// Navigate to a metric within a dashboard in a chat
const complexRoute = assetParamsToRoute({
assetType: 'metric',
assetId: 'metric-789',
chatId: 'chat-123',
dashboardId: 'dash-456',
});
// Returns: '/app/chats/$chatId/dashboard/$dashboardId/metrics/$metricId'
```
### Using the RouteBuilder
```typescript
import { createRouteBuilder } from '@/lib/assets/assetParamsToRoute';
// Build routes step by step
const builder = createRouteBuilder()
.withChat('chat-123')
.withDashboard('dash-456')
.withMetric('metric-789');
const route = builder.build();
// Returns: '/app/chats/$chatId/dashboard/$dashboardId/metrics/$metricId'
const params = builder.getParams();
// Returns: { chatId: 'chat-123', dashboardId: 'dash-456', metricId: 'metric-789' }
```
### Integration with TanStack Router
```typescript
import { useNavigate } from '@tanstack/react-router';
import { createAssetNavigation } from '@/lib/assets/assetParamsToRoute.example';
function MyComponent() {
const navigate = useNavigate();
const handleNavigation = () => {
const { route, params } = createAssetNavigation({
assetType: 'dashboard',
assetId: 'dash-123',
chatId: 'chat-456',
});
// Use with your navigation API
navigate({ to: route, params });
};
return <button onClick={handleNavigation}>Go to Dashboard</button>;
}
```
## API Reference
### `assetParamsToRoute(params: AssetParamsToRoute): RouteFilePaths`
Main function to convert asset parameters to a route path.
#### Parameters
- `params`: An object containing:
- `assetType`: The type of asset ('chat' | 'metric' | 'dashboard' | 'report')
- `assetId`: The ID of the main asset
- Additional optional context parameters (chatId, metricId, dashboardId, reportId)
#### Returns
A type-safe route path matching `FileRouteTypes['id']`.
### `createRouteBuilder(): RouteBuilder`
Creates a new RouteBuilder instance for fluent route construction.
#### RouteBuilder Methods
- `withChat(chatId: string)`: Add a chat ID to the route
- `withMetric(metricId: string)`: Add a metric ID to the route
- `withDashboard(dashboardId: string)`: Add a dashboard ID to the route
- `withReport(reportId: string)`: Add a report ID to the route
- `build()`: Build the final route path
- `getParams()`: Get the params object for navigation
## Type Definitions
```typescript
type ChatParamsToRoute = {
assetType: 'chat';
assetId: string;
metricId?: string;
dashboardId?: string;
reportId?: string;
};
type MetricParamsToRoute = {
assetType: 'metric';
assetId: string;
dashboardId?: string;
reportId?: string;
chatId?: string;
};
type DashboardParamsToRoute = {
assetType: 'dashboard';
assetId: string;
metricId?: string;
reportId?: string;
chatId?: string;
};
type ReportParamsToRoute = {
assetType: 'report';
assetId: string;
metricId?: string;
chatId?: string;
};
```
## Examples
See `assetParamsToRoute.example.tsx` for comprehensive usage examples and `assetParamsToRoute.test.ts` for test cases.

View File

@ -1,7 +1,7 @@
import type { AssetType } from '@buster/server-shared/assets';
import type { AnyRouter, NavigateOptions, RegisteredRouter } from '@tanstack/react-router';
import type { FileRouteTypes } from '@/routeTree.gen';
import type { BusterNavigateOptions } from './typeSafeNavigation';
import type { BusterNavigateOptions } from '../tss-routes';
type RouteFilePaths = FileRouteTypes['id'];

View File

@ -1,10 +1,10 @@
import { Link, useNavigate } from '@tanstack/react-router';
import { routeToHref } from '../tss-routes';
import {
type AssetParamsToRoute,
assetParamsToRoute,
assetParamsToRoutePath,
} from './assetParamsToRoute';
import { navigationOptionsToHref, toHref } from './typeSafeNavigation';
// Example React component showing usage
export function AssetNavigationExample() {
@ -59,11 +59,11 @@ export function AssetNavigationExample() {
});
// Convert to actual URL
const reportHref = navigationOptionsToHref(reportNavOptions);
const reportHref = routeToHref(reportNavOptions);
console.log(reportHref); // '/app/chats/chat-123/report/report-999'
// Example 6: Using with native anchor tags
const dashboardHref = toHref(dashboardNavOptions);
const dashboardHref = routeToHref(dashboardNavOptions);
// dashboardHref is: '/app/chats/chat-123/dashboard/dash-789/metrics/metric-456'
return (
@ -97,7 +97,7 @@ export function AssetNavigationExample() {
// Example function that accepts AssetParamsToRoute
export function createAssetLink(params: AssetParamsToRoute) {
const navOptions = assetParamsToRoute(params);
const href = toHref(navOptions);
const href = routeToHref(navOptions);
// You can use either Link component or native anchor
return (
@ -128,8 +128,8 @@ export function generateShareableLinks() {
// Convert to absolute URLs for sharing
const baseUrl = window.location.origin;
const chatHref = baseUrl + toHref(chatOptions);
const metricHref = baseUrl + toHref(metricOptions);
const chatHref = baseUrl + routeToHref(chatOptions);
const metricHref = baseUrl + routeToHref(metricOptions);
console.log('Share these links:');
console.log('Chat:', chatHref); // https://example.com/app/chats/chat-123

View File

@ -1,177 +0,0 @@
import type { NavigateOptions, RegisteredRouter } from '@tanstack/react-router';
import type { FileRouteTypes } from '@/routeTree.gen';
/**
* Type representing navigation options that can be passed to testNavigate
* This is useful for creating type-safe route objects
*
* IMPORTANT: Always provide type parameters when using this type!
*
* Bad: BusterNavigateOptions (no type safety)
* Good: BusterNavigateOptions<'/', '/app/chats/$chatId'>
*
* Without type parameters, TypeScript won't enforce required params!
*/
export type BusterNavigateOptions<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
TMaskFrom extends FileRouteTypes['id'] = TFrom,
TMaskTo extends string = '',
> = NavigateOptions<RegisteredRouter, TFrom, TTo, TMaskFrom, TMaskTo>;
/**
* Type-safe navigate function for testing that matches the behavior of useNavigate()
* This function provides the same type safety as the regular navigate hook
* by leveraging the RegisteredRouter type which contains all route definitions
*/
export function acceptsTypeSafeNavigateOptions<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
TMaskFrom extends FileRouteTypes['id'] = TFrom,
TMaskTo extends string = '',
>(options: BusterNavigateOptions<TFrom, TTo, TMaskFrom, TMaskTo>): void {
// In a test environment, you might want to just log or store the navigation
// For actual implementation, you could:
// 1. Store the navigation in a test spy
// 2. Update window.location in a test environment
// 3. Use a mock router's navigate method
// For now, just log it
console.log('Test navigation called with:', options);
}
/**
* Creates a type-safe route object that can be passed to testNavigate
* This function helps ensure that route creation is type-safe
*
* @example
* const route = createRoute({
* to: '/app/chats/$chatId',
* params: {
* chatId: '123'
* }
* });
* testNavigate(route);
*
* @example
* // Use it in a function to return type-safe navigation options
* const createMyRoute = () => {
* return createRoute({
* to: '/app/chats/$chatId',
* params: {
* chatId: '123'
* }
* });
* };
*/
export function createRoute<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
TMaskFrom extends FileRouteTypes['id'] = TFrom,
TMaskTo extends string = '',
>(
options: BusterNavigateOptions<TFrom, TTo, TMaskFrom, TMaskTo>
): BusterNavigateOptions<TFrom, TTo, TMaskFrom, TMaskTo> {
return options;
}
/**
* Type helper to extract the route options type for a specific route
*
* @example
* type ChatRouteOptions = RouteOptions<'/app/chats/$chatId'>;
* // This will give you the type with proper params like { chatId: string }
*/
export type RouteOptions<
TTo extends FileRouteTypes['id'],
TFrom extends FileRouteTypes['id'] = '/',
> = BusterNavigateOptions<TFrom, TTo>;
/**
* Factory function to create route builders for specific routes
* This provides even more type safety by locking in the route path
*
* @example
* const createChatRoute = createRouteFactory('/app/chats/$chatId');
* const route = createChatRoute({ chatId: '123' });
*/
export function createRouteFactory<
TTo extends FileRouteTypes['id'],
TFrom extends FileRouteTypes['id'] = '/',
>(to: TTo) {
return (
params: BusterNavigateOptions<TFrom, TTo> extends { params: infer P } ? P : never,
options?: Omit<BusterNavigateOptions<TFrom, TTo>, 'to' | 'params'>
): BusterNavigateOptions<TFrom, TTo> => {
return {
to,
params,
...options,
} as BusterNavigateOptions<TFrom, TTo>;
};
}
/**
* Converts navigation options to a URL string (href)
* This replaces dynamic segments ($param) with actual parameter values
*
* @example
* const navOptions = {
* to: '/app/chats/$chatId',
* params: { chatId: '123' }
* };
* const href = navigationOptionsToHref(navOptions);
* // Returns: '/app/chats/123'
*
* @example
* // With multiple params
* const complexNavOptions = {
* to: '/app/chats/$chatId/dashboard/$dashboardId',
* params: { chatId: '123', dashboardId: '456' }
* };
* const complexHref = navigationOptionsToHref(complexNavOptions);
* // Returns: '/app/chats/123/dashboard/456'
*/
export function navigationOptionsToHref<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
>(options: BusterNavigateOptions<TFrom, TTo>): string {
let href = options.to as string;
// Replace all $param placeholders with actual values from params
if (options.params && typeof options.params === 'object') {
Object.entries(options.params).forEach(([key, value]) => {
// Replace $param with the actual value
// Use a regex to ensure we only replace the exact $param pattern
const pattern = new RegExp(`\\$${key}(?=/|$)`, 'g');
href = href.replace(pattern, encodeURIComponent(String(value)));
});
}
// Add search params if they exist
if (options.search && typeof options.search === 'object') {
const searchParams = new URLSearchParams();
Object.entries(options.search).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const searchString = searchParams.toString();
if (searchString) {
href += `?${searchString}`;
}
}
// Add hash if it exists
if (options.hash) {
href += `#${options.hash}`;
}
return href;
}
/**
* Shorthand function to get href from navigation options
* Alias for navigationOptionsToHref
*/
export const toHref = navigationOptionsToHref;

View File

@ -0,0 +1,23 @@
import type { FileRouteTypes } from '@/routeTree.gen';
import type { BusterNavigateOptions } from './types';
/**
* Type-safe navigate function for testing that matches the behavior of useNavigate()
* This function provides the same type safety as the regular navigate hook
* by leveraging the RegisteredRouter type which contains all route definitions
*/
export function acceptsTypeSafeNavigateOptions<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
TMaskFrom extends FileRouteTypes['id'] = TFrom,
TMaskTo extends string = '',
>(options: BusterNavigateOptions<TFrom, TTo, TMaskFrom, TMaskTo>): void {
// In a test environment, you might want to just log or store the navigation
// For actual implementation, you could:
// 1. Store the navigation in a test spy
// 2. Update window.location in a test environment
// 3. Use a mock router's navigate method
// For now, just log it
console.log('Test navigation called with:', options);
}

View File

@ -0,0 +1,61 @@
import type { FileRouteTypes } from '@/routeTree.gen';
import type { BusterNavigateOptions } from './types';
/**
* Creates a type-safe route object that can be passed to testNavigate
* This function helps ensure that route creation is type-safe
*
* @example
* const route = createRoute({
* to: '/app/chats/$chatId',
* params: {
* chatId: '123'
* }
* });
* testNavigate(route);
*
* @example
* // Use it in a function to return type-safe navigation options
* const createMyRoute = () => {
* return createRoute({
* to: '/app/chats/$chatId',
* params: {
* chatId: '123'
* }
* });
* };
*/
export function createRoute<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
TMaskFrom extends FileRouteTypes['id'] = TFrom,
TMaskTo extends string = '',
>(
options: BusterNavigateOptions<TFrom, TTo, TMaskFrom, TMaskTo>
): BusterNavigateOptions<TFrom, TTo, TMaskFrom, TMaskTo> {
return options;
}
/**
* Factory function to create route builders for specific routes
* This provides even more type safety by locking in the route path
*
* @example
* const createChatRoute = createRouteFactory('/app/chats/$chatId');
* const route = createChatRoute({ chatId: '123' });
*/
export function createRouteFactory<
TTo extends FileRouteTypes['id'],
TFrom extends FileRouteTypes['id'] = '/',
>(to: TTo) {
return (
params: BusterNavigateOptions<TFrom, TTo> extends { params: infer P } ? P : never,
options?: Omit<BusterNavigateOptions<TFrom, TTo>, 'to' | 'params'>
): BusterNavigateOptions<TFrom, TTo> => {
return {
to,
params,
...options,
} as BusterNavigateOptions<TFrom, TTo>;
};
}

View File

@ -0,0 +1,4 @@
export { acceptsTypeSafeNavigateOptions } from './acceptsTypeSafeNavigateOptions';
export { createRoute, createRouteFactory } from './createRoute';
export { routeToHref } from './navigationOptionsToHref';
export type { BusterNavigateOptions, RouteOptions } from './types';

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { BusterNavigateOptions } from './typeSafeNavigation';
import { navigationOptionsToHref, toHref } from './typeSafeNavigation';
import type { BusterNavigateOptions } from '../tss-routes';
import { routeToHref } from '../tss-routes';
describe('navigationOptionsToHref', () => {
it('should convert simple route with single param', () => {
@ -11,7 +11,7 @@ describe('navigationOptionsToHref', () => {
},
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/123');
});
@ -24,7 +24,7 @@ describe('navigationOptionsToHref', () => {
},
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/chat-123/dashboard/dash-456');
});
@ -38,7 +38,7 @@ describe('navigationOptionsToHref', () => {
},
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/chat-123/dashboard/dash-456/metrics/metric-789');
});
@ -50,7 +50,7 @@ describe('navigationOptionsToHref', () => {
},
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/chat%20with%20spaces%20%26%20special');
});
@ -66,7 +66,7 @@ describe('navigationOptionsToHref', () => {
},
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/123?filter=active&sort=date');
});
@ -79,7 +79,7 @@ describe('navigationOptionsToHref', () => {
hash: 'section-1',
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/123#section-1');
});
@ -95,7 +95,7 @@ describe('navigationOptionsToHref', () => {
hash: 'privacy',
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/123?tab=settings#privacy');
});
@ -107,7 +107,7 @@ describe('navigationOptionsToHref', () => {
},
};
const href = toHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/metrics/metric-123');
});
@ -116,7 +116,7 @@ describe('navigationOptionsToHref', () => {
to: '/app/home',
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/home');
});
@ -130,7 +130,7 @@ describe('navigationOptionsToHref', () => {
},
};
const href = navigationOptionsToHref(options);
const href = routeToHref(options);
expect(href).toBe('/app/chats/123/report/report-456');
});
});

View File

@ -0,0 +1,67 @@
import type { FileRouteTypes } from '@/routeTree.gen';
import type { BusterNavigateOptions } from './types';
/**
* Converts navigation options to a URL string (href)
* This replaces dynamic segments ($param) with actual parameter values
*
* @example
* const navOptions = {
* to: '/app/chats/$chatId',
* params: { chatId: '123' }
* };
* const href = navigationOptionsToHref(navOptions);
* // Returns: '/app/chats/123'
*
* @example
* // With multiple params
* const complexNavOptions = {
* to: '/app/chats/$chatId/dashboard/$dashboardId',
* params: { chatId: '123', dashboardId: '456' }
* };
* const complexHref = navigationOptionsToHref(complexNavOptions);
* // Returns: '/app/chats/123/dashboard/456'
*/
function navigationOptionsToHref<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
>(options: BusterNavigateOptions<TFrom, TTo>): string {
let href = options.to as string;
// Replace all $param placeholders with actual values from params
if (options.params && typeof options.params === 'object') {
Object.entries(options.params).forEach(([key, value]) => {
// Replace $param with the actual value
// Use a regex to ensure we only replace the exact $param pattern
const pattern = new RegExp(`\\$${key}(?=/|$)`, 'g');
href = href.replace(pattern, encodeURIComponent(String(value)));
});
}
// Add search params if they exist
if (options.search && typeof options.search === 'object') {
const searchParams = new URLSearchParams();
Object.entries(options.search).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const searchString = searchParams.toString();
if (searchString) {
href += `?${searchString}`;
}
}
// Add hash if it exists
if (options.hash) {
href += `#${options.hash}`;
}
return href;
}
/**
* Shorthand function to get href from navigation options
* Alias for navigationOptionsToHref
*/
export const routeToHref = navigationOptionsToHref;

View File

@ -4,7 +4,7 @@ import {
createRoute,
createRouteFactory,
type RouteOptions,
} from './typeSafeNavigation';
} from '../tss-routes';
// Example 1: Using acceptsTypeSafeNavigateOptions
acceptsTypeSafeNavigateOptions({

View File

@ -0,0 +1,32 @@
import type { NavigateOptions, RegisteredRouter } from '@tanstack/react-router';
import type { FileRouteTypes } from '@/routeTree.gen';
/**
* Type representing navigation options that can be passed to testNavigate
* This is useful for creating type-safe route objects
*
* IMPORTANT: Always provide type parameters when using this type!
*
* Bad: BusterNavigateOptions (no type safety)
* Good: BusterNavigateOptions<'/', '/app/chats/$chatId'>
*
* Without type parameters, TypeScript won't enforce required params!
*/
export type BusterNavigateOptions<
TFrom extends FileRouteTypes['id'] = '/',
TTo extends string | undefined = undefined,
TMaskFrom extends FileRouteTypes['id'] = TFrom,
TMaskTo extends string = '',
> = NavigateOptions<RegisteredRouter, TFrom, TTo, TMaskFrom, TMaskTo>;
/**
* Type helper to extract the route options type for a specific route
*
* @example
* type ChatRouteOptions = RouteOptions<'/app/chats/$chatId'>;
* // This will give you the type with proper params like { chatId: string }
*/
export type RouteOptions<
TTo extends FileRouteTypes['id'],
TFrom extends FileRouteTypes['id'] = '/',
> = BusterNavigateOptions<TFrom, TTo>;