blob: a870835716bd4caf56c3028c32b415f671dccaed [file] [log] [blame]
/**
* @license
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../gr-change-list/gr-change-list';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
import '../gr-create-commands-dialog/gr-create-commands-dialog';
import '../gr-create-change-help/gr-create-change-help';
import '../gr-create-destination-dialog/gr-create-destination-dialog';
import '../gr-user-header/gr-user-header';
import {getAppContext} from '../../../services/app-context';
import {changeIsOpen} from '../../../utils/change-util';
import {parseDate} from '../../../utils/date-util';
import {
AccountDetailInfo,
ChangeInfo,
DashboardId,
PreferencesInput,
RepoName,
} from '../../../types/common';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
import {
CreateDestinationConfirmDetail,
GrCreateDestinationDialog,
} from '../gr-create-destination-dialog/gr-create-destination-dialog';
import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
import {
fireAlert,
fire,
firePageError,
fireTitleChange,
} from '../../../utils/event-util';
import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
import {ChangeListSection} from '../gr-change-list/gr-change-list';
import {a11yStyles} from '../../../styles/gr-a11y-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html, css, nothing} from 'lit';
import {customElement, property, state, query} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
import {ShortcutController} from '../../lit/shortcut-controller';
import {
dashboardViewModelToken,
DashboardViewState,
} from '../../../models/views/dashboard';
import {createSearchUrl} from '../../../models/views/search';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {
getUserDashboard,
OUTGOING,
UserDashboard,
YOUR_TURN,
} from '../../../utils/dashboard-util';
import {userModelToken} from '../../../models/user/user-model';
import {Timing} from '../../../constants/reporting';
import {modalStyles} from '../../../styles/gr-modal-styles';
const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
const slotNameBySectionName = new Map<string, string>([
[YOUR_TURN.name, 'your-turn-slot'],
[OUTGOING.name, 'outgoing-slot'],
]);
@customElement('gr-dashboard-view')
export class GrDashboardView extends LitElement {
@query('#confirmDeleteDialog') protected confirmDeleteDialog?: GrDialog;
@query('#commandsDialog') protected commandsDialog?: GrCreateCommandsDialog;
@query('#destinationDialog')
protected destinationDialog?: GrCreateDestinationDialog;
@query('#confirmDeleteModal')
protected confirmDeleteModal?: HTMLDialogElement;
@property({type: Object})
account?: AccountDetailInfo;
@property({type: Object})
preferences?: PreferencesInput;
@state()
viewState?: DashboardViewState;
// private but used in test
@state() results?: ChangeListSection[];
// private but used in test
@state() loading = true;
// private but used in test
@state() showDraftsBanner = false;
// private but used in test
@state() showNewUserHelp = false;
private reporting = getAppContext().reportingService;
private readonly restApiService = getAppContext().restApiService;
private readonly getUserModel = resolve(this, userModelToken);
private readonly getViewModel = resolve(this, dashboardViewModelToken);
private lastVisibleTimestampMs = 0;
/**
* For `DASHBOARD_DISPLAYED` timing we can only rely on the router to have
* reset the timer properly when the dashboard loads for the first time.
* Later we won't have a guarantee that the timer was just reset. So we will
* just reset the timer at the beginning of `reload()`. The dashboard view
* is cached anyway, so there is unlikely a lot of time that has passed
* initiating the reload and the reload() method being executed.
*/
private firstTimeLoad = true;
private readonly shortcuts = new ShortcutController(this);
constructor() {
super();
subscribe(
this,
() => this.getUserModel().account$,
x => (this.account = x)
);
subscribe(
this,
() => this.getViewModel().state$,
x => {
this.viewState = x;
this.reload();
}
);
this.addEventListener('reload', () => this.reload());
this.shortcuts.addAbstract(Shortcut.UP_TO_DASHBOARD, () => this.reload());
}
private readonly visibilityChangeListener = () => {
if (document.visibilityState === 'visible') {
if (
Date.now() - this.lastVisibleTimestampMs >
RELOAD_DASHBOARD_INTERVAL_MS
)
this.reload();
} else {
this.lastVisibleTimestampMs = Date.now();
}
};
override connectedCallback() {
super.connectedCallback();
this.loadPreferences();
document.addEventListener(
'visibilitychange',
this.visibilityChangeListener
);
}
override disconnectedCallback() {
document.removeEventListener(
'visibilitychange',
this.visibilityChangeListener
);
super.disconnectedCallback();
}
static override get styles() {
return [
a11yStyles,
sharedStyles,
modalStyles,
css`
:host {
display: block;
}
.loading {
color: var(--deemphasized-text-color);
padding: var(--spacing-l);
}
gr-change-list {
width: 100%;
}
gr-user-header {
border-bottom: 1px solid var(--border-color);
}
.banner {
align-items: center;
background-color: var(--comment-background-color);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-l);
}
.hide {
display: none;
}
#emptyOutgoing {
display: block;
}
@media only screen and (max-width: 50em) {
.loading {
padding: 0 var(--spacing-l);
}
}
`,
];
}
override render() {
if (!this.viewState) return nothing;
return html`
${this.renderBanner()} ${this.renderContent()}
<dialog id="confirmDeleteModal" tabindex="-1">
<gr-dialog
id="confirmDeleteDialog"
confirm-label="Delete"
@confirm=${() => {
this.handleConfirmDelete();
}}
@cancel=${() => {
this.closeConfirmDeleteModal();
}}
>
<div class="header" slot="header">Delete comments</div>
<div class="main" slot="main">
Are you sure you want to delete all your draft comments in closed
changes? This action cannot be undone.
</div>
</gr-dialog>
</dialog>
<gr-create-destination-dialog
id="destinationDialog"
@confirm-destination=${(
e: CustomEvent<CreateDestinationConfirmDetail>
) => {
this.handleDestinationConfirm(e);
}}
></gr-create-destination-dialog>
<gr-create-commands-dialog
id="commandsDialog"
></gr-create-commands-dialog>
`;
}
private renderBanner() {
if (!this.showDraftsBanner) return;
return html`
<div class="banner">
<div>
You have draft comments on closed changes.
<a href=${this.computeDraftsLink()} target="_blank">(view all)</a>
</div>
<div>
<gr-button
class="delete"
link
@click=${() => {
this.handleOpenDeleteDialog();
}}
>Delete All</gr-button
>
</div>
</div>
`;
}
private renderContent() {
// In case of an internal reload we want the ChangeList section components
// to remain in the DOM so that the Bulk Actions Model associated with them
// is not recreated after the reload resulting in user selections being lost
return html`
<div class="loading" ?hidden=${!this.loading}>Loading...</div>
<div ?hidden=${this.loading}>
${this.renderUserHeader()}
<h1 class="assistive-tech-only">Dashboard</h1>
<gr-change-list
.account=${this.account}
.preferences=${this.preferences}
.sections=${this.results}
.usp=${'dashboard'}
@toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
this.handleToggleStar(e);
}}
>
<div id="emptyOutgoing" slot="outgoing-slot">
${this.renderShowNewUserHelp()}
</div>
<div id="emptyYourTurn" slot="your-turn-slot">
<span>No changes need your attention &nbsp;&#x1f389;</span>
</div>
</gr-change-list>
</div>
`;
}
private renderUserHeader() {
if (
!!this.viewState?.project ||
!this.viewState?.user ||
this.viewState?.user === 'self'
) {
return;
}
return html`
<gr-user-header .userId=${this.viewState?.user}></gr-user-header>
`;
}
private renderShowNewUserHelp() {
if (!this.showNewUserHelp) return ' No changes ';
return html`
<gr-create-change-help
@create-tap=${() => {
this.handleCreateChangeTap();
}}
></gr-create-change-help>
`;
}
private loadPreferences() {
return this.restApiService.getLoggedIn().then(loggedIn => {
if (loggedIn) {
this.restApiService.getPreferences().then(preferences => {
this.preferences = preferences;
});
} else {
this.preferences = {};
}
});
}
// private but used in test
getRepositoryDashboard(
repo: RepoName,
dashboard?: DashboardId
): Promise<UserDashboard | undefined> {
const errFn = (response?: Response | null) => {
firePageError(response);
};
assertIsDefined(dashboard, 'project dashboard must have id');
return this.restApiService
.getDashboard(repo, dashboard, errFn)
.then(response => {
if (!response) {
return;
}
return {
title: response.title,
sections: response.sections.map(section => {
const suffix = response.foreach ? ' ' + response.foreach : '';
return {
name: section.name,
query: (section.query + suffix).replace(
PROJECT_PLACEHOLDER_PATTERN,
repo
),
};
}),
};
});
}
// private but used in test
computeTitle(user?: string) {
if (!user || user === 'self') {
return 'My Reviews';
}
return 'Dashboard for ' + user;
}
/**
* Reloads the element.
*
* private but used in test
*/
reload() {
if (!this.viewState) return Promise.resolve();
// See `firstTimeLoad` comment above.
if (!this.firstTimeLoad) {
this.reporting.time(Timing.DASHBOARD_DISPLAYED);
}
this.firstTimeLoad = false;
this.loading = true;
const {project, dashboard, title, user, sections} = this.viewState;
const dashboardPromise: Promise<UserDashboard | undefined> = project
? this.getRepositoryDashboard(project, dashboard)
: Promise.resolve(
getUserDashboard(user, sections, title || this.computeTitle(user))
);
// Checking `this.account` to make sure that the user is logged in.
// Otherwise sending a query for 'owner:self' will result in an error.
const checkForNewUser = !project && !!this.account && user === 'self';
return dashboardPromise
.then(res => {
if (res && res.title) {
fireTitleChange(res.title);
}
return this.fetchDashboardChanges(res, checkForNewUser);
})
.then(() => {
this.maybeShowDraftsBanner();
this.reporting.dashboardDisplayed();
})
.catch(err => {
fireTitleChange(title || this.computeTitle(user));
this.reporting.error('Dashboard reload', err);
})
.finally(() => {
this.loading = false;
});
}
/**
* Fetches the changes for each dashboard section and sets this.results
* with the response.
*
* private but used in test
*/
fetchDashboardChanges(
res: UserDashboard | undefined,
checkForNewUser: boolean
): Promise<void> {
if (!res) {
return Promise.resolve();
}
let queries: string[];
if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
queries = window.PRELOADED_QUERIES.dashboardQuery;
// we use preloaded query from index only on first page load
window.PRELOADED_QUERIES.dashboardQuery = undefined;
} else {
queries = res.sections.map(section =>
section.suffixForDashboard
? section.query + ' ' + section.suffixForDashboard
: section.query
);
if (checkForNewUser) {
queries.push('owner:self limit:1');
}
}
return this.restApiService
.getChangesForMultipleQueries(undefined, queries)
.then(changes => {
if (!changes) {
throw new Error('getChanges returns undefined');
}
if (checkForNewUser) {
// Last set of results is not meant for dashboard display.
const lastResultSet = changes.pop();
this.showNewUserHelp = lastResultSet!.length === 0;
}
this.results = changes
.map((results, i) => {
return {
name: res.sections[i].name,
countLabel: this.computeSectionCountLabel(results),
query: res.sections[i].query,
results: this.maybeSortResults(res.sections[i].name, results),
emptyStateSlotName: slotNameBySectionName.get(
res.sections[i].name
),
};
})
.filter(
(section, i) =>
i < res.sections.length &&
(!res.sections[i].hideIfEmpty || section.results.length)
);
});
}
/**
* Usually we really want to stick to the sorting that the backend provides,
* but for the "Your Turn" section it is important to put the changes at the
* top where the current user is a reviewer. Owned changes are less important.
* And then we want to emphasize the changes where the waiting time is larger.
*/
private maybeSortResults(name: string, results: ChangeInfo[]) {
const userId = this.account?._account_id;
const sortedResults = [...results];
if (name === YOUR_TURN.name && userId) {
sortedResults.sort((c1, c2) => {
const c1Owner = c1.owner._account_id === userId;
const c2Owner = c2.owner._account_id === userId;
if (c1Owner !== c2Owner) return c1Owner ? 1 : -1;
// Should never happen, because the change is in the 'Your Turn'
// section, so the userId should be found in the attention set of both.
if (!c1.attention_set || !c1.attention_set[userId]) return 0;
if (!c2.attention_set || !c2.attention_set[userId]) return 0;
const c1Update = c1.attention_set[userId].last_update;
const c2Update = c2.attention_set[userId].last_update;
// Should never happen that an attention set entry has no update.
if (!c1Update || !c2Update) return c1Update ? 1 : -1;
return parseDate(c1Update).valueOf() - parseDate(c2Update).valueOf();
});
}
return sortedResults;
}
// private but used in test
computeSectionCountLabel(changes: ChangeInfo[]) {
if (!changes || !changes.length || changes.length === 0) {
return '';
}
const more = changes[changes.length - 1]._more_changes;
const numChanges = changes.length;
const andMore = more ? ' and more' : '';
return `(${numChanges}${andMore})`;
}
// private but used in test
async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
const msg = e.detail.starred
? 'Starring change...'
: 'Unstarring change...';
fireAlert(this, msg);
await this.restApiService.saveChangeStarred(
e.detail.change._number,
e.detail.starred
);
fire(this, 'hide-alert', {});
if (e.detail.starred) {
this.reporting.reportInteraction('change-starred-from-dashboard');
}
// When a change is updated the same change may appear elsewhere in the
// dashboard (but is not the same object), so we must update other
// occurrences of the same change.
this.results?.forEach((dashboardChange, dashboardIndex) =>
dashboardChange.results.forEach((change, changeIndex) => {
if (change.id === e.detail.change.id) {
this.results![dashboardIndex].results[changeIndex].starred =
e.detail.starred;
this.requestUpdate('results');
}
})
);
}
/**
* Banner is shown if a user is on their own dashboard and they have draft
* comments on closed changes.
*
* private but used in test
*/
maybeShowDraftsBanner() {
this.showDraftsBanner = false;
if (!(this.viewState?.user === 'self')) return;
if (!this.results) {
throw new Error('this.results must be set. restAPI returned undefined');
}
const draftSection = this.results.find(
section => section.query === 'has:draft'
);
if (!draftSection || !draftSection.results.length) return;
const closedChanges = draftSection.results.filter(
change => !changeIsOpen(change)
);
if (!closedChanges.length) return;
this.showDraftsBanner = true;
}
// private but used in test
handleOpenDeleteDialog() {
assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
this.confirmDeleteModal.showModal();
}
// private but used in test
handleConfirmDelete() {
assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
this.confirmDeleteDialog.disabled = true;
return this.restApiService.deleteDraftComments('-is:open').then(() => {
this.closeConfirmDeleteModal();
this.reload();
});
}
private closeConfirmDeleteModal() {
assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
this.confirmDeleteModal.close();
}
private computeDraftsLink() {
return createSearchUrl({query: 'has:draft -is:open'});
}
private handleCreateChangeTap() {
assertIsDefined(this.destinationDialog, 'destinationDialog');
this.destinationDialog.open();
}
private handleDestinationConfirm(
e: CustomEvent<CreateDestinationConfirmDetail>
) {
assertIsDefined(this.commandsDialog, 'commandsDialog');
this.commandsDialog.branch = e.detail.branch;
this.commandsDialog.open();
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-dashboard-view': GrDashboardView;
}
}