]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: add beforeEnter
authorEduardo San Martin Morote <posva13@gmail.com>
Wed, 1 May 2019 12:48:31 +0000 (14:48 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Wed, 1 May 2019 12:48:31 +0000 (14:48 +0200)
__tests__/matcher.spec.js
__tests__/per-router-before-guards.spec.js [new file with mode: 0644]
explorations/html5.ts
src/history/html5.ts
src/matcher.ts
src/router.ts
src/types/index.ts

index dbd0b49d7413989e9d2162b1fb390eb7514a8209..8e8521f74c4040849ea8c9a62318c1872e381552 100644 (file)
@@ -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 (file)
index 0000000..3647791
--- /dev/null
@@ -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<import('../src/router').RouterOptions> & { routes: import('../src/types').RouteRecord[]}} options
+ */
+function createRouter(options) {
+  return new Router({
+    history: new HTML5History(),
+    ...options,
+  })
+}
+
+const Home = { template: `<div>Home</div>` }
+const Foo = { template: `<div>Foo</div>` }
+
+/** @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(
+      `<!DOCTYPE html><html><head></head><body></body></html>`,
+      {
+        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')
+  })
+})
index 8296a96b3aba06efd663b0d30e502d9f13503739..132c3c7262c16d8e858fd4c448bbd32f45f26296 100644 (file)
@@ -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 },
   ],
 })
index 44ee80ea9668aecdc0c1d144618d0e5dcf17c532..c0b0a5a6927b8365e1efb5558a5ccbf60aa30a73 100644 (file)
@@ -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 {
index 00d0fd8f1a633e6595868c84dc3dc4785c5ac487..38d418250897daff4be190a92b007b0d3f34236e 100644 (file)
@@ -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,
     }
   }
 }
index 0219b3e3c667fa7b20591e4384aea361d660e434..cf560e65b04d970d9983015024c3d47ddfc19fdd 100644 (file)
@@ -83,27 +83,30 @@ export class Router {
   ): Promise<TODO> {
     // 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<any>> = []
+    let guards: Array<() => Promise<any>> = []
 
+    // 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<void> {
+  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)
+    })
+}
index 7ef0a8398ecd362534bdca9d2f4824036f715feb..52cf760eb7d16543e058066c58f1b3a3e63e9e9c 100644 (file)
@@ -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 {