Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ability to merge routes when using modular navigation #19

Merged
merged 3 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/modular.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ wildcard.
In a module, we must tell the navigator this is a module and where it should attach itself in the host application. Let's take the module `account`
as an example.

All the routes of the module `account` must replace the route named `root.account` in the host application. We declare this in the `navigation.yaml`
All the routes of the module `account` must attached to the route named `root.account` in the host application. We declare this in the `navigation.yaml`
of the module:

```yaml
Expand All @@ -40,6 +40,7 @@ of the module:
In the module `application` we'll access the routes normally, but when running in a modular environment, the navigator will know that the root route
of this module must replace `root.account` in the parent. The character `~` is what indicates the link between the module and the host.

- NOTE 1: when running `application` as a standalone app, the route `/` will result in a 404, since our root is actually at the path `/account`.
- NOTE 2: the host navigator will accept everything under `account/` until the module is loaded. When it is loaded, the wildcard is replaced by the
- NOTE 1: if the route in the host already had children, the child routes are copied over to the new route, created by the module.
- NOTE 2: when running `application` as a standalone app, the route `/` will result in a 404, since our root is actually at the path `/account`.
- NOTE 3: the host navigator will accept everything under `account/` until the module is loaded. When it is loaded, the wildcard is replaced by the
actual route (from the module) and any attempt to access an invalid route will result in a 404.
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stack-spot/citron-navigator-cli",
"version": "1.0.0",
"version": "1.1.0",
"main": "dist/index.js",
"license": "Apache-2.0",
"homepage": "https://github.com/stack-spot/citron-navigator",
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/Codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,20 +161,22 @@ export class Codegen {
: { route: ContextualizedRoute<RouteByKey[T], RouteParams[T]>, params: RouteParams[T] }

interface NavigationContext {
when: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
when: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
otherwise: (handler: () => VoidOrPromise) => NavigationContext,
whenNotFound: (handler: (path: string) => VoidOrPromise) => NavigationContext,
}

function buildContext(clauses: NavigationClauses) {
const context: NavigationContext = {
when: (key, handler) => {
clauses.when[key] = handler
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.when[k] = handler)
return context
},
whenSubrouteOf: (key, handler) => {
clauses.whenSubrouteOf.push({ key, handler })
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.whenSubrouteOf.push({ key: k, handler }))
return context
},
otherwise: (handler) => {
Expand Down
40 changes: 24 additions & 16 deletions packages/cli/test/__snapshots__/generate.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,22 @@ export type ViewPropsOf<T extends keyof RouteParams> = RouteParams[T] extends vo
: { route: ContextualizedRoute<RouteByKey[T], RouteParams[T]>, params: RouteParams[T] }

interface NavigationContext {
when: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
when: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
otherwise: (handler: () => VoidOrPromise) => NavigationContext,
whenNotFound: (handler: (path: string) => VoidOrPromise) => NavigationContext,
}

function buildContext(clauses: NavigationClauses) {
const context: NavigationContext = {
when: (key, handler) => {
clauses.when[key] = handler
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.when[k] = handler)
return context
},
whenSubrouteOf: (key, handler) => {
clauses.whenSubrouteOf.push({ key, handler })
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.whenSubrouteOf.push({ key: k, handler }))
return context
},
otherwise: (handler) => {
Expand Down Expand Up @@ -296,20 +298,22 @@ export type ViewPropsOf<T extends keyof RouteParams> = RouteParams[T] extends vo
: { route: ContextualizedRoute<RouteByKey[T], RouteParams[T]>, params: RouteParams[T] }

interface NavigationContext {
when: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
when: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
otherwise: (handler: () => VoidOrPromise) => NavigationContext,
whenNotFound: (handler: (path: string) => VoidOrPromise) => NavigationContext,
}

function buildContext(clauses: NavigationClauses) {
const context: NavigationContext = {
when: (key, handler) => {
clauses.when[key] = handler
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.when[k] = handler)
return context
},
whenSubrouteOf: (key, handler) => {
clauses.whenSubrouteOf.push({ key, handler })
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.whenSubrouteOf.push({ key: k, handler }))
return context
},
otherwise: (handler) => {
Expand Down Expand Up @@ -580,20 +584,22 @@ export type ViewPropsOf<T extends keyof RouteParams> = RouteParams[T] extends vo
: { route: ContextualizedRoute<RouteByKey[T], RouteParams[T]>, params: RouteParams[T] }

interface NavigationContext {
when: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
when: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
otherwise: (handler: () => VoidOrPromise) => NavigationContext,
whenNotFound: (handler: (path: string) => VoidOrPromise) => NavigationContext,
}

function buildContext(clauses: NavigationClauses) {
const context: NavigationContext = {
when: (key, handler) => {
clauses.when[key] = handler
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.when[k] = handler)
return context
},
whenSubrouteOf: (key, handler) => {
clauses.whenSubrouteOf.push({ key, handler })
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.whenSubrouteOf.push({ key: k, handler }))
return context
},
otherwise: (handler) => {
Expand Down Expand Up @@ -864,20 +870,22 @@ export type ViewPropsOf<T extends keyof RouteParams> = RouteParams[T] extends vo
: { route: ContextualizedRoute<RouteByKey[T], RouteParams[T]>, params: RouteParams[T] }

interface NavigationContext {
when: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T, handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
when: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
whenSubrouteOf: <T extends keyof RouteParams>(key: T | T[], handler: (props: ViewPropsOf<T>) => VoidOrPromise) => NavigationContext,
otherwise: (handler: () => VoidOrPromise) => NavigationContext,
whenNotFound: (handler: (path: string) => VoidOrPromise) => NavigationContext,
}

function buildContext(clauses: NavigationClauses) {
const context: NavigationContext = {
when: (key, handler) => {
clauses.when[key] = handler
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.when[k] = handler)
return context
},
whenSubrouteOf: (key, handler) => {
clauses.whenSubrouteOf.push({ key, handler })
const keys = Array.isArray(key) ? key : [key]
keys.forEach(k => clauses.whenSubrouteOf.push({ key: k, handler }))
return context
},
otherwise: (handler) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
{
"name": "@stack-spot/citron-navigator",
"version": "1.2.0",
"version": "1.3.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "Apache-2.0",
"homepage": "https://github.com/stack-spot/citron-navigator",
"scripts": {
"build": "rimraf dist && tsc --project tsconfig.build.json --declaration && tsc-esm-fix --target='dist'",
"test": "jest",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"copy-readme": "ts-node ../../scripts/copy-readme.ts",
"prepublishOnly": "pnpm copy-readme"
Expand Down
37 changes: 32 additions & 5 deletions packages/runtime/src/CitronNavigator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AnyRoute, Route } from './Route'
import { NavigationError } from './errors'
import { NavigationError, NavigationSetupError } from './errors'
import { removeElementFromArray, splitPath } from './utils'

type NotFoundListener = (path: string) => void
Expand Down Expand Up @@ -44,26 +44,53 @@ export class CitronNavigator {
}

/**
* Updates the navigation tree by replacing a node for another.
* Updates the navigation tree by merging a node with another.
*
* This is used by modular navigation. A module can load more routes into the tree.
* @param route the node to enter the tree.
* @param keyToReplace the key of the node to be replaces.
* @param route the node to be merged into the tree.
* @param keyToReplace the key of the node to be merged.
*/
updateNavigationTree(route: Route<any, any, any>, keyToReplace: string) {
let oldRoute: any = this.root
const reminderKey = keyToReplace.replace(new RegExp(`^${this.root.$key}\\.?`), '')
const keyParts = reminderKey.split('.')
if (reminderKey) keyParts.forEach(key => oldRoute = oldRoute?.[key])
if (!oldRoute) {
throw new Error(`Navigation error: cannot update navigation tree at route with key "${keyToReplace}" because the key doesn't exist.`)
throw new NavigationSetupError(
`Navigation error: cannot update navigation tree at route with key "${keyToReplace}" because the key doesn't exist.`,
)
}
if (oldRoute === this.root) {
this.root = route
} else {
route.$parent = oldRoute.$parent
oldRoute.$parent[keyParts[keyParts.length - 1]] = route
}
// validation: check for route clashes
const oldPaths = Object.keys(oldRoute).reduce<string[]>((result, key) => {
if (key.startsWith('$')) return result
const value = oldRoute[key as keyof typeof oldRoute]
return !(value instanceof Route) || value.$path.endsWith('/*') ? result : [...result, value.$path]
}, [])
Object.keys(route).forEach((key) => {
const value = route[key as keyof typeof route]
if (key.startsWith('$') || !(value instanceof Route)) return
if (oldPaths.includes(value.$path)) {
throw new NavigationSetupError(
`Error while merging modular route with key "${keyToReplace}". Path "${value.$path}" is already defined in parent. Only paths with wildcard can be replaced.`,
)
}
if (key in oldRoute && oldRoute[key] instanceof Route && !oldRoute[key].$path.endsWith('/*')) {
throw new NavigationSetupError(
`Error while merging modular route, key "${keyToReplace}" is already defined in parent with a non-wildcard path.`,
)
}
})
// copy all the child routes that existed in the old route, but don't exist in the new one (merge):
Object.keys(oldRoute).forEach((key) => {
// @ts-ignore
if (!key.startsWith('$') && !(key in route) && oldRoute[key] instanceof Route) route[key] = oldRoute[key]
})
this.updateRoute()
}

Expand Down
14 changes: 13 additions & 1 deletion packages/runtime/src/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ interface GoOptions {
export type AnyRoute = Route<any, any, any>

/**
* A Route of the Citron Navigator. The root route, i.e. the route that has no parent will be Navigation Tree.
* A Route of the Citron Navigator. The root route, i.e. the route that has no parent will be the Navigation Tree.
*
* Routes are equal if they have the same key. To test route equality, use `routeA.equals(routeB)`, do not use same-value-zero equality
* (`routeA === routeB`).
*
* Every property of this class not prefixed with "$" is a child route.
*/
Expand Down Expand Up @@ -145,6 +148,15 @@ export abstract class Route<
return this.$key === key
}

/**
* Checks if the route passed as parameter is equivalent to the this route.
* @param route the route to compare.
* @returns true if it's equivalent, false otherwise.
*/
$equals(route: AnyRoute) {
return this.$key === route.$key
}

/**
* Checks if the key passed as parameter corresponds to this route or a sub-route of this route.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ export class NavigationError extends Error {
super(`Navigation error: ${message}`)
}
}

export class NavigationSetupError extends Error {
constructor(message: string) {
super(`Navigation setup error: ${message}`)
}
}
42 changes: 36 additions & 6 deletions packages/runtime/test/CitronNavigator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { CitronNavigator } from '../src/CitronNavigator'
import { AccountRoute, AlternativeRootRoute, ExtendedAccountRoute, RootRoute, SettingsRoute, StudiosRoute, WorkspacesRoute } from './routes'
import { NavigationSetupError } from '../src/errors'
import {
AccountRoute, AlternativeRootRoute, AlternativeRootRouteWithPathClash, AlternativeRootRouteWithStudios, ExtendedAccountRoute, RootRoute,
SettingsRoute, StudiosRoute, WorkspacesRoute,
} from './routes'
import { delay, expectToFail, mockConsoleLogs, mockLocation, testHash } from './utils'

describe('Citron Navigator', () => {
Expand Down Expand Up @@ -39,7 +43,7 @@ describe('Citron Navigator', () => {
expect(window.addEventListener).toHaveBeenCalledWith('popstate', expect.any(Function))
})

it('should update navigation tree (replacing a branch)', () => {
it('should update navigation tree (merging into a branch)', () => {
const navigator = CitronNavigator.create(new RootRoute())
const root = (navigator as any).root
expect(root.account.settings).toBeUndefined()
Expand All @@ -50,7 +54,7 @@ describe('Citron Navigator', () => {
expect(root.studios).toBeInstanceOf(StudiosRoute)
})

it('should update navigation tree (replacing the root)', () => {
it('should update navigation tree (merging into the root)', () => {
const navigator = CitronNavigator.create(new RootRoute())
let root = (navigator as any).root
expect(root.workspaces).toBeUndefined()
Expand All @@ -59,8 +63,8 @@ describe('Citron Navigator', () => {
expect(root).not.toBeInstanceOf(RootRoute)
expect(root).toBeInstanceOf(AlternativeRootRoute)
expect(root.workspaces).toBeInstanceOf(WorkspacesRoute)
expect(root.account).toBeUndefined()
expect(root.studios).toBeUndefined()
expect(root.account).toBeInstanceOf(AccountRoute)
expect(root.studios).toBeInstanceOf(StudiosRoute)
})

it("should fail to update navigation tree if anchor doesn't exist", () => {
Expand All @@ -69,10 +73,36 @@ describe('Citron Navigator', () => {
navigator.updateNavigationTree(new ExtendedAccountRoute(), 'root.inexistent')
expectToFail()
} catch (error: any) {
expect(error.message).toMatch(/^Navigation error:/)
expect(error).toBeInstanceOf(NavigationSetupError)
}
})

it(
'should fail to update navigation tree if the new branch has a path that already exists in the current tree and is not a wildcard',
() => {
try {
const navigator = CitronNavigator.create(new RootRoute())
navigator.updateNavigationTree(new AlternativeRootRouteWithPathClash(), 'root')
expectToFail()
} catch (error) {
expect(error).toBeInstanceOf(NavigationSetupError)
}
},
)

it(
'should fail to update navigation tree if the new branch has a key that already exists in the current tree with a non-wildcard path',
() => {
try {
const navigator = CitronNavigator.create(new RootRoute())
navigator.updateNavigationTree(new AlternativeRootRouteWithStudios(), 'root')
expectToFail()
} catch (error) {
expect(error).toBeInstanceOf(NavigationSetupError)
}
},
)

describe('should compute the path of a url', testHash(({ p, navigator }) => {
const path = navigator.getPath(new URL(`https://www.stackspot.com${p('/pt/ai-assistente')}`))
expect(path).toBe('pt/ai-assistente')
Expand Down
Loading