Skip to content

Commit 2d7ba29

Browse files
authored
Merge pull request #2820 from bcgov/DEP-254-create-routes-helper
Add constant ROUTES list, route generator function
2 parents 2558e02 + 7349b5e commit 2d7ba29

3 files changed

Lines changed: 177 additions & 0 deletions

File tree

CHANGELOG.MD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## April 13, 2026
2+
3+
- **Feature** Add constant ROUTES list in routes/routes.ts [🎟️ DEP-254](https://citz-gdx.atlassian.net/browse/DEP-254)
4+
- Created a new file `routes/routes.ts` that exports a constant `ROUTES` object containing all the route paths used in the application, organized by section (e.g. ENGAGEMENTS, SURVEYS, USERS, etc.).
5+
- Also created a type-safe `getRoute` function based on generatePath from react-router that takes a route key and optional parameters, and returns the corresponding route path with parameters interpolated. This centralizes route management and makes it easier to update routes in the future without having to search through the entire codebase.
6+
17
## April 9, 2026
28

39
- **Chore** Track CORS config files in-repository and update docs [🎟️ DEP-220](https://citz-gdx.atlassian.net/browse/DEP-220)

web/src/routes/routes.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { generatePath } from 'react-router';
2+
3+
export const ROUTES = {
4+
// Public routes
5+
PUBLIC_LANDING: '/',
6+
PUBLIC_ENGAGEMENT_BY_SLUG: '/:slug/:language',
7+
PUBLIC_DASHBOARD_BY_SLUG: '/:slug/dashboard/:dashboardType/:language',
8+
PUBLIC_COMMENTS_BY_SLUG: '/:slug/comments/:dashboardType/:language',
9+
PUBLIC_SURVEY_EDIT_BY_SLUG: '/:slug/edit/:token/:language',
10+
PUBLIC_ENGAGEMENT_CREATE_FORM: '/engagements/create/form/:language',
11+
PUBLIC_ENGAGEMENT_VIEW: '/engagements/:engagementId/view/:language',
12+
PUBLIC_ENGAGEMENT_VIEW_NO_LANG: '/engagements/:engagementId/view',
13+
PUBLIC_ENGAGEMENT_COMMENTS: '/engagements/:engagementId/comments/:dashboardType/:language',
14+
PUBLIC_ENGAGEMENT_COMMENTS_NO_LANG: '/engagements/:engagementId/comments/:dashboardType',
15+
PUBLIC_ENGAGEMENT_DASHBOARD: '/engagements/:engagementId/dashboard/:dashboardType/:language',
16+
PUBLIC_ENGAGEMENT_DASHBOARD_NO_LANG: '/engagements/:engagementId/dashboard/:dashboardType',
17+
PUBLIC_ENGAGEMENT_EDIT: '/engagements/:engagementId/edit/:token/:language',
18+
PUBLIC_MANAGE_SUBSCRIPTION: '/engagements/:engagementId/:subscriptionStatus/:scriptionKey/:language',
19+
PUBLIC_ENGAGEMENT_FORM: '/engagements/:engagementId/form/:language',
20+
PUBLIC_SURVEY_SUBMIT: '/surveys/submit/:surveyId/:token/:language',
21+
PUBLIC_NOT_AVAILABLE: '/not-available',
22+
PUBLIC_NOT_FOUND: '/not-found',
23+
24+
// Admin routes
25+
ADMIN_ENGAGEMENT_PREVIEW: '/engagements/:engagementId/preview',
26+
HOME: '/home',
27+
SURVEYS: '/surveys',
28+
SURVEY_CREATE: '/surveys/create',
29+
SURVEY_BUILD: '/surveys/:surveyId/build',
30+
SURVEY_REPORT: '/surveys/:surveyId/report',
31+
SURVEY_ADMIN_SUBMIT: '/surveys/:surveyId/submit',
32+
SURVEY_COMMENTS: '/surveys/:surveyId/comments',
33+
SURVEY_COMMENTS_ALL: '/surveys/:surveyId/comments/all',
34+
SURVEY_SUBMISSION_REVIEW: '/surveys/:surveyId/submissions/:submissionId/review',
35+
ENGAGEMENTS: '/engagements',
36+
ENGAGEMENT: '/engagements/:engagementId',
37+
ENGAGEMENT_SEARCH: '/engagements/search',
38+
ENGAGEMENT_FORM: '/engagements/:engagementId/form',
39+
ENGAGEMENT_CREATE: '/engagements/create',
40+
ENGAGEMENT_CREATE_WIZARD: '/engagements/create/wizard',
41+
ENGAGEMENT_DETAILS: '/engagements/:engagementId/details',
42+
ENGAGEMENT_DETAILS_CONFIG_EDIT: '/engagements/:engagementId/details/config/edit',
43+
ENGAGEMENT_DETAILS_CONFIG: '/engagements/:engagementId/details/config',
44+
ENGAGEMENT_DETAILS_AUTHORING: '/engagements/:engagementId/details/authoring',
45+
ENGAGEMENT_DETAILS_ACTIVITY: '/engagements/:engagementId/details/activity',
46+
ENGAGEMENT_DETAILS_RESULTS: '/engagements/:engagementId/details/results',
47+
ENGAGEMENT_DETAILS_PUBLISH: '/engagements/:engagementId/details/publish',
48+
AUTHORING_BANNER: '/engagements/:engagementId/details/authoring/banner',
49+
AUTHORING_SUMMARY: '/engagements/:engagementId/details/authoring/summary',
50+
AUTHORING_DETAILS: '/engagements/:engagementId/details/authoring/details',
51+
AUTHORING_FEEDBACK: '/engagements/:engagementId/details/authoring/feedback',
52+
AUTHORING_RESULTS: '/engagements/:engagementId/details/authoring/results',
53+
AUTHORING_SUBSCRIBE: '/engagements/:engagementId/details/authoring/subscribe',
54+
AUTHORING_MORE: '/engagements/:engagementId/details/authoring/more',
55+
AUTHORING_PAGE: '/engagements/:engagementId/details/authoring/:page',
56+
ENGAGEMENT_COMMENTS_DASHBOARD: '/engagements/comments/:dashboardType',
57+
ENGAGEMENT_DASHBOARD: '/engagements/dashboard/:dashboardType',
58+
SLUG_COMMENTS_DASHBOARD: '/:slug/comments/:dashboardType',
59+
SLUG_DASHBOARD: '/:slug/dashboard/:dashboardType',
60+
METADATA_MANAGEMENT: '/metadatamanagement',
61+
LANGUAGES: '/languages',
62+
TENANT_ADMIN: '/tenantadmin',
63+
TENANT_ADMIN_CREATE: '/tenantadmin/create',
64+
TENANT_ADMIN_DETAIL: '/tenantadmin/:tenantShortName/detail',
65+
TENANT_ADMIN_EDIT: '/tenantadmin/:tenantShortName/edit',
66+
FEEDBACK: '/feedback',
67+
CALENDAR: '/calendar',
68+
REPORTING: '/reporting',
69+
USER_MANAGEMENT: '/usermanagement',
70+
USER_MANAGEMENT_SEARCH: '/usermanagement/search',
71+
USER_DETAILS: '/usermanagement/:userId/details',
72+
UNAUTHORIZED: '/unauthorized',
73+
ADMIN_NOT_FOUND: '/not-found',
74+
} as const;
75+
76+
// A key of ROUTES, e.g. 'HOME' or 'SURVEY_BUILD'.
77+
type RouteKey = keyof typeof ROUTES;
78+
79+
/**
80+
* Extract param names from a route pattern.
81+
* '/manage/surveys/:surveyId/build' => 'surveyId'
82+
* '/manage/engagements/:engagementId/details/:section' => 'engagementId' | 'section'
83+
*/
84+
type ExtractRouteParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
85+
?
86+
| (Param extends `${infer CleanParam}?`
87+
? CleanParam
88+
: Param extends `${infer CleanParam}*`
89+
? CleanParam
90+
: Param)
91+
| ExtractRouteParams<`/${Rest}`>
92+
: T extends `${string}:${infer Param}`
93+
? Param extends `${infer CleanParam}?`
94+
? CleanParam
95+
: Param extends `${infer CleanParam}*`
96+
? CleanParam
97+
: Param
98+
: never;
99+
100+
/**
101+
* Get the params object type for a route.
102+
* If no params, returns {}; otherwise returns record of required params.
103+
*/
104+
type RouteParams<Path extends string> =
105+
ExtractRouteParams<Path> extends never
106+
? Record<string, never>
107+
: { [P in ExtractRouteParams<Path>]: string | number };
108+
109+
/**
110+
* Type-safe route path generator. (wraps react-router's generatePath)
111+
* Automatically extracts required params from the route pattern.
112+
*
113+
* @example
114+
* getPath(ROUTES.HOME) // '/home'
115+
* getPath(ROUTES.ENGAGEMENT_DETAILS, {'enagementId': '123' }) // '/engagements/123/details/authoring'
116+
* getPath(ROUTES.HOME, { surveyId: '123' }) // ✗ TS error: HOME has no surveyId param
117+
* @see https://reactrouter.com/api/utils/generatePath
118+
*/
119+
export function getPath<Path extends (typeof ROUTES)[RouteKey]>(
120+
route: Path,
121+
...params: ExtractRouteParams<Path> extends never ? [] : [params: RouteParams<Path>]
122+
): string {
123+
return generatePath(route as string, params[0] as { [key: string]: string | null } | undefined);
124+
}
125+
126+
export type { RouteKey, RouteParams };
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ROUTES, getPath } from 'routes/routes';
2+
3+
describe('routes/getPath', () => {
4+
test('returns static route without params', () => {
5+
expect(getPath(ROUTES.HOME)).toEqual('/home');
6+
expect(getPath(ROUTES.PUBLIC_LANDING)).toEqual('/');
7+
});
8+
9+
test('interpolates route params', () => {
10+
expect(getPath(ROUTES.SURVEY_BUILD, { surveyId: '123' })).toEqual('/surveys/123/build');
11+
expect(getPath(ROUTES.USER_DETAILS, { userId: 42 })).toEqual('/usermanagement/42/details');
12+
expect(getPath(ROUTES.PUBLIC_ENGAGEMENT_BY_SLUG, { slug: 'my-engagement', language: 'en' })).toEqual(
13+
'/my-engagement/en',
14+
);
15+
});
16+
17+
test('supports multi-param interpolation', () => {
18+
expect(
19+
getPath(ROUTES.PUBLIC_MANAGE_SUBSCRIPTION, {
20+
engagementId: '88',
21+
subscriptionStatus: 'unsubscribe',
22+
scriptionKey: 'abc123',
23+
language: 'fr',
24+
}),
25+
).toEqual('/engagements/88/unsubscribe/abc123/fr');
26+
});
27+
});
28+
29+
// Compile-time type checks.
30+
// These are intentionally not runtime assertions -
31+
// they will cause TypeScript compilation errors if the types are not correct,
32+
// which is the main point of these tests.
33+
const typeSafetyChecks = () => {
34+
getPath(ROUTES.SURVEY_BUILD, { surveyId: '1' });
35+
getPath(ROUTES.HOME);
36+
37+
// @ts-expect-error - surveyId is required for SURVEY_BUILD.
38+
getPath(ROUTES.SURVEY_BUILD);
39+
40+
// @ts-expect-error - HOME does not accept params.
41+
getPath(ROUTES.HOME, { surveyId: '1' });
42+
43+
// @ts-expect-error - USER_DETAILS requires userId.
44+
getPath(ROUTES.USER_DETAILS, {});
45+
};

0 commit comments

Comments
 (0)