mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-09-11 15:26:19 +03:00
Implement type safe links (#1235)
This commit is contained in:
parent
91fd49ae7a
commit
792c014551
@ -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
|
||||
|
@ -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'
|
20
waspc/data/Generator/templates/react-app/src/router/Link.tsx
Normal file
20
waspc/data/Generator/templates/react-app/src/router/Link.tsx
Normal 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} />
|
||||
}
|
@ -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);
|
||||
}
|
36
waspc/data/Generator/templates/react-app/src/router/types.ts
Normal file
36
waspc/data/Generator/templates/react-app/src/router/types.ts
Normal 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 }
|
@ -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" }]
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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
|
43
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx
generated
Normal file
43
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx
generated
Normal 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'
|
20
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/Link.tsx
generated
Normal file
20
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/Link.tsx
generated
Normal 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} />
|
||||
}
|
44
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/linkHelpers.ts
generated
Normal file
44
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/linkHelpers.ts
generated
Normal 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);
|
||||
}
|
36
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/types.ts
generated
Normal file
36
waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router/types.ts
generated
Normal 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 }
|
@ -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" }]
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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
|
43
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.tsx
generated
Normal file
43
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.tsx
generated
Normal 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'
|
20
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/Link.tsx
generated
Normal file
20
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/Link.tsx
generated
Normal 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} />
|
||||
}
|
@ -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);
|
||||
}
|
36
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/types.ts
generated
Normal file
36
waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router/types.ts
generated
Normal 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 }
|
@ -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" }]
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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
|
51
waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/router.tsx
generated
Normal file
51
waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/router.tsx
generated
Normal 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'
|
@ -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} />
|
||||
}
|
@ -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);
|
||||
}
|
@ -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 }
|
@ -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" }]
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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
|
43
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.tsx
generated
Normal file
43
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.tsx
generated
Normal 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'
|
20
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router/Link.tsx
generated
Normal file
20
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router/Link.tsx
generated
Normal 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} />
|
||||
}
|
44
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router/linkHelpers.ts
generated
Normal file
44
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router/linkHelpers.ts
generated
Normal 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);
|
||||
}
|
36
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router/types.ts
generated
Normal file
36
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router/types.ts
generated
Normal 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 }
|
@ -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" }]
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -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
|
43
waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router.tsx
generated
Normal file
43
waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router.tsx
generated
Normal 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'
|
20
waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router/Link.tsx
generated
Normal file
20
waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router/Link.tsx
generated
Normal 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} />
|
||||
}
|
@ -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);
|
||||
}
|
36
waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router/types.ts
generated
Normal file
36
waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router/types.ts
generated
Normal 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 }
|
@ -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" }]
|
||||
|
@ -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,
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from '@wasp/router'
|
||||
|
||||
import { LoginForm } from '@wasp/auth/forms/Login'
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from '@wasp/router'
|
||||
|
||||
import { LoginForm } from '@wasp/auth/forms/Login'
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
||||
|
@ -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]
|
||||
|
18
waspc/src/Wasp/Util/WebRouterPath.hs
Normal file
18
waspc/src/Wasp/Util/WebRouterPath.hs
Normal 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
|
@ -81,7 +81,7 @@ spec_WebAppGenerator = do
|
||||
(SP.toFilePath Common.webAppSrcDirInWebAppRootDir </>)
|
||||
[ "logo.png",
|
||||
"index.tsx",
|
||||
"router.jsx"
|
||||
"router.tsx"
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -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
134
web/docs/advanced/links.md
Normal 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 } })
|
||||
```
|
@ -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.
|
||||
|
@ -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:
|
||||
|
@ -107,6 +107,7 @@ module.exports = {
|
||||
'advanced/web-sockets',
|
||||
'advanced/apis',
|
||||
'advanced/middleware-config',
|
||||
'advanced/links',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user