Implement type safe links (#1235)

This commit is contained in:
Mihovil Ilakovac 2023-08-30 14:40:17 +02:00 committed by GitHub
parent 91fd49ae7a
commit 792c014551
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 1356 additions and 172 deletions

View File

@ -1,5 +1,50 @@
# Changelog
## 0.11.3
### 🎉 [New Feature] Type-safe links
Wasp now offers a way to link to pages in your app in a type-safe way. This means that you can't accidentally link to a page that doesn't exist, or pass the wrong arguments to a page.
After you defined your routes:
```wasp
route TaskRoute { path: "/task/:id", to: TaskPage }
```
You can get the benefits of type-safe links by using the `Link` component from `@wasp/router`:
```jsx
import { Link } from '@wasp/router'
export const TaskList = () => {
// ...
return (
<div>
{tasks.map((task) => (
<Link
key={task.id}
to="/task/:id"
{/* 👆 You must provide a valid path here */}
params={{ id: task.id }}>
{/* 👆 All the params must be correctly passed in */}
{task.description}
</Link>
))}
</div>
)
}
```
You can also get all the pages in your app with the `routes` object:
```jsx
import { routes } from '@wasp/router'
const linkToTask = routes.TaskRoute({ params: { id: 1 } })
```
## 0.11.2
### 🐞 Bug fixes / 🔧 small improvements

View File

@ -1,6 +1,12 @@
{{={= =}=}}
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import { interpolatePath } from './router/linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './router/types'
{=# rootComponent.isDefined =}
{=& rootComponent.importStatement =}
{=/ rootComponent.isDefined =}
@ -17,15 +23,43 @@ import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange"
{=/ isExternalAuthEnabled =}
export const routes = {
{=# routes =}
{= name =}: {
to: "{= urlPath =}",
component: {= targetComponent =},
{=# hasUrlParams =}
build: (
options: {
params: {{=# urlParams =}{= name =}{=# isOptional =}?{=/ isOptional =}: ParamValue;{=/ urlParams =}}
} & OptionalRouteOptions,
) => interpolatePath("{= urlPath =}", options.params, options.search, options.hash),
{=/ hasUrlParams =}
{=^ hasUrlParams =}
build: (
options?: OptionalRouteOptions,
) => interpolatePath("{= urlPath =}", undefined, options.search, options.hash),
{=/ hasUrlParams =}
},
{=/ routes =}
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = (
<Router>
{=# rootComponent.isDefined =}
<{= rootComponent.importIdentifier =}>
{=/ rootComponent.isDefined =}
<Switch>
{=# routes =}
<Route exact path="{= urlPath =}" component={ {= targetComponent =} }/>
{=/ routes =}
{Object.entries(routes).map(([routeKey, route]) => (
<Route
exact
key={routeKey}
path={route.to}
component={route.component}
/>
))}
{=# isExternalAuthEnabled =}
{=# externalAuthProviders =}
{=# authProviderEnabled =}
@ -43,3 +77,5 @@ const router = (
)
export default router
export { Link } from './router/Link'

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers'
type RouterLinkProps = Parameters<typeof RouterLink>[0]
export function Link(
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
& {
search?: Record<string, string>;
hash?: string;
}
& Routes
) {
const toPropWithParams = useMemo(() => {
return interpolatePath(to, params, search, hash)
}, [to, params])
return <RouterLink to={toPropWithParams} {...restOfProps} />
}

View File

@ -0,0 +1,44 @@
import type { Params, Search } from "./types";
export function interpolatePath(
path: string,
params?: Params,
search?: Search,
hash?: string
) {
const interpolatedPath = params ? interpolatePathParams(path, params) : path
const interpolatedSearch = search
? `?${new URLSearchParams(search).toString()}`
: ''
const interpolatedHash = hash ? `#${hash}` : ''
return interpolatedPath + interpolatedSearch + interpolatedHash
}
function interpolatePathParams(path: string, params: Params) {
function mapPathPart(part: string) {
if (part.startsWith(":")) {
const paramName = extractParamNameFromPathPart(part);
return params[paramName];
}
return part;
}
const interpolatedPath = path
.split("/")
.map(mapPathPart)
.filter(isValidPathPart)
.join("/");
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
}
function isValidPathPart(part: any): boolean {
return !!part;
}
function extractParamNameFromPathPart(paramString: string) {
if (paramString.endsWith("?")) {
return paramString.slice(1, -1);
}
return paramString.slice(1);
}

View File

@ -0,0 +1,36 @@
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
export type OptionalRouteOptions = {
search?: Search
hash?: string
}
export type ParamValue = string | number
export type Params = { [name: string]: ParamValue }
export type Search =
| string[][]
| Record<string, string>
| string
| URLSearchParams
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
[K in keyof Routes]: {
to: Routes[K]['to']
} & ParamsFromBuildFn<Routes[K]['build']>
}
type RoutesDefinition = {
[name: string]: {
to: string
build: BuildFn
}
}
type BuildFn = (params: unknown) => string
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
params: infer Params
}
? { params: Params }
: { params?: never }

View File

@ -4,7 +4,9 @@
// Temporary loosen the type checking until we can address all the errors.
"jsx": "preserve",
"allowJs": true,
"strict": false
"strict": false,
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -68,7 +68,10 @@ waspBuild/.wasp/build/web-app/src/queries/core.js
waspBuild/.wasp/build/web-app/src/queries/index.d.ts
waspBuild/.wasp/build/web-app/src/queries/index.js
waspBuild/.wasp/build/web-app/src/queryClient.js
waspBuild/.wasp/build/web-app/src/router.jsx
waspBuild/.wasp/build/web-app/src/router.tsx
waspBuild/.wasp/build/web-app/src/router/Link.tsx
waspBuild/.wasp/build/web-app/src/router/linkHelpers.ts
waspBuild/.wasp/build/web-app/src/router/types.ts
waspBuild/.wasp/build/web-app/src/storage.ts
waspBuild/.wasp/build/web-app/src/test/index.ts
waspBuild/.wasp/build/web-app/src/test/vitest/helpers.tsx

View File

@ -492,9 +492,30 @@
[
[
"file",
"web-app/src/router.jsx"
"web-app/src/router.tsx"
],
"c1188ee948f821f5e3e7e741f54b503afffe4bdb24b8f528c32aee0990cf5457"
"79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197"
],
[
[
"file",
"web-app/src/router/Link.tsx"
],
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
],
[
[
"file",
"web-app/src/router/linkHelpers.ts"
],
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
],
[
[
"file",
"web-app/src/router/types.ts"
],
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
],
[
[
@ -564,7 +585,7 @@
"file",
"web-app/tsconfig.json"
],
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
],
[
[

View File

@ -1,16 +0,0 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import MainPage from './ext-src/MainPage.jsx'
const router = (
<Router>
<Switch>
<Route exact path="/" component={ MainPage }/>
</Switch>
</Router>
)
export default router

View File

@ -0,0 +1,43 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import { interpolatePath } from './router/linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './router/types'
import MainPage from './ext-src/MainPage.jsx'
export const routes = {
RootRoute: {
to: "/",
component: MainPage,
build: (
options?: OptionalRouteOptions,
) => interpolatePath("/", undefined, options.search, options.hash),
},
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = (
<Router>
<Switch>
{Object.entries(routes).map(([routeKey, route]) => (
<Route
exact
key={routeKey}
path={route.to}
component={route.component}
/>
))}
</Switch>
</Router>
)
export default router
export { Link } from './router/Link'

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers'
type RouterLinkProps = Parameters<typeof RouterLink>[0]
export function Link(
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
& {
search?: Record<string, string>;
hash?: string;
}
& Routes
) {
const toPropWithParams = useMemo(() => {
return interpolatePath(to, params, search, hash)
}, [to, params])
return <RouterLink to={toPropWithParams} {...restOfProps} />
}

View File

@ -0,0 +1,44 @@
import type { Params, Search } from "./types";
export function interpolatePath(
path: string,
params?: Params,
search?: Search,
hash?: string
) {
const interpolatedPath = params ? interpolatePathParams(path, params) : path
const interpolatedSearch = search
? `?${new URLSearchParams(search).toString()}`
: ''
const interpolatedHash = hash ? `#${hash}` : ''
return interpolatedPath + interpolatedSearch + interpolatedHash
}
function interpolatePathParams(path: string, params: Params) {
function mapPathPart(part: string) {
if (part.startsWith(":")) {
const paramName = extractParamNameFromPathPart(part);
return params[paramName];
}
return part;
}
const interpolatedPath = path
.split("/")
.map(mapPathPart)
.filter(isValidPathPart)
.join("/");
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
}
function isValidPathPart(part: any): boolean {
return !!part;
}
function extractParamNameFromPathPart(paramString: string) {
if (paramString.endsWith("?")) {
return paramString.slice(1, -1);
}
return paramString.slice(1);
}

View File

@ -0,0 +1,36 @@
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
export type OptionalRouteOptions = {
search?: Search
hash?: string
}
export type ParamValue = string | number
export type Params = { [name: string]: ParamValue }
export type Search =
| string[][]
| Record<string, string>
| string
| URLSearchParams
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
[K in keyof Routes]: {
to: Routes[K]['to']
} & ParamsFromBuildFn<Routes[K]['build']>
}
type RoutesDefinition = {
[name: string]: {
to: string
build: BuildFn
}
}
type BuildFn = (params: unknown) => string
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
params: infer Params
}
? { params: Params }
: { params?: never }

View File

@ -4,7 +4,9 @@
// Temporary loosen the type checking until we can address all the errors.
"jsx": "preserve",
"allowJs": true,
"strict": false
"strict": false,
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -70,7 +70,10 @@ waspCompile/.wasp/out/web-app/src/queries/core.js
waspCompile/.wasp/out/web-app/src/queries/index.d.ts
waspCompile/.wasp/out/web-app/src/queries/index.js
waspCompile/.wasp/out/web-app/src/queryClient.js
waspCompile/.wasp/out/web-app/src/router.jsx
waspCompile/.wasp/out/web-app/src/router.tsx
waspCompile/.wasp/out/web-app/src/router/Link.tsx
waspCompile/.wasp/out/web-app/src/router/linkHelpers.ts
waspCompile/.wasp/out/web-app/src/router/types.ts
waspCompile/.wasp/out/web-app/src/storage.ts
waspCompile/.wasp/out/web-app/src/test/index.ts
waspCompile/.wasp/out/web-app/src/test/vitest/helpers.tsx

View File

@ -506,9 +506,30 @@
[
[
"file",
"web-app/src/router.jsx"
"web-app/src/router.tsx"
],
"c1188ee948f821f5e3e7e741f54b503afffe4bdb24b8f528c32aee0990cf5457"
"79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197"
],
[
[
"file",
"web-app/src/router/Link.tsx"
],
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
],
[
[
"file",
"web-app/src/router/linkHelpers.ts"
],
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
],
[
[
"file",
"web-app/src/router/types.ts"
],
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
],
[
[
@ -578,7 +599,7 @@
"file",
"web-app/tsconfig.json"
],
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
],
[
[

View File

@ -1,16 +0,0 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import MainPage from './ext-src/MainPage.jsx'
const router = (
<Router>
<Switch>
<Route exact path="/" component={ MainPage }/>
</Switch>
</Router>
)
export default router

View File

@ -0,0 +1,43 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import { interpolatePath } from './router/linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './router/types'
import MainPage from './ext-src/MainPage.jsx'
export const routes = {
RootRoute: {
to: "/",
component: MainPage,
build: (
options?: OptionalRouteOptions,
) => interpolatePath("/", undefined, options.search, options.hash),
},
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = (
<Router>
<Switch>
{Object.entries(routes).map(([routeKey, route]) => (
<Route
exact
key={routeKey}
path={route.to}
component={route.component}
/>
))}
</Switch>
</Router>
)
export default router
export { Link } from './router/Link'

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers'
type RouterLinkProps = Parameters<typeof RouterLink>[0]
export function Link(
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
& {
search?: Record<string, string>;
hash?: string;
}
& Routes
) {
const toPropWithParams = useMemo(() => {
return interpolatePath(to, params, search, hash)
}, [to, params])
return <RouterLink to={toPropWithParams} {...restOfProps} />
}

View File

@ -0,0 +1,44 @@
import type { Params, Search } from "./types";
export function interpolatePath(
path: string,
params?: Params,
search?: Search,
hash?: string
) {
const interpolatedPath = params ? interpolatePathParams(path, params) : path
const interpolatedSearch = search
? `?${new URLSearchParams(search).toString()}`
: ''
const interpolatedHash = hash ? `#${hash}` : ''
return interpolatedPath + interpolatedSearch + interpolatedHash
}
function interpolatePathParams(path: string, params: Params) {
function mapPathPart(part: string) {
if (part.startsWith(":")) {
const paramName = extractParamNameFromPathPart(part);
return params[paramName];
}
return part;
}
const interpolatedPath = path
.split("/")
.map(mapPathPart)
.filter(isValidPathPart)
.join("/");
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
}
function isValidPathPart(part: any): boolean {
return !!part;
}
function extractParamNameFromPathPart(paramString: string) {
if (paramString.endsWith("?")) {
return paramString.slice(1, -1);
}
return paramString.slice(1);
}

View File

@ -0,0 +1,36 @@
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
export type OptionalRouteOptions = {
search?: Search
hash?: string
}
export type ParamValue = string | number
export type Params = { [name: string]: ParamValue }
export type Search =
| string[][]
| Record<string, string>
| string
| URLSearchParams
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
[K in keyof Routes]: {
to: Routes[K]['to']
} & ParamsFromBuildFn<Routes[K]['build']>
}
type RoutesDefinition = {
[name: string]: {
to: string
build: BuildFn
}
}
type BuildFn = (params: unknown) => string
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
params: infer Params
}
? { params: Params }
: { params?: never }

View File

@ -4,7 +4,9 @@
// Temporary loosen the type checking until we can address all the errors.
"jsx": "preserve",
"allowJs": true,
"strict": false
"strict": false,
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -130,7 +130,10 @@ waspComplexTest/.wasp/out/web-app/src/queries/core.js
waspComplexTest/.wasp/out/web-app/src/queries/index.d.ts
waspComplexTest/.wasp/out/web-app/src/queries/index.js
waspComplexTest/.wasp/out/web-app/src/queryClient.js
waspComplexTest/.wasp/out/web-app/src/router.jsx
waspComplexTest/.wasp/out/web-app/src/router.tsx
waspComplexTest/.wasp/out/web-app/src/router/Link.tsx
waspComplexTest/.wasp/out/web-app/src/router/linkHelpers.ts
waspComplexTest/.wasp/out/web-app/src/router/types.ts
waspComplexTest/.wasp/out/web-app/src/stitches.config.js
waspComplexTest/.wasp/out/web-app/src/storage.ts
waspComplexTest/.wasp/out/web-app/src/test/index.ts

View File

@ -905,9 +905,30 @@
[
[
"file",
"web-app/src/router.jsx"
"web-app/src/router.tsx"
],
"ba66454c9f0ca79ed5bc6af694b5b9f748db75cf82309f2600c78069a3b9d0f7"
"1b167573635d206d53a8598ae39a81b140cd017c6c71ce13bf57c9a04b5b8160"
],
[
[
"file",
"web-app/src/router/Link.tsx"
],
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
],
[
[
"file",
"web-app/src/router/linkHelpers.ts"
],
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
],
[
[
"file",
"web-app/src/router/types.ts"
],
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
],
[
[
@ -984,7 +1005,7 @@
"file",
"web-app/tsconfig.json"
],
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
],
[
[

View File

@ -1,24 +0,0 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import App from './ext-src/App.jsx'
import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
import MainPage from './ext-src/MainPage.jsx'
import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange"
const router = (
<Router>
<App>
<Switch>
<Route exact path="/" component={ MainPage }/>
<Route exact path="/auth/login/google">
<OAuthCodeExchange pathToApiServerRouteHandlingOauthRedirect="/auth/google/callback" />
</Route>
</Switch>
</App>
</Router>
)
export default router

View File

@ -0,0 +1,51 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import { interpolatePath } from './router/linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './router/types'
import App from './ext-src/App.jsx'
import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
import MainPage from './ext-src/MainPage.jsx'
import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange"
export const routes = {
RootRoute: {
to: "/",
component: MainPage,
build: (
options?: OptionalRouteOptions,
) => interpolatePath("/", undefined, options.search, options.hash),
},
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = (
<Router>
<App>
<Switch>
{Object.entries(routes).map(([routeKey, route]) => (
<Route
exact
key={routeKey}
path={route.to}
component={route.component}
/>
))}
<Route exact path="/auth/login/google">
<OAuthCodeExchange pathToApiServerRouteHandlingOauthRedirect="/auth/google/callback" />
</Route>
</Switch>
</App>
</Router>
)
export default router
export { Link } from './router/Link'

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers'
type RouterLinkProps = Parameters<typeof RouterLink>[0]
export function Link(
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
& {
search?: Record<string, string>;
hash?: string;
}
& Routes
) {
const toPropWithParams = useMemo(() => {
return interpolatePath(to, params, search, hash)
}, [to, params])
return <RouterLink to={toPropWithParams} {...restOfProps} />
}

View File

@ -0,0 +1,44 @@
import type { Params, Search } from "./types";
export function interpolatePath(
path: string,
params?: Params,
search?: Search,
hash?: string
) {
const interpolatedPath = params ? interpolatePathParams(path, params) : path
const interpolatedSearch = search
? `?${new URLSearchParams(search).toString()}`
: ''
const interpolatedHash = hash ? `#${hash}` : ''
return interpolatedPath + interpolatedSearch + interpolatedHash
}
function interpolatePathParams(path: string, params: Params) {
function mapPathPart(part: string) {
if (part.startsWith(":")) {
const paramName = extractParamNameFromPathPart(part);
return params[paramName];
}
return part;
}
const interpolatedPath = path
.split("/")
.map(mapPathPart)
.filter(isValidPathPart)
.join("/");
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
}
function isValidPathPart(part: any): boolean {
return !!part;
}
function extractParamNameFromPathPart(paramString: string) {
if (paramString.endsWith("?")) {
return paramString.slice(1, -1);
}
return paramString.slice(1);
}

View File

@ -0,0 +1,36 @@
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
export type OptionalRouteOptions = {
search?: Search
hash?: string
}
export type ParamValue = string | number
export type Params = { [name: string]: ParamValue }
export type Search =
| string[][]
| Record<string, string>
| string
| URLSearchParams
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
[K in keyof Routes]: {
to: Routes[K]['to']
} & ParamsFromBuildFn<Routes[K]['build']>
}
type RoutesDefinition = {
[name: string]: {
to: string
build: BuildFn
}
}
type BuildFn = (params: unknown) => string
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
params: infer Params
}
? { params: Params }
: { params?: never }

View File

@ -4,7 +4,9 @@
// Temporary loosen the type checking until we can address all the errors.
"jsx": "preserve",
"allowJs": true,
"strict": false
"strict": false,
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -73,7 +73,10 @@ waspJob/.wasp/out/web-app/src/queries/core.js
waspJob/.wasp/out/web-app/src/queries/index.d.ts
waspJob/.wasp/out/web-app/src/queries/index.js
waspJob/.wasp/out/web-app/src/queryClient.js
waspJob/.wasp/out/web-app/src/router.jsx
waspJob/.wasp/out/web-app/src/router.tsx
waspJob/.wasp/out/web-app/src/router/Link.tsx
waspJob/.wasp/out/web-app/src/router/linkHelpers.ts
waspJob/.wasp/out/web-app/src/router/types.ts
waspJob/.wasp/out/web-app/src/storage.ts
waspJob/.wasp/out/web-app/src/test/index.ts
waspJob/.wasp/out/web-app/src/test/vitest/helpers.tsx

View File

@ -520,9 +520,30 @@
[
[
"file",
"web-app/src/router.jsx"
"web-app/src/router.tsx"
],
"c1188ee948f821f5e3e7e741f54b503afffe4bdb24b8f528c32aee0990cf5457"
"79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197"
],
[
[
"file",
"web-app/src/router/Link.tsx"
],
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
],
[
[
"file",
"web-app/src/router/linkHelpers.ts"
],
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
],
[
[
"file",
"web-app/src/router/types.ts"
],
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
],
[
[
@ -592,7 +613,7 @@
"file",
"web-app/tsconfig.json"
],
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
],
[
[

View File

@ -1,16 +0,0 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import MainPage from './ext-src/MainPage.jsx'
const router = (
<Router>
<Switch>
<Route exact path="/" component={ MainPage }/>
</Switch>
</Router>
)
export default router

View File

@ -0,0 +1,43 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import { interpolatePath } from './router/linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './router/types'
import MainPage from './ext-src/MainPage.jsx'
export const routes = {
RootRoute: {
to: "/",
component: MainPage,
build: (
options?: OptionalRouteOptions,
) => interpolatePath("/", undefined, options.search, options.hash),
},
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = (
<Router>
<Switch>
{Object.entries(routes).map(([routeKey, route]) => (
<Route
exact
key={routeKey}
path={route.to}
component={route.component}
/>
))}
</Switch>
</Router>
)
export default router
export { Link } from './router/Link'

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers'
type RouterLinkProps = Parameters<typeof RouterLink>[0]
export function Link(
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
& {
search?: Record<string, string>;
hash?: string;
}
& Routes
) {
const toPropWithParams = useMemo(() => {
return interpolatePath(to, params, search, hash)
}, [to, params])
return <RouterLink to={toPropWithParams} {...restOfProps} />
}

View File

@ -0,0 +1,44 @@
import type { Params, Search } from "./types";
export function interpolatePath(
path: string,
params?: Params,
search?: Search,
hash?: string
) {
const interpolatedPath = params ? interpolatePathParams(path, params) : path
const interpolatedSearch = search
? `?${new URLSearchParams(search).toString()}`
: ''
const interpolatedHash = hash ? `#${hash}` : ''
return interpolatedPath + interpolatedSearch + interpolatedHash
}
function interpolatePathParams(path: string, params: Params) {
function mapPathPart(part: string) {
if (part.startsWith(":")) {
const paramName = extractParamNameFromPathPart(part);
return params[paramName];
}
return part;
}
const interpolatedPath = path
.split("/")
.map(mapPathPart)
.filter(isValidPathPart)
.join("/");
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
}
function isValidPathPart(part: any): boolean {
return !!part;
}
function extractParamNameFromPathPart(paramString: string) {
if (paramString.endsWith("?")) {
return paramString.slice(1, -1);
}
return paramString.slice(1);
}

View File

@ -0,0 +1,36 @@
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
export type OptionalRouteOptions = {
search?: Search
hash?: string
}
export type ParamValue = string | number
export type Params = { [name: string]: ParamValue }
export type Search =
| string[][]
| Record<string, string>
| string
| URLSearchParams
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
[K in keyof Routes]: {
to: Routes[K]['to']
} & ParamsFromBuildFn<Routes[K]['build']>
}
type RoutesDefinition = {
[name: string]: {
to: string
build: BuildFn
}
}
type BuildFn = (params: unknown) => string
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
params: infer Params
}
? { params: Params }
: { params?: never }

View File

@ -4,7 +4,9 @@
// Temporary loosen the type checking until we can address all the errors.
"jsx": "preserve",
"allowJs": true,
"strict": false
"strict": false,
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -75,7 +75,10 @@ waspMigrate/.wasp/out/web-app/src/queries/core.js
waspMigrate/.wasp/out/web-app/src/queries/index.d.ts
waspMigrate/.wasp/out/web-app/src/queries/index.js
waspMigrate/.wasp/out/web-app/src/queryClient.js
waspMigrate/.wasp/out/web-app/src/router.jsx
waspMigrate/.wasp/out/web-app/src/router.tsx
waspMigrate/.wasp/out/web-app/src/router/Link.tsx
waspMigrate/.wasp/out/web-app/src/router/linkHelpers.ts
waspMigrate/.wasp/out/web-app/src/router/types.ts
waspMigrate/.wasp/out/web-app/src/storage.ts
waspMigrate/.wasp/out/web-app/src/test/index.ts
waspMigrate/.wasp/out/web-app/src/test/vitest/helpers.tsx

View File

@ -506,9 +506,30 @@
[
[
"file",
"web-app/src/router.jsx"
"web-app/src/router.tsx"
],
"c1188ee948f821f5e3e7e741f54b503afffe4bdb24b8f528c32aee0990cf5457"
"79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197"
],
[
[
"file",
"web-app/src/router/Link.tsx"
],
"7b6214295d59d8dffbd61b82f9dab2b080b2d7ebe98cc7d9f9e8c229f99a890d"
],
[
[
"file",
"web-app/src/router/linkHelpers.ts"
],
"c296ed5e7924ad1173f4f0fb4dcce053cffd5812612069b5f62d1bf9e96495cf"
],
[
[
"file",
"web-app/src/router/types.ts"
],
"7f08b262987c17f953c4b95814631a7aaac82eb77660bcb247ef7bf866846fe1"
],
[
[
@ -578,7 +599,7 @@
"file",
"web-app/tsconfig.json"
],
"887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
"f1b31ca75b2b32c5b0441aec4fcd7f285c18346761ba1640761d6253d65e3580"
],
[
[

View File

@ -1,16 +0,0 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import MainPage from './ext-src/MainPage.jsx'
const router = (
<Router>
<Switch>
<Route exact path="/" component={ MainPage }/>
</Switch>
</Router>
)
export default router

View File

@ -0,0 +1,43 @@
import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import { interpolatePath } from './router/linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './router/types'
import MainPage from './ext-src/MainPage.jsx'
export const routes = {
RootRoute: {
to: "/",
component: MainPage,
build: (
options?: OptionalRouteOptions,
) => interpolatePath("/", undefined, options.search, options.hash),
},
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = (
<Router>
<Switch>
{Object.entries(routes).map(([routeKey, route]) => (
<Route
exact
key={routeKey}
path={route.to}
component={route.component}
/>
))}
</Switch>
</Router>
)
export default router
export { Link } from './router/Link'

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers'
type RouterLinkProps = Parameters<typeof RouterLink>[0]
export function Link(
{ to, params, search, hash, ...restOfProps }: Omit<RouterLinkProps, "to">
& {
search?: Record<string, string>;
hash?: string;
}
& Routes
) {
const toPropWithParams = useMemo(() => {
return interpolatePath(to, params, search, hash)
}, [to, params])
return <RouterLink to={toPropWithParams} {...restOfProps} />
}

View File

@ -0,0 +1,44 @@
import type { Params, Search } from "./types";
export function interpolatePath(
path: string,
params?: Params,
search?: Search,
hash?: string
) {
const interpolatedPath = params ? interpolatePathParams(path, params) : path
const interpolatedSearch = search
? `?${new URLSearchParams(search).toString()}`
: ''
const interpolatedHash = hash ? `#${hash}` : ''
return interpolatedPath + interpolatedSearch + interpolatedHash
}
function interpolatePathParams(path: string, params: Params) {
function mapPathPart(part: string) {
if (part.startsWith(":")) {
const paramName = extractParamNameFromPathPart(part);
return params[paramName];
}
return part;
}
const interpolatedPath = path
.split("/")
.map(mapPathPart)
.filter(isValidPathPart)
.join("/");
return path.startsWith("/") ? `/${interpolatedPath}` : interpolatedPath;
}
function isValidPathPart(part: any): boolean {
return !!part;
}
function extractParamNameFromPathPart(paramString: string) {
if (paramString.endsWith("?")) {
return paramString.slice(1, -1);
}
return paramString.slice(1);
}

View File

@ -0,0 +1,36 @@
export type RouteDefinitionsToRoutes<Routes extends RoutesDefinition> =
RouteDefinitionsToRoutesObj<Routes>[keyof RouteDefinitionsToRoutesObj<Routes>]
export type OptionalRouteOptions = {
search?: Search
hash?: string
}
export type ParamValue = string | number
export type Params = { [name: string]: ParamValue }
export type Search =
| string[][]
| Record<string, string>
| string
| URLSearchParams
type RouteDefinitionsToRoutesObj<Routes extends RoutesDefinition> = {
[K in keyof Routes]: {
to: Routes[K]['to']
} & ParamsFromBuildFn<Routes[K]['build']>
}
type RoutesDefinition = {
[name: string]: {
to: string
build: BuildFn
}
}
type BuildFn = (params: unknown) => string
type ParamsFromBuildFn<BF extends BuildFn> = Parameters<BF>[0] extends {
params: infer Params
}
? { params: Params }
: { params?: never }

View File

@ -4,7 +4,9 @@
// Temporary loosen the type checking until we can address all the errors.
"jsx": "preserve",
"allowJs": true,
"strict": false
"strict": false,
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -31,7 +31,7 @@ page SignupPage {
component: import { SignupPage } from "@client/SignupPage.tsx",
}
route DetailRoute { path: "/:id", to: DetailPage }
route DetailRoute { path: "/:id/:something?", to: DetailPage }
page DetailPage {
component: import Main from "@client/DetailPage.tsx",
authRequired: true,

View File

@ -1,7 +1,8 @@
import "./Main.css";
import React from "react";
import { Link, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { Link } from "@wasp/router";
import { tasks as tasksCrud } from "@wasp/crud/tasks";

View File

@ -1,4 +1,5 @@
import { LoginForm } from "@wasp/auth/forms/Login";
import { Link } from "@wasp/router";
export const LoginPage = () => {
return (
@ -6,6 +7,9 @@ export const LoginPage = () => {
<main>
<h1>Login</h1>
<LoginForm />
<div>
<Link to="/signup">Sign up</Link>
</div>
</main>
</div>
);

View File

@ -1,13 +1,16 @@
import "./Main.css";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { Link, routes } from "@wasp/router";
import { tasks as tasksCrud } from "@wasp/crud/tasks";
import { User, Task } from "@wasp/entities";
import { User } from "@wasp/entities";
const MainPage = ({ user }: { user: User }) => {
const { data: tasks, isLoading } = tasksCrud.getAll.useQuery();
type Task = NonNullable<typeof tasks>[number];
const createTask = tasksCrud.create.useAction();
const deleteTask = tasksCrud.delete.useAction();
const updateTask = tasksCrud.update.useAction();
@ -86,8 +89,14 @@ const MainPage = ({ user }: { user: User }) => {
) : (
<>
<div className="task__title">
<Link to={`/${task.id}`}>
{JSON.stringify(task, null, 2)}
<Link
to="/:id/:something?"
params={{ id: task.id, something: "else" }}
>
Visit {task.title} at{" "}
{routes.DetailRoute.build({
params: { id: task.id, something: "else" },
})}
</Link>
</div>
<button onClick={() => handleTaskDelete(task)}>Delete</button>

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import logout from '@wasp/auth/logout'
import useAuth from '@wasp/auth/useAuth'

View File

@ -1,5 +1,5 @@
import React, { useState, FormEventHandler, ChangeEventHandler } from 'react'
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { useQuery } from '@wasp/queries'
import { OptimisticUpdateDefinition, useAction } from '@wasp/actions'
@ -138,7 +138,9 @@ const TaskView = ({ task }: { task: Task }) => {
/>
</td>
<td>
<Link to={`/task/${task.id}`}> {task.description} </Link>
<Link to="/task/:id" params={{ id: task.id }}>
{task.description}
</Link>
</td>
</tr>
)

View File

@ -1,14 +1,14 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
const About = () => {
return (
<>
<div>I am About page!</div>
<Link to='/'>Go to dashboard</Link>
<Link to="/">Go to dashboard</Link>
</>
)
}
export default About;
export default About

View File

@ -1,8 +1,12 @@
import React, { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, routes } from '@wasp/router'
import { User } from '@wasp/auth/types'
import api from '@wasp/api'
import { useSocket, useSocketListener, ServerToClientPayload } from '@wasp/webSocket'
import {
useSocket,
useSocketListener,
ServerToClientPayload,
} from '@wasp/webSocket'
async function fetchCustomRoute() {
const res = await api.get('/foo/bar')
@ -14,7 +18,9 @@ export const ProfilePage = ({
}: {
user: User
}) => {
const [messages, setMessages] = useState<ServerToClientPayload<'chatMessage'>[]>([])
const [messages, setMessages] = useState<
ServerToClientPayload<'chatMessage'>[]
>([])
const { socket, isConnected } = useSocket()
const inputRef = useRef<HTMLInputElement>(null)
@ -22,7 +28,9 @@ export const ProfilePage = ({
fetchCustomRoute()
}, [])
useSocketListener('chatMessage', (msg) => setMessages((priorMessages) => [msg, ...priorMessages]))
useSocketListener('chatMessage', (msg) =>
setMessages((priorMessages) => [msg, ...priorMessages])
)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@ -48,7 +56,17 @@ export const ProfilePage = ({
<strong>{isEmailVerified ? 'verfied' : 'unverified'}</strong>.
</div>
<br />
<Link to="/">Go to dashboard</Link>
<Link to="/task/:id" params={{ id: 3 }}>
Task 3
</Link>
<p>
Route is{' '}
{routes.TaskRoute.build({
params: { id: 5 },
search: { google: 'true' },
hash: 'Miho',
})}
</p>
<div>
<form onSubmit={handleSubmit}>
<div className="flex space-x-4 place-items-center">

View File

@ -1,5 +1,5 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { useQuery } from '@wasp/queries'
import { OptimisticUpdateDefinition, useAction } from '@wasp/actions'
@ -8,7 +8,7 @@ import getTask from '@wasp/queries/getTask.js'
import getTasks from '@wasp/queries/getTasks.js'
import { Task } from '@wasp/entities'
type TaskPayload = Pick<Task, "id" | "isDone">
type TaskPayload = Pick<Task, 'id' | 'isDone'>
const Todo = (props: any) => {
const taskId = parseInt(props.match.params.id)
@ -25,11 +25,12 @@ const Todo = (props: any) => {
{
getQuerySpecifier: () => [getTasks],
updateQuery: (updatedTask, oldTasks) =>
oldTasks && oldTasks.map(task =>
oldTasks &&
oldTasks.map((task) =>
task.id === updatedTask.id ? { ...task, ...updatedTask } : task
),
} as OptimisticUpdateDefinition<TaskPayload, Task[]>
]
} as OptimisticUpdateDefinition<TaskPayload, Task[]>,
],
})
if (!task) return <div> Task with id {taskId} does not exist. </div>;
@ -53,11 +54,13 @@ const Todo = (props: any) => {
<div> id: {task.id} </div>
<div> description: {task.description} </div>
<div> is done: {task.isDone ? 'Yes' : 'No'} </div>
<button onClick={() => toggleIsDone(task)}>Mark as {task.isDone ? 'undone' : 'done'}</button>
<button onClick={() => toggleIsDone(task)}>
Mark as {task.isDone ? 'undone' : 'done'}
</button>
</>
)}
<br />
<Link to='/'>Go to dashboard</Link>
<Link to="/">Go to dashboard</Link>
</>
)
}

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail'
import appearance from './appearance'

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { LoginForm } from '@wasp/auth/forms/Login'

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword'
import appearance from './appearance'

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { SignupForm } from '@wasp/auth/forms/Signup'
import getNumTasks from '@wasp/queries/getNumTasks'

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import logout from '@wasp/auth/logout'
import useAuth from '@wasp/auth/useAuth'

View File

@ -1,5 +1,5 @@
import React, { useState, FormEventHandler, ChangeEventHandler } from 'react'
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { useQuery } from '@wasp/queries'
import { OptimisticUpdateDefinition, useAction } from '@wasp/actions'

View File

@ -1,14 +1,14 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
const About = () => {
return (
<>
<div>I am About page!</div>
<Link to='/'>Go to dashboard</Link>
<Link to="/">Go to dashboard</Link>
</>
)
}
export default About;
export default About

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { User } from '@wasp/entities'
import api from '@wasp/api'

View File

@ -1,5 +1,5 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { useQuery } from '@wasp/queries'
import { OptimisticUpdateDefinition, useAction } from '@wasp/actions'
@ -8,7 +8,7 @@ import getTask from '@wasp/queries/getTask.js'
import getTasks from '@wasp/queries/getTasks.js'
import { Task } from '@wasp/entities'
type TaskPayload = Pick<Task, "id" | "isDone">
type TaskPayload = Pick<Task, 'id' | 'isDone'>
const Todo = (props: any) => {
const taskId = parseInt(props.match.params.id)
@ -25,11 +25,12 @@ const Todo = (props: any) => {
{
getQuerySpecifier: () => [getTasks],
updateQuery: (updatedTask, oldTasks) =>
oldTasks && oldTasks.map(task =>
oldTasks &&
oldTasks.map((task) =>
task.id === updatedTask.id ? { ...task, ...updatedTask } : task
),
} as OptimisticUpdateDefinition<TaskPayload, Task[]>
]
} as OptimisticUpdateDefinition<TaskPayload, Task[]>,
],
})
if (!task) return <div>Task with id {taskId} does not exist.</div>
@ -53,11 +54,13 @@ const Todo = (props: any) => {
<div> id: {task.id} </div>
<div> description: {task.description} </div>
<div> is done: {task.isDone ? 'Yes' : 'No'} </div>
<button onClick={() => toggleIsDone(task)}>Mark as {task.isDone ? 'undone' : 'done'}</button>
<button onClick={() => toggleIsDone(task)}>
Mark as {task.isDone ? 'undone' : 'done'}
</button>
</>
)}
<br />
<Link to='/'>Go to dashboard</Link>
<Link to="/">Go to dashboard</Link>
</>
)
}

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail'
import appearance from './appearance'

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { LoginForm } from '@wasp/auth/forms/Login'

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword'
import appearance from './appearance'

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link } from '@wasp/router'
import { SignupForm } from '@wasp/auth/forms/Signup'
import getNumTasks from '@wasp/queries/getNumTasks'

View File

@ -250,13 +250,13 @@ genSrcDir spec =
genFileCopy [relfile|api.ts|],
genFileCopy [relfile|api/events.ts|],
genFileCopy [relfile|storage.ts|],
genRouter spec,
getIndexTs spec
]
<++> genOperations spec
<++> genEntitiesDir spec
<++> genAuth spec
<++> genWebSockets spec
<++> genRouter spec
where
genFileCopy = return . C.mkSrcTmplFd

View File

@ -28,6 +28,7 @@ import Wasp.Generator.WebAppGenerator.Common (asTmplFile, asWebAppSrcFile)
import qualified Wasp.Generator.WebAppGenerator.Common as C
import Wasp.Generator.WebAppGenerator.JsImport (extImportToImportJson, extImportToJsImport)
import Wasp.JsImport (applyJsImportAlias, getJsImportStmtAndIdentifier)
import Wasp.Util.WebRouterPath (Param (Optional, Required), extractPathParams)
data RouterTemplateData = RouterTemplateData
{ _routes :: ![RouteTemplateData],
@ -50,14 +51,19 @@ instance ToJSON RouterTemplateData where
]
data RouteTemplateData = RouteTemplateData
{ _urlPath :: !String,
{ _routeName :: !String,
_urlPath :: !String,
_urlParams :: ![Param],
_targetComponent :: !String
}
instance ToJSON RouteTemplateData where
toJSON routeTD =
object
[ "urlPath" .= _urlPath routeTD,
[ "name" .= _routeName routeTD,
"urlPath" .= _urlPath routeTD,
"urlParams" .= map mapPathParamToJson (_urlParams routeTD),
"hasUrlParams" .= (not . null $ _urlParams routeTD),
"targetComponent" .= _targetComponent routeTD
]
@ -87,15 +93,26 @@ instance ToJSON ExternalAuthProviderTemplateData where
"authProviderEnabled" .= _authProviderEnabled externalProviderTD
]
genRouter :: AppSpec -> Generator FileDraft
genRouter spec = do
genRouter :: AppSpec -> Generator [FileDraft]
genRouter spec =
sequence
[ genRouterTsx spec,
genFileCopy [relfile|src/router/types.ts|],
genFileCopy [relfile|src/router/linkHelpers.ts|],
genFileCopy [relfile|src/router/Link.tsx|]
]
where
genFileCopy = return . C.mkTmplFd
genRouterTsx :: AppSpec -> Generator FileDraft
genRouterTsx spec = do
return $
C.mkTmplFdWithDstAndData
(asTmplFile $ [reldir|src|] </> routerPath)
targetPath
(Just $ toJSON templateData)
where
routerPath = [relfile|router.jsx|]
routerPath = [relfile|router.tsx|]
templateData = createRouterTemplateData spec
targetPath = C.webAppSrcDirInWebAppRootDir </> asWebAppSrcFile routerPath
@ -133,11 +150,15 @@ createExternalAuthProviderTemplateData maybeAuth (method, provider) =
}
createRouteTemplateData :: AppSpec -> (String, AS.Route.Route) -> RouteTemplateData
createRouteTemplateData spec namedRoute@(_, route) =
createRouteTemplateData spec namedRoute@(name, route) =
RouteTemplateData
{ _urlPath = AS.Route.path route,
{ _routeName = name,
_urlPath = path,
_urlParams = extractPathParams path,
_targetComponent = determineRouteTargetComponent spec namedRoute
}
where
path = AS.Route.path route
-- NOTE: This should be prevented by Analyzer, so use error since it should not be possible
determineRouteTargetComponent :: AppSpec -> (String, AS.Route.Route) -> String
@ -151,7 +172,8 @@ determineRouteTargetComponent spec (_, route) =
targetPage =
fromMaybe
( error $
"Can't find page with name '" ++ targetPageName
"Can't find page with name '"
++ targetPageName
++ "', pointed to by route '"
++ AS.Route.path route
++ "'"
@ -182,3 +204,7 @@ createPageTemplateData page =
relPathToWebAppSrcDir :: Path Posix (Rel importLocation) (Dir C.WebAppSrcDir)
relPathToWebAppSrcDir = [reldirP|./|]
mapPathParamToJson :: Param -> Aeson.Value
mapPathParamToJson (Required name) = object ["name" .= name, "isOptional" .= False]
mapPathParamToJson (Optional name) = object ["name" .= name, "isOptional" .= True]

View File

@ -0,0 +1,18 @@
module Wasp.Util.WebRouterPath where
import Data.List (isSuffixOf)
import Data.List.Split (splitOn)
import Data.Maybe (mapMaybe)
data Param = Optional String | Required String deriving (Show, Eq)
extractPathParams :: String -> [Param]
extractPathParams = mapMaybe parseParam . splitOn "/"
where
parseParam :: String -> Maybe Param
parseParam (':' : xs) =
Just $
if "?" `isSuffixOf` xs
then Optional (take (length xs - 1) xs)
else Required xs
parseParam _ = Nothing

View File

@ -81,7 +81,7 @@ spec_WebAppGenerator = do
(SP.toFilePath Common.webAppSrcDirInWebAppRootDir </>)
[ "logo.png",
"index.tsx",
"router.jsx"
"router.tsx"
]
]

View File

@ -336,6 +336,7 @@ library
Wasp.Util.FilePath
Wasp.Util.StrongPath
Wasp.Util.HashMap
Wasp.Util.WebRouterPath
Wasp.WaspignoreFile
Wasp.Generator.NpmDependencies
Wasp.Generator.NpmInstall

134
web/docs/advanced/links.md Normal file
View File

@ -0,0 +1,134 @@
---
title: Type-Safe Links
---
import { Required } from '@site/src/components/Required'
If you are using Typescript, you can use Wasp's custom `Link` component to create type-safe links to other pages on your site.
## Using the `Link` Component
After you defined a route:
```wasp title="main.wasp"
route TaskRoute { path: "/task/:id", to: TaskPage }
page TaskPage { ... }
```
You can get the benefits of type-safe links by using the `Link` component from `@wasp/router`:
```jsx title="TaskList.tsx"
import { Link } from '@wasp/router'
export const TaskList = () => {
// ...
return (
<div>
{tasks.map((task) => (
<Link
key={task.id}
to="/task/:id"
{/* 👆 You must provide a valid path here */}
params={{ id: task.id }}>
{/* 👆 All the params must be correctly passed in */}
{task.description}
</Link>
))}
</div>
)
}
```
### Using Search Query & Hash
You can also pass `search` and `hash` props to the `Link` component:
```tsx title="TaskList.tsx"
<Link
to="/task/:id"
params={{ id: task.id }}
search={{ sortBy: 'date' }}
hash="comments"
>
{task.description}
</Link>
```
This will result in a link like this: `/task/1?sortBy=date#comments`. Check out the [API Reference](#link-component) for more details.
## The `routes` Object
You can also get all the pages in your app with the `routes` object:
```jsx title="TaskList.tsx"
import { routes } from '@wasp/router'
const linkToTask = routes.TaskRoute.build({ params: { id: 1 } })
```
This will result in a link like this: `/task/1`.
You can also pass `search` and `hash` props to the `build` function. Check out the [API Reference](#routes-object) for more details.
## API Reference
### `Link` Component
The `Link` component accepts the following props:
- `to` <Required />
- A valid Wasp Route path from your `main.wasp` file.
- `params: { [name: string]: string | number }` <Required /> (if the path contains params)
- An object with keys and values for each param in the path.
- For example, if the path is `/task/:id`, then the `params` prop must be `{ id: 1 }`. Wasp supports required and optional params.
- `search: string[][] | Record<string, string> | string | URLSearchParams`
- Any valid input for `URLSearchParams` constructor.
- For example, the object `{ sortBy: 'date' }` becomes `?sortBy=date`.
- `hash: string`
- all other props that the `react-router-dom`'s [Link](https://v5.reactrouter.com/web/api/Link) component accepts
### `routes` Object
The `routes` object contains a function for each route in your app.
```ts title="router.tsx"
export const routes = {
// RootRoute has a path like "/"
RootRoute: {
build: (options?: {
search?: string[][] | Record<string, string> | string | URLSearchParams
hash?: string
}) => // ...
},
// DetailRoute has a path like "/task/:id/:something?"
DetailRoute: {
build: (
options: {
params: { id: ParamValue; something?: ParamValue; },
search?: string[][] | Record<string, string> | string | URLSearchParams
hash?: string
}
) => // ...
}
}
```
The `params` object is required if the route contains params. The `search` and `hash` parameters are optional.
You can use the `routes` object like this:
```tsx
import { routes } from '@wasp/router'
const linkToRoot = routes.RootRoute.build()
const linkToTask = routes.DetailRoute.build({ params: { id: 1 } })
```

View File

@ -139,6 +139,13 @@ export default HelloPage
Now you can visit `/hello/johnny` and see "Here's johnny!"
<ShowForTs>
:::tip Type-safe links
Since you are using Typescript, you can benefit from using Wasp's type-safe `Link` component and the `routes` object. Check out the [type-safe links docs](/docs/advanced/links) for more details.
:::
</ShowForTs>
## Cleaning Up
Let's prepare for building the Todo app by cleaning up the project and removing files and code we won't need. Start by deleting `Main.css`, `waspLogo.png`, and `HelloPage.{jsx,tsx}` that we just created in the `src/client/` folder.

View File

@ -3,6 +3,7 @@ title: 7. Adding Authentication
---
import useBaseUrl from '@docusaurus/useBaseUrl';
import { ShowForTs } from '@site/src/components/TsJsHelpers';
Most apps today require some sort of registration and login flow, so Wasp has first-class support for it. Let's add it to our Todo app!
@ -216,6 +217,14 @@ export default SignupPage
</TabItem>
</Tabs>
<ShowForTs>
:::tip Type-safe links
Since you are using Typescript, you can benefit from using Wasp's type-safe `Link` component and the `routes` object. Check out the [type-safe links docs](/docs/advanced/links) for more details.
:::
</ShowForTs>
## Update the Main Page to Require Auth
We don't want users who are not logged in to access the main page, because they won't be able to create any tasks. So let's make the page private by requiring the user to be logged in:

View File

@ -107,6 +107,7 @@ module.exports = {
'advanced/web-sockets',
'advanced/apis',
'advanced/middleware-config',
'advanced/links',
],
},
{