Remove custom URL parameter parsing in favor of URLSearchParams

Release-Notes: skip
Google-Bug-Id: b/244279450
Change-Id: Idd36303d079f17a9b82aab3e3e27ccfdc480e194
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 79b7d44..3912de7 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -79,6 +79,7 @@
 } from '../../../models/views/documentation';
 import {PluginViewModel, PluginViewState} from '../../../models/views/plugin';
 import {SearchViewModel, SearchViewState} from '../../../models/views/search';
+import {DashboardSection} from '../../../utils/dashboard-util';
 
 const RoutePattern = {
   ROOT: '/',
@@ -245,16 +246,6 @@
 const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
 
 /**
- * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
- */
-const PLUS_PATTERN = /\+/g;
-
-/**
- * Pattern to recognize leading '?' in window.location.search, for stripping.
- */
-const QUESTION_PATTERN = /^\?*/;
-
-/**
  * GWT UI would use @\d+ at the end of a path to indicate linenum.
  */
 const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
@@ -277,8 +268,6 @@
   });
 })();
 
-type QueryStringItem = [string, string]; // [key, value]
-
 export const routerToken = define<GrRouter>('router');
 
 export class GrRouter implements Finalizable {
@@ -918,48 +907,6 @@
   }
 
   /**
-   * Decode an application/x-www-form-urlencoded string.
-   *
-   * @param qs The application/x-www-form-urlencoded string.
-   * @return The decoded string.
-   */
-  private decodeQueryString(qs: string) {
-    return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
-  }
-
-  /**
-   * Parse a query string (e.g. window.location.search) into an array of
-   * name/value pairs.
-   *
-   * @param qs The application/x-www-form-urlencoded query string.
-   * @return An array of name/value pairs, where each
-   * element is a 2-element array.
-   */
-  parseQueryString(qs: string): Array<QueryStringItem> {
-    qs = qs.replace(QUESTION_PATTERN, '');
-    if (!qs) {
-      return [];
-    }
-    const params: Array<[string, string]> = [];
-    qs.split('&').forEach(param => {
-      const idx = param.indexOf('=');
-      let name;
-      let value;
-      if (idx < 0) {
-        name = this.decodeQueryString(param);
-        value = '';
-      } else {
-        name = this.decodeQueryString(param.substring(0, idx));
-        value = this.decodeQueryString(param.substring(idx + 1));
-      }
-      if (name) {
-        params.push([name, value]);
-      }
-    });
-    return params;
-  }
-
-  /**
    * Handle dashboard routes. These may be user, or project dashboards.
    */
   handleDashboardRoute(data: PageContext) {
@@ -984,62 +931,38 @@
     });
   }
 
-  /**
-   * Handle custom dashboard routes.
-   *
-   * @param qs Optional query string associated with the route.
-   * If not given, window.location.search is used. (Used by tests).
-   */
-  handleCustomDashboardRoute(
-    _: PageContext,
-    qs: string = window.location.search
-  ) {
-    const queryParams = this.parseQueryString(qs);
-    let title = 'Custom Dashboard';
-    const titleParam = queryParams.find(
-      elem => elem[0].toLowerCase() === 'title'
-    );
-    if (titleParam) {
-      title = titleParam[1];
-    }
-    // Dashboards support a foreach param which adds a base query to any
-    // additional query.
-    const forEachParam = queryParams.find(
-      elem => elem[0].toLowerCase() === 'foreach'
-    );
-    let forEachQuery: string | null = null;
-    if (forEachParam) {
-      forEachQuery = forEachParam[1];
-    }
-    const sectionParams = queryParams.filter(
-      elem =>
-        elem[0] &&
-        elem[1] &&
-        elem[0].toLowerCase() !== 'title' &&
-        elem[0].toLowerCase() !== 'foreach'
-    );
-    const sections = sectionParams.map(elem => {
-      const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
-      return {
-        name: elem[0],
-        query,
-      };
-    });
+  handleCustomDashboardRoute(ctx: PageContext) {
+    const queryParams = new URLSearchParams(ctx.querystring);
 
-    if (sections.length > 0) {
-      const state: DashboardViewState = {
-        view: GerritView.DASHBOARD,
-        user: 'self',
-        sections,
-        title,
-      };
-      this.setState(state);
-      this.dashboardViewModel.updateState(state);
+    let title = 'Custom Dashboard';
+    const titleParam = queryParams.get('title');
+    if (titleParam) title = titleParam;
+    queryParams.delete('title');
+
+    let forEachQuery = '';
+    const forEachParam = queryParams.get('foreach');
+    if (forEachParam) forEachQuery = forEachParam + ' ';
+    queryParams.delete('foreach');
+
+    const sections: DashboardSection[] = [];
+    for (const [name, query] of queryParams) {
+      if (!name || !query) continue;
+      sections.push({name, query: `${forEachQuery}${query}`});
+    }
+
+    if (sections.length === 0) {
+      this.redirect('/dashboard/self');
       return Promise.resolve();
     }
 
-    // Redirect /dashboard/ -> /dashboard/self.
-    this.redirect('/dashboard/self');
+    const state: DashboardViewState = {
+      view: GerritView.DASHBOARD,
+      user: 'self',
+      sections,
+      title,
+    };
+    this.setState(state);
+    this.dashboardViewModel.updateState(state);
     return Promise.resolve();
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 96078cc..a766176 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -630,12 +630,13 @@
       });
 
       test('no user specified', () => {
-        const data = {
+        const data: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '',
         };
-        return router.handleCustomDashboardRoute(data, '').then(() => {
+        return router.handleCustomDashboardRoute(data).then(() => {
           assert.isFalse(setStateStub.called);
           assert.isTrue(redirectStub.called);
           assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
@@ -643,68 +644,65 @@
       });
 
       test('custom dashboard without title', () => {
-        const data = {
+        const data: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '?a=b&c&d=e',
         };
-        return router
-          .handleCustomDashboardRoute(data, '?a=b&c&d=e')
-          .then(() => {
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(setStateStub.calledOnce);
-            assert.deepEqual(setStateStub.lastCall.args[0], {
-              view: GerritView.DASHBOARD,
-              user: 'self',
-              sections: [
-                {name: 'a', query: 'b'},
-                {name: 'd', query: 'e'},
-              ],
-              title: 'Custom Dashboard',
-            });
+        return router.handleCustomDashboardRoute(data).then(() => {
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [
+              {name: 'a', query: 'b'},
+              {name: 'd', query: 'e'},
+            ],
+            title: 'Custom Dashboard',
           });
+        });
       });
 
       test('custom dashboard with title', () => {
-        const data = {
+        const data: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '?a=b&c&d=&=e&title=t',
         };
-        return router
-          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&title=t')
-          .then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(setStateStub.calledOnce);
-            assert.deepEqual(setStateStub.lastCall.args[0], {
-              view: GerritView.DASHBOARD,
-              user: 'self',
-              sections: [{name: 'a', query: 'b'}],
-              title: 't',
-            });
+        return router.handleCustomDashboardRoute(data).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [{name: 'a', query: 'b'}],
+            title: 't',
           });
+        });
       });
 
       test('custom dashboard with foreach', () => {
-        const data = {
+        const data: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '?a=b&c&d=&=e&foreach=is:open',
         };
-        return router
-          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&foreach=is:open')
-          .then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(setStateStub.calledOnce);
-            assert.deepEqual(setStateStub.lastCall.args[0], {
-              view: GerritView.DASHBOARD,
-              user: 'self',
-              sections: [{name: 'a', query: 'is:open b'}],
-              title: 'Custom Dashboard',
-            });
+        return router.handleCustomDashboardRoute(data).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [{name: 'a', query: 'is:open b'}],
+            title: 'Custom Dashboard',
           });
+        });
       });
     });
 
@@ -1336,35 +1334,4 @@
       assert.isFalse(redirectStub.called);
     });
   });
-
-  suite('parseQueryString', () => {
-    test('empty queries', () => {
-      assert.deepEqual(router.parseQueryString(''), []);
-      assert.deepEqual(router.parseQueryString('?'), []);
-      assert.deepEqual(router.parseQueryString('??'), []);
-      assert.deepEqual(router.parseQueryString('&&&'), []);
-    });
-
-    test('url decoding', () => {
-      assert.deepEqual(router.parseQueryString('+'), [[' ', '']]);
-      assert.deepEqual(router.parseQueryString('???+%3d+'), [[' = ', '']]);
-      assert.deepEqual(
-        router.parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
-        [['name', 'value']]
-      );
-    });
-
-    test('multiple parameters', () => {
-      assert.deepEqual(router.parseQueryString('a=b&c=d&e=f'), [
-        ['a', 'b'],
-        ['c', 'd'],
-        ['e', 'f'],
-      ]);
-      assert.deepEqual(router.parseQueryString('&a=b&&&e=f&c'), [
-        ['a', 'b'],
-        ['e', 'f'],
-        ['c', ''],
-      ]);
-    });
-  });
 });