blob: e40b5b828c50eedccace6653c48beb00b9936d09 [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {RepoName, SubmitRequirementInfo} from '../../../types/common';
import {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 '@material/web/textfield/outlined-text-field';
import {resolve} from '../../../models/dependency';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {createChangeUrl} from '../../../models/views/change';
import {
BatchSubmitRequirementInput,
SubmitRequirementInput,
} from '../../../api/rest-api';
import {GrButton} from '../../shared/gr-button/gr-button';
import {materialStyles} from '../../../styles/gr-material-styles';
import '@material/web/checkbox/checkbox';
import {MdCheckbox} from '@material/web/checkbox/checkbox';
@customElement('gr-repo-submit-requirements')
export class GrRepoSubmitRequirements 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()
submitRequirements?: SubmitRequirementInfo[];
@state()
showCreateDialog = false;
@state() isProjectOwner = false;
@state()
disableSaveWithoutReview = true;
@state()
showSaveForReviewButton = false;
@state()
newRequirement: SubmitRequirementInput = this.getEmptyRequirement();
@state() offset = 0;
@state() filter = '';
@state() itemsPerPage = 25;
@state() showDeleteDialog = false;
@state() requirementToDelete?: SubmitRequirementInfo;
@state()
requirementToEdit?: SubmitRequirementInfo;
@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: 100px;
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 {
width: 36em;
}
gr-dialog .footer {
width: 100%;
display: flex;
justify-content: flex-end;
}
md-outlined-text-field {
width: 20em;
}
`,
];
}
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.submitRequirements}
.loading=${this.loading}
.offset=${this.offset}
.path=${createRepoUrl({
repo: this.repo,
detail: RepoDetailView.SUBMIT_REQUIREMENTS,
})}
@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">Applicability Expression</th>
<th class="topHeader">Submittability Expression</th>
<th class="topHeader">Override Expression</th>
<th
class="topHeader"
title="Whether override is allowed in child projects"
>
Allow Override
</th>
${when(
this.isProjectOwner,
() => html`<th class="topHeader"></th>`
)}
</tr>
</tbody>
<tbody id="submit-requirements">
${when(
this.loading,
() => html`<tr id="loadingContainer">
<td>Loading...</td>
</tr>`,
() =>
html` ${(this.submitRequirements ?? []).map(
item => html`
<tr class="table">
<td class="name">${item.name}</td>
<td class="desc">${item.description}</td>
<td class="applicability">
${item.applicability_expression}
</td>
<td class="submittability">
${item.submittability_expression}
</td>
<td class="override">${item.override_expression}</td>
<td class="allowOverride">
${this.renderCheckmark(
item.allow_override_in_child_projects
)}
</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.getSubmitRequirements();
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.getSubmitRequirements(this.filter, this.offset);
}
private getSubmitRequirements(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
.getRepoSubmitRequirements(repo, errFn)
.then((res?: SubmitRequirementInfo[]) => {
if (!res) {
return;
}
this.submitRequirements = res
.filter(item =>
filter === undefined
? true
: item.name.toLowerCase().includes(filter.toLowerCase())
)
.slice(offset ?? 0, (offset ?? 0) + this.itemsPerPage);
this.loading = false;
});
}
private renderCheckmark(check?: boolean) {
return check ? '✓' : '';
}
private handleCreateClick() {
this.isEditing = false;
this.newRequirement = this.getEmptyRequirement();
assertIsDefined(this.createDialog, 'createDialog');
this.createDialog.showModal();
}
private handleEditClick(requirement: SubmitRequirementInfo) {
this.isEditing = true;
this.requirementToEdit = requirement;
this.newRequirement = {
name: requirement.name,
description: requirement.description || '',
applicability_expression: requirement.applicability_expression || '',
submittability_expression: requirement.submittability_expression || '',
override_expression: requirement.override_expression || '',
allow_override_in_child_projects:
requirement.allow_override_in_child_projects || false,
};
assertIsDefined(this.createDialog, 'createDialog');
this.createDialog.showModal();
}
private handleCreateCancel() {
assertIsDefined(this.createDialog, 'createDialog');
this.createDialog.close();
this.newRequirement = this.getEmptyRequirement();
this.requirementToEdit = undefined;
this.isEditing = false;
}
private handleCreateConfirm() {
if (!this.repo) return;
if (
!this.newRequirement.name ||
!this.newRequirement.submittability_expression
) {
return;
}
const errFn: ErrorCallback = response => {
firePageError(response);
};
const promise =
this.isEditing && this.requirementToEdit
? this.restApiService.updateSubmitRequirement(
this.repo,
this.requirementToEdit.name,
this.newRequirement,
errFn
)
: this.restApiService.createSubmitRequirement(
this.repo,
this.newRequirement,
errFn
);
promise.then(() => {
this.createDialog?.close();
this.newRequirement = this.getEmptyRequirement();
this.requirementToEdit = undefined;
this.isEditing = false;
this.getSubmitRequirements(this.filter, this.offset);
});
}
private getEmptyRequirement(): SubmitRequirementInput {
return {
name: '',
description: '',
applicability_expression: '',
submittability_expression: '',
override_expression: '',
allow_override_in_child_projects: false,
};
}
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'} Submit Requirement
</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.newRequirement.name ?? ''}
@input=${(e: InputEvent) => {
this.newRequirement = {
...this.newRequirement,
name: (e.target as HTMLInputElement).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">
<textarea
id="description"
.value=${this.newRequirement.description ?? ''}
@input=${(e: InputEvent) => {
this.newRequirement = {
...this.newRequirement,
description: (e.target as HTMLTextAreaElement)
.value,
};
}}
placeholder="Optional"
></textarea>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Applicability Expression</span>
</div>
<div class="value-flex">
<span class="value">
<md-outlined-text-field
id="applicability"
class="showBlueFocusBorder"
placeholder="Optional"
.value=${this.newRequirement.applicability_expression ??
''}
@input=${(e: InputEvent) => {
this.newRequirement = {
...this.newRequirement,
applicability_expression: (
e.target as HTMLInputElement
).value,
};
}}
>
</md-outlined-text-field>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Submittability Expression</span>
</div>
<div class="value-flex">
<span class="value">
<md-outlined-text-field
id="submittability"
class="showBlueFocusBorder"
?required=${true}
.value=${this.newRequirement
.submittability_expression ?? ''}
@input=${(e: InputEvent) => {
this.newRequirement = {
...this.newRequirement,
submittability_expression: (
e.target as HTMLInputElement
).value,
};
}}
>
</md-outlined-text-field>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Override Expression</span>
</div>
<div class="value-flex">
<span class="value">
<md-outlined-text-field
id="override"
class="showBlueFocusBorder"
placeholder="Optional"
.value=${this.newRequirement.override_expression ?? ''}
@input=${(e: InputEvent) => {
this.newRequirement = {
...this.newRequirement,
override_expression: (e.target as HTMLInputElement)
.value,
};
}}
>
</md-outlined-text-field>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Allow Override in Child Projects</span>
</div>
<div class="value-flex">
<span class="value">
<md-checkbox
id="allowOverride"
?checked=${this.newRequirement
.allow_override_in_child_projects}
@change=${(e: Event) => {
this.newRequirement = {
...this.newRequirement,
allow_override_in_child_projects: (
e.target as MdCheckbox
).checked,
};
}}
></md-checkbox>
</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.newRequirement.name ||
!this.newRequirement.submittability_expression ||
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.newRequirement.name ||
!this.newRequirement.submittability_expression}
@click=${this.handleSaveForReview}
>
Save for review
</gr-button>
</div>
</gr-dialog>
</dialog>
`;
}
private async handleSaveForReview(e: Event) {
if (!this.repo) return;
if (
!this.newRequirement.name ||
!this.newRequirement.submittability_expression
) {
return;
}
const button = e.target as GrButton;
button.loading = true;
const errFn: ErrorCallback = response => {
firePageError(response);
};
const input: BatchSubmitRequirementInput = {};
if (this.isEditing && this.requirementToEdit) {
input.update = {[this.newRequirement.name]: this.newRequirement};
} else {
input.create = [this.newRequirement];
}
const promise = this.restApiService.saveRepoSubmitRequirementsForReview(
this.repo,
input,
errFn
);
try {
const change = await promise;
if (change) {
this.getNavigation().setUrl(createChangeUrl({change}));
}
} finally {
button.loading = false;
this.createDialog?.close();
this.newRequirement = this.getEmptyRequirement();
this.requirementToEdit = undefined;
this.isEditing = false;
void this.getSubmitRequirements(this.filter, this.offset);
}
}
private renderDeleteDialog() {
if (!this.isProjectOwner) return nothing;
return html`
<dialog id="deleteDialog" tabindex="-1">
<gr-dialog .cancelLabel=${''} .confirmLabel=${''}>
<div class="header" slot="header">Delete Submit Requirement</div>
<div class="main" slot="main">
Are you sure you want to delete the submit requirement
"${this.requirementToDelete?.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(requirement: SubmitRequirementInfo) {
this.requirementToDelete = requirement;
assertIsDefined(this.deleteDialog, 'deleteDialog');
this.deleteDialog.showModal();
}
private handleDeleteCancel() {
assertIsDefined(this.deleteDialog, 'deleteDialog');
this.deleteDialog.close();
this.requirementToDelete = undefined;
}
private async handleDeleteForReviewConfirm() {
if (!this.repo || !this.requirementToDelete) return;
const errFn: ErrorCallback = response => {
firePageError(response);
};
const input: BatchSubmitRequirementInput = {
delete: [this.requirementToDelete.name],
};
const promise = this.restApiService.saveRepoSubmitRequirementsForReview(
this.repo,
input,
errFn
);
try {
const change = await promise;
if (change) {
this.getNavigation().setUrl(createChangeUrl({change}));
}
} finally {
this.deleteDialog?.close();
this.requirementToDelete = undefined;
void this.getSubmitRequirements(this.filter, this.offset);
}
}
private handleDeleteConfirm() {
if (!this.repo || !this.requirementToDelete) return;
const errFn: ErrorCallback = response => {
firePageError(response);
};
this.restApiService
.deleteSubmitRequirement(this.repo, this.requirementToDelete.name, errFn)
.then(() => {
this.deleteDialog?.close();
this.requirementToDelete = undefined;
this.getSubmitRequirements(this.filter, this.offset);
});
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-repo-submit-requirements': GrRepoSubmitRequirements;
}
}