blob: b978ddb27ed3ae7f598e9a2b50c04d3becd1de9c [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
RepoName,
SubmitRequirementInfo,
SubmitRequirementInput,
} 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 {LitElement, css, html, PropertyValues, nothing} from 'lit';
import {customElement, property, state, query} 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 '@polymer/iron-input/iron-input';
@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()
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 restApiService = getAppContext().restApiService;
static override get styles() {
return [
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;
}
gr-dialog {
width: 36em;
}
`,
];
}
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;
} 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
confirm-label=${this.isEditing ? 'Save' : 'Create'}
cancel-label="Cancel"
?disabled=${!this.newRequirement.name ||
!this.newRequirement.submittability_expression}
@confirm=${this.handleCreateConfirm}
@cancel=${this.handleCreateCancel}
>
<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">
<iron-input
.bindValue=${this.newRequirement.name}
@bind-value-changed=${(e: Event) => {
this.newRequirement = {
...this.newRequirement,
name: (e as CustomEvent).detail.value,
};
}}
>
<input
id="name"
type="text"
required
?disabled=${this.isEditing}
/>
</iron-input>
</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}
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">
<iron-input
.bindValue=${this.newRequirement
.applicability_expression}
@bind-value-changed=${(e: Event) => {
this.newRequirement = {
...this.newRequirement,
applicability_expression: (e as CustomEvent).detail
.value,
};
}}
>
<input
id="applicability"
type="text"
placeholder="Optional"
/>
</iron-input>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Submittability Expression</span>
</div>
<div class="value-flex">
<span class="value">
<iron-input
.bindValue=${this.newRequirement
.submittability_expression}
@bind-value-changed=${(e: Event) => {
this.newRequirement = {
...this.newRequirement,
submittability_expression: (e as CustomEvent).detail
.value,
};
}}
>
<input id="submittability" type="text" required />
</iron-input>
</span>
</div>
</section>
<section>
<div class="title-flex">
<span class="title">Override Expression</span>
</div>
<div class="value-flex">
<span class="value">
<iron-input
.bindValue=${this.newRequirement.override_expression}
@bind-value-changed=${(e: Event) => {
this.newRequirement = {
...this.newRequirement,
override_expression: (e as CustomEvent).detail
.value,
};
}}
>
<input
id="override"
type="text"
placeholder="Optional"
/>
</iron-input>
</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">
<gr-select
id="allowOverride"
.bindValue=${this.newRequirement
.allow_override_in_child_projects}
@bind-value-changed=${(e: Event) => {
this.newRequirement = {
...this.newRequirement,
allow_override_in_child_projects:
(e as CustomEvent).detail.value === 'true',
};
}}
>
<select>
<option value="true">True</option>
<option value="false">False</option>
</select>
</gr-select>
</span>
</div>
</section>
</div>
</div>
</div>
</gr-dialog>
</dialog>
`;
}
private renderDeleteDialog() {
if (!this.isProjectOwner) return nothing;
return html`
<dialog id="deleteDialog" tabindex="-1">
<gr-dialog
confirm-label="Delete"
cancel-label="Cancel"
@confirm=${this.handleDeleteConfirm}
@cancel=${this.handleDeleteCancel}
>
<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>
</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 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;
}
}