Skip to content

Commit

Permalink
implements the component Link
Browse files Browse the repository at this point in the history
  • Loading branch information
Tiagoperes committed May 8, 2024
1 parent a0be7f3 commit b42b6eb
Show file tree
Hide file tree
Showing 24 changed files with 653 additions and 355 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ dist-ssr

# Generated code
generated

# Copied readmes
packages/cli/README.md
packages/runtime/README.md
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"typescript.format.semicolons": "remove",
"cSpell.words": [
"subroute",
"Subroute"
"Subroute",
"testid"
],
}
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ const Album = ({ route, params: { albumId, limit, page } }: ViewPropsOf<'root.ph
<p>Album {albumId}</p>
<p>limit is {limit}</p>
<p>page is {page}</p>
<p><a href={route.$parent.$link()}>Go back to albums</a></p>
<p><a href={route.photo.$link({ photoId: '001' })}>Check out this picture</a></p>
<p><Link to={route.$parent}>Go back to albums</Link></p>
<p><Link to={route.photo} params={{ photoId: '001' }}>Check out this picture</Link></p>
)
```

Expand All @@ -129,6 +129,10 @@ Notice that, from "album", we can easily create a link to "photo" by passing onl
When creating links or navigating to other pages, the type will always be checked by Typescript. In the example above, if we didn't pass
`photoId` when creating a link to "photo", we'd get a type error and the code wouldn't build.

Attention: we used the component [Link](docs/navigation.md#the-link-component)
from Citron Navigator. This is necessary if you're not using hash based URLs (flag `--useHash=false`). If you are using hash based URLs
(`/#/path`), then you can safely use a simple `a` tag instead. Example: `<a href={route.$parent.$link()}>Go back to albums</a>`.

## Installation
We're going to use [PNPM](https://pnpm.io) throughout this documentation, but feel free to use either NPM or YARN.
```sh
Expand Down Expand Up @@ -177,19 +181,19 @@ It's a good idea to call `citron` before running the application locally or buil
```json
{
"scripts": {
"start": "citron && vite --port 3000",
"dev": "citron && vite",
"build": "citron && tsc && vite build --mode production",
}
}
```

## Documentation
- [Sample project](https://github.com/stack-spot/citron-navigator/blob/main/packages/sample)
- [Configuration file (yaml)](https://github.com/stack-spot/citron-navigator/blob/main/docs/configuration-file.md)
- [Route-based rendering](https://github.com/stack-spot/citron-navigator/blob/main/docs/route-based-rendering.md)
- [Loading routes asynchronously](https://github.com/stack-spot/citron-navigator/blob/main/docs/async-route-rendering.md)
- [Navigation](https://github.com/stack-spot/citron-navigator/blob/main/docs/navigation.md)
- [The Route object](https://github.com/stack-spot/citron-navigator/blob/main/docs/route-object.md)
- [Hooks](https://github.com/stack-spot/citron-navigator/blob/main/docs/hooks.md)
- [Parameter serialization/deserialization](https://github.com/stack-spot/citron-navigator/blob/main/docs/serialization.md)
- [Modular applications](https://github.com/stack-spot/citron-navigator/blob/main/docs/modular.md)
- [Sample project](packages/sample)
- [Configuration file (yaml)](docs/configuration-file.md)
- [Route-based rendering](docs/route-based-rendering.md)
- [Loading routes asynchronously](docs/async-route-rendering.md)
- [Navigation](docs/navigation.md)
- [The Route object](docs/route-object.md)
- [Hooks](docs/hooks.md)
- [Parameter serialization/deserialization](docs/serialization.md)
- [Modular applications](docs/modular.md)
36 changes: 35 additions & 1 deletion docs/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ through the view Props. Check the example below:

```tsx
const Home = ({ route }: ViewPropsOf<'root'>) => (
<a href={route.account.$link()}>Go to account</a>
<Link to={route.account}>Go to account</Link>
)
```

Expand All @@ -19,3 +19,37 @@ parameters with the current, pass a second object with options.
Attention: this will use `history.pushState` or `history.replaceState`, just like the forward navigation.

You can navigate to every route declared in your YAML file.

## The Link component
If you're using the default configuration, where `useHash = true` and don't intend to change this in the future, then you can ignore the
Link component and use the native `a` tag from HTML instead. Example of navigation using the `a` tag:

```tsx
const Home = ({ route }: ViewPropsOf<'root'>) => (
<a href={route.account.$link()}>Go to account</a>
)
```

Otherwise, if you don't use hashes in the URL or want to support both mechanisms (with and without hashes), then you should use the Link
component to navigate with HTML anchors.

If a simple `a` tag is used and `useHash` is false, the browser will reload the page in order to perform a navigation. We don't want this.
`Link` prevents such reload.

The Link component can be used in one of two ways:
1. Passing a route and its parameters:
```tsx
const Albums = ({ route }: ViewPropsOf<'root.photoAlbums'>) => (
<Link to={route.album} params={{ albumId: 1 }}>Go to Album 1</Link>
<Link to={route.album} params={{ albumId: 2 }}>Go to Album 2</Link>
)
```
2. Passing the `href` prop:
```tsx
const Albums = ({ route }: ViewPropsOf<'root.photoAlbums'>) => (
<Link href={route.album.$link({ albumId: 1 })}>Go to Album 1</Link>
<Link href={route.album.$link({ albumId: 2 })}>Go to Album 2</Link>
)
```

A `Link` will always render an `a` tag in the HTML.
18 changes: 9 additions & 9 deletions docs/route-based-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ const Album = ({ route, params: { albumId, limit, page } }: ViewPropsOf<'root.ph
<p>Album {albumId}</p>
<p>limit is {limit}</p>
<p>page is {page}</p>
<p><a href={route.$parent.$link()}>Go back to albums</a></p>
<p><a href={route.photo.$link({ photoId: '001' })}>Check out this picture</a></p>
<p><Link to={route.$parent}>Go back to albums</Link></p>
<p><Link to={route.photo} params={{ photoId: '001' }}>Check out this picture</Link></p>
)
```

