| /** |
| * @license |
| * Copyright 2017 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '@polymer/iron-input/iron-input'; |
| import '../gr-button/gr-button'; |
| import '../gr-icon/gr-icon'; |
| import {encodeURL, getBaseUrl} from '../../../utils/url-util'; |
| import {fireEvent} from '../../../utils/event-util'; |
| import {debounce, DelayedTask} from '../../../utils/async-util'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {LitElement, PropertyValues, css, html} from 'lit'; |
| import {customElement, property} from 'lit/decorators.js'; |
| import {BindValueChangeEvent} from '../../../types/events'; |
| import {resolve} from '../../../models/dependency'; |
| import {navigationToken} from '../../core/gr-navigation/gr-navigation'; |
| |
| const REQUEST_DEBOUNCE_INTERVAL_MS = 200; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-list-view': GrListView; |
| } |
| } |
| |
| @customElement('gr-list-view') |
| export class GrListView extends LitElement { |
| @property({type: Boolean}) |
| createNew?: boolean; |
| |
| @property({type: Array}) |
| items?: unknown[]; |
| |
| @property({type: Number}) |
| itemsPerPage = 25; |
| |
| @property({type: String}) |
| filter?: string; |
| |
| @property({type: Number}) |
| offset = 0; |
| |
| @property({type: Boolean}) |
| loading?: boolean; |
| |
| @property({type: String}) |
| path?: string; |
| |
| private reloadTask?: DelayedTask; |
| |
| private readonly getNavigation = resolve(this, navigationToken); |
| |
| override disconnectedCallback() { |
| this.reloadTask?.cancel(); |
| super.disconnectedCallback(); |
| } |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| css` |
| #filter { |
| max-width: 25em; |
| } |
| #filter:focus { |
| outline: none; |
| } |
| #topContainer { |
| align-items: center; |
| display: flex; |
| height: 3rem; |
| justify-content: space-between; |
| margin: 0 var(--spacing-l); |
| } |
| #createNewContainer:not(.show) { |
| display: none; |
| } |
| a { |
| color: var(--primary-text-color); |
| text-decoration: none; |
| } |
| a:hover { |
| text-decoration: underline; |
| } |
| nav { |
| align-items: center; |
| display: flex; |
| height: 3rem; |
| justify-content: flex-end; |
| margin-right: 20px; |
| color: var(--deemphasized-text-color); |
| } |
| gr-icon { |
| font-size: 1.85rem; |
| margin-left: 16px; |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| return html` |
| <div id="topContainer"> |
| <div class="filterContainer"> |
| <label>Filter:</label> |
| <iron-input |
| .bindValue=${this.filter} |
| @bind-value-changed=${this.handleFilterBindValueChanged} |
| > |
| <input type="text" id="filter" /> |
| </iron-input> |
| </div> |
| <div id="createNewContainer" class=${this.createNew ? 'show' : ''}> |
| <gr-button |
| id="createNew" |
| primary |
| link |
| @click=${() => this.createNewItem()} |
| > |
| Create New |
| </gr-button> |
| </div> |
| </div> |
| <slot></slot> |
| <nav> |
| Page ${this.computePage()} |
| <a |
| id="prevArrow" |
| href=${this.computeNavLink(-1)} |
| ?hidden=${this.loading || this.offset === 0} |
| > |
| <gr-icon icon="chevron_left"></gr-icon> |
| </a> |
| <a |
| id="nextArrow" |
| href=${this.computeNavLink(1)} |
| ?hidden=${this.hideNextArrow()} |
| > |
| <gr-icon icon="chevron_right"></gr-icon> |
| </a> |
| </nav> |
| `; |
| } |
| |
| override willUpdate(changedProperties: PropertyValues) { |
| // We have to do this for the tests. |
| if (changedProperties.has('filter')) { |
| this.filterChanged( |
| this.filter, |
| changedProperties.get('filter') as string |
| ); |
| } |
| } |
| |
| private filterChanged(newFilter?: string, oldFilter?: string) { |
| // newFilter can be empty string and then !newFilter === true |
| if (!newFilter && !oldFilter) { |
| return; |
| } |
| this.debounceReload(newFilter); |
| } |
| |
| // private but used in test |
| debounceReload(filter?: string) { |
| this.reloadTask = debounce( |
| this.reloadTask, |
| () => { |
| if (!this.isConnected || !this.path) return; |
| if (filter) { |
| this.getNavigation().setUrl( |
| `${this.path}/q/filter:${encodeURL(filter)}` |
| ); |
| return; |
| } |
| this.getNavigation().setUrl(this.path); |
| }, |
| REQUEST_DEBOUNCE_INTERVAL_MS |
| ); |
| } |
| |
| private createNewItem() { |
| fireEvent(this, 'create-clicked'); |
| } |
| |
| // private but used in test |
| computeNavLink(direction: number) { |
| // Offset could be a string when passed from the router. |
| const offset = +(this.offset || 0); |
| const newOffset = Math.max(0, offset + this.itemsPerPage * direction); |
| let href = getBaseUrl() + (this.path ?? ''); |
| if (this.filter) { |
| href += '/q/filter:' + encodeURL(this.filter); |
| } |
| if (newOffset > 0) { |
| href += `,${newOffset}`; |
| } |
| return href; |
| } |
| |
| // private but used in test |
| hideNextArrow() { |
| if (this.loading || !this.items?.length) return true; |
| const lastPage = this.items.length < this.itemsPerPage + 1; |
| return lastPage; |
| } |
| |
| // TODO: fix offset (including itemsPerPage) |
| // to either support a decimal or make it go to the nearest |
| // whole number (e.g 3). |
| // private but used in test |
| computePage() { |
| return this.offset / this.itemsPerPage + 1; |
| } |
| |
| private handleFilterBindValueChanged(e: BindValueChangeEvent) { |
| this.filter = e.detail.value; |
| } |
| } |