Convert gr-repo-list to lit Change-Id: I160d92d65b81e516b6b2bf250a268b273f5bebb3
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts index a444067..8e50b5f 100644 --- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts +++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -14,24 +14,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import '../../../styles/gr-table-styles'; -import '../../../styles/shared-styles'; import '../../shared/gr-dialog/gr-dialog'; import '../../shared/gr-list-view/gr-list-view'; import '../../shared/gr-overlay/gr-overlay'; import '../gr-create-repo-dialog/gr-create-repo-dialog'; -import {PolymerElement} from '@polymer/polymer/polymer-element'; -import {htmlTemplate} from './gr-repo-list_html'; import {GerritNav} from '../../core/gr-navigation/gr-navigation'; -import {customElement, property, observe, computed} from '@polymer/decorators'; import {AppElementAdminParams} from '../../gr-app-types'; import {GrOverlay} from '../../shared/gr-overlay/gr-overlay'; -import {RepoName, ProjectInfoWithName} from '../../../types/common'; +import { + RepoName, + ProjectInfoWithName, + WebLinkInfo, +} from '../../../types/common'; import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog'; import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants'; import {fireTitleChange} from '../../../utils/event-util'; import {appContext} from '../../../services/app-context'; import {encodeURL, getBaseUrl} from '../../../utils/url-util'; +import {tableStyles} from '../../../styles/gr-table-styles'; +import {sharedStyles} from '../../../styles/shared-styles'; +import {LitElement, PropertyValues, css, html} from 'lit'; +import {customElement, property, query, state} from 'lit/decorators'; declare global { interface HTMLElementTagNameMap { @@ -39,154 +42,259 @@ } } -export interface GrRepoList { - $: { - createOverlay: GrOverlay; - createNewModal: GrCreateRepoDialog; - }; -} - @customElement('gr-repo-list') -export class GrRepoList extends PolymerElement { - static get template() { - return htmlTemplate; - } +export class GrRepoList extends LitElement { + @query('#createOverlay') + createOverlay?: GrOverlay; + + @query('#createNewModal') + createNewModal?: GrCreateRepoDialog; @property({type: Object}) params?: AppElementAdminParams; - @property({type: Number}) - _offset = 0; + @state() offset = 0; - @property({type: String}) - readonly _path = '/admin/repos'; + @state() newRepoName = false; - @property({type: Boolean}) - _newRepoName = false; + @state() createNewCapability = false; - @property({type: Boolean}) - _createNewCapability = false; + @state() repos: ProjectInfoWithName[] = []; - @property({type: Array}) - _repos: ProjectInfoWithName[] = []; + @state() reposPerPage = 25; - @property({type: Number}) - _reposPerPage = 25; + @state() loading = true; - @property({type: Boolean}) - _loading = true; + @state() filter = ''; - @property({type: String}) - _filter = ''; - - @computed('_repos') - get _shownRepos() { - return this._repos.slice(0, SHOWN_ITEMS_COUNT); - } + @state() readonly path = '/admin/repos'; private readonly restApiService = appContext.restApiService; - override connectedCallback() { + override async connectedCallback() { super.connectedCallback(); - this._getCreateRepoCapability(); + await this.getCreateRepoCapability(); fireTitleChange(this, 'Repos'); - this._maybeOpenCreateOverlay(this.params); + this.maybeOpenCreateOverlay(this.params); } - @observe('params') - _paramsChanged(params: AppElementAdminParams) { - this._loading = true; - this._filter = params?.filter ?? ''; - this._offset = Number(params?.offset ?? 0); + static override get styles() { + return [ + tableStyles, + sharedStyles, + css` + .genericList tr td:last-of-type { + text-align: left; + } + .genericList tr th:last-of-type { + text-align: left; + } + .readOnly { + text-align: center; + } + .changesLink, + .name, + .repositoryBrowser, + .readOnly { + white-space: nowrap; + } + `, + ]; + } - return this._getRepos(this._filter, this._reposPerPage, this._offset); + override render() { + return html` + <gr-list-view + .createNew=${this.createNewCapability} + .filter=${this.filter} + .itemsPerPage=${this.reposPerPage} + .items=${this.repos} + .loading=${this.loading} + .offset=${this.offset} + .path=${this.path} + @create-clicked=${this.handleCreateClicked} + > + <table id="list" class="genericList"> + <tbody> + <tr class="headerRow"> + <th class="name topHeader">Repository Name</th> + <th class="repositoryBrowser topHeader">Repository Browser</th> + <th class="changesLink topHeader">Changes</th> + <th class="topHeader readOnly">Read only</th> + <th class="description topHeader">Repository Description</th> + </tr> + <tr + id="loading" + class="loadingMsg ${this.computeLoadingClass(this.loading)}" + > + <td>Loading...</td> + </tr> + </tbody> + <tbody class="${this.computeLoadingClass(this.loading)}"> + ${this.renderRepoList()} + </tbody> + </table> + </gr-list-view> + <gr-overlay id="createOverlay" with-backdrop> + <gr-dialog + id="createDialog" + class="confirmDialog" + ?disabled=${!this.newRepoName} + confirm-label="Create" + @confirm=${this.handleCreateRepo} + @cancel=${this.handleCloseCreate} + > + <div class="header" slot="header">Create Repository</div> + <div class="main" slot="main"> + <gr-create-repo-dialog + id="createNewModal" + @new-repo-name=${this.handleNewRepoName} + ></gr-create-repo-dialog> + </div> + </gr-dialog> + </gr-overlay> + `; + } + + private renderRepoList() { + const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT); + return shownRepos.map(item => this.renderRepo(item)); + } + + private renderRepo(item: ProjectInfoWithName) { + return html` + <tr class="table"> + <td class="name"> + <a href="${this.computeRepoUrl(item.name)}">${item.name}</a> + </td> + <td class="repositoryBrowser">${this.renderWebLinks(item)}</td> + <td class="changesLink"> + <a href="${this.computeChangesLink(item.name)}">view all</a> + </td> + <td class="readOnly"> + ${item.state === ProjectState.READ_ONLY ? 'Y' : ''} + </td> + <td class="description">${item.description}</td> + </tr> + `; + } + + private renderWebLinks(links: ProjectInfoWithName) { + const webLinks = links.web_links ? links.web_links : []; + return webLinks.map(link => this.renderWebLink(link)); + } + + private renderWebLink(link: WebLinkInfo) { + return html` + <a href="${link.url}" class="webLink" rel="noopener" target="_blank"> + ${link.name} + </a> + `; + } + + 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); + + return await this.getRepos(); } /** - * Opens the create overlay if the route has a hash 'create' + * Opens the create overlay if the route has a hash 'create'. + * + * private but used in test */ - _maybeOpenCreateOverlay(params?: AppElementAdminParams) { + maybeOpenCreateOverlay(params?: AppElementAdminParams) { if (params?.openCreateModal) { - this.$.createOverlay.open(); + this.createOverlay?.open(); } } - _computeRepoUrl(name: string) { - return getBaseUrl() + this._path + '/' + encodeURL(name, true); + private computeRepoUrl(name: string) { + return `${getBaseUrl()}${this.path}/${encodeURL(name, true)}`; } - _computeChangesLink(name: string) { + private computeChangesLink(name: string) { return GerritNav.getUrlForProjectChanges(name as RepoName); } - _getCreateRepoCapability() { - return this.restApiService.getAccount().then(account => { - if (!account) { - return; - } - return this.restApiService - .getAccountCapabilities(['createProject']) - .then(capabilities => { - if (capabilities?.createProject) { - this._createNewCapability = true; - } - }); - }); - } + private async getCreateRepoCapability() { + const account = await this.restApiService.getAccount(); - _getRepos(filter: string, reposPerPage: number, offset?: number) { - this._repos = []; - return this.restApiService - .getRepos(filter, reposPerPage, offset) - .then(repos => { - // Late response. - if (filter !== this._filter || !repos) { - return; - } - this._repos = repos.filter(repo => - repo.name.toLowerCase().includes(filter.toLowerCase()) - ); - this._loading = false; - }); - } + if (!account) return; - _refreshReposList() { - this.restApiService.invalidateReposCache(); - return this._getRepos(this._filter, this._reposPerPage, this._offset); - } - - async _handleCreateRepo() { - await this.$.createNewModal.handleCreateRepo(); - this._refreshReposList(); - } - - _handleCloseCreate() { - this.$.createOverlay.close(); - } - - _handleCreateClicked() { - this.$.createOverlay.open().then(() => { - this.$.createNewModal.focus(); - }); - } - - _readOnly(repo: ProjectInfoWithName) { - return repo.state === ProjectState.READ_ONLY ? 'Y' : ''; - } - - _computeWeblink(repo: ProjectInfoWithName) { - if (!repo.web_links) { - return ''; + const accountCapabilities = + await this.restApiService.getAccountCapabilities(['createProject']); + if (accountCapabilities?.createProject) { + this.createNewCapability = true; } - const webLinks = repo.web_links; - return webLinks.length ? webLinks : null; + + return account; } + /* private but used in test */ + async getRepos() { + this.repos = []; + + // We save the filter before getting the repos + // and then we check the value hasn't changed aftwards. + const filter = this.filter; + + const repos = await this.restApiService.getRepos( + this.filter, + this.reposPerPage, + this.offset + ); + + // Late response. + if (filter !== this.filter || !repos) return; + + this.repos = repos.filter(repo => + repo.name.toLowerCase().includes(filter.toLowerCase()) + ); + this.loading = false; + + return repos; + } + + private async refreshReposList() { + this.restApiService.invalidateReposCache(); + return await this.getRepos(); + } + + /* private but used in test */ + async handleCreateRepo() { + await this.createNewModal?.handleCreateRepo(); + await this.refreshReposList(); + } + + /* private but used in test */ + handleCloseCreate() { + this.createOverlay?.close(); + } + + /* private but used in test */ + handleCreateClicked() { + this.createOverlay?.open().then(() => { + this.createNewModal?.focus(); + }); + } + + /* private but used in test */ computeLoadingClass(loading: boolean) { return loading ? 'loading' : ''; } - _handleNewRepoName() { - this._newRepoName = this.$.createNewModal.nameChanged; + private handleNewRepoName() { + if (!this.createNewModal) return; + this.newRepoName = this.createNewModal.nameChanged; } }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts deleted file mode 100644 index d65c499..0000000 --- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts +++ /dev/null
@@ -1,116 +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 {html} from '@polymer/polymer/lib/utils/html-tag'; - -export const htmlTemplate = html` - <style include="shared-styles"> - /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */ - </style> - <style include="gr-table-styles"> - /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */ - </style> - <style> - .genericList tr td:last-of-type { - text-align: left; - } - .genericList tr th:last-of-type { - text-align: left; - } - .readOnly { - text-align: center; - } - .changesLink, - .name, - .repositoryBrowser, - .readOnly { - white-space: nowrap; - } - </style> - <gr-list-view - create-new="[[_createNewCapability]]" - filter="[[_filter]]" - items-per-page="[[_reposPerPage]]" - items="[[_repos]]" - loading="[[_loading]]" - offset="[[_offset]]" - on-create-clicked="_handleCreateClicked" - path="[[_path]]" - > - <table id="list" class="genericList"> - <tbody> - <tr class="headerRow"> - <th class="name topHeader">Repository Name</th> - <th class="repositoryBrowser topHeader">Repository Browser</th> - <th class="changesLink topHeader">Changes</th> - <th class="topHeader readOnly">Read only</th> - <th class="description topHeader">Repository Description</th> - </tr> - <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]"> - <td>Loading...</td> - </tr> - </tbody> - <tbody class$="[[computeLoadingClass(_loading)]]"> - <template is="dom-repeat" items="[[_shownRepos]]"> - <tr class="table"> - <td class="name"> - <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a> - </td> - <td class="repositoryBrowser"> - <template - is="dom-repeat" - items="[[_computeWeblink(item)]]" - as="link" - > - <a - href$="[[link.url]]" - class="webLink" - rel="noopener" - target="_blank" - > - [[link.name]] - </a> - </template> - </td> - <td class="changesLink"> - <a href$="[[_computeChangesLink(item.name)]]">view all</a> - </td> - <td class="readOnly">[[_readOnly(item)]]</td> - <td class="description">[[item.description]]</td> - </tr> - </template> - </tbody> - </table> - </gr-list-view> - <gr-overlay id="createOverlay" with-backdrop=""> - <gr-dialog - id="createDialog" - class="confirmDialog" - disabled="[[!_newRepoName]]" - confirm-label="Create" - on-confirm="_handleCreateRepo" - on-cancel="_handleCloseCreate" - > - <div class="header" slot="header">Create Repository</div> - <div class="main" slot="main"> - <gr-create-repo-dialog - id="createNewModal" - on-new-repo-name="_handleNewRepoName" - ></gr-create-repo-dialog> - </div> - </gr-dialog> - </gr-overlay> -`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts index 7f7b0cf..142a838 100644 --- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts +++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -19,14 +19,18 @@ import './gr-repo-list'; import {GrRepoList} from './gr-repo-list'; import {page} from '../../../utils/page-wrapper-utils'; -import {queryAndAssert, stubRestApi} from '../../../test/test-utils'; +import { + mockPromise, + queryAndAssert, + stubRestApi, +} from '../../../test/test-utils'; import { UrlEncodedRepoName, ProjectInfoWithName, RepoName, } from '../../../types/common'; import {AppElementAdminParams} from '../../gr-app-types'; -import {ProjectState} from '../../../constants/constants'; +import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants'; import {GerritView} from '../../../services/router/router-model'; import {GrOverlay} from '../../shared/gr-overlay/gr-overlay'; import {GrDialog} from '../../shared/gr-dialog/gr-dialog'; @@ -60,47 +64,46 @@ let element: GrRepoList; let repos: ProjectInfoWithName[]; - const value: AppElementAdminParams = {view: GerritView.ADMIN, adminView: ''}; - - setup(() => { + setup(async () => { sinon.stub(page, 'show'); element = basicFixture.instantiate(); + await element.updateComplete; }); suite('list with repos', () => { setup(async () => { repos = createRepoList('test', 26); stubRestApi('getRepos').returns(Promise.resolve(repos)); - await element._paramsChanged(value); - await flush(); + await element._paramsChanged(); + await element.updateComplete; }); test('test for test repo in the list', async () => { - await flush(); - assert.equal(element._repos[0].id, 'test0'); - assert.equal(element._repos[1].id, 'test1'); - assert.equal(element._repos[2].id, 'test2'); + await element.updateComplete; + assert.equal(element.repos[0].id, 'test0'); + assert.equal(element.repos[1].id, 'test1'); + assert.equal(element.repos[2].id, 'test2'); }); - test('_shownRepos', () => { - assert.equal(element._shownRepos.length, 25); + test('shownRepos', () => { + assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25); }); - test('_maybeOpenCreateOverlay', () => { + test('maybeOpenCreateOverlay', () => { const overlayOpen = sinon.stub( queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open' ); - element._maybeOpenCreateOverlay(); + element.maybeOpenCreateOverlay(); assert.isFalse(overlayOpen.called); - element._maybeOpenCreateOverlay(undefined); + element.maybeOpenCreateOverlay(undefined); assert.isFalse(overlayOpen.called); const params: AppElementAdminParams = { view: GerritView.ADMIN, adminView: '', openCreateModal: true, }; - element._maybeOpenCreateOverlay(params); + element.maybeOpenCreateOverlay(params); assert.isTrue(overlayOpen.called); }); }); @@ -109,12 +112,12 @@ setup(async () => { repos = createRepoList('test', 25); stubRestApi('getRepos').returns(Promise.resolve(repos)); - await element._paramsChanged(value); - await flush(); + await element._paramsChanged(); + await element.updateComplete; }); - test('_shownRepos', () => { - assert.equal(element._shownRepos.length, 25); + test('shownRepos', () => { + assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25); }); }); @@ -129,41 +132,51 @@ test('_paramsChanged', async () => { const repoStub = stubRestApi('getRepos'); repoStub.returns(Promise.resolve(repos)); - const value: AppElementAdminParams = { + element.params = { view: GerritView.ADMIN, adminView: '', filter: 'test', offset: 25, - }; - await element._paramsChanged(value); + } as AppElementAdminParams; + await element._paramsChanged(); assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25)); }); test('latest repos requested are always set', async () => { const repoStub = stubRestApi('getRepos'); - repoStub.withArgs('test', 25).returns(Promise.resolve(repos)); - repoStub.withArgs('filter', 25).returns(Promise.resolve(reposFiltered)); - element._filter = 'test'; + const promise = mockPromise<ProjectInfoWithName[]>(); + repoStub.withArgs('filter', 25).returns(promise); - // Repos are not set because the element._filter differs. - await element._getRepos('filter', 25, 0); - assert.deepEqual(element._repos, []); + element.filter = 'test'; + element.reposPerPage = 25; + element.offset = 0; + + // Repos are not set because the element.filter differs. + const p = element.getRepos(); + element.filter = 'filter'; + promise.resolve(reposFiltered); + await p; + assert.deepEqual(element.repos, []); }); test('filter is case insensitive', async () => { const repoStub = stubRestApi('getRepos'); const repos = [createRepo('aSDf', 0)]; repoStub.withArgs('asdf', 25).returns(Promise.resolve(repos)); - element._filter = 'asdf'; - await element._getRepos('asdf', 25, 0); - assert.equal(element._repos.length, 1); + + element.filter = 'asdf'; + element.reposPerPage = 25; + element.offset = 0; + + await element.getRepos(); + assert.equal(element.repos.length, 1); }); }); suite('loading', () => { - test('correct contents are displayed', () => { - assert.isTrue(element._loading); - assert.equal(element.computeLoadingClass(element._loading), 'loading'); + test('correct contents are displayed', async () => { + assert.isTrue(element.loading); + assert.equal(element.computeLoadingClass(element.loading), 'loading'); assert.equal( getComputedStyle( queryAndAssert<HTMLTableRowElement>(element, '#loading') @@ -171,11 +184,11 @@ 'block' ); - element._loading = false; - element._repos = createRepoList('test', 25); + element.loading = false; + element.repos = createRepoList('test', 25); - flush(); - assert.equal(element.computeLoadingClass(element._loading), ''); + await element.updateComplete; + assert.equal(element.computeLoadingClass(element.loading), ''); assert.equal( getComputedStyle( queryAndAssert<HTMLTableRowElement>(element, '#loading') @@ -186,11 +199,9 @@ }); suite('create new', () => { - test('_handleCreateClicked called when create-click fired', () => { - const handleCreateClickedStub = sinon.stub( - element, - '_handleCreateClicked' - ); + test('handleCreateClicked called when create-clicked fired', () => { + const handleCreateClickedStub = sinon.stub(); + element.addEventListener('create-clicked', handleCreateClickedStub); queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent( new CustomEvent('create-clicked', { composed: true, @@ -200,31 +211,33 @@ assert.isTrue(handleCreateClickedStub.called); }); - test('_handleCreateClicked opens modal', () => { + test('handleCreateClicked opens modal', () => { const openStub = sinon .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open') .returns(Promise.resolve()); - element._handleCreateClicked(); + element.handleCreateClicked(); assert.isTrue(openStub.called); }); - test('_handleCreateRepo called when confirm fired', () => { - const handleCreateRepoStub = sinon.stub(element, '_handleCreateRepo'); + test('handleCreateRepo called when confirm fired', () => { + const handleCreateRepoStub = sinon.stub(); + element.addEventListener('confirm', handleCreateRepoStub); queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent( new CustomEvent('confirm', { composed: true, - bubbles: true, + bubbles: false, }) ); assert.isTrue(handleCreateRepoStub.called); }); - test('_handleCloseCreate called when cancel fired', () => { - const handleCloseCreateStub = sinon.stub(element, '_handleCloseCreate'); + test('handleCloseCreate called when cancel fired', () => { + const handleCloseCreateStub = sinon.stub(); + element.addEventListener('cancel', handleCloseCreateStub); queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent( new CustomEvent('cancel', { composed: true, - bubbles: true, + bubbles: false, }) ); assert.isTrue(handleCloseCreateStub.called);
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts index 3ac7c7b..4406a73 100644 --- a/polygerrit-ui/app/types/common.ts +++ b/polygerrit-ui/app/types/common.ts
@@ -206,6 +206,7 @@ UrlEncodedRepoName, UserConfigInfo, VotingRangeInfo, + WebLinkInfo, isDetailedLabelInfo, isQuickLabelInfo, };