Update typesafe navigation stuff

This commit is contained in:
Nate Kelley 2025-08-14 23:02:47 -06:00
parent 45e09d4603
commit 776be1a68a
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
4 changed files with 292 additions and 8 deletions

View File

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import type { FileRouteTypes } from '@/routeTree.gen';
import { assetParamsToRoute, createRouteBuilder } from './assetParamsToRoute';
@ -87,7 +87,12 @@ describe('assetParamsToRoute', () => {
assetType: 'chat',
assetId: 'chat-123',
});
expect(chatRoute).toBe('/app/chats/$chatId');
expect(chatRoute).toEqual({
to: '/app/chats/$chatId',
params: {
chatId: 'chat-123',
},
});
// Chat with dashboard
const chatDashRoute = assetParamsToRoute({
@ -95,7 +100,13 @@ describe('assetParamsToRoute', () => {
assetId: 'chat-123',
dashboardId: 'dash-456',
});
expect(chatDashRoute).toBe('/app/chats/$chatId/dashboard/$dashboardId');
expect(chatDashRoute).toEqual({
to: '/app/chats/$chatId/dashboard/$dashboardId',
params: {
chatId: 'chat-123',
dashboardId: 'dash-456',
},
});
// Chat with metric
const chatMetricRoute = assetParamsToRoute({
@ -103,7 +114,13 @@ describe('assetParamsToRoute', () => {
assetId: 'chat-123',
metricId: 'metric-456',
});
expect(chatMetricRoute).toBe('/app/chats/$chatId/metrics/$metricId');
expect(chatMetricRoute).toEqual({
to: '/app/chats/$chatId/metrics/$metricId',
params: {
chatId: 'chat-123',
metricId: 'metric-456',
},
});
// Chat with dashboard and metric
const chatDashMetricRoute = assetParamsToRoute({
@ -112,9 +129,14 @@ describe('assetParamsToRoute', () => {
dashboardId: 'dash-456',
metricId: 'metric-789',
});
expect(chatDashMetricRoute).toBe(
'/app/chats/$chatId/dashboard/$dashboardId/metrics/$metricId'
);
expect(chatDashMetricRoute).toEqual({
to: '/app/chats/$chatId/dashboard/$dashboardId/metrics/$metricId',
params: {
chatId: 'chat-123',
dashboardId: 'dash-456',
metricId: 'metric-789',
},
});
});
it('should handle metric asset type correctly', () => {

View File

@ -4,6 +4,7 @@ import {
assetParamsToRoute,
assetParamsToRoutePath,
} from './assetParamsToRoute';
import { navigationOptionsToHref, toHref } from './typeSafeNavigation';
// Example React component showing usage
export function AssetNavigationExample() {
@ -50,6 +51,21 @@ export function AssetNavigationExample() {
});
console.log(justThePath); // '/app/chats/$chatId/report/$reportId'
// Example 5: Converting navigation options to href
const reportNavOptions = assetParamsToRoute({
assetType: 'report',
assetId: 'report-999',
chatId: 'chat-123',
});
// Convert to actual URL
const reportHref = navigationOptionsToHref(reportNavOptions);
console.log(reportHref); // '/app/chats/chat-123/report/report-999'
// Example 6: Using with native anchor tags
const dashboardHref = toHref(dashboardNavOptions);
// dashboardHref is: '/app/chats/chat-123/dashboard/dash-789/metrics/metric-456'
return (
<div>
<button type="button" onClick={handleNavigateToChat}>
@ -66,6 +82,14 @@ export function AssetNavigationExample() {
<Link to={dashboardNavOptions.to} params={dashboardNavOptions.params}>
Go to Dashboard (explicit)
</Link>
{/* Native anchor tag with href */}
<a href={dashboardHref}>Go to Dashboard (native anchor)</a>
{/* Example with custom styling or external link behavior */}
<a href={reportHref} target="_blank" rel="noopener noreferrer" className="external-link">
Open Report in New Tab
</a>
</div>
);
}
@ -73,6 +97,43 @@ export function AssetNavigationExample() {
// Example function that accepts AssetParamsToRoute
export function createAssetLink(params: AssetParamsToRoute) {
const navOptions = assetParamsToRoute(params);
const href = toHref(navOptions);
return <Link {...navOptions}>View {params.assetType}</Link>;
// You can use either Link component or native anchor
return (
<>
{/* Option 1: TanStack Router Link */}
<Link {...navOptions}>View {params.assetType}</Link>
{/* Option 2: Native anchor with href */}
<a href={href} className="asset-link">
View {params.assetType} (native)
</a>
</>
);
}
// Example: Creating hrefs for external use (emails, sharing, etc.)
export function generateShareableLinks() {
const chatOptions = assetParamsToRoute({
assetType: 'chat',
assetId: 'chat-123',
});
const metricOptions = assetParamsToRoute({
assetType: 'metric',
assetId: 'metric-456',
chatId: 'chat-123',
});
// Convert to absolute URLs for sharing
const baseUrl = window.location.origin;
const chatHref = baseUrl + toHref(chatOptions);
const metricHref = baseUrl + toHref(metricOptions);
console.log('Share these links:');
console.log('Chat:', chatHref); // https://example.com/app/chats/chat-123
console.log('Metric:', metricHref); // https://example.com/app/chats/chat-123/metrics/metric-456
return { chatHref, metricHref };
}

View File

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import type { BusterNavigateOptions } from './typeSafeNavigation';
import { navigationOptionsToHref, toHref } from './typeSafeNavigation';
describe('navigationOptionsToHref', () => {
it('should convert simple route with single param', () => {
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId',
params: {
chatId: '123',
},
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/123');
});
it('should convert route with multiple params', () => {
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId/dashboard/$dashboardId',
params: {
chatId: 'chat-123',
dashboardId: 'dash-456',
},
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/chat-123/dashboard/dash-456');
});
it('should convert complex nested route', () => {
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId/dashboard/$dashboardId/metrics/$metricId',
params: {
chatId: 'chat-123',
dashboardId: 'dash-456',
metricId: 'metric-789',
},
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/chat-123/dashboard/dash-456/metrics/metric-789');
});
it('should handle URL encoding for special characters', () => {
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId',
params: {
chatId: 'chat with spaces & special',
},
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/chat%20with%20spaces%20%26%20special');
});
it('should add search params when provided', () => {
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId',
params: {
chatId: '123',
},
search: {
filter: 'active',
sort: 'date',
},
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/123?filter=active&sort=date');
});
it('should add hash when provided', () => {
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId',
params: {
chatId: '123',
},
hash: 'section-1',
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/123#section-1');
});
it('should handle search params and hash together', () => {
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId',
params: {
chatId: '123',
},
search: {
tab: 'settings',
},
hash: 'privacy',
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/123?tab=settings#privacy');
});
it('should work with toHref alias', () => {
const options: BusterNavigateOptions = {
to: '/app/metrics/$metricId',
params: {
metricId: 'metric-123',
},
};
const href = toHref(options);
expect(href).toBe('/app/metrics/metric-123');
});
it('should handle routes without params', () => {
const options: BusterNavigateOptions = {
to: '/app/home',
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/home');
});
it('should handle params that appear multiple times in different positions', () => {
// Test with a route that has repeated param patterns
const options: BusterNavigateOptions = {
to: '/app/chats/$chatId/report/$reportId',
params: {
chatId: '123',
reportId: 'report-456',
},
};
const href = navigationOptionsToHref(options);
expect(href).toBe('/app/chats/123/report/report-456');
});
});

View File

@ -110,3 +110,68 @@ export function createRouteFactory<
} 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;