Merge "Move PROJECT_DASHBOARD_ROUTE into view model"
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 3af8207..1d2a272 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -280,6 +280,7 @@
 export class PageContext {
   /**
    * Includes everything: base, path, query and hash.
+   * NOT decoded.
    */
   canonicalPath = '';
 
@@ -287,18 +288,21 @@
    * Does not include base path.
    * Does not include hash.
    * Includes query string.
+   * NOT decoded.
    */
   path = '';
 
-  /** Does not include hash. */
+  /** Decoded. Does not include hash. */
   querystring = '';
 
+  /** Decoded. */
   hash = '';
 
   /**
    * Regular expression matches of capturing groups. The first entry params[0]
    * corresponds to the first capturing group. The entire matched string is not
    * returned in this array.
+   * Each param is double decoded.
    */
   params: string[] = [];
 
@@ -346,17 +350,24 @@
   replaceState() {
     window.history.replaceState(this.state, this.title, this.canonicalPath);
   }
+
+  match(re: RegExp) {
+    const qsIndex = this.path.indexOf('?');
+    const pathname = qsIndex !== -1 ? this.path.slice(0, qsIndex) : this.path;
+    const matches = re.exec(decodeURIComponent(pathname));
+    if (matches) {
+      this.params = matches
+        .slice(1)
+        .map(match => decodeURIComponentString(match));
+    }
+    return !!matches;
+  }
 }
 
 function createRoute(re: RegExp, fn: Function) {
   return (ctx: PageContext, next: Function) => {
-    const qsIndex = ctx.path.indexOf('?');
-    const pathname = qsIndex !== -1 ? ctx.path.slice(0, qsIndex) : ctx.path;
-    const matches = re.exec(decodeURIComponent(pathname));
+    const matches = ctx.match(re);
     if (matches) {
-      ctx.params = matches
-        .slice(1)
-        .map(match => decodeURIComponentString(match));
       fn(ctx, next);
     } else {
       next();
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 997d9d5..da4a576 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -14,7 +14,6 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
-  DashboardId,
   GroupId,
   NumericChangeId,
   RevisionPatchSetNum,
@@ -73,6 +72,7 @@
 import {
   DashboardViewModel,
   DashboardViewState,
+  PROJECT_DASHBOARD_ROUTE,
 } from '../../../models/views/dashboard';
 import {
   SettingsViewModel,
@@ -107,7 +107,6 @@
 
   DASHBOARD: /^\/dashboard\/(.+)$/,
   CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
-  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
   LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
 
   AGREEMENTS: /^\/settings\/agreements\/?/,
@@ -635,10 +634,10 @@
       ctx => this.handleCustomDashboardRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.PROJECT_DASHBOARD,
-      'handleProjectDashboardRoute',
-      ctx => this.handleProjectDashboardRoute(ctx)
+    this.mapRouteState(
+      PROJECT_DASHBOARD_ROUTE,
+      this.dashboardViewModel,
+      'handleProjectDashboardRoute'
     );
 
     this.mapRoute(
@@ -1008,19 +1007,6 @@
     return Promise.resolve();
   }
 
-  handleProjectDashboardRoute(ctx: PageContext) {
-    const project = ctx.params[0] as RepoName;
-    const state: DashboardViewState = {
-      view: GerritView.DASHBOARD,
-      project,
-      dashboard: decodeURIComponent(ctx.params[1]) as DashboardId,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.dashboardViewModel.setState(state);
-    this.reporting.setRepoName(project);
-  }
-
   handleLegacyProjectDashboardRoute(ctx: PageContext) {
     this.redirect('/p/' + ctx.params[0] + '/+/dashboard/' + ctx.params[1]);
   }
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index b6089af..0881018 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -3,28 +3,34 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {assert} from '@open-wc/testing';
-import {PageContext} from '../../elements/core/gr-router/gr-page';
 import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
-import {AdminChildView, PLUGIN_LIST_ROUTE} from './admin';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+  PLUGIN_LIST_ROUTE,
+} from './admin';
 
 suite('admin view model', () => {
   suite('routes', () => {
     test('PLUGIN_LIST', () => {
-      const {urlPattern: pattern, createState} = PLUGIN_LIST_ROUTE;
+      assertRouteFalse(PLUGIN_LIST_ROUTE, 'admin/plugins');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins?');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '/admin/plugins//');
 
-      assert.isTrue(pattern.test('/admin/plugins'));
-      assert.isTrue(pattern.test('/admin/plugins/'));
-      assert.isFalse(pattern.test('admin/plugins'));
-      assert.isFalse(pattern.test('//admin/plugins'));
-      assert.isFalse(pattern.test('//admin/plugins?'));
-      assert.isFalse(pattern.test('/admin/plugins//'));
-
-      assert.deepEqual(createState(new PageContext('')), {
+      const state: AdminViewState = {
         view: GerritView.ADMIN,
         adminView: AdminChildView.PLUGINS,
-      });
+      };
+      assertRouteState<AdminViewState>(
+        PLUGIN_LIST_ROUTE,
+        '/admin/plugins',
+        state,
+        createAdminUrl
+      );
     });
   });
 });
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index 74523db..d2e7995 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -10,7 +10,21 @@
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+
+export const PROJECT_DASHBOARD_ROUTE: Route<DashboardViewState> = {
+  urlPattern: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+  createState: ctx => {
+    const project = (ctx.params[0] ?? '') as RepoName;
+    const dashboard = (ctx.params[1] ?? '') as DashboardId;
+    const state: DashboardViewState = {
+      view: GerritView.DASHBOARD,
+      project,
+      dashboard,
+    };
+    return state;
+  },
+};
 
 export interface DashboardViewState extends ViewState {
   view: GerritView.DASHBOARD;
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index a7620dd..9509977 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -5,11 +5,36 @@
  */
 import {assert} from '@open-wc/testing';
 import {RepoName} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
 import {DashboardId} from '../../types/common';
-import {createDashboardUrl} from './dashboard';
+import {
+  createDashboardUrl,
+  DashboardViewState,
+  PROJECT_DASHBOARD_ROUTE,
+} from './dashboard';
 
 suite('dashboard view state tests', () => {
+  suite('routes', () => {
+    test('PROJECT_DASHBOARD_ROUTE', () => {
+      assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p//+/dashboard/qwer');
+      assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p/asdf/+/dashboard/');
+
+      const state: DashboardViewState = {
+        view: GerritView.DASHBOARD,
+        project: 'asdf' as RepoName,
+        dashboard: 'qwer' as DashboardId,
+      };
+      assertRouteState(
+        PROJECT_DASHBOARD_ROUTE,
+        '/p/asdf/+/dashboard/qwer',
+        state,
+        createDashboardUrl
+      );
+    });
+  });
+
   suite('createDashboardUrl()', () => {
     test('self dashboard', () => {
       assert.equal(createDashboardUrl({}), '/dashboard/self');
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index c400d9c..19c3a7b 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -14,6 +14,8 @@
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
 import {assert} from '@open-wc/testing';
+import {Route, ViewState} from '../models/views/base';
+import {PageContext} from '../elements/core/gr-router/gr-page';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise<T> extends Promise<T> {
@@ -328,3 +330,26 @@
   };
   return new Proxy(obj, handler) as unknown as T;
 }
+
+export function assertRouteState<T extends ViewState>(
+  route: Route<T>,
+  path: string,
+  state: T,
+  createUrl: (state: T) => string
+) {
+  const {urlPattern, createState} = route;
+  const ctx = new PageContext(path);
+  const matches = ctx.match(urlPattern);
+  assert.isTrue(matches);
+  assert.deepEqual(createState(ctx), state);
+  assert.equal(path, createUrl(state));
+}
+
+export function assertRouteFalse<T extends ViewState>(
+  route: Route<T>,
+  path: string
+) {
+  const ctx = new PageContext(path);
+  const matches = ctx.match(route.urlPattern);
+  assert.isFalse(matches);
+}