mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 14:42:22 +02:00
Compare commits
5 Commits
api--set-A
...
webdriver-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
787cb03172 | ||
|
|
77a2b1e1b7 | ||
|
|
1356db9288 | ||
|
|
11b2b88b21 | ||
|
|
c428e77c6e |
2
web/.gitignore
vendored
2
web/.gitignore
vendored
@@ -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
90
web/e2e/elements/proxy.ts
Normal 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>;
|
||||
}
|
||||
175
web/e2e/fixtures/FormFixture.ts
Normal file
175
web/e2e/fixtures/FormFixture.ts
Normal 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
|
||||
}
|
||||
30
web/e2e/fixtures/PageFixture.ts
Normal file
30
web/e2e/fixtures/PageFixture.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
42
web/e2e/fixtures/PointerFixture.ts
Normal file
42
web/e2e/fixtures/PointerFixture.ts
Normal 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()
|
||||
);
|
||||
};
|
||||
}
|
||||
119
web/e2e/fixtures/SessionFixture.ts
Normal file
119
web/e2e/fixtures/SessionFixture.ts
Normal 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
56
web/e2e/index.ts
Normal 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
44
web/e2e/selectors/ouid.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
13
web/e2e/selectors/types.ts
Normal file
13
web/e2e/selectors/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
|
||||
export type LocatorContext = Pick<
|
||||
Locator,
|
||||
| "locator"
|
||||
| "getByRole"
|
||||
| "getByTestId"
|
||||
| "getByText"
|
||||
| "getByLabel"
|
||||
| "getByAltText"
|
||||
| "getByTitle"
|
||||
| "getByPlaceholder"
|
||||
>;
|
||||
60
web/e2e/utils/generators.ts
Normal file
60
web/e2e/utils/generators.ts
Normal 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
102
web/logger/node.js
Normal 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
22
web/logger/transport.js
Normal 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
1815
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
94
web/playwright.config.js
Normal 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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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> `;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> `;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>`;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <form> 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 <form> 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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, () => "");
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
81
web/src/elements/table/TableColumn.ts
Normal file
81
web/src/elements/table/TableColumn.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
13
web/src/elements/table/shared.ts
Normal file
13
web/src/elements/table/shared.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
217
web/test/browser/providers.test.ts
Normal file
217
web/test/browser/providers.test.ts
Normal 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
|
||||
});
|
||||
35
web/test/browser/session.test.ts
Normal file
35
web/test/browser/session.test.ts
Normal 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
87
web/test/lit/rendering.js
Normal 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
12
web/test/lit/setup.js
Normal 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());
|
||||
9
web/test/sum.unit.test.ts
Normal file
9
web/test/sum.unit.test.ts
Normal 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);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.ts", "./tests"]
|
||||
"exclude": [
|
||||
// ---
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.comp.ts",
|
||||
"./**/*.stories.ts",
|
||||
"./tests"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
23
web/types/node.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user