From 9e7ffdd4192d8fa930953c91be626cfc4602ae11 Mon Sep 17 00:00:00 2001 From: Mirfayz Karimoff Date: Fri, 7 Jun 2024 21:55:38 +0500 Subject: [PATCH 1/7] wip: custom library for implementing type-safe routes --- lib/makeRoute.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 31 +++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 lib/makeRoute.ts diff --git a/lib/makeRoute.ts b/lib/makeRoute.ts new file mode 100644 index 00000000..a7b898e9 --- /dev/null +++ b/lib/makeRoute.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; +import { + type ReadonlyURLSearchParams, + useParams as useNextParams, + useSearchParams as useNextSearchParams, +} from 'next/navigation'; +import queryString from 'query-string'; + +type RouteBuilder = { + (p?: z.input, options?: { search?: z.input }): string; + parse: (input: z.input) => z.output; + useParams: () => z.output; + useSearchParams: () => z.output; + params: z.output; +}; + +const empty: z.ZodSchema = z.object({}); + +function makeRoute( + fn: (p: z.input) => string, + paramsSchema: Params = empty as Params, + search: Search = empty as Search, +): RouteBuilder { + const routeBuilder: RouteBuilder = (params, options) => { + const baseUrl = fn(params); + const searchString = + options?.search && queryString.stringify(options.search); + return [baseUrl, searchString ? `?${searchString}` : ''].join(''); + }; + + routeBuilder.parse = function parse(args: z.input): z.output { + const res = paramsSchema.safeParse(args); + if (!res.success) { + const routeName = + Object.entries(Routes).find( + ([, route]) => route === routeBuilder, + )?.[0] ?? '(unknown route)'; + throw new Error( + `Invalid route params for route ${routeName}: ${res.error.message}`, + ); + } + return res.data; + }; + + routeBuilder.useParams = function useParams(): z.output { + const res = paramsSchema.safeParse(useNextParams()); + if (!res.success) { + const routeName = + Object.entries(Routes).find( + ([, route]) => route === routeBuilder, + )?.[0] ?? '(unknown route)'; + throw new Error( + `Invalid route params for route ${routeName}: ${res.error.message}`, + ); + } + return res.data; + }; + + routeBuilder.useSearchParams = function useSearchParams(): z.output { + const res = search.safeParse( + convertURLSearchParamsToObject(useNextSearchParams()), + ); + if (!res.success) { + const routeName = + Object.entries(Routes).find( + ([, route]) => route === routeBuilder, + )?.[0] ?? '(unknown route)'; + throw new Error( + `Invalid search params for route ${routeName}: ${res.error.message}`, + ); + } + return res.data; + }; + + // set the type + routeBuilder.params = undefined as z.output; + // set the runtime getter + Object.defineProperty(routeBuilder, 'params', { + get() { + throw new Error( + 'Routes.[route].params is only for type usage, not runtime. Use it like `typeof Routes.[routes].params`', + ); + }, + }); + + return routeBuilder; +} + +export function convertURLSearchParamsToObject( + params: ReadonlyURLSearchParams | null, +): Record { + if (!params) { + return {}; + } + + const obj: Record = {}; + + for (const [key, value] of Array.from(params.entries())) { + if (params.getAll(key).length > 1) { + obj[key] = params.getAll(key); + } else { + obj[key] = value; + } + } + return obj; +} + +export default makeRoute; + +export const Routes = { + about: makeRoute(() => `/about`, z.object({}) /* no params */), +}; diff --git a/package.json b/package.json index 4866dde4..a13d0eb3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "postgres": "^3.4.4", "prettier": "^3.3.1", "prettier-plugin-tailwindcss": "^0.6.1", + "query-string": "^9.0.0", "react": "^18", "react-dom": "^18", "sharp": "^0.33.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fa96bd8..188ef9bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.6.1 version: 0.6.1(prettier@3.3.1) + query-string: + specifier: ^9.0.0 + version: 9.0.0 react: specifier: ^18 version: 18.3.1 @@ -1401,6 +1404,10 @@ packages: supports-color: optional: true + decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + deep-freeze@0.0.1: resolution: {integrity: sha512-Z+z8HiAvsGwmjqlphnHW5oz6yWlOwu6EQfFTjmeTWlDeda3FS2yv3jhq35TX/ewmsnqB+RX2IdsIOyjJCQN5tg==} @@ -1743,6 +1750,10 @@ packages: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} engines: {node: '>=0.10.0'} + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2465,6 +2476,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + query-string@9.0.0: + resolution: {integrity: sha512-4EWwcRGsO2H+yzq6ddHcVqkCQ2EFUSfDMEjF8ryp8ReymyZhIuaFRGLomeOQLkrzacMHoyky2HW0Qe30UbzkKw==} + engines: {node: '>=18'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2601,6 +2616,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -3950,6 +3969,8 @@ snapshots: dependencies: ms: 2.1.2 + decode-uri-component@0.4.1: {} + deep-freeze@0.0.1: {} deep-is@0.1.4: {} @@ -4421,6 +4442,8 @@ snapshots: filter-obj@1.1.0: {} + filter-obj@5.1.0: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5079,6 +5102,12 @@ snapshots: punycode@2.3.1: {} + query-string@9.0.0: + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + queue-microtask@1.2.3: {} react-dom@18.3.1(react@18.3.1): @@ -5245,6 +5274,8 @@ snapshots: source-map@0.6.1: {} + split-on-first@3.0.0: {} + streamsearch@1.1.0: {} string-width@4.2.3: From a271f0acce3e242abdf609a1ed70808047dc14f4 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 10 Jun 2024 10:39:36 +0200 Subject: [PATCH 2/7] use makeRoute from https://github.com/nicnocquee/next-type-safe-routing --- app/signin/page.tsx | 1 + lib/makeRoute.ts | 169 ++++++++++++++++++++++---------------------- routes.ts | 11 +++ 3 files changed, 95 insertions(+), 86 deletions(-) create mode 100644 routes.ts diff --git a/app/signin/page.tsx b/app/signin/page.tsx index e249074a..2794041a 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -9,6 +9,7 @@ import { } from '~/components/ui/card'; import { validateRequest } from '~/lib/auth'; import SignInForm from './_components/SignInForm'; +import { Routes } from '~/routes'; export default async function Page() { const { session, user } = await validateRequest(); diff --git a/lib/makeRoute.ts b/lib/makeRoute.ts index a7b898e9..55559763 100644 --- a/lib/makeRoute.ts +++ b/lib/makeRoute.ts @@ -1,112 +1,109 @@ -import { z } from 'zod'; import { - type ReadonlyURLSearchParams, useParams as useNextParams, useSearchParams as useNextSearchParams, } from 'next/navigation'; -import queryString from 'query-string'; +import { z } from 'zod'; type RouteBuilder = { - (p?: z.input, options?: { search?: z.input }): string; - parse: (input: z.input) => z.output; - useParams: () => z.output; - useSearchParams: () => z.output; - params: z.output; + ( + p?: z.input, + options?: { readonly search?: z.input }, + ): string; + readonly useParams: () => z.output; + readonly useSearchParams: () => z.output; + readonly params: z.output; }; -const empty: z.ZodSchema = z.object({}); - -function makeRoute( +export function makeRoute< + Params extends z.ZodSchema, + Search extends z.ZodSchema, +>( fn: (p: z.input) => string, - paramsSchema: Params = empty as Params, - search: Search = empty as Search, + paramsSchema: Params = z.object({}) as unknown as Params, + search: Search = z.object({}) as unknown as Search, ): RouteBuilder { - const routeBuilder: RouteBuilder = (params, options) => { - const baseUrl = fn(params); - const searchString = - options?.search && queryString.stringify(options.search); - return [baseUrl, searchString ? `?${searchString}` : ''].join(''); - }; - - routeBuilder.parse = function parse(args: z.input): z.output { - const res = paramsSchema.safeParse(args); - if (!res.success) { - const routeName = - Object.entries(Routes).find( - ([, route]) => route === routeBuilder, - )?.[0] ?? '(unknown route)'; - throw new Error( - `Invalid route params for route ${routeName}: ${res.error.message}`, - ); - } - return res.data; - }; - - routeBuilder.useParams = function useParams(): z.output { - const res = paramsSchema.safeParse(useNextParams()); - if (!res.success) { - const routeName = - Object.entries(Routes).find( - ([, route]) => route === routeBuilder, - )?.[0] ?? '(unknown route)'; - throw new Error( - `Invalid route params for route ${routeName}: ${res.error.message}`, - ); - } - return res.data; - }; - - routeBuilder.useSearchParams = function useSearchParams(): z.output { - const res = search.safeParse( - convertURLSearchParamsToObject(useNextSearchParams()), - ); - if (!res.success) { - const routeName = - Object.entries(Routes).find( - ([, route]) => route === routeBuilder, - )?.[0] ?? '(unknown route)'; - throw new Error( - `Invalid search params for route ${routeName}: ${res.error.message}`, + const routeBuilder = Object.assign( + ( + params?: z.input, + options?: { readonly search?: z.input }, + ): string => { + const paramsValidationResult = paramsSchema.safeParse(params ?? {}); + if (!paramsValidationResult.success) { + throw new Error( + `Invalid route params: ${paramsValidationResult.error.message}`, + ); + } + const searchString = options?.search + ? new URLSearchParams(options.search) + : null; + const searchParamsValidationResult = search.safeParse( + convertURLSearchParamsToObject(searchString), ); - } - return res.data; - }; + if (!searchParamsValidationResult.success) { + throw new Error( + `Invalid search params: ${searchParamsValidationResult.error.message}`, + ); + } + const baseUrl = fn(params); - // set the type - routeBuilder.params = undefined as z.output; - // set the runtime getter - Object.defineProperty(routeBuilder, 'params', { - get() { - throw new Error( - 'Routes.[route].params is only for type usage, not runtime. Use it like `typeof Routes.[routes].params`', + return [baseUrl, searchString ? `?${searchString.toString()}` : ''].join( + '', ); }, - }); + { + useParams: function useParams(): z.output { + const res = paramsSchema.safeParse(useNextParams()); + if (!res.success) { + throw new Error(`Invalid route params: ${res.error.message}`); + } + return res.data; + }, + useSearchParams: function useSearchParams(): z.output { + const res = search.safeParse( + convertURLSearchParamsToObject(useNextSearchParams()), + ); + if (!res.success) { + throw new Error(`Invalid search params: ${res.error.message}`); + } + return res.data; + }, + params: { + get() { + // Replace the throw statement with functional error handling if needed + console.warn( + 'Routes.[route].params is only for type usage, not runtime.', + ); + return undefined; // Or handle accordingly + }, + enumerable: true, + configurable: false, + }, + }, + ); return routeBuilder; } export function convertURLSearchParamsToObject( - params: ReadonlyURLSearchParams | null, -): Record { + params: URLSearchParams | null, +): Record { if (!params) { return {}; } - const obj: Record = {}; + const obj: Record = Array.from( + params.entries(), + ).reduce( + (accumulator, [key, value]) => { + const allValues = params.getAll(key); + // Return a new object instead of modifying the accumulator + return { + ...accumulator, + [key]: allValues.length > 1 ? allValues : value, + }; + }, + {} as Record, + ); - for (const [key, value] of Array.from(params.entries())) { - if (params.getAll(key).length > 1) { - obj[key] = params.getAll(key); - } else { - obj[key] = value; - } - } return obj; } - -export default makeRoute; - -export const Routes = { - about: makeRoute(() => `/about`, z.object({}) /* no params */), -}; diff --git a/routes.ts b/routes.ts new file mode 100644 index 00000000..3238c65b --- /dev/null +++ b/routes.ts @@ -0,0 +1,11 @@ +//routes.ts +import { z } from 'zod'; +import { makeRoute } from './lib/makeRoute'; + +export const OrgParams = z.object({ orgId: z.string() }); + +export const Routes = { + home: makeRoute(({ orgId }) => `/org/${orgId}`, OrgParams), + signin: makeRoute(() => '/signin'), + signup: makeRoute(() => '/signup'), +}; From f95f0c70d9a1d119f2c107067bf7c4d7c6160061 Mon Sep 17 00:00:00 2001 From: Mirfayz Karimoff Date: Mon, 10 Jun 2024 16:12:50 +0500 Subject: [PATCH 3/7] remove the lightcontrol approach --- app/signin/page.tsx | 1 - lib/makeRoute.ts | 109 -------------------------------------------- package.json | 1 - pnpm-lock.yaml | 31 ------------- routes.ts | 11 ----- 5 files changed, 153 deletions(-) delete mode 100644 lib/makeRoute.ts delete mode 100644 routes.ts diff --git a/app/signin/page.tsx b/app/signin/page.tsx index 2794041a..e249074a 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -9,7 +9,6 @@ import { } from '~/components/ui/card'; import { validateRequest } from '~/lib/auth'; import SignInForm from './_components/SignInForm'; -import { Routes } from '~/routes'; export default async function Page() { const { session, user } = await validateRequest(); diff --git a/lib/makeRoute.ts b/lib/makeRoute.ts deleted file mode 100644 index 55559763..00000000 --- a/lib/makeRoute.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - useParams as useNextParams, - useSearchParams as useNextSearchParams, -} from 'next/navigation'; -import { z } from 'zod'; - -type RouteBuilder = { - ( - p?: z.input, - options?: { readonly search?: z.input }, - ): string; - readonly useParams: () => z.output; - readonly useSearchParams: () => z.output; - readonly params: z.output; -}; - -export function makeRoute< - Params extends z.ZodSchema, - Search extends z.ZodSchema, ->( - fn: (p: z.input) => string, - paramsSchema: Params = z.object({}) as unknown as Params, - search: Search = z.object({}) as unknown as Search, -): RouteBuilder { - const routeBuilder = Object.assign( - ( - params?: z.input, - options?: { readonly search?: z.input }, - ): string => { - const paramsValidationResult = paramsSchema.safeParse(params ?? {}); - if (!paramsValidationResult.success) { - throw new Error( - `Invalid route params: ${paramsValidationResult.error.message}`, - ); - } - const searchString = options?.search - ? new URLSearchParams(options.search) - : null; - const searchParamsValidationResult = search.safeParse( - convertURLSearchParamsToObject(searchString), - ); - if (!searchParamsValidationResult.success) { - throw new Error( - `Invalid search params: ${searchParamsValidationResult.error.message}`, - ); - } - const baseUrl = fn(params); - - return [baseUrl, searchString ? `?${searchString.toString()}` : ''].join( - '', - ); - }, - { - useParams: function useParams(): z.output { - const res = paramsSchema.safeParse(useNextParams()); - if (!res.success) { - throw new Error(`Invalid route params: ${res.error.message}`); - } - return res.data; - }, - useSearchParams: function useSearchParams(): z.output { - const res = search.safeParse( - convertURLSearchParamsToObject(useNextSearchParams()), - ); - if (!res.success) { - throw new Error(`Invalid search params: ${res.error.message}`); - } - return res.data; - }, - params: { - get() { - // Replace the throw statement with functional error handling if needed - console.warn( - 'Routes.[route].params is only for type usage, not runtime.', - ); - return undefined; // Or handle accordingly - }, - enumerable: true, - configurable: false, - }, - }, - ); - - return routeBuilder; -} - -export function convertURLSearchParamsToObject( - params: URLSearchParams | null, -): Record { - if (!params) { - return {}; - } - - const obj: Record = Array.from( - params.entries(), - ).reduce( - (accumulator, [key, value]) => { - const allValues = params.getAll(key); - // Return a new object instead of modifying the accumulator - return { - ...accumulator, - [key]: allValues.length > 1 ? allValues : value, - }; - }, - {} as Record, - ); - - return obj; -} diff --git a/package.json b/package.json index a13d0eb3..4866dde4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "postgres": "^3.4.4", "prettier": "^3.3.1", "prettier-plugin-tailwindcss": "^0.6.1", - "query-string": "^9.0.0", "react": "^18", "react-dom": "^18", "sharp": "^0.33.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 188ef9bc..9fa96bd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.6.1 version: 0.6.1(prettier@3.3.1) - query-string: - specifier: ^9.0.0 - version: 9.0.0 react: specifier: ^18 version: 18.3.1 @@ -1404,10 +1401,6 @@ packages: supports-color: optional: true - decode-uri-component@0.4.1: - resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} - engines: {node: '>=14.16'} - deep-freeze@0.0.1: resolution: {integrity: sha512-Z+z8HiAvsGwmjqlphnHW5oz6yWlOwu6EQfFTjmeTWlDeda3FS2yv3jhq35TX/ewmsnqB+RX2IdsIOyjJCQN5tg==} @@ -1750,10 +1743,6 @@ packages: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} engines: {node: '>=0.10.0'} - filter-obj@5.1.0: - resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} - engines: {node: '>=14.16'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2476,10 +2465,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - query-string@9.0.0: - resolution: {integrity: sha512-4EWwcRGsO2H+yzq6ddHcVqkCQ2EFUSfDMEjF8ryp8ReymyZhIuaFRGLomeOQLkrzacMHoyky2HW0Qe30UbzkKw==} - engines: {node: '>=18'} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2616,10 +2601,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - split-on-first@3.0.0: - resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} - engines: {node: '>=12'} - streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -3969,8 +3950,6 @@ snapshots: dependencies: ms: 2.1.2 - decode-uri-component@0.4.1: {} - deep-freeze@0.0.1: {} deep-is@0.1.4: {} @@ -4442,8 +4421,6 @@ snapshots: filter-obj@1.1.0: {} - filter-obj@5.1.0: {} - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5102,12 +5079,6 @@ snapshots: punycode@2.3.1: {} - query-string@9.0.0: - dependencies: - decode-uri-component: 0.4.1 - filter-obj: 5.1.0 - split-on-first: 3.0.0 - queue-microtask@1.2.3: {} react-dom@18.3.1(react@18.3.1): @@ -5274,8 +5245,6 @@ snapshots: source-map@0.6.1: {} - split-on-first@3.0.0: {} - streamsearch@1.1.0: {} string-width@4.2.3: diff --git a/routes.ts b/routes.ts deleted file mode 100644 index 3238c65b..00000000 --- a/routes.ts +++ /dev/null @@ -1,11 +0,0 @@ -//routes.ts -import { z } from 'zod'; -import { makeRoute } from './lib/makeRoute'; - -export const OrgParams = z.object({ orgId: z.string() }); - -export const Routes = { - home: makeRoute(({ orgId }) => `/org/${orgId}`, OrgParams), - signin: makeRoute(() => '/signin'), - signup: makeRoute(() => '/signup'), -}; From ec0bb4143623825d9d71dd061d2b7f66eaebc9e2 Mon Sep 17 00:00:00 2001 From: Mirfayz Karimoff Date: Mon, 10 Jun 2024 17:27:55 +0500 Subject: [PATCH 4/7] Implement type safe routing with next-safe-navigation and adjust the code for most of the pages --- app/[org]/[project]/interviews/page.tsx | 15 ++++--- app/[org]/[project]/layout.tsx | 37 ++++++++++++---- app/[org]/[project]/page.tsx | 14 +++--- app/[org]/[project]/participants/page.tsx | 13 ++++-- app/[org]/[project]/protocols/page.tsx | 17 ++++--- app/[org]/[project]/settings/page.tsx | 13 ++++-- app/[org]/layout.tsx | 20 ++++++--- app/[org]/page.tsx | 11 ++++- app/[org]/settings/page.tsx | 12 ++++- app/_components/SignOutBtn.tsx | 6 ++- app/page.tsx | 19 ++++++-- app/signin/page.tsx | 5 ++- app/signup/page.tsx | 5 ++- lib/auth/index.ts | 3 +- lib/routes.ts | 54 +++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 16 +++++++ 17 files changed, 206 insertions(+), 55 deletions(-) create mode 100644 lib/routes.ts diff --git a/app/[org]/[project]/interviews/page.tsx b/app/[org]/[project]/interviews/page.tsx index b7c4af54..3f621a1d 100644 --- a/app/[org]/[project]/interviews/page.tsx +++ b/app/[org]/[project]/interviews/page.tsx @@ -1,11 +1,14 @@ import { getProjectBySlug } from '~/server/queries/projects'; +import { routes } from '~/lib/routes'; -export default async function InterviewsPage({ - params, -}: { - params: { org: string; project: string }; -}) { - const { project: projectSlug } = params; +type InterviewsPageProps = { + // ✅ Never assume the types of your params before validation + params?: unknown; +}; + +export default async function InterviewsPage({ params }: InterviewsPageProps) { + const { project: projectSlug } = + routes.orgProjectProtocols.$parseParams(params); const project = await getProjectBySlug(projectSlug); if (!project) { diff --git a/app/[org]/[project]/layout.tsx b/app/[org]/[project]/layout.tsx index 88f5b49e..830d230c 100644 --- a/app/[org]/[project]/layout.tsx +++ b/app/[org]/[project]/layout.tsx @@ -1,21 +1,42 @@ import Link from 'next/link'; +import { routes } from '~/lib/routes'; type ProjectLayoutProps = { children: React.ReactNode; - params: { org: string; project: string }; + // ✅ Never assume the types of your params before validation + params?: unknown; }; const ProjectLayout = ({ children, params }: ProjectLayoutProps) => { - const { org: orgSlug, project: projectSlug } = params; + const { org, project } = routes.orgProject.$parseParams(params); + return ( <>
- - Participants - - Interviews - Protocols - Settings +
+ + Participants + {' '} + ➔ +
+
+ + Interviews + {' '} + ➔ +
+
+ + Protocols + {' '} + ➔ +
+
+ + Settings + {' '} + ➔ +
{children} diff --git a/app/[org]/[project]/page.tsx b/app/[org]/[project]/page.tsx index 50211e28..e1f65129 100644 --- a/app/[org]/[project]/page.tsx +++ b/app/[org]/[project]/page.tsx @@ -1,11 +1,13 @@ import { getProjectBySlug } from '~/server/queries/projects'; +import { routes } from '~/lib/routes'; -export default async function ProjectPage({ - params, -}: { - params: { org: string; project: string }; -}) { - const { project: projectSlug } = params; +type ProjectPageProps = { + // ✅ Never assume the types of your params before validation + params?: unknown; +}; + +export default async function ProjectPage({ params }: ProjectPageProps) { + const { project: projectSlug } = routes.orgProject.$parseParams(params); const project = await getProjectBySlug(projectSlug); if (!project) { diff --git a/app/[org]/[project]/participants/page.tsx b/app/[org]/[project]/participants/page.tsx index accee238..a1261fd7 100644 --- a/app/[org]/[project]/participants/page.tsx +++ b/app/[org]/[project]/participants/page.tsx @@ -1,11 +1,16 @@ import { getProjectBySlug } from '~/server/queries/projects'; +import { routes } from '~/lib/routes'; + +type ParticipantsPageProps = { + // ✅ Never assume the types of your params before validation + params?: unknown; +}; export default async function ParticipantsPage({ params, -}: { - params: { org: string; project: string }; -}) { - const { project: projectSlug } = params; +}: ParticipantsPageProps) { + const { project: projectSlug } = + routes.orgProjectProtocols.$parseParams(params); const project = await getProjectBySlug(projectSlug); if (!project) { diff --git a/app/[org]/[project]/protocols/page.tsx b/app/[org]/[project]/protocols/page.tsx index a304cfbf..e5d10ba3 100644 --- a/app/[org]/[project]/protocols/page.tsx +++ b/app/[org]/[project]/protocols/page.tsx @@ -1,11 +1,14 @@ import { getProjectBySlug } from '~/server/queries/projects'; +import { routes } from '~/lib/routes'; -export default async function ProtocolsPage({ - params, -}: { - params: { org: string; project: string }; -}) { - const { project: projectSlug } = params; +type ProtocolsPageProps = { + // ✅ Never assume the types of your params before validation + params?: unknown; +}; + +export default async function ProtocolsPage({ params }: ProtocolsPageProps) { + const { project: projectSlug } = + routes.orgProjectProtocols.$parseParams(params); const project = await getProjectBySlug(projectSlug); if (!project) { @@ -15,7 +18,7 @@ export default async function ProtocolsPage({ return (
-
{project.name} Protocols Page
+
{project.name} Protocols
slug: {projectSlug}
); diff --git a/app/[org]/[project]/settings/page.tsx b/app/[org]/[project]/settings/page.tsx index 252caef9..02ac5e0e 100644 --- a/app/[org]/[project]/settings/page.tsx +++ b/app/[org]/[project]/settings/page.tsx @@ -1,11 +1,16 @@ import { getProjectBySlug } from '~/server/queries/projects'; +import { routes } from '~/lib/routes'; + +type ProjectSettingsPageProps = { + // ✅ Never assume the types of your params before validation + params?: unknown; +}; export default async function ProjectSettingsPage({ params, -}: { - params: { org: string; project: string }; -}) { - const { project: projectSlug } = params; +}: ProjectSettingsPageProps) { + const { project: projectSlug } = + routes.orgProjectProtocols.$parseParams(params); const project = await getProjectBySlug(projectSlug); if (!project) { diff --git a/app/[org]/layout.tsx b/app/[org]/layout.tsx index eaf5f03b..1bca9008 100644 --- a/app/[org]/layout.tsx +++ b/app/[org]/layout.tsx @@ -1,18 +1,26 @@ import Link from 'next/link'; +import { routes } from '~/lib/routes'; type OrganizationLayoutProps = { children: React.ReactNode; - params: { org: string }; + // ✅ Never assume the types of your params before validation + params?: unknown; }; const OrganizationLayout = ({ children, params }: OrganizationLayoutProps) => { - const { org: orgSlug } = params; + const { org } = routes.orgDashboard.$parseParams(params); + return (
-
- Studio - Org Dashboard - Org Settings +
+
+ Studio ➔ +
+
+ Org Dashboard{' '} + ➔ +
+ Org Settings
{children}
diff --git a/app/[org]/page.tsx b/app/[org]/page.tsx index 60db82b5..f16b4c66 100644 --- a/app/[org]/page.tsx +++ b/app/[org]/page.tsx @@ -1,10 +1,17 @@ import { getProjects } from '~/server/queries/projects'; import CreateProjectForm from '~/app/[org]/_components/CreateProjectForm'; import ProjectCard from './_components/ProjectCard'; +import { routes } from '~/lib/routes'; -export default async function OrgPage({ params }: { params: { org: string } }) { - const { org } = params; +type OrgPageProps = { + // ✅ Never assume the types of your params before validation + params?: unknown; +}; + +export default async function OrgPage({ params }: OrgPageProps) { + const { org } = routes.orgDashboard.$parseParams(params); const allProjects = await getProjects(org); + return (

Organization Page

diff --git a/app/[org]/settings/page.tsx b/app/[org]/settings/page.tsx index 25caefb0..6694f034 100644 --- a/app/[org]/settings/page.tsx +++ b/app/[org]/settings/page.tsx @@ -1,5 +1,13 @@ -export default function Page({ params }: { params: { org: string } }) { - const { org } = params; +import { routes } from '~/lib/routes'; + +type OrgSettingPageProps = { + // ✅ Never assume the types of your params before validation + params?: unknown; +}; + +export default function OrgSettingPage({ params }: OrgSettingPageProps) { + const { org } = routes.orgSettings.$parseParams(params); + return (
Organization Settings Page
diff --git a/app/_components/SignOutBtn.tsx b/app/_components/SignOutBtn.tsx index 8da95b75..64b8220c 100644 --- a/app/_components/SignOutBtn.tsx +++ b/app/_components/SignOutBtn.tsx @@ -5,7 +5,11 @@ import { signout } from '~/server/actions/auth'; import { Button } from '~/components/ui/button'; const SignOutBtn = () => { - return ; + return ( + + ); }; export default SignOutBtn; diff --git a/app/page.tsx b/app/page.tsx index ca5c0b0b..6237c47a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,14 @@ import { getOrganizations } from '~/server/queries/organizations'; import CreateOrgForm from '~/app/[org]/_components/CreateOrgForm'; +<<<<<<< HEAD import { requirePageAuth } from '~/lib/auth'; import SignOutBtn from './_components/SignOutBtn'; +======= +import { requirePageAuth } from '~/utils/auth'; +import SignOutBtn from './_components/SignOutBtn'; +import Link from 'next/link'; +import { routes } from '~/lib/routes'; +>>>>>>> aaafd9b (Implement type safe routing with next-safe-navigation and adjust the code for most of the pages) export default async function Home() { await requirePageAuth(); @@ -9,14 +16,18 @@ export default async function Home() { const allOrgs = await getOrganizations(); return (
-
Studio MVP
+

Studio MVP

-
All Organizations
-
+

All Organizations

+ diff --git a/app/signin/page.tsx b/app/signin/page.tsx index e249074a..3bb839e5 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -9,13 +9,14 @@ import { } from '~/components/ui/card'; import { validateRequest } from '~/lib/auth'; import SignInForm from './_components/SignInForm'; +import { routes } from '~/lib/routes'; export default async function Page() { const { session, user } = await validateRequest(); if (session && user) { // If the user is already signed in, redirect to the home page - redirect('/'); + redirect(routes.home()); } return ( @@ -25,7 +26,7 @@ export default async function Page() { Sign in to Studio Don't have an account?{' '} - + Sign Up diff --git a/app/signup/page.tsx b/app/signup/page.tsx index 494ff3d2..c532f386 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -9,13 +9,14 @@ import { import SignUpForm from './_components/SignUpForm'; import { validateRequest } from '~/lib/auth'; import { redirect } from 'next/navigation'; +import { routes } from '~/lib/routes'; export default async function Page() { const { session, user } = await validateRequest(); if (session && user) { // If the user is already signed in, redirect to the home page - redirect('/'); + redirect(routes.home()); } return ( @@ -25,7 +26,7 @@ export default async function Page() { Create an account Already have an account?{' '} - + Sign In diff --git a/lib/auth/index.ts b/lib/auth/index.ts index 30238158..f1bf1ca0 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -11,6 +11,7 @@ import { type UserType, } from '~/lib/db/schema'; import { env } from '~/env'; +import { routes } from '../routes'; const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, userTable); @@ -85,7 +86,7 @@ export async function requirePageAuth() { const { session, user } = await validateRequest(); if (!session || !user) { - redirect('/signin', RedirectType.replace); + redirect(routes.signIn(), RedirectType.replace); } return { session, user }; diff --git a/lib/routes.ts b/lib/routes.ts new file mode 100644 index 00000000..72b6cb5c --- /dev/null +++ b/lib/routes.ts @@ -0,0 +1,54 @@ +// This file is a source of truth for the app's navigation system. +// It defines the routes and their parameters for the app. +// Todo: So, please keep it up to date as you add or remove routes. +// reference: https://github.com/lukemorales/next-safe-navigation/tree/main?tab=readme-ov-file#declare-your-application-routes-and-parameters-in-a-single-place + +import { createNavigationConfig } from 'next-safe-navigation'; +import { z } from 'zod'; + +export const { routes, useSafeParams, useSafeSearchParams } = + createNavigationConfig((defineRoute) => ({ + home: defineRoute('/'), + signIn: defineRoute('/signin'), + signUp: defineRoute('/signup'), + orgDashboard: defineRoute('/[org]', { + params: z.object({ + org: z.string(), + }), + }), + orgSettings: defineRoute('/[org]/settings', { + params: z.object({ + org: z.string(), + }), + }), + orgProject: defineRoute('/[org]/[project]', { + params: z.object({ + org: z.string(), + project: z.string(), + }), + }), + orgProjectParticipants: defineRoute('/[org]/[project]/participants', { + params: z.object({ + org: z.string(), + project: z.string(), + }), + }), + orgProjectInterviews: defineRoute('/[org]/[project]/interviews', { + params: z.object({ + org: z.string(), + project: z.string(), + }), + }), + orgProjectProtocols: defineRoute('/[org]/[project]/protocols', { + params: z.object({ + org: z.string(), + project: z.string(), + }), + }), + orgProjectSettings: defineRoute('/[org]/[project]/settings', { + params: z.object({ + org: z.string(), + project: z.string(), + }), + }), + })); diff --git a/package.json b/package.json index 4866dde4..a7c27004 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lucia": "^3.2.0", "nanoid": "^5.0.7", "next": "14.2.3", + "next-safe-navigation": "^0.3.2", "postgres": "^3.4.4", "prettier": "^3.3.1", "prettier-plugin-tailwindcss": "^0.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fa96bd8..071fd04c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: next: specifier: 14.2.3 version: 14.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next-safe-navigation: + specifier: ^0.3.2 + version: 0.3.2(next@14.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.4.5)(zod@3.23.8) postgres: specifier: ^3.4.4 version: 3.4.4 @@ -2190,6 +2193,13 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-safe-navigation@0.3.2: + resolution: {integrity: sha512-Y0HiEsRXe7qgM9TEbGbVkq/X68rKDg+/RkHnacNyRGCm6ZY50ieLpqJaMpzm9xBPytVoOwNHnxE5Mh7RxzQvUg==} + peerDependencies: + next: '>=13.0.0' + typescript: '>=4.8.2' + zod: '>=3.20.0' + next@14.2.3: resolution: {integrity: sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==} engines: {node: '>=18.17.0'} @@ -4868,6 +4878,12 @@ snapshots: natural-compare@1.4.0: {} + next-safe-navigation@0.3.2(next@14.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.4.5)(zod@3.23.8): + dependencies: + next: 14.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + typescript: 5.4.5 + zod: 3.23.8 + next@14.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.3 From 1152bf983c7ea812de1b5edefb6f75ada8bac79b Mon Sep 17 00:00:00 2001 From: Mirfayz Karimoff Date: Mon, 10 Jun 2024 18:25:37 +0500 Subject: [PATCH 5/7] implement safe navigation for the rest of the pages --- lib/routes.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/routes.ts b/lib/routes.ts index 72b6cb5c..2ff7acbd 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -11,6 +11,21 @@ export const { routes, useSafeParams, useSafeSearchParams } = home: defineRoute('/'), signIn: defineRoute('/signin'), signUp: defineRoute('/signup'), + interview: defineRoute('/interview/[interviewId]', { + params: z.object({ + interviewId: z.string(), + }), + }), + interviewFinished: defineRoute('/interview/finished'), + onboardProtocolRoute: defineRoute('/onboard/[protocolId]', { + params: z.object({ + protocolId: z.string(), + }), + }), + onboardError: defineRoute('/onboard/error'), + onboardNoAnonymousRecruitment: defineRoute( + '/onboard/no-anonymous-recruitment', + ), orgDashboard: defineRoute('/[org]', { params: z.object({ org: z.string(), From e3b34504120cf997e0f1c30c98bf757fab36988b Mon Sep 17 00:00:00 2001 From: Mirfayz Karimoff Date: Mon, 10 Jun 2024 18:45:03 +0500 Subject: [PATCH 6/7] remove unused exports from navigation.ts temporarily --- lib/routes.ts | 105 +++++++++++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/lib/routes.ts b/lib/routes.ts index 2ff7acbd..002d41d5 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -6,64 +6,65 @@ import { createNavigationConfig } from 'next-safe-navigation'; import { z } from 'zod'; -export const { routes, useSafeParams, useSafeSearchParams } = - createNavigationConfig((defineRoute) => ({ - home: defineRoute('/'), - signIn: defineRoute('/signin'), - signUp: defineRoute('/signup'), - interview: defineRoute('/interview/[interviewId]', { - params: z.object({ - interviewId: z.string(), - }), +// Todo: add back the useSafeParams and useSafeSearchParams exports below as needed +// deleting them temporarily because of knip errors +export const { routes } = createNavigationConfig((defineRoute) => ({ + home: defineRoute('/'), + signIn: defineRoute('/signin'), + signUp: defineRoute('/signup'), + interview: defineRoute('/interview/[interviewId]', { + params: z.object({ + interviewId: z.string(), }), - interviewFinished: defineRoute('/interview/finished'), - onboardProtocolRoute: defineRoute('/onboard/[protocolId]', { - params: z.object({ - protocolId: z.string(), - }), + }), + interviewFinished: defineRoute('/interview/finished'), + onboardProtocolRoute: defineRoute('/onboard/[protocolId]', { + params: z.object({ + protocolId: z.string(), }), - onboardError: defineRoute('/onboard/error'), - onboardNoAnonymousRecruitment: defineRoute( - '/onboard/no-anonymous-recruitment', - ), - orgDashboard: defineRoute('/[org]', { - params: z.object({ - org: z.string(), - }), + }), + onboardError: defineRoute('/onboard/error'), + onboardNoAnonymousRecruitment: defineRoute( + '/onboard/no-anonymous-recruitment', + ), + orgDashboard: defineRoute('/[org]', { + params: z.object({ + org: z.string(), }), - orgSettings: defineRoute('/[org]/settings', { - params: z.object({ - org: z.string(), - }), + }), + orgSettings: defineRoute('/[org]/settings', { + params: z.object({ + org: z.string(), }), - orgProject: defineRoute('/[org]/[project]', { - params: z.object({ - org: z.string(), - project: z.string(), - }), + }), + orgProject: defineRoute('/[org]/[project]', { + params: z.object({ + org: z.string(), + project: z.string(), }), - orgProjectParticipants: defineRoute('/[org]/[project]/participants', { - params: z.object({ - org: z.string(), - project: z.string(), - }), + }), + orgProjectParticipants: defineRoute('/[org]/[project]/participants', { + params: z.object({ + org: z.string(), + project: z.string(), }), - orgProjectInterviews: defineRoute('/[org]/[project]/interviews', { - params: z.object({ - org: z.string(), - project: z.string(), - }), + }), + orgProjectInterviews: defineRoute('/[org]/[project]/interviews', { + params: z.object({ + org: z.string(), + project: z.string(), }), - orgProjectProtocols: defineRoute('/[org]/[project]/protocols', { - params: z.object({ - org: z.string(), - project: z.string(), - }), + }), + orgProjectProtocols: defineRoute('/[org]/[project]/protocols', { + params: z.object({ + org: z.string(), + project: z.string(), }), - orgProjectSettings: defineRoute('/[org]/[project]/settings', { - params: z.object({ - org: z.string(), - project: z.string(), - }), + }), + orgProjectSettings: defineRoute('/[org]/[project]/settings', { + params: z.object({ + org: z.string(), + project: z.string(), }), - })); + }), +})); From cacf057d44c7a44c9a24cbd5f59f5793ad525b6f Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 10 Jun 2024 17:13:42 +0200 Subject: [PATCH 7/7] finish rebase --- app/page.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 6237c47a..85f25e2f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,14 +1,9 @@ import { getOrganizations } from '~/server/queries/organizations'; import CreateOrgForm from '~/app/[org]/_components/CreateOrgForm'; -<<<<<<< HEAD import { requirePageAuth } from '~/lib/auth'; import SignOutBtn from './_components/SignOutBtn'; -======= -import { requirePageAuth } from '~/utils/auth'; -import SignOutBtn from './_components/SignOutBtn'; import Link from 'next/link'; import { routes } from '~/lib/routes'; ->>>>>>> aaafd9b (Implement type safe routing with next-safe-navigation and adjust the code for most of the pages) export default async function Home() { await requirePageAuth(); @@ -21,11 +16,10 @@ export default async function Home() {

All Organizations