diff --git a/apps/web-tss/src/lib/assets/assetParamsToRoute.test.ts b/apps/web-tss/src/lib/assets/assetParamsToRoute.test.ts index c131c3469..385fb8666 100644 --- a/apps/web-tss/src/lib/assets/assetParamsToRoute.test.ts +++ b/apps/web-tss/src/lib/assets/assetParamsToRoute.test.ts @@ -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', () => { diff --git a/apps/web-tss/src/lib/assets/assetParamsToRoute.usage.example.tsx b/apps/web-tss/src/lib/assets/assetParamsToRoute.usage.example.tsx index b472ddf81..55eada443 100644 --- a/apps/web-tss/src/lib/assets/assetParamsToRoute.usage.example.tsx +++ b/apps/web-tss/src/lib/assets/assetParamsToRoute.usage.example.tsx @@ -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 (
); } @@ -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 View {params.assetType}; + // You can use either Link component or native anchor + return ( + <> + {/* Option 1: TanStack Router Link */} + View {params.assetType} + + {/* Option 2: Native anchor with href */} + + View {params.assetType} (native) + + + ); +} + +// 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 }; } diff --git a/apps/web-tss/src/lib/assets/navigationOptionsToHref.test.ts b/apps/web-tss/src/lib/assets/navigationOptionsToHref.test.ts new file mode 100644 index 000000000..deb7369ab --- /dev/null +++ b/apps/web-tss/src/lib/assets/navigationOptionsToHref.test.ts @@ -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'); + }); +}); diff --git a/apps/web-tss/src/lib/assets/typeSafeNavigation.ts b/apps/web-tss/src/lib/assets/typeSafeNavigation.ts index 418a5fbd8..3121b508b 100644 --- a/apps/web-tss/src/lib/assets/typeSafeNavigation.ts +++ b/apps/web-tss/src/lib/assets/typeSafeNavigation.ts @@ -110,3 +110,68 @@ export function createRouteFactory< } as BusterNavigateOptions; }; } + +/** + * 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): 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;