web: Table parity (#427)

* core: fix application API always being sorted by name

* web: add sorting to tables

* web: add search to TablePage

* core: add search to applications API

* core: add MetaNameSerializer

* *: fix signature for non-modal serializers

* providers/*: implement MetaNameSerializer

* web: implement full app list page, use as default in sidebar

* web: fix linting errors

* admin: remove old application list

* web: fix default sorting for application list

* web: fix spacing for search element in toolbar
This commit is contained in:
Jens L
2020-12-24 09:56:05 +01:00
committed by GitHub
parent c3e9168b46
commit 79da2bf698
26 changed files with 355 additions and 244 deletions

View File

@ -1,10 +1,11 @@
import { DefaultClient, PBResponse, QueryArguments } from "./Client";
import { Provider } from "./Providers";
export class Application {
pk: string;
name: string;
slug: string;
provider: number;
provider: Provider;
launch_url: string;
meta_launch_url: string;
@ -24,4 +25,8 @@ export class Application {
static list(filter?: QueryArguments): Promise<PBResponse<Application>> {
return DefaultClient.fetch<PBResponse<Application>>(["core", "applications"], filter);
}
static adminUrl(rest: string): string {
return `/administration/applications/${rest}`;
}
}

View File

@ -4,6 +4,8 @@ export class Provider {
pk: number;
name: string;
authorization_flow: string;
verbose_name: string;
verbose_name_plural: string;
constructor() {
throw Error();

View File

@ -136,6 +136,12 @@ select[multiple] {
--pf-c-table--BorderColor: var(--ak-dark-background-lighter);
--pf-c-table--cell--Color: var(--ak-dark-foreground);
}
.pf-c-table__text {
color: var(--ak-dark-foreground) !important;
}
.pf-c-table__sort-indicator i {
color: var(--ak-dark-foreground) !important;
}
/* class for pagination text */
.pf-c-options-menu__toggle {
color: var(--ak-dark-foreground);

View File

@ -1,7 +1,7 @@
import { gettext } from "django";
import { customElement, html, property, TemplateResult } from "lit-element";
import { PBResponse } from "../../api/Client";
import { Table } from "../../elements/table/Table";
import { Table, TableColumn } from "../../elements/table/Table";
import { PolicyBinding } from "../../api/PolicyBindings";
import "../../elements/Tabs";
@ -22,8 +22,14 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
});
}
columns(): string[] {
return ["Policy", "Enabled", "Order", "Timeout", ""];
columns(): TableColumn[] {
return [
new TableColumn("Policy"),
new TableColumn("Enabled", "enabled"),
new TableColumn("Order", "order"),
new TableColumn("Timeout", "timeout"),
new TableColumn(""),
];
}
row(item: PolicyBinding): TemplateResult[] {

View File

@ -6,10 +6,72 @@ import { COMMON_STYLES } from "../../common/styles";
import "./TablePagination";
import "../EmptyState";
export class TableColumn {
title: string;
orderBy?: string;
onClick?: () => void;
constructor(title: string, orderBy?: string) {
this.title = title;
this.orderBy = orderBy;
}
headerClickHandler(table: Table<unknown>): void {
if (!this.orderBy) {
return;
}
if (table.order === this.orderBy) {
table.order = `-${this.orderBy}`;
} else {
table.order = this.orderBy;
}
table.fetch();
}
private getSortIndicator(table: Table<unknown>): string {
switch (table.order) {
case this.orderBy:
return "fa-long-arrow-alt-down";
case `-${this.orderBy}`:
return "fa-long-arrow-alt-up";
default:
return "fa-arrows-alt-v";
}
}
renderSortable(table: Table<unknown>): TemplateResult {
return html`
<button class="pf-c-table__button" @click=${() => this.headerClickHandler(table)}>
<div class="pf-c-table__button-content">
<span class="pf-c-table__text">${gettext(this.title)}</span>
<span class="pf-c-table__sort-indicator">
<i class="fas ${this.getSortIndicator(table)}"></i>
</span>
</div>
</button>`;
}
render(table: Table<unknown>): TemplateResult {
return html`<th
role="columnheader"
scope="col"
class="
${this.orderBy ? "pf-c-table__sort " : " "}
${(table.order === this.orderBy || table.order === `-${this.orderBy}`) ? "pf-m-selected " : ""}
">
${this.orderBy ? this.renderSortable(table) : html`${gettext(this.title)}`}
</th>`;
}
}
export abstract class Table<T> extends LitElement {
abstract apiEndpoint(page: number): Promise<PBResponse<T>>;
abstract columns(): Array<string>;
abstract row(item: T): Array<TemplateResult>;
abstract columns(): TableColumn[];
abstract row(item: T): TemplateResult[];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderExpanded(item: T): TemplateResult {
@ -25,6 +87,12 @@ export abstract class Table<T> extends LitElement {
@property({type: Number})
page = 1;
@property({type: String})
order?: string;
@property({type: String})
search?: string;
@property({type: Boolean})
expandable = false;
@ -43,6 +111,7 @@ export abstract class Table<T> extends LitElement {
}
public fetch(): void {
this.data = undefined;
this.apiEndpoint(this.page).then((r) => {
this.data = r;
this.page = r.pagination.current;
@ -123,12 +192,17 @@ export abstract class Table<T> extends LitElement {
</button>`;
}
renderSearch(): TemplateResult {
return html``;
}
renderTable(): TemplateResult {
if (!this.data) {
this.fetch();
}
return html`<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
${this.renderSearch()}&nbsp;
<div class="pf-c-toolbar__bulk-select">
${this.renderToolbar()}
</div>
@ -143,7 +217,7 @@ export abstract class Table<T> extends LitElement {
<thead>
<tr role="row">
${this.expandable ? html`<td role="cell">` : html``}
${this.columns().map((col) => html`<th role="columnheader" scope="col">${gettext(col)}</th>`)}
${this.columns().map((col) => col.render(this))}
</tr>
</thead>
${this.data ? this.renderRows() : this.renderLoading()}

View File

@ -1,19 +1,34 @@
import { html, TemplateResult } from "lit-html";
import { ifDefined } from "lit-html/directives/if-defined";
import { Table } from "./Table";
import "./TableSearch";
export abstract class TablePage<T> extends Table<T> {
abstract pageTitle(): string;
abstract pageDescription(): string;
abstract pageDescription(): string | undefined;
abstract pageIcon(): string;
abstract searchEnabled(): boolean;
renderSearch(): TemplateResult {
if (!this.searchEnabled()) {
return super.renderSearch();
}
return html`<ak-table-search value=${ifDefined(this.search)} .onSearch=${(value: string) => {
this.search = value;
this.fetch();
}}>
</ak-table-search>`;
}
render(): TemplateResult {
const description = this.pageDescription();
return html`<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="${this.pageIcon()}"></i>
${this.pageTitle()}
</h1>
<p>${this.pageDescription()}</p>
${description ? html`<p>${description}</p>` : html``}
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">

View File

@ -0,0 +1,41 @@
import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
import { ifDefined } from "lit-html/directives/if-defined";
import { COMMON_STYLES } from "../../common/styles";
@customElement("ak-table-search")
export class TableSearch extends LitElement {
@property()
value?: string;
@property()
onSearch?: (value: string) => void;
static get styles(): CSSResult[] {
return COMMON_STYLES;
}
render(): TemplateResult {
return html`<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<form class="pf-c-input-group" method="GET" @submit=${(e: Event) => {
e.preventDefault();
if (!this.onSearch) return;
const el = this.shadowRoot?.querySelector<HTMLInputElement>("input[type=search]");
if (!el) return;
if (el.value === "") return;
this.onSearch(el?.value);
}}>
<input class="pf-c-form-control" name="search" type="search" placeholder="Search..." value="${ifDefined(this.value)}" @search=${() => {
if (!this.onSearch) return;
this.onSearch("");
}}>
<button class="pf-c-button pf-m-control" type="submit">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
</form>
</div>
</div>`;
}
}

View File

@ -14,7 +14,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
return User.me().then(u => u.is_superuser);
}),
new SidebarItem("Administration").children(
new SidebarItem("Applications", "/administration/applications/").activeWhen(
new SidebarItem("Applications", "/applications/").activeWhen(
`^/applications/(?<slug>${SLUG_REGEX})/$`
),
new SidebarItem("Sources", "/administration/sources/").activeWhen(

View File

@ -1,14 +1,18 @@
import { gettext } from "django";
import { customElement, html, TemplateResult } from "lit-element";
import { customElement, html, property, TemplateResult } from "lit-element";
import { Application } from "../../api/Applications";
import { PBResponse } from "../../api/Client";
import { TablePage } from "../../elements/table/TablePage";
import "../../elements/buttons/ModalButton";
import "../../elements/buttons/SpinnerButton";
import { TableColumn } from "../../elements/table/Table";
@customElement("ak-application-list")
export class ApplicationList extends TablePage<Application> {
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return gettext("Applications");
}
@ -19,31 +23,51 @@ export class ApplicationList extends TablePage<Application> {
return gettext("pf-icon pf-icon-applications");
}
@property()
order = "name";
apiEndpoint(page: number): Promise<PBResponse<Application>> {
return Application.list({
ordering: "order",
ordering: this.order,
page: page,
search: this.search || "",
});
}
columns(): string[] {
return ["Name", "Slug", "Provider", "Provider Type", ""];
columns(): TableColumn[] {
return [
new TableColumn(""),
new TableColumn("Name", "name"),
new TableColumn("Slug", "slug"),
new TableColumn("Provider"),
new TableColumn("Provider Type"),
new TableColumn(""),
];
}
row(item: Application): TemplateResult[] {
return [
html`${item.name}`,
html`${item.slug}`,
html`${item.provider}`,
html`${item.provider}`,
html`
<ak-modal-button href="administration/policies/bindings/${item.pk}/update/">
${item.meta_icon ?
html`<img class="app-icon pf-c-avatar" src="${item.meta_icon}" alt="${gettext("Application Icon")}">` :
html`<i class="pf-icon pf-icon-arrow"></i>`}`,
html`<a href="#/applications/${item.slug}/">
<div>
${item.name}
</div>
${item.meta_publisher ? html`<small>${item.meta_publisher}</small>` : html``}
</a>`,
html`<code>${item.slug}</code>`,
html`${item.provider.name}`,
html`${item.provider.verbose_name}`,
html`
<ak-modal-button href="${Application.adminUrl(`${item.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
Edit
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-modal-button href="administration/policies/bindings/${item.pk}/delete/">
<ak-modal-button href="${Application.adminUrl(`${item.pk}/delete/`)}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
Delete
</ak-spinner-button>
@ -52,4 +76,16 @@ export class ApplicationList extends TablePage<Application> {
`,
];
}
renderToolbar(): TemplateResult {
return html`
<ak-modal-button href=${Application.adminUrl("create/")}>
<ak-spinner-button slot="trigger" class="pf-m-primary">
${gettext("Create")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
${super.renderToolbar()}
`;
}
}

View File

@ -1,7 +1,7 @@
import { gettext } from "django";
import { customElement, html, property, TemplateResult } from "lit-element";
import { PBResponse } from "../../api/Client";
import { Table } from "../../elements/table/Table";
import { Table, TableColumn } from "../../elements/table/Table";
import "../../elements/Tabs";
import "../../elements/AdminLoginsChart";
@ -25,8 +25,13 @@ export class BoundStagesList extends Table<FlowStageBinding> {
});
}
columns(): string[] {
return ["Order", "Name", "Type", ""];
columns(): TableColumn[] {
return [
new TableColumn("Order"),
new TableColumn("Name"),
new TableColumn("Type"),
new TableColumn(""),
];
}
row(item: FlowStageBinding): TemplateResult[] {