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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 355 additions and 244 deletions

View file

@ -4,10 +4,9 @@ from collections import Counter
from datetime import timedelta
from typing import Dict, List
from django.db.models import Count, ExpressionWrapper, F
from django.db.models import Count, ExpressionWrapper, F, Model
from django.db.models.fields import DurationField
from django.db.models.functions import ExtractHour
from django.http import response
from django.utils.timezone import now
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.fields import SerializerMethodField
@ -60,10 +59,10 @@ class AdministrationMetricsSerializer(Serializer):
"""Get failed logins per hour for the last 24 hours"""
return get_events_per_1h(action=EventAction.LOGIN_FAILED)
def create(self, request: Request) -> response:
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, request: Request) -> Response:
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError

View file

@ -2,6 +2,7 @@
from importlib import import_module
from django.contrib import messages
from django.db.models import Model
from django.http.response import Http404
from django.utils.translation import gettext_lazy as _
from drf_yasg2.utils import swagger_auto_schema
@ -26,10 +27,10 @@ class TaskSerializer(Serializer):
status = IntegerField(source="result.status.value")
messages = ListField(source="result.messages")
def create(self, request: Request) -> Response:
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, request: Request) -> Response:
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError

View file

@ -1,5 +1,6 @@
"""authentik administration overview"""
from django.core.cache import cache
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema
from packaging.version import parse
from rest_framework.fields import SerializerMethodField
@ -39,10 +40,10 @@ class VersionSerializer(Serializer):
self.get_version_latest(instance)
)
def create(self, request: Request) -> Response:
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, request: Request) -> Response:
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError

View file

@ -1,131 +0,0 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load authentik_utils %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="pf-icon pf-icon-applications"></i>
{% trans 'Applications' %}
</h1>
<p>{% trans "External Applications which use authentik as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}</p>
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
{% if object_list %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
{% include 'partials/toolbar_search.html' %}
<div class="pf-c-toolbar__bulk-select">
<ak-modal-button href="{% url 'authentik_admin:application-create' %}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
{% trans 'Create' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<button role="ak-refresh" class="pf-c-button pf-m-primary">
{% trans 'Refresh' %}
</button>
</div>
{% include 'partials/pagination.html' %}
</div>
</div>
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
<thead>
<tr role="row">
<th role="columnheader"></th>
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
<th role="columnheader" scope="col">{% trans 'Slug' %}</th>
<th role="columnheader" scope="col">{% trans 'Provider' %}</th>
<th role="columnheader" scope="col">{% trans 'Provider Type' %}</th>
<th role="columnheader"></th>
</tr>
</thead>
<tbody role="rowgroup">
{% for application in object_list %}
<tr role="row">
<td role="cell" {% if application.meta_icon %} style="vertical-align: bottom;" {% endif %}>
{% if application.meta_icon %}
<img class="app-icon pf-c-avatar" src="{{ application.meta_icon.url }}" alt="{% trans 'Application Icon' %}">
{% else %}
<i class="pf-icon pf-icon-arrow"></i>
{% endif %}
</td>
<td role="cell">
<a href="/applications/{{ application.slug }}/">
<div>
{{ application.name }}
</div>
{% if application.meta_publisher %}
<small>{{ application.meta_publisher }}</small>
{% endif %}
</a>
</td>
<td role="cell">
<code>{{ application.slug }}</span>
</td>
<td role="cell">
<span>
{{ application.get_provider }}
</span>
</td>
<td role="cell">
<span>
{{ application.get_provider|verbose_name }}
</span>
</td>
<td>
<ak-modal-button href="{% url 'authentik_admin:application-update' pk=application.pk %}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
{% trans 'Edit' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-modal-button href="{% url 'authentik_admin:application-delete' pk=application.pk %}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
{% trans 'Delete' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pf-c-pagination pf-m-bottom">
{% include 'partials/pagination.html' %}
</div>
{% else %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
{% include 'partials/toolbar_search.html' %}
</div>
</div>
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<i class="pf-icon pf-icon-applications pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">
{% trans 'No Applications.' %}
</h1>
<div class="pf-c-empty-state__body">
{% if request.GET.search != "" %}
{% trans "Your search query doesn't match any application." %}
{% else %}
{% trans 'Currently no applications exist. Click the button below to create one.' %}
{% endif %}
</div>
<ak-modal-button href="{% url 'authentik_admin:application-create' %}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
{% trans 'Create' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</div>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -35,9 +35,6 @@ urlpatterns = [
name="overview-clear-policy-cache",
),
# Applications
path(
"applications/", applications.ApplicationListView.as_view(), name="applications"
),
path(
"applications/create/",
applications.ApplicationCreateView.as_view(),

View file

@ -6,44 +6,18 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
from authentik.admin.views.utils import (
BackSuccessUrlMixin,
DeleteMessageView,
SearchListMixin,
UserPaginateListMixin,
)
from authentik.core.forms.applications import ApplicationForm
from authentik.core.models import Application
from authentik.lib.views import CreateAssignPermView
class ApplicationListView(
LoginRequiredMixin,
PermissionListMixin,
UserPaginateListMixin,
SearchListMixin,
ListView,
):
"""Show list of all applications"""
model = Application
permission_required = "authentik_core.view_application"
ordering = "name"
template_name = "administration/application/list.html"
search_fields = [
"name",
"slug",
"meta_launch_url",
"meta_icon_url",
"meta_description",
"meta_publisher",
]
class ApplicationCreateView(
SuccessMessageMixin,
BackSuccessUrlMixin,

View file

@ -1,4 +1,5 @@
"""core Configs API"""
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
@ -19,10 +20,10 @@ class ConfigSerializer(Serializer):
error_reporting_environment = ReadOnlyField()
error_reporting_send_pii = ReadOnlyField()
def create(self, request: Request) -> Response:
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, request: Request) -> Response:
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError

View file

@ -1,5 +1,6 @@
"""core messages API"""
from django.contrib.messages import get_messages
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
@ -17,10 +18,10 @@ class MessageSerializer(Serializer):
extra_tags = ReadOnlyField()
level_tag = ReadOnlyField()
def create(self, request: Request) -> Response:
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, request: Request) -> Response:
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError

View file

@ -12,6 +12,7 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.admin.api.metrics import get_events_per_1h
from authentik.core.api.providers import ProviderSerializer
from authentik.core.models import Application
from authentik.events.models import EventAction
from authentik.policies.engine import PolicyEngine
@ -21,6 +22,7 @@ class ApplicationSerializer(ModelSerializer):
"""Application Serializer"""
launch_url = SerializerMethodField()
provider = ProviderSerializer(source="get_provider")
def get_launch_url(self, instance: Application) -> str:
"""Get generated launch URL"""
@ -48,7 +50,15 @@ class ApplicationViewSet(ModelViewSet):
queryset = Application.objects.all()
serializer_class = ApplicationSerializer
search_fields = [
"name",
"slug",
"meta_launch_url",
"meta_description",
"meta_publisher",
]
lookup_field = "slug"
ordering = ["name"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
@ -63,7 +73,7 @@ class ApplicationViewSet(ModelViewSet):
queryset = self._filter_queryset_for_list(self.get_queryset())
self.paginate_queryset(queryset)
allowed_applications = []
for application in queryset.order_by("name"):
for application in queryset:
engine = PolicyEngine(application, self.request.user, self.request)
engine.build()
if engine.passing:

View file

@ -2,15 +2,16 @@
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer):
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer"""
__type__ = SerializerMethodField(method_name="get_type")
object_type = SerializerMethodField()
def get_type(self, obj):
def get_object_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("provider", "")
@ -29,7 +30,9 @@ class ProviderSerializer(ModelSerializer):
"application",
"authorization_flow",
"property_mappings",
"__type__",
"object_type",
"verbose_name",
"verbose_name_plural",
]

View file

@ -0,0 +1,24 @@
"""API Utilities"""
from django.db.models import Model
from rest_framework.serializers import Serializer, SerializerMethodField
class MetaNameSerializer(Serializer):
"""Add verbose names to response"""
verbose_name = SerializerMethodField()
verbose_name_plural = SerializerMethodField()
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
def get_verbose_name(self, obj: Model) -> str:
"""Return object's verbose_name"""
return obj._meta.verbose_name
def get_verbose_name_plural(self, obj: Model) -> str:
"""Return object's plural verbose_name"""
return obj._meta.verbose_name_plural

View file

@ -2,10 +2,11 @@
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
class OAuth2ProviderSerializer(ModelSerializer):
class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""OAuth2Provider Serializer"""
class Meta:
@ -25,6 +26,8 @@ class OAuth2ProviderSerializer(ModelSerializer):
"redirect_uris",
"sub_mode",
"property_mappings",
"verbose_name",
"verbose_name_plural",
]

View file

@ -6,6 +6,7 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.oauth2.views.provider import ProviderInfoView
from authentik.providers.proxy.models import ProxyProvider
@ -33,7 +34,7 @@ class OpenIDConnectConfigurationSerializer(Serializer):
raise NotImplementedError
class ProxyProviderSerializer(ModelSerializer):
class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
"""ProxyProvider Serializer"""
def create(self, validated_data):
@ -60,6 +61,8 @@ class ProxyProviderSerializer(ModelSerializer):
"basic_auth_enabled",
"basic_auth_password_attribute",
"basic_auth_user_attribute",
"verbose_name",
"verbose_name_plural",
]

View file

@ -2,10 +2,11 @@
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
class SAMLProviderSerializer(ModelSerializer):
class SAMLProviderSerializer(ModelSerializer, MetaNameSerializer):
"""SAMLProvider Serializer"""
class Meta:
@ -25,6 +26,8 @@ class SAMLProviderSerializer(ModelSerializer):
"signature_algorithm",
"signing_kp",
"verification_kp",
"verbose_name",
"verbose_name_plural",
]

View file

@ -148,9 +148,9 @@ class SAMLProvider(Provider):
@property
def serializer(self) -> Type[Serializer]:
from authentik.providers.saml.api import SAMLPropertyMappingSerializer
from authentik.providers.saml.api import SAMLProviderSerializer
return SAMLPropertyMappingSerializer
return SAMLProviderSerializer
@property
def form(self) -> Type[ModelForm]:

View file

@ -6729,11 +6729,55 @@ definitions:
title: Outdated
type: boolean
readOnly: true
Provider:
title: Provider
description: Provider Serializer
required:
- name
- application
- authorization_flow
type: object
properties:
pk:
title: ID
type: integer
readOnly: true
name:
title: Name
type: string
minLength: 1
application:
title: Application
type: string
authorization_flow:
title: Authorization flow
description: Flow used when authorizing this provider.
type: string
format: uuid
property_mappings:
type: array
items:
type: string
format: uuid
uniqueItems: true
object_type:
title: Object type
type: string
readOnly: true
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
Application:
description: Application Serializer
required:
- name
- slug
- provider
type: object
properties:
pk:
@ -6755,9 +6799,7 @@ definitions:
maxLength: 50
minLength: 1
provider:
title: Provider
type: integer
x-nullable: true
$ref: '#/definitions/Provider'
launch_url:
title: Launch url
type: string
@ -7720,40 +7762,6 @@ definitions:
title: Expression
type: string
minLength: 1
Provider:
description: Provider Serializer
required:
- name
- application
- authorization_flow
type: object
properties:
pk:
title: ID
type: integer
readOnly: true
name:
title: Name
type: string
minLength: 1
application:
title: Application
type: string
authorization_flow:
title: Authorization flow
description: Flow used when authorizing this provider.
type: string
format: uuid
property_mappings:
type: array
items:
type: string
format: uuid
uniqueItems: true
__type__:
title: 'type '
type: string
readOnly: true
OAuth2Provider:
description: OAuth2Provider Serializer
required:
@ -7845,6 +7853,14 @@ definitions:
type: string
format: uuid
uniqueItems: true
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
ProxyProvider:
description: ProxyProvider Serializer
required:
@ -7898,6 +7914,14 @@ definitions:
description: User/Group Attribute used for the user part of the HTTP-Basic
Header. If not set, the user's Email address is used.
type: string
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
SAMLProvider:
description: SAMLProvider Serializer
required:
@ -7984,6 +8008,14 @@ definitions:
type: string
format: uuid
x-nullable: true
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
Config:
description: Serialize authentik Config into DRF Object
type: object

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[] {