Skip to content

Commit

Permalink
web: unit tests for the simple things, with fixes that the tests reve…
Browse files Browse the repository at this point in the history
…aled (goauthentik#11633)

* Added tests and refinements as tests indicate.

* Building out the test suite.

* web: test the simple things. Fix what the tests revealed.

- Move `EmptyState.test.ts` into the `./tests` folder.
- Provide unit tests for:
  - Alert
  - Divider
  - Expand
  - Label
  - LoadingOverlay
- Give all tested items an Interface and a functional variant for rendering
- Give Label an alternative syntax for declaring alert levels
- Remove the slot name in LoadingOverlay
  - Change the slot call in `./enterprise/rac/index.ts` to not need the slot name as well
- Change the attribute names `topMost`, `textOpen`, and `textClosed` to `topmost`, `text-open`, and
  `text-closed`, respectively.
  - Change locations in the code where those are used to correspond

** Why interfaces: **

Provides another check on the input/output boundaries of our elements, gives Storybook and
WebdriverIO another validation to check, and guarantees any rendering functions cannot be passed
invalid property names.

** Why functions for rendering: **

Providing functions for rendering gets us one step closer to dynamically defining our forms-in-code
at runtime without losing any type safety.

** Why rename the attributes: **

A *very* subtle bug:
[Element:setAttribute()](https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute)
automatically "converts an attribute name to all lower-case when called on an HTML element in an
HTML document." The three attributes renamed are all treated *as* attributes, either classic boolean
or stringly-typed attributes, and attempting to manipulate them with `setAttribute()` will fail.

All of these attributes are presentational; none of them end up in a transaction with the back-end,
so kebab-to-camel conversions are not a concern.

Also, ["topmost" is one word](https://www.merriam-webster.com/dictionary/topmost).

** Why remove the slot name: **

Because there was only one slot.  A name is not needed.

* Fix minor spelling error.
  • Loading branch information
kensternberg-authentik authored Oct 10, 2024
1 parent 795e0ff commit 058a388
Show file tree
Hide file tree
Showing 16 changed files with 494 additions and 64 deletions.
79 changes: 65 additions & 14 deletions web/src/elements/Alert.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { AKElement } from "@goauthentik/elements/Base";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers";

import { CSSResult, TemplateResult, html } from "lit";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";

import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
Expand All @@ -13,36 +16,84 @@ export enum Level {
Danger = "pf-m-danger",
}

export const levelNames = ["warning", "info", "success", "danger"];
export type Levels = (typeof levelNames)[number];

export interface IAlert {
inline?: boolean;
plain?: boolean;
icon?: string;
level?: string;
}

/**
* @class Alert
* @element ak-alert
*
* Alerts are in-page elements intended to draw the user's attention and alert them to important
* details. Alerts are used alongside form elements to warn users of potential mistakes they can
* make, as well as in in-line documentation.
*/
@customElement("ak-alert")
export class Alert extends AKElement {
export class Alert extends AKElement implements IAlert {
/**
* Whether or not to display the entire component's contents in-line or not.
*
* @attr
*/
@property({ type: Boolean })
inline = false;

@property({ type: Boolean })
plain = false;

/**
* Method of determining severity
*
* @attr
*/
@property()
level: Level | Levels = Level.Warning;

/**
* Icon to display
*
* @attr
*/
@property()
level: Level = Level.Warning;
icon = "fa-exclamation-circle";

static get styles(): CSSResult[] {
static get styles() {
return [PFBase, PFAlert];
}

render(): TemplateResult {
return html`<div
class="pf-c-alert ${this.inline ? "pf-m-inline" : ""} ${this.plain
? "pf-m-plain"
: ""} ${this.level}"
>
get classmap() {
const level = levelNames.includes(this.level)
? `pf-m-${this.level}`
: (this.level as string);
return {
"pf-c-alert": true,
"pf-m-inline": this.inline,
"pf-m-plain": this.plain,
[level]: true,
};
}

render() {
return html`<div class="${classMap(this.classmap)}">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
<i class="fas ${this.icon}"></i>
</div>
<h4 class="pf-c-alert__title">
<slot></slot>
</h4>
<h4 class="pf-c-alert__title"><slot></slot></h4>
</div>`;
}
}

export function akAlert(properties: IAlert, content: SlottedTemplateResult = nothing) {
const message = typeof content === "string" ? html`<span>${content}</span>` : content;
return html`<ak-alert ${spread(properties as Spread)}>${message}</ak-alert>`;
}

declare global {
interface HTMLElementTagNameMap {
"ak-alert": Alert;
Expand Down
16 changes: 12 additions & 4 deletions web/src/elements/Divider.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { AKElement } from "@goauthentik/elements/Base";
import { type SlottedTemplateResult } from "@goauthentik/elements/types";

import { CSSResult, TemplateResult, css, html } from "lit";
import { css, html, nothing } from "lit";
import { customElement } from "lit/decorators.js";

import PFBase from "@patternfly/patternfly/patternfly-base.css";

@customElement("ak-divider")
export class Divider extends AKElement {
static get styles(): CSSResult[] {
static get styles() {
return [
PFBase,
css`
Expand Down Expand Up @@ -35,11 +36,18 @@ export class Divider extends AKElement {
];
}

render(): TemplateResult {
return html`<div class="separator"><slot></slot></div>`;
render() {
return html`<div class="separator">
<slot></slot>
</div>`;
}
}

export function akDivider(content: SlottedTemplateResult = nothing) {
const message = typeof content === "string" ? html`<span>${content}</span>` : content;
return html`<ak-divider>${message}</ak-divider>`;
}

declare global {
interface HTMLElementTagNameMap {
"ak-divider": Divider;
Expand Down
23 changes: 19 additions & 4 deletions web/src/elements/EmptyState.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { PFSize } from "@goauthentik/common/enums.js";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Spinner";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers";

import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";

import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";

export interface IEmptyState {
icon?: string;
loading?: boolean;
fullHeight?: boolean;
header?: string;
}

@customElement("ak-empty-state")
export class EmptyState extends AKElement {
export class EmptyState extends AKElement implements IEmptyState {
@property({ type: String })
icon = "";

Expand All @@ -24,7 +33,7 @@ export class EmptyState extends AKElement {
@property()
header?: string;

static get styles(): CSSResult[] {
static get styles() {
return [
PFBase,
PFEmptyState,
Expand All @@ -38,7 +47,7 @@ export class EmptyState extends AKElement {
];
}

render(): TemplateResult {
render() {
return html`<div class="pf-c-empty-state ${this.fullHeight && "pf-m-full-height"}">
<div class="pf-c-empty-state__content">
${this.loading
Expand All @@ -64,6 +73,12 @@ export class EmptyState extends AKElement {
}
}

export function akEmptyState(properties: IEmptyState, content: SlottedTemplateResult = nothing) {
const message =
typeof content === "string" ? html`<span slot="body">${content}</span>` : content;
return html`<ak-empty-state ${spread(properties as Spread)}>${message}</ak-empty-state>`;
}

declare global {
interface HTMLElementTagNameMap {
"ak-empty-state": EmptyState;
Expand Down
25 changes: 19 additions & 6 deletions web/src/elements/Expand.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
import { AKElement } from "@goauthentik/elements/Base";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers";

import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";

import PFExpandableSection from "@patternfly/patternfly/components/ExpandableSection/expandable-section.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";

export interface IExpand {
expanded?: boolean;
textOpen?: string;
textClosed?: string;
}

@customElement("ak-expand")
export class Expand extends AKElement {
export class Expand extends AKElement implements IExpand {
@property({ type: Boolean })
expanded = false;

@property()
@property({ type: String, attribute: "text-open" })
textOpen = msg("Show less");

@property()
@property({ type: String, attribute: "text-closed" })
textClosed = msg("Show more");

static get styles(): CSSResult[] {
static get styles() {
return [
PFBase,
PFExpandableSection,
Expand All @@ -30,7 +38,7 @@ export class Expand extends AKElement {
];
}

render(): TemplateResult {
render() {
return html`<div
class="pf-c-expandable-section pf-m-display-lg pf-m-indented ${this.expanded
? "pf-m-expanded"
Expand Down Expand Up @@ -58,6 +66,11 @@ export class Expand extends AKElement {
}
}

export function akExpand(properties: IExpand, content: SlottedTemplateResult = nothing) {
const message = typeof content === "string" ? html`<span>${content}</span>` : content;
return html`<ak-expand ${spread(properties as Spread)}>${message}</ak-expand>`;
}

declare global {
interface HTMLElementTagNameMap {
"ak-expand": Expand;
Expand Down
67 changes: 45 additions & 22 deletions web/src/elements/Label.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { AKElement } from "@goauthentik/elements/Base";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers";

import { CSSResult, TemplateResult, html } from "lit";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";

import PFLabel from "@patternfly/patternfly/components/Label/label.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
Expand All @@ -13,8 +16,25 @@ export enum PFColor {
Grey = "",
}

export const levelNames = ["warning", "info", "success", "danger"];
export type Level = (typeof levelNames)[number];

type Chrome = [Level, PFColor, string, string];
const chromeList: Chrome[] = [
["danger", PFColor.Red, "pf-m-red", "fa-times"],
["warning", PFColor.Orange, "pf-m-orange", "fa-exclamation-triangle"],
["success", PFColor.Green, "pf-m-green", "fa-check"],
["info", PFColor.Grey, "pf-m-grey", "fa-info-circle"],
];

export interface ILabel {
icon?: string;
compact?: boolean;
color?: string;
}

@customElement("ak-label")
export class Label extends AKElement {
export class Label extends AKElement implements ILabel {
@property()
color: PFColor = PFColor.Grey;

Expand All @@ -24,40 +44,43 @@ export class Label extends AKElement {
@property({ type: Boolean })
compact = false;

static get styles(): CSSResult[] {
static get styles() {
return [PFBase, PFLabel];
}

getDefaultIcon(): string {
switch (this.color) {
case PFColor.Green:
return "fa-check";
case PFColor.Orange:
return "fa-exclamation-triangle";
case PFColor.Red:
return "fa-times";
case PFColor.Grey:
return "fa-info-circle";
default:
return "";
}
get classesAndIcon() {
const chrome = chromeList.find(
([level, color]) => this.color === level || this.color === color,
);
const [illo, icon] = chrome ? chrome.slice(2) : ["pf-m-grey", "fa-info-circle"];
return {
classes: {
"pf-c-label": true,
"pf-m-compact": this.compact,
...(illo ? { [illo]: true } : {}),
},
icon: this.icon ? this.icon : icon,
};
}

render(): TemplateResult {
return html`<span class="pf-c-label ${this.color} ${this.compact ? "pf-m-compact" : ""}">
render() {
const { classes, icon } = this.classesAndIcon;
return html`<span class=${classMap(classes)}>
<span class="pf-c-label__content">
<span class="pf-c-label__icon">
<i
class="fas fa-fw ${this.icon || this.getDefaultIcon()}"
aria-hidden="true"
></i>
<i class="fas fa-fw ${icon}" aria-hidden="true"></i>
</span>
<slot></slot>
</span>
</span>`;
}
}

export function akLabel(properties: ILabel, content: SlottedTemplateResult = nothing) {
const message = typeof content === "string" ? html`<span>${content}</span>` : content;
return html`<ak-label ${spread(properties as Spread)}>${message}</ak-label>`;
}

declare global {
interface HTMLElementTagNameMap {
"ak-label": Label;
Expand Down
Loading

0 comments on commit 058a388

Please sign in to comment.