Expand Down Expand Up @@ -122,10 +122,10 @@ Just like a view, here we need a React component that accepts as prop the type `
```tsx
export const AccountMenu = ({ route }: ViewPropsOf<'root.account'>) => (
<ul>
<li><a href={route.$link()} className={route.$isActive() ? 'active' : ''}>Dashboard</a></li>
<li><a href={route.profile.$link()} className={route.profile.$isActive() ? 'active' : ''}>Profile</a></li>
<li><a href={route.changePassword.$link()} className={route.changePassword.$isActive() ? 'active' : ''}>Change password</a></li>
<li><a href={route.billing.$link()} className={route.billing.$isActive() ? 'active' : ''}>Billing</a></li>
<li><Link to={route} className={route.$isActive() ? 'active' : ''}>Dashboard</Link></li>
<li><Link to={route.profile} className={route.profile.$isActive() ? 'active' : ''}>Profile</Link></li>
<li><Link to={route.changePassword} className={route.changePassword.$isActive() ? 'active' : ''}>Change password</Link></li>
<li><Link to={route.billing} className={route.billing.$isActive() ? 'active' : ''}>Billing</Link></li>
</ul>
)
```
Expand All @@ -137,9 +137,9 @@ import { root } from 'src/generated/navigation' // replace this import for what
export const MainMenu = () => (
<ul>
<li><a href={root.$link()} className={root.$isActive() ? 'active' : ''}>Home</a></li>
<li><a href={root.account.$link()}>Account</a></li>
<li><a href={root.photoAlbums.$link()}>Photo albums</a></li>
<li><Link to={root} className={root.$isActive() ? 'active' : ''}>Home</Link></li>
<li><Link to={root.account}>Account</Link></li>
<li><Link to={root.photoAlbums}>Photo albums</Link></li>
</ul>
)
```
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"scripts": {
"build": "tsc --project tsconfig.build.json --declaration",
"test": "jest",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"prepublishOnly": "pnpm copy-readme"
},
"bin": {
"citron": "./dist/run.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const config: Config = {
verbose: true,
preset: 'ts-jest',
testEnvironment: 'jsdom',
testMatch: ['**/test/**/*.spec.ts'],
testMatch: ['**/test/**/*.spec.{ts,tsx}'],
}

export default config
16 changes: 10 additions & 6 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
{
"name": "@stack-spot/citron-navigator",
"version": "1.0.0",
"version": "1.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc --project tsconfig.build.json --declaration && tsc-esm-fix --target='dist'",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"copy-readme": "ts-node ../../scripts/copy-readme.ts",
"prepublishOnly": "pnpm copy-readme"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
},
"devDependencies": {
"@testing-library/react": "^15.0.7",
"@types/jest": "^29.5.12",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/react": "18.2.66",
"@types/react-dom": "18.2.22",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0",
Expand All @@ -29,6 +32,7 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"tsc-esm-fix": "^2.20.26",
"typescript": "^5.2.2"
}
Expand Down
49 changes: 49 additions & 0 deletions packages/runtime/src/Link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react'
import { CitronNavigator } from './CitronNavigator'
import { AnyRoute, Route } from './Route'
import { RequiredKeysOf } from './types'

type RouteProps<T extends AnyRoute | undefined> = { to?: T } & (T extends Route<any, infer Params, any>
? Params extends void
? unknown
: RequiredKeysOf<Params> extends never ? { params?: Params } : { params: Params }
: unknown)

type Props<T extends AnyRoute | undefined> = React.AnchorHTMLAttributes<HTMLAnchorElement> & RouteProps<T>

interface LinkFn {
<T extends AnyRoute | undefined>(props: Props<T>): React.ReactNode,
}

export const Link: LinkFn = (props) => {
const { to, params, href, children, target, ...anchorProps } = props as Props<Route<any, object, any>>
const actualHref = to ? to.$link(params) : href
const ref = useRef<HTMLAnchorElement>(null)

useEffect(() => {
const isHashUrl = actualHref && /^\/?#/.test(actualHref)
if (!ref.current || isHashUrl || (target && target != '_self')) return

function navigate(event: Event) {
event.preventDefault()
history.pushState(null, '', actualHref)
// since we called event.preventDefault(), we now must manually trigger a navigation update
CitronNavigator.instance?.updateRoute?.()
}

function onKeyPress(event: KeyboardEvent) {
if (event.key != 'Enter') return
navigate(event)
}

ref.current.addEventListener('click', navigate)
ref.current.addEventListener('keydown', onKeyPress)

return () => {
ref.current?.removeEventListener('click', navigate)
ref.current?.removeEventListener('keydown', onKeyPress)
}
}, [actualHref, ref.current])

return <a ref={ref} href={actualHref} target={target} {...anchorProps}>{children}</a>
}
2 changes: 1 addition & 1 deletion packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { CitronNavigator } from './CitronNavigator'
export { Link } from './Link'
export { AnyRoute, Route } from './Route'
export * from './errors'
export { AnyRouteWithParams } from './types'

2 changes: 1 addition & 1 deletion packages/runtime/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LinkedList } from './LinkedList'
import { Route } from './Route'

type RequiredKeysOf<T> = Exclude<{
export type RequiredKeysOf<T> = Exclude<{
[K in keyof T]: T extends Record<K, T[K]>
? K
: never
Expand Down
97 changes: 97 additions & 0 deletions packages/runtime/test/Link.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { fireEvent, render } from '@testing-library/react'
import { Link } from '../src/Link'
import { NavigatorMock } from './NavigatorMock'
import { RootRoute } from './routes'
import { delay } from './utils'

describe('Link', () => {
const root = new RootRoute()
const navigator = new NavigatorMock(root)

beforeEach(() => {
navigator.reset()
history.replaceState(null, '', 'http://localhost/')
})

describe('URLs without hash', () => {
beforeAll(() => {
navigator.useHash = false
})

test('should render an anchor tag with the correct attributes', async () => {
const rendered = render(<Link data-testid="link" href="/test" className="class">Test</Link>)
const anchor = await rendered.findByTestId('link')
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('/test')
expect(anchor.getAttribute('class')).toBe('class')
})

async function shouldNavigateAndNotRefresh(event: Event) {
const rendered = render(<Link data-testid="link" href="/test">Test</Link>)
const anchor = await rendered.findByTestId('link')
event.preventDefault = jest.fn(event.preventDefault)
fireEvent(anchor, event)
await delay()
expect(navigator.updateRoute).toHaveBeenCalled()
expect(location.href).toBe('http://localhost/test')
expect(event.preventDefault).toHaveBeenCalled()
}

test('should navigate and not refresh when clicked', () => shouldNavigateAndNotRefresh(new MouseEvent('click')))

test(
'should navigate and not refresh when enter is pressed',
() => shouldNavigateAndNotRefresh(new KeyboardEvent('keydown', { key: 'Enter' })),
)

test('should create link from route and params', async () => {
const rendered = render(<Link data-testid="link" to={root.studios.studio} params={{ studioId: '1' }}>Studio 1</Link>)
const anchor = await rendered.findByTestId('link')
expect(anchor.getAttribute('href')).toBe('/studios/1')
})

test('should open in another context (target != _self)', async () => {
const rendered = render(<Link data-testid="link" href="/test" target="_blank">Test</Link>)
const anchor = await rendered.findByTestId('link')
const event = new MouseEvent('click')
event.preventDefault = jest.fn(event.preventDefault)
fireEvent(anchor, event)
await delay()
expect(navigator.updateRoute).not.toHaveBeenCalled()
expect(location.href).toBe('http://localhost/')
expect(event.preventDefault).not.toHaveBeenCalled()
})
})

describe('URLs with hash', () => {
beforeAll(() => {
navigator.useHash = true
})

test('should render an anchor tag with the correct attributes', async () => {
const rendered = render(<Link data-testid="link" href="/#/test" className="class">Test</Link>)
const anchor = await rendered.findByTestId('link')
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('/#/test')
expect(anchor.getAttribute('class')).toBe('class')
})

test('should act like simple anchor when clicked', async () => {
const rendered = render(<Link data-testid="link" href="/#/test">Test</Link>)
const anchor = await rendered.findByTestId('link')
const event = new MouseEvent('click')
event.preventDefault = jest.fn(event.preventDefault)
fireEvent(anchor, event)
await delay()
expect(navigator.updateRoute).not.toHaveBeenCalled()
expect(location.href).toBe('http://localhost/#/test')
expect(event.preventDefault).not.toHaveBeenCalled()
})

test('should create link from route and params', async () => {
const rendered = render(<Link data-testid="link" to={root.studios.studio} params={{ studioId: '1' }}>Studio 1</Link>)
const anchor = await rendered.findByTestId('link')
expect(anchor.getAttribute('href')).toBe('/#/studios/1')
})
})
})
4 changes: 3 additions & 1 deletion packages/runtime/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"extends": "../../tsconfig",
"compilerOptions": {
"module": "ESNext",
"outDir": "dist"
"outDir": "dist",
"jsx": "react-jsx",
"moduleResolution": "Bundler"
},
"include": ["src", "test"]
}
Loading

0 comments on commit b42b6eb

Please sign in to comment.