/** * @license * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { BasePatchSetNum, BranchName, ChangeConfigInfo, ChangeInfo, CommentLinks, CommitId, DashboardId, EditPatchSetNum, GroupId, Hashtag, NumericChangeId, ParentPatchSetNum, PatchSetNum, RepoName, ServerInfo, TopicName, UrlEncodedCommentId, } from '../../../types/common'; import {GerritView} from '../../../services/router/router-model'; import {ParsedChangeInfo} from '../../../types/types'; // Navigation parameters object format: // // Each object has a `view` property with a value from GerritNav.View. The // remaining properties depend on the value used for view. // // - GerritNav.View.CHANGE: // - `changeNum`, required, String: the numeric ID of the change. // - `project`, optional, String: the project name. // - `patchNum`, optional, Number: the patch for the right-hand-side of // the diff. // - `basePatchNum`, optional, Number: the patch for the left-hand-side // of the diff. If `basePatchNum` is provided, then `patchNum` must // also be provided. // - `edit`, optional, Boolean: whether or not to load the file list with // edit controls. // - `messageHash`, optional, String: the hash of the change message to // scroll to. // // - GerritNav.View.SEARCH: // - `query`, optional, String: the literal search query. If provided, // the string will be used as the query, and all other params will be // ignored. // - `owner`, optional, String: the owner name. // - `project`, optional, String: the project name. // - `branch`, optional, String: the branch name. // - `topic`, optional, String: the topic name. // - `hashtag`, optional, String: the hashtag name. // - `statuses`, optional, Array: the list of change statuses to // search for. If more than one is provided, the search will OR them // together. // - `offset`, optional, Number: the offset for the query. // // - GerritNav.View.DIFF: // - `changeNum`, required, String: the numeric ID of the change. // - `path`, required, String: the filepath of the diff. // - `patchNum`, required, Number: the patch for the right-hand-side of // the diff. // - `basePatchNum`, optional, Number: the patch for the left-hand-side // of the diff. If `basePatchNum` is provided, then `patchNum` must // also be provided. // - `lineNum`, optional, Number: the line number to be selected on load. // - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value // of true selects the line from base of the patch range. False by // default. // // - GerritNav.View.GROUP: // - `groupId`, required, String: the ID of the group. // - `detail`, optional, String: the name of the group detail view. // Takes any value from GerritNav.GroupDetailView. // // - GerritNav.View.REPO: // - `repoName`, required, String: the name of the repo // - `detail`, optional, String: the name of the repo detail view. // Takes any value from GerritNav.RepoDetailView. // // - GerritNav.View.DASHBOARD // - `repo`, optional, String. // - `sections`, optional, Array of objects with `title` and `query` // strings. // - `user`, optional, String. // // - GerritNav.View.ROOT: // - no possible parameters. const uninitialized = () => { console.warn('Use of uninitialized routing'); }; const uninitializedNavigate: NavigateCallback = () => { uninitialized(); return ''; }; const uninitializedGenerateUrl: GenerateUrlCallback = () => { uninitialized(); return ''; }; const uninitializedGenerateWebLinks: GenerateWebLinksCallback = () => { uninitialized(); return []; }; const uninitializedMapCommentLinks: MapCommentLinksCallback = () => { uninitialized(); return {}; }; const USER_PLACEHOLDER_PATTERN = /\${user}/g; export interface DashboardSection { name: string; query: string; suffixForDashboard?: string; selfOnly?: boolean; hideIfEmpty?: boolean; assigneeOnly?: boolean; isOutgoing?: boolean; results?: ChangeInfo[]; } export interface UserDashboardConfig { change?: ChangeConfigInfo; } export interface UserDashboard { title?: string; sections: DashboardSection[]; } // NOTE: These queries are tested in Java. Any changes made to definitions // here require corresponding changes to: // java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java const HAS_DRAFTS: DashboardSection = { // Changes with unpublished draft comments. This section is omitted when // viewing other users, so we don't need to filter anything out. name: 'Has draft comments', query: 'has:draft', selfOnly: true, hideIfEmpty: true, suffixForDashboard: 'limit:10', }; export const YOUR_TURN: DashboardSection = { // Changes where the user is in the attention set. name: 'Your Turn', query: 'attention:${user}', hideIfEmpty: false, suffixForDashboard: 'limit:25', }; const ASSIGNED: DashboardSection = { // Changes that are assigned to the viewed user. name: 'Assigned reviews', query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' + 'is:open -is:ignored', hideIfEmpty: true, suffixForDashboard: 'limit:25', assigneeOnly: true, }; const WIP: DashboardSection = { // WIP open changes owned by viewing user. This section is omitted when // viewing other users, so we don't need to filter anything out. name: 'Work in progress', query: 'is:open owner:${user} is:wip', selfOnly: true, hideIfEmpty: true, suffixForDashboard: 'limit:25', }; const OUTGOING: DashboardSection = { // Non-WIP open changes owned by viewed user. Filter out changes ignored // by the viewing user. name: 'Outgoing reviews', query: 'is:open owner:${user} -is:wip -is:ignored', isOutgoing: true, suffixForDashboard: 'limit:25', }; const INCOMING: DashboardSection = { // Non-WIP open changes not owned by the viewed user, that the viewed user // is associated with (as either a reviewer or the assignee). Changes // ignored by the viewing user are filtered out. name: 'Incoming reviews', query: 'is:open -owner:${user} -is:wip -is:ignored ' + '(reviewer:${user} OR assignee:${user})', suffixForDashboard: 'limit:25', }; const CCED: DashboardSection = { // Open changes the viewed user is CCed on. Changes ignored by the viewing // user are filtered out. name: 'CCed on', query: 'is:open -is:ignored -is:wip cc:${user}', suffixForDashboard: 'limit:10', }; export const INTEGRATING: DashboardSection = { name: 'Integrating', query: '(is:staged OR is:integrating) -is:ignored (-is:wip OR owner:self) ' + '(owner:${user} OR reviewer:${user} OR assignee:${user} ' + 'OR cc:${user})', suffixForDashboard: 'limit:20', }; export const CLOSED: DashboardSection = { name: 'Recently closed', // Closed changes where viewed user is owner, reviewer, or assignee. // Changes ignored by the viewing user are filtered out, and so are WIP // changes not owned by the viewing user (the one instance of // 'owner:self' is intentional and implements this logic). query: '(is:merged OR is:abandoned OR is:deferred) -is:ignored (-is:wip OR owner:self) ' + '(owner:${user} OR reviewer:${user} OR assignee:${user} ' + 'OR cc:${user})', suffixForDashboard: '-age:4w limit:10', }; const DEFAULT_SECTIONS: DashboardSection[] = [ HAS_DRAFTS, YOUR_TURN, ASSIGNED, WIP, OUTGOING, INCOMING, CCED, INTEGRATING, CLOSED, ]; export interface GenerateUrlSearchViewParameters { view: GerritView.SEARCH; query?: string; offset?: number; project?: RepoName; branch?: BranchName; topic?: TopicName; // TODO(TS): Define more precise type (enum?) statuses?: string[]; hashtag?: string; host?: string; owner?: string; } export interface GenerateUrlChangeViewParameters { view: GerritView.CHANGE; // TODO(TS): NumericChangeId - not sure about it, may be it can be removed changeNum: NumericChangeId; project: RepoName; patchNum?: PatchSetNum; basePatchNum?: BasePatchSetNum; edit?: boolean; host?: string; messageHash?: string; queryMap?: Map | URLSearchParams; commentId?: UrlEncodedCommentId; // TODO(TS): querystring isn't set anywhere, try to remove querystring?: string; } export interface GenerateUrlRepoViewParameters { view: GerritView.REPO; repoName: RepoName; detail?: RepoDetailView; } export interface GenerateUrlDashboardViewParameters { view: GerritView.DASHBOARD; user?: string; repo?: RepoName; dashboard?: DashboardId; // TODO(TS): properties bellow aren't set anywhere, try to remove project?: RepoName; sections?: DashboardSection[]; title?: string; } export interface GenerateUrlGroupViewParameters { view: GerritView.GROUP; groupId: GroupId; detail?: GroupDetailView; } export interface GenerateUrlEditViewParameters { view: GerritView.EDIT; changeNum: NumericChangeId; project: RepoName; path: string; patchNum: PatchSetNum; lineNum?: number | string; } export interface GenerateUrlRootViewParameters { view: GerritView.ROOT; } export interface GenerateUrlSettingsViewParameters { view: GerritView.SETTINGS; } export interface GenerateUrlDiffViewParameters { view: GerritView.DIFF; changeNum: NumericChangeId; project: RepoName; path?: string; patchNum?: PatchSetNum; basePatchNum?: BasePatchSetNum; lineNum?: number | string; leftSide?: boolean; commentId?: UrlEncodedCommentId; // TODO(TS): remove - property is set but never used commentLink?: boolean; } export type GenerateUrlParameters = | GenerateUrlSearchViewParameters | GenerateUrlChangeViewParameters | GenerateUrlRepoViewParameters | GenerateUrlDashboardViewParameters | GenerateUrlGroupViewParameters | GenerateUrlEditViewParameters | GenerateUrlRootViewParameters | GenerateUrlSettingsViewParameters | GenerateUrlDiffViewParameters; export function isGenerateUrlChangeViewParameters( x: GenerateUrlParameters ): x is GenerateUrlChangeViewParameters { return x.view === GerritView.CHANGE; } export function isGenerateUrlEditViewParameters( x: GenerateUrlParameters ): x is GenerateUrlEditViewParameters { return x.view === GerritView.EDIT; } export function isGenerateUrlDiffViewParameters( x: GenerateUrlParameters ): x is GenerateUrlDiffViewParameters { return x.view === GerritView.DIFF; } export interface GenerateWebLinksOptions { weblinks?: GeneratedWebLink[]; config?: ServerInfo; } export interface GenerateWebLinksPatchsetParameters { type: WeblinkType.PATCHSET; repo: RepoName; commit?: CommitId; options?: GenerateWebLinksOptions; } export interface GenerateWebLinksResolveConflictsParameters { type: WeblinkType.RESOLVE_CONFLICTS; repo: RepoName; commit?: CommitId; options?: GenerateWebLinksOptions; } export interface GenerateWebLinksEditParameters { type: WeblinkType.EDIT; repo: RepoName; commit: CommitId; file: string; options?: GenerateWebLinksOptions; } export interface GenerateWebLinksFileParameters { type: WeblinkType.FILE; repo: RepoName; commit: CommitId; file: string; options?: GenerateWebLinksOptions; } export interface GenerateWebLinksChangeParameters { type: WeblinkType.CHANGE; repo: RepoName; commit: CommitId; options?: GenerateWebLinksOptions; } export type GenerateWebLinksParameters = | GenerateWebLinksPatchsetParameters | GenerateWebLinksResolveConflictsParameters | GenerateWebLinksEditParameters | GenerateWebLinksFileParameters | GenerateWebLinksChangeParameters; export type NavigateCallback = (target: string, redirect?: boolean) => void; export type GenerateUrlCallback = (params: GenerateUrlParameters) => string; // TODO: Refactor to return only GeneratedWebLink[] export type GenerateWebLinksCallback = ( params: GenerateWebLinksParameters ) => GeneratedWebLink[] | GeneratedWebLink; export type MapCommentLinksCallback = (patterns: CommentLinks) => CommentLinks; export interface WebLink { name?: string; label: string; url: string; } export interface GeneratedWebLink { name?: string; label?: string; url?: string; } export enum GroupDetailView { MEMBERS = 'members', LOG = 'log', } export enum RepoDetailView { GENERAL = 'general', ACCESS = 'access', BRANCHES = 'branches', COMMANDS = 'commands', DASHBOARDS = 'dashboards', TAGS = 'tags', } export enum WeblinkType { CHANGE = 'change', EDIT = 'edit', FILE = 'file', PATCHSET = 'patchset', RESOLVE_CONFLICTS = 'resolve-conflicts', } // TODO(dmfilippov) Convert to class, extract consts, give better name and // expose as a service from appContext export const GerritNav = { View: GerritView, GroupDetailView, RepoDetailView, WeblinkType, _navigate: uninitializedNavigate, _generateUrl: uninitializedGenerateUrl, _generateWeblinks: uninitializedGenerateWebLinks, mapCommentlinks: uninitializedMapCommentLinks, _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum) { if (basePatchNum && !patchNum) { throw new Error('Cannot use base patch number without patch number.'); } }, /** * Setup router implementation. * * @param navigate the router-abstracted equivalent of * `window.location.href = ...` or window.location.replace(...). The * string is a new location and boolean defines is it redirect or not * (true means redirect, i.e. equivalent of window.location.replace). * @param generateUrl generates a URL given * navigation parameters, detailed in the file header. * @param generateWeblinks weblinks generator * function takes single payload parameter with type property that * determines which * part of the UI is the consumer of the weblinks. type property can * be one of file, change, or patchset. * - For file type, payload will also contain string properties: repo, * commit, file. * - For patchset type, payload will also contain string properties: * repo, commit. * - For change type, payload will also contain string properties: * repo, commit. If server provides weblinks, those will be passed * as options.weblinks property on the main payload object. * @param mapCommentlinks provides an escape * hatch to modify the commentlinks object, e.g. if it contains any * relative URLs. */ setup( navigate: NavigateCallback, generateUrl: GenerateUrlCallback, generateWeblinks: GenerateWebLinksCallback, mapCommentlinks: MapCommentLinksCallback ) { this._navigate = navigate; this._generateUrl = generateUrl; this._generateWeblinks = generateWeblinks; this.mapCommentlinks = mapCommentlinks; }, destroy() { this._navigate = uninitializedNavigate; this._generateUrl = uninitializedGenerateUrl; this._generateWeblinks = uninitializedGenerateWebLinks; this.mapCommentlinks = uninitializedMapCommentLinks; }, /** * Generate a URL for the given route parameters. */ _getUrlFor(params: GenerateUrlParameters) { return this._generateUrl(params); }, getUrlForSearchQuery(query: string, offset?: number) { return this._getUrlFor({ view: GerritView.SEARCH, query, offset, }); }, /** * @param openOnly When true, only search open changes in the project. * @param host The host in which to search. */ getUrlForProjectChanges( project: RepoName, openOnly?: boolean, host?: string ) { return this._getUrlFor({ view: GerritView.SEARCH, project, statuses: openOnly ? ['open'] : [], host, }); }, /** * @param status The status to search. * @param host The host in which to search. */ getUrlForBranch( branch: BranchName, project: RepoName, status?: string, host?: string ) { return this._getUrlFor({ view: GerritView.SEARCH, branch, project, statuses: status ? [status] : undefined, host, }); }, /** * @param topic The name of the topic. * @param host The host in which to search. */ getUrlForTopic(topic: TopicName, host?: string) { return this._getUrlFor({ view: GerritView.SEARCH, topic, host, }); }, /** * @param hashtag The name of the hashtag. */ getUrlForHashtag(hashtag: Hashtag) { return this._getUrlFor({ view: GerritView.SEARCH, hashtag, statuses: ['open', 'merged'], }); }, /** * Navigate to a search for changes with the given status. */ navigateToStatusSearch(status: string) { this._navigate( this._getUrlFor({ view: GerritView.SEARCH, statuses: [status], }) ); }, /** * Navigate to a search query */ navigateToSearchQuery(query: string, offset?: number) { return this._navigate(this.getUrlForSearchQuery(query, offset)); }, /** * Navigate to the user's dashboard */ navigateToUserDashboard() { return this._navigate(this.getUrlForUserDashboard('self')); }, /** * @param basePatchNum The string 'PARENT' can be used for none. */ getUrlForChange( change: Pick, patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum, isEdit?: boolean, messageHash?: string ) { if (basePatchNum === ParentPatchSetNum) { basePatchNum = undefined; } this._checkPatchRange(patchNum, basePatchNum); return this._getUrlFor({ view: GerritView.CHANGE, changeNum: change._number, project: change.project, patchNum, basePatchNum, edit: isEdit, host: change.internalHost || undefined, messageHash, }); }, getUrlForChangeById( changeNum: NumericChangeId, project: RepoName, patchNum?: PatchSetNum ) { return this._getUrlFor({ view: GerritView.CHANGE, changeNum, project, patchNum, }); }, /** * @param basePatchNum The string 'PARENT' can be used for none. * @param redirect redirect to a change - if true, the current * location (i.e. page which makes redirect) is not added to a history. * I.e. back/forward buttons skip current location * */ navigateToChange( change: Pick, patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum, isEdit?: boolean, redirect?: boolean ) { this._navigate( this.getUrlForChange(change, patchNum, basePatchNum, isEdit), redirect ); }, /** * @param basePatchNum The string 'PARENT' can be used for none. */ getUrlForDiff( change: ChangeInfo | ParsedChangeInfo, filePath: string, patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum, lineNum?: number ) { return this.getUrlForDiffById( change._number, change.project, filePath, patchNum, basePatchNum, lineNum ); }, getUrlForComment( changeNum: NumericChangeId, project: RepoName, commentId: UrlEncodedCommentId ) { return this._getUrlFor({ view: GerritView.DIFF, changeNum, project, commentId, }); }, getUrlForCommentsTab( changeNum: NumericChangeId, project: RepoName, commentId: UrlEncodedCommentId ) { return this._getUrlFor({ view: GerritView.CHANGE, changeNum, project, commentId, }); }, /** * @param basePatchNum The string 'PARENT' can be used for none. */ getUrlForDiffById( changeNum: NumericChangeId, project: RepoName, filePath: string, patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum, lineNum?: number, leftSide?: boolean ) { if (basePatchNum === ParentPatchSetNum) { basePatchNum = undefined; } this._checkPatchRange(patchNum, basePatchNum); return this._getUrlFor({ view: GerritView.DIFF, changeNum, project, path: filePath, patchNum, basePatchNum, lineNum, leftSide, }); }, getEditUrlForDiff( change: ChangeInfo | ParsedChangeInfo, filePath: string, patchNum?: PatchSetNum, lineNum?: number ) { return this.getEditUrlForDiffById( change._number, change.project, filePath, patchNum, lineNum ); }, /** * @param patchNum The patchNum the file content should be based on, or * ${EditPatchSetNum} if left undefined. * @param lineNum The line number to pass to the inline editor. */ getEditUrlForDiffById( changeNum: NumericChangeId, project: RepoName, filePath: string, patchNum?: PatchSetNum, lineNum?: number ) { return this._getUrlFor({ view: GerritView.EDIT, changeNum, project, path: filePath, patchNum: patchNum || EditPatchSetNum, lineNum, }); }, /** * @param basePatchNum The string 'PARENT' can be used for none. */ navigateToDiff( change: ChangeInfo | ParsedChangeInfo, filePath: string, patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum, lineNum?: number ) { this._navigate( this.getUrlForDiff(change, filePath, patchNum, basePatchNum, lineNum) ); }, /** * @param owner The name of the owner. */ getUrlForOwner(owner: string) { return this._getUrlFor({ view: GerritView.SEARCH, owner, }); }, /** * @param user The name of the user. */ getUrlForUserDashboard(user: string) { return this._getUrlFor({ view: GerritView.DASHBOARD, user, }); }, getUrlForRoot() { return this._getUrlFor({ view: GerritView.ROOT, }); }, /** * @param repo The name of the repo. * @param dashboard The ID of the dashboard, in the form of ':'. */ getUrlForRepoDashboard(repo: RepoName, dashboard: DashboardId) { return this._getUrlFor({ view: GerritView.DASHBOARD, repo, dashboard, }); }, /** * Navigate to an arbitrary relative URL. */ navigateToRelativeUrl(relativeUrl: string) { if (!relativeUrl.startsWith('/')) { throw new Error('navigateToRelativeUrl with non-relative URL'); } this._navigate(relativeUrl); }, getUrlForRepo(repoName: RepoName) { return this._getUrlFor({ view: GerritView.REPO, detail: RepoDetailView.GENERAL, repoName, }); }, /** * Navigate to a repo settings page. */ navigateToRepo(repoName: RepoName) { this._navigate(this.getUrlForRepo(repoName)); }, getUrlForRepoTags(repoName: RepoName) { return this._getUrlFor({ view: GerritView.REPO, repoName, detail: RepoDetailView.TAGS, }); }, getUrlForRepoBranches(repoName: RepoName) { return this._getUrlFor({ view: GerritView.REPO, repoName, detail: GerritNav.RepoDetailView.BRANCHES, }); }, getUrlForRepoAccess(repoName: RepoName) { return this._getUrlFor({ view: GerritView.REPO, repoName, detail: GerritNav.RepoDetailView.ACCESS, }); }, getUrlForRepoCommands(repoName: RepoName) { return this._getUrlFor({ view: GerritView.REPO, repoName, detail: GerritNav.RepoDetailView.COMMANDS, }); }, getUrlForRepoDashboards(repoName: RepoName) { return this._getUrlFor({ view: GerritView.REPO, repoName, detail: GerritNav.RepoDetailView.DASHBOARDS, }); }, getUrlForGroup(groupId: GroupId) { return this._getUrlFor({ view: GerritView.GROUP, groupId, }); }, getUrlForGroupLog(groupId: GroupId) { return this._getUrlFor({ view: GerritView.GROUP, groupId, detail: GerritNav.GroupDetailView.LOG, }); }, getUrlForGroupMembers(groupId: GroupId) { return this._getUrlFor({ view: GerritView.GROUP, groupId, detail: GroupDetailView.MEMBERS, }); }, getUrlForSettings() { return this._getUrlFor({view: GerritView.SETTINGS}); }, getEditWebLinks( repo: RepoName, commit: CommitId, file: string, options?: GenerateWebLinksOptions ): GeneratedWebLink[] { const params: GenerateWebLinksEditParameters = { type: WeblinkType.EDIT, repo, commit, file, }; if (options) { params.options = options; } return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params)); }, getFileWebLinks( repo: RepoName, commit: CommitId, file: string, options?: GenerateWebLinksOptions ): GeneratedWebLink[] { const params: GenerateWebLinksFileParameters = { type: WeblinkType.FILE, repo, commit, file, }; if (options) { params.options = options; } return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params)); }, getPatchSetWeblink( repo: RepoName, commit?: CommitId, options?: GenerateWebLinksOptions ): GeneratedWebLink { const params: GenerateWebLinksPatchsetParameters = { type: WeblinkType.PATCHSET, repo, commit, }; if (options) { params.options = options; } const result = this._generateWeblinks(params); if (Array.isArray(result)) { // TODO(TS): Unclear what to do with empty array. // Either write a comment why result can't be empty or change the return // type or add a check. return result.pop()!; } else { return result; } }, getResolveConflictsWeblinks( repo: RepoName, commit?: CommitId, options?: GenerateWebLinksOptions ): GeneratedWebLink[] { const params: GenerateWebLinksResolveConflictsParameters = { type: WeblinkType.RESOLVE_CONFLICTS, repo, commit, }; if (options) { params.options = options; } return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params)); }, getChangeWeblinks( repo: RepoName, commit: CommitId, options?: GenerateWebLinksOptions ): GeneratedWebLink[] { const params: GenerateWebLinksChangeParameters = { type: WeblinkType.CHANGE, repo, commit, }; if (options) { params.options = options; } return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params)); }, getUserDashboard( user = 'self', sections = DEFAULT_SECTIONS, title = '', config: UserDashboardConfig = {} ): UserDashboard { const assigneeEnabled = config.change && !!config.change.enable_assignee; sections = sections .filter(section => assigneeEnabled || !section.assigneeOnly) .filter(section => user === 'self' || !section.selfOnly) .map(section => { return { ...section, name: section.name, query: section.query.replace(USER_PLACEHOLDER_PATTERN, user), }; }); return {title, sections}; }, };