Merge "Add mentioned users to CC and Attention Set"
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 f4fa348..a95ecb3 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -9,16 +9,10 @@
   PageNextCallback,
 } from '../../../utils/page-wrapper-utils';
 import {
-  DashboardSection,
   GeneratedWebLink,
   GenerateUrlChangeViewParameters,
-  GenerateUrlDashboardViewParameters,
   GenerateUrlDiffViewParameters,
-  GenerateUrlEditViewParameters,
-  GenerateUrlGroupViewParameters,
   GenerateUrlParameters,
-  GenerateUrlRepoViewParameters,
-  GenerateUrlSearchViewParameters,
   GenerateWebLinksChangeParameters,
   GenerateWebLinksEditParameters,
   GenerateWebLinksFileParameters,
@@ -27,7 +21,6 @@
   GenerateWebLinksResolveConflictsParameters,
   GerritNav,
   GroupDetailView,
-  isGenerateUrlDiffViewParameters,
   RepoDetailView,
   WeblinkType,
 } from '../gr-navigation/gr-navigation';
@@ -53,16 +46,15 @@
 import {LocationChangeEventDetail} from '../../../types/events';
 import {GerritView} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
-import {addQuotesWhen} from '../../../utils/string-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
-  encodeURL,
   getBaseUrl,
   toPath,
   toPathname,
   toSearchParams,
 } from '../../../utils/url-util';
 import {Execution, LifeCycle, Timing} from '../../../constants/reporting';
+import {generateUrl} from '../../../utils/router-util';
 
 const RoutePattern = {
   ROOT: '/',
@@ -245,8 +237,6 @@
 
 const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
 
-const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
-
 // Polymer makes `app` intrinsically defined on the window by virtue of the
 // custom element having the id "pg-app", but it is made explicit here.
 // If you move this code to other place, please update comment about
@@ -328,33 +318,7 @@
   }
 
   generateUrl(params: GenerateUrlParameters) {
-    const base = getBaseUrl();
-    let url = '';
-
-    if (params.view === GerritView.SEARCH) {
-      url = this.generateSearchUrl(params);
-    } else if (params.view === GerritView.CHANGE) {
-      url = this.generateChangeUrl(params);
-    } else if (params.view === GerritView.DASHBOARD) {
-      url = this.generateDashboardUrl(params);
-    } else if (
-      params.view === GerritView.DIFF ||
-      params.view === GerritView.EDIT
-    ) {
-      url = this.generateDiffOrEditUrl(params);
-    } else if (params.view === GerritView.GROUP) {
-      url = this.generateGroupUrl(params);
-    } else if (params.view === GerritView.REPO) {
-      url = this.generateRepoUrl(params);
-    } else if (params.view === GerritView.ROOT) {
-      url = '/';
-    } else if (params.view === GerritView.SETTINGS) {
-      url = this.generateSettingsUrl();
-    } else {
-      assertNever(params, "Can't generate");
-    }
-
-    return base + url;
+    return generateUrl(params);
   }
 
   generateWeblinks(
@@ -458,216 +422,6 @@
     return params.options?.weblinks ?? [];
   }
 
-  private generateSearchUrl(params: GenerateUrlSearchViewParameters) {
-    let offsetExpr = '';
-    if (params.offset && params.offset > 0) {
-      offsetExpr = `,${params.offset}`;
-    }
-
-    if (params.query) {
-      return '/q/' + encodeURL(params.query, true) + offsetExpr;
-    }
-
-    const operators: string[] = [];
-    if (params.owner) {
-      operators.push('owner:' + encodeURL(params.owner, false));
-    }
-    if (params.project) {
-      operators.push('project:' + encodeURL(params.project, false));
-    }
-    if (params.branch) {
-      operators.push('branch:' + encodeURL(params.branch, false));
-    }
-    if (params.topic) {
-      operators.push(
-        'topic:' +
-          addQuotesWhen(
-            encodeURL(params.topic, false),
-            /[\s:]/.test(params.topic)
-          )
-      );
-    }
-    if (params.hashtag) {
-      operators.push(
-        'hashtag:' +
-          addQuotesWhen(
-            encodeURL(params.hashtag.toLowerCase(), false),
-            /[\s:]/.test(params.hashtag)
-          )
-      );
-    }
-    if (params.statuses) {
-      if (params.statuses.length === 1) {
-        operators.push('status:' + encodeURL(params.statuses[0], false));
-      } else if (params.statuses.length > 1) {
-        operators.push(
-          '(' +
-            params.statuses
-              .map(s => `status:${encodeURL(s, false)}`)
-              .join(' OR ') +
-            ')'
-        );
-      }
-    }
-
-    return '/q/' + operators.join('+') + offsetExpr;
-  }
-
-  private generateChangeUrl(params: GenerateUrlChangeViewParameters) {
-    let range = this.getPatchRangeExpression(params);
-    if (range.length) {
-      range = '/' + range;
-    }
-    let suffix = `${range}`;
-    const queries = [];
-    if (params.forceReload) {
-      queries.push('forceReload=true');
-    }
-    if (params.openReplyDialog) {
-      queries.push('openReplyDialog=true');
-    }
-    if (params.usp) {
-      queries.push(`usp=${params.usp}`);
-    }
-    if (params.edit) {
-      suffix += ',edit';
-    }
-    if (params.commentId) {
-      suffix = suffix + `/comments/${params.commentId}`;
-    }
-    if (queries.length > 0) {
-      suffix += '?' + queries.join('&');
-    }
-    if (params.messageHash) {
-      suffix += params.messageHash;
-    }
-    if (params.project) {
-      const encodedProject = encodeURL(params.project, true);
-      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-    } else {
-      return `/c/${params.changeNum}${suffix}`;
-    }
-  }
-
-  private generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
-    const repoName = params.repo || params.project || undefined;
-    if (params.sections) {
-      // Custom dashboard.
-      const queryParams = this.sectionsToEncodedParams(
-        params.sections,
-        repoName
-      );
-      if (params.title) {
-        queryParams.push('title=' + encodeURIComponent(params.title));
-      }
-      const user = params.user ? params.user : '';
-      return `/dashboard/${user}?${queryParams.join('&')}`;
-    } else if (repoName) {
-      // Project dashboard.
-      const encodedRepo = encodeURL(repoName, true);
-      return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
-    } else {
-      // User dashboard.
-      return `/dashboard/${params.user || 'self'}`;
-    }
-  }
-
-  private sectionsToEncodedParams(
-    sections: DashboardSection[],
-    repoName?: RepoName
-  ) {
-    return sections.map(section => {
-      // If there is a repo name provided, make sure to substitute it into the
-      // ${repo} (or legacy ${project}) query tokens.
-      const query = repoName
-        ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
-        : section.query;
-      return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
-    });
-  }
-
-  private generateDiffOrEditUrl(
-    params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
-  ) {
-    let range = this.getPatchRangeExpression(params);
-    if (range.length) {
-      range = '/' + range;
-    }
-
-    let suffix = `${range}/${encodeURL(params.path || '', true)}`;
-
-    if (params.view === GerritView.EDIT) {
-      suffix += ',edit';
-    }
-
-    if (params.lineNum) {
-      suffix += '#';
-      if (isGenerateUrlDiffViewParameters(params) && params.leftSide) {
-        suffix += 'b';
-      }
-      suffix += params.lineNum;
-    }
-
-    if (isGenerateUrlDiffViewParameters(params) && params.commentId) {
-      suffix = `/comment/${params.commentId}` + suffix;
-    }
-
-    if (params.project) {
-      const encodedProject = encodeURL(params.project, true);
-      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-    } else {
-      return `/c/${params.changeNum}${suffix}`;
-    }
-  }
-
-  private generateGroupUrl(params: GenerateUrlGroupViewParameters) {
-    let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
-    if (params.detail === GroupDetailView.MEMBERS) {
-      url += ',members';
-    } else if (params.detail === GroupDetailView.LOG) {
-      url += ',audit-log';
-    }
-    return url;
-  }
-
-  private generateRepoUrl(params: GenerateUrlRepoViewParameters) {
-    let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
-    if (params.detail === RepoDetailView.GENERAL) {
-      url += ',general';
-    } else if (params.detail === RepoDetailView.ACCESS) {
-      url += ',access';
-    } else if (params.detail === RepoDetailView.BRANCHES) {
-      url += ',branches';
-    } else if (params.detail === RepoDetailView.TAGS) {
-      url += ',tags';
-    } else if (params.detail === RepoDetailView.COMMANDS) {
-      url += ',commands';
-    } else if (params.detail === RepoDetailView.DASHBOARDS) {
-      url += ',dashboards';
-    }
-    return url;
-  }
-
-  private generateSettingsUrl() {
-    return '/settings';
-  }
-
-  /**
-   * Given an object of parameters, potentially including a `patchNum` or a
-   * `basePatchNum` or both, return a string representation of that range. If
-   * no range is indicated in the params, the empty string is returned.
-   */
-  getPatchRangeExpression(params: PatchRangeParams) {
-    let range = '';
-    if (params.patchNum) {
-      range = `${params.patchNum}`;
-    }
-    if (params.basePatchNum && params.basePatchNum !== PARENT) {
-      range = `${params.basePatchNum}..${range}`;
-    }
-    return range;
-  }
-
   /**
    * Normalizes the params object, and determines if the URL needs to be
    * modified to fit the proper schema.
@@ -821,7 +575,7 @@
           page.show(url);
         }
       },
-      params => this.generateUrl(params),
+      params => generateUrl(params),
       params => this.generateWeblinks(params),
       x => x
     );
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 21f8a99..5fc99d8 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
@@ -8,12 +8,8 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {
   GenerateUrlChangeViewParameters,
-  GenerateUrlDashboardViewParameters,
-  GenerateUrlDiffViewParameters,
   GenerateUrlEditViewParameters,
-  GenerateUrlGroupViewParameters,
   GenerateUrlParameters,
-  GenerateUrlSearchViewParameters,
   GerritNav,
   GroupDetailView,
   WeblinkType,
@@ -32,16 +28,12 @@
 import {GerritView} from '../../../services/router/router-model';
 import {
   BasePatchSetNum,
-  BranchName,
   CommitId,
-  DashboardId,
-  EDIT,
   GroupId,
   NumericChangeId,
   PARENT,
   RepoName,
   RevisionPatchSetNum,
-  TopicName,
   UrlEncodedCommentId,
   WebLinkInfo,
 } from '../../../types/common';
@@ -328,294 +320,6 @@
     });
   });
 
-  suite('generateUrl', () => {
-    test('search', () => {
-      let params: GenerateUrlSearchViewParameters = {
-        view: GerritView.SEARCH,
-        owner: 'a%b',
-        project: 'c%d' as RepoName,
-        branch: 'e%f' as BranchName,
-        topic: 'g%h' as TopicName,
-        statuses: ['op%en'],
-      };
-      assert.equal(
-        router.generateUrl(params),
-        '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:g%2525h+status:op%2525en'
-      );
-
-      params.offset = 100;
-      assert.equal(
-        router.generateUrl(params),
-        '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:g%2525h+status:op%2525en,100'
-      );
-      delete params.offset;
-
-      // The presence of the query param overrides other params.
-      params.query = 'foo$bar';
-      assert.equal(router.generateUrl(params), '/q/foo%2524bar');
-
-      params.offset = 100;
-      assert.equal(router.generateUrl(params), '/q/foo%2524bar,100');
-
-      params = {
-        view: GerritNav.View.SEARCH,
-        statuses: ['a', 'b', 'c'],
-      };
-      assert.equal(
-        router.generateUrl(params),
-        '/q/(status:a OR status:b OR status:c)'
-      );
-
-      params = {
-        view: GerritNav.View.SEARCH,
-        topic: 'test' as TopicName,
-      };
-      assert.equal(router.generateUrl(params), '/q/topic:test');
-      params = {
-        view: GerritNav.View.SEARCH,
-        topic: 'test test' as TopicName,
-      };
-      assert.equal(router.generateUrl(params), '/q/topic:"test+test"');
-      params = {
-        view: GerritNav.View.SEARCH,
-        topic: 'test:test' as TopicName,
-      };
-      assert.equal(router.generateUrl(params), '/q/topic:"test:test"');
-    });
-
-    test('change', () => {
-      const params: GenerateUrlChangeViewParameters = {
-        view: GerritView.CHANGE,
-        changeNum: 1234 as NumericChangeId,
-        project: 'test' as RepoName,
-      };
-
-      assert.equal(router.generateUrl(params), '/c/test/+/1234');
-
-      params.patchNum = 10 as RevisionPatchSetNum;
-      assert.equal(router.generateUrl(params), '/c/test/+/1234/10');
-
-      params.basePatchNum = 5 as BasePatchSetNum;
-      assert.equal(router.generateUrl(params), '/c/test/+/1234/5..10');
-
-      params.messageHash = '#123';
-      assert.equal(router.generateUrl(params), '/c/test/+/1234/5..10#123');
-    });
-
-    test('change with repo name encoding', () => {
-      const params: GenerateUrlChangeViewParameters = {
-        view: GerritView.CHANGE,
-        changeNum: 1234 as NumericChangeId,
-        project: 'x+/y+/z+/w' as RepoName,
-      };
-      assert.equal(
-        router.generateUrl(params),
-        '/c/x%252B/y%252B/z%252B/w/+/1234'
-      );
-    });
-
-    test('diff', () => {
-      const params: GenerateUrlDiffViewParameters = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        path: 'x+y/path.cpp' as RepoName,
-        patchNum: 12 as RevisionPatchSetNum,
-        project: '' as RepoName,
-      };
-      assert.equal(router.generateUrl(params), '/c/42/12/x%252By/path.cpp');
-
-      params.project = 'test' as RepoName;
-      assert.equal(
-        router.generateUrl(params),
-        '/c/test/+/42/12/x%252By/path.cpp'
-      );
-
-      params.basePatchNum = 6 as BasePatchSetNum;
-      assert.equal(
-        router.generateUrl(params),
-        '/c/test/+/42/6..12/x%252By/path.cpp'
-      );
-
-      params.path = 'foo bar/my+file.txt%';
-      params.patchNum = 2 as RevisionPatchSetNum;
-      delete params.basePatchNum;
-      assert.equal(
-        router.generateUrl(params),
-        '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
-      );
-
-      params.path = 'file.cpp';
-      params.lineNum = 123;
-      assert.equal(router.generateUrl(params), '/c/test/+/42/2/file.cpp#123');
-
-      params.leftSide = true;
-      assert.equal(router.generateUrl(params), '/c/test/+/42/2/file.cpp#b123');
-    });
-
-    test('diff with repo name encoding', () => {
-      const params: GenerateUrlDiffViewParameters = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        path: 'x+y/path.cpp',
-        patchNum: 12 as RevisionPatchSetNum,
-        project: 'x+/y' as RepoName,
-      };
-      assert.equal(
-        router.generateUrl(params),
-        '/c/x%252B/y/+/42/12/x%252By/path.cpp'
-      );
-    });
-
-    test(EDIT, () => {
-      const params: GenerateUrlEditViewParameters = {
-        view: GerritView.EDIT,
-        changeNum: 42 as NumericChangeId,
-        project: 'test' as RepoName,
-        path: 'x+y/path.cpp',
-        patchNum: EDIT as RevisionPatchSetNum,
-      };
-      assert.equal(
-        router.generateUrl(params),
-        '/c/test/+/42/edit/x%252By/path.cpp,edit'
-      );
-    });
-
-    test('getPatchRangeExpression', () => {
-      const params: PatchRangeParams = {};
-      let actual = router.getPatchRangeExpression(params);
-      assert.equal(actual, '');
-
-      params.patchNum = 4 as RevisionPatchSetNum;
-      actual = router.getPatchRangeExpression(params);
-      assert.equal(actual, '4');
-
-      params.basePatchNum = 2 as BasePatchSetNum;
-      actual = router.getPatchRangeExpression(params);
-      assert.equal(actual, '2..4');
-
-      delete params.patchNum;
-      actual = router.getPatchRangeExpression(params);
-      assert.equal(actual, '2..');
-    });
-
-    suite('dashboard', () => {
-      test('self dashboard', () => {
-        const params: GenerateUrlDashboardViewParameters = {
-          view: GerritView.DASHBOARD,
-        };
-        assert.equal(router.generateUrl(params), '/dashboard/self');
-      });
-
-      test('user dashboard', () => {
-        const params: GenerateUrlDashboardViewParameters = {
-          view: GerritView.DASHBOARD,
-          user: 'user',
-        };
-        assert.equal(router.generateUrl(params), '/dashboard/user');
-      });
-
-      test('custom self dashboard, no title', () => {
-        const params: GenerateUrlDashboardViewParameters = {
-          view: GerritView.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: 'query 2'},
-          ],
-        };
-        assert.equal(
-          router.generateUrl(params),
-          '/dashboard/?section%201=query%201&section%202=query%202'
-        );
-      });
-
-      test('custom repo dashboard', () => {
-        const params: GenerateUrlDashboardViewParameters = {
-          view: GerritView.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1 ${project}'},
-            {name: 'section 2', query: 'query 2 ${repo}'},
-          ],
-          repo: 'repo-name' as RepoName,
-        };
-        assert.equal(
-          router.generateUrl(params),
-          '/dashboard/?section%201=query%201%20repo-name&' +
-            'section%202=query%202%20repo-name'
-        );
-      });
-
-      test('custom user dashboard, with title', () => {
-        const params: GenerateUrlDashboardViewParameters = {
-          view: GerritView.DASHBOARD,
-          user: 'user',
-          sections: [{name: 'name', query: 'query'}],
-          title: 'custom dashboard',
-        };
-        assert.equal(
-          router.generateUrl(params),
-          '/dashboard/user?name=query&title=custom%20dashboard'
-        );
-      });
-
-      test('repo dashboard', () => {
-        const params: GenerateUrlDashboardViewParameters = {
-          view: GerritView.DASHBOARD,
-          repo: 'gerrit/repo' as RepoName,
-          dashboard: 'default:main' as DashboardId,
-        };
-        assert.equal(
-          router.generateUrl(params),
-          '/p/gerrit/repo/+/dashboard/default:main'
-        );
-      });
-
-      test('project dashboard (legacy)', () => {
-        const params: GenerateUrlDashboardViewParameters = {
-          view: GerritView.DASHBOARD,
-          project: 'gerrit/project' as RepoName,
-          dashboard: 'default:main' as DashboardId,
-        };
-        assert.equal(
-          router.generateUrl(params),
-          '/p/gerrit/project/+/dashboard/default:main'
-        );
-      });
-    });
-
-    suite('groups', () => {
-      test('group info', () => {
-        const params: GenerateUrlGroupViewParameters = {
-          view: GerritView.GROUP,
-          groupId: '1234' as GroupId,
-        };
-        assert.equal(router.generateUrl(params), '/admin/groups/1234');
-      });
-
-      test('group members', () => {
-        const params: GenerateUrlGroupViewParameters = {
-          view: GerritView.GROUP,
-          groupId: '1234' as GroupId,
-          detail: 'members' as GroupDetailView,
-        };
-        assert.equal(router.generateUrl(params), '/admin/groups/1234,members');
-      });
-
-      test('group audit log', () => {
-        const params: GenerateUrlGroupViewParameters = {
-          view: GerritView.GROUP,
-          groupId: '1234' as GroupId,
-          detail: 'log' as GroupDetailView,
-        };
-        assert.equal(
-          router.generateUrl(params),
-          '/admin/groups/1234,audit-log'
-        );
-      });
-    });
-  });
-
   suite('param normalization', () => {
     suite('normalizePatchRangeParams', () => {
       test('range n..n normalizes to n', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index cbc1428..76c5706 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -30,7 +30,7 @@
 
 export const WIP_TOOLTIP =
   "This change isn't ready to be reviewed or submitted. " +
-  "It will not appear on dashboards unless you are CC'ed, " +
+  'It will not appear on dashboards unless you are in the attention set, ' +
   'and email notifications will be silenced until the review is started.';
 
 export const MERGE_CONFLICT_TOOLTIP =
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
index f38444a..8bffa4c 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -34,7 +34,7 @@
         has-tooltip=""
         max-width="40em"
         position-below=""
-        title="This change isn't ready to be reviewed or submitted. It will not appear on dashboards unless you are CC'ed, and email notifications will be silenced until the review is started."
+        title="This change isn't ready to be reviewed or submitted. It will not appear on dashboards unless you are in the attention set, and email notifications will be silenced until the review is started."
       >
         <div aria-label="Label: WIP" class="chip">Work in Progress</div>
       </gr-tooltip-content>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index cdd4c4c..e93b020 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -100,7 +100,9 @@
             @click=${this._copyToClipboard}
             aria-label="Click to copy to clipboard"
           >
-            <gr-icon id="icon" icon="content_copy" small></gr-icon>
+            <div>
+              <gr-icon id="icon" icon="content_copy" small></gr-icon>
+            </div>
           </gr-button>
         </gr-tooltip-content>
       </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index b10f488..0840c3b 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -43,7 +43,9 @@
             role="button"
             tabindex="0"
           >
-            <gr-icon icon="content_copy" id="icon" small></gr-icon>
+            <div>
+              <gr-icon icon="content_copy" id="icon" small></gr-icon>
+            </div>
           </gr-button>
         </gr-tooltip-content>
       </div>
diff --git a/polygerrit-ui/app/utils/router-util.ts b/polygerrit-ui/app/utils/router-util.ts
new file mode 100644
index 0000000..6340f1b
--- /dev/null
+++ b/polygerrit-ui/app/utils/router-util.ts
@@ -0,0 +1,268 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PARENT, RepoName} from '../types/common';
+import {
+  DashboardSection,
+  GenerateUrlChangeViewParameters,
+  GenerateUrlDashboardViewParameters,
+  GenerateUrlDiffViewParameters,
+  GenerateUrlEditViewParameters,
+  GenerateUrlGroupViewParameters,
+  GenerateUrlParameters,
+  GenerateUrlRepoViewParameters,
+  GenerateUrlSearchViewParameters,
+  GroupDetailView,
+  isGenerateUrlDiffViewParameters,
+  RepoDetailView,
+} from '../elements/core/gr-navigation/gr-navigation';
+import {PatchRangeParams} from '../elements/core/gr-router/gr-router';
+import {encodeURL, getBaseUrl} from './url-util';
+import {assertNever} from './common-util';
+import {GerritView} from '../services/router/router-model';
+import {addQuotesWhen} from './string-util';
+
+export const TEST_ONLY = {
+  getPatchRangeExpression,
+};
+
+export function generateUrl(params: GenerateUrlParameters) {
+  const base = getBaseUrl();
+  let url = '';
+
+  if (params.view === GerritView.SEARCH) {
+    url = generateSearchUrl(params);
+  } else if (params.view === GerritView.CHANGE) {
+    url = generateChangeUrl(params);
+  } else if (params.view === GerritView.DASHBOARD) {
+    url = generateDashboardUrl(params);
+  } else if (
+    params.view === GerritView.DIFF ||
+    params.view === GerritView.EDIT
+  ) {
+    url = generateDiffOrEditUrl(params);
+  } else if (params.view === GerritView.GROUP) {
+    url = generateGroupUrl(params);
+  } else if (params.view === GerritView.REPO) {
+    url = generateRepoUrl(params);
+  } else if (params.view === GerritView.ROOT) {
+    url = '/';
+  } else if (params.view === GerritView.SETTINGS) {
+    url = generateSettingsUrl();
+  } else {
+    assertNever(params, "Can't generate");
+  }
+
+  return base + url;
+}
+
+/**
+ * Given an object of parameters, potentially including a `patchNum` or a
+ * `basePatchNum` or both, return a string representation of that range. If
+ * no range is indicated in the params, the empty string is returned.
+ */
+function getPatchRangeExpression(params: PatchRangeParams) {
+  let range = '';
+  if (params.patchNum) {
+    range = `${params.patchNum}`;
+  }
+  if (params.basePatchNum && params.basePatchNum !== PARENT) {
+    range = `${params.basePatchNum}..${range}`;
+  }
+  return range;
+}
+
+function generateChangeUrl(params: GenerateUrlChangeViewParameters) {
+  let range = getPatchRangeExpression(params);
+  if (range.length) {
+    range = '/' + range;
+  }
+  let suffix = `${range}`;
+  const queries = [];
+  if (params.forceReload) {
+    queries.push('forceReload=true');
+  }
+  if (params.openReplyDialog) {
+    queries.push('openReplyDialog=true');
+  }
+  if (params.usp) {
+    queries.push(`usp=${params.usp}`);
+  }
+  if (params.edit) {
+    suffix += ',edit';
+  }
+  if (params.commentId) {
+    suffix = suffix + `/comments/${params.commentId}`;
+  }
+  if (queries.length > 0) {
+    suffix += '?' + queries.join('&');
+  }
+  if (params.messageHash) {
+    suffix += params.messageHash;
+  }
+  if (params.project) {
+    const encodedProject = encodeURL(params.project, true);
+    return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+  } else {
+    return `/c/${params.changeNum}${suffix}`;
+  }
+}
+
+const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
+
+function sectionsToEncodedParams(
+  sections: DashboardSection[],
+  repoName?: RepoName
+) {
+  return sections.map(section => {
+    // If there is a repo name provided, make sure to substitute it into the
+    // ${repo} (or legacy ${project}) query tokens.
+    const query = repoName
+      ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
+      : section.query;
+    return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+  });
+}
+
+function generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
+  const repoName = params.repo || params.project || undefined;
+  if (params.sections) {
+    // Custom dashboard.
+    const queryParams = sectionsToEncodedParams(params.sections, repoName);
+    if (params.title) {
+      queryParams.push('title=' + encodeURIComponent(params.title));
+    }
+    const user = params.user ? params.user : '';
+    return `/dashboard/${user}?${queryParams.join('&')}`;
+  } else if (repoName) {
+    // Project dashboard.
+    const encodedRepo = encodeURL(repoName, true);
+    return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
+  } else {
+    // User dashboard.
+    return `/dashboard/${params.user || 'self'}`;
+  }
+}
+
+function generateSearchUrl(params: GenerateUrlSearchViewParameters) {
+  let offsetExpr = '';
+  if (params.offset && params.offset > 0) {
+    offsetExpr = `,${params.offset}`;
+  }
+
+  if (params.query) {
+    return '/q/' + encodeURL(params.query, true) + offsetExpr;
+  }
+
+  const operators: string[] = [];
+  if (params.owner) {
+    operators.push('owner:' + encodeURL(params.owner, false));
+  }
+  if (params.project) {
+    operators.push('project:' + encodeURL(params.project, false));
+  }
+  if (params.branch) {
+    operators.push('branch:' + encodeURL(params.branch, false));
+  }
+  if (params.topic) {
+    operators.push(
+      'topic:' +
+        addQuotesWhen(
+          encodeURL(params.topic, false),
+          /[\s:]/.test(params.topic)
+        )
+    );
+  }
+  if (params.hashtag) {
+    operators.push(
+      'hashtag:' +
+        addQuotesWhen(
+          encodeURL(params.hashtag.toLowerCase(), false),
+          /[\s:]/.test(params.hashtag)
+        )
+    );
+  }
+  if (params.statuses) {
+    if (params.statuses.length === 1) {
+      operators.push('status:' + encodeURL(params.statuses[0], false));
+    } else if (params.statuses.length > 1) {
+      operators.push(
+        '(' +
+          params.statuses
+            .map(s => `status:${encodeURL(s, false)}`)
+            .join(' OR ') +
+          ')'
+      );
+    }
+  }
+
+  return '/q/' + operators.join('+') + offsetExpr;
+}
+
+function generateDiffOrEditUrl(
+  params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
+) {
+  let range = getPatchRangeExpression(params);
+  if (range.length) {
+    range = '/' + range;
+  }
+
+  let suffix = `${range}/${encodeURL(params.path || '', true)}`;
+
+  if (params.view === GerritView.EDIT) {
+    suffix += ',edit';
+  }
+
+  if (params.lineNum) {
+    suffix += '#';
+    if (isGenerateUrlDiffViewParameters(params) && params.leftSide) {
+      suffix += 'b';
+    }
+    suffix += params.lineNum;
+  }
+
+  if (isGenerateUrlDiffViewParameters(params) && params.commentId) {
+    suffix = `/comment/${params.commentId}` + suffix;
+  }
+
+  if (params.project) {
+    const encodedProject = encodeURL(params.project, true);
+    return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+  } else {
+    return `/c/${params.changeNum}${suffix}`;
+  }
+}
+
+function generateGroupUrl(params: GenerateUrlGroupViewParameters) {
+  let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
+  if (params.detail === GroupDetailView.MEMBERS) {
+    url += ',members';
+  } else if (params.detail === GroupDetailView.LOG) {
+    url += ',audit-log';
+  }
+  return url;
+}
+
+function generateRepoUrl(params: GenerateUrlRepoViewParameters) {
+  let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
+  if (params.detail === RepoDetailView.GENERAL) {
+    url += ',general';
+  } else if (params.detail === RepoDetailView.ACCESS) {
+    url += ',access';
+  } else if (params.detail === RepoDetailView.BRANCHES) {
+    url += ',branches';
+  } else if (params.detail === RepoDetailView.TAGS) {
+    url += ',tags';
+  } else if (params.detail === RepoDetailView.COMMANDS) {
+    url += ',commands';
+  } else if (params.detail === RepoDetailView.DASHBOARDS) {
+    url += ',dashboards';
+  }
+  return url;
+}
+
+function generateSettingsUrl() {
+  return '/settings';
+}
diff --git a/polygerrit-ui/app/utils/router-util_test.ts b/polygerrit-ui/app/utils/router-util_test.ts
new file mode 100644
index 0000000..b0fc324
--- /dev/null
+++ b/polygerrit-ui/app/utils/router-util_test.ts
@@ -0,0 +1,305 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  RepoName,
+  BranchName,
+  TopicName,
+  NumericChangeId,
+  RevisionPatchSetNum,
+  BasePatchSetNum,
+  EDIT,
+  GroupId,
+} from '../api/rest-api';
+import {
+  GenerateUrlSearchViewParameters,
+  GerritNav,
+  GenerateUrlChangeViewParameters,
+  GenerateUrlDiffViewParameters,
+  GenerateUrlEditViewParameters,
+  GenerateUrlDashboardViewParameters,
+  GenerateUrlGroupViewParameters,
+  GroupDetailView,
+} from '../elements/core/gr-navigation/gr-navigation';
+import {PatchRangeParams} from '../elements/core/gr-router/gr-router';
+import {GerritView} from '../services/router/router-model';
+import '../test/common-test-setup-karma';
+import {DashboardId} from '../types/common';
+import {generateUrl, TEST_ONLY} from './router-util';
+
+suite('router-util tests', () => {
+  suite('generateUrl', () => {
+    test('search', () => {
+      let params: GenerateUrlSearchViewParameters = {
+        view: GerritView.SEARCH,
+        owner: 'a%b',
+        project: 'c%d' as RepoName,
+        branch: 'e%f' as BranchName,
+        topic: 'g%h' as TopicName,
+        statuses: ['op%en'],
+      };
+      assert.equal(
+        generateUrl(params),
+        '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:g%2525h+status:op%2525en'
+      );
+
+      params.offset = 100;
+      assert.equal(
+        generateUrl(params),
+        '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:g%2525h+status:op%2525en,100'
+      );
+      delete params.offset;
+
+      // The presence of the query param overrides other params.
+      params.query = 'foo$bar';
+      assert.equal(generateUrl(params), '/q/foo%2524bar');
+
+      params.offset = 100;
+      assert.equal(generateUrl(params), '/q/foo%2524bar,100');
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        statuses: ['a', 'b', 'c'],
+      };
+      assert.equal(
+        generateUrl(params),
+        '/q/(status:a OR status:b OR status:c)'
+      );
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test' as TopicName,
+      };
+      assert.equal(generateUrl(params), '/q/topic:test');
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test test' as TopicName,
+      };
+      assert.equal(generateUrl(params), '/q/topic:"test+test"');
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test:test' as TopicName,
+      };
+      assert.equal(generateUrl(params), '/q/topic:"test:test"');
+    });
+
+    test('change', () => {
+      const params: GenerateUrlChangeViewParameters = {
+        view: GerritView.CHANGE,
+        changeNum: 1234 as NumericChangeId,
+        project: 'test' as RepoName,
+      };
+
+      assert.equal(generateUrl(params), '/c/test/+/1234');
+
+      params.patchNum = 10 as RevisionPatchSetNum;
+      assert.equal(generateUrl(params), '/c/test/+/1234/10');
+
+      params.basePatchNum = 5 as BasePatchSetNum;
+      assert.equal(generateUrl(params), '/c/test/+/1234/5..10');
+
+      params.messageHash = '#123';
+      assert.equal(generateUrl(params), '/c/test/+/1234/5..10#123');
+    });
+
+    test('change with repo name encoding', () => {
+      const params: GenerateUrlChangeViewParameters = {
+        view: GerritView.CHANGE,
+        changeNum: 1234 as NumericChangeId,
+        project: 'x+/y+/z+/w' as RepoName,
+      };
+      assert.equal(generateUrl(params), '/c/x%252B/y%252B/z%252B/w/+/1234');
+    });
+
+    test('diff', () => {
+      const params: GenerateUrlDiffViewParameters = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        path: 'x+y/path.cpp' as RepoName,
+        patchNum: 12 as RevisionPatchSetNum,
+        project: '' as RepoName,
+      };
+      assert.equal(generateUrl(params), '/c/42/12/x%252By/path.cpp');
+
+      params.project = 'test' as RepoName;
+      assert.equal(generateUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+
+      params.basePatchNum = 6 as BasePatchSetNum;
+      assert.equal(generateUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+
+      params.path = 'foo bar/my+file.txt%';
+      params.patchNum = 2 as RevisionPatchSetNum;
+      delete params.basePatchNum;
+      assert.equal(
+        generateUrl(params),
+        '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+      );
+
+      params.path = 'file.cpp';
+      params.lineNum = 123;
+      assert.equal(generateUrl(params), '/c/test/+/42/2/file.cpp#123');
+
+      params.leftSide = true;
+      assert.equal(generateUrl(params), '/c/test/+/42/2/file.cpp#b123');
+    });
+
+    test('diff with repo name encoding', () => {
+      const params: GenerateUrlDiffViewParameters = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        path: 'x+y/path.cpp',
+        patchNum: 12 as RevisionPatchSetNum,
+        project: 'x+/y' as RepoName,
+      };
+      assert.equal(generateUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+    });
+
+    test(EDIT, () => {
+      const params: GenerateUrlEditViewParameters = {
+        view: GerritView.EDIT,
+        changeNum: 42 as NumericChangeId,
+        project: 'test' as RepoName,
+        path: 'x+y/path.cpp',
+        patchNum: EDIT as RevisionPatchSetNum,
+      };
+      assert.equal(
+        generateUrl(params),
+        '/c/test/+/42/edit/x%252By/path.cpp,edit'
+      );
+    });
+
+    test('getPatchRangeExpression', () => {
+      const params: PatchRangeParams = {};
+      let actual = TEST_ONLY.getPatchRangeExpression(params);
+      assert.equal(actual, '');
+
+      params.patchNum = 4 as RevisionPatchSetNum;
+      actual = TEST_ONLY.getPatchRangeExpression(params);
+      assert.equal(actual, '4');
+
+      params.basePatchNum = 2 as BasePatchSetNum;
+      actual = TEST_ONLY.getPatchRangeExpression(params);
+      assert.equal(actual, '2..4');
+
+      delete params.patchNum;
+      actual = TEST_ONLY.getPatchRangeExpression(params);
+      assert.equal(actual, '2..');
+    });
+
+    suite('dashboard', () => {
+      test('self dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+        };
+        assert.equal(generateUrl(params), '/dashboard/self');
+      });
+
+      test('user dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          user: 'user',
+        };
+        assert.equal(generateUrl(params), '/dashboard/user');
+      });
+
+      test('custom self dashboard, no title', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: 'query 2'},
+          ],
+        };
+        assert.equal(
+          generateUrl(params),
+          '/dashboard/?section%201=query%201&section%202=query%202'
+        );
+      });
+
+      test('custom repo dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1 ${project}'},
+            {name: 'section 2', query: 'query 2 ${repo}'},
+          ],
+          repo: 'repo-name' as RepoName,
+        };
+        assert.equal(
+          generateUrl(params),
+          '/dashboard/?section%201=query%201%20repo-name&' +
+            'section%202=query%202%20repo-name'
+        );
+      });
+
+      test('custom user dashboard, with title', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          user: 'user',
+          sections: [{name: 'name', query: 'query'}],
+          title: 'custom dashboard',
+        };
+        assert.equal(
+          generateUrl(params),
+          '/dashboard/user?name=query&title=custom%20dashboard'
+        );
+      });
+
+      test('repo dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          repo: 'gerrit/repo' as RepoName,
+          dashboard: 'default:main' as DashboardId,
+        };
+        assert.equal(
+          generateUrl(params),
+          '/p/gerrit/repo/+/dashboard/default:main'
+        );
+      });
+
+      test('project dashboard (legacy)', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          project: 'gerrit/project' as RepoName,
+          dashboard: 'default:main' as DashboardId,
+        };
+        assert.equal(
+          generateUrl(params),
+          '/p/gerrit/project/+/dashboard/default:main'
+        );
+      });
+    });
+
+    suite('groups', () => {
+      test('group info', () => {
+        const params: GenerateUrlGroupViewParameters = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+        };
+        assert.equal(generateUrl(params), '/admin/groups/1234');
+      });
+
+      test('group members', () => {
+        const params: GenerateUrlGroupViewParameters = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+          detail: 'members' as GroupDetailView,
+        };
+        assert.equal(generateUrl(params), '/admin/groups/1234,members');
+      });
+
+      test('group audit log', () => {
+        const params: GenerateUrlGroupViewParameters = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+          detail: 'log' as GroupDetailView,
+        };
+        assert.equal(generateUrl(params), '/admin/groups/1234,audit-log');
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index d1475973..9b1f3b9 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -1,11 +1,12 @@
-import {ServerInfo} from '../types/common';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
-
 /**
  * @license
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+
+import {ServerInfo} from '../types/common';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+
 const PROBE_PATH = '/Documentation/index.html';
 const DOCS_BASE_PATH = '/Documentation';