mirror of https://github.com/buster-so/buster.git
Update typesafe navigation stuff
This commit is contained in:
parent
45e09d4603
commit
776be1a68a
|
@ -1,4 +1,4 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { FileRouteTypes } from '@/routeTree.gen';
|
import type { FileRouteTypes } from '@/routeTree.gen';
|
||||||
import { assetParamsToRoute, createRouteBuilder } from './assetParamsToRoute';
|
import { assetParamsToRoute, createRouteBuilder } from './assetParamsToRoute';
|
||||||
|
|
||||||
|
@ -87,7 +87,12 @@ describe('assetParamsToRoute', () => {
|
||||||
assetType: 'chat',
|
assetType: 'chat',
|
||||||
assetId: 'chat-123',
|
assetId: 'chat-123',
|
||||||
});
|
});
|
||||||
expect(chatRoute).toBe('/app/chats/$chatId');
|
expect(chatRoute).toEqual({
|
||||||
|
to: '/app/chats/$chatId',
|
||||||
|
params: {
|
||||||
|
chatId: 'chat-123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Chat with dashboard
|
// Chat with dashboard
|
||||||
const chatDashRoute = assetParamsToRoute({
|
const chatDashRoute = assetParamsToRoute({
|
||||||
|
@ -95,7 +100,13 @@ describe('assetParamsToRoute', () => {
|
||||||
assetId: 'chat-123',
|
assetId: 'chat-123',
|
||||||
dashboardId: 'dash-456',
|
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
|
// Chat with metric
|
||||||
const chatMetricRoute = assetParamsToRoute({
|
const chatMetricRoute = assetParamsToRoute({
|
||||||
|
@ -103,7 +114,13 @@ describe('assetParamsToRoute', () => {
|
||||||
assetId: 'chat-123',
|
assetId: 'chat-123',
|
||||||
metricId: 'metric-456',
|
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
|
// Chat with dashboard and metric
|
||||||
const chatDashMetricRoute = assetParamsToRoute({
|
const chatDashMetricRoute = assetParamsToRoute({
|
||||||
|
@ -112,9 +129,14 @@ describe('assetParamsToRoute', () => {
|
||||||
dashboardId: 'dash-456',
|
dashboardId: 'dash-456',
|
||||||
metricId: 'metric-789',
|
metricId: 'metric-789',
|
||||||
});
|
});
|
||||||
expect(chatDashMetricRoute).toBe(
|
expect(chatDashMetricRoute).toEqual({
|
||||||
'/app/chats/$chatId/dashboard/$dashboardId/metrics/$metricId'
|
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', () => {
|
it('should handle metric asset type correctly', () => {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
assetParamsToRoute,
|
assetParamsToRoute,
|
||||||
assetParamsToRoutePath,
|
assetParamsToRoutePath,
|
||||||
} from './assetParamsToRoute';
|
} from './assetParamsToRoute';
|
||||||
|
import { navigationOptionsToHref, toHref } from './typeSafeNavigation';
|
||||||
|
|
||||||
// Example React component showing usage
|
// Example React component showing usage
|
||||||
export function AssetNavigationExample() {
|
export function AssetNavigationExample() {
|
||||||
|
@ -50,6 +51,21 @@ export function AssetNavigationExample() {
|
||||||
});
|
});
|
||||||
console.log(justThePath); // '/app/chats/$chatId/report/$reportId'
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button type="button" onClick={handleNavigateToChat}>
|
<button type="button" onClick={handleNavigateToChat}>
|
||||||
|
@ -66,6 +82,14 @@ export function AssetNavigationExample() {
|
||||||
<Link to={dashboardNavOptions.to} params={dashboardNavOptions.params}>
|
<Link to={dashboardNavOptions.to} params={dashboardNavOptions.params}>
|
||||||
Go to Dashboard (explicit)
|
Go to Dashboard (explicit)
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -73,6 +97,43 @@ export function AssetNavigationExample() {
|
||||||
// Example function that accepts AssetParamsToRoute
|
// Example function that accepts AssetParamsToRoute
|
||||||
export function createAssetLink(params: AssetParamsToRoute) {
|
export function createAssetLink(params: AssetParamsToRoute) {
|
||||||
const navOptions = assetParamsToRoute(params);
|
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 };
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -110,3 +110,68 @@ export function createRouteFactory<
|
||||||
} as BusterNavigateOptions<TFrom, TTo>;
|
} 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;
|
||||||
|
|
Loading…
Reference in New Issue