blob: 2996e50fac7c811452b057a4cddbd1ae2dad8f54 [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/iron-input/iron-input';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
import {customElement, property, query} from 'lit/decorators.js';
import {
AutocompleteQuery,
GrAutocomplete,
AutocompleteSuggestion,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {assertIsDefined} from '../../../utils/common-util';
import {ProjectWatchInfo, RepoName} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {css, html, LitElement} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {formStyles} from '../../../styles/gr-form-styles';
import {when} from 'lit/directives/when.js';
import {fire} from '../../../utils/event-util';
import {PropertiesOfType} from '../../../utils/type-util';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
const NOTIFICATION_TYPES: Array<{name: string; key: NotificationKey}> = [
{name: 'Changes', key: 'notify_new_changes'},
{name: 'Patches', key: 'notify_new_patch_sets'},
{name: 'Comments', key: 'notify_all_comments'},
{name: 'Submits', key: 'notify_submitted_changes'},
{name: 'Abandons', key: 'notify_abandoned_changes'},
];
@customElement('gr-watched-projects-editor')
export class GrWatchedProjectsEditor extends LitElement {
// Private but used in tests.
@query('#newFilter')
newFilter?: HTMLInputElement;
// Private but used in tests.
@query('#newProject')
newProject?: GrAutocomplete;
@property({type: Boolean})
hasUnsavedChanges = false;
@property({type: Array})
projects?: ProjectWatchInfo[];
@property({type: Array})
projectsToRemove: ProjectWatchInfo[] = [];
private readonly query: AutocompleteQuery = input =>
this.getProjectSuggestions(input);
private readonly restApiService = getAppContext().restApiService;
static override get styles() {
return [
sharedStyles,
formStyles,
css`
#watchedProjects .notifType {
text-align: center;
padding: 0 var(--spacing-s);
}
.notifControl {
cursor: pointer;
text-align: center;
}
.notifControl:hover {
outline: 1px solid var(--border-color);
}
.projectFilter {
color: var(--deemphasized-text-color);
font-style: italic;
margin-left: var(--spacing-l);
}
.newFilterInput {
width: 100%;
}
`,
];
}
override render() {
const types = NOTIFICATION_TYPES;
return html` <div class="gr-form-styles">
<table id="watchedProjects">
<thead>
<tr>
<th>Repo</th>
${types.map(type => html`<th class="notifType">${type.name}</th>`)}
<th></th>
</tr>
</thead>
<tbody>
${(this.projects ?? []).map(project => this.renderProject(project))}
</tbody>
<tfoot>
<tr>
<th>
<gr-autocomplete
id="newProject"
.query=${this.query}
threshold="1"
allow-non-suggested-values
tab-complete
placeholder="Repo"
></gr-autocomplete>
</th>
<th colspan=${types.length}>
<iron-input id="newFilterInput" class="newFilterInput">
<input
id="newFilter"
class="newFilterInput"
placeholder="branch:name, or other search expression"
/>
</iron-input>
</th>
<th>
<gr-button link="" @click=${this.handleAddProject}>Add</gr-button>
</th>
</tr>
</tfoot>
</table>
</div>`;
}
private renderProject(project: ProjectWatchInfo) {
const types = NOTIFICATION_TYPES;
return html` <tr>
<td>
${project.project}
${when(
project.filter,
() => html`<div class="projectFilter">${project.filter}</div>`
)}
</td>
${types.map(type => this.renderNotifyControl(project, type.key))}
<td>
<gr-button
link=""
@click=${(_e: Event) => this.handleRemoveProject(project)}
>Delete</gr-button
>
</td>
</tr>`;
}
private renderNotifyControl(project: ProjectWatchInfo, key: NotificationKey) {
return html` <td class="notifControl" @click=${this.handleNotifCellClick}>
<input
type="checkbox"
data-key=${key}
@change=${(e: Event) => this.handleCheckboxChange(project, key, e)}
?checked=${!!project[key]}
/>
</td>`;
}
loadData() {
return this.restApiService.getWatchedProjects().then(projs => {
this.projects = projs;
});
}
save() {
let deletePromise: Promise<Response | undefined>;
if (this.projectsToRemove.length) {
deletePromise = this.restApiService.deleteWatchedProjects(
this.projectsToRemove
);
} else {
deletePromise = Promise.resolve(undefined);
}
return deletePromise
.then(() => {
if (this.projects) {
return this.restApiService.saveWatchedProjects(this.projects);
} else {
return Promise.resolve(undefined);
}
})
.then(projects => {
this.projects = projects;
this.projectsToRemove = [];
this.setHasUnsavedChanges(false);
});
}
// private but used in tests.
getProjectSuggestions(input: string) {
return this.restApiService
.getSuggestedRepos(input, /* n=*/ undefined, throwingErrorCallback)
.then(response => {
const repos: AutocompleteSuggestion[] = [];
for (const [name, repo] of Object.entries(response ?? {})) {
repos.push({name, value: repo.id});
}
return repos;
});
}
private handleRemoveProject(project: ProjectWatchInfo) {
if (!this.projects) return;
const index = this.projects.indexOf(project);
if (index < 0) return;
this.projects.splice(index, 1);
this.projectsToRemove.push(project);
this.requestUpdate();
this.setHasUnsavedChanges(true);
}
// private but used in tests.
canAddProject(
project: string | null,
text: string | null,
filter: string | null
) {
if (project === null && text === null) {
return false;
}
// This will only be used if not using the auto complete
if (!project && text) {
return true;
}
if (!this.projects) return true;
// Check if the project with filter is already in the list.
for (let i = 0; i < this.projects.length; i++) {
if (
this.projects[i].project === project &&
this.areFiltersEqual(this.projects[i].filter, filter)
) {
return false;
}
}
return true;
}
// private but used in tests.
getNewProjectIndex(name: string, filter: string | null) {
if (!this.projects) return;
let i;
for (i = 0; i < this.projects.length; i++) {
const projectFilter = this.projects[i].filter;
if (
this.projects[i].project > name ||
(this.projects[i].project === name &&
this.isFilterDefined(projectFilter) &&
this.isFilterDefined(filter) &&
projectFilter! > filter!)
) {
break;
}
}
return i;
}
// Private but used in tests.
handleAddProject() {
assertIsDefined(this.newProject, 'newProject');
assertIsDefined(this.newFilter, 'newFilter');
const newProject = this.newProject.value;
const newProjectName = this.newProject.text as RepoName;
const filter = this.newFilter.value;
if (!this.canAddProject(newProject, newProjectName, filter)) {
return;
}
const insertIndex = this.getNewProjectIndex(newProjectName, filter);
if (insertIndex !== undefined) {
this.projects?.splice(insertIndex, 0, {
project: newProjectName,
filter,
_is_local: true,
});
this.requestUpdate();
}
this.newProject.clear();
this.newFilter.value = '';
this.setHasUnsavedChanges(true);
}
private handleCheckboxChange(
project: ProjectWatchInfo,
key: NotificationKey,
e: Event
) {
const el = e.target as HTMLInputElement;
const checked = el.checked;
project[key] = !!checked;
this.requestUpdate();
this.setHasUnsavedChanges(true);
}
private handleNotifCellClick(e: Event) {
if (e.target === null) return;
const checkbox = (e.target as HTMLElement).querySelector('input');
if (checkbox) {
checkbox.click();
}
}
private setHasUnsavedChanges(value: boolean) {
this.hasUnsavedChanges = value;
fire(this, 'has-unsaved-changes-changed', {value});
}
isFilterDefined(filter: string | null | undefined) {
return filter !== null && filter !== undefined;
}
areFiltersEqual(
filter1: string | null | undefined,
filter2: string | null | undefined
) {
// null and undefined are equal
if (!this.isFilterDefined(filter1) && !this.isFilterDefined(filter2)) {
return true;
}
return filter1 === filter2;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-watched-projects-editor': GrWatchedProjectsEditor;
}
}