blob: 2996e50fac7c811452b057a4cddbd1ae2dad8f54 [file] [log] [blame]
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04001/**
2 * @license
Ben Rohlfs94fcbbc2022-05-27 10:45:03 +02003 * Copyright 2016 Google LLC
4 * SPDX-License-Identifier: Apache-2.0
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04005 */
Milutin Kristofic77b774e2020-08-25 14:30:26 +02006import '@polymer/iron-input/iron-input';
7import '../../shared/gr-autocomplete/gr-autocomplete';
8import '../../shared/gr-button/gr-button';
Frank Borden42c1a452022-08-11 16:27:20 +02009import {customElement, property, query} from 'lit/decorators.js';
Milutin Kristofic77b774e2020-08-25 14:30:26 +020010import {
11 AutocompleteQuery,
12 GrAutocomplete,
13 AutocompleteSuggestion,
14} from '../../shared/gr-autocomplete/gr-autocomplete';
Chris Poucet38d53432022-04-07 13:02:19 +020015import {assertIsDefined} from '../../../utils/common-util';
16import {ProjectWatchInfo, RepoName} from '../../../types/common';
Chris Poucetc6e880b2021-11-15 19:57:06 +010017import {getAppContext} from '../../../services/app-context';
Chris Poucet38d53432022-04-07 13:02:19 +020018import {css, html, LitElement} from 'lit';
19import {sharedStyles} from '../../../styles/shared-styles';
20import {formStyles} from '../../../styles/gr-form-styles';
Frank Borden42c1a452022-08-11 16:27:20 +020021import {when} from 'lit/directives/when.js';
Chris Poucet38d53432022-04-07 13:02:19 +020022import {fire} from '../../../utils/event-util';
Frank Borden79448112022-04-12 16:59:32 +020023import {PropertiesOfType} from '../../../utils/type-util';
Kamil Musinc39f4382022-11-29 17:43:40 +010024import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
Chris Poucet38d53432022-04-07 13:02:19 +020025
26type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
27
28const NOTIFICATION_TYPES: Array<{name: string; key: NotificationKey}> = [
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010029 {name: 'Changes', key: 'notify_new_changes'},
30 {name: 'Patches', key: 'notify_new_patch_sets'},
31 {name: 'Comments', key: 'notify_all_comments'},
32 {name: 'Submits', key: 'notify_submitted_changes'},
33 {name: 'Abandons', key: 'notify_abandoned_changes'},
34];
Wyatt Allenbd472172016-06-09 17:44:51 -070035
Milutin Kristofic77b774e2020-08-25 14:30:26 +020036@customElement('gr-watched-projects-editor')
Chris Poucet38d53432022-04-07 13:02:19 +020037export class GrWatchedProjectsEditor extends LitElement {
38 // Private but used in tests.
39 @query('#newFilter')
40 newFilter?: HTMLInputElement;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010041
Chris Poucet38d53432022-04-07 13:02:19 +020042 // Private but used in tests.
43 @query('#newProject')
44 newProject?: GrAutocomplete;
45
46 @property({type: Boolean})
Milutin Kristofic77b774e2020-08-25 14:30:26 +020047 hasUnsavedChanges = false;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010048
Milutin Kristofic77b774e2020-08-25 14:30:26 +020049 @property({type: Array})
Chris Poucet38d53432022-04-07 13:02:19 +020050 projects?: ProjectWatchInfo[];
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010051
Milutin Kristofic77b774e2020-08-25 14:30:26 +020052 @property({type: Array})
Chris Poucet38d53432022-04-07 13:02:19 +020053 projectsToRemove: ProjectWatchInfo[] = [];
Milutin Kristofic77b774e2020-08-25 14:30:26 +020054
Chris Poucet38d53432022-04-07 13:02:19 +020055 private readonly query: AutocompleteQuery = input =>
56 this.getProjectSuggestions(input);
Milutin Kristofic77b774e2020-08-25 14:30:26 +020057
Chris Poucetc6e880b2021-11-15 19:57:06 +010058 private readonly restApiService = getAppContext().restApiService;
Ben Rohlfs43935a42020-12-01 19:14:09 +010059
Chris Poucet38d53432022-04-07 13:02:19 +020060 static override get styles() {
61 return [
62 sharedStyles,
63 formStyles,
64 css`
65 #watchedProjects .notifType {
66 text-align: center;
67 padding: 0 var(--spacing-s);
68 }
69 .notifControl {
70 cursor: pointer;
71 text-align: center;
72 }
73 .notifControl:hover {
74 outline: 1px solid var(--border-color);
75 }
76 .projectFilter {
77 color: var(--deemphasized-text-color);
78 font-style: italic;
79 margin-left: var(--spacing-l);
80 }
81 .newFilterInput {
82 width: 100%;
83 }
84 `,
85 ];
86 }
87
88 override render() {
89 const types = NOTIFICATION_TYPES;
90 return html` <div class="gr-form-styles">
91 <table id="watchedProjects">
92 <thead>
93 <tr>
94 <th>Repo</th>
95 ${types.map(type => html`<th class="notifType">${type.name}</th>`)}
96 <th></th>
97 </tr>
98 </thead>
99 <tbody>
100 ${(this.projects ?? []).map(project => this.renderProject(project))}
101 </tbody>
102 <tfoot>
103 <tr>
104 <th>
105 <gr-autocomplete
106 id="newProject"
Frank Borden1a8dbba2022-09-01 14:09:15 +0200107 .query=${this.query}
Chris Poucet38d53432022-04-07 13:02:19 +0200108 threshold="1"
Dhruv80229b52022-04-08 15:46:48 +0200109 allow-non-suggested-values
110 tab-complete
Chris Poucet38d53432022-04-07 13:02:19 +0200111 placeholder="Repo"
112 ></gr-autocomplete>
113 </th>
114 <th colspan=${types.length}>
115 <iron-input id="newFilterInput" class="newFilterInput">
116 <input
117 id="newFilter"
118 class="newFilterInput"
119 placeholder="branch:name, or other search expression"
120 />
121 </iron-input>
122 </th>
123 <th>
124 <gr-button link="" @click=${this.handleAddProject}>Add</gr-button>
125 </th>
126 </tr>
127 </tfoot>
128 </table>
129 </div>`;
130 }
131
132 private renderProject(project: ProjectWatchInfo) {
133 const types = NOTIFICATION_TYPES;
134 return html` <tr>
135 <td>
136 ${project.project}
137 ${when(
138 project.filter,
139 () => html`<div class="projectFilter">${project.filter}</div>`
140 )}
141 </td>
142 ${types.map(type => this.renderNotifyControl(project, type.key))}
143 <td>
144 <gr-button
145 link=""
146 @click=${(_e: Event) => this.handleRemoveProject(project)}
147 >Delete</gr-button
148 >
149 </td>
150 </tr>`;
151 }
152
153 private renderNotifyControl(project: ProjectWatchInfo, key: NotificationKey) {
154 return html` <td class="notifControl" @click=${this.handleNotifCellClick}>
155 <input
156 type="checkbox"
157 data-key=${key}
158 @change=${(e: Event) => this.handleCheckboxChange(project, key, e)}
159 ?checked=${!!project[key]}
160 />
161 </td>`;
Dmitrii Filippov3fd2b102019-11-15 16:16:46 +0100162 }
163
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100164 loadData() {
Ben Rohlfs43935a42020-12-01 19:14:09 +0100165 return this.restApiService.getWatchedProjects().then(projs => {
Chris Poucet38d53432022-04-07 13:02:19 +0200166 this.projects = projs;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100167 });
168 }
169
170 save() {
Ben Rohlfsed6e6f42020-12-07 10:50:04 +0100171 let deletePromise: Promise<Response | undefined>;
Chris Poucet38d53432022-04-07 13:02:19 +0200172 if (this.projectsToRemove.length) {
Ben Rohlfs43935a42020-12-01 19:14:09 +0100173 deletePromise = this.restApiService.deleteWatchedProjects(
Chris Poucet38d53432022-04-07 13:02:19 +0200174 this.projectsToRemove
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200175 );
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100176 } else {
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200177 deletePromise = Promise.resolve(undefined);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100178 }
179
180 return deletePromise
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200181 .then(() => {
Chris Poucet38d53432022-04-07 13:02:19 +0200182 if (this.projects) {
183 return this.restApiService.saveWatchedProjects(this.projects);
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200184 } else {
185 return Promise.resolve(undefined);
186 }
187 })
188 .then(projects => {
Chris Poucet38d53432022-04-07 13:02:19 +0200189 this.projects = projects;
190 this.projectsToRemove = [];
191 this.setHasUnsavedChanges(false);
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200192 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100193 }
194
Chris Poucet38d53432022-04-07 13:02:19 +0200195 // private but used in tests.
196 getProjectSuggestions(input: string) {
Kamil Musinc39f4382022-11-29 17:43:40 +0100197 return this.restApiService
198 .getSuggestedRepos(input, /* n=*/ undefined, throwingErrorCallback)
199 .then(response => {
200 const repos: AutocompleteSuggestion[] = [];
201 for (const [name, repo] of Object.entries(response ?? {})) {
202 repos.push({name, value: repo.id});
203 }
204 return repos;
205 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100206 }
207
Chris Poucet38d53432022-04-07 13:02:19 +0200208 private handleRemoveProject(project: ProjectWatchInfo) {
209 if (!this.projects) return;
210 const index = this.projects.indexOf(project);
211 if (index < 0) return;
212 this.projects.splice(index, 1);
213 this.projectsToRemove.push(project);
214 this.requestUpdate();
215 this.setHasUnsavedChanges(true);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100216 }
217
Chris Poucet38d53432022-04-07 13:02:19 +0200218 // private but used in tests.
219 canAddProject(
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200220 project: string | null,
221 text: string | null,
222 filter: string | null
223 ) {
224 if (project === null && text === null) {
225 return false;
226 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100227
228 // This will only be used if not using the auto complete
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200229 if (!project && text) {
230 return true;
231 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100232
Chris Poucet38d53432022-04-07 13:02:19 +0200233 if (!this.projects) return true;
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200234 // Check if the project with filter is already in the list.
Chris Poucet38d53432022-04-07 13:02:19 +0200235 for (let i = 0; i < this.projects.length; i++) {
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200236 if (
Chris Poucet38d53432022-04-07 13:02:19 +0200237 this.projects[i].project === project &&
238 this.areFiltersEqual(this.projects[i].filter, filter)
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200239 ) {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100240 return false;
241 }
242 }
243
244 return true;
245 }
246
Chris Poucet38d53432022-04-07 13:02:19 +0200247 // private but used in tests.
248 getNewProjectIndex(name: string, filter: string | null) {
249 if (!this.projects) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100250 let i;
Chris Poucet38d53432022-04-07 13:02:19 +0200251 for (i = 0; i < this.projects.length; i++) {
252 const projectFilter = this.projects[i].filter;
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200253 if (
Chris Poucet38d53432022-04-07 13:02:19 +0200254 this.projects[i].project > name ||
255 (this.projects[i].project === name &&
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200256 this.isFilterDefined(projectFilter) &&
257 this.isFilterDefined(filter) &&
258 projectFilter! > filter!)
259 ) {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100260 break;
261 }
262 }
263 return i;
264 }
265
Chris Poucet38d53432022-04-07 13:02:19 +0200266 // Private but used in tests.
267 handleAddProject() {
268 assertIsDefined(this.newProject, 'newProject');
269 assertIsDefined(this.newFilter, 'newFilter');
270 const newProject = this.newProject.value;
271 const newProjectName = this.newProject.text as RepoName;
272 const filter = this.newFilter.value;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100273
Chris Poucet38d53432022-04-07 13:02:19 +0200274 if (!this.canAddProject(newProject, newProjectName, filter)) {
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200275 return;
276 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100277
Chris Poucet38d53432022-04-07 13:02:19 +0200278 const insertIndex = this.getNewProjectIndex(newProjectName, filter);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100279
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200280 if (insertIndex !== undefined) {
Chris Poucet38d53432022-04-07 13:02:19 +0200281 this.projects?.splice(insertIndex, 0, {
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200282 project: newProjectName,
283 filter,
284 _is_local: true,
285 });
Chris Poucet38d53432022-04-07 13:02:19 +0200286 this.requestUpdate();
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200287 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100288
Chris Poucet38d53432022-04-07 13:02:19 +0200289 this.newProject.clear();
290 this.newFilter.value = '';
291 this.setHasUnsavedChanges(true);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100292 }
293
Chris Poucet38d53432022-04-07 13:02:19 +0200294 private handleCheckboxChange(
295 project: ProjectWatchInfo,
296 key: NotificationKey,
297 e: Event
298 ) {
299 const el = e.target as HTMLInputElement;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100300 const checked = el.checked;
Chris Poucet38d53432022-04-07 13:02:19 +0200301 project[key] = !!checked;
302 this.requestUpdate();
303 this.setHasUnsavedChanges(true);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100304 }
305
Chris Poucet38d53432022-04-07 13:02:19 +0200306 private handleNotifCellClick(e: Event) {
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200307 if (e.target === null) return;
308 const checkbox = (e.target as HTMLElement).querySelector('input');
309 if (checkbox) {
310 checkbox.click();
311 }
312 }
313
Chris Poucet38d53432022-04-07 13:02:19 +0200314 private setHasUnsavedChanges(value: boolean) {
315 this.hasUnsavedChanges = value;
316 fire(this, 'has-unsaved-changes-changed', {value});
317 }
318
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200319 isFilterDefined(filter: string | null | undefined) {
320 return filter !== null && filter !== undefined;
321 }
322
323 areFiltersEqual(
324 filter1: string | null | undefined,
325 filter2: string | null | undefined
326 ) {
327 // null and undefined are equal
328 if (!this.isFilterDefined(filter1) && !this.isFilterDefined(filter2)) {
329 return true;
330 }
331 return filter1 === filter2;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100332 }
333}
334
Milutin Kristofic77b774e2020-08-25 14:30:26 +0200335declare global {
336 interface HTMLElementTagNameMap {
337 'gr-watched-projects-editor': GrWatchedProjectsEditor;
338 }
339}