blob: 072ec73dad971877351697ddd87532fb37d2c6d7 [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {NumericChangeId, BranchName} from '../../../types/common';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-autocomplete/gr-autocomplete';
import {
GrAutocomplete,
AutocompleteQuery,
AutocompleteSuggestion,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {ValueChangedEvent} from '../../../types/events';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
export interface RebaseChange {
name: string;
value: NumericChangeId;
}
export interface ConfirmRebaseEventDetail {
base: string | null;
allowConflicts: boolean;
}
@customElement('gr-confirm-rebase-dialog')
export class GrConfirmRebaseDialog extends LitElement {
/**
* Fired when the confirm button is pressed.
*
* @event confirm
*/
/**
* Fired when the cancel button is pressed.
*
* @event cancel
*/
@property({type: String})
branch?: BranchName;
@property({type: Number})
changeNumber?: NumericChangeId;
@property({type: Boolean})
hasParent?: boolean;
@property({type: Boolean})
rebaseOnCurrent?: boolean;
@state()
text = '';
@state()
private query: AutocompleteQuery;
@state()
recentChanges?: RebaseChange[];
@query('#rebaseOnParentInput')
private rebaseOnParentInput!: HTMLInputElement;
@query('#rebaseOnTipInput')
private rebaseOnTipInput!: HTMLInputElement;
@query('#rebaseOnOtherInput')
rebaseOnOtherInput!: HTMLInputElement;
@query('#rebaseAllowConflicts')
private rebaseAllowConflicts!: HTMLInputElement;
@query('#parentInput')
parentInput!: GrAutocomplete;
private readonly restApiService = getAppContext().restApiService;
constructor() {
super();
this.query = input => this.getChangeSuggestions(input);
}
override willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has('rebaseOnCurrent') ||
changedProperties.has('hasParent')
) {
this.updateSelectedOption();
}
}
static override styles = [
sharedStyles,
css`
:host {
display: block;
width: 30em;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
label {
cursor: pointer;
}
.message {
font-style: italic;
}
.parentRevisionContainer label,
.parentRevisionContainer input[type='text'] {
display: block;
width: 100%;
}
.rebaseAllowConflicts {
margin-top: var(--spacing-m);
}
.rebaseOption {
margin: var(--spacing-m) 0;
}
`,
];
override render() {
return html`
<gr-dialog
id="confirmDialog"
confirm-label="Rebase"
@confirm=${this.handleConfirmTap}
@cancel=${this.handleCancelTap}
>
<div class="header" slot="header">Confirm rebase</div>
<div class="main" slot="main">
<div
id="rebaseOnParent"
class="rebaseOption"
?hidden=${!this.displayParentOption()}
>
<input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
<label id="rebaseOnParentLabel" for="rebaseOnParentInput">
Rebase on parent change
</label>
</div>
<div
id="parentUpToDateMsg"
class="message"
?hidden=${!this.displayParentUpToDateMsg()}
>
This change is up to date with its parent.
</div>
<div
id="rebaseOnTip"
class="rebaseOption"
?hidden=${!this.displayTipOption()}
>
<input
id="rebaseOnTipInput"
name="rebaseOptions"
type="radio"
?disabled=${!this.displayTipOption()}
/>
<label id="rebaseOnTipLabel" for="rebaseOnTipInput">
Rebase on top of the ${this.branch} branch<span
?hidden=${!this.hasParent}
>
(breaks relation chain)
</span>
</label>
</div>
<div
id="tipUpToDateMsg"
class="message"
?hidden=${this.displayTipOption()}
>
Change is up to date with the target branch already (${this.branch})
</div>
<div id="rebaseOnOther" class="rebaseOption">
<input
id="rebaseOnOtherInput"
name="rebaseOptions"
type="radio"
@click=${this.handleRebaseOnOther}
/>
<label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
Rebase on a specific change, ref, or commit
<span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
</label>
</div>
<div class="parentRevisionContainer">
<gr-autocomplete
id="parentInput"
.query=${this.query}
no-debounce
.text=${this.text}
@text-changed=${(e: ValueChangedEvent) =>
(this.text = e.detail.value)}
@click=${this.handleEnterChangeNumberClick}
allow-non-suggested-values
placeholder="Change number, ref, or commit hash"
>
</gr-autocomplete>
</div>
<div class="rebaseAllowConflicts">
<input id="rebaseAllowConflicts" type="checkbox" />
<label for="rebaseAllowConflicts"
>Allow rebase with conflicts</label
>
</div>
</div>
</gr-dialog>
`;
}
// This is called by gr-change-actions every time the rebase dialog is
// re-opened. Unlike other autocompletes that make a request with each
// updated input, this one gets all recent changes once and then filters
// them by the input. The query is re-run each time the dialog is opened
// in case there are new/updated changes in the generic query since the
// last time it was run.
fetchRecentChanges() {
return this.restApiService
.getChanges(
undefined,
'is:open -age:90d',
/* offset=*/ undefined,
/* options=*/ undefined,
throwingErrorCallback
)
.then(response => {
if (!response) return [];
const changes: RebaseChange[] = [];
for (const change of response) {
changes.push({
name: `${change._number}: ${change.subject}`,
value: change._number,
});
}
this.recentChanges = changes;
return this.recentChanges;
});
}
getRecentChanges() {
if (this.recentChanges) {
return Promise.resolve(this.recentChanges);
}
return this.fetchRecentChanges();
}
private getChangeSuggestions(input: string) {
return this.getRecentChanges().then(changes =>
this.filterChanges(input, changes)
);
}
filterChanges(
input: string,
changes: RebaseChange[]
): AutocompleteSuggestion[] {
return changes
.filter(
change =>
change.name.includes(input) && change.value !== this.changeNumber
)
.map(
change =>
({
name: change.name,
value: `${change.value}`,
} as AutocompleteSuggestion)
);
}
private displayParentOption() {
return this.hasParent && this.rebaseOnCurrent;
}
private displayParentUpToDateMsg() {
return this.hasParent && !this.rebaseOnCurrent;
}
private displayTipOption() {
return this.rebaseOnCurrent || this.hasParent;
}
/**
* There is a subtle but important difference between setting the base to an
* empty string and omitting it entirely from the payload. An empty string
* implies that the parent should be cleared and the change should be
* rebased on top of the target branch. Leaving out the base implies that it
* should be rebased on top of its current parent.
*/
getSelectedBase() {
if (this.rebaseOnParentInput.checked) {
return null;
}
if (this.rebaseOnTipInput.checked) {
return '';
}
if (!this.text) {
return '';
}
// Change numbers will have their description appended by the
// autocomplete.
return this.text.split(':')[0];
}
private handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
const detail: ConfirmRebaseEventDetail = {
base: this.getSelectedBase(),
allowConflicts: this.rebaseAllowConflicts.checked,
};
this.dispatchEvent(new CustomEvent('confirm', {detail}));
this.text = '';
}
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(new CustomEvent('cancel'));
this.text = '';
}
private handleRebaseOnOther() {
this.parentInput.focus();
}
private handleEnterChangeNumberClick() {
this.rebaseOnOtherInput.checked = true;
}
/**
* Sets the default radio button based on the state of the app and
* the corresponding value to be submitted.
*/
private updateSelectedOption() {
const {rebaseOnCurrent, hasParent} = this;
if (rebaseOnCurrent === undefined || hasParent === undefined) {
return;
}
if (this.displayParentOption()) {
this.rebaseOnParentInput.checked = true;
} else if (this.displayTipOption()) {
this.rebaseOnTipInput.checked = true;
} else {
this.rebaseOnOtherInput.checked = true;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-confirm-rebase-dialog': GrConfirmRebaseDialog;
}
}