blob: 45ed24d93069a383f2ef9663397b6d7c0a148f75 [file] [log] [blame] [edit]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {RepoName} from '../../../types/common';
import {fireError, firePageError} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
import {sharedStyles} from '../../../styles/shared-styles';
import {tableStyles} from '../../../styles/gr-table-styles';
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {when} from 'lit/directives/when.js';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {assertIsDefined} from '../../../utils/common-util';
import {modalStyles} from '../../../styles/gr-modal-styles';
import '../../shared/gr-list-view/gr-list-view';
import {
createRepoUrl,
RepoDetailView,
RepoViewState,
} from '../../../models/views/repo';
import {
BatchLabelInput,
DeleteLabelInput,
LabelDefinitionInfo,
LabelDefinitionInfoFunction,
LabelDefinitionInput,
LabelValueToDescriptionMap,
} from '../../../api/rest-api';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {createChangeUrl} from '../../../models/views/change';
import {resolve} from '../../../models/dependency';
import {GrButton} from '../../shared/gr-button/gr-button';
import '@material/web/textfield/outlined-text-field';
import {materialStyles} from '../../../styles/gr-material-styles';
@customElement('gr-repo-labels')
export class GrRepoLabels extends LitElement {
@property({type: String})
repo?: RepoName;
@property({type: Object})
params?: RepoViewState;
@query('#createDialog')
private readonly createDialog?: HTMLDialogElement;
@query('#deleteDialog')
private readonly deleteDialog?: HTMLDialogElement;
@state()
loading = true;
@state()
labels?: LabelDefinitionInfo[];
@state()
showCreateDialog = false;
@state() isProjectOwner = false;
@state()
disableSaveWithoutReview = true;
@state()
showSaveForReviewButton = false;
@state()
newLabel: LabelDefinitionInput = this.getEmptyLabel();
@state() offset = 0;
@state() filter = '';
@state() itemsPerPage = 25;
@state() showDeleteDialog = false;
@state()
labelToDelete?: LabelDefinitionInfo;
@state()
labelToEdit?: LabelDefinitionInfo;
@state()
isEditing = false;
private readonly getNavigation = resolve(this, navigationToken);
private readonly restApiService = getAppContext().restApiService;
static override get styles() {
return [
materialStyles,
sharedStyles,
tableStyles,
grFormStyles,
modalStyles,
css`
:host {
display: block;
margin-bottom: var(--spacing-xxl);
}
.actions {
display: flex;
justify-content: flex-end;
margin-bottom: var(--spacing-m);
padding: var(--spacing-l);
}
.createButton {
margin-left: var(--spacing-m);
}
.deleteBtn {
--gr-button-padding: var(--spacing-s) var(--spacing-m);
}
div.title-flex,
div.value-flex {
display: flex;
flex-direction: column;
justify-content: center;
}
input {
width: 20em;
box-sizing: border-box;
}
div.gr-form-styles section {
margin: var(--spacing-m) 0;
}
div.gr-form-styles span.title {
width: 13em;
}
section .title gr-icon {
vertical-align: top;
}
textarea {
width: 20em;
min-height: 4em;
resize: vertical;
box-sizing: border-box;
background-color: var(--view-background-color);
color: var(--primary-text-color);
}
textarea:focus {
outline: none;
box-shadow: none;
border: 2px solid var(--input-focus-border-color);
margin: -1px;
}
textarea::placeholder {
color: var(--deemphasized-text-color);
}
gr-dialog .main {
max-height: 70vh;
overflow-y: auto;
}
gr-dialog .footer {
width: 100%;
display: flex;
justify-content: flex-end;
}
gr-dialog {
width: 36em;
}
.warning {
color: var(--warning-foreground);
margin-top: var(--spacing-s);
}
.warning gr-icon {
color: var(--warning-icon-color);
vertical-align: bottom;
margin-right: var(--spacing-s);
}
td gr-icon {
vertical-align: bottom;
margin-left: var(--spacing-s);
}
md-outlined-text-field {
max-width: 25em;
}
`,
];
}
constructor() {
super();
if (this.repo) {
this.checkProjectOwner();
}
}
private async checkProjectOwner() {
if (!this.repo) return;
try {
const access = await this.restApiService.getRepoAccessRights(this.repo);
this.isProjectOwner = !!access?.is_owner;
this.disableSaveWithoutReview =
!!access?.require_change_for_config_update;
this.showSaveForReviewButton = true;
} catch (e) {
console.error('Failed to check project owner status:', e);
this.isProjectOwner = false;
}
}
override render() {
return html`
<gr-list-view
.createNew=${this.isProjectOwner}
.filter=${this.filter}
.itemsPerPage=${this.itemsPerPage}
.items=${this.labels}
.loading=${this.loading}
.offset=${this.offset}
.path=${createRepoUrl({
repo: this.repo,
detail: RepoDetailView.LABELS,
})}
@create-clicked=${() => this.handleCreateClick()}
>
<table id="list" class="genericList">
<tbody>
<tr class="headerRow">
<th class="topHeader">Name</th>
<th class="topHeader">Description</th>
<th class="topHeader">Function</th>
<th class="topHeader">Default Value</th>
<th class="topHeader">Copy Condition</th>
<th class="topHeader">Allow Post Submit</th>
<th class="topHeader">Can Override</th>
<th class="topHeader">Ignore Self Approval</th>
<th class="topHeader">Branches</th>
<th class="topHeader">Values</th>
${when(
this.isProjectOwner,
() => html`<th class="topHeader"></th>`
)}
</tr>
</tbody>
<tbody id="labels">
${when(
this.loading,
() => html`<tr id="loadingContainer">
<td>Loading...</td>
</tr>`,
() =>
html` ${(this.labels ?? []).map(
item => html`
<tr class="table">
<td class="name">${item.name}</td>
<td class="description">${item.description}</td>
<td class="function">
${item.function}
${when(
this.isFunctionDeprecated(item.function),
() => html`
<gr-icon
icon="warning"
title="This function is deprecated."
></gr-icon>
`
)}
</td>
<td class="defaultValue">${item.default_value}</td>
<td class="copyCondition">${item.copy_condition}</td>
<td class="allowPostSubmit">
${this.renderCheckmark(item.allow_post_submit)}
</td>
<td class="canOverride">
${this.renderCheckmark(item.can_override)}
</td>
<td class="ignoreSelfApproval">
${this.renderCheckmark(item.ignore_self_approval)}
${when(
item.ignore_self_approval,
() => html`
<gr-icon
icon="warning"
title="ignoreSelfApproval is deprecated."
></gr-icon>
`
)}
</td>
<td class="branches">
${(item.branches ?? []).join(', ')}
</td>
<td class="values">${JSON.stringify(item.values)}</td>
${when(
this.isProjectOwner,
() => html`
<td class="actions">
<gr-button
class="editBtn"
link
@click=${() => this.handleEditClick(item)}
>
Edit
</gr-button>
<gr-button
class="deleteBtn"
link
@click=${() => this.handleDeleteClick(item)}
>
Delete
</gr-button>
</td>
`
)}
</tr>
`
)}`
)}
</tbody>
</table>
</gr-list-view>
${this.renderCreateDialog()} ${this.renderDeleteDialog()}
`;
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('repo')) {
this.getLabels();
this.checkProjectOwner();
}
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('params')) {
this.paramsChanged();
}
}
async paramsChanged() {
const params = this.params;
this.loading = true;
this.filter = params?.filter ?? '';
this.offset = Number(params?.offset ?? 0);
await this.getLabels(this.filter, this.offset);
}
private getLabels(filter?: string, offset?: number) {
const repo = this.repo;
this.loading = true;
if (!repo) {
return Promise.resolve();
}
const errFn: ErrorCallback = response => {
firePageError(response);
};
return this.restApiService
.getRepoLabels(repo, errFn)
.then((res?: LabelDefinitionInfo[]) => {
if (!res) {
return;
}
this.labels = res
.filter(item =>
filter === undefined
? true
: item.name.toLowerCase().includes(filter.toLowerCase())
)
.slice(offset ?? 0, (offset ?? 0) + this.itemsPerPage);
this.loading = false;
});
}
private isFunctionDeprecated(fun?: LabelDefinitionInfoFunction) {
if (!fun) return false;
return (
fun !== LabelDefinitionInfoFunction.NoBlock &&
fun !== LabelDefinitionInfoFunction.Noop &&
fun !== LabelDefinitionInfoFunction.PatchSetLock
);
}
private renderCheckmark(check?: boolean) {
return check ? '✓' : '';
}
private handleCreateClick() {
this.isEditing = false;
this.newLabel = this.getEmptyLabel();
assertIsDefined(this.createDialog, 'createDialog');
this.createDialog.showModal();
}
private handleEditClick(label: LabelDefinitionInfo) {
this.isEditing = true;
this.labelToEdit = label;
this.newLabel = {
name: label.name,
description: label.description,
function: label.function,
default_value: label.default_value,
copy_condition: label.copy_condition,
allow_post_submit: label.allow_post_submit,
can_override: label.can_override,
ignore_self_approval: label.ignore_self_approval,
values: label.values,
branches: label.branches,
};
assertIsDefined(this.createDialog, 'createDialog');
this.createDialog.showModal();
}
private handleCreateCancel() {
assertIsDefined(this.createDialog, 'createDialog');
this.createDialog.close();
this.newLabel = this.getEmptyLabel();
this.labelToEdit = undefined;
this.isEditing = false;
}
private handleCreateConfirm() {
if (!this.repo) return;
if (!this.newLabel.name) {
return;
}
if (
this.isEditing &&
this.labelToEdit?.copy_condition &&
!this.newLabel.copy_condition
) {
this.newLabel.unset_copy_condition = true;
}
const errFn: ErrorCallback = response => {
firePageError(response);
};
const promise =
this.isEditing && this.labelToEdit
? this.restApiService.updateRepoLabel(
this.repo,
this.labelToEdit.name,
this.newLabel,
errFn
)
: this.restApiService.createRepoLabel(
this.repo,
this.newLabel.name,
this.newLabel,
errFn
);
promise.then(() => {
this.createDialog?.close();
this.newLabel = this.getEmptyLabel();
this.labelToEdit = undefined;
this.isEditing = false;
this.getLabels(this.filter, this.offset);
});
}
private getEmptyLabel(): LabelDefinitionInput {
return {
name: '',
description: '',
function: LabelDefinitionInfoFunction.NoBlock,
default_value: 0,
copy_condition: '',
allow_post_submit: false,
can_override: true,
ignore_self_approval: false,
values: {
' 0': 'No score',
'-1': 'I would prefer this is not submitted as is',
'-2': 'This shall not be submitted',
'+1': 'Looks good to me, but someone else must approve',
'+2': 'Looks good to me, approved',
},
branches: [],
};
}
private async handleSaveForReview(e: Event) {
if (!this.repo) return;
if (!this.newLabel.name) {
return;
}
const button = e.target as GrButton;
button.loading = true;
const errFn: ErrorCallback = response => {
firePageError(response);
};
if (
this.isEditing &&
this.labelToEdit?.copy_condition &&
!this.newLabel.copy_condition
) {
this.newLabel.unset_copy_condition = true;
}
const input: BatchLabelInput = {};
if (this.isEditing && this.labelToEdit) {
const updatedLabel: Partial<LabelDefinitionInput> = {};
const original = this.labelToEdit;
const updated = this.newLabel;
if (updated.description !== original.description) {
updatedLabel.description = updated.description;
}
if (updated.function !== original.function) {
updatedLabel.function = updated.function;
}
if (JSON.stringify(updated.values) !== JSON.stringify(original.values)) {
updatedLabel.values = updated.values;
}
if (updated.default_value !== original.default_value) {
updatedLabel.default_value = updated.default_value;
}
if (
JSON.stringify(updated.branches) !== JSON.stringify(original.branches)
) {
updatedLabel.branches = updated.branches;
}
if (updated.can_override !== original.can_override) {
updatedLabel.can_override = updated.can_override;
}
if (updated.copy_condition !== original.copy_condition) {
updatedLabel.copy_condition = updated.copy_condition;
}
if (updated.allow_post_submit !== original.allow_post_submit) {
updatedLabel.allow_post_submit = updated.allow_post_submit;
}
if (updated.ignore_self_approval !== original.ignore_self_approval) {
updatedLabel.ignore_self_approval = updated.ignore_self_approval;
}
if (updated.unset_copy_condition) {
updatedLabel.unset_copy_condition = updated.unset_copy_condition;
}
input.update = {[this.newLabel.name]: updatedLabel};
} else {
input.create = [this.newLabel];
}
const promise = this.restApiService.saveRepoLabelsForReview(
this.repo,
input,
errFn
);
try {
const change = await promise;
if (change) {
this.getNavigation().setUrl(createChangeUrl({change}));
}
} finally {
button.loading = false;
this.createDialog?.close();
this.newLabel = this.getEmptyLabel();
this.labelToEdit = undefined;
this.isEditing = false;
await this.getLabels(this.filter, this.offset);
}
}
private renderCreateDialog() {
if (!this.isProjectOwner) return nothing;
return html`
<dialog id="createDialog" tabindex="-1">
<gr-dialog .cancelLabel=${''} .confirmLabel=${''}>
<div class="header" slot="header">
${this.isEditing ? 'Edit' : 'Create'} Label
</div>
<div class="main" slot="main">
<div class="gr-form-styles">
<div id="form">
<section>
<div class="title-flex">
<span class="title">Name</span>
</div>
<div class="value-flex">
<span class="value">
<md-outlined-text-field
id="name"
class="showBlueFocusBorder"
?required=${true}
?disabled=${this.isEditing}
.value=${this.newLabel.name ?? ''}
@input=${(e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.newLabel = {
...this.newLabel,
name: target.value,
};
}}
>
</md-outlined-text-field>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Description</span>
</div>
<div class="value-flex">
<span class="value">
<md-outlined-text-field
id="description"
class="showBlueFocusBorder"
.value=${this.newLabel.description ?? ''}
@input=${(e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.newLabel = {
...this.newLabel,
description: target.value,
};
}}
>
</md-outlined-text-field>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Function</span>
</div>
<div class="value-flex">
<span class="value">
<gr-dropdown-list
.items=${Object.values(LabelDefinitionInfoFunction).map(
fun => {
return {
text: fun,
value: fun,
};
}
)}
.value=${this.newLabel.function ?? ''}
@value-change=${(e: CustomEvent<{value: string}>) => {
const value = e.detail.value;
this.newLabel = {
...this.newLabel,
function: value
? (value as LabelDefinitionInfoFunction)
: undefined,
};
}}
></gr-dropdown-list>
${when(
this.isFunctionDeprecated(this.newLabel.function),
() => html`
<div class="warning">
<gr-icon icon="warning"></gr-icon>
This function is deprecated.
</div>
`
)}
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Default Value</span>
</div>
<div class="value-flex">
<span class="value">
<md-outlined-text-field
id="defaultValue"
class="showBlueFocusBorder"
type="number"
.value=${this.newLabel.default_value?.toString() ?? ''}
@input=${(e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.newLabel = {
...this.newLabel,
default_value: Number(target.value),
};
}}
>
</md-outlined-text-field>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Copy Condition</span>
</div>
<div class="value-flex">
<span class="value">
<md-outlined-text-field
id="copyCondition"
class="showBlueFocusBorder"
.value=${this.newLabel.copy_condition ?? ''}
@input=${(e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.newLabel = {
...this.newLabel,
copy_condition: target.value,
};
}}
>
</md-outlined-text-field>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Can Override</span>
</div>
<div class="value-flex">
<span class="value">
<input
id="canOverride"
type="checkbox"
?checked=${this.newLabel.can_override ?? false}
@change=${(e: Event) => {
this.newLabel = {
...this.newLabel,
can_override: (e.target as HTMLInputElement)
.checked,
};
}}
/>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Allow Post Submit</span>
</div>
<div class="value-flex">
<span class="value">
<input
id="allowPostSubmit"
type="checkbox"
?checked=${this.newLabel.allow_post_submit ?? false}
@change=${(e: Event) => {
this.newLabel = {
...this.newLabel,
allow_post_submit: (e.target as HTMLInputElement)
.checked,
};
}}
/>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Ignore Self Approval</span>
</div>
<div class="value-flex">
<span class="value">
<input
id="ignoreSelfApproval"
type="checkbox"
?checked=${this.newLabel.ignore_self_approval ?? false}
@change=${(e: Event) => {
this.newLabel = {
...this.newLabel,
ignore_self_approval: (e.target as HTMLInputElement)
.checked,
};
}}
/>
${when(
this.newLabel.ignore_self_approval,
() => html`
<div class="warning">
<gr-icon icon="warning"></gr-icon>
ignoreSelfApproval is deprecated.
</div>
`
)}
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Branches</span>
</div>
<div class="value-flex">
<span class="value">
<textarea
id="branches"
rows="2"
.value=${(this.newLabel.branches ?? []).join('\n')}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
this.newLabel = {
...this.newLabel,
branches: target.value
.split('\n')
.map(b => b.trim())
.filter(Boolean),
};
}}
></textarea>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Values</span>
</div>
<div class="value-flex">
<span class="value">
<textarea
id="values"
.value=${JSON.stringify(this.newLabel.values, null, 2)}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
try {
this.newLabel = {
...this.newLabel,
values: JSON.parse(
target.value
) as LabelValueToDescriptionMap,
};
} catch (err) {
fireError(this, 'Invalid JSON');
}
}}
></textarea>
</span>
</div>
</section>
</div>
</div>
</div>
<div class="footer" slot="footer">
<gr-button @click=${this.handleCreateCancel} link>Cancel</gr-button>
<gr-button
class="action save-button"
link
primary
?disabled=${!this.newLabel.name || this.disableSaveWithoutReview}
@click=${this.handleCreateConfirm}
>
${this.isEditing ? 'Save' : 'Create'}
</gr-button>
<gr-button
class="action save-for-review"
primary
link
?hidden=${!this.showSaveForReviewButton}
?disabled=${!this.newLabel.name}
@click=${this.handleSaveForReview}
>
Save for review
</gr-button>
</div>
</gr-dialog>
</dialog>
`;
}
private renderDeleteDialog() {
if (!this.isProjectOwner) return nothing;
return html`
<dialog id="deleteDialog" tabindex="-1">
<gr-dialog .cancelLabel=${''} .confirmLabel=${''}>
<div class="header" slot="header">Delete Label</div>
<div class="main" slot="main">
Are you sure you want to delete the label
"${this.labelToDelete?.name}"?
</div>
<div class="footer" slot="footer">
<gr-button link @click=${this.handleDeleteCancel}>Cancel</gr-button>
<gr-button
class="action"
link
?disabled=${this.disableSaveWithoutReview}
@click=${this.handleDeleteConfirm}
>
Delete
</gr-button>
<gr-button
class="action"
primary
link
?hidden=${!this.showSaveForReviewButton}
@click=${this.handleDeleteForReviewConfirm}
>
Delete for review
</gr-button>
</div>
</gr-dialog>
</dialog>
`;
}
private handleDeleteClick(label: LabelDefinitionInfo) {
this.labelToDelete = label;
assertIsDefined(this.deleteDialog, 'deleteDialog');
this.deleteDialog.showModal();
}
private handleDeleteCancel() {
assertIsDefined(this.deleteDialog, 'deleteDialog');
this.deleteDialog.close();
this.labelToDelete = undefined;
}
private async handleDeleteForReviewConfirm() {
if (!this.repo || !this.labelToDelete) return;
const errFn: ErrorCallback = response => {
firePageError(response);
};
const input: BatchLabelInput = {
delete: [this.labelToDelete.name],
};
const promise = this.restApiService.saveRepoLabelsForReview(
this.repo,
input,
errFn
);
try {
const change = await promise;
if (change) {
this.getNavigation().setUrl(createChangeUrl({change}));
}
} finally {
this.deleteDialog?.close();
this.labelToDelete = undefined;
await this.getLabels(this.filter, this.offset);
}
}
private handleDeleteConfirm() {
if (!this.repo || !this.labelToDelete) return;
const errFn: ErrorCallback = response => {
firePageError(response);
};
const deleteInput: DeleteLabelInput = {};
this.restApiService
.deleteRepoLabel(this.repo, this.labelToDelete.name, deleteInput, errFn)
.then(() => {
this.deleteDialog?.close();
this.labelToDelete = undefined;
this.getLabels(this.filter, this.offset);
});
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-repo-labels': GrRepoLabels;
}
}