]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: handle basic matching
authorEduardo San Martin Morote <posva13@gmail.com>
Tue, 16 Apr 2019 10:58:50 +0000 (12:58 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Tue, 16 Apr 2019 10:58:50 +0000 (12:58 +0200)
__tests__/matcher.spec.js [new file with mode: 0644]
src/errors.ts [new file with mode: 0644]
src/matcher.ts
src/router.ts
src/types/index.ts

diff --git a/__tests__/matcher.spec.js b/__tests__/matcher.spec.js
new file mode 100644 (file)
index 0000000..e38de67
--- /dev/null
@@ -0,0 +1,118 @@
+// @ts-check
+require('./helper')
+const expect = require('expect')
+const { RouterMatcher } = require('../src/matcher')
+const { START_LOCATION_NORMALIZED } = require('../src/types')
+
+const component = null
+
+describe('Router Matcher', () => {
+  describe('resolve', () => {
+    /**
+     *
+     * @param {import('../src/types').RouteRecord} record
+     * @param {import('../src/types').MatcherLocation} location
+     * @param {Partial<import('../src/types').MatcherLocationNormalized>} resolved
+     * @param {import('../src/types').MatcherLocationNormalized} start
+     */
+    function assertRecordMatch(
+      record,
+      location,
+      resolved,
+      start = START_LOCATION_NORMALIZED
+    ) {
+      const matcher = new RouterMatcher([record])
+      const targetLocation = {}
+
+      // add location if provided as it should be the same value
+      if ('path' in location) {
+        resolved.path = location.path
+      }
+
+      // allows not passing params
+      if ('params' in location) {
+        resolved.params = resolved.params || location.params
+      } else {
+        resolved.params = resolved.params || {}
+      }
+
+      const result = matcher.resolve(
+        {
+          ...targetLocation,
+          // override anything provided in location
+          ...location,
+        },
+        start
+      )
+      expect(result).toEqual(resolved)
+    }
+
+    describe('LocationAsPath', () => {
+      it('resolves a normal path', () => {
+        assertRecordMatch(
+          { path: '/', name: 'Home', component },
+          { path: '/' },
+          { name: 'Home', path: '/', params: {} }
+        )
+      })
+
+      it('resolves a path with params', () => {
+        assertRecordMatch(
+          { path: '/users/:id', name: 'User', component },
+          { path: '/users/posva' },
+          { name: 'User', params: { id: 'posva' } }
+        )
+      })
+
+      it('resolves a path with multiple params', () => {
+        assertRecordMatch(
+          { path: '/users/:id/:other', name: 'User', component },
+          { path: '/users/posva/hey' },
+          { name: 'User', params: { id: 'posva', other: 'hey' } }
+        )
+      })
+    })
+
+    describe('LocationAsName', () => {
+      it('matches a name', () => {
+        assertRecordMatch(
+          { path: '/home', name: 'Home', component },
+          { name: 'Home' },
+          { name: 'Home', path: '/home' }
+        )
+      })
+
+      it('matches a name and fill params', () => {
+        assertRecordMatch(
+          { path: '/users/:id/m/:role', name: 'UserEdit', component },
+          { name: 'UserEdit', params: { id: 'posva', role: 'admin' } },
+          { name: 'UserEdit', path: '/users/posva/m/admin' }
+        )
+      })
+    })
+
+    describe('LocationAsRelative', () => {
+      it('matches with nothing', () => {
+        assertRecordMatch(
+          { path: '/home', name: 'Home', component },
+          {},
+          { name: 'Home', path: '/home' },
+          { name: 'Home', params: {}, path: '/home' }
+        )
+      })
+
+      it('replace params', () => {
+        assertRecordMatch(
+          { path: '/users/:id/m/:role', name: 'UserEdit', component },
+          { params: { id: 'posva', role: 'admin' } },
+          { name: 'UserEdit', path: '/users/posva/m/admin' },
+          {
+            path: '/users/ed/m/user',
+            name: 'UserEdit',
+            params: { id: 'ed', role: 'user' },
+          }
+        )
+      })
+    })
+  })
+})
diff --git a/src/errors.ts b/src/errors.ts
new file mode 100644 (file)
index 0000000..b3b096f
--- /dev/null
@@ -0,0 +1,6 @@
+export class NoRouteMatchError extends Error {
+  constructor(currentLocation: any, location: any) {
+    super('No match for' + JSON.stringify({ ...currentLocation, ...location }))
+    Object.setPrototypeOf(this, new.target.prototype)
+  }
+}
index 945e63bc509117525c6a8aac3be51082b8f9caa6..f49d0426f0f5b4f54ae36438e724a3fe28c0f9a9 100644 (file)
@@ -3,14 +3,14 @@ import {
   RouteRecord,
   RouteParams,
   MatcherLocation,
-  RouterLocationNormalized,
+  MatcherLocationNormalized,
 } from './types/index'
-import { stringifyQuery } from './utils'
+import { NoRouteMatchError } from './errors'
 
 // TODO: rename
 interface RouteMatcher {
   re: RegExp
-  resolve: (params: RouteParams) => string
+  resolve: (params?: RouteParams) => string
   record: RouteRecord
   keys: string[]
 }
@@ -40,67 +40,75 @@ export class RouterMatcher {
    */
   resolve(
     location: Readonly<MatcherLocation>,
-    currentLocation: Readonly<RouterLocationNormalized>
-  ): RouterLocationNormalized {
-    // TODO: type guard HistoryURL
-    if ('fullPath' in location)
-      return {
-        path: location.path,
-        fullPath: location.fullPath,
-        // TODO: resolve params, query and hash
-        params: {},
-        query: location.query,
-        hash: location.hash,
-      }
+    currentLocation: Readonly<MatcherLocationNormalized>
+    // TODO: return type is wrong, should contain fullPath and record/matched
+  ): MatcherLocationNormalized {
+    let matcher: RouteMatcher | void
+    // TODO: refactor with type guards
 
     if ('path' in location) {
-      // TODO: warn missing params
-      // TODO: extract query and hash? warn about presence
+      // we don't even need currentLocation here
+      matcher = this.matchers.find(m => m.re.test(location.path))
+
+      if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+
+      const params: RouteParams = {}
+      const result = matcher.re.exec(location.path)
+      if (!result) {
+        throw new Error(`Error parsing path "${location.path}"`)
+      }
+
+      for (let i = 0; i < matcher.keys.length; i++) {
+        const key = matcher.keys[i]
+        const value = result[i + 1]
+        if (!value) {
+          throw new Error(
+            `Error parsing path "${
+              location.path
+            }" when looking for key "${key}"`
+          )
+        }
+        params[key] = value
+      }
+
       return {
+        name: matcher.record.name,
+        /// no need to resolve the path with the matcher as it was provided
         path: location.path,
-        // TODO: normalize query?
-        query: location.query || {},
-        hash: location.hash || '',
-        params: {},
-        fullPath:
-          location.path +
-          stringifyQuery(location.query) +
-          (location.hash || ''),
+        params,
       }
     }
 
-    let matcher: RouteMatcher | void
-    if (!('name' in location)) {
-      // TODO: use current location
-      // location = {...location, name: this.}
-      if (currentLocation.name) {
-        // we don't want to match an undefined name
-        matcher = this.matchers.find(
-          m => m.record.name === currentLocation.name
-        )
-      } else {
-        matcher = this.matchers.find(m => m.re.test(currentLocation.path))
-      }
-      // return '/using current location'
-    } else {
+    // named route
+    if ('name' in location) {
       matcher = this.matchers.find(m => m.record.name === location.name)
+
+      if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+
+      // TODO: try catch for resolve -> missing params
+
+      return {
+        name: location.name,
+        path: matcher.resolve(location.params),
+        params: location.params || {}, // TODO: normalize params
+      }
     }
 
-    if (!matcher) {
-      // TODO: error
-      throw new Error(
-        'No match for' + JSON.stringify({ ...currentLocation, ...location })
-      )
+    // location is a relative path
+    if (currentLocation.name) {
+      // we don't want to match an undefined name
+      matcher = this.matchers.find(m => m.record.name === currentLocation.name)
+    } else {
+      // match by path
+      matcher = this.matchers.find(m => m.re.test(currentLocation.path))
     }
 
-    // TODO: try catch to show missing params
-    const fullPath = matcher.resolve(location.params || {})
+    if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+
     return {
-      path: fullPath, // TODO: extract path path, query, hash
-      fullPath,
-      query: {},
-      params: {},
-      hash: '',
+      name: currentLocation.name,
+      path: matcher.resolve(location.params),
+      params: location.params || {},
     }
   }
 }
index 9afbd670995a1e696dab62c66f7b55a7b5383211..e2e0ca5179eae168dd0e17becb2b8c3e341bbd7a 100644 (file)
@@ -1,10 +1,10 @@
 import { BaseHistory } from './history/base'
 import { RouterMatcher } from './matcher'
 import {
-  RouterLocation,
+  RouteLocation,
   RouteRecord,
   START_LOCATION_NORMALIZED,
-  RouterLocationNormalized,
+  RouteLocationNormalized,
 } from './types/index'
 
 interface RouterOptions {
@@ -15,7 +15,7 @@ interface RouterOptions {
 export class Router {
   protected history: BaseHistory
   private matcher: RouterMatcher
-  currentRoute: RouterLocationNormalized = START_LOCATION_NORMALIZED
+  currentRoute: Readonly<RouteLocationNormalized> = START_LOCATION_NORMALIZED
 
   constructor(options: RouterOptions) {
     this.history = options.history
@@ -26,7 +26,9 @@ export class Router {
     this.history.listen((to, from, info) => {
       // TODO: check navigation guards
       const url = this.history.parseURL(to)
-      this.currentRoute = this.matcher.resolve(url, this.currentRoute)
+      const matchedRoute = this.matcher.resolve(url, this.currentRoute)
+      console.log({ url, matchedRoute })
+      // TODO: navigate
     })
   }
 
@@ -34,14 +36,16 @@ export class Router {
    * Trigger a navigation, should resolve all guards first
    * @param to Where to go
    */
-  push(to: RouterLocation) {
+  push(to: RouteLocation) {
     // TODO: resolve URL
     const url = typeof to === 'string' ? this.history.parseURL(to) : to
     const location = this.matcher.resolve(url, this.currentRoute)
+    console.log(location)
     // TODO: call hooks, guards
-    this.history.push(location.fullPath)
-    this.currentRoute = location
+    // TODO: navigate
+    // this.history.push(location.fullPath)
+    // this.currentRoute = location
   }
 
-  getRouteRecord(location: RouterLocation) {}
+  getRouteRecord(location: RouteLocation) {}
 }
index 99bd875055d012fb0ef8be3185ce072ae7e07142..b505137a92852541ba6faed61999093dab91a044 100644 (file)
@@ -1,10 +1,49 @@
-import { HistoryURL } from '../history/base'
-
 type TODO = any
 
+// TODO: support numbers for easier writing but cast them
 export type RouteParams = Record<string, string | string[]>
 export type RouteQuery = Record<string, string | string[] | null>
 
+export interface RouteQueryAndHash {
+  query?: RouteQuery
+  hash?: string
+}
+export interface LocationAsPath {
+  path: string
+}
+
+export interface LocationAsName {
+  name: string
+  params?: RouteParams
+}
+
+export interface LocationAsRelative {
+  params?: RouteParams
+}
+
+// User level location
+export type RouteLocation =
+  | string
+  | RouteQueryAndHash & LocationAsPath
+  | RouteQueryAndHash & LocationAsName
+  | RouteQueryAndHash & LocationAsRelative
+
+// the matcher doesn't care about query and hash
+export type MatcherLocation =
+  | LocationAsPath
+  | LocationAsName
+  | LocationAsRelative
+
+// exposed to the user in a very consistant way
+export interface RouteLocationNormalized {
+  path: string
+  fullPath: string
+  name: string | void
+  params: RouteParams
+  query: RouteQuery
+  hash: string
+}
+
 // interface PropsTransformer {
 //   (params: RouteParams): any
 // }
@@ -26,51 +65,27 @@ export interface RouteRecord {
   // props: PT
 }
 
-type RouteObjectLocation =
-  | {
-      // no params because they must be provided by the user
-      path: string
-      query?: RouteQuery
-      hash?: string
-    }
-  | {
-      // named location
-      name: string
-      params?: RouteParams
-      query?: RouteQuery
-      hash?: string
-    }
-  | {
-      // relative location
-      params?: RouteParams
-      query?: RouteQuery
-      hash?: string
-    }
-
-// TODO: location should be an object
-export type MatcherLocation = HistoryURL | RouteObjectLocation
-
-export type RouterLocation = string | RouteObjectLocation
-
-export interface RouterLocationNormalized {
-  path: string
-  fullPath: string
-  name?: string
-  params: RouteParams
-  query: RouteQuery
-  hash: string
-}
-
 export const START_RECORD: RouteRecord = {
   path: '/',
   // @ts-ignore
   component: { render: h => h() },
 }
 
-export const START_LOCATION_NORMALIZED: RouterLocationNormalized = {
+export const START_LOCATION_NORMALIZED: RouteLocationNormalized = {
   path: '/',
+  name: undefined,
   params: {},
   query: {},
   hash: '',
   fullPath: '/',
 }
+
+// Matcher types
+// TODO: can probably have types with no record, path and others
+// should be an & type
+export interface MatcherLocationNormalized {
+  name: RouteLocationNormalized['name']
+  path: string
+  // record?
+  params: RouteLocationNormalized['params']
+}