root: move webapp to /web (#347)

* root: move webapp to /web

* root: fix static build

* root: fix static files not being served for e2e tests
This commit is contained in:
Jens L
2020-11-28 19:43:42 +01:00
committed by GitHub
parent 127ffbd456
commit 9466f91466
71 changed files with 15 additions and 26 deletions

View File

@ -0,0 +1,105 @@
import { getCookie } from "../utils";
import { css, customElement, html, LitElement, property } from "lit-element";
// @ts-ignore
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
// @ts-ignore
import ButtonStyle from "@patternfly/patternfly/components/Button/button.css";
// @ts-ignore
import SpinnerStyle from "@patternfly/patternfly/components/Spinner/spinner.css";
import {
ColorStyles,
ERROR_CLASS,
PRIMARY_CLASS,
PROGRESS_CLASS,
SUCCESS_CLASS,
} from "../constants";
@customElement("pb-action-button")
export class ActionButton extends LitElement {
@property()
url: string = "";
@property()
isRunning = false;
static get styles() {
return [
GlobalsStyle,
ButtonStyle,
SpinnerStyle,
ColorStyles,
css`
button {
/* Have to use !important here, as buttons with pf-m-progress have transition already */
transition: all var(--pf-c-button--m-progress--TransitionDuration) ease 0s !important;
}
`,
];
}
constructor() {
super();
this.classList.add(PRIMARY_CLASS);
}
setLoading() {
this.isRunning = true;
this.classList.add(PROGRESS_CLASS);
this.requestUpdate();
}
setDone(statusClass: string) {
this.isRunning = false;
this.classList.remove(PROGRESS_CLASS);
this.classList.replace(PRIMARY_CLASS, statusClass);
this.requestUpdate();
setTimeout(() => {
this.classList.replace(statusClass, PRIMARY_CLASS);
this.requestUpdate();
}, 1000);
}
callAction() {
if (this.isRunning === true) {
return;
}
this.setLoading();
const csrftoken = getCookie("passbook_csrf");
const request = new Request(this.url, {
headers: { "X-CSRFToken": csrftoken! },
});
fetch(request, {
method: "POST",
mode: "same-origin",
})
.then((r) => r.json())
.then((r) => {
this.setDone(SUCCESS_CLASS);
})
.catch(() => {
this.setDone(ERROR_CLASS);
});
}
render() {
return html`<button
class="pf-c-button pf-m-progress ${this.classList.toString()}"
@click=${() => this.callAction()}
>
${this.isRunning
? html` <span class="pf-c-button__progress">
<span
class="pf-c-spinner pf-m-md"
role="progressbar"
aria-valuetext="Loading..."
>
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</span>`
: ""}
<slot></slot>
</button>`;
}
}

View File

@ -0,0 +1,108 @@
import { css, customElement, html, LitElement, property } from "lit-element";
import Chart from "chart.js";
interface TickValue {
value: number;
major: boolean;
}
@customElement("pb-admin-logins-chart")
export class AdminLoginsChart extends LitElement {
@property()
url: string = "";
chart: any;
static get styles() {
return css`
:host {
position: relative;
height: 100%;
width: 100%;
display: block;
min-height: 25rem;
}
canvas {
width: 100px;
height: 100px;
}
`;
}
constructor() {
super();
window.addEventListener("resize", () => {
if (this.chart) {
this.chart.resize();
}
});
}
firstUpdated() {
fetch(this.url)
.then((r) => r.json())
.catch((e) => console.error(e))
.then((r) => {
let ctx = (<HTMLCanvasElement>this.shadowRoot?.querySelector("canvas")).getContext(
"2d"
)!;
this.chart = new Chart(ctx, {
type: "bar",
data: {
datasets: [
{
label: "Failed Logins",
backgroundColor: "rgba(201, 25, 11, .5)",
spanGaps: true,
data: r.logins_failed_per_1h,
},
{
label: "Successful Logins",
backgroundColor: "rgba(189, 229, 184, .5)",
spanGaps: true,
data: r.logins_per_1h,
},
],
},
options: {
maintainAspectRatio: false,
spanGaps: true,
scales: {
xAxes: [
{
stacked: true,
gridLines: {
color: "rgba(0, 0, 0, 0)",
},
type: "time",
offset: true,
ticks: {
callback: function (value, index: number, values) {
const valueStamp = <TickValue>(<unknown>values[index]);
const delta = Date.now() - valueStamp.value;
const ago = Math.round(delta / 1000 / 3600);
return `${ago} Hours ago`;
},
autoSkip: true,
maxTicksLimit: 8,
},
},
],
yAxes: [
{
stacked: true,
gridLines: {
color: "rgba(0, 0, 0, 0)",
},
},
],
},
},
});
});
}
render() {
return html`<canvas></canvas>`;
}
}

View File

@ -0,0 +1,40 @@
import { customElement, html, LitElement, property } from "lit-element";
// @ts-ignore
import CodeMirror from "codemirror";
import "codemirror/addon/display/autorefresh";
import "codemirror/mode/xml/xml.js";
import "codemirror/mode/yaml/yaml.js";
import "codemirror/mode/python/python.js";
@customElement("pb-codemirror")
export class CodeMirrorTextarea extends LitElement {
@property()
readOnly: boolean = false;
@property()
mode: string = "yaml";
editor?: CodeMirror.EditorFromTextArea;
createRenderRoot() {
return this;
}
firstUpdated() {
const textarea = this.querySelector("textarea");
if (!textarea) {
return;
}
this.editor = CodeMirror.fromTextArea(textarea, {
mode: this.mode,
theme: "monokai",
lineNumbers: false,
readOnly: this.readOnly,
autoRefresh: true,
});
this.editor.on("blur", (e) => {
this.editor?.save();
});
}
}

View File

@ -0,0 +1,18 @@
import { customElement, html, LitElement } from "lit-element";
@customElement("pb-dropdown")
export class DropdownButton extends LitElement {
constructor() {
super();
const menu = <HTMLElement>this.querySelector(".pf-c-dropdown__menu")!;
this.querySelectorAll("button.pf-c-dropdown__toggle").forEach((btn) => {
btn.addEventListener("click", (e) => {
menu.hidden = !menu.hidden;
});
});
}
render() {
return html`<slot></slot>`;
}
}

View File

@ -0,0 +1,91 @@
import { LitElement, html, customElement, property } from "lit-element";
interface ComparisonHash {
[key: string]: (a: any, b: any) => boolean;
}
@customElement("fetch-fill-slot")
export class FetchFillSlot extends LitElement {
@property()
url: string = "";
@property()
key: string = "";
@property()
value: string = "";
comparison(slotName: string) {
let comparisonOperatorsHash = <ComparisonHash>{
"<": function (a: any, b: any) {
return a < b;
},
">": function (a: any, b: any) {
return a > b;
},
">=": function (a: any, b: any) {
return a >= b;
},
"<=": function (a: any, b: any) {
return a <= b;
},
"==": function (a: any, b: any) {
return a == b;
},
"!=": function (a: any, b: any) {
return a != b;
},
"===": function (a: any, b: any) {
return a === b;
},
"!==": function (a: any, b: any) {
return a !== b;
},
};
const tokens = slotName.split(" ");
if (tokens.length < 3) {
throw new Error("nah");
}
let a: any = tokens[0];
if (a === "value") {
a = this.value;
} else {
a = parseInt(a, 10);
}
let b: any = tokens[2];
if (b === "value") {
b = this.value;
} else {
b = parseInt(b, 10);
}
const comp = tokens[1];
if (!(comp in comparisonOperatorsHash)) {
throw new Error("Invalid comparison");
}
return comparisonOperatorsHash[comp](a, b);
}
firstUpdated() {
fetch(this.url)
.then((r) => r.json())
.then((r) => r[this.key])
.then((r) => (this.value = r));
}
render() {
if (this.value === undefined) {
return html`<slot></slot>`;
}
let selectedSlot = "";
this.querySelectorAll("[slot]").forEach((slot) => {
const comp = slot.getAttribute("slot")!;
if (this.comparison(comp)) {
selectedSlot = comp;
}
});
this.querySelectorAll("[data-value]").forEach((dv) => {
dv.textContent = this.value;
});
return html`<slot name=${selectedSlot}></slot>`;
}
}

View File

@ -0,0 +1,115 @@
import { LitElement, html, customElement, property } from "lit-element";
const LEVEL_ICON_MAP: { [key: string]: string } = {
error: "fas fa-exclamation-circle",
warning: "fas fa-exclamation-triangle",
success: "fas fa-check-circle",
info: "fas fa-info",
};
let ID = function (prefix: string) {
return prefix + Math.random().toString(36).substr(2, 9);
};
interface Message {
levelTag: string;
tags: string;
message: string;
}
@customElement("pb-messages")
export class Messages extends LitElement {
@property()
url: string = "";
messageSocket?: WebSocket;
retryDelay: number = 200;
createRenderRoot() {
return this;
}
constructor() {
super();
try {
this.connect();
} catch (error) {
console.warn(`passbook/messages: failed to connect to ws ${error}`);
}
}
firstUpdated() {
this.fetchMessages();
}
connect() {
const wsUrl = `${window.location.protocol.replace("http", "ws")}//${
window.location.host
}/ws/client/`;
this.messageSocket = new WebSocket(wsUrl);
this.messageSocket.addEventListener("open", (e) => {
console.debug(`passbook/messages: connected to ${wsUrl}`);
});
this.messageSocket.addEventListener("close", (e) => {
console.debug(`passbook/messages: closed ws connection: ${e}`);
setTimeout(() => {
console.debug(`passbook/messages: reconnecting ws in ${this.retryDelay}ms`);
this.connect();
}, this.retryDelay);
this.retryDelay = this.retryDelay * 2;
});
this.messageSocket.addEventListener("message", (e) => {
const data = JSON.parse(e.data);
this.renderMessage(data);
});
this.messageSocket.addEventListener("error", (e) => {
console.warn(`passbook/messages: error ${e}`);
this.retryDelay = this.retryDelay * 2;
});
}
/* Fetch messages which were stored in the session.
* This mostly gets messages which were created when the user arrives/leaves the site
* and especially the login flow */
fetchMessages() {
console.debug(`passbook/messages: fetching messages over direct api`);
return fetch(this.url)
.then((r) => r.json())
.then((r) => {
r.forEach((m: any) => {
const message = <Message>{
levelTag: m.level_tag,
tags: m.tags,
message: m.message,
};
this.renderMessage(message);
});
});
}
renderMessage(message: Message) {
const container = <HTMLElement>this.querySelector(".pf-c-alert-group")!;
const id = ID("pb-message");
const el = document.createElement("template");
el.innerHTML = `<li id=${id} class="pf-c-alert-group__item">
<div class="pf-c-alert pf-m-${message.levelTag} ${
message.levelTag === "error" ? "pf-m-danger" : ""
}">
<div class="pf-c-alert__icon">
<i class="${LEVEL_ICON_MAP[message.levelTag]}"></i>
</div>
<p class="pf-c-alert__title">
${message.message}
</p>
</div>
</li>`;
setTimeout(() => {
this.querySelector(`#${id}`)?.remove();
}, 1500);
container.appendChild(el.content.firstChild!);
}
render() {
return html`<ul class="pf-c-alert-group pf-m-toast"></ul>`;
}
}

View File

@ -0,0 +1,157 @@
import { css, customElement, html, LitElement, property } from "lit-element";
// @ts-ignore
import ModalBoxStyle from "@patternfly/patternfly/components/ModalBox/modal-box.css";
// @ts-ignore
import BullseyeStyle from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
// @ts-ignore
import BackdropStyle from "@patternfly/patternfly/components/Backdrop/backdrop.css";
// @ts-ignore
import ButtonStyle from "@patternfly/patternfly/components/Button/button.css";
// @ts-ignore
import fa from "@fortawesome/fontawesome-free/css/solid.css";
import { convertToSlug } from "../utils";
@customElement("pb-modal-button")
export class ModalButton extends LitElement {
@property()
href?: string;
@property()
open: boolean = false;
static get styles() {
return [
css`
:host {
text-align: left;
}
::slotted(*) {
overflow-y: auto;
}
`,
ModalBoxStyle,
BullseyeStyle,
BackdropStyle,
ButtonStyle,
fa,
];
}
constructor() {
super();
window.addEventListener("keyup", (e) => {
if (e.code === "Escape") {
this.open = false;
}
});
}
updateHandlers() {
// Ensure links close the modal
this.querySelectorAll<HTMLAnchorElement>("[slot=modal] a").forEach((a) => {
// Make click on a close the modal
a.addEventListener("click", (e) => {
e.preventDefault();
this.open = false;
});
});
// Make name field update slug field
this.querySelectorAll<HTMLInputElement>("input[name=name]").forEach((input) => {
input.addEventListener("input", (e) => {
const form = input.closest("form");
if (form === null) {
return;
}
const slugField = form.querySelector<HTMLInputElement>("input[name=slug]");
if (!slugField) {
return;
}
slugField.value = convertToSlug(input.value);
});
});
// Ensure forms sends in AJAX
this.querySelectorAll<HTMLFormElement>("[slot=modal] form").forEach((form) => {
form.addEventListener("submit", (e) => {
e.preventDefault();
let formData = new FormData(form);
fetch(this.href ? this.href : form.action, {
method: form.method,
body: formData,
redirect: "manual",
})
.then((response) => {
return response.text();
})
.then((data) => {
if (data.indexOf("csrfmiddlewaretoken") !== -1) {
this.querySelector("[slot=modal]")!.innerHTML = data;
console.debug(`passbook/modalbutton: re-showing form`);
this.updateHandlers();
} else {
this.open = false;
console.debug(`passbook/modalbutton: successful submit`);
this.dispatchEvent(
new CustomEvent("hashchange", {
bubbles: true,
})
);
}
})
.catch((e) => {
console.error(e);
});
});
});
}
onClick(e: MouseEvent) {
if (!this.href) {
this.updateHandlers();
this.open = true;
} else {
const request = new Request(this.href);
fetch(request, {
mode: "same-origin",
})
.then((r) => r.text())
.then((t) => {
this.querySelector("[slot=modal]")!.innerHTML = t;
this.updateHandlers();
this.open = true;
})
.catch((e) => {
console.error(e);
});
}
}
renderModal() {
return html`<div class="pf-c-backdrop">
<div class="pf-l-bullseye">
<div
class="pf-c-modal-box pf-m-lg"
role="dialog"
aria-modal="true"
aria-labelledby="modal-md-title"
aria-describedby="modal-md-description"
>
<button
@click=${() => (this.open = false)}
class="pf-c-button pf-m-plain"
type="button"
aria-label="Close dialog"
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<slot name="modal"> </slot>
</div>
</div>
</div>`;
}
render() {
return html` <slot name="trigger" @click=${(e: any) => this.onClick(e)}></slot>
${this.open ? this.renderModal() : ""}`;
}
}

212
web/src/elements/Sidebar.ts Normal file
View File

@ -0,0 +1,212 @@
import { css, customElement, html, LitElement, property, TemplateResult } from "lit-element";
// @ts-ignore
import PageStyle from "@patternfly/patternfly/components/Page/page.css";
// @ts-ignore
import NavStyle from "@patternfly/patternfly/components/Nav/nav.css";
// @ts-ignore
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
import { User } from "../api/user";
export interface SidebarItem {
name: string;
path?: string[];
children?: SidebarItem[];
condition?: (sb: Sidebar) => boolean;
}
export const SIDEBAR_ITEMS: SidebarItem[] = [
{
name: "Library",
path: ["/-/overview/"],
},
{
name: "Monitor",
path: ["/audit/audit/"],
condition: (sb: Sidebar) => {
return sb.user?.is_superuser!;
},
},
{
name: "Administration",
children: [
{
name: "Overview",
path: ["/administration/overview/"],
},
{
name: "System Tasks",
path: ["/administration/tasks/"],
},
{
name: "Applications",
path: ["/administration/applications/"],
},
{
name: "Sources",
path: ["/administration/sources/"],
},
{
name: "Providers",
path: ["/administration/providers/"],
},
{
name: "User Management",
children: [
{
name: "User",
path: ["/administration/users/"],
},
{
name: "Groups",
path: ["/administration/groups/"],
},
],
},
{
name: "Outposts",
children: [
{
name: "Outposts",
path: ["/administration/outposts/"],
},
{
name: "Service Connections",
path: ["/administration/outposts/service_connections/"],
},
],
},
{
name: "Policies",
path: ["/administration/policies/"],
},
{
name: "Property Mappings",
path: ["/administration/property-mappings/"],
},
{
name: "Flows",
children: [
{
name: "Flows",
path: ["/administration/flows/"],
},
{
name: "Stages",
path: ["/administration/stages/"],
},
{
name: "Prompts",
path: ["/administration/stages/prompts/"],
},
{
name: "Invitations",
path: ["/administration/stages/invitations/"],
},
],
},
{
name: "Certificates",
path: ["/administration/crypto/certificates/"],
},
{
name: "Tokens",
path: ["/administration/tokens/"],
},
],
condition: (sb: Sidebar) => {
return sb.user?.is_superuser!;
},
},
];
@customElement("pb-sidebar")
export class Sidebar extends LitElement {
@property()
activePath: string;
@property()
user?: User;
static get styles() {
return [
GlobalsStyle,
PageStyle,
NavStyle,
css`
.pf-c-nav__link {
--pf-c-nav__link--PaddingTop: 0.5rem;
--pf-c-nav__link--PaddingRight: 0.5rem;
--pf-c-nav__link--PaddingBottom: 0.5rem;
}
.pf-c-nav__subnav {
--pf-c-nav__subnav--PaddingBottom: 0px;
}
.pf-c-nav__item-bottom {
position: absolute;
bottom: 0;
width: 100%;
}
`,
];
}
constructor() {
super();
User.me().then((u) => (this.user = u));
this.activePath = window.location.hash.slice(1, Infinity);
window.addEventListener("hashchange", (e) => {
this.activePath = window.location.hash.slice(1, Infinity);
});
}
renderItem(item: SidebarItem): TemplateResult {
if (item.condition) {
const result = item.condition(this);
if (!result) {
return html``;
}
}
return html` <li
class="pf-c-nav__item ${item.children ? "pf-m-expandable pf-m-expanded" : ""}"
>
${item.path
? html`<a
href="#${item.path}"
class="pf-c-nav__link ${item.path.some((v) => v === this.activePath)
? "pf-m-current"
: ""}"
>
${item.name}
</a>`
: html`<a class="pf-c-nav__link" aria-expanded="true"
>${item.name}
<span class="pf-c-nav__toggle">
<i class="fas fa-angle-right" aria-hidden="true"></i>
</span>
</a>
<section class="pf-c-nav__subnav">
<ul class="pf-c-nav__simple-list">
${item.children?.map((i) => this.renderItem(i))}
</ul>
</section>`}
</li>`;
}
render() {
return html`<div class="pf-c-page__sidebar-body">
<nav class="pf-c-nav" aria-label="Global">
<ul class="pf-c-nav__list">
<li class="pf-c-nav__item">
<pb-sidebar-brand></pb-sidebar-brand>
</li>
${SIDEBAR_ITEMS.map((i) => this.renderItem(i))}
<li class="pf-c-nav__item pf-c-nav__item-bottom">
<pb-sidebar-user .user=${this.user}></pb-sidebar-user>
</li>
</ul>
</nav>
</div>`;
}
}

View File

@ -0,0 +1,56 @@
import { css, customElement, html, LitElement, property } from "lit-element";
// @ts-ignore
import PageStyle from "@patternfly/patternfly/components/Page/page.css";
// @ts-ignore
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
import { Config } from "../api/config";
@customElement("pb-sidebar-brand")
export class SidebarBrand extends LitElement {
@property()
config?: Config;
static get styles() {
return [
GlobalsStyle,
PageStyle,
css`
.pf-c-brand {
font-family: "DIN 1451 Std";
line-height: 60px;
font-size: 3rem;
color: var(--pf-c-nav__link--m-current--Color);
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
margin: 0 1rem;
margin-bottom: 1.5rem;
}
.pf-c-brand img {
max-height: 60px;
margin-right: 8px;
}
`,
];
}
constructor() {
super();
Config.get().then((c) => (this.config = c));
}
render() {
if (!this.config) {
return html``;
}
return html` <a href="" class="pf-c-page__header-brand-link">
<div class="pf-c-brand pb-brand">
<img src="${this.config?.branding_logo}" alt="passbook icon" loading="lazy" />
${this.config?.branding_title
? html`<span>${this.config.branding_title}</span>`
: ""}
</div>
</a>`;
}
}

View File

@ -0,0 +1,61 @@
import { css, customElement, html, LitElement, property } from "lit-element";
// @ts-ignore
import NavStyle from "@patternfly/patternfly/components/Nav/nav.css";
// @ts-ignore
import fa from "@fortawesome/fontawesome-free/css/all.css";
// @ts-ignore
import AvatarStyle from "@patternfly/patternfly/components/Avatar/avatar.css";
import { User } from "../api/user";
@customElement("pb-sidebar-user")
export class SidebarUser extends LitElement {
@property()
user?: User;
static get styles() {
return [
fa,
NavStyle,
AvatarStyle,
css`
:host {
display: flex;
width: 100%;
flex-direction: row;
justify-content: space-between;
}
.pf-c-nav__link {
align-items: center;
}
.user-avatar {
display: flex;
flex-direction: row;
}
.user-avatar > span {
line-height: var(--pf-c-avatar--Height);
padding-left: var(--pf-global--spacer--sm);
font-size: var(--pf-global--FontSize--lg);
}
.user-logout {
flex-shrink: 3;
max-width: 75px;
}
`,
];
}
render() {
if (!this.user) {
return html``;
}
return html`
<a href="#/-/user/" class="pf-c-nav__link user-avatar" id="user-settings">
<img class="pf-c-avatar" src="${this.user?.avatar}" alt="" />
<span>${this.user?.username}</span>
</a>
<a href="/flows/-/default/invalidation/" class="pf-c-nav__link user-logout" id="logout">
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
</a>
`;
}
}

58
web/src/elements/Table.ts Normal file
View File

@ -0,0 +1,58 @@
import { css, html, LitElement, TemplateResult } from "lit-element";
import { until } from "lit-html/directives/until.js";
import { DefaultClient, PBResponse } from "../api/client";
export abstract class Table extends LitElement {
abstract apiEndpoint(): string[];
abstract columns(): Array<string>;
abstract row(item: any): Array<TemplateResult>;
private data: PBResponse = <PBResponse>{};
public static get styles() {
return css`
table {
width: 100%;
}
table,
tr,
td {
border: 1px inset white;
border-collapse: collapse;
}
td,
th {
padding: 0.5rem;
}
td:hover {
border: 1px solid red;
}
`;
}
private renderRows() {
return DefaultClient.fetch<PBResponse>(...this.apiEndpoint())
.then((r) => (this.data = r))
.then(() => {
return this.data.results.map((item) => {
return this.row(item).map((col) => {
// let t = <TemplateStringsArray>[];
return col;
});
});
});
}
render() {
return html`<table>
<thead>
<tr>
${this.columns().map((col) => html`<th>${col}</th>`)}
</tr>
</thead>
<tbody>
${until(this.renderRows(), html`<tr><td>loading...</tr></td>`)}
</tbody>
</table>`;
}
}

50
web/src/elements/Tabs.ts Normal file
View File

@ -0,0 +1,50 @@
import { LitElement, html, customElement, property } from "lit-element";
// @ts-ignore
import TabsStyle from "@patternfly/patternfly/components/Tabs/tabs.css";
// @ts-ignore
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
import { CURRENT_CLASS } from "../constants";
@customElement("pb-tabs")
export class Tabs extends LitElement {
@property()
currentPage?: string;
static get styles() {
return [GlobalsStyle, TabsStyle];
}
render() {
let pages = Array.from(this.querySelectorAll("[slot]")!);
if (!this.currentPage) {
if (pages.length < 1) {
return html`<h1>no tabs defined</h1>`;
}
this.currentPage = pages[0].attributes.getNamedItem("slot")?.value;
}
return html`<div class="pf-c-tabs">
<ul class="pf-c-tabs__list">
${pages.map((page) => {
const slot = page.attributes.getNamedItem("slot")?.value;
return html` <li
class="pf-c-tabs__item ${slot === this.currentPage
? CURRENT_CLASS
: ""}"
>
<button
class="pf-c-tabs__link"
@click=${() => {
this.currentPage = slot;
}}
>
<span class="pf-c-tabs__item-text">
${page.attributes.getNamedItem("tab-title")?.value}
</span>
</button>
</li>`;
})}
</ul>
</div>
<slot name="${this.currentPage}"></slot>`;
}
}

View File

@ -0,0 +1,53 @@
import { css, customElement, html, LitElement, property } from "lit-element";
// @ts-ignore
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
// @ts-ignore
import ButtonStyle from "@patternfly/patternfly/components/Button/button.css";
import { tokenByIdentifier } from "../api/token";
import { ColorStyles, ERROR_CLASS, PRIMARY_CLASS, SUCCESS_CLASS } from "../constants";
@customElement("pb-token-copy-button")
export class TokenCopyButton extends LitElement {
@property()
identifier?: string;
@property()
buttonClass: string = PRIMARY_CLASS;
static get styles() {
return [
GlobalsStyle,
ButtonStyle,
ColorStyles,
css`
button {
transition: background-color 0.3s ease 0s;
}
`,
];
}
onClick() {
if (!this.identifier) {
this.buttonClass = ERROR_CLASS;
setTimeout(() => {
this.buttonClass = PRIMARY_CLASS;
}, 1500);
return;
}
tokenByIdentifier(this.identifier).then((token) => {
navigator.clipboard.writeText(token).then(() => {
this.buttonClass = SUCCESS_CLASS;
setTimeout(() => {
this.buttonClass = PRIMARY_CLASS;
}, 1500);
});
});
}
render() {
return html`<button @click=${() => this.onClick()} class="pf-c-button ${this.buttonClass}">
<slot></slot>
</button>`;
}
}