From: Eduardo San Martin Morote Date: Wed, 1 May 2019 12:48:31 +0000 (+0200) Subject: feat: add beforeEnter X-Git-Tag: v4.0.0-alpha.0~425 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=d76268130dd71b7556f9d5423c57290454648c17;p=thirdparty%2Fvuejs%2Frouter.git feat: add beforeEnter --- diff --git a/__tests__/matcher.spec.js b/__tests__/matcher.spec.js index dbd0b49d..8e8521f7 100644 --- a/__tests__/matcher.spec.js +++ b/__tests__/matcher.spec.js @@ -29,6 +29,9 @@ describe('Router Matcher', () => { resolved.path = location.path } + // use one single record + if (!('matched' in resolved)) resolved.matched = [record] + // allows not passing params if ('params' in location) { resolved.params = resolved.params || location.params @@ -144,43 +147,57 @@ describe('Router Matcher', () => { describe('LocationAsRelative', () => { it('matches with nothing', () => { + const record = { path: '/home', name: 'Home', component } assertRecordMatch( - { path: '/home', name: 'Home', component }, + record, {}, { name: 'Home', path: '/home' }, - { name: 'Home', params: {}, path: '/home' } + { name: 'Home', params: {}, path: '/home', matched: [record] } ) }) it('replace params even with no name', () => { + const record = { path: '/users/:id/m/:role', component } assertRecordMatch( - { path: '/users/:id/m/:role', component }, + record, { params: { id: 'posva', role: 'admin' } }, { name: undefined, path: '/users/posva/m/admin' }, { path: '/users/ed/m/user', name: undefined, params: { id: 'ed', role: 'user' }, + matched: [record], } ) }) it('replace params', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + component, + } assertRecordMatch( - { path: '/users/:id/m/:role', name: 'UserEdit', component }, + record, { params: { id: 'posva', role: 'admin' } }, { name: 'UserEdit', path: '/users/posva/m/admin' }, { path: '/users/ed/m/user', name: 'UserEdit', params: { id: 'ed', role: 'user' }, + matched: [], } ) }) it('keep params if not provided', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + component, + } assertRecordMatch( - { path: '/users/:id/m/:role', name: 'UserEdit', component }, + record, {}, { name: 'UserEdit', @@ -191,13 +208,15 @@ describe('Router Matcher', () => { path: '/users/ed/m/user', name: 'UserEdit', params: { id: 'ed', role: 'user' }, + matched: [record], } ) }) it('keep params if not provided even with no name', () => { + const record = { path: '/users/:id/m/:role', component } assertRecordMatch( - { path: '/users/:id/m/:role', component }, + record, {}, { name: undefined, @@ -208,18 +227,17 @@ describe('Router Matcher', () => { path: '/users/ed/m/user', name: undefined, params: { id: 'ed', role: 'user' }, + matched: [record], } ) }) it('throws if the current named route does not exists', () => { - expect( - assertErrorMatch( - { path: '/', component }, - {}, - { name: 'home', params: {}, path: '/' } - ) - ).toMatchInlineSnapshot( + const record = { path: '/', component } + const start = { name: 'home', params: {}, path: '/', matched: [record] } + // the property should be non enumerable + Object.defineProperty(start, 'matched', { enumerable: false }) + expect(assertErrorMatch(record, {}, start)).toMatchInlineSnapshot( `[Error: No match for {"name":"home","params":{},"path":"/"}]` ) }) diff --git a/__tests__/per-router-before-guards.spec.js b/__tests__/per-router-before-guards.spec.js new file mode 100644 index 00000000..36477914 --- /dev/null +++ b/__tests__/per-router-before-guards.spec.js @@ -0,0 +1,111 @@ +// @ts-check +require('./helper') +const expect = require('expect') +const { HTML5History } = require('../src/history/html5') +const { Router } = require('../src/router') +const { JSDOM } = require('jsdom') +const fakePromise = require('faked-promise') + +const tick = () => new Promise(resolve => process.nextTick(resolve)) + +/** + * @param {Partial & { routes: import('../src/types').RouteRecord[]}} options + */ +function createRouter(options) { + return new Router({ + history: new HTML5History(), + ...options, + }) +} + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } + +/** @type {import('../src/types').RouteRecord[]} */ +const routes = [ + { path: '/', component: Home }, + { path: '/foo', component: Foo }, +] + +describe('navigation guards', () => { + beforeAll(() => { + // TODO: move to utils for tests that need DOM + const dom = new JSDOM( + ``, + { + url: 'https://example.org/', + referrer: 'https://example.com/', + contentType: 'text/html', + } + ) + + // @ts-ignore + global.window = dom.window + }) + + it('calls beforeEnter guards on push', async () => { + const spy = jest.fn() + const router = createRouter({ + routes: [ + ...routes, + { + path: '/guard/:n', + component: Foo, + beforeEnter: spy, + }, + ], + }) + spy.mockImplementationOnce((to, from, next) => { + if (to.params.n !== 'valid') return next(false) + next() + }) + await router.push('/guard/valid') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it.skip('calls beforeEnter guards on replace', () => {}) + + it('waits before navigating', async () => { + const [promise, resolve] = fakePromise() + const router = createRouter({ routes }) + router.beforeEach(async (to, from, next) => { + await promise + next() + }) + const p = router.push('/foo') + expect(router.currentRoute.fullPath).toBe('/') + resolve() + await p + expect(router.currentRoute.fullPath).toBe('/foo') + }) + + it('waits in the right order', async () => { + const [p1, r1] = fakePromise() + const [p2, r2] = fakePromise() + const router = createRouter({ routes }) + const guard1 = jest.fn(async (to, from, next) => { + await p1 + next() + }) + router.beforeEach(guard1) + const guard2 = jest.fn(async (to, from, next) => { + await p2 + next() + }) + router.beforeEach(guard2) + let navigation = router.push('/foo') + expect(router.currentRoute.fullPath).toBe('/') + expect(guard1).toHaveBeenCalled() + expect(guard2).not.toHaveBeenCalled() + r1() + // wait until the guard is called + await tick() + await tick() + expect(guard2).toHaveBeenCalled() + r2() + expect(router.currentRoute.fullPath).toBe('/') + await navigation + expect(guard2).toHaveBeenCalled() + expect(router.currentRoute.fullPath).toBe('/foo') + }) +}) diff --git a/explorations/html5.ts b/explorations/html5.ts index 8296a96b..132c3c72 100644 --- a/explorations/html5.ts +++ b/explorations/html5.ts @@ -8,6 +8,15 @@ const r = new Router({ { path: '/', component }, { path: '/users/:id', name: 'user', component }, { path: '/multiple/:a/:b', name: 'user', component }, + { + path: '/with-guard/:n', + name: 'guarded', + component, + beforeEnter: (to, from, next) => { + if (to.params.n !== 'valid') next(false) + next() + }, + }, // { path: /^\/about\/?$/, component }, ], }) diff --git a/src/history/html5.ts b/src/history/html5.ts index 44ee80ea..c0b0a5a6 100644 --- a/src/history/html5.ts +++ b/src/history/html5.ts @@ -4,6 +4,8 @@ import { NavigationCallback, HistoryState, NavigationType } from './base' const cs = consola.withTag('html5') +if (process.env.NODE_ENV === 'test') cs.mockTypes(() => jest.fn()) + type PopStateListener = (this: Window, ev: PopStateEvent) => any interface StateEntry { diff --git a/src/matcher.ts b/src/matcher.ts index 00d0fd8f..38d41825 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -51,6 +51,8 @@ export class RouterMatcher { matcher = this.matchers.find(m => m.re.test(location.path)) if (!matcher) throw new NoRouteMatchError(currentLocation, location) + // TODO: build up the array with children based on current location + const matched = [matcher.record] const params: RouteParams = {} const result = matcher.re.exec(location.path) @@ -76,6 +78,7 @@ export class RouterMatcher { /// no need to resolve the path with the matcher as it was provided path: location.path, params, + matched, } } @@ -84,6 +87,8 @@ export class RouterMatcher { matcher = this.matchers.find(m => m.record.name === location.name) if (!matcher) throw new NoRouteMatchError(currentLocation, location) + // TODO: build up the array with children based on current location + const matched = [matcher.record] // TODO: try catch for resolve -> missing params @@ -91,6 +96,7 @@ export class RouterMatcher { name: location.name, path: matcher.resolve(location.params), params: location.params || {}, // TODO: normalize params + matched, } } @@ -104,6 +110,8 @@ export class RouterMatcher { } if (!matcher) throw new NoRouteMatchError(currentLocation, location) + // TODO: build up the array with children based on current location + const matched = [matcher.record] let params = location.params ? location.params : currentLocation.params @@ -111,6 +119,7 @@ export class RouterMatcher { name: currentLocation.name, path: matcher.resolve(params), params, + matched, } } } diff --git a/src/router.ts b/src/router.ts index 0219b3e3..cf560e65 100644 --- a/src/router.ts +++ b/src/router.ts @@ -83,27 +83,30 @@ export class Router { ): Promise { // TODO: Will probably need to be some kind of queue in the future that allows to remove // elements and other stuff - const guards: Array<() => Promise> = [] + let guards: Array<() => Promise> = [] + // check global guards first for (const guard of this.beforeGuards) { - guards.push( - () => - new Promise((resolve, reject) => { - const next: NavigationGuardCallback = (valid?: boolean) => { - // TODO: better error - if (valid === false) reject(new Error('Aborted')) - else resolve() - } - - guard(to, from, next) - }) - ) + guards.push(guardToPromiseFn(guard, to, from)) } // console.log('Guarding against', guards.length, 'guards') for (const guard of guards) { await guard() } + + // check the route beforeEnter + // TODO: check children. Should we also check reused routes guards + guards = [] + for (const record of to.matched) { + if (record.beforeEnter) + guards.push(guardToPromiseFn(record.beforeEnter, to, from)) + } + + // run the queue of guards + for (const guard of guards) { + await guard() + } } getRouteRecord(location: RouteLocation) {} @@ -130,3 +133,20 @@ export class Router { } } } + +function guardToPromiseFn( + guard: NavigationGuard, + to: RouteLocationNormalized, + from: RouteLocationNormalized +): () => Promise { + return () => + new Promise((resolve, reject) => { + const next: NavigationGuardCallback = (valid?: boolean) => { + // TODO: better error + if (valid === false) reject(new Error('Aborted')) + else resolve() + } + + guard(to, from, next) + }) +} diff --git a/src/types/index.ts b/src/types/index.ts index 7ef0a839..52cf760e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -39,6 +39,7 @@ export interface RouteLocationNormalized query: HistoryQuery // the normalized version cannot have numbers // TODO: do the same for params name: string | void + matched: RouteRecord[] // non-enumerable } // interface PropsTransformer { @@ -59,6 +60,7 @@ export interface RouteRecord { path: string // | RegExp component: TODO name?: string + beforeEnter?: NavigationGuard // props: PT } @@ -76,8 +78,14 @@ export const START_LOCATION_NORMALIZED: RouteLocationNormalized = { query: {}, hash: '', fullPath: '/', + matched: [], } +// make matched non enumerable for easy printing +Object.defineProperty(START_LOCATION_NORMALIZED, 'matched', { + enumerable: false, +}) + // Matcher types // the matcher doesn't care about query and hash export type MatcherLocation = @@ -90,6 +98,7 @@ export interface MatcherLocationNormalized { path: string // record? params: RouteLocationNormalized['params'] + matched: RouteRecord[] } export interface NavigationGuardCallback {