diff --git a/web/packages/core/id/index.js b/web/packages/core/id/index.js new file mode 100644 index 0000000000..d8475bb377 --- /dev/null +++ b/web/packages/core/id/index.js @@ -0,0 +1,50 @@ +/** + * @file Unique ID utilities. + */ + +/** + * A global ID generator. + * + * @singleton + * @runtime common + * + * @category IDs + */ +export class IDGenerator { + static #sequenceIndex = 0; + static #elementIndex = 0; + + /** + * Create a new ID for an HTML element. + * + * This ID will be unique for the lifetime of the page and will not be + * exposed on the `window` object. + * + * @param {string | number} [name] An optional name to use for the element. + */ + static elementID(name) { + name = name || ++this.#elementIndex; + + return "«ak-" + name + "»"; + } + + /** + * Create a new ID. + */ + static next() { + this.#sequenceIndex += 1; + + return this.#sequenceIndex; + } + + /** + * Generate a random ID in hexadecimal format. + * + * @param {number} [characterLength] + */ + static randomID(characterLength = 6) { + const bytes = crypto.getRandomValues(new Uint8Array(characterLength / 2)); + + return Array.from(bytes, (a) => a.toString(16)).join(""); + } +} diff --git a/web/packages/core/promises/index.js b/web/packages/core/promises/index.js new file mode 100644 index 0000000000..3bc2308e24 --- /dev/null +++ b/web/packages/core/promises/index.js @@ -0,0 +1,27 @@ +/** + * @file Helpers for running tests. + */ + +/** + * A function that returns a promise. + * @template {never[]} [A=never[]] + * @typedef {(...args: A) => Promise} Thenable + */ + +/** + * A tuple of a function and its arguments. + * @template {Thenable} [T=Thenable] + * @typedef {[T, Parameters]} SerializedThenable + */ + +/** + * Executes a sequence of promise-returning functions in series + * @template {Thenable[]} T + * @param {{ [K in keyof T]: [T[K], ...Parameters] }} sequence + * @returns {Promise} + */ +export async function series(...sequence) { + for (const [thenable, ...args] of sequence) { + await thenable(...args); + } +} diff --git a/web/src/admin/AdminInterface/AboutModal.ts b/web/src/admin/AdminInterface/AboutModal.ts index b9a82840b2..111ecac9aa 100644 --- a/web/src/admin/AdminInterface/AboutModal.ts +++ b/web/src/admin/AdminInterface/AboutModal.ts @@ -12,6 +12,7 @@ import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthent import { msg } from "@lit/localize"; import { css, html, TemplateResult } from "lit"; import { customElement } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; import { until } from "lit/directives/until.js"; import PFAbout from "@patternfly/patternfly/components/AboutModalBox/about-modal-box.css"; @@ -56,21 +57,32 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) ]; } - renderModal() { + #contentRef = createRef(); + + #backdropListener = (event: PointerEvent) => { + // We only want to close the modal when the backdrop is clicked, not when it's children are clicked. + + if (this.#contentRef.value?.contains(event.target as Node)) { + return; + } + this.close(); + }; + + protected override renderModal() { let product = this.brandingTitle; if (this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed) { product += ` ${msg("Enterprise")}`; } - return html`
{ - e.stopPropagation(); - this.closeModal(); - }} - > + return html`
-
+
-
-

${product}

+

${product}

diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index cdc301905d..c68d977df3 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -1,9 +1,12 @@ import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "#elements/router/Route"; +import { SidebarItemProperties } from "#elements/sidebar/SidebarItem"; +import { LitPropertyRecord } from "#elements/types"; import { spread } from "@open-wc/lit-helpers"; import { msg } from "@lit/localize"; import { html, nothing, TemplateResult } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; import { repeat } from "lit/directives/repeat.js"; // The second attribute type is of string[] to help with the 'activeWhen' control, which was @@ -11,7 +14,7 @@ import { repeat } from "lit/directives/repeat.js"; type SidebarEntry = [ path: string | null, label: string, - attributes?: Record | string[] | null, // eslint-disable-line + attributes?: LitPropertyRecord | string[] | null, children?: SidebarEntry[], ]; @@ -32,8 +35,7 @@ export function renderSidebarItem([ properties.path = path; } - return html` - ${label ? html`${label}` : nothing} + return html` ${children ? renderSidebarItems(children) : nothing} `; } diff --git a/web/src/admin/AdminInterface/index.entrypoint.ts b/web/src/admin/AdminInterface/index.entrypoint.ts index afef379a6a..175f6747cd 100644 --- a/web/src/admin/AdminInterface/index.entrypoint.ts +++ b/web/src/admin/AdminInterface/index.entrypoint.ts @@ -31,6 +31,7 @@ import { ROUTES } from "#admin/Routes"; import { CapabilitiesEnum, SessionUser, UiThemeEnum } from "@goauthentik/api"; +import { msg } from "@lit/localize"; import { css, CSSResult, html, nothing, TemplateResult } from "lit"; import { customElement, eventOptions, property, query } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; @@ -163,16 +164,18 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac } async firstUpdated(): Promise { - this.user = await me(); + me().then((session) => { + this.user = session; - const canAccessAdmin = - this.user.user.isSuperuser || - // TODO: somehow add `access_admin_interface` to the API schema - this.user.user.systemPermissions.includes("access_admin_interface"); + const canAccessAdmin = + this.user.user.isSuperuser || + // TODO: somehow add `access_admin_interface` to the API schema + this.user.user.systemPermissions.includes("access_admin_interface"); - if (!canAccessAdmin && this.user.user.pk > 0) { - window.location.assign("/if/user/"); - } + if (!canAccessAdmin && this.user.user.pk > 0) { + window.location.assign("/if/user/"); + } + }); } render(): TemplateResult { @@ -191,13 +194,14 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac }; return html` +
- + ${renderSidebarItems(AdminSidebarEntries)} ${this.can(CapabilitiesEnum.IsEnterprise) ? renderSidebarItems(AdminSidebarEnterpriseEntries) @@ -209,9 +213,10 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
-
+
-
+
extends AggregateCard { * * @returns TemplateResult displaying the value */ - protected renderValue(): TemplateResult { - return html`${this.value}`; + protected renderValue(): SlottedTemplateResult { + return this.value ? html`${this.value}` : nothing; } /** @@ -105,7 +106,7 @@ export abstract class AdminStatusCard extends AggregateCard { * @param status - AdminStatus object containing icon and message * @returns TemplateResult for status display */ - private renderStatus(status: AdminStatus): TemplateResult { + private renderStatus(status: AdminStatus): SlottedTemplateResult { return html`

 ${this.renderValue()}

${status.message ? html`

${status.message}

` : nothing} @@ -118,9 +119,9 @@ export abstract class AdminStatusCard extends AggregateCard { * @param error - Error message to display * @returns TemplateResult for error display */ - private renderError(error: string): TemplateResult { + private renderError(error: string): SlottedTemplateResult { return html` -

 ${msg("Failed to fetch")}

+

 ${msg("Failed to fetch")}

${error}

`; } @@ -130,7 +131,7 @@ export abstract class AdminStatusCard extends AggregateCard { * * @returns TemplateResult for loading spinner */ - private renderLoading(): TemplateResult { + private renderLoading(): SlottedTemplateResult { return html``; } @@ -139,7 +140,7 @@ export abstract class AdminStatusCard extends AggregateCard { * * @returns TemplateResult for current component state */ - renderInner(): TemplateResult { + renderInner(): SlottedTemplateResult { return html`

${ diff --git a/web/src/admin/admin-overview/cards/SystemStatusCard.ts b/web/src/admin/admin-overview/cards/SystemStatusCard.ts index 61a8951fd7..fdab03d544 100644 --- a/web/src/admin/admin-overview/cards/SystemStatusCard.ts +++ b/web/src/admin/admin-overview/cards/SystemStatusCard.ts @@ -1,11 +1,13 @@ import { DEFAULT_CONFIG } from "#common/api/config"; +import { SlottedTemplateResult } from "#elements/types"; + import { AdminStatus, AdminStatusCard } from "#admin/admin-overview/cards/AdminStatusCard"; import { AdminApi, OutpostsApi, SystemInfo } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { html, TemplateResult } from "lit"; +import { html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; @customElement("ak-admin-status-system") @@ -82,12 +84,12 @@ export class SystemStatusCard extends AdminStatusCard { }); } - renderHeader(): TemplateResult { - return html`${msg("System status")}`; + renderHeader(): SlottedTemplateResult { + return msg("System status"); } - renderValue(): TemplateResult { - return html`${this.statusSummary}`; + renderValue(): SlottedTemplateResult { + return this.statusSummary ? html`${this.statusSummary}` : nothing; } } diff --git a/web/src/admin/applications/ApplicationListPage.ts b/web/src/admin/applications/ApplicationListPage.ts index a6ee6a5e1a..4e61725abc 100644 --- a/web/src/admin/applications/ApplicationListPage.ts +++ b/web/src/admin/applications/ApplicationListPage.ts @@ -84,7 +84,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage) ]; } - renderSidebarAfter(): TemplateResult { + protected renderSidebarAfter(): TemplateResult { return html`

diff --git a/web/src/admin/rbac/InitialPermissionsListPage.ts b/web/src/admin/rbac/InitialPermissionsListPage.ts index de034077b6..2268873469 100644 --- a/web/src/admin/rbac/InitialPermissionsListPage.ts +++ b/web/src/admin/rbac/InitialPermissionsListPage.ts @@ -12,7 +12,7 @@ import { TablePage } from "#elements/table/TablePage"; import { InitialPermissions, RbacApi } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { html, TemplateResult } from "lit"; +import { html, HTMLTemplateResult, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -68,7 +68,7 @@ export class InitialPermissionsListPage extends TablePage { `; } - render(): TemplateResult { + render(): HTMLTemplateResult { return html` { `; } - render(): TemplateResult { + render(): HTMLTemplateResult { return html` { `; } - render(): TemplateResult { + render(): HTMLTemplateResult { return html`
- +
-

+

`; } } @@ -103,6 +104,6 @@ export function akAlert(properties: IAlert, content: SlottedTemplateResult = not declare global { interface HTMLElementTagNameMap { - "ak-alert": Alert; + "ak-alert": AKAlert; } } diff --git a/web/src/elements/a11y/ak-skip-to-content.ts b/web/src/elements/a11y/ak-skip-to-content.ts new file mode 100644 index 0000000000..b93a5073c2 --- /dev/null +++ b/web/src/elements/a11y/ak-skip-to-content.ts @@ -0,0 +1,75 @@ +import { AKElement } from "#elements/Base"; + +import { msg } from "@lit/localize"; +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +@customElement("ak-skip-to-content") +export class AKSkipToContent extends AKElement { + static styles = [ + PFBase, + css` + .show-on-focus:not(:focus) { + width: 1px !important; + height: 1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(1px, 1px, 1px, 1px) !important; + border: 0 !important; + } + + .show-on-focus { + position: absolute !important; + } + + .skip-to-content { + z-index: 99999; + } + + button { + color: var(--ak-dark-foreground); + z-index: 99999; + background-color: var(--ak-accent); + font-family: var(--pf-global--FontFamily--heading--sans-serif); + padding: var(--pf-global--spacer--md); + } + `, + ]; + + @property({ type: String }) + public flowTo: string = "main-content"; + + #skipToContent = () => { + const element = + this.parentElement?.querySelector(`#${this.flowTo}`) || + document.getElementById(this.flowTo); + + if (!element) { + console.warn(`Could not find element with ID "${this.flowTo}"`); + return; + } + + element.scrollIntoView(); + element.focus?.(); + }; + + render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-skip-to-content": AKSkipToContent; + } +} diff --git a/web/src/elements/buttons/ModalButton.ts b/web/src/elements/buttons/ModalButton.ts index f1c068fff8..aa57346238 100644 --- a/web/src/elements/buttons/ModalButton.ts +++ b/web/src/elements/buttons/ModalButton.ts @@ -1,3 +1,5 @@ +import { SlottedTemplateResult } from "../types.js"; + import { PFSize } from "#common/enums"; import { AKElement } from "#elements/Base"; @@ -35,21 +37,15 @@ export const MODAL_BUTTON_STYLES = css` `; @customElement("ak-modal-button") -export class ModalButton extends AKElement { - //#region Properties - +export abstract class ModalButton extends AKElement { @property() - size: PFSize = PFSize.Large; + public size: PFSize = PFSize.Large; @property({ type: Boolean }) - open = false; + public open = false; @property({ type: Boolean }) - locked = false; - - //#endregion - - handlerBound = false; + public locked = false; static styles: CSSResult[] = [ PFBase, @@ -74,50 +70,63 @@ export class ModalButton extends AKElement { `, ]; - closeModal() { - this.resetForms(); - this.open = false; - } - - resetForms(): void { - for (const form of this.querySelectorAll
("[slot=form]")) { + public resetForms(): void { + this.querySelectorAll("[slot=form]").forEach((form) => { form.reset?.(); - } - } - - onClick(): void { - this.open = true; - this.dispatchEvent(new ModalShowEvent(this)); - this.querySelectorAll("*").forEach((child) => { - if ("requestUpdate" in child) { - (child as AKElement).requestUpdate(); - } }); } - //#region Render + /** + * Close the modal. + */ + public close = () => { + this.resetForms(); + this.open = false; + }; - renderModalInner(): TemplateResult | typeof nothing { + /** + * Show the modal. + */ + public show = (): void => { + this.open = true; + + this.dispatchEvent(new ModalShowEvent(this)); + + this.querySelectorAll("*").forEach((child) => { + child.requestUpdate?.(); + }); + }; + + #closeListener = () => { + this.dispatchEvent(new ModalHideEvent(this)); + }; + + #backdropListener = (event: PointerEvent) => { + event.stopPropagation(); + }; + + /** + * @abstract + */ + protected renderModalInner(): SlottedTemplateResult { return html``; } - renderModal(): TemplateResult { - return html`
{ - e.stopPropagation(); - }} - > + /** + * @abstract + */ + protected renderModal(): SlottedTemplateResult { + return html`
diff --git a/web/src/elements/messages/MessageContainer.ts b/web/src/elements/messages/MessageContainer.ts index 808c2caa4e..e91ce9cfb2 100644 --- a/web/src/elements/messages/MessageContainer.ts +++ b/web/src/elements/messages/MessageContainer.ts @@ -12,16 +12,20 @@ import { APIMessage } from "#elements/messages/Message"; import { instanceOfValidationError } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { css, CSSResult, html, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { css, CSSResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-group.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; /** * Adds a message to the message container, displaying it to the user. + * * @param message The message to display. * @param unique Whether to only display the message if the title is unique. + * + * @todo Consider making this a static method on singleton {@linkcode MessageContainer} */ export function showMessage(message: APIMessage, unique = false): void { const container = document.querySelector("ak-message-container"); @@ -75,8 +79,8 @@ export function showAPIErrorMessage(error: APIError, unique = false): void { @customElement("ak-message-container") export class MessageContainer extends AKElement { - @property({ attribute: false }) - messages: APIMessage[] = []; + @state() + protected messages: APIMessage[] = []; @property() alignment: "top" | "bottom" = "top"; @@ -99,6 +103,9 @@ export class MessageContainer extends AKElement { constructor() { super(); + // Note: This seems to be susceptible to race conditions. + // Events are dispatched regardless if the message container is listening. + window.addEventListener(EVENT_WS_MESSAGE, ((e: CustomEvent) => { if (e.detail.message_type !== WS_MSG_TYPE_MESSAGE) return; @@ -110,31 +117,38 @@ export class MessageContainer extends AKElement { }) as EventListener); } - addMessage(message: APIMessage, unique = false): void { + public addMessage(message: APIMessage, unique = false): void { if (unique) { - const matchIndex = this.messages.findIndex((m) => m.message === message.message); + const match = this.messages.some((m) => m.message === message.message); - if (matchIndex !== -1) return; + if (match) return; } - this.messages.push(message); - this.requestUpdate(); + this.messages = [...this.messages, message]; } - render(): TemplateResult { - return html`
    - ${Array.from(this.messages) - .reverse() - .map((message) => { - return html` { - this.messages = this.messages.filter((v) => v !== m); - this.requestUpdate(); - }} - > - `; - })} + #removeMessage = (message: APIMessage) => { + this.messages = this.messages.filter((v) => v !== message); + }; + + render() { + return html`
      + ${this.messages.toReversed().map((message, idx) => { + const { message: title, description, level } = message; + + return html` this.#removeMessage(message)} + > + ${title} + `; + })}
    `; } } diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index ae8f106ad2..ea28cebd4d 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -6,7 +6,7 @@ import { UiThemeEnum } from "@goauthentik/api"; import { msg } from "@lit/localize"; import { css, CSSResult, html, TemplateResult } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; @@ -23,11 +23,25 @@ export class Sidebar extends AKElement { z-index: 100; --pf-c-page__sidebar--Transition: 0 !important; } + + .pf-c-nav { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: hidden; + --pf-c-nav__link--hover--before--BorderBottomWidth: 1px; + } + + .pf-c-nav__link:hover::before { + --pf-c-nav__link--before--BorderColor: transparent; + } + .pf-c-nav__link.pf-m-current::after, .pf-c-nav__link.pf-m-current:hover::after, .pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after { --pf-c-nav__link--m-current--after--BorderColor: #fd4b2d; } + :host([theme="light"]) { border-right-color: transparent !important; } @@ -36,12 +50,6 @@ export class Sidebar extends AKElement { --pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm); } - nav { - display: flex; - flex-direction: column; - height: 100%; - overflow-y: hidden; - } .pf-c-nav__list { flex-grow: 1; overflow-y: auto; @@ -61,16 +69,25 @@ export class Sidebar extends AKElement { `, ]; + @property({ type: Boolean }) + hidden = false; + render(): TemplateResult { - return html`
    -
      +
      -
    `; +
`; } } diff --git a/web/src/elements/sidebar/SidebarItem.ts b/web/src/elements/sidebar/SidebarItem.ts index b316455656..4ff9e1dd20 100644 --- a/web/src/elements/sidebar/SidebarItem.ts +++ b/web/src/elements/sidebar/SidebarItem.ts @@ -2,13 +2,21 @@ import { ROUTE_SEPARATOR } from "#common/constants"; import { AKElement } from "#elements/Base"; -import { css, CSSResult, html, TemplateResult } from "lit"; +import { msg, str } from "@lit/localize"; +import { css, CSSResult, html, nothing, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; +export interface SidebarItemProperties { + path?: string; + activeWhen?: string[]; + expanded?: boolean; +} + @customElement("ak-sidebar-item") export class SidebarItem extends AKElement { static styles: CSSResult[] = [ @@ -67,32 +75,35 @@ export class SidebarItem extends AKElement { ]; @property() - path?: string; + public path?: string; + + @property({ type: String }) + public label?: string; activeMatchers: RegExp[] = []; @property({ type: Boolean }) - expanded = false; + public expanded = false; @property({ type: Boolean }) - isActive = false; + public current?: boolean; @property({ type: Boolean }) - isAbsoluteLink = false; + public isAbsoluteLink = false; @property({ type: Boolean }) - highlight = false; + public highlight?: boolean; - parent?: SidebarItem; + public parent?: SidebarItem; - get childItems(): SidebarItem[] { + public get childItems(): SidebarItem[] { const children = Array.from(this.querySelectorAll("ak-sidebar-item") || []); children.forEach((child) => (child.parent = this)); return children; } @property({ attribute: false }) - set activeWhen(regexp: string[]) { + public set activeWhen(regexp: string[]) { regexp.forEach((r) => { this.activeMatchers.push(new RegExp(r)); }); @@ -108,7 +119,7 @@ export class SidebarItem extends AKElement { this.childItems.forEach((item) => { this.expandParentRecursive(activePath, item); }); - this.isActive = this.matchesPath(activePath); + this.current = this.matchesPath(activePath); } private matchesPath(path: string): boolean { @@ -136,36 +147,53 @@ export class SidebarItem extends AKElement { renderWithChildren() { return html`
  • -
    -
      - +
      +
        + ${this.expanded ? html`` : nothing}
      -
    +
  • `; } renderWithPathAndChildren() { return html`
  • - + ${this.label} -
    +
    -
    +
  • `; } renderWithPath() { return html` - + ${this.label} `; } renderWithLabel() { - return html` - - - - `; + return html` ${this.label} `; } renderInner() { @@ -210,7 +236,11 @@ export class SidebarItem extends AKElement { return this.path ? this.renderWithPathAndChildren() : this.renderWithChildren(); } - return html`
  • + return html`
  • ${this.path ? this.renderWithPath() : this.renderWithLabel()}
  • `; } diff --git a/web/src/elements/sidebar/SidebarVersion.ts b/web/src/elements/sidebar/SidebarVersion.ts index 754b577153..9c67018893 100644 --- a/web/src/elements/sidebar/SidebarVersion.ts +++ b/web/src/elements/sidebar/SidebarVersion.ts @@ -51,14 +51,32 @@ export class SidebarVersion extends WithLicenseSummary(WithVersion(AKElement)) { product += ` ${msg("Enterprise")}`; } return html``; } } diff --git a/web/src/elements/stories/Alert.stories.ts b/web/src/elements/stories/Alert.stories.ts index 6d59c32a13..64c559b5ed 100644 --- a/web/src/elements/stories/Alert.stories.ts +++ b/web/src/elements/stories/Alert.stories.ts @@ -1,6 +1,6 @@ import "../Alert.js"; -import { Alert, type IAlert } from "../Alert.js"; +import { AKAlert, type IAlert } from "../Alert.js"; import type { Meta, StoryObj } from "@storybook/web-components"; @@ -9,7 +9,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; type IAlertForTesting = IAlert & { message: string }; -const metadata: Meta = { +const metadata: Meta = { title: "Elements/", component: "ak-alert", parameters: { diff --git a/web/src/elements/sync/SyncStatusCard.ts b/web/src/elements/sync/SyncStatusCard.ts index ff0fe39d50..ec4083b001 100644 --- a/web/src/elements/sync/SyncStatusCard.ts +++ b/web/src/elements/sync/SyncStatusCard.ts @@ -8,11 +8,12 @@ import { formatElapsedTime } from "#common/temporal"; import { AKElement } from "#elements/Base"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; +import { SlottedTemplateResult } from "#elements/types"; import { SyncStatus, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { css, CSSResult, html, TemplateResult } from "lit"; +import { css, CSSResult, html, nothing, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; @@ -87,12 +88,12 @@ export class SyncStatusTable extends Table { `; } - renderToolbarContainer() { - return html``; + protected override renderToolbarContainer(): SlottedTemplateResult { + return nothing; } - renderTablePagination() { - return html``; + protected override renderTablePagination(): SlottedTemplateResult { + return nothing; } } diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 306952b355..8b5bfc7f87 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -304,7 +304,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements `; } - renderEmpty(inner?: SlottedTemplateResult): TemplateResult { + protected renderEmpty(inner?: SlottedTemplateResult): TemplateResult { return html` @@ -466,11 +466,11 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements >`; } - renderToolbarSelected(): SlottedTemplateResult { + protected renderToolbarSelected(): SlottedTemplateResult { return nothing; } - renderToolbarAfter(): SlottedTemplateResult { + protected renderToolbarAfter(): SlottedTemplateResult { return nothing; } @@ -508,7 +508,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements
    `; } - renderToolbarContainer(): TemplateResult { + protected renderToolbarContainer(): SlottedTemplateResult { return html`
    ${this.renderSearch()} @@ -562,7 +562,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements return this.checkbox && this.checkboxChip; } - renderChipGroup(): TemplateResult { + protected renderChipGroup(): TemplateResult { return html` ${this.selectedElements.map((el) => { return html`${this.renderSelectedChip(el)}`; @@ -571,7 +571,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements } /* A simple pagination display, shown at both the top and bottom of the page. */ - renderTablePagination(): TemplateResult { + protected renderTablePagination(): SlottedTemplateResult { const handler = (page: number) => { this.page = page; this.fetch(); @@ -587,7 +587,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements `; } - renderTable(): TemplateResult { + protected renderTable(): TemplateResult { const renderBottomPagination = () => html`
    ${this.renderTablePagination()}
    `; diff --git a/web/src/elements/table/TableModal.ts b/web/src/elements/table/TableModal.ts index 4d16f81e2a..bff6b2c680 100644 --- a/web/src/elements/table/TableModal.ts +++ b/web/src/elements/table/TableModal.ts @@ -1,3 +1,5 @@ +import { SlottedTemplateResult } from "../types.js"; + import { PFSize } from "#common/enums"; import { AKElement } from "#elements/Base"; @@ -7,7 +9,7 @@ import type { Form } from "#elements/forms/Form"; import { Table } from "#elements/table/Table"; import { msg } from "@lit/localize"; -import { CSSResult, html, TemplateResult } from "lit"; +import { CSSResult, html, nothing, TemplateResult } from "lit"; import { property } from "lit/decorators.js"; import PFBackdrop from "@patternfly/patternfly/components/Backdrop/backdrop.css"; @@ -17,23 +19,24 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css"; import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css"; -export abstract class TableModal extends Table { +export abstract class TableModal extends Table { @property() - size: PFSize = PFSize.Large; + public size: PFSize = PFSize.Large; @property({ type: Boolean }) - set open(value: boolean) { - this._open = value; - if (value) { + set open(nextValue: boolean) { + this.#open = nextValue; + + if (nextValue) { this.fetch(); } } get open(): boolean { - return this._open; + return this.#open; } - _open = false; + #open = false; static styles: CSSResult[] = [ ...super.styles, @@ -53,10 +56,10 @@ export abstract class TableModal extends Table { return super.fetch(); } - closeModal() { + public close = () => { this.resetForms(); this.open = false; - } + }; resetForms(): void { for (const form of this.querySelectorAll("[slot=form]")) { @@ -64,45 +67,48 @@ export abstract class TableModal extends Table { } } - onClick(): void { + public show = () => { this.open = true; this.dispatchEvent(new ModalShowEvent(this)); + this.querySelectorAll("*").forEach((child) => { if ("requestUpdate" in child) { (child as AKElement).requestUpdate(); } }); + }; + + #closeListener = () => { + this.open = false; + }; + + #backdropListener(event: PointerEvent) { + event.stopPropagation(); } - renderModalInner(): TemplateResult { - return this.renderTable(); - } - - renderModal(): TemplateResult { - return html`
    { - e.stopPropagation(); - }} - > + /** + * @abstract + */ + protected renderModal(): SlottedTemplateResult { + return html`
    - ${this.renderModalInner()} + ${this.renderTable()}
    `; } render(): TemplateResult { - return html` this.onClick()}> - ${this.open ? this.renderModal() : ""}`; + return html` + ${this.open ? this.renderModal() : nothing}`; } } diff --git a/web/src/elements/table/TablePage.ts b/web/src/elements/table/TablePage.ts index 843b1974c9..562851da91 100644 --- a/web/src/elements/table/TablePage.ts +++ b/web/src/elements/table/TablePage.ts @@ -1,6 +1,8 @@ import "#components/ak-page-header"; +import { updateURLParams } from "#elements/router/RouteMatch"; import { Table } from "#elements/table/Table"; +import { SlottedTemplateResult } from "#elements/types"; import { msg } from "@lit/localize"; import { CSSResult, html, nothing, TemplateResult } from "lit"; @@ -10,32 +12,57 @@ import PFContent from "@patternfly/patternfly/components/Content/content.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFSidebar from "@patternfly/patternfly/components/Sidebar/sidebar.css"; -export abstract class TablePage extends Table { - abstract pageTitle(): string; - abstract pageDescription(): string | undefined; - abstract pageIcon(): string; - +export abstract class TablePage extends Table { static styles: CSSResult[] = [...super.styles, PFPage, PFContent, PFSidebar]; - renderSidebarBefore(): TemplateResult { - return html``; - } + //#region Abstract methods - renderSidebarAfter(): TemplateResult { - return html``; - } + /** + * The title of the page. + * @abstract + */ + abstract pageTitle(): string; - // Optionally render section above the table - renderSectionBefore(): TemplateResult { - return html``; - } + /** + * The description of the page. + * @abstract + */ + abstract pageDescription(): string | undefined; - // Optionally render section below the table - renderSectionAfter(): TemplateResult { - return html``; - } + /** + * The icon to display in the page header. + * @abstract + */ + abstract pageIcon(): string; - renderEmpty(inner?: TemplateResult): TemplateResult { + /** + * Render content before the sidebar. + * @abstract + */ + protected renderSidebarBefore?(): TemplateResult; + + /** + * Render content after the sidebar. + * @abstract + */ + protected renderSidebarAfter?(): TemplateResult; + + /** + * Render content before the main section. + * @abstract + */ + protected renderSectionBefore?(): TemplateResult; + + /** + * Render content after the main section. + * @abstract + */ + protected renderSectionAfter?(): TemplateResult; + + /** + * Render the empty state. + */ + protected renderEmpty(inner?: TemplateResult): TemplateResult { return super.renderEmpty(html` ${inner ? inner @@ -49,9 +76,21 @@ export abstract class TablePage extends Table { `); } - renderEmptyClearSearch(): TemplateResult { - if (this.search === "") { - return html``; + protected clearSearch = () => { + this.search = ""; + + this.requestUpdate(); + + updateURLParams({ + search: "", + }); + + return this.fetch(); + }; + + protected renderEmptyClearSearch(): SlottedTemplateResult { + if (!this.search) { + return nothing; } return html``; } - render(): TemplateResult { + render() { return html` - ${this.renderSectionBefore()} -
    + ${this.renderSectionBefore?.()} +
    - ${this.renderSidebarBefore()} + ${this.renderSidebarBefore?.()}
    ${this.renderTable()}
    - ${this.renderSidebarAfter()} + ${this.renderSidebarAfter?.()}
    - ${this.renderSectionAfter()}`; + ${this.renderSectionAfter?.()}`; } } diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts index 1335bc289f..1bda8ff724 100644 --- a/web/src/elements/types.ts +++ b/web/src/elements/types.ts @@ -2,6 +2,8 @@ import { OwnPropertyRecord, Writeable } from "#common/types"; import type { LitElement, nothing, ReactiveControllerHost, TemplateResult } from "lit"; +//#region HTML Helpers + /** * Utility type to extract a record of tag names which correspond to a given type. * @@ -13,11 +15,49 @@ export type HTMLElementTagNameMapOf = { : never]: HTMLElementTagNameMap[K]; }; +//#endregion + +//#region Element Properties + +/** + * + * Given an element and a base class, pluck the properties not defined on the base class. + */ export type TemplatedProperties< T extends HTMLElement, Base extends Element = HTMLElement, > = Partial>; +/** + * Given a record-like object, prefixes each key with a dot, allowing it to be spread into a + * template literal. + * + * ```ts + * interface MyElementProperties { + * foo: string; + * bar: number; + * } + * + * const properties {} as LitPropertyRecord + * + * console.log(properties) // { '.foo': string; '.bar': number } + * ``` + */ +export type LitPropertyRecord = { + [K in keyof T as K extends string ? LitPropertyKey : never]: T[K]; +}; + +/** + * A type that represents a property key that can be used in a LitPropertyRecord. + * + * @see {@linkcode LitPropertyRecord} + */ +export type LitPropertyKey = K extends string ? `.${K}` | `?${K}` | K : K; + +//#endregion + +//#region Host/Controller + /** * A custom element which may be used as a host for a ReactiveController. * @@ -27,6 +67,10 @@ export type TemplatedProperties< */ export type ReactiveElementHost = Partial> & HTMLElement; +//#endregion + +//#region Constructors + export type AbstractLitElementConstructor = abstract new ( // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: any[] @@ -35,6 +79,10 @@ export type AbstractLitElementConstructor = abstract new ( // eslint-disable-next-line @typescript-eslint/no-explicit-any export type LitElementConstructor = new (...args: any[]) => LitElement & T; +//#endregion + +//#region Mixins + /** * A constructor that has been extended with a mixin. */ diff --git a/web/src/user/LibraryApplication/index.ts b/web/src/user/LibraryApplication/index.ts index cee7030815..91c534321f 100644 --- a/web/src/user/LibraryApplication/index.ts +++ b/web/src/user/LibraryApplication/index.ts @@ -102,7 +102,7 @@ export class LibraryApplication extends AKElement { return html`
    { - this.racEndpointLaunch?.onClick(); + this.racEndpointLaunch?.show(); }} > { - this.racEndpointLaunch?.onClick(); + this.racEndpointLaunch?.show(); }} > ${this.application.name} diff --git a/web/src/user/index.entrypoint.ts b/web/src/user/index.entrypoint.ts index 0f32373690..272f0c9810 100644 --- a/web/src/user/index.entrypoint.ts +++ b/web/src/user/index.entrypoint.ts @@ -224,7 +224,6 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {