Compare commits

...

5 Commits

Author SHA1 Message Date
Teffen Ellis
787cb03172 web: Flesh out slim tests. 2025-07-30 04:47:14 +02:00
Teffen Ellis
77a2b1e1b7 web: Flesh out Playwright. 2025-07-30 03:49:56 +02:00
Teffen Ellis
1356db9288 web: Prep form inputs for a11y. 2025-07-30 03:49:16 +02:00
Teffen Ellis
11b2b88b21 web: Prep for a11y. 2025-07-30 03:48:42 +02:00
Teffen Ellis
c428e77c6e web: Prep for a11y, tables, modals. 2025-07-30 03:46:01 +02:00
65 changed files with 3582 additions and 1157 deletions

2
web/.gitignore vendored
View File

@@ -25,6 +25,8 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
playwright-report
test-results
*.lcov
# nyc test coverage

90
web/e2e/elements/proxy.ts Normal file
View File

@@ -0,0 +1,90 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { LocatorContext } from "#e2e/selectors/types";
import { ConsoleLogger } from "#logger/node";
import { expect, Locator } from "@playwright/test";
import { kebabCase } from "change-case";
export type LocatorMatchers = ReturnType<typeof expect<Locator>>;
export interface LocatorProxy extends Pick<Locator, keyof Locator> {
$: Locator;
expect: LocatorMatchers;
}
// Type helpers to extract the shape of the proxy
export type DeepLocatorProxy<T> =
Disposable & T extends Record<string, any>
? T extends HTMLElement
? LocatorProxy
: {
[K in keyof T]: DeepLocatorProxy<T[K]>;
}
: LocatorProxy;
export function createLocatorProxy<T extends Record<string, any>>(
ctx: LocatorContext,
initialPathPrefix: string[] = [],
dataAttribute: string = "test-id",
): DeepLocatorProxy<T> {
dataAttribute = kebabCase(dataAttribute);
function createProxy(path: string[] = initialPathPrefix): any {
const proxyCache = new Map<string, LocatorProxy>();
return new Proxy({} as any, {
get(_, property: string) {
// Build the current path
const currentPath = [...path, property];
// Convert the path to kebab-case and join with hyphens
const selectorValue = currentPath.map((segment) => kebabCase(segment)).join("-");
const selector = `[data-${dataAttribute}="${selectorValue}"]`;
// Create a locator for the current selector
const locator = ctx.locator(selector);
if (proxyCache.has(selector)) {
ConsoleLogger.debug(`Using cached locator for ${selector}`);
return proxyCache.get(selector)!;
}
// Return a new proxy that also behaves like a Locator
// This allows us to either continue chaining or use Locator methods
const nextProxy = new Proxy(locator, {
get(target, prop) {
if (typeof prop === "string") {
// The user is likely trying to access a property on the page.
if (prop === "$") {
return target as any;
}
if (prop === "expect") {
return expect(target);
}
}
// If the property exists on the Locator, use it
if (prop in target) {
const value = (target as any)[prop];
// Bind methods to the locator instance
if (typeof value === "function") {
return value.bind(target);
}
return value;
}
// Otherwise, continue building the path
return createProxy(currentPath)[prop];
},
});
proxyCache.set(selector, nextProxy as LocatorProxy);
return nextProxy;
},
});
}
return createProxy() as DeepLocatorProxy<T>;
}

View File

@@ -0,0 +1,175 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import type { LocatorContext } from "#e2e/selectors/types";
import { expect, Page } from "@playwright/test";
export class FormFixture extends PageFixture {
static fixtureName = "Form";
//#region Selector Methods
//#endregion
//#region Field Methods
/**
* Set the value of a text input.
*
* @param fieldName The name of the form element.
* @param value the value to set.
*/
public fill = async (
fieldName: string,
value: string,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent
.getByRole("textbox", {
name: fieldName,
})
.or(
parent.getByRole("spinbutton", {
name: fieldName,
}),
)
.first();
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
await control.fill(value);
};
/**
* Set the value of a radio or checkbox input.
*
* @param fieldName The name of the form element.
* @param value the value to set.
*/
public setInputCheck = async (
fieldName: string,
value: boolean = true,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent.locator("ak-switch-input", {
hasText: fieldName,
});
await control.scrollIntoViewIfNeeded();
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
const currentChecked = await control
.getAttribute("checked")
.then((value) => value !== null);
if (currentChecked === value) {
return;
}
await control.click();
};
/**
* Set the value of a radio or checkbox input.
*
* @param fieldName The name of the form element.
* @param pattern the value to set.
*/
public setRadio = async (
groupName: string,
fieldName: string,
parent: LocatorContext = this.page,
): Promise<void> => {
const group = parent.getByRole("group", { name: groupName });
await expect(group, `Field "${groupName}" should be visible`).toBeVisible();
const control = parent.getByRole("radio", { name: fieldName });
await control.setChecked(true, {
force: true,
});
};
/**
* Set the value of a search select input.
*
* @param fieldLabel The name of the search select element.
* @param pattern The text to match against the search select entry.
*/
public selectSearchValue = async (
fieldLabel: string,
pattern: string | RegExp,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent.getByRole("textbox", { name: fieldLabel });
await expect(
control,
`Search select control (${fieldLabel}) should be visible`,
).toBeVisible();
const fieldName = await control.getAttribute("name");
if (!fieldName) {
throw new Error(`Unable to find name attribute on search select (${fieldLabel})`);
}
// Find the search select input control and activate it.
await control.click();
const button = this.page
// ---
.locator(`div[data-managed-for*="${fieldName}"] button`, {
hasText: pattern,
});
if (!button) {
throw new Error(
`Unable to find an ak-search-select entry matching ${fieldLabel}:${pattern.toString()}`,
);
}
await button.click();
await this.page.keyboard.press("Tab");
await control.blur();
};
public setFormGroup = async (
pattern: string | RegExp,
value: boolean = true,
parent: LocatorContext = this.page,
) => {
const control = parent
.locator("ak-form-group", {
hasText: pattern,
})
.first();
const currentOpen = await control.getAttribute("open").then((value) => value !== null);
if (currentOpen === value) {
this.logger.debug(`Form group ${pattern} is already ${value ? "open" : "closed"}`);
return;
}
this.logger.debug(`Toggling form group ${pattern} to ${value ? "open" : "closed"}`);
await control.click();
if (value) {
await expect(control).toHaveAttribute("open");
} else {
await expect(control).not.toHaveAttribute("open");
}
};
//#endregion
//#region Lifecycle
constructor(page: Page, testName: string) {
super({ page, testName });
}
//#endregion
}

View File

@@ -0,0 +1,30 @@
import { ConsoleLogger, FixtureLogger } from "#logger/node";
import { Page } from "@playwright/test";
export interface PageFixtureOptions {
page: Page;
testName: string;
}
export abstract class PageFixture {
/**
* The name of the fixture.
*
* Used for logging.
*/
static fixtureName: string;
protected readonly logger: FixtureLogger;
protected readonly page: Page;
protected readonly testName: string;
constructor({ page, testName }: PageFixtureOptions) {
this.page = page;
this.testName = testName;
const Constructor = this.constructor as typeof PageFixture;
this.logger = ConsoleLogger.fixture(Constructor.fixtureName, this.testName);
}
}

View File

@@ -0,0 +1,42 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import type { LocatorContext } from "#e2e/selectors/types";
import { Page } from "@playwright/test";
export type GetByRoleParameters = Parameters<Page["getByRole"]>;
export type ARIARole = GetByRoleParameters[0];
export type ARIAOptions = GetByRoleParameters[1];
export type ClickByName = (name: string) => Promise<void>;
export type ClickByRole = (
role: ARIARole,
options?: ARIAOptions,
context?: LocatorContext,
) => Promise<void>;
export class PointerFixture extends PageFixture {
public static fixtureName = "Pointer";
public click = (
name: string,
optionsOrRole?: ARIAOptions | ARIARole,
context: LocatorContext = this.page,
): Promise<void> => {
if (typeof optionsOrRole === "string") {
return context.getByRole(optionsOrRole, { name }).click();
}
const options = {
...optionsOrRole,
name,
};
return (
context
// ---
.getByRole("button", options)
.or(context.getByRole("link", options))
.click()
);
};
}

View File

@@ -0,0 +1,119 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import { expect, Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
export const BAD_USERNAME = "bad-username@bad-login.io";
export const BAD_PASSWORD = "-this-is-a-bad-password-";
export interface LoginInit {
username?: string;
password?: string;
to?: URL | string;
}
export class SessionFixture extends PageFixture {
static fixtureName = "Session";
public static readonly pathname = "/if/flow/default-authentication-flow/";
//#region Selectors
public $identificationStage = this.page.locator("ak-stage-identification");
/**
* The username field on the login page.
*/
public $usernameField = this.$identificationStage.locator('input[name="uidField"]');
/**
* The button to continue with the login process,
* typically to the password flow stage.
*/
public $submitUsernameStageButton = this.$identificationStage.locator('button[type="submit"]');
public $passwordStage = this.page.locator("ak-stage-password");
public $passwordField = this.$passwordStage.locator('input[name="password"]');
/**
* The button to submit the the login flow,
* typically redirecting to the authenticated interface.
*/
public $submitPasswordStageButton = this.$passwordStage.locator('button[type="submit"]');
/**
* A possible authentication failure message.
*/
public $authFailureMessage = this.page.locator(".pf-m-error");
//#endregion
constructor(page: Page, testName: string) {
super({ page, testName });
}
//#region Specific interactions
public async submitUsernameStage(username: string) {
this.logger.info("Submitting username stage", username);
await this.$usernameField.fill(username);
await expect(this.$submitUsernameStageButton).toBeEnabled();
await this.$submitUsernameStageButton.click();
}
public async submitPasswordStage(password: string) {
this.logger.info("Submitting password stage");
await this.$passwordField.fill(password);
await expect(this.$submitPasswordStageButton).toBeEnabled();
await this.$submitPasswordStageButton.click();
}
public checkAuthenticated = async (): Promise<boolean> => {
// TODO: Check if the user is authenticated via API
return true;
};
/**
* Log into the application.
*/
public async login({
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
}: LoginInit = {}) {
this.logger.info("Logging in...");
const initialURL = new URL(this.page.url());
if (initialURL.pathname === SessionFixture.pathname) {
this.logger.info("Skipping navigation because we're already in a authentication flow");
} else {
await this.page.goto(to.toString());
}
await this.submitUsernameStage(username);
await this.$passwordField.waitFor({ state: "visible" });
await this.submitPasswordStage(password);
const expectedPathname = typeof to === "string" ? to : to.pathname;
await this.page.waitForURL(`**${expectedPathname}`);
}
//#endregion
//#region Navigation
public async toLoginPage() {
await this.page.goto(SessionFixture.pathname);
}
}

56
web/e2e/index.ts Normal file
View File

@@ -0,0 +1,56 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { createLocatorProxy, DeepLocatorProxy } from "#e2e/elements/proxy";
import { FormFixture } from "#e2e/fixtures/FormFixture";
import { PointerFixture } from "#e2e/fixtures/PointerFixture";
import { SessionFixture } from "#e2e/fixtures/SessionFixture";
import { createOUIDNameEngine } from "#e2e/selectors/ouid";
import { test as base } from "@playwright/test";
export { expect } from "@playwright/test";
type TestIDLocatorProxy = DeepLocatorProxy<TestIDSelectorMap>;
interface E2EFixturesTestScope {
/**
* A proxy to retrieve elements by test ID.
*
* ```ts
* const $button = $.button;
* ```
*/
$: TestIDLocatorProxy;
session: SessionFixture;
pointer: PointerFixture;
form: FormFixture;
}
interface E2EWorkerScope {
selectorRegistration: void;
}
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({
selectorRegistration: [
async ({ playwright }, use) => {
await playwright.selectors.register("ouid", createOUIDNameEngine);
await use();
},
{ auto: true, scope: "worker" },
],
$: async ({ page }, use) => {
await use(createLocatorProxy<TestIDSelectorMap>(page));
},
session: async ({ page }, use, { title }) => {
await use(new SessionFixture(page, title));
},
form: async ({ page }, use, { title }) => {
await use(new FormFixture(page, title));
},
pointer: async ({ page }, use, { title }) => {
await use(new PointerFixture({ page, testName: title }));
},
});

44
web/e2e/selectors/ouid.ts Normal file
View File

@@ -0,0 +1,44 @@
/* eslint-disable no-console */
type SelectorRoot = Document | ShadowRoot;
export function createOUIDNameEngine() {
const attributeName = "data-ouid-component-name";
console.log("Creating OUID selector engine!!");
return {
// Returns all elements matching given selector in the root's subtree.
queryAll(scope: SelectorRoot, componentName: string) {
const result: Element[] = [];
const match = (element: Element) => {
const name = element.getAttribute(attributeName);
if (name === componentName) {
result.push(element);
}
};
const query = (root: Element | ShadowRoot | Document) => {
const shadows: ShadowRoot[] = [];
if ((root as Element).shadowRoot) {
shadows.push((root as Element).shadowRoot!);
}
for (const element of root.querySelectorAll("*")) {
match(element);
if (element.shadowRoot) {
shadows.push(element.shadowRoot);
}
}
shadows.forEach(query);
};
query(scope);
return result;
},
};
}

View File

@@ -0,0 +1,13 @@
import type { Locator } from "@playwright/test";
export type LocatorContext = Pick<
Locator,
| "locator"
| "getByRole"
| "getByTestId"
| "getByText"
| "getByLabel"
| "getByAltText"
| "getByTitle"
| "getByPlaceholder"
>;

View File

@@ -0,0 +1,60 @@
import { IDGenerator } from "@goauthentik/core/id";
import {
adjectives,
colors,
Config as NameConfig,
uniqueNamesGenerator,
} from "unique-names-generator";
/**
* Given a dictionary of words, slice the dictionary to only include words that start with the given letter.
*/
export function alliterate(dictionary: string[], letter: string): string[] {
let firstIndex = 0;
for (let i = 0; i < dictionary.length; i++) {
if (dictionary[i][0] === letter) {
firstIndex = i;
break;
}
}
let lastIndex = firstIndex;
for (let i = firstIndex; i < dictionary.length; i++) {
if (dictionary[i][0] !== letter) {
lastIndex = i;
break;
}
}
return dictionary.slice(firstIndex, lastIndex);
}
export function createRandomName({
seed = IDGenerator.randomID(),
...config
}: Partial<NameConfig> = {}) {
const randomLetterIndex =
typeof seed === "number"
? seed
: Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
const letter = adjectives[randomLetterIndex % adjectives.length][0];
const availableAdjectives = alliterate(adjectives, letter);
const availableColors = alliterate(colors, letter);
const name = uniqueNamesGenerator({
dictionaries: [availableAdjectives, availableAdjectives, availableColors],
style: "capital",
separator: " ",
length: 3,
seed,
...config,
});
return name;
}

102
web/logger/node.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* Application logger.
*
* @import { LoggerOptions, Logger, Level, ChildLoggerOptions } from "pino"
* @import { PrettyOptions } from "pino-pretty"
*/
import { pino } from "pino";
//#region Constants
/**
* Default options for creating a Pino logger.
*
* @category Logger
* @satisfies {LoggerOptions<never, false>}
*/
export const DEFAULT_PINO_LOGGER_OPTIONS = {
enabled: true,
level: "info",
transport: {
target: "./transport.js",
options: /** @satisfies {PrettyOptions} */ ({
colorize: true,
}),
},
};
//#endregion
//#region Functions
/**
* Read the log level from the environment.
* @return {Level}
*/
export function readLogLevel() {
return process.env.AK_LOG_LEVEL || DEFAULT_PINO_LOGGER_OPTIONS.level;
}
/**
* @typedef {Logger} FixtureLogger
*/
/**
* @this {Logger}
* @param {string} fixtureName
* @param {string} [testName]
* @param {ChildLoggerOptions} [options]
* @returns {FixtureLogger}
*/
function createFixtureLogger(fixtureName, testName, options) {
return this.child(
{ name: fixtureName },
{
msgPrefix: `[${testName}] `,
...options,
},
);
}
/**
* @typedef {object} CustomLoggerMethods
* @property {typeof createFixtureLogger} fixture
*/
/**
* @typedef {Logger & CustomLoggerMethods} ConsoleLogger
*/
/**
* A singleton logger instance for Node.js.
*
* ```js
* import { ConsoleLogger } from "#logger/node";
*
* ConsoleLogger.info("Hello, world!");
* ```
*
* @runtime node
* @type {ConsoleLogger}
*/
export const ConsoleLogger = Object.assign(
pino({
...DEFAULT_PINO_LOGGER_OPTIONS,
level: readLogLevel(),
}),
{ fixture: createFixtureLogger },
);
/**
* @typedef {ReturnType<ConsoleLogger['child']>} ChildConsoleLogger
*/
//#region Aliases
export const info = ConsoleLogger.info.bind(ConsoleLogger);
export const debug = ConsoleLogger.debug.bind(ConsoleLogger);
export const warn = ConsoleLogger.warn.bind(ConsoleLogger);
export const error = ConsoleLogger.error.bind(ConsoleLogger);
//#endregion

22
web/logger/transport.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* @file Pretty transport for Pino
*
* @import { PrettyOptions } from "pino-pretty"
*/
import PinoPretty from "pino-pretty";
/**
* @param {PrettyOptions} options
*/
function prettyTransporter(options) {
const pretty = PinoPretty({
...options,
ignore: "pid,hostname",
translateTime: "SYS:HH:MM:ss",
});
return pretty;
}
export default prettyTransporter;

1815
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,8 +24,8 @@
"pseudolocalize": "node ./scripts/pseudolocalize.mjs",
"storybook": "storybook dev -p 6006",
"storybook:build": "wireit",
"test": "wireit",
"test:e2e": "wireit",
"test": "vitest",
"test:e2e": "playwright test",
"test:e2e:watch": "wireit",
"test:watch": "wireit",
"tsc": "wireit",
@@ -69,6 +69,9 @@
"#flow/*": "./src/flow/*.js",
"#locales/*": "./src/locales/*.js",
"#stories/*": "./src/stories/*.js",
"#tests/*": "./tests/*.js",
"#e2e": "./e2e/index.ts",
"#e2e/*": "./e2e/*.ts",
"#*/browser": {
"types": "./out/*/browser.d.ts",
"import": "./*/browser.js"
@@ -113,6 +116,7 @@
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2",
"@playwright/test": "^1.54.1",
"@sentry/browser": "^9.42.1",
"@spotlightjs/spotlight": "^3.0.1",
"@storybook/addon-docs": "^9.0.18",
@@ -128,6 +132,7 @@
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@vitest/browser": "^3.2.4",
"@wdio/browser-runner": "^9.18.4",
"@wdio/cli": "9.15",
"@wdio/spec-reporter": "^9.15.0",
@@ -163,6 +168,9 @@
"md-front-matter": "^1.0.4",
"mermaid": "^11.9.0",
"npm-run-all": "^4.1.5",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"playwright": "^1.54.1",
"prettier": "^3.6.2",
"pseudolocale": "^2.1.0",
"rapidoc": "^9.3.8",
@@ -183,7 +191,10 @@
"turnstile-types": "^1.2.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"unique-names-generator": "^4.7.1",
"unist-util-visit": "^5.0.0",
"vite": "^7.0.6",
"vitest": "^3.2.4",
"webcomponent-qr-code": "^1.3.0",
"wireit": "^0.14.12",
"yaml": "^2.8.0"
@@ -267,7 +278,7 @@
"command": "lit-analyzer src"
},
"lint:types:tests": {
"command": "tsc --noEmit -p ./tests"
"command": "tsc --noEmit -p tsconfig.test.json"
},
"lint:types": {
"command": "tsc -p .",
@@ -316,7 +327,7 @@
],
"env": {
"CI": "true",
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
"TS_NODE_PROJECT": "tsconfig.test.json"
}
},
"test:e2e:watch": {
@@ -325,7 +336,7 @@
"build"
],
"env": {
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
"TS_NODE_PROJECT": "tsconfig.test.json"
}
},
"test:watch": {

94
web/playwright.config.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* @file Playwright configuration.
*
* @see https://playwright.dev/docs/test-configuration
*
* @import { LogFn, Logger } from "pino"
*/
import { ConsoleLogger } from "#logger/node";
import { defineConfig, devices } from "@playwright/test";
const CI = !!process.env.CI;
/**
* @type {Map<string, Logger>}
*/
const LoggerCache = new Map();
const baseURL = process.env.AK_TEST_RUNNER_PAGE_URL ?? "http://localhost:9000";
export default defineConfig({
testDir: "./test/browser",
fullyParallel: true,
forbidOnly: CI,
retries: CI ? 2 : 0,
workers: CI ? 1 : undefined,
reporter: CI
? "github"
: [
// ---
["list", { printSteps: true }],
["html", { open: "never" }],
],
use: {
testIdAttribute: "data-test-id",
baseURL,
trace: "on-first-retry",
launchOptions: {
logger: {
isEnabled() {
return true;
},
log: (name, severity, message, args) => {
let logger = LoggerCache.get(name);
if (!logger) {
logger = ConsoleLogger.child({
name: `Playwright ${name.toUpperCase()}`,
});
LoggerCache.set(name, logger);
}
/**
* @type {LogFn}
*/
let log;
switch (severity) {
case "verbose":
log = logger.debug;
break;
case "warning":
log = logger.warn;
break;
case "error":
log = logger.error;
break;
default:
log = logger.info;
break;
}
if (name === "api") {
log = logger.debug;
}
log.call(logger, message.toString(), args);
},
},
},
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
],
});

View File

@@ -50,13 +50,12 @@ export class CoreGroupSearch extends CustomListenerElement(AKElement) {
search!: SearchSelect<Group>;
@property({ type: String })
name: string | null | undefined;
public name?: string | null;
selectedGroup?: Group;
constructor() {
super();
this.selected = this.selected.bind(this);
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
}
@@ -83,9 +82,9 @@ export class CoreGroupSearch extends CustomListenerElement(AKElement) {
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
}
selected(group: Group) {
selected = (group: Group) => {
return this.group === group.pk;
}
};
render() {
return html`

View File

@@ -40,7 +40,13 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
search!: SearchSelect<CertificateKeyPair>;
@property({ type: String })
name: string | null | undefined;
public name?: string | null;
@property({ type: String })
public label?: string | undefined;
@property({ type: String })
public placeholder?: string | undefined;
/**
* Set to `true` to allow certificates without private key to show up. When set to `false`,
@@ -48,7 +54,7 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
* @attr
*/
@property({ type: Boolean, attribute: "nokey" })
noKey = false;
public noKey = false;
/**
* Set this to true if, should there be only one certificate available, you want the system to
@@ -57,16 +63,12 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
* @attr
*/
@property({ type: Boolean, attribute: "singleton" })
singleton = false;
public singleton = false;
selectedKeypair?: CertificateKeyPair;
constructor() {
super();
this.selected = this.selected.bind(this);
this.fetchObjects = this.fetchObjects.bind(this);
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
}
/**
* @todo Document this.
*/
public selectedKeypair?: CertificateKeyPair;
get value() {
return this.selectedKeypair ? renderValue(this.selectedKeypair) : null;
@@ -85,13 +87,13 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
}
}
handleSearchUpdate(ev: CustomEvent) {
handleSearchUpdate = (ev: CustomEvent) => {
ev.stopPropagation();
this.selectedKeypair = ev.detail.value;
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
}
};
async fetchObjects(query?: string): Promise<CertificateKeyPair[]> {
fetchObjects = async (query?: string): Promise<CertificateKeyPair[]> => {
const args: CryptoCertificatekeypairsListRequest = {
ordering: "name",
hasKey: !this.noKey,
@@ -104,19 +106,21 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
args,
);
return certificates.results;
}
};
selected(item: CertificateKeyPair, items: CertificateKeyPair[]) {
selected = (item: CertificateKeyPair, items: CertificateKeyPair[]) => {
return (
(this.singleton && !this.certificate && items.length === 1) ||
(!!this.certificate && this.certificate === item.pk)
);
}
};
render() {
return html`
<ak-search-select
name=${ifDefined(this.name ?? undefined)}
label=${ifDefined(this.label ?? undefined)}
placeholder=${ifDefined(this.placeholder)}
.fetchObjects=${this.fetchObjects}
.renderElement=${renderElement}
.value=${renderValue}

View File

@@ -3,6 +3,7 @@ import "#elements/forms/SearchSelect/index";
import { DEFAULT_CONFIG } from "#common/api/config";
import { AKElement } from "#elements/Base";
import type { HorizontalFormElement } from "#elements/forms/HorizontalFormElement";
import { SearchSelect } from "#elements/forms/SearchSelect/index";
import { CustomListenerElement } from "#elements/utils/eventEmitter";
@@ -11,6 +12,7 @@ import { RenderFlowOption } from "#admin/flows/utils";
import type { Flow, FlowsInstancesListRequest } from "@goauthentik/api";
import { FlowsApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html } from "lit";
import { property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -33,17 +35,17 @@ export function getFlowValue(flow: Flow | undefined): string | undefined {
* A wrapper around SearchSelect that understands the basic semantics of querying about Flows. This
* code eliminates the long blocks of unreadable invocation that were embedded in every provider, as well as in
* sources, brands, and applications.
*
*/
export abstract class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement) {
//#region Properties
export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement) {
/**
* The type of flow we're looking for.
*
* @attr
*/
@property({ type: String })
flowType?: FlowsInstancesListDesignationEnum;
public flowType?: FlowsInstancesListDesignationEnum;
/**
* The id of the current flow, if any. For stages where the flow is already defined.
@@ -51,7 +53,7 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
* @attr
*/
@property({ type: String })
currentFlow?: string | undefined;
public currentFlow?: string | undefined;
/**
* If true, it is not valid to leave the flow blank.
@@ -59,10 +61,7 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
* @attr
*/
@property({ type: Boolean })
required?: boolean = false;
@query("ak-search-select")
search!: SearchSelect<T>;
public required?: boolean = false;
/**
* When specified and the object instance does not have a flow selected, auto-select the flow with the given slug.
@@ -70,60 +69,81 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
* @attr
*/
@property()
defaultFlowSlug?: string;
public defaultFlowSlug?: string;
@property({ type: String })
name: string | null | undefined;
public name?: string;
selectedFlow?: T;
/**
* The label of the input, for forms.
*
* @attr
*/
@property({ type: String })
public label?: string;
/**
* The textual placeholder for the search's <input> object, if currently empty. Used as the
* native <input> object's `placeholder` field.
*
* @attr
*/
@property({ type: String })
public placeholder = msg("Select a flow...");
@query("ak-search-select")
protected search!: SearchSelect<T>;
protected selectedFlow?: T;
get value() {
return this.selectedFlow ? getFlowValue(this.selectedFlow) : null;
}
constructor() {
super();
this.fetchObjects = this.fetchObjects.bind(this);
this.selected = this.selected.bind(this);
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
}
protected searchUpdateListener = (event: CustomEvent) => {
event.stopPropagation();
this.selectedFlow = event.detail.value;
handleSearchUpdate(ev: CustomEvent) {
ev.stopPropagation();
this.selectedFlow = ev.detail.value;
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
}
};
async fetchObjects(query?: string): Promise<Flow[]> {
protected fetchObjects = (query?: string): Promise<Flow[]> => {
const args: FlowsInstancesListRequest = {
ordering: "slug",
designation: this.flowType,
...(query !== undefined ? { search: query } : {}),
...(query ? { search: query } : {}),
};
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args);
return flows.results;
}
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args).then((flows) => flows.results);
};
/* This is the most commonly overridden method of this class. About half of the Flow Searches
* use this method, but several have more complex needs, such as relating to the brand, or just
* returning false.
*/
selected(flow: Flow): boolean {
let selected = this.currentFlow === flow.pk;
protected selected(flow: Flow): boolean {
if (!this.currentFlow && this.defaultFlowSlug && flow.slug === this.defaultFlowSlug) {
selected = true;
return true;
}
return selected;
return this.currentFlow === flow.pk;
}
connectedCallback() {
super.connectedCallback();
const horizontalContainer = this.closest("ak-form-element-horizontal[name]");
const horizontalContainer = this.closest<HorizontalFormElement>(
"ak-form-element-horizontal[name]",
);
if (!horizontalContainer) {
throw new Error("This search can only be used in a named ak-form-element-horizontal");
}
const name = horizontalContainer.getAttribute("name");
const myName = this.getAttribute("name");
if (name !== null && name !== myName) {
this.setAttribute("name", name);
}
@@ -137,8 +157,10 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
.renderElement=${renderElement}
.renderDescription=${renderDescription}
.value=${getFlowValue}
name=${ifDefined(this.name ?? undefined)}
@ak-change=${this.handleSearchUpdate}
placeholder=${ifDefined(this.placeholder)}
label=${ifDefined(this.label)}
name=${ifDefined(this.name)}
@ak-change=${this.searchUpdateListener}
?blankable=${!this.required}
>
</ak-search-select>

View File

@@ -19,14 +19,9 @@ export class AkBrandedFlowSearch<T extends Flow> extends FlowSearch<T> {
* @attr
*/
@property({ attribute: false, type: String })
brandFlow?: string;
public brandFlow?: string;
constructor() {
super();
this.selected = this.selected.bind(this);
}
selected(flow: Flow): boolean {
protected override selected(flow: Flow): boolean {
return super.selected(flow) || flow.pk === this.brandFlow;
}
}

View File

@@ -24,7 +24,7 @@ export class AkFlowSearchNoDefault<T extends Flow> extends FlowSearch<T> {
.renderElement=${renderElement}
.renderDescription=${renderDescription}
.value=${getFlowValue}
@ak-change=${this.handleSearchUpdate}
@ak-change=${this.searchUpdateListener}
?blankable=${!this.required}
>
</ak-search-select>

View File

@@ -18,9 +18,8 @@ export class AkSourceFlowSearch<T extends Flow> extends FlowSearch<T> {
*
* @attr
*/
@property({ type: String })
fallback: string | undefined;
public fallback?: string;
/**
* The primary key of the Source (not the Flow). Mostly the instancePk itself, used to affirm
@@ -29,16 +28,11 @@ export class AkSourceFlowSearch<T extends Flow> extends FlowSearch<T> {
* @attr
*/
@property({ type: String })
instanceId: string | undefined;
constructor() {
super();
this.selected = this.selected.bind(this);
}
public instanceId?: string;
// If there's no instance or no currentFlowId for it and the flow resembles the fallback,
// otherwise defer to the parent class.
selected(flow: Flow): boolean {
protected override selected(flow: Flow): boolean {
return (
(!this.instanceId && !this.currentFlow && flow.slug === this.fallback) ||
super.selected(flow)

View File

@@ -3,7 +3,7 @@ import { ModelForm } from "#elements/forms/ModelForm";
import { msg } from "@lit/localize";
export abstract class BaseProviderForm<T> extends ModelForm<T, number> {
getSuccessMessage(): string {
public override getSuccessMessage(): string {
return this.instance
? msg("Successfully updated provider.")
: msg("Successfully created provider.");

View File

@@ -29,32 +29,38 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-provider-list")
export class ProviderListPage extends TablePage<Provider> {
searchEnabled(): boolean {
override searchEnabled(): boolean {
return true;
}
pageTitle(): string {
override pageTitle(): string {
return msg("Providers");
}
pageDescription(): string {
override pageDescription(): string {
return msg("Provide support for protocols like SAML and OAuth to assigned applications.");
}
pageIcon(): string {
override pageIcon(): string {
return "pf-icon pf-icon-integration";
}
checkbox = true;
clearOnRefresh = true;
override checkbox = true;
override clearOnRefresh = true;
@property()
order = "name";
public order = "name";
async apiEndpoint(): Promise<PaginatedResponse<Provider>> {
public searchLabel = msg("Provider name");
public searchPlaceholder = msg("Search for providers…");
override async apiEndpoint(): Promise<PaginatedResponse<Provider>> {
return new ProvidersApi(DEFAULT_CONFIG).providersAllList(
await this.defaultEndpointConfig(),
);
}
columns(): TableColumn[] {
override columns(): TableColumn[] {
return [
new TableColumn(msg("Name"), "name"),
new TableColumn(msg("Application")),
@@ -63,8 +69,9 @@ export class ProviderListPage extends TablePage<Provider> {
];
}
renderToolbarSelected(): TemplateResult {
override renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Provider(s)")}
.objects=${this.selectedElements}
@@ -85,7 +92,7 @@ export class ProviderListPage extends TablePage<Provider> {
</ak-forms-delete-bulk>`;
}
rowApp(item: Provider): TemplateResult {
#rowApp(item: Provider): TemplateResult {
if (item.assignedApplicationName) {
return html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${msg("Assigned to application ")}
@@ -93,6 +100,7 @@ export class ProviderListPage extends TablePage<Provider> {
>${item.assignedApplicationName}</a
>`;
}
if (item.assignedBackchannelApplicationName) {
return html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${msg("Assigned to application (backchannel) ")}
@@ -100,15 +108,15 @@ export class ProviderListPage extends TablePage<Provider> {
>${item.assignedBackchannelApplicationName}</a
>`;
}
return html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i> ${msg(
"Warning: Provider not assigned to any application.",
)}`;
return html`<i aria-hidden="true" class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
${msg("Warning: Provider not assigned to any application.")}`;
}
row(item: Provider): TemplateResult[] {
override row(item: Provider): TemplateResult[] {
return [
html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`,
this.rowApp(item),
this.#rowApp(item),
html`${item.verboseName}`,
html`<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
@@ -121,16 +129,20 @@ export class ProviderListPage extends TablePage<Provider> {
type=${item.component}
>
</ak-proxy-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<button
aria-label=${msg("Edit provider")}
slot="trigger"
class="pf-c-button pf-m-plain"
>
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit"></i>
<i aria-hidden="true" class="fas fa-edit"></i>
</pf-tooltip>
</button>
</ak-forms-modal>`,
];
}
renderObjectCreate(): TemplateResult {
override renderObjectCreate(): TemplateResult {
return html`<ak-provider-wizard> </ak-provider-wizard> `;
}
}

View File

@@ -140,6 +140,8 @@ export function renderForm(
.errorMessages=${errors?.certificate ?? []}
>
<ak-crypto-certificate-search
label=${msg("Certificate")}
placeholder=${msg("Select a certificate...")}
certificate=${ifDefined(provider?.certificate ?? nothing)}
name="certificate"
>

View File

@@ -124,6 +124,7 @@ export function renderForm(
) {
return html` <ak-text-input
name="name"
placeholder=${msg("Provider name")}
label=${msg("Name")}
value=${ifDefined(provider?.name)}
required
@@ -135,6 +136,8 @@ export function renderForm(
required
>
<ak-flow-search
label=${msg("Authorization flow")}
placeholder=${msg("Select an authorization flow...")}
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${provider?.authorizationFlow}
required
@@ -197,6 +200,8 @@ export function renderForm(
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
<!-- NOTE: 'null' cast to 'undefined' on signingKey to satisfy Lit requirements -->
<ak-crypto-certificate-search
label=${msg("Signing Key")}
placeholder=${msg("Select a signing key...")}
certificate=${ifDefined(provider?.signingKey ?? undefined)}
singleton
></ak-crypto-certificate-search>
@@ -205,6 +210,8 @@ export function renderForm(
<ak-form-element-horizontal label=${msg("Encryption Key")} name="encryptionKey">
<!-- NOTE: 'null' cast to 'undefined' on encryptionKey to satisfy Lit requirements -->
<ak-crypto-certificate-search
label=${msg("Encryption Key")}
placeholder=${msg("Select an encryption key...")}
certificate=${ifDefined(provider?.encryptionKey ?? undefined)}
></ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">${msg("Key used to encrypt the tokens.")}</p>
@@ -219,6 +226,8 @@ export function renderForm(
label=${msg("Authentication flow")}
>
<ak-flow-search
label=${msg("Authentication flow")}
placeholder=${msg("Select an authentication flow...")}
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authenticationFlow}
></ak-flow-search>
@@ -234,6 +243,8 @@ export function renderForm(
required
>
<ak-flow-search
label=${msg("Invalidation flow")}
placeholder=${msg("Select an invalidation flow...")}
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
.currentFlow=${provider?.invalidationFlow}
defaultFlowSlug="default-provider-invalidation-flow"

View File

@@ -26,6 +26,7 @@ import {
RbacPermissionsAssignedByUsersListModelEnum,
User,
} from "@goauthentik/api";
import { IDGenerator } from "@goauthentik/core/id";
import MDProviderOAuth2 from "~docs/add-secure-apps/providers/oauth2/index.mdx";
@@ -267,12 +268,16 @@ export class OAuth2ProviderViewPage extends AKElement {
<div class="pf-c-card__body">
<form class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<label
class="pf-c-form__label"
for="${IDGenerator.elementID("providerInfo")}"
>
<span class="pf-c-form__label-text"
>${msg("OpenID Configuration URL")}</span
>
</label>
<input
id="${IDGenerator.elementID("providerInfo")}"
class="pf-c-form-control"
readonly
type="text"
@@ -280,12 +285,16 @@ export class OAuth2ProviderViewPage extends AKElement {
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<label
class="pf-c-form__label"
for="${IDGenerator.elementID("issuer")}"
>
<span class="pf-c-form__label-text"
>${msg("OpenID Configuration Issuer")}</span
>
</label>
<input
id="${IDGenerator.elementID("issuer")}"
class="pf-c-form-control"
readonly
type="text"
@@ -294,12 +303,16 @@ export class OAuth2ProviderViewPage extends AKElement {
</div>
<hr class="pf-c-divider" />
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<label
class="pf-c-form__label"
for="${IDGenerator.elementID("authorize")}"
>
<span class="pf-c-form__label-text"
>${msg("Authorize URL")}</span
>
</label>
<input
id="${IDGenerator.elementID("authorize")}"
class="pf-c-form-control"
readonly
type="text"
@@ -307,10 +320,14 @@ export class OAuth2ProviderViewPage extends AKElement {
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<label
class="pf-c-form__label"
for="${IDGenerator.elementID("token")}"
>
<span class="pf-c-form__label-text">${msg("Token URL")}</span>
</label>
<input
id="${IDGenerator.elementID("token")}"
class="pf-c-form-control"
readonly
type="text"
@@ -318,12 +335,16 @@ export class OAuth2ProviderViewPage extends AKElement {
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<label
class="pf-c-form__label"
for="${IDGenerator.elementID("userInfo")}"
>
<span class="pf-c-form__label-text"
>${msg("Userinfo URL")}</span
>
</label>
<input
id="${IDGenerator.elementID("userInfo")}"
class="pf-c-form-control"
readonly
type="text"
@@ -331,10 +352,14 @@ export class OAuth2ProviderViewPage extends AKElement {
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<label
class="pf-c-form__label"
for="${IDGenerator.elementID("logout")}"
>
<span class="pf-c-form__label-text">${msg("Logout URL")}</span>
</label>
<input
id="${IDGenerator.elementID("logout")}"
class="pf-c-form-control"
readonly
type="text"
@@ -342,10 +367,14 @@ export class OAuth2ProviderViewPage extends AKElement {
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<label
class="pf-c-form__label"
for="${IDGenerator.elementID("jwks")}"
>
<span class="pf-c-form__label-text">${msg("JWKS URL")}</span>
</label>
<input
id="${IDGenerator.elementID("jwks")}"
class="pf-c-form-control"
readonly
type="text"
@@ -391,9 +420,12 @@ export class OAuth2ProviderViewPage extends AKElement {
${renderDescriptionList(
[
[
msg("Preview for user"),
html`<label for="${IDGenerator.elementID("preview-user")}"
>${msg("Preview for user")}</label
>`,
html`
<ak-search-select
id="${IDGenerator.elementID("preview-user")}"
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
ordering: "username",

View File

@@ -45,6 +45,7 @@ export function renderForm(
<ak-text-input
name="name"
label=${msg("Name")}
placeholder=${msg("Provider name")}
value=${ifDefined(provider?.name)}
.errorMessages=${errors?.name ?? []}
required
@@ -58,6 +59,8 @@ export function renderForm(
.errorMessages=${errors?.authorizationFlow ?? []}
>
<ak-branded-flow-search
label=${msg("Authentication flow")}
placeholder=${msg("Select an authentication flow...")}
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authorizationFlow}
.brandFlow=${brand?.flowAuthentication}

View File

@@ -413,7 +413,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
`;
}
renderSidebarBefore(): TemplateResult {
protected renderSidebarBefore(): TemplateResult {
return html`<div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-card">
<div class="pf-c-card__title">${msg("User folders")}</div>

View File

@@ -292,7 +292,7 @@ export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "au
* @todo Can this be handled with a Lit Mixin?
*/
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
const element = document.body.querySelector<T>("[data-ak-interface-root]");
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
if (!element) {
throw new Error(

View File

@@ -1,10 +1,14 @@
import { SlottedTemplateResult } from "#elements/types";
import { html, nothing, TemplateResult } from "lit";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
export type DescriptionDesc = string | TemplateResult | undefined | typeof nothing;
export type DescriptionPair = [string, DescriptionDesc];
export type DescriptionRecord = { term: string; desc: DescriptionDesc };
export type DescriptionPair = [
term: SlottedTemplateResult,
desc: SlottedTemplateResult | undefined,
];
export type DescriptionRecord = { term: string; desc: SlottedTemplateResult | undefined };
interface DescriptionConfig {
horizontal?: boolean;

View File

@@ -1,18 +1,21 @@
import "#elements/forms/HorizontalFormElement";
import { SlottedTemplateResult } from "../elements/types";
import { AKElement, type AKElementProps } from "#elements/Base";
import { IDGenerator } from "@goauthentik/core/id";
import { html, nothing, TemplateResult } from "lit";
import { property } from "lit/decorators.js";
type HelpType = TemplateResult | typeof nothing;
import { ifDefined } from "lit/directives/if-defined.js";
export interface HorizontalLightComponentProps<T> extends AKElementProps {
name: string;
label?: string;
required?: boolean;
help?: string;
bighelp?: TemplateResult | TemplateResult[];
bighelp?: SlottedTemplateResult | SlottedTemplateResult[];
hidden?: boolean;
invalid?: boolean;
errorMessages?: string[];
@@ -20,7 +23,10 @@ export interface HorizontalLightComponentProps<T> extends AKElementProps {
inputHint?: string;
}
export class HorizontalLightComponent<T> extends AKElement {
export abstract class HorizontalLightComponent<T>
extends AKElement
implements HorizontalLightComponentProps<T>
{
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
@@ -46,7 +52,7 @@ export class HorizontalLightComponent<T> extends AKElement {
* @attribute
*/
@property({ type: String, reflect: true })
label = "";
label?: string;
/**
* @property
@@ -104,16 +110,19 @@ export class HorizontalLightComponent<T> extends AKElement {
* @attribute
*/
@property({ type: String, attribute: "input-hint" })
inputHint = "";
inputHint?: string;
protected renderControl() {
throw new Error("Must be implemented in a subclass");
}
renderHelp(): HelpType[] {
const bigHelp: HelpType[] = Array.isArray(this.bighelp)
protected fieldID = IDGenerator.elementID().toString();
protected renderHelp(): SlottedTemplateResult | SlottedTemplateResult[] {
const bigHelp: SlottedTemplateResult[] = Array.isArray(this.bighelp)
? this.bighelp
: [this.bighelp ?? nothing];
return [
this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing,
...bigHelp,
@@ -121,17 +130,16 @@ export class HorizontalLightComponent<T> extends AKElement {
}
render() {
// prettier-ignore
return html`<ak-form-element-horizontal
label=${this.label}
fieldID=${this.fieldID}
label=${ifDefined(this.label)}
?required=${this.required}
?hidden=${this.hidden}
name=${this.name}
.errorMessages=${this.errorMessages}
?invalid=${this.invalid}
>
${this.renderControl()}
${this.renderHelp()}
>
${this.renderControl()} ${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}

View File

@@ -6,8 +6,6 @@ import {
HorizontalLightComponentProps,
} from "./HorizontalLightComponent.js";
import { bound } from "#elements/decorators/bound";
import { msg } from "@lit/localize";
import { css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@@ -24,6 +22,8 @@ export interface AkHiddenTextInputProps extends BaseProps {
export type InputLike = HTMLTextAreaElement | HTMLInputElement;
export type InputListener = (ev: InputEvent) => void;
/**
* @element ak-hidden-text-input
* @class AkHiddenTextInput
@@ -72,6 +72,15 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
@property({ type: String })
public placeholder?: string;
/**
* Text for when the input has no set value
*
* @property
* @attribute
*/
@property({ type: String })
public label?: string;
/**
* Specify kind of help the browser should try to provide
*
@@ -98,28 +107,16 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
@query("#main > input")
protected inputField!: T;
@bound
private handleToggleVisibility() {
this.revealed = !this.revealed;
// Maintain focus on input after toggle
this.updateComplete.then(() => {
if (this.inputField && document.activeElement === this) {
this.inputField.focus();
}
});
}
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
// in the LightDom so the inner components actually inherit styling, the normal `css` options
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
// refresh.
protected renderInputField(setValue: (ev: InputEvent) => void, code: boolean) {
protected renderInputField(setValue: InputListener, code: boolean) {
return html` <input
style="flex: 1 1 auto; min-width: 0;"
part="input"
autocomplete=${ifDefined(this.autocomplete)}
type=${this.revealed ? "text" : "password"}
aria-label=${ifDefined(this.label)}
@input=${setValue}
value=${ifDefined(this.value)}
placeholder=${ifDefined(this.placeholder)}
@@ -134,12 +131,12 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
protected override renderControl() {
const code = this.inputHint === "code";
const setValue = (ev: InputEvent) => {
const setValue: InputListener = (ev) => {
this.value = (ev.target as T).value;
};
return html` <div style="display: flex; gap: 0.25rem">
${this.renderInputField(setValue, code)}
<!-- -->
<ak-visibility-toggle
part="toggle"
style="flex: 0 0 auto; align-self: flex-start"

View File

@@ -1,4 +1,8 @@
import { AkHiddenTextInput, type AkHiddenTextInputProps } from "./ak-hidden-text-input.js";
import {
AkHiddenTextInput,
type AkHiddenTextInputProps,
InputListener,
} from "./ak-hidden-text-input.js";
import { html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@@ -96,7 +100,7 @@ export class AkHiddenTextAreaInput
// in the LightDom so the inner components actually inherit styling, the normal `css` options
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
// refresh.
protected override renderInputField(setValue: (ev: InputEvent) => void, code: boolean) {
protected override renderInputField(setValue: InputListener, code: boolean) {
const wrap = this.revealed ? this.wrap : "soft";
return html`
@@ -105,6 +109,7 @@ export class AkHiddenTextAreaInput
part="textarea"
@input=${setValue}
placeholder=${ifDefined(this.placeholder)}
aria-label=${ifDefined(this.label)}
rows=${ifDefined(this.rows)}
cols=${ifDefined(this.cols)}
wrap=${ifDefined(wrap)}

View File

@@ -21,6 +21,7 @@ export class AkNumberInput extends HorizontalLightComponent<number> {
return html`<input
type="number"
@input=${setValue}
aria-label=${ifDefined(this.label)}
value=${ifDefined(this.value)}
min=${ifDefined(this.min)}
class="pf-c-form-control"

View File

@@ -3,9 +3,11 @@ import "#elements/forms/Radio";
import { HorizontalLightComponent } from "./HorizontalLightComponent.js";
import { RadioOption } from "#elements/forms/Radio";
import { SlottedTemplateResult } from "#elements/types";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-radio-input")
export class AkRadioInput<T> extends HorizontalLightComponent<T> {
@@ -21,20 +23,20 @@ export class AkRadioInput<T> extends HorizontalLightComponent<T> {
}
}
renderHelp() {
// This is weird, but Typescript says it's necessary?
return [nothing as typeof nothing];
protected override renderHelp(): SlottedTemplateResult {
return nothing;
}
renderControl() {
const helpText = this.help.trim();
return html`<ak-radio
label=${ifDefined(this.label)}
.options=${this.options}
.value=${this.value}
@input=${this.handleInput}
></ak-radio>
${this.help.trim()
? html`<p class="pf-c-form__helper-radio">${this.help}</p>`
: nothing}`;
${helpText ? html`<p class="pf-c-form__helper-radio">${helpText}</p>` : nothing}`;
}
}

View File

@@ -1,7 +1,10 @@
import { AKElement } from "#elements/Base";
import { IDGenerator } from "@goauthentik/core/id";
import { html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-switch-input")
export class AkSwitchInput extends AKElement {
@@ -34,12 +37,21 @@ export class AkSwitchInput extends AKElement {
@query("input.pf-c-switch__input[type=checkbox]")
checkbox!: HTMLInputElement;
#fieldID: string = IDGenerator.randomID();
render() {
const doCheck = this.checked ? this.checked : undefined;
const helpText = this.help.trim();
return html` <ak-form-element-horizontal name=${this.name} ?required=${this.required}>
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" ?checked=${doCheck} />
<label class="pf-c-switch" for="${this.#fieldID}">
<input
id="${this.#fieldID}"
class="pf-c-switch__input"
type="checkbox"
?checked=${doCheck}
aria-label=${ifDefined(this.label)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
@@ -47,7 +59,7 @@ export class AkSwitchInput extends AKElement {
</span>
<span class="pf-c-switch__label">${this.label}</span>
</label>
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
${helpText ? html`<p class="pf-c-form__helper-text">${helpText}</p>` : nothing}
</ak-form-element-horizontal>`;
}
}

View File

@@ -10,6 +10,9 @@ export class AkTextInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true })
value = "";
@property({ type: String })
autocomplete?: string;
@property({ type: String })
placeholder?: string;
@@ -22,14 +25,17 @@ export class AkTextInput extends HorizontalLightComponent<string> {
return html` <input
type="text"
role="textbox"
id=${ifDefined(this.fieldID)}
@input=${setValue}
value=${ifDefined(this.value)}
class="${classMap({
"pf-c-form-control": true,
"pf-m-monospace": code,
})}"
autocomplete=${ifDefined(code ? "off" : undefined)}
autocomplete=${ifDefined(code ? "off" : this.autocomplete)}
spellcheck=${ifDefined(code ? "false" : undefined)}
aria-label=${ifDefined(this.placeholder || this.label)}
placeholder=${ifDefined(this.placeholder)}
?required=${this.required}
/>`;

View File

@@ -28,6 +28,6 @@ export abstract class Interface extends AKElement {
public connectedCallback(): void {
super.connectedCallback();
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
this.dataset.testId = "interface-root";
}
}

View File

@@ -103,7 +103,7 @@ type ContentValue = SlottedTemplateResult | undefined;
*/
export function akLoadingOverlay(
properties: ILoadingOverlay = {},
content: ILoadingOverlayContent = {},
content: string | ILoadingOverlayContent = {},
) {
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
// slot-name.

View File

@@ -44,12 +44,20 @@ const testOptions = [
];
export const CheckboxGroup = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById("check-message-pad")!.innerHTML = `
<p>Values selected on target: ${ev.target.value.join(", ")}</p>
<p>Values sent in event: ${ev.detail.join(", ")}</p>
<p>Values present as data-ak-control: <kbd>${JSON.stringify(ev.target.json, null)}</kbd></p>`;
const displayChange = (event: CustomEvent<string[]>) => {
const target = event.target as AkCheckboxGroup;
document.getElementById("check-message-pad")!.innerHTML = /*html*/ `
<p>
Values selected on target: ${target.value.join(", ")}
</p>
<p>
Values sent in event: ${event.detail.join(", ")}
</p>
<p>
Values present as data-ak-control: <kbd>${JSON.stringify(target.json(), null)}</kbd>
</p>
`;
};
return container(
@@ -66,28 +74,32 @@ export const CheckboxGroup = () => {
);
};
type FDType = [string, string | FormDataEntryValue];
type FDType = [key: string, value: string | FormDataEntryValue];
export const FormCheckboxGroup = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
ev.preventDefault();
const formData = new FormData(ev.target);
const displayChange = (event: SubmitEvent) => {
event.preventDefault();
const valList = Array.from(formData)
.map(([_key, val]: FDType) => val)
.join(", ");
if (!(event.target instanceof HTMLFormElement)) {
throw new Error("Expected target to be a form element");
}
const fdList = Array.from(formData)
.map(
([key, val]: FDType) =>
`${encodeURIComponent(key)}=${encodeURIComponent(val as string)}`,
)
.join("&");
const formData = new FormData(event.target);
document.getElementById("check-message-pad")!.innerHTML = `
<p>Values as seen in \`form.formData\`: ${valList}</p>
<p>Values as seen in x-form-encoded format: <kbd>${fdList}</kbd></p>`;
const valList = Array.from(formData.values()).join(", ");
const fdList = Array.from(formData, ([key, val]: FDType) => {
return `${encodeURIComponent(key)}=${encodeURIComponent(val as string)}`;
}).join("&");
document.getElementById("check-message-pad")!.innerHTML = /*html*/ `
<p>
Values as seen in ${"`form.formData`"}: ${valList}
</p>
<p>
Values as seen in x-form-encoded format: <kbd>${fdList}</kbd>
</p>
`;
};
return container(
@@ -95,9 +107,9 @@ export const FormCheckboxGroup = () => {
FormData example. This variant emits the same events and exhibits the same behavior
as the above, but instead of monitoring for 'change' events on the checkbox group,
we monitor for the user pressing the 'submit' button. What is displayed is the
values as understood by the &lt;form&gt; object, via its internal \`formData\`
field, to demonstrate that this component works with forms as if it were a native
form element.
values as understood by the &lt;form&gt; object, via its internal
${"`form.formData`"} field, to demonstrate that this component works with forms as
if it were a native form element.
</p>
<form @submit=${displayChange}>

View File

@@ -116,8 +116,8 @@ export abstract class ModalButton extends AKElement {
* @abstract
*/
protected renderModal(): SlottedTemplateResult {
return html`<div class="pf-c-backdrop" @click=${this.#backdropListener}>
<div class="pf-l-bullseye">
return html`<div class="pf-c-backdrop" @click=${this.#backdropListener} role="presentation">
<div class="pf-l-bullseye" role="presentation">
<div
class="pf-c-modal-box ${this.size} ${this.locked ? "locked" : ""}"
role="dialog"

View File

@@ -20,7 +20,7 @@ import PFList from "@patternfly/patternfly/components/List/list.css";
type BulkDeleteMetadata = { key: string; value: string }[];
@customElement("ak-delete-objects-table")
export class DeleteObjectsTable<T> extends Table<T> {
export class DeleteObjectsTable<T extends object> extends Table<T> {
paginated = false;
@property({ attribute: false })
@@ -246,7 +246,7 @@ export class DeleteBulkForm<T> extends ModalButton {
declare global {
interface HTMLElementTagNameMap {
"ak-delete-objects-table": DeleteObjectsTable<unknown>;
"ak-forms-delete-bulk": DeleteBulkForm<unknown>;
"ak-delete-objects-table": DeleteObjectsTable<object>;
"ak-forms-delete-bulk": DeleteBulkForm<object>;
}
}

View File

@@ -100,7 +100,7 @@ export class ModalForm extends ModalButton {
);
};
renderModalInner(): TemplateResult {
protected renderModalInner(): TemplateResult {
return html`${this.loading
? html`<ak-loading-overlay topmost></ak-loading-overlay>`
: nothing}

View File

@@ -1,8 +1,8 @@
import { randomId } from "../utils/randomId.js";
import { AKElement } from "#elements/Base";
import { CustomEmitterElement } from "#elements/utils/eventEmitter";
import { IDGenerator } from "@goauthentik/core/id";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
@@ -22,15 +22,15 @@ export interface RadioOption<T> {
@customElement("ak-radio")
export class Radio<T> extends CustomEmitterElement(AKElement) {
@property({ attribute: false })
options: RadioOption<T>[] = [];
public options: RadioOption<T>[] = [];
@property()
name = "";
public name = "";
@property({ attribute: false })
value?: T;
public value?: T;
internalId: string;
#fieldID: string = this.name || IDGenerator.randomID();
static styles: CSSResult[] = [
PFBase,
@@ -46,16 +46,13 @@ export class Radio<T> extends CustomEmitterElement(AKElement) {
.pf-c-radio span {
user-select: none;
}
.pf-c-radio__description {
text-wrap: balance;
}
`,
];
constructor() {
super();
this.renderRadio = this.renderRadio.bind(this);
this.buildChangeHandler = this.buildChangeHandler.bind(this);
this.internalId = this.name || `radio-${randomId(8)}`;
}
// Set the value if it's not set already. Property changes inside the `willUpdate()` method do
// not trigger an element update.
willUpdate() {
@@ -71,42 +68,48 @@ export class Radio<T> extends CustomEmitterElement(AKElement) {
// radio loses its setting, and the selected radio gains its setting. We want radio buttons to
// present a unified event interface, so we prevent the event from triggering if the value is
// already set.
buildChangeHandler(option: RadioOption<T>) {
#buildChangeListener = (option: RadioOption<T>) => {
return (ev: Event) => {
// This is a controlled input. Stop the native event from escaping or affecting the
// value. We'll do that ourselves.
// value. We'll do that ourselves.
ev.stopPropagation();
if (option.disabled) {
return;
}
this.value = option.value;
this.dispatchCustomEvent("change", { value: option.value });
this.dispatchCustomEvent("input", { value: option.value });
};
}
};
renderRadio(option: RadioOption<T>, index: number) {
const elId = `${this.internalId}-${index}`;
const handler = this.buildChangeHandler(option);
return html`<div class="pf-c-radio" @click=${handler}>
#renderRadio = (option: RadioOption<T>, index: number) => {
const id = `${this.#fieldID}-${index}`;
const changeListener = this.#buildChangeListener(option);
return html`<div class="pf-c-radio" @click=${changeListener}>
<input
class="pf-c-radio__input"
type="radio"
name="${this.name}"
id=${elId}
aria-label=${option.label}
id=${id}
.checked=${option.value === this.value}
.disabled=${option.disabled}
/>
<label class="pf-c-radio__label" for=${elId}>${option.label}</label>
<label class="pf-c-radio__label" for=${id}>${option.label}</label>
${option.description
? html`<span class="pf-c-radio__description">${option.description}</span>`
: nothing}
</div>`;
}
};
render() {
return html`<div class="pf-c-form__group-control pf-m-stack">
${map(this.options, this.renderRadio)}
${map(this.options, this.#renderRadio)}
</div>`;
}
}

View File

@@ -31,73 +31,99 @@ export interface ISearchSelectBase<T> {
emptyOption: string;
}
export class SearchSelectBase<T> extends AkControlElement<string> implements ISearchSelectBase<T> {
export abstract class SearchSelectBase<T>
extends AkControlElement<string>
implements ISearchSelectBase<T>
{
static styles = [PFBase];
// A function which takes the query state object (accepting that it may be empty) and returns a
// new collection of objects.
fetchObjects!: (query?: string) => Promise<T[]>;
//#region Properties
// A function passed to this object that extracts a string representation of items of the
// collection under search.
renderElement!: (element: T) => string;
/**
* A function which takes the query state object (accepting that it may be empty)
* and returns a
* new collection of objects.
*/
public abstract fetchObjects: (query?: string) => Promise<T[]>;
// A function passed to this object that extracts an HTML representation of additional
// information for items of the collection under search.
renderDescription?: (element: T) => string | TemplateResult;
/**
* A function passed to this object that extracts a string representation of items of the
* collection under search.
*/
public abstract renderElement: (element: T) => string;
// A function which returns the currently selected object's primary key, used for serialization
// into forms.
value!: (element: T | undefined) => string;
/**
* A function passed to this object that extracts an HTML representation of additional
* information for items of the collection under search.
*/
public abstract renderDescription?: (element: T) => string | TemplateResult;
// A function passed to this object that determines an object in the collection under search
// should be automatically selected. Only used when the search itself is responsible for
// fetching the data; sets an initial default value.
selected?: (element: T, elements: T[]) => boolean;
/**
* A function which returns the currently selected object's primary key, used for serialization
* into forms.
*/
public abstract value: (element?: T) => string;
// A function passed to this object (or using the default below) that groups objects in the
// collection under search into categories.
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
return groupBy(items, () => {
return "";
});
/**
* A function passed to this object that determines an object in the collection under search
* should be automatically selected. Only used when the search itself is responsible for
* fetching the data; sets an initial default value.
*/
public abstract selected?: (element: T, elements: T[]) => boolean;
/**
* A function passed to this object (or using the default below) that groups objects in the
* collection under search into categories.
*/
public groupBy: (items: T[]) => [string, T[]][] = (items) => {
return groupBy(items, () => "");
};
// Whether or not the dropdown component can be left blank
@property({ type: Boolean })
blankable = false;
public blankable = false;
// An initial string to filter the search contents, and the value of the input which further
// serves to restrict the search
@property()
query?: string;
public query?: string;
// The objects currently available under search
@property({ attribute: false })
objects?: T[];
public objects?: T[];
// The currently selected object
@property({ attribute: false })
selectedObject?: T;
public selectedObject?: T;
// Used to inform the form of the name of the object
@property()
name?: string;
public name?: string;
// Used to inform the form of the input label.
@property()
public label?: string;
// The textual placeholder for the search's <input> object, if currently empty. Used as the
// native <input> object's `placeholder` field.
@property()
placeholder: string = msg("Select an object.");
public placeholder: string = msg("Select an object.");
// A textual string representing "The user has affirmed they want to leave the selection blank."
// Only used if `blankable` above is true.
@property()
emptyOption = "---------";
public emptyOption = "---------";
isFetchingData = false;
//#endregion
//#region State
#loading = false;
@state()
error?: APIError;
protected error?: APIError;
//#endregion
public toForm(): string {
if (!this.objects) {
@@ -121,26 +147,29 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
}
public async updateData() {
if (this.isFetchingData) {
if (this.#loading) {
return Promise.resolve();
}
this.isFetchingData = true;
this.#loading = true;
this.dispatchEvent(new Event("loading"));
return this.fetchObjects(this.query)
.then((nextObjects) => {
nextObjects.forEach((obj) => {
if (this.selected && this.selected(obj, nextObjects || [])) {
this.selectedObject = obj;
this.dispatchChangeEvent(this.selectedObject);
if (this.selected) {
for (const obj of nextObjects) {
if (this.selected(obj, nextObjects)) {
this.selectedObject = obj;
this.dispatchChangeEvent(this.selectedObject);
}
}
});
}
this.objects = nextObjects;
this.isFetchingData = false;
this.#loading = false;
})
.catch(async (error: unknown) => {
this.isFetchingData = false;
this.#loading = false;
this.objects = undefined;
const parsedError = await parseAPIResponseError(error);
@@ -163,9 +192,10 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
this.removeEventListener(EVENT_REFRESH, this.updateData);
}
private onSearch(event: InputEvent) {
#searchListener = (event: InputEvent) => {
const value = (event.target as SearchSelectView).rawValue;
if (value === undefined) {
if (!value) {
this.selectedObject = undefined;
return;
}
@@ -174,7 +204,7 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
this.updateData()?.then(() => {
this.dispatchChangeEvent(this.selectedObject);
});
}
};
private onSelect(event: InputEvent) {
const value = (event.target as SearchSelectView).value;
@@ -258,21 +288,22 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
.options=${options}
value=${ifDefined(value)}
?blankable=${this.blankable}
label=${ifDefined(this.label)}
name=${ifDefined(this.name)}
placeholder=${this.placeholder}
emptyOption=${ifDefined(this.blankable ? this.emptyOption : undefined)}
@input=${this.onSearch}
@input=${this.#searchListener}
@change=${this.onSelect}
></ak-search-select-view> `;
}
public override updated(changed: PropertyValues<this>) {
if (!this.isFetchingData && changed.has("objects")) {
if (!this.#loading && changed.has("objects")) {
this.dispatchEvent(new Event("ready"));
}
// It is not safe for automated tests to interact with this component while it is fetching
// data.
if (!this.isFetchingData) {
if (!this.#loading) {
this.setAttribute("data-ouia-component-safe", "true");
}
}

View File

@@ -47,18 +47,26 @@ export interface ISearchSelectEz<T> extends ISearchSelectBase<T> {
export class SearchSelectEz<T> extends SearchSelectBase<T> implements ISearchSelectEz<T> {
static styles = [...SearchSelectBase.styles];
@property({ type: Object, attribute: false })
config!: ISearchSelectApi<T>;
public fetchObjects!: (query?: string) => Promise<T[]>;
public renderElement!: (element: T) => string;
public renderDescription?: ((element: T) => string | TemplateResult) | undefined;
public value!: (element?: T | undefined) => string;
public selected?: ((element: T, elements: T[]) => boolean) | undefined;
connectedCallback() {
@property({ type: Object, attribute: false })
public config!: ISearchSelectApi<T>;
public override connectedCallback() {
this.fetchObjects = this.config.fetchObjects;
this.renderElement = this.config.renderElement;
this.renderDescription = this.config.renderDescription;
this.value = this.config.value;
this.selected = this.config.selected;
if (this.config.groupBy !== undefined) {
if (this.config.groupBy) {
this.groupBy = this.config.groupBy;
}
super.connectedCallback();
}
}

View File

@@ -5,12 +5,11 @@ import { findFlatOptions, findOptionsSubset, groupOptions, optionsToFlat } from
import { ListSelect } from "#elements/ak-list-select/ak-list-select";
import { AKElement } from "#elements/Base";
import { bound } from "#elements/decorators/bound";
import type { GroupedOptions, SelectOption, SelectOptions } from "#elements/types";
import { randomId } from "#elements/utils/randomId";
import { msg } from "@lit/localize";
import { html, nothing, PropertyValues } from "lit";
import { CSSResult, html, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref, Ref } from "lit/directives/ref.js";
@@ -70,7 +69,9 @@ export interface ISearchSelectView {
*/
@customElement("ak-search-select-view")
export class SearchSelectView extends AKElement implements ISearchSelectView {
static styles = [PFBase, PFForm, PFFormControl, PFSelect];
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl, PFSelect];
//#region Properties
/**
* The options collection. The simplest variant is just [key, label, optional<description>]. See
@@ -79,16 +80,16 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
* @prop
*/
@property({ type: Array, attribute: false })
set options(options: SelectOptions) {
this._options = groupOptions(options);
this.flatOptions = optionsToFlat(this._options);
public set options(options: SelectOptions) {
this.#options = groupOptions(options);
this.#flatOptions = optionsToFlat(this.#options);
}
get options() {
return this._options;
public get options() {
return this.#options;
}
_options!: GroupedOptions;
#options!: GroupedOptions;
/**
* The current value. Must be one of the keys in the options group above.
@@ -96,7 +97,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
* @prop
*/
@property({ type: String, reflect: true })
value?: string;
public value?: string;
/**
* Whether or not the dropdown is open
@@ -104,7 +105,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
* @attr
*/
@property({ type: Boolean, reflect: true })
open = false;
public open = false;
/**
* If set to true, this object MAY return undefined in no value is passed in and none is set
@@ -113,7 +114,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
* @attr
*/
@property({ type: Boolean })
blankable = false;
public blankable = false;
/**
* If not managed, make the matcher case-sensitive during interaction. If managed,
@@ -122,15 +123,23 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
* @attr
*/
@property({ type: Boolean, attribute: "case-sensitive" })
caseSensitive = false;
public caseSensitive = false;
/**
* The name of the input, for forms
* The name of the input, for forms.
*
* @attr
*/
@property({ type: String })
name?: string;
public name?: string;
/**
* The label of the input, for forms.
*
* @attr
*/
@property({ type: String })
public label?: string;
/**
* The textual placeholder for the search's <input> object, if currently empty. Used as the
@@ -139,7 +148,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
* @attr
*/
@property({ type: String })
placeholder: string = msg("Select an object.");
public placeholder: string = msg("Select an object.");
/**
* If true, the component only sends an input message up to a parent component. If false, the
@@ -149,7 +158,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
*@attr
*/
@property({ type: Boolean })
managed = false;
public managed = false;
/**
* A textual string representing "The user has affirmed they want to leave the selection blank."
@@ -158,36 +167,50 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
* @attr
*/
@property()
emptyOption = "---------";
public emptyOption = "---------";
// Handle the behavior of the drop-down when the :host scrolls off the page.
scrollHandler?: () => void;
//#endregion
// observer: IntersectionObserver;
//#region State
@state()
displayValue = "";
protected displayValue = "";
// Tracks when the inputRef is populated, so we can safely reschedule the
// render of the dropdown with respect to it.
@state()
inputRefIsAvailable = false;
protected inputRefIsAvailable = false;
/**
* Permanent identity with the portal so focus events can be checked.
*/
menuRef: Ref<ListSelect> = createRef();
#menuRef: Ref<ListSelect> = createRef();
/**
* Permanent identify for the input object, so the floating portal can find where to anchor
* itself.
*/
inputRef: Ref<HTMLInputElement> = createRef();
#inputRef: Ref<HTMLInputElement> = createRef();
/**
* Maps a value from the portal to labels to be put into the <input> field>
* Maps a value from the portal to labels to be put into the <input> field>
*/
flatOptions: [string, SelectOption][] = [];
#flatOptions: [string, SelectOption][] = [];
//#endregion
//#region Lifecycle
public override updated() {
this.setAttribute("data-ouia-component-safe", "true");
}
public override firstUpdated() {
// Route around Lit's scheduling algorithm complaining about re-renders
window.setTimeout(() => {
this.inputRefIsAvailable = Boolean(this.#inputRef?.value);
}, 0);
}
connectedCallback() {
super.connectedCallback();
@@ -203,24 +226,26 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
// TODO
}
@bound
onClick(_ev: Event) {
//#endregion
//#region Event Listeners
#clickListener = (_ev: Event) => {
this.open = !this.open;
this.inputRef.value?.focus();
}
this.#inputRef.value?.focus();
};
setFromMatchList(value: string | undefined) {
if (value === undefined) {
return;
}
const probableValue = this.flatOptions.find((option) => option[0] === this.value);
if (probableValue && this.inputRef.value) {
this.inputRef.value.value = probableValue[1][1];
const probableValue = this.#flatOptions.find((option) => option[0] === this.value);
if (probableValue && this.#inputRef.value) {
this.#inputRef.value.value = probableValue[1][1];
}
}
@bound
onKeydown(event: KeyboardEvent) {
#searchKeydownListener = (event: KeyboardEvent) => {
if (event.code === "Escape") {
event.stopPropagation();
this.open = false;
@@ -231,45 +256,44 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
if (event.code === "Tab" && this.open) {
event.preventDefault();
this.setFromMatchList(this.value);
this.menuRef.value?.currentElement?.focus();
this.#menuRef.value?.currentElement?.focus();
}
}
};
@bound
onListBlur(event: FocusEvent) {
#blurListener = (event: FocusEvent) => {
// If we lost focus but the menu got it, don't do anything;
const relatedTarget = event.relatedTarget as HTMLElement | undefined;
if (
relatedTarget &&
(this.contains(relatedTarget) ||
this.renderRoot.contains(relatedTarget) ||
this.menuRef.value?.contains(relatedTarget) ||
this.menuRef.value?.renderRoot.contains(relatedTarget))
this.#menuRef.value?.contains(relatedTarget) ||
this.#menuRef.value?.renderRoot.contains(relatedTarget))
) {
return;
}
this.open = false;
if (this.value === undefined) {
if (this.inputRef.value) {
this.inputRef.value.value = "";
if (!this.value) {
if (this.#inputRef.value) {
this.#inputRef.value.value = "";
}
this.setValue(undefined);
}
}
};
setValue(newValue: string | undefined) {
this.value = newValue;
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
}
findValueForInput() {
const value = this.inputRef.value?.value;
const value = this.#inputRef.value?.value;
if (value === undefined || value.trim() === "") {
this.setValue(undefined);
return;
}
const matchesFound = findFlatOptions(this.flatOptions, value);
const matchesFound = findFlatOptions(this.#flatOptions, value);
if (matchesFound.length > 0) {
const newValue = matchesFound[0][0];
if (newValue === value) {
@@ -281,47 +305,46 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
}
}
@bound
onInput(_ev: InputEvent) {
#inputListener = (_ev: InputEvent) => {
if (!this.managed) {
this.findValueForInput();
this.requestUpdate();
}
this.open = true;
}
};
@bound
onListKeydown(event: KeyboardEvent) {
#listKeydownListener = (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.open = false;
this.inputRef.value?.focus();
this.#inputRef.value?.focus();
}
if (event.key === "Tab" && event.shiftKey) {
event.preventDefault();
this.inputRef.value?.focus();
this.#inputRef.value?.focus();
}
}
};
@bound
onListChange(event: InputEvent) {
#changeListener = (event: InputEvent) => {
if (!event.target) {
return;
}
const value = (event.target as HTMLInputElement).value;
if (value !== undefined) {
if (value) {
const newDisplayValue = this.findDisplayForValue(value);
if (this.inputRef.value) {
this.inputRef.value.value = newDisplayValue ?? "";
if (this.#inputRef.value) {
this.#inputRef.value.value = newDisplayValue ?? "";
}
} else if (this.inputRef.value) {
this.inputRef.value.value = "";
} else if (this.#inputRef.value) {
this.#inputRef.value.value = "";
}
this.open = false;
this.setValue(value);
}
};
//#endregion
findDisplayForValue(value: string) {
const newDisplayValue = this.flatOptions.find((option) => option[0] === value);
const newDisplayValue = this.#flatOptions.find((option) => option[0] === value);
return newDisplayValue ? newDisplayValue[1][1] : undefined;
}
@@ -340,15 +363,17 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
}
get rawValue() {
return this.inputRef.value?.value ?? "";
return this.#inputRef.value?.value ?? "";
}
get managedOptions() {
return this.managed
? this._options
: findOptionsSubset(this._options, this.rawValue, this.caseSensitive);
? this.#options
: findOptionsSubset(this.#options, this.rawValue, this.caseSensitive);
}
//#region Render
public override render() {
const emptyOption = this.blankable ? this.emptyOption : undefined;
const open = this.open;
@@ -361,13 +386,15 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
autocomplete="off"
class="pf-c-form-control pf-c-select__toggle-typeahead"
type="text"
${ref(this.inputRef)}
${ref(this.#inputRef)}
placeholder=${this.placeholder}
aria-label=${ifDefined(this.label)}
name=${ifDefined(this.name)}
spellcheck="false"
@input=${this.onInput}
@click=${this.onClick}
@blur=${this.onListBlur}
@keydown=${this.onKeydown}
@input=${this.#inputListener}
@click=${this.#clickListener}
@blur=${this.#blurListener}
@keydown=${this.#searchKeydownListener}
value=${this.displayValue}
/>
</div>
@@ -377,34 +404,25 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
? html`
<ak-portal
name=${ifDefined(this.name)}
.anchor=${this.inputRef.value}
.anchor=${this.#inputRef.value}
?open=${open}
>
<ak-list-select
id="menu-${this.getAttribute("data-ouia-component-id")}"
${ref(this.menuRef)}
${ref(this.#menuRef)}
.options=${this.managedOptions}
value=${ifDefined(this.value)}
@change=${this.onListChange}
@blur=${this.onListBlur}
@change=${this.#changeListener}
@blur=${this.#blurListener}
emptyOption=${ifDefined(emptyOption)}
@keydown=${this.onListKeydown}
@keydown=${this.#listKeydownListener}
></ak-list-select>
</ak-portal>
`
: nothing}`;
}
public override updated() {
this.setAttribute("data-ouia-component-safe", "true");
}
public override firstUpdated() {
// Route around Lit's scheduling algorithm complaining about re-renders
window.setTimeout(() => {
this.inputRefIsAvailable = Boolean(this.inputRef?.value);
}, 0);
}
//#endregion
}
declare global {

View File

@@ -44,44 +44,28 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
* consequence of the user typing or when selecting from the list.
*
*/
@customElement("ak-search-select")
export class SearchSelect<T> extends SearchSelectBase<T> implements ISearchSelect<T> {
static styles = [...SearchSelectBase.styles];
// A function which takes the query state object (accepting that it may be empty) and returns a
// new collection of objects.
@property({ attribute: false })
fetchObjects!: (query?: string) => Promise<T[]>;
public fetchObjects!: (query?: string) => Promise<T[]>;
// A function passed to this object that extracts a string representation of items of the
// collection under search.
@property({ attribute: false })
renderElement!: (element: T) => string;
public renderElement!: (element: T) => string;
// A function passed to this object that extracts an HTML representation of additional
// information for items of the collection under search.
@property({ attribute: false })
renderDescription?: (element: T) => string | TemplateResult;
public renderDescription?: (element: T) => string | TemplateResult;
// A function which returns the currently selected object's primary key, used for serialization
// into forms.
@property({ attribute: false })
value!: (element: T | undefined) => string;
public value!: (element?: T) => string;
// A function passed to this object that determines an object in the collection under search
// should be automatically selected. Only used when the search itself is responsible for
// fetching the data; sets an initial default value.
@property({ attribute: false })
selected?: (element: T, elements: T[]) => boolean;
public selected?: (element: T, elements: T[]) => boolean;
// A function passed to this object (or using the default below) that groups objects in the
// collection under search into categories.
@property({ attribute: false })
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
return groupBy(items, () => {
return "";
});
public groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
return groupBy(items, () => "");
};
}

View File

@@ -5,6 +5,9 @@ import "#elements/chips/ChipGroup";
import "#elements/table/TablePagination";
import "#elements/table/TableSearch";
import { TableLike } from "./shared.js";
import { TableColumn } from "./TableColumn.js";
import { EVENT_REFRESH } from "#common/constants";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { uiConfig } from "#common/ui/config";
@@ -17,7 +20,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { Pagination } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { msg, str } from "@lit/localize";
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
@@ -32,10 +35,8 @@ import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface TableLike {
order?: string;
fetch: () => void;
}
export * from "./shared.js";
export * from "./TableColumn.js";
export interface PaginatedResponse<T> {
pagination: Pagination;
@@ -44,138 +45,10 @@ export interface PaginatedResponse<T> {
results: Array<T>;
}
export class TableColumn {
title: string;
orderBy?: string;
onClick?: () => void;
constructor(title: string, orderBy?: string) {
this.title = title;
this.orderBy = orderBy;
}
headerClickHandler(table: TableLike): void {
if (!this.orderBy) {
return;
}
table.order = table.order === this.orderBy ? `-${this.orderBy}` : this.orderBy;
table.fetch();
}
private getSortIndicator(table: TableLike): 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: TableLike): 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">${this.title}</span>
<span class="pf-c-table__sort-indicator">
<i class="fas ${this.getSortIndicator(table)}"></i>
</span>
</div>
</button>`;
}
render(table: TableLike): TemplateResult {
const classes = {
"pf-c-table__sort": !!this.orderBy,
"pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`,
};
return html`<th role="columnheader" scope="col" class="${classMap(classes)}">
${this.orderBy ? this.renderSortable(table) : html`${this.title}`}
</th>`;
}
}
export abstract class Table<T> extends WithLicenseSummary(AKElement) implements TableLike {
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
abstract columns(): TableColumn[];
abstract row(item: T): SlottedTemplateResult[];
private isLoading = false;
#pageParam = `${this.tagName.toLowerCase()}-page`;
#searchParam = `${this.tagName.toLowerCase()}-search`;
@property({ type: Boolean })
supportsQL: boolean = false;
searchEnabled(): boolean {
return false;
}
renderExpanded(_item: T): SlottedTemplateResult {
if (this.expandable) {
throw new Error("Expandable is enabled but renderExpanded is not overridden!");
}
return nothing;
}
@property({ attribute: false })
data?: PaginatedResponse<T>;
@property({ type: Number })
page = getURLParam(this.#pageParam, 1);
/**
* Set if your `selectedElements` use of the selection box is to enable bulk-delete,
* so that stale data is cleared out when the API returns a new list minus the deleted entries.
*
* @prop
*/
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
clearOnRefresh = false;
@property({ type: String })
order?: string;
@property({ type: String })
search: string = "";
@property({ type: Boolean })
checkbox = false;
@property({ type: Boolean })
clickable = false;
@property({ attribute: false })
clickHandler: (item: T) => void = () => {};
@property({ type: Boolean })
radioSelect = false;
@property({ type: Boolean })
checkboxChip = false;
@property({ attribute: false })
selectedElements: T[] = [];
@property({ type: Boolean })
paginated = true;
@property({ type: Boolean })
expandable = false;
@property({ attribute: false })
expandedElements: T[] = [];
@state()
error?: APIError;
export abstract class Table<T extends object>
extends WithLicenseSummary(AKElement)
implements TableLike
{
static styles: CSSResult[] = [
PFBase,
PFTable,
@@ -213,16 +86,124 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
`,
];
constructor() {
super();
this.addEventListener(EVENT_REFRESH, async () => {
await this.fetch();
});
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
abstract columns(): TableColumn[];
abstract row(item: T): SlottedTemplateResult[];
#loading = false;
#pageParam = `${this.tagName.toLowerCase()}-page`;
#searchParam = `${this.tagName.toLowerCase()}-search`;
@property({ type: Boolean })
public supportsQL: boolean = false;
//#region Properties
@property({ type: String })
public toolbarLabel = msg("Table actions");
@property({ type: String })
public label?: string;
@property({ attribute: false })
public data?: PaginatedResponse<T>;
@property({ type: Number })
public page = getURLParam(this.#pageParam, 1);
/**
* Set if your `selectedElements` use of the selection box is to enable bulk-delete,
* so that stale data is cleared out when the API returns a new list minus the deleted entries.
*
* @prop
*/
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
public clearOnRefresh = false;
@property({ type: String })
public order?: string;
@property({ type: String })
public search: string = "";
@property({ type: Boolean })
public checkbox = false;
@property({ type: Boolean })
public clickable = false;
@property({ attribute: false })
public clickHandler: (item: T) => void = () => {};
@property({ type: Boolean })
public radioSelect = false;
@property({ type: Boolean })
public checkboxChip = false;
@property({ attribute: false })
public selectedElements: T[] = [];
@property({ type: Boolean })
public paginated = true;
@property({ type: Boolean })
public expandable = false;
@property({ attribute: false })
public expandedElements: T[] = [];
@property({ attribute: false })
public searchLabel?: string;
@property({ attribute: false })
public searchPlaceholder?: string;
//#endregion
//#region Lifecycle
@state()
protected error?: APIError;
#refreshListener = () => {
return this.fetch();
};
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener(EVENT_REFRESH, this.#refreshListener);
if (this.searchEnabled()) {
this.search = getURLParam(this.#searchParam, "");
}
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(EVENT_REFRESH, this.#refreshListener);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("page")) {
updateURLParams({
[this.#pageParam]: this.page,
});
}
if (changedProperties.has("search")) {
updateURLParams({
[this.#searchParam]: this.search,
});
}
}
firstUpdated(): void {
this.fetch();
}
//#endregion
async defaultEndpointConfig() {
return {
ordering: this.order,
@@ -232,16 +213,12 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
};
}
public groupBy(items: T[]): [SlottedTemplateResult, T[]][] {
return groupBy(items, () => {
return "";
});
}
public fetch(): Promise<void> {
if (this.#loading) {
return Promise.resolve();
}
public async fetch(): Promise<void> {
if (this.isLoading) return;
this.isLoading = true;
this.#loading = true;
return this.apiEndpoint()
.then((data) => {
@@ -289,12 +266,14 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
this.error = await parseAPIResponseError(error);
})
.finally(() => {
this.isLoading = false;
this.#loading = false;
this.requestUpdate();
});
}
private renderLoading(): TemplateResult {
//#region Render
protected renderLoading(): TemplateResult {
return html`<tr role="row">
<td role="cell" colspan="25">
<div class="pf-l-bullseye">
@@ -320,11 +299,21 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
</tbody>`;
}
renderObjectCreate(): SlottedTemplateResult {
/**
* Render the create object button.
*
* @abstract
*/
protected renderObjectCreate(): SlottedTemplateResult {
return nothing;
}
renderError(): SlottedTemplateResult {
/**
* Render the error state.
*
* @abstract
*/
protected renderError(): SlottedTemplateResult {
if (!this.error) return nothing;
return html`<ak-empty-state icon="fa-ban"
@@ -333,11 +322,27 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
</ak-empty-state>`;
}
//#region Rows
/**
* Render a row for a given item.
*
* @param item The item to render.
*/
protected rowLabel<T extends object>(item: T): string | typeof nothing {
const name = "name" in item && typeof item.name === "string" ? item.name.trim() : null;
if (!name) {
return nothing;
}
return msg(str`${name}`);
}
private renderRows(): TemplateResult[] | undefined {
if (this.error) {
return [this.renderEmpty(this.renderError())];
}
if (!this.data || this.isLoading) {
if (!this.data || this.#loading) {
return [this.renderLoading()];
}
if (this.data.pagination.count === 0) {
@@ -357,7 +362,23 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
});
}
//#region Grouping
public groupBy(items: T[]): [SlottedTemplateResult, T[]][] {
return groupBy(items, () => "");
}
renderExpanded(_item: T): SlottedTemplateResult {
if (this.expandable) {
throw new Error("Expandable is enabled but renderExpanded is not overridden!");
}
return nothing;
}
private renderRowGroup(items: T[]): TemplateResult[] {
const columns = this.columns();
return items.map((item) => {
const itemSelectHandler = (ev: InputEvent | PointerEvent) => {
const target = ev.target as HTMLElement;
@@ -388,7 +409,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
};
const renderCheckbox = () =>
html`<td class="pf-c-table__check" role="cell">
html`<td aria-label="${msg("Select row")}" class="pf-c-table__check" role="button">
<label class="ignore-click"
><input
type="checkbox"
@@ -428,9 +449,9 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
</td>`;
};
return html`<tbody role="rowgroup" class="${classMap(expandedClass)}">
return html`<tbody class="${classMap(expandedClass)}">
<tr
role="row"
aria-label="${this.rowLabel(item)}"
class="${this.checkbox || this.clickable ? "pf-m-hoverable" : ""}"
@click=${this.clickable
? () => {
@@ -441,7 +462,13 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
${this.checkbox ? renderCheckbox() : nothing}
${this.expandable ? renderExpansion() : nothing}
${this.row(item).map((column, columnIndex) => {
return html`<td data-column-index="${columnIndex}" role="cell">
const columnLabel = columns[columnIndex]?.title;
return html`<td
aria-label=${ifDefined(columnLabel)}
data-column-index="${columnIndex}"
role="cell"
>
${column}
</td>`;
})}
@@ -454,7 +481,11 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
});
}
renderToolbar(): TemplateResult {
//#endregion
//#region Toolbar
protected renderToolbar(): TemplateResult {
return html` ${this.renderObjectCreate()}
<ak-spinner-button
.callAction=${() => {
@@ -474,57 +505,64 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
return nothing;
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("page")) {
updateURLParams({
[this.#pageParam]: this.page,
});
}
if (changedProperties.has("search")) {
updateURLParams({
[this.#searchParam]: this.search,
});
}
}
renderSearch(): TemplateResult {
const runSearch = (value: string) => {
this.search = value;
this.page = 1;
this.fetch();
};
const isQL = this.supportsQL && this.hasEnterpriseLicense;
return !this.searchEnabled()
? html``
: html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
<ak-table-search
?supportsQL=${this.supportsQL}
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
value=${ifDefined(this.search)}
.onSearch=${runSearch}
.apiResponse=${this.data}
>
</ak-table-search>
</div>`;
}
protected renderToolbarContainer(): SlottedTemplateResult {
return html`<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
return html`<header class="pf-c-toolbar" role="toolbar" aria-label="${this.toolbarLabel}">
<div role="presentation" class="pf-c-toolbar__content">
${this.renderSearch()}
<div class="pf-c-toolbar__bulk-select">${this.renderToolbar()}</div>
<div class="pf-c-toolbar__group">${this.renderToolbarAfter()}</div>
<div class="pf-c-toolbar__group">${this.renderToolbarSelected()}</div>
${this.paginated ? this.renderTablePagination() : html``}
<div role="presentation" class="pf-c-toolbar__bulk-select">
${this.renderToolbar()}
</div>
<div role="presentation" class="pf-c-toolbar__group">
${this.renderToolbarAfter()}
</div>
<div role="presentation" class="pf-c-toolbar__group">
${this.renderToolbarSelected()}
</div>
${this.paginated ? this.renderTablePagination() : nothing}
</div>
</header>`;
}
//#endregion
//#region Search
#searchListener = (value: string) => {
this.search = value;
this.page = 1;
this.fetch();
};
protected searchEnabled(): boolean {
return false;
}
protected renderSearch(): SlottedTemplateResult {
if (!this.searchEnabled()) {
return nothing;
}
const isQL = this.supportsQL && this.hasEnterpriseLicense;
return html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
<ak-table-search
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
value=${ifDefined(this.search)}
label=${ifDefined(this.searchLabel)}
placeholder=${ifDefined(this.searchPlaceholder)}
.onSearch=${this.#searchListener}
>
</ak-table-search>
</div>`;
}
firstUpdated(): void {
this.fetch();
}
//#endregion
/* The checkbox on the table header row that allows the user to "activate all on this page,"
//#region Chips
/**
* The checkbox on the table header row that allows the user to
* "activate all on this page,"
* "deactivate all on this page" with a single click.
*/
renderAllOnThisPageCheckbox(): TemplateResult {
@@ -549,11 +587,13 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
</td>`;
}
/* For very large tables where the user is selecting a limited number of entries, we provide a
* chip-based subtable at the top that shows the list of selected entries. Long text result in
* ellipsized chips, which is sub-optimal.
/**
* For very large tables where the user is selecting a limited number of entries,
* we provide a chip-based subtable at the top that shows the list of selected entries.
*
* Long text result in ellipsized chips, which is sub-optimal.
*/
renderSelectedChip(_item: T): SlottedTemplateResult {
protected renderSelectedChip(_item: T): SlottedTemplateResult {
// Override this for chip-based displays
return nothing;
}
@@ -570,7 +610,9 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
</ak-chip-group>`;
}
/* A simple pagination display, shown at both the top and bottom of the page. */
/**
* A simple pagination display, shown at both the top and bottom of the page.
*/
protected renderTablePagination(): SlottedTemplateResult {
const handler = (page: number) => {
this.page = page;
@@ -591,19 +633,22 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
const renderBottomPagination = () =>
html`<div class="pf-c-pagination pf-m-bottom">${this.renderTablePagination()}</div>`;
return html`${this.needChipGroup ? this.renderChipGroup() : html``}
return html`${this.needChipGroup ? this.renderChipGroup() : nothing}
${this.renderToolbarContainer()}
<table class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
<thead>
<tr role="row" class="pf-c-table__header-row">
${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``}
${this.expandable ? html`<td role="cell"></td>` : html``}
<table
aria-label=${this.label ? msg(str`Table of ${this.label}`) : msg("Table content")}
class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable"
>
<thead aria-label=${msg("Table actions")}>
<tr role="presentation" class="pf-c-table__header-row">
${this.checkbox ? this.renderAllOnThisPageCheckbox() : nothing}
${this.expandable ? html`<td role="cell"></td>` : nothing}
${this.columns().map((col) => col.render(this))}
</tr>
</thead>
${this.renderRows()}
</table>
${this.paginated ? renderBottomPagination() : html``}`;
${this.paginated ? renderBottomPagination() : nothing}`;
}
render(): TemplateResult {

View File

@@ -0,0 +1,81 @@
import { TableLike } from "#elements/table/shared";
import { html, TemplateResult } from "lit";
import { classMap } from "lit/directives/class-map.js";
type ARIASort = "ascending" | "descending" | "none" | "other";
export class TableColumn {
title: string;
orderBy?: string;
onClick?: () => void;
constructor(title: string, orderBy?: string) {
this.title = title;
this.orderBy = orderBy;
}
//#region Sorting
#sortButtonListener(table: TableLike): void {
if (!this.orderBy) {
return;
}
table.order = table.order === this.orderBy ? `-${this.orderBy}` : this.orderBy;
table.fetch();
}
private getSortIndicator(table: TableLike): string {
switch (this.getARIASort(table)) {
case "ascending":
return "fa-long-arrow-alt-up";
case "descending":
return "fa-long-arrow-alt-down";
default:
return "fa-arrows-alt-v";
}
}
public getARIASort(table: TableLike): ARIASort {
switch (table.order) {
case this.orderBy:
return "ascending";
case `-${this.orderBy}`:
return "descending";
default:
return "none";
}
}
protected renderSortable(table: TableLike): TemplateResult {
return html` <button
class="pf-c-table__button"
@click=${() => this.#sortButtonListener(table)}
>
<div class="pf-c-table__button-content">
<span class="pf-c-table__text">${this.title}</span>
<span class="pf-c-table__sort-indicator">
<i aria-hidden="true" class="fas ${this.getSortIndicator(table)}"></i>
</span>
</div>
</button>`;
}
public render(table: TableLike): TemplateResult {
const classes = {
"pf-c-table__sort": !!this.orderBy,
"pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`,
};
return html`<th
role="columnheader"
scope="col"
aria-sort=${this.getARIASort(table)}
class="${classMap(classes)}"
>
${this.orderBy ? this.renderSortable(table) : html`${this.title}`}
</th>`;
}
}

View File

@@ -49,10 +49,11 @@ export abstract class TableModal<T extends object> extends Table<T> {
MODAL_BUTTON_STYLES,
];
public async fetch(): Promise<void> {
public override async fetch(): Promise<void> {
if (!this.open) {
return;
}
return super.fetch();
}

View File

@@ -3,22 +3,25 @@ import { AKElement } from "#elements/Base";
import { Pagination } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
import { css, CSSResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export type TablePageChangeListener = (page: number) => void;
@customElement("ak-table-pagination")
export class TablePagination extends AKElement {
@property({ type: String })
label?: string;
@property({ attribute: false })
pages?: Pagination;
@property({ attribute: false })
pageChangeHandler: (page: number) => void = () => {
return;
};
onPageChange?: TablePageChangeListener;
static styles: CSSResult[] = [
PFBase,
@@ -35,28 +38,38 @@ export class TablePagination extends AKElement {
`,
];
render(): TemplateResult {
#navigatePrevious = () => {
this.onPageChange?.(this.pages?.previous || 0);
};
#navigateNext = () => {
this.onPageChange?.(this.pages?.next || 0);
};
render() {
if (!this.pages) {
return html``;
return nothing;
}
return html` <div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
return html` <nav
aria-label=${this.label || msg("Table pagination")}
class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md"
>
<div class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
<span role="heading" aria-level="4" class="pf-c-options-menu__toggle-text">
${msg(
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
)}
</span>
</div>
</div>
<nav class="pf-c-pagination__nav" aria-label=${msg("Pagination")}>
<div class="pf-c-pagination__nav">
<div class="pf-c-pagination__nav-control pf-m-prev">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.pageChangeHandler(this.pages?.previous || 0);
}}
@click=${this.#navigatePrevious}
?disabled="${(this.pages?.previous || 0) < 1}"
aria-label="${msg("Go to previous page")}"
>
@@ -66,18 +79,16 @@ export class TablePagination extends AKElement {
<div class="pf-c-pagination__nav-control pf-m-next">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.pageChangeHandler(this.pages?.next || 0);
}}
@click=${this.#navigateNext}
?disabled="${(this.pages?.next || 0) <= 0}"
aria-label="${msg("Go to next page")}"
>
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
</nav>
</div>
</div>
</div>`;
</nav>`;
}
}

View File

@@ -18,16 +18,16 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-table-search")
export class TableSearch extends WithLicenseSummary(AKElement) {
@property()
value?: string;
public value?: string;
@property({ type: Boolean })
supportsQL: boolean = false;
public supportsQL: boolean = false;
@property({ attribute: false })
apiResponse?: PaginatedResponse<unknown>;
public apiResponse?: PaginatedResponse<unknown>;
@property()
onSearch?: (value: string) => void;
public onSearch?: (value: string) => void;
static styles: CSSResult[] = [
PFBase,
@@ -45,6 +45,29 @@ export class TableSearch extends WithLicenseSummary(AKElement) {
`,
];
public reset = () => {
if (!this.onSearch) return;
this.value = "";
this.onSearch("");
};
#submitListener = (event: SubmitEvent) => {
event.preventDefault();
if (!this.onSearch) return;
const form = event.target as HTMLFormElement;
const data = new FormData(form);
const value = data.get("search")?.toString().trim();
if (!value) {
return;
}
this.onSearch(value);
};
renderInput(): TemplateResult {
if (this.supportsQL && this.hasEnterpriseLicense) {
return html`<ak-search-ql
@@ -57,51 +80,28 @@ export class TableSearch extends WithLicenseSummary(AKElement) {
name="search"
></ak-search-ql>`;
}
return html`<input
class="pf-c-form-control"
name="search"
type="search"
placeholder=${msg("Search...")}
value="${ifDefined(this.value)}"
@search=${(ev: Event) => {
if (!this.onSearch) return;
this.onSearch((ev.target as HTMLInputElement).value);
}}
/>`;
}
render(): TemplateResult {
return html`<form
class="pf-c-input-group"
method="get"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
if (!this.onSearch) return;
const el = this.shadowRoot?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"[name=search]",
);
if (!el) return;
if (el.value === "") return;
this.onSearch(el?.value);
}}
>
return html`<form class="pf-c-input-group" method="get" @submit=${this.#submitListener}>
${this.renderInput()}
<button
aria-label=${msg("Clear search")}
class="pf-c-button pf-m-control"
type="reset"
@click=${() => {
if (!this.onSearch) return;
this.value = "";
this.onSearch("");
}}
@click=${this.reset}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<button class="pf-c-button pf-m-control" type="submit">
<button aria-label=${msg("Search")} type="submit" class="pf-c-button pf-m-control">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
</form>`;

View File

@@ -0,0 +1,13 @@
import { Pagination } from "@goauthentik/api";
export interface TableLike {
order?: string;
fetch: () => void;
}
export interface PaginatedResponse<T> {
pagination: Pagination;
autocomplete?: { [key: string]: string };
results: Array<T>;
}

View File

@@ -184,7 +184,7 @@ export class LibraryPage extends AKElement {
render() {
return html`<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
<div class="pf-c-content header">
<h1 role="heading" aria-level="1" id="library-page-title">
<h1 role="heading" aria-level="1" data-test-id="page-heading">
${msg("My applications")}
</h1>
${this.uiConfig.searchEnabled ? this.renderSearch() : nothing}
@@ -193,3 +193,13 @@ export class LibraryPage extends AKElement {
</main>`;
}
}
declare global {
interface PageTestIDMap {
heading: HTMLHeadingElement;
}
interface TestIDSelectorMap {
page: PageTestIDMap;
}
}

View File

@@ -0,0 +1,217 @@
import { expect, test } from "#e2e";
import { createRandomName } from "#e2e/utils/generators";
import { ConsoleLogger } from "#logger/node";
import { IDGenerator } from "@goauthentik/core/id";
import { series } from "@goauthentik/core/promises";
test.describe("Provider Wizard", () => {
const providerNames = new Map<string, string>();
//#region Lifecycle
test.beforeEach("Configure Providers", async ({ page, session }, { testId }) => {
const seed = IDGenerator.randomID(6);
const providerName = `${createRandomName({ seed })} (${seed})`;
providerNames.set(testId, providerName);
const wizard = page.getByRole("dialog", { name: "New provider" });
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/core/providers",
});
});
await test.step("Navigate to provider wizard", async () => {
await expect(wizard, "Wizard is initially closed").toBeHidden();
await page.getByRole("button", { name: "New Provider" }).click();
await expect(wizard, "Wizard opens after clicking on New Provider").toBeVisible();
await expect(
page.getByRole("listbox", { name: "Select a provider type" }),
"Wizard opens with a list of provider types",
).toBeVisible();
await expect(
wizard.getByRole("navigation").getByRole("button", {
name: /next|finish/i,
}),
"Wizard can't be navigated to next step",
).toBeDisabled();
});
});
test.afterEach("Verification", async ({ page }, { testId }) => {
//#region Confirm provider
const providerName = providerNames.get(testId)!;
const $provider = await test.step("Find provider via search", async () => {
const searchInput = page.getByRole("search").getByPlaceholder("Search for providers");
await searchInput.fill(providerName);
// We have to wait for the provider to appear in the table,
// but several UI elements will be rendered asynchronously.
// We attempt several times to find the provider to avoid flakiness.
const tries = 10;
let found = false;
for (let i = 0; i < tries; i++) {
await searchInput.press("Enter");
await searchInput.blur();
const $rowEntry = page.getByRole("row", {
name: providerName,
});
ConsoleLogger.info(
`${i + 1}/${tries} Waiting for provider ${providerName} to appear in the table`,
);
found = await $rowEntry
.waitFor({
timeout: 1500,
})
.then(() => true)
.catch(() => false);
if (found) {
ConsoleLogger.info(`Provider ${providerName} found in the table`);
return $rowEntry;
}
}
throw new Error(`Provider ${providerName} not found in the table`);
});
await expect($provider, "Provider is visible").toBeVisible();
//#endregion
});
//#endregion
//#region OAuth2
test("Simple OAuth2 Provider", async ({ form, pointer }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, selectSearchValue } = form;
const { click } = pointer;
await series(
[click, "OAuth2/OpenID", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[
selectSearchValue,
"Authorization flow",
/default-provider-authorization-explicit-consent/,
],
[click, "Finish"],
);
});
test("Complete OAuth2 Provider", async ({ page, form, pointer }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, selectSearchValue, setFormGroup, setRadio, setInputCheck } = form;
const { click } = pointer;
const $clientSecretInput = page.getByRole("textbox", { name: "Client Secret" });
await series(
[click, "OAuth2/OpenID", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[
selectSearchValue,
"Authorization flow",
/default-provider-authorization-explicit-consent/,
],
[setFormGroup, "Protocol settings", true],
[setRadio, "Client Type", "Public"],
[
expect(
$clientSecretInput,
"Client Secret should be hidden when Client Type is Public",
).toBeHidden,
],
[setRadio, "Client Type", "Confidential"],
[
expect(
$clientSecretInput,
"Client Secret should be visible when Client Type is Confidential",
).toBeVisible,
],
[selectSearchValue, "Signing Key", /authentik Self-signed Certificate/],
[selectSearchValue, "Encryption Key", /authentik Self-signed Certificate/],
[setFormGroup, "Advanced flow settings", true],
[selectSearchValue, "Authentication flow", /default-source-authentication/],
[selectSearchValue, "Invalidation flow", /default-invalidation-flow/],
[setFormGroup, "Advanced protocol settings", true],
[fill, "Access code validity", "minutes=2"],
[fill, "Access token validity", "minutes=10"],
[fill, "Refresh token validity", "days=40"],
[setInputCheck, "Include claims in id_token", false],
[setRadio, "Subject mode", "Based on the User's username"],
[setRadio, "Issuer mode", "Same identifier is used for all providers"],
[setFormGroup, "Machine-to-Machine authentication settings", true],
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
);
});
//#endregion
//#region LDAP
test("Complete LDAP Provider", async ({ page, pointer, form }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, setFormGroup, selectSearchValue, setInputCheck, setRadio } = form;
const { click } = pointer;
await series(
[click, "LDAP", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[setFormGroup, "Flow settings", true],
[setFormGroup, "Protocol settings", true],
[selectSearchValue, "Bind flow", /default-authentication-flow/],
[fill, "Base DN", "DC=ldap-2,DC=goauthentik,DC=io"],
[selectSearchValue, "Certificate", /authentik Self-signed Certificate/],
[fill, "TLS Server name", "goauthentik.io"],
[fill, "UID start number", "2001"],
[fill, "GID start number", "4001"],
[setRadio, "Search mode", "Direct querying"],
[setRadio, "Bind mode", "Direct binding"],
[setInputCheck, "MFA Support", false],
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
);
});
//#endregion
//#region RADIUS
test("Complete RADIUS Provider", async ({ page, pointer, form }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, selectSearchValue } = form;
const { click } = pointer;
await series(
[click, "RADIUS", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[selectSearchValue, "Authentication flow", /default-authentication-flow/],
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
);
});
//#endregion
});

View File

@@ -0,0 +1,35 @@
import { expect, test } from "#e2e";
import {
BAD_PASSWORD,
BAD_USERNAME,
GOOD_PASSWORD,
GOOD_USERNAME,
} from "#e2e/fixtures/SessionFixture";
test.beforeEach(async ({ session }) => {
await session.toLoginPage();
});
test.describe("Session management", () => {
test("Login with valid credentials", async ({ session, $ }) => {
await session.login({ username: GOOD_USERNAME, password: GOOD_PASSWORD });
await $.page.heading.expect.toHaveText("My applications");
});
test("Reject bad username", async ({ session }) => {
await session.submitUsernameStage(BAD_USERNAME);
await session.submitPasswordStage(GOOD_PASSWORD);
await expect(session.$authFailureMessage).toBeVisible();
await expect(session.$authFailureMessage).toHaveText("Invalid password");
});
test("Reject bad password", async ({ session }) => {
await session.submitUsernameStage(GOOD_USERNAME);
await session.submitPasswordStage(BAD_PASSWORD);
await expect(session.$authFailureMessage).toBeVisible();
await expect(session.$authFailureMessage).toHaveText("Invalid password");
});
});

87
web/test/lit/rendering.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* @file Vitest browser utilities for Lit.
*
* @import { LocatorSelectors } from '@vitest/browser/context'
* @import { PrettyDOMOptions } from '@vitest/browser/utils'
* @import { RenderOptions as LitRenderOptions } from 'lit'
*/
import { debug, getElementLocatorSelectors } from "@vitest/browser/utils";
import { render as renderLit } from "lit";
/**
* @implements {Disposable}
*/
export class LitViteContext {
/**
* @type {Set<Disposable>}
*/
static #resources = new Set();
/**
* @param {unknown} template
* @param {HTMLElement} [container]
* @param {LitRenderOptions} [options]
*
* @returns {LitViteContext}
*/
static render = (template, container = document.createElement("div"), options) => {
const context = new LitViteContext(container);
context.render(template, options);
return context;
};
static [Symbol.dispose] = () => {
this.#resources.forEach((resource) => resource[Symbol.dispose]());
this.#resources.clear();
};
static cleanup = () => {
return this[Symbol.dispose]();
};
/**
* @param {unknown} template
* @param {LitRenderOptions} [options]
*/
render(template, options) {
return renderLit(template, this.container, options);
}
/**
* @type {HTMLElement} container
*/
container;
/**
* @type {LocatorSelectors}
*/
$;
/**
* @param {HTMLElement} container
*/
constructor(container) {
this.container = container;
this.$ = getElementLocatorSelectors(container);
}
toFragment() {
return document.createRange().createContextualFragment(this.container.innerHTML);
}
/**
* @param {number} [maxLength]
* @param {PrettyDOMOptions} [options]
*/
debug(maxLength, options) {
return debug(this.container, maxLength, options);
}
[Symbol.dispose] = () => {
this.container.remove();
LitViteContext.#resources.delete(this);
};
}

12
web/test/lit/setup.js Normal file
View File

@@ -0,0 +1,12 @@
import { LitViteContext } from "./rendering.js";
import { page } from "@vitest/browser/context";
import { beforeEach } from "vitest";
page.extend({
// @ts-ignore
renderLit: LitViteContext.render,
[Symbol.for("vitest:component-cleanup")]: LitViteContext.cleanup,
});
beforeEach(() => LitViteContext.cleanup());

View File

@@ -0,0 +1,9 @@
import { expect, test } from "vitest";
function sum(a: number, b: number) {
return a + b;
}
test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});

View File

@@ -1,6 +1,5 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"moduleResolution": "node",
"module": "ESNext",

View File

@@ -2,5 +2,11 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts", "./tests"]
"exclude": [
// ---
"src/**/*.test.ts",
"src/**/*.comp.ts",
"./**/*.stories.ts",
"./tests"
]
}

View File

@@ -1,24 +1,5 @@
// @file TSConfig used during tests.
// @file TSConfig used by the web package during build.
{
"compilerOptions": {
"baseUrl": ".",
"types": ["node", "webdriverio/async", "@wdio/cucumber-framework", "expect-webdriverio"],
"target": "esnext",
"module": "esnext",
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"lib": [
"ES5",
"ES2015",
"ES2016",
"ES2017",
"ES2018",
"ES2019",
"ES2020",
"ESNext",
"DOM",
"DOM.Iterable",
"WebWorker"
]
}
"extends": "./tsconfig.json"
}

23
web/types/node.d.ts vendored
View File

@@ -14,12 +14,13 @@ declare module "module" {
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
* ```
*/
var __dirname: string;
}
}
declare module "process" {
import { Level } from "pino";
global {
namespace NodeJS {
interface ProcessEnv {
@@ -30,6 +31,26 @@ declare module "process" {
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
*/
readonly NODE_ENV?: "development" | "production";
/**
* Whether or not we are running on a CI server.
*/
readonly CI?: string;
/**
* The application log level.
*/
readonly AK_LOG_LEVEL?: Level;
/**
* The base URL of web server to run the tests against.
*
* Typically this is `http://localhost:9000`.
*
* @format url
*/
readonly AK_TEST_RUNNER_PAGE_URL?: string;
/**
* @todo Determine where this is used and if it is needed,
* give it a better name.

View File

@@ -1,3 +1,5 @@
/// <reference types="vitest/config" />
import { createBundleDefinitions } from "#bundler/utils/node";
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
@@ -9,4 +11,41 @@ export default defineConfig({
// ---
inlineCSSPlugin(),
],
test: {
dir: "./test",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/.{idea,git,cache,output,temp}/**",
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
],
projects: [
{
test: {
include: ["./unit/**/*.{test,spec}.ts", "**/*.unit.{test,spec}.ts"],
name: "unit",
environment: "node",
},
},
{
test: {
setupFiles: ["./test/lit/setup.js"],
include: ["./browser/**/*.{test,spec}.ts", "**/*.browser.{test,spec}.ts"],
name: "browser",
browser: {
enabled: true,
provider: "playwright",
instances: [
{
browser: "chromium",
},
],
},
},
},
],
},
});