Convert the reviewers plugin to TypeScript and Lit Change-Id: I41ec46590e4b44a9219e786e5f51fbde50f2c329
diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index e69de29..0000000 --- a/.eslintignore +++ /dev/null
diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..749f7c4 --- /dev/null +++ b/.eslintrc.js
@@ -0,0 +1,20 @@ +/** + * @license + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +__plugindir = 'reviewers'; +module.exports = { + extends: '../.eslintrc.js', +};
diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index da33ea2..0000000 --- a/.eslintrc.json +++ /dev/null
@@ -1,165 +0,0 @@ -{ - "extends": ["eslint:recommended", "google"], - "parserOptions": { - "ecmaVersion": 8, - "sourceType": "module" - }, - "env": { - "browser": true, - "es6": true - }, - "globals": { - "__dirname": false, - "app": false, - "page": false, - "Polymer": false, - "process": false, - "require": false, - "Gerrit": false, - "Promise": false, - "assert": false, - "test": false, - "flushAsynchronousOperations": false - }, - "rules": { - "arrow-parens": ["error", "as-needed"], - "block-spacing": ["error", "always"], - "brace-style": ["error", "1tbs", { "allowSingleLine": true }], - "camelcase": "off", - "comma-dangle": ["error", { - "arrays": "always-multiline", - "objects": "always-multiline", - "imports": "always-multiline", - "exports": "always-multiline", - "functions": "never" - }], - "eol-last": "off", - "indent": ["error", 2, { - "MemberExpression": 2, - "FunctionDeclaration": {"body": 1, "parameters": 2}, - "FunctionExpression": {"body": 1, "parameters": 2}, - "CallExpression": {"arguments": 2 }, - "ArrayExpression": 1, - "ObjectExpression": 1, - "SwitchCase": 1 - }], - "keyword-spacing": ["error", { "after": true, "before": true }], - "lines-between-class-members": ["error", "always"], - "max-len": [ - "error", - 80, - 2, - { - "ignoreComments": true, - "ignorePattern": "^import .*;$" - } - ], - "new-cap": ["error", { "capIsNewExceptions": ["Polymer", "LegacyElementMixin", "GestureEventListeners", "LegacyDataMixin"] }], - "no-console": "off", - "no-multiple-empty-lines": [ "error", { "max": 1 } ], - "no-prototype-builtins": "off", - "no-redeclare": "off", - "no-restricted-syntax": [ - "error", - { - "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']", - "message": "Remove test.only." - }, - { - "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']", - "message": "Remove suite.only." - } - ], - "no-undef": "off", - "no-useless-escape": "off", - "no-var": "error", - "object-shorthand": ["error", "always"], - "padding-line-between-statements": [ - "error", - { - "blankLine": "always", - "prev": "class", - "next": "*" - }, - { - "blankLine": "always", - "prev": "*", - "next": "class" - } - ], - "prefer-arrow-callback": "error", - "prefer-const": "error", - "prefer-spread": "error", - "quote-props": ["error", "consistent-as-needed"], - "semi": [2, "always"], - "template-curly-spacing": "error", - "valid-jsdoc": "off", - "require-jsdoc": 0, - "valid-jsdoc": 0, - "jsdoc/check-alignment": 2, - "jsdoc/check-examples": 0, - "jsdoc/check-indentation": 0, - "jsdoc/check-param-names": 0, - "jsdoc/check-syntax": 0, - "jsdoc/check-tag-names": 0, - "jsdoc/check-types": 0, - "jsdoc/implements-on-classes": 2, - "jsdoc/match-description": 0, - "jsdoc/newline-after-description": 2, - "jsdoc/no-types": 0, - "jsdoc/no-undefined-types": 0, - "jsdoc/require-description": 0, - "jsdoc/require-description-complete-sentence": 0, - "jsdoc/require-example": 0, - "jsdoc/require-hyphen-before-param-description": 0, - "jsdoc/require-jsdoc": 0, - "jsdoc/require-param": 0, - "jsdoc/require-param-description": 0, - "jsdoc/require-param-name": 2, - "jsdoc/require-param-type": 2, - "jsdoc/require-returns": 0, - "jsdoc/require-returns-check": 0, - "jsdoc/require-returns-description": 0, - "jsdoc/require-returns-type": 2, - "jsdoc/valid-types": 2, - "jsdoc/require-file-overview": ["error", { - "tags": { - "license": { - "mustExist": true, - "preventDuplicates": true - } - } - }], - "import/named": 2, - "import/no-unresolved": 2, - "import/no-self-import": 2, - // The no-cycle rule is slow, because it doesn't cache dependencies. - // Disable it. - "import/no-cycle": 0, - "import/no-useless-path-segments": 2, - "import/no-unused-modules": 2, - "import/no-default-export": 2 - }, - "plugins": [ - "html", - "jsdoc", - "import" - ], - "settings": { - "html/report-bad-indent": "error" - }, - "overrides": [ - { - "files": ["*_html.js", "*-styles.js", "externs.js"], - "rules": { - "max-len": "off" - } - }, - { - "files": ["*.html"], - "rules": { - "jsdoc/require-file-overview": "off" - } - } - ] -}
diff --git a/.gitignore b/.gitignore index 1c805b7..cd156c1 100644 --- a/.gitignore +++ b/.gitignore
@@ -4,3 +4,4 @@ /local.properties *.pyc /package-lock.json +/node_modules
diff --git a/BUILD b/BUILD index 52f43b1..6ad9310 100644 --- a/BUILD +++ b/BUILD
@@ -1,6 +1,6 @@ load("@rules_java//java:defs.bzl", "java_library") load("//tools/bzl:junit.bzl", "junit_tests") -load("//tools/js:eslint.bzl", "eslint") +load("//tools/js:eslint.bzl", "plugin_eslint") load( "//tools/bzl:plugin.bzl", "PLUGIN_DEPS", @@ -8,6 +8,7 @@ "gerrit_plugin", ) load("//tools/bzl:js.bzl", "gerrit_js_bundle") +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") gerrit_plugin( name = "reviewers", @@ -20,9 +21,32 @@ resources = glob(["src/main/resources/**/*"]), ) +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//plugins:tsconfig-plugins-base.json", + ], +) + +ts_project( + name = "rv-reviewers-ts", + srcs = glob([ + "rv-reviewers/**/*.ts", + ]), + incremental = True, + supports_workers = True, + tsc = "//tools/node_tools:tsc-bin", + tsconfig = ":tsconfig", + deps = [ + "@plugins_npm//@gerritcodereview/typescript-api", + "@plugins_npm//lit", + ], +) + gerrit_js_bundle( name = "rv-reviewers", - srcs = glob(["rv-reviewers/*.js"]), + srcs = [":rv-reviewers-ts"], entry_point = "rv-reviewers/plugin.js", ) @@ -36,23 +60,4 @@ ], ) -# Define the eslinter for the plugin -# The eslint macro creates 2 rules: lint_test and lint_bin -eslint( - name = "lint", - srcs = glob([ - "rv-reviewers/*.js", - ]), - config = ".eslintrc.json", - data = [], - extensions = [ - ".js", - ], - ignore = ".eslintignore", - plugins = [ - "@npm//eslint-config-google", - "@npm//eslint-plugin-html", - "@npm//eslint-plugin-import", - "@npm//eslint-plugin-jsdoc", - ], -) +plugin_eslint()
diff --git a/rv-reviewers/plugin.ts b/rv-reviewers/plugin.ts index ba7e1d2..3b73980 100644 --- a/rv-reviewers/plugin.ts +++ b/rv-reviewers/plugin.ts
@@ -14,9 +14,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import './rv-reviewers.js'; +import '@gerritcodereview/typescript-api/gerrit'; +import './rv-reviewers'; -Gerrit.install(plugin => { - plugin.registerCustomComponent( - 'repo-command', 'rv-reviewers'); +window.Gerrit.install(plugin => { + plugin.registerCustomComponent('repo-command', 'rv-reviewers'); });
diff --git a/rv-reviewers/rv-edit-screen.ts b/rv-reviewers/rv-edit-screen.ts index b0419bf..1d6dcc7 100644 --- a/rv-reviewers/rv-edit-screen.ts +++ b/rv-reviewers/rv-edit-screen.ts
@@ -14,73 +14,148 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {htmlTemplate} from './rv-edit-screen_html.js'; +import {RepoName} from '@gerritcodereview/typescript-api/rest-api'; +import {RestPluginApi} from '@gerritcodereview/typescript-api/rest'; +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators'; +import './rv-filter-section'; +import {Section} from './rv-filter-section'; +import {fire} from './util'; -class RvEditScreen extends Polymer.Element { - /** @returns {string} name of the component */ - static get is() { return 'rv-edit-screen'; } +function getReviewersUrl(repoName: RepoName) { + return `/projects/${encodeURIComponent(repoName)}/reviewers`; +} - /** @returns {?} template for this component */ - static get template() { return htmlTemplate; } - - /** - * Defines properties of the component - * - * @returns {?} - */ - static get properties() { - return { - pluginRestApi: { - type: Object, - observer: '_loadFilterSections', - }, - repoName: String, - loading: Boolean, - canModifyConfig: Boolean, - _editingFilter: { - type: Boolean, - value: false, - }, - _filterSections: Array, - }; - } - - _loadFilterSections() { - this.pluginRestApi.get(this._getReviewersUrl(this.repoName)) - .then(filterSections => { - this._filterSections = filterSections; - }); - } - - _computeAddFilterBtnHidden(canModifyConfig, editingFilter) { - return !canModifyConfig || editingFilter; - } - - _computeLoadingClass(loading) { - return loading ? 'loading' : ''; - } - - _getReviewersUrl(repoName) { - return `/projects/${encodeURIComponent(repoName)}/reviewers`; - } - - _handleCreateSection() { - const section = {filter: '', reviewers: [], ccs: [], editing: true}; - this._editingFilter = true; - this.push('_filterSections', section); - } - - _handleCloseTap(e) { - e.preventDefault(); - this.dispatchEvent(new CustomEvent('close', { - composed: true, bubbles: false, - })); - } - - _handleReviewerChanged(e) { - this._filterSections = e.detail.result; - this._editingFilter = false; +declare global { + interface HTMLElementTagNameMap { + 'rv-edit-screen': RvEditScreen; } } -customElements.define(RvEditScreen.is, RvEditScreen); +@customElement('rv-edit-screen') +export class RvEditScreen extends LitElement { + @property() + pluginRestApi!: RestPluginApi; + + @property() + repoName!: RepoName; + + @property() + loading = false; + + @property() + canModifyConfig = false; + + @state() + editingFilter = false; + + @state() + filterSections: Section[] = []; + + static override get styles() { + return [ + css` + :host { + padding: var(--spacing-xl); + display: block; + } + .bottomButtons { + display: flex; + justify-content: flex-end; + } + gr-button { + margin-left: var(--spacing-m); + } + #filterSections { + width: 100%; + } + `, + ]; + } + + render() { + return html` + <div> + <h3 class="heading-3">Reviewers Config</h3> + <table id="filterSections"> + <tbody> + ${this.renderEmpty()} + ${this.loading + ? html`<tr> + <td>Loading...</td> + </tr>` + : this.filterSections.map(s => this.renderSection(s))} + </tbody> + </table> + <div class="bottomButtons"> + <gr-button + id="addFilterBtn" + @click="${this.handleCreateSection}" + ?hidden="${!this.canModifyConfig || this.editingFilter}" + > + Add New Filter + </gr-button> + <gr-button id="closeButton" @click="${this.handleCloseTap}"> + Close + </gr-button> + </div> + </div> + `; + } + + private renderEmpty() { + if (this.loading || this.filterSections.length > 0) return; + return html`<tr> + <td>No filter defined yet.</td> + </tr>`; + } + + private renderSection(section: Section) { + return html` + <tr> + <td> + <rv-filter-section + .filter="${section.filter}" + .reviewers="${section.reviewers}" + .ccs="${section.ccs}" + .reviewersUrl="${getReviewersUrl(this.repoName)}" + .repoName="${this.repoName}" + .pluginRestApi="${this.pluginRestApi}" + .canModifyConfig="${this.canModifyConfig}" + @reviewer-changed="${this.handleReviewerChanged}" + > + </rv-filter-section> + </td> + </tr> + `; + } + + connectedCallback() { + super.connectedCallback(); + this.pluginRestApi + .get<Section[]>(getReviewersUrl(this.repoName)) + .then(sections => { + this.filterSections = sections; + }); + } + + private handleCreateSection() { + const section = {filter: '', reviewers: [], ccs: [], editing: true}; + this.filterSections = [...this.filterSections, section]; + this.editingFilter = true; + fire(this, 'fit'); + } + + private handleCloseTap(e: Event) { + e.preventDefault(); + fire(this, 'close'); + } + + private handleReviewerChanged(e: CustomEvent<Section[]>) { + // Even if just one reviewer is changed or deleted, then we still completely + // re-render everything from scratch. + this.filterSections = e.detail; + this.editingFilter = false; + fire(this, 'fit'); + } +}
diff --git a/rv-reviewers/rv-edit-screen_html.ts b/rv-reviewers/rv-edit-screen_html.ts deleted file mode 100644 index 954d46c..0000000 --- a/rv-reviewers/rv-edit-screen_html.ts +++ /dev/null
@@ -1,78 +0,0 @@ -/** - * @license - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import './rv-filter-section.js'; - -export const htmlTemplate = Polymer.html` - <style include="shared-styles"></style> - <style include="gr-menu-page-styles"></style> - <style include="gr-subpage-styles"> - :host { - padding: var(--spacing-xl); - } - .bottomButtons { - display: flex; - } - #closeButton { - float: right; - } - #filterSections { - width: 100%; - } - header { - border-bottom: 1px solid var(--border-colo); - flex-shrink: 0; - font-weight: var(--font-weight-bold) - } - </style> - <div> - <header>Reviewers Config</header> - <table id="filterSections"> - <tr> - <th>Filter Sections</th> - </tr> - <tr id="loading" class$="loadingMsg [[_computeLoadingClass(loading)]]"> - <td>Loading...</td> - </tr> - <tbody class$="[[_computeLoadingClass(loading)]]"> - <tr> - <template - is="dom-repeat" - items="[[_filterSections]]" - as="section"> - <rv-filter-section - filter="[[section.filter]]" - reviewers="[[section.reviewers]]" - ccs="[[section.ccs]]" - editing="[[section.editing]]" - reviewers-url="[[_getReviewersUrl(repoName)]]" - repo-name="[[repoName]]" - plugin-rest-api="[[pluginRestApi]]" - can-modify-config="[[canModifyConfig]]" - on-reviewer-changed="_handleReviewerChanged"></rv-filter-section> - </template> - </tr> - </tbody> - </table> - <div class="bottomButtons"> - <gr-button id="closeButton" on-tap="_handleCloseTap">Close</gr-button> - <gr-button - id="addFilterBtn" - on-tap="_handleCreateSection" - hidden="[[_computeAddFilterBtnHidden(canModifyConfig, _editingFilter)]]">Add New Filter</gr-button> - </div> - </div> -`;
diff --git a/rv-reviewers/rv-filter-section.ts b/rv-reviewers/rv-filter-section.ts index ae43a62..cfde6b8 100644 --- a/rv-reviewers/rv-filter-section.ts +++ b/rv-reviewers/rv-filter-section.ts
@@ -14,120 +14,239 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {htmlTemplate} from './rv-filter-section_html.js'; +import {css, html, LitElement} from 'lit'; +import {customElement, property, query, state} from 'lit/decorators'; +import {RestPluginApi} from '@gerritcodereview/typescript-api/rest'; +import {RepoName} from '@gerritcodereview/typescript-api/rest-api'; +import './rv-reviewer'; +import { + ReviewerAddedEventDetail, + ReviewerDeletedEventDetail, + Type, +} from './rv-reviewer'; +import {fire} from './util'; -class RvFilterSection extends Polymer.Element { - /** @returns {string} name of the component */ - static get is() { return 'rv-filter-section'; } +enum Action { + ADD = 'ADD', + REMOVE = 'REMOVE', +} - /** @returns {?} template for this component */ - static get template() { return htmlTemplate; } +export interface Section { + filter: string; + reviewers: string[]; + ccs: string[]; + editing: boolean; +} - /** - * Defines properties of the component - * - * @returns {?} - */ - static get properties() { - return { - pluginRestApi: Object, - repoName: String, - reviewers: Array, - ccs: Array, - filter: String, - canModifyConfig: Boolean, - _originalFilter: String, - _editingReviewer: { - type: Boolean, - value: false, - }, - reviewersUrl: String, - }; - } - - connectedCallback() { - super.connectedCallback(); - this._updateSection(); - } - - _updateSection() { - this._originalFilter = this.filter; - } - - _computeEditing(filter, _originalFilter) { - if (_originalFilter === '') { - return true; - } - return filter === ''; - } - - _computeCancelHidden(filter, _originalFilter) { - return !this._computeEditing(filter, _originalFilter); - } - - _computeAddBtnHidden(canModifyConfig, editingReviewer) { - return !(canModifyConfig && !editingReviewer); - } - - _computeFilterInputDisabled(canModifyConfig, originalFilter) { - return !canModifyConfig || originalFilter !== ''; - } - - _handleCancel() { - this.remove(); - } - - _handleReviewerDeleted(e) { - const type = e.detail.type; - if (e.detail.editing) { - if (type === 'CC') { - this.ccs.pop(); - } else { - this.reviewers.pop(); - } - this._editingReviewer = false; - } else { - const index = e.model.index; - const deleted = type === 'CC' ? this.ccs[index] : this.reviewers[index]; - this._putReviewer(deleted, 'REMOVE', type); - } - } - - _handleReviewerAdded(e) { - this._editingReviewer = false; - this._putReviewer(e.detail.reviewer, 'ADD', e.detail.type).catch(err => { - this.dispatchEvent(new CustomEvent('show-alert', { - detail: { - message: err, - }, - composed: true, bubbles: true, - })); - throw err; - }); - } - - _putReviewer(reviewer, action, type) { - return this.pluginRestApi.put(this.reviewersUrl, { - action, - reviewer, - type, - filter: this.filter, - }).then(result => { - const detail = {result}; - this.dispatchEvent( - new CustomEvent('reviewer-changed', {detail, bubbles: true})); - }); - } - - _handleAddReviewer() { - this.push('reviewers', ''); - this._editingReviewer = true; - } - - _handleAddCc() { - this.push('ccs', ''); - this._editingReviewer = true; +declare global { + interface HTMLElementTagNameMap { + 'rv-filter-section': RvFilterSection; } } -customElements.define(RvFilterSection.is, RvFilterSection); +@customElement('rv-filter-section') +export class RvFilterSection extends LitElement { + @query('#filterInput') + filterInput?: HTMLInputElement; + + @property() + pluginRestApi!: RestPluginApi; + + @property() + repoName!: RepoName; + + @property() + reviewers: string[] = []; + + @property() + ccs: string[] = []; + + @property() + filter = ''; + + @property() + canModifyConfig = false; + + @property({type: String}) + reviewersUrl = ''; + + /** + * If a filter was already set initially, then you cannot "cancel" creating + * this filter. + */ + @state() + originalFilter = ''; + + /** While a reviewer is being edited you cannot add another. */ + @state() + editingReviewer = false; + + connectedCallback() { + super.connectedCallback(); + this.originalFilter = this.filter; + } + + static override get styles() { + return [ + css` + :host { + display: block; + margin-bottom: 1em; + } + #container { + display: block; + border: 1px solid var(--border-color); + } + #filter { + align-items: center; + background: var(--table-header-background-color); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + min-height: 3em; + padding: 0 var(--spacing-m); + } + #filterInput { + width: 30vw; + max-width: 500px; + margin-left: var(--spacing-s); + } + gr-button { + margin-left: var(--spacing-m); + } + #addReviewer { + display: flex; + padding: var(--spacing-s) 0; + } + `, + ]; + } + + render() { + return html` + <div id="container"> + <div id="filter"> + <span class="heading-3">Filter</span> + <input + id="filterInput" + value="${this.filter}" + @input="${this.onFilterInput}" + ?disabled="${!this.canModifyConfig || this.originalFilter !== ''}" + /> + <gr-button + @click="${() => this.remove()}" + ?hidden="${this.originalFilter !== '' && this.filter !== ''}" + > + Cancel + </gr-button> + </div> + <div> + ${this.reviewers.map(item => + this.renderReviewer(item, Type.REVIEWER) + )} + ${this.ccs.map(item => this.renderReviewer(item, Type.CC))} + <div id="addReviewer"> + <gr-button + link + @click="${this.handleAddReviewer}" + ?disabled="${this.filter === ''}" + ?hidden="${!this.canModifyConfig || this.editingReviewer}" + > + Add Reviewer + </gr-button> + <gr-button + link + @click="${this.handleAddCc}" + ?disabled="${this.filter === ''}" + ?hidden="${!this.canModifyConfig || this.editingReviewer}" + > + Add CC + </gr-button> + </div> + </div> + </div> + `; + } + + private renderReviewer(reviewer: string, type: Type) { + return html` + <rv-reviewer + .reviewer="${reviewer}" + .type="${type}" + .canModifyConfig="${this.canModifyConfig}" + .pluginRestApi="${this.pluginRestApi}" + .repoName="${this.repoName}" + @reviewer-deleted="${(e: CustomEvent<ReviewerDeletedEventDetail>) => + this.handleReviewerDeleted(e, reviewer)}" + @reviewer-added="${(e: CustomEvent<ReviewerAddedEventDetail>) => + this.handleReviewerAdded(e)}" + > + </rv-reviewer> + `; + } + + private onFilterInput() { + this.filter = this.filterInput?.value ?? ''; + } + + private handleReviewerDeleted( + e: CustomEvent<ReviewerDeletedEventDetail>, + reviewer: string + ) { + const {type, editing} = e.detail; + if (editing) { + // Just cancelling edit. Nothing was persisted yet, so nothing to delete. + if (type === Type.CC) { + this.ccs = [...this.ccs.slice(0, -1)]; + } else { + this.reviewers = [...this.reviewers.slice(0, -1)]; + } + this.editingReviewer = false; + } else { + // The reviewer was not in edit mode, but DELETE was clicked. + this.putReviewer(reviewer, Action.REMOVE, type); + } + } + + private handleReviewerAdded(e: CustomEvent<ReviewerAddedEventDetail>) { + this.editingReviewer = false; + this.putReviewer(e.detail.reviewer, Action.ADD, e.detail.type).catch( + err => { + fire(this, 'show-alert', {message: err}); + throw err; + } + ); + } + + private putReviewer(reviewer: string, action: Action, type: Type) { + if (this.filter === '') throw new Error('empty filter'); + if (reviewer === '') throw new Error('empty reviewer'); + return this.pluginRestApi + .put<Section[]>(this.reviewersUrl, { + action, + reviewer, + type, + filter: this.filter, + }) + .then((sections: Section[]) => { + // Even if just one reviewer is changed or deleted, we will get the + // the complete list of sections back from the server, and we dispatch + // this event such that the entire dialog re-renders from scratch. + // Lit is smart enough to re-use the component though, so we also want + // to re-initialize the state here: + this.editingReviewer = false; + this.originalFilter = this.filter; + fire(this, 'reviewer-changed', sections); + }); + } + + private handleAddReviewer() { + this.reviewers = [...this.reviewers, '']; + this.editingReviewer = true; + } + + private handleAddCc() { + this.ccs = [...this.ccs, '']; + this.editingReviewer = true; + } +}
diff --git a/rv-reviewers/rv-filter-section_html.ts b/rv-reviewers/rv-filter-section_html.ts deleted file mode 100644 index 87cc7a3..0000000 --- a/rv-reviewers/rv-filter-section_html.ts +++ /dev/null
@@ -1,123 +0,0 @@ -/** - * @license - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import './rv-reviewer.js'; - -export const htmlTemplate = Polymer.html` - <style include="shared-styles"> - :host { - display: block; - margin-bottom: 1em; - } - fieldset { - border: 1px solid var(--border-color); - } - .name { - align-items: center; - display: flex; - } - .name gr-button { - margin-left: var(--spacing-m); - } - .header { - align-items: center; - background: var(--table-header-background-color); - border-bottom: 1px dotted var(--border-color); - display: flex; - justify-content: space-between; - min-height: 3em; - padding: 0 .7em; - } - #addReviewer { - display: flex; - } - #editFilterInput { - width: 30vw; - max-width: 500px; - margin-left: 3px; - } - #mainContainer { - display: block; - } - </style> - <style include="gr-form-styles"></style> - <fieldset id="section" - class$="gr-form-styles"> - <div id="mainContainer"> - <div class="header"> - <div class="name"> - <h3>Filter:</h3> - <iron-input - id="editFilterInput" - bind-value="{{filter}}" - type="text" - disabled="[[_computeFilterInputDisabled(canModifyConfig, _originalFilter)]]"> - <input - id="editFilterInput" - bind-value="{{filter}}" - is="iron-input" - type="text" - disabled="[[_computeFilterInputDisabled(canModifyConfig, _originalFilter)]]"> - </iron-input> - <gr-button - id="cancelBtn" - on-tap="_handleCancel" - hidden$="[[_computeCancelHidden(filter, _originalFilter)]]">Cancel</gr-button> - </div><!-- name --> - </div><!-- header --> - <div class="reviewers"> - <template - is="dom-repeat" - items="{{reviewers}}"> - <rv-reviewer - reviewer="{{item}}" - type="REVIEWER" - can-modify-config="[[canModifyConfig]]" - plugin-rest-api="[[pluginRestApi]]" - repo-name="[[repoName]]" - on-reviewer-deleted="_handleReviewerDeleted" - on-reviewer-added="_handleReviewerAdded"> - </rv-reviewer> - </template> - <template - is="dom-repeat" - items="{{ccs}}"> - <rv-reviewer - reviewer="{{item}}" - type="CC" - can-modify-config="[[canModifyConfig]]" - plugin-rest-api="[[pluginRestApi]]" - repo-name="[[repoName]]" - on-reviewer-deleted="_handleReviewerDeleted" - on-reviewer-added="_handleReviewerAdded"> - </rv-reviewer> - </template> - <div id="addReviewer"> - <gr-button - link - id="addRevBtn" - on-tap="_handleAddReviewer" - hidden="[[_computeAddBtnHidden(canModifyConfig, _editingReviewer)]]">Add Reviewer</gr-button> - <gr-button - link - id="addCcBtn" - on-tap="_handleAddCc" - hidden="[[_computeAddBtnHidden(canModifyConfig, _editingReviewer)]]">Add Cc</gr-button> - </div><!-- addReviewer --> - </div><!-- reviewers --> - </div> - </fieldset> -`;
diff --git a/rv-reviewers/rv-reviewer.ts b/rv-reviewers/rv-reviewer.ts index 6145013..1158be7 100644 --- a/rv-reviewers/rv-reviewer.ts +++ b/rv-reviewers/rv-reviewer.ts
@@ -14,154 +14,254 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {htmlTemplate} from './rv-reviewer_html.js'; +import {customElement, property, state} from 'lit/decorators'; +import {css, html, LitElement} from 'lit'; +import {RestPluginApi} from '@gerritcodereview/typescript-api/rest'; +import { + AccountInfo, + GroupInfo, + RepoName, +} from '@gerritcodereview/typescript-api/rest-api'; +import {fire} from './util'; -class RvReviewer extends Polymer.Element { - /** @returns {string} name of the component */ - static get is() { return 'rv-reviewer'; } +declare global { + interface HTMLElementTagNameMap { + 'rv-reviewer': RvReviewer; + } +} - /** @returns {?} template for this component */ - static get template() { return htmlTemplate; } +export enum Type { + REVIEWER = 'REVIEWER', + CC = 'CC', +} + +export interface ReviewerDeletedEventDetail { + /** + * If true, then this means a reviewer addition was just canceled. Not server + * update required. + * If false, then the entry has to be deleted server side by the event + * handler. + */ + editing: boolean; + type: Type; +} + +export interface ReviewerAddedEventDetail { + reviewer: string; + type: Type; +} + +type GroupNameToInfo = {[name: string]: GroupInfo}; + +interface NameValue { + name: string; + value: string; +} + +function computeValue(account: AccountInfo): string | undefined { + if (account.username) { + return account.username; + } + if (account.email) { + return account.email; + } + return String(account._account_id); +} + +function computeName(account: AccountInfo): string | undefined { + if (account.email) { + return `${account.name} <${account.email}>`; + } + return account.name; +} + +@customElement('rv-reviewer') +export class RvReviewer extends LitElement { + /** + * Fired when the 'CANCEL' or 'DELETE' button for a reviewer was clicked. + * + * @event reviewer-deleted + */ /** - * Defines properties of the component + * Fired when the 'ADD' button for a reviewer was clicked. * - * @returns {?} + * @event reviewer-added */ - static get properties() { - return { - canModifyConfig: Boolean, - pluginRestAPi: Object, - repoName: String, - reviewer: String, - type: String, - _header: { - type: String, - computed: '_computeHeader(type)', - }, - _reviewerSearchId: String, - _queryReviewers: { - type: Function, - value() { - return this._getReviewerSuggestions.bind(this); - }, - }, - _originalReviewer: String, - _deleted: Boolean, - _editing: { - type: Boolean, - computed: '_computeEditing(reviewer, _originalReviewer)', - }, - }; + + @property() + canModifyConfig = false; + + @property() + pluginRestApi!: RestPluginApi; + + @property() + repoName!: RepoName; + + @property() + type = Type.REVIEWER; + + /** + * This is the value that is persisted on the server side. For new reviewers + * this is empty until the user clicks "ADD" and the data was saved. + */ + @property() + reviewer = ''; + + /** + * This is value that the user has picked from the auto-completion. It will + * be used for saving (when the user clicks "ADD") and then assigned to the + * `reviewer` property. + */ + @state() + selectedReviewer = ''; + + static override get styles() { + return [ + css` + :host { + display: block; + padding: var(--spacing-s) 0; + } + #editReviewerInput { + display: block; + width: 250px; + } + .reviewerRow { + align-items: center; + display: flex; + } + #reviewerHeader, + #editReviewerInput, + #deleteCancelBtn, + #addBtn, + #reviewerField { + margin-left: var(--spacing-m); + } + #reviewerField { + width: 250px; + text-indent: 1px; + border: 1px solid var(--border-color); + } + `, + ]; } - connectedCallback() { - super.connectedCallback(); - this._originalReviewer = this.reviewer; + render() { + return html` + <div class="reviewerRow"> + <span class="heading-3" id="reviewerHeader"> + ${this.type === Type.CC ? 'CC' : 'Reviewer'} + </span> + ${this.isEditing() + ? this.renderAutocomplete() + : html`<td id="reviewerField">${this.reviewer}</td>`} + <gr-button + id="deleteCancelBtn" + @click="${this.handleDeleteCancel}" + ?hidden="${!this.canModifyConfig}" + > + ${this.isEditing() ? 'Cancel' : 'Delete'} + </gr-button> + <gr-button + id="addBtn" + @click="${this.handleAddReviewer}" + ?hidden="${!this.isEditing() || !this.selectedReviewer}" + > + Add + </gr-button> + </div> + `; } - _computeHeader(type) { - if (type === 'CC') { - return 'Cc'; - } - return 'Reviewer'; + renderAutocomplete() { + return html` + <span class="value"> + <!-- + TODO: + Investigate whether we could reuse gr-account-list. + If the REST API returns AccountInfo instead of an account + identifier String we should be able to use gr-account-list(size=1) + for all reviewers, including those who are non-editable + (#reviewerField below) and align the plugin with how accounts + are displayed in core Gerrit's UI. + --> + <gr-autocomplete + id="editReviewerInput" + .query="${(input: string) => this.getReviewerSuggestions(input)}" + .placeholder="Name Or Email" + @value-changed="${this.onReviewerSelected}" + > + </gr-autocomplete> + </span> + `; } - _computeEditing(reviewer, _originalReviewer) { - if (_originalReviewer === '') { - return true; - } - return reviewer === ''; + onReviewerSelected(e: CustomEvent<{value: string}>) { + if (!e.detail.value) return; + this.selectedReviewer = e.detail.value; } - _computeDeleteCancel(reviewer, _originalReviewer) { - return this._computeEditing(reviewer, _originalReviewer) ? - 'Cancel' : 'Delete'; + /** + * "Editing" actually just means "adding". This component does not allow + * editing. You can only add new entries or delete existing ones. + */ + isEditing() { + return this.reviewer === ''; } - _computeHideAddButton(reviewer, _originalReviewer) { - return !(this._computeEditing(reviewer, _originalReviewer) && - this._reviewerSearchId); + getReviewerSuggestions(input: string): Promise<NameValue[]> { + if (input.length === 0) return Promise.resolve([]); + const p1 = this.getSuggestedGroups(input); + const p2 = this.getSuggestedAccounts(input); + return Promise.all([p1, p2]).then(result => result.flat()); } - _computeHideDeleteButton(canModifyConfig) { - return !canModifyConfig; - } - - _getReviewerSuggestions(input) { - if (input.length === 0) { return Promise.resolve([]); } - const promises = []; - promises.push(this._getSuggestedGroups(input)); - promises.push(this._getSuggestedAccounts(input)); - return Promise.all(promises).then(result => { - return result.flat(); - }); - } - - _getSuggestedGroups(input) { + getSuggestedGroups(input: string): Promise<NameValue[]> { const suggestUrl = `/groups/?suggest=${input}&p=${this.repoName}`; - return this.pluginRestApi.get(suggestUrl).then(groups => { - if (!groups) { return []; } - const groupSuggestions = []; - for (const key in groups) { - if (!groups.hasOwnProperty(key) || key.startsWith('user/')) { - continue; - } - groupSuggestions.push({ - name: key, - value: key, + return this.pluginRestApi.get<GroupNameToInfo>(suggestUrl).then(groups => { + if (!groups) return []; + return Object.keys(groups) + .filter(name => !name.startsWith('user/')) + .map(name => { + return {name, value: name}; }); - } - return groupSuggestions; }); } - _getSuggestedAccounts(input) { + getSuggestedAccounts(input: string): Promise<NameValue[]> { const suggestUrl = `/accounts/?suggest&q=${input}`; - return this.pluginRestApi.get(suggestUrl).then(accounts => { - const accountSuggestions = []; - let nameAndEmail; - let value; - if (!accounts) { return []; } - for (const key in accounts) { - if (!accounts.hasOwnProperty(key)) { continue; } - if (accounts[key].email) { - nameAndEmail = accounts[key].name + - ' <' + accounts[key].email + '>'; - } else { - nameAndEmail = accounts[key].name; - } - if (accounts[key].username) { - value = accounts[key].username; - } else if (accounts[key].email) { - value = accounts[key].email; - } else { - value = accounts[key]._account_id; - } - accountSuggestions.push({ - name: nameAndEmail, - value, - }); + return this.pluginRestApi.get<AccountInfo[]>(suggestUrl).then(accounts => { + const accountSuggestions: NameValue[] = []; + if (!accounts) return []; + for (const account of accounts) { + const name = computeName(account); + const value = computeValue(account); + if (!name || !value) continue; + accountSuggestions.push({name, value}); } return accountSuggestions; }); } - _handleDeleteCancel() { - const detail = {editing: this._editing, type: this.type}; - if (this._editing) { + handleDeleteCancel() { + const detail: ReviewerDeletedEventDetail = { + editing: this.isEditing(), + type: this.type, + }; + if (this.isEditing()) { this.remove(); } - this.dispatchEvent( - new CustomEvent('reviewer-deleted', {detail, bubbles: true})); + fire(this, 'reviewer-deleted', detail); } - _handleAddReviewer() { - const detail = {reviewer: this._reviewerSearchId, type: this.type}; - this._originalReviewer = this.reviewer; - this.dispatchEvent( - new CustomEvent('reviewer-added', {detail, bubbles: true})); + handleAddReviewer() { + const detail: ReviewerAddedEventDetail = { + reviewer: this.selectedReviewer, + type: this.type, + }; + this.reviewer = this.selectedReviewer; + this.selectedReviewer = ''; + fire(this, 'reviewer-added', detail); } } - -customElements.define(RvReviewer.is, RvReviewer); \ No newline at end of file
diff --git a/rv-reviewers/rv-reviewer_html.ts b/rv-reviewers/rv-reviewer_html.ts deleted file mode 100644 index 29b22de..0000000 --- a/rv-reviewers/rv-reviewer_html.ts +++ /dev/null
@@ -1,80 +0,0 @@ -/** - * @license - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export const htmlTemplate = Polymer.html` - <style include="shared-styles"> - :host { - display: block; - padding: var(--spacing-s) 0; - } - #editReviewerInput { - display: block; - width: 250px; - } - .reviewerRow { - align-items: center; - display: flex; - } - #reviewerHeader, - #editReviewerInput, - #deleteCancelBtn, - #addBtn, - #reviewerField { - margin-left: var(--spacing-m); - } - #reviewerField { - width: 250px; - text-indent: 1px; - border: 1px solid var(--border-color); - } - </style> - <style include="gr-form-styles"></style> - <div class="reviewerRow"> - <h4 id="reviewerHeader">[[_header]]:</h4> - <template is="dom-if" if="[[_computeEditing(reviewer, _originalReviewer)]]"> - <span class="value"> - <!-- - TODO: - Investigate whether we could reuse gr-account-list. - If the REST API returns AccountInfo instead of an account - identifier String we should be able to use gr-account-list(size=1) - for all reviewers, including those who are non-editable - (#reviewerField below) and align the plugin with how accounts - are displayed in core Gerrit's UI. - --> - <gr-autocomplete - id="editReviewerInput" - text="{{reviewer}}" - value="{{_reviewerSearchId}}" - query="[[_queryReviewers]]" - placeholder="Name Or Email"> - </gr-autocomplete> - </span> - </template> - <template is="dom-if" if="[[!_computeEditing(reviewer, _originalReviewer)]]"> - <td id="reviewerField">[[reviewer]]</td> - </template> - <gr-button - id="deleteCancelBtn" - on-tap="_handleDeleteCancel" - hidden$="[[_computeHideDeleteButton(canModifyConfig)]]" - >[[_computeDeleteCancel(reviewer, _originalReviewer)]]</gr-button> - <gr-button - id="addBtn" - on-tap="_handleAddReviewer" - hidden$="[[_computeHideAddButton(reviewer, _originalReviewer)]]">Add</gr-button> - </div> <!-- reviewerRow --> -`; \ No newline at end of file
diff --git a/rv-reviewers/rv-reviewers.ts b/rv-reviewers/rv-reviewers.ts index 9782b8c..28f4096 100644 --- a/rv-reviewers/rv-reviewers.ts +++ b/rv-reviewers/rv-reviewers.ts
@@ -14,87 +14,118 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {htmlTemplate} from './rv-reviewers_html.js'; +import {RepoName} from '@gerritcodereview/typescript-api/rest-api'; +import {RestPluginApi} from '@gerritcodereview/typescript-api/rest'; +import {PluginApi} from '@gerritcodereview/typescript-api/plugin'; +import {customElement, property, query, state} from 'lit/decorators'; +import {css, html, LitElement} from 'lit'; +import './rv-edit-screen'; -class RvReviewers extends Polymer.Element { - /** @returns {string} name of the component */ - static get is() { return 'rv-reviewers'; } +// TODO: This should be defined and exposed by @gerritcodereview/typescript-api +type GrOverlay = Element & { + open(): void; + close(): void; + fit(): void; +}; - /** @returns {?} template for this component */ - static get template() { return htmlTemplate; } +declare global { + interface HTMLElementTagNameMap { + 'rv-reviewers': RvReviewers; + } +} - /** - * Defines properties of the component - * - * @returns {?} - */ - static get properties() { - return { - pluginRestApi: Object, - repoName: String, - _canModifyConfig: { - type: Boolean, - computed: '_computeCanModifyConfig(_isOwner, _hasModifyCapability)', - }, - _loading: { - type: Boolean, - value: true, - }, - _isOwner: { - type: Boolean, - value: false, - }, - _hasModifyCapability: { - type: Boolean, - value: false, - }, - }; +declare interface AccountCapabilityInfo { + 'reviewers-modifyReviewersConfig'?: boolean; +} + +type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo}; + +declare interface ProjectAccessInfo { + is_owner?: boolean; +} + +@customElement('rv-reviewers') +export class RvReviewers extends LitElement { + @query('#rvScreenOverlay') + rvScreenOverlay?: GrOverlay; + + /** Guaranteed to be set by the `repo-command` endpoint. */ + @property({type: Object}) + plugin!: PluginApi; + + /** Guaranteed to be set by the `repo-command` endpoint. */ + @property({type: String}) + repoName!: RepoName; + + @state() + pluginRestApi!: RestPluginApi; + + @state() + canModifyConfig = false; + + @state() + loading = true; + + static override get styles() { + return [ + css` + :host { + display: block; + margin-bottom: var(--spacing-xxl); + } + #rvScreenOverlay { + width: 50em; + overflow: auto; + } + `, + ]; + } + + render() { + return html` + <h3 class="heading-3">Reviewers Config</h3> + <gr-button @click="${() => this.rvScreenOverlay?.open()}"> + Reviewers Config + </gr-button> + <gr-overlay id="rvScreenOverlay" with-backdrop> + <rv-edit-screen + .pluginRestApi="${this.pluginRestApi}" + .repoName="${this.repoName}" + .loading="${this.loading}" + .canModifyConfig="${this.canModifyConfig}" + @close="${() => this.rvScreenOverlay?.close()}" + @fit="${() => this.rvScreenOverlay?.fit()}" + > + </rv-edit-screen> + </gr-overlay> + `; } connectedCallback() { super.connectedCallback(); this.pluginRestApi = this.plugin.restApi(); - this._setCanModifyConfig(); - } - - _handleCommandTap() { - this.$.rvScreenOverlay.open(); - } - - _handleRvEditScreenClose() { - this.$.rvScreenOverlay.close(); - } - - _setCanModifyConfig() { - const promises = []; - promises.push(this._getRepoAccess(this.repoName).then( access => { - if (access && access[this.repoName] && access[this.repoName].is_owner) { - this._isOwner = true; + const p1 = this.getRepoAccess(this.repoName).then(access => { + if (access[this.repoName]?.is_owner) { + this.canModifyConfig = true; } - })); - promises.push(this._getCapabilities().then(capabilities => { - if (capabilities['reviewers-modifyReviewersConfig']) { - this._hasModifyCapability = true; - } - })); - Promise.all(promises).then(() => { - this._loading = false; }); + const p2 = this.getCapabilities().then(capabilities => { + if (capabilities['reviewers-modifyReviewersConfig']) { + this.canModifyConfig = true; + } + }); + Promise.all([p1, p2]).then(() => (this.loading = false)); } - _computeCanModifyConfig(isOwner, hasModifyCapability) { - return isOwner || hasModifyCapability; + getRepoAccess(repoName: RepoName) { + return this.pluginRestApi.get<ProjectAccessInfoMap>( + '/access/?project=' + encodeURIComponent(repoName) + ); } - _getRepoAccess(repoName) { - return this.pluginRestApi.get( - '/access/?project=' + encodeURIComponent(repoName)); - } - - _getCapabilities() { - return this.pluginRestApi.get('/accounts/self/capabilities'); + getCapabilities() { + return this.pluginRestApi.get<AccountCapabilityInfo>( + '/accounts/self/capabilities' + ); } } - -customElements.define(RvReviewers.is, RvReviewers); -
diff --git a/rv-reviewers/rv-reviewers_html.ts b/rv-reviewers/rv-reviewers_html.ts deleted file mode 100644 index 53a8aec..0000000 --- a/rv-reviewers/rv-reviewers_html.ts +++ /dev/null
@@ -1,44 +0,0 @@ -/** - * @license - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import './rv-edit-screen.js'; - -export const htmlTemplate = Polymer.html` - <style include="shared-styles"> - :host { - display: block; - margin-bottom: var(--spacing-xxl); - } - #rvScreenOverlay { - width: 50em; - overflow: auto; - } - </style> - <h3>Reviewers Config</h3> - <gr-button - on-click="_handleCommandTap" - > - Reviewers Config - </gr-button> - <gr-overlay id="rvScreenOverlay" with-backdrop> - <rv-edit-screen - plugin-rest-api="[[pluginRestApi]]" - repo-name="[[repoName]]" - loading="[[_loading]]" - can-modify-config="[[_canModifyConfig]]" - on-close="_handleRvEditScreenClose"></rv-edit-screen> - </gr-overlay> -`;
diff --git a/rv-reviewers/util.ts b/rv-reviewers/util.ts new file mode 100644 index 0000000..c2559c5 --- /dev/null +++ b/rv-reviewers/util.ts
@@ -0,0 +1,26 @@ +/** + * @license + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function fire<T>(target: EventTarget, type: string, detail?: T) { + target.dispatchEvent( + new CustomEvent<T>(type, { + detail, + composed: true, + bubbles: true, + }) + ); +}
diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f0a5ff3 --- /dev/null +++ b/tsconfig.json
@@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-plugins-base.json", + "include": [ + "rv-reviewers/**/*", + ] +}