blob: 6365904e416ce0f7f5521d6c6206ff63787de4bd [file]
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../shared/gr-button/gr-button';
import '../shared/gr-dropdown-list/gr-dropdown-list';
import '../shared/gr-tooltip-content/gr-tooltip-content';
import {css, html, LitElement} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {when} from 'lit/directives/when.js';
import {CheckRun, checksModelToken} from '../../models/checks/checks-model';
import {
ALL_ATTEMPTS,
AttemptChoice,
attemptChoiceLabel,
isAttemptChoice,
LATEST_ATTEMPT,
sortAttemptChoices,
stringToAttemptChoice,
} from '../../models/checks/checks-util';
import {changeModelToken} from '../../models/change/change-model';
import {resolve} from '../../models/dependency';
import {PatchSetNumber} from '../../types/common';
import {assert, assertIsDefined, unique} from '../../utils/common-util';
import {subscribe} from '../lit/subscription-controller';
import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
/**
* Below this width the selector switches to its compact layout, because the
* full trigger texts and the "Go To Latest Patchset" button do not fit.
*/
const MIN_FULL_WIDTH_PX = 160;
/**
* Lets the user choose the patchset and the attempt that the Checks tab shows
* runs and results for.
*/
@customElement('gr-checks-patchset-select')
export class GrChecksPatchsetSelect extends LitElement {
/**
* Compact layout for narrow containers such as the collapsed runs panel: The
* patchset dropdown has a short trigger text, the attempt dropdown is not
* rendered, and "Go To Latest Patchset" is shortened to "Latest".
*
* The compact layout is also used when the available width is too small
* for the full layout, see `narrow`.
*/
@property({type: Boolean})
compact = false;
/** Whether the available width is below `MIN_FULL_WIDTH_PX`. */
@state()
narrow = false;
@state()
runs: CheckRun[] = [];
@state()
checksPatchsetNumber: PatchSetNumber | undefined = undefined;
@state()
latestPatchsetNumber: PatchSetNumber | undefined = undefined;
@state()
selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getChecksModel = resolve(this, checksModelToken);
constructor() {
super();
subscribe(
this,
() => this.getChecksModel().allRunsSelectedPatchset$,
x => (this.runs = x)
);
subscribe(
this,
() => this.getChecksModel().checksSelectedPatchsetNumber$,
x => (this.checksPatchsetNumber = x)
);
subscribe(
this,
() => this.getChecksModel().checksSelectedAttemptNumber$,
x => (this.selectedAttempt = x)
);
subscribe(
this,
() => this.getChangeModel().latestPatchNum$,
x => (this.latestPatchsetNumber = x)
);
// The host is a block level flex container, so its width is the width
// that is available, regardless of the content being rendered. The update
// is deferred, because re-rendering changes the height of the host, which
// must not happen while resize observations are being delivered.
new ResizeObserver(entries => {
const width = entries[entries.length - 1].contentRect.width;
requestAnimationFrame(() => {
this.narrow = width > 0 && width < MIN_FULL_WIDTH_PX;
});
}).observe(this);
}
private isCompact() {
return this.compact || this.narrow;
}
static override get styles() {
return css`
:host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-s) var(--spacing-m);
}
gr-dropdown-list {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 0 var(--spacing-m);
}
gr-dropdown-list.compact {
padding: 0 var(--spacing-xs);
}
gr-button {
--gr-button-padding: var(--spacing-s) var(--spacing-m);
}
gr-button.compact {
--gr-button-padding: var(--spacing-s) var(--spacing-xs);
}
`;
}
override render() {
const compact = this.isCompact();
const attemptItems = this.createAttemptDropdownItems();
const notLatest =
!!this.checksPatchsetNumber &&
this.checksPatchsetNumber !== this.latestPatchsetNumber;
return html`
<gr-dropdown-list
class=${classMap({patchsetSelect: true, compact})}
value=${(this.checksPatchsetNumber || this.latestPatchsetNumber) ?? 0}
.items=${this.createPatchsetDropdownItems()}
@value-change=${this.onPatchsetSelected}
></gr-dropdown-list>
${when(
!compact && attemptItems.length > 0,
() => html`<gr-dropdown-list
class="attemptSelect"
value=${this.selectedAttempt ?? 0}
.items=${attemptItems}
@value-change=${this.onAttemptSelected}
></gr-dropdown-list>`
)}
${when(notLatest, () => this.renderGoToLatest(compact))}
`;
}
private renderGoToLatest(compact: boolean) {
if (!compact) {
return html`<gr-button
class="goToLatest"
link
@click=${this.goToLatestPatchset}
>Go To Latest Patchset</gr-button
>`;
}
return html`<gr-tooltip-content has-tooltip title="Go To Latest Patchset">
<gr-button
class="goToLatest compact"
link
aria-label="Go To Latest Patchset"
@click=${this.goToLatestPatchset}
>Latest</gr-button
>
</gr-tooltip-content>`;
}
private onAttemptSelected(e: CustomEvent<{value: string | undefined}>) {
const attempt = stringToAttemptChoice(e.detail.value);
assertIsDefined(attempt, `unexpected attempt choice ${e.detail.value}`);
this.getChecksModel().updateStateSetAttempt(attempt);
}
private onPatchsetSelected(e: CustomEvent<{value: string}>) {
const patchset = Number(e.detail.value) as PatchSetNumber;
assert(Number.isInteger(patchset), `patchset must be integer: ${patchset}`);
this.getChecksModel().updateStateSetPatchset(patchset);
}
private goToLatestPatchset() {
assertIsDefined(this.latestPatchsetNumber, 'latestPatchsetNumber');
this.getChecksModel().updateStateSetPatchset(this.latestPatchsetNumber);
}
private createAttemptDropdownItems() {
if (this.runs.every(run => run.isSingleAttempt)) return [];
const attempts: AttemptChoice[] = this.runs
.map(run => run.attempt ?? 0)
.filter(isAttemptChoice)
.filter(unique);
attempts.push(LATEST_ATTEMPT);
attempts.push(ALL_ATTEMPTS);
const items: DropdownItem[] = attempts.sort(sortAttemptChoices).map(a => {
return {
value: a,
text: attemptChoiceLabel(a),
};
});
return items;
}
private createPatchsetDropdownItems() {
if (!this.latestPatchsetNumber) return [];
return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
assertIsDefined(this.latestPatchsetNumber, 'latestPatchsetNumber');
const index = this.latestPatchsetNumber - i;
const postfix = index === this.latestPatchsetNumber ? ' (latest)' : '';
return {
value: `${index}`,
text: `Patchset ${index}${postfix}`,
triggerText: this.isCompact() ? `PS ${index}` : undefined,
};
});
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-checks-patchset-select': GrChecksPatchsetSelect;
}
}