web: Fix TypeScript compilation issues for mixins, events. (#13766)

This commit is contained in:
Teffen Ellis
2025-04-07 19:53:51 +02:00
committed by GitHub
parent 363d655378
commit 220378b3f2
13 changed files with 343 additions and 142 deletions

View File

@ -19,7 +19,7 @@ import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthent
@customElement("ak-about-modal")
export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) {
static get styles() {
return super.styles.concat(
return ModalButton.styles.concat(
PFAbout,
css`
.pf-c-about-modal-box__hero {

View File

@ -71,7 +71,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
}
static get styles(): CSSResult[] {
return super.styles.concat(PFCard, applicationListStyle);
return TablePage.styles.concat(PFCard, applicationListStyle);
}
columns(): TableColumn[] {

View File

@ -127,7 +127,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
me?: SessionUser;
static get styles(): CSSResult[] {
return super.styles.concat(PFDescriptionList, PFAlert, PFBanner);
return Table.styles.concat(PFDescriptionList, PFAlert, PFBanner);
}
async apiEndpoint(): Promise<PaginatedResponse<User>> {

View File

@ -118,7 +118,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
me?: SessionUser;
static get styles(): CSSResult[] {
return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
return [...TablePage.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
}
constructor() {

View File

@ -1,18 +1,44 @@
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
import type { Constructor } from "@goauthentik/elements/types.js";
import { createMixin } from "@goauthentik/elements/types";
import { consume } from "@lit/context";
import type { LitElement } from "lit";
import type { Config } from "@goauthentik/api";
export function WithAuthentikConfig<T extends Constructor<LitElement>>(
superclass: T,
subscribe = true,
) {
abstract class WithAkConfigProvider extends superclass {
@consume({ context: authentikConfigContext, subscribe })
public authentikConfig!: Config;
}
return WithAkConfigProvider;
/**
* A consumer that provides the application configuration to the element.
*/
export interface AKConfigMixin {
/**
* The current configuration of the application.
*/
readonly authentikConfig: Readonly<Config>;
}
/**
* A mixin that provides the application configuration to the element.
*
* @category Mixin
*/
export const WithAuthentikConfig = createMixin<AKConfigMixin>(
({
/**
* The superclass constructor to extend.
*/
SuperClass,
/**
* Whether or not to subscribe to the context.
*/
subscribe = true,
}) => {
abstract class AKConfigProvider extends SuperClass implements AKConfigMixin {
@consume({
context: authentikConfigContext,
subscribe,
})
public readonly authentikConfig!: Readonly<Config>;
}
return AKConfigProvider;
},
);

View File

@ -1,20 +1,48 @@
import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts";
import type { AbstractConstructor } from "@goauthentik/elements/types.js";
import { createMixin } from "@goauthentik/elements/types";
import { consume } from "@lit/context";
import type { LitElement } from "lit";
import { state } from "lit/decorators.js";
import type { CurrentBrand } from "@goauthentik/api";
export function WithBrandConfig<T extends AbstractConstructor<LitElement>>(
superclass: T,
subscribe = true,
) {
abstract class WithBrandProvider extends superclass {
@consume({ context: authentikBrandContext, subscribe })
@state()
public brand!: CurrentBrand;
}
return WithBrandProvider;
/**
* A mixin that provides the current brand to the element.
*/
export interface StyleBrandMixin {
/**
* The current style branding configuration.
*/
brand: CurrentBrand;
}
/**
* A mixin that provides the current brand to the element.
*
* @category Mixin
*
* @see {@link https://lit.dev/docs/composition/mixins/#mixins-in-typescript | Lit Mixins}
*/
export const WithBrandConfig = createMixin<StyleBrandMixin>(
({
/**
* The superclass constructor to extend.
*/
SuperClass,
/**
* Whether or not to subscribe to the context.
*/
subscribe = true,
}) => {
abstract class StyleBrandProvider extends SuperClass implements StyleBrandMixin {
@consume({
context: authentikBrandContext,
subscribe,
})
@state()
public brand!: CurrentBrand;
}
return StyleBrandProvider;
},
);

View File

@ -1,67 +1,85 @@
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
import type { AbstractConstructor } from "@goauthentik/elements/types.js";
import { createMixin } from "@goauthentik/elements/types";
import { consume } from "@lit/context";
import type { LitElement } from "lit";
import { CapabilitiesEnum } from "@goauthentik/api";
import { Config } from "@goauthentik/api";
// Using a unique, lexically scoped, and locally static symbol as the field name for the context
// means that it's inaccessible to any child class looking for it. It's one of the strongest privacy
// guarantees in JavaScript.
class WCC {
public static readonly capabilitiesConfig: unique symbol = Symbol();
/**
* A consumer that provides the capability methods to the element.
*
*/
export interface CapabilitiesMixin {
/**
* Predicate to determine if the current user has a given capability.
*/
can(
/**
* The capability enum to check.
*/
capability: CapabilitiesEnum,
): boolean;
}
/**
* withCapabilitiesContext mixes in a single method to any LitElement, `can()`, which takes a
* CapabilitiesEnum and returns true or false.
* Lexically-scoped symbol for the capabilities configuration.
*
* @internal
*/
const kCapabilitiesConfig: unique symbol = Symbol("capabilitiesConfig");
/**
* A mixin that provides the capability methods to the element.
*
* Usage:
*
* After importing, simply mixin this function:
*
* ```
* ```ts
* export class AkMyNiftyNewFeature extends withCapabilitiesContext(AKElement) {
* }
* ```
*
* And then if you need to check on a capability:
*
* ```
* ```ts
* if (this.can(CapabilitiesEnum.IsEnterprise) { ... }
* ```
*
* This code re-exports CapabilitiesEnum, so you won't have to import it on a separate line if you
* don't need anything else from the API.
*
* Passing `true` as the second mixin argument will cause the inheriting class to subscribe to the
* configuration context. Should the context be explicitly reset, all active web components that are
* currently active and subscribed to the context will automatically have a `requestUpdate()`
* triggered with the new configuration.
* Passing `true` as the second mixin argument
*
* @category Mixin
*
*/
export const WithCapabilitiesConfig = createMixin<CapabilitiesMixin>(
({ SuperClass, subscribe = true }) => {
abstract class CapabilitiesProvider extends SuperClass implements CapabilitiesMixin {
@consume({
context: authentikConfigContext,
subscribe,
})
private readonly [kCapabilitiesConfig]: Config | undefined;
export function WithCapabilitiesConfig<T extends AbstractConstructor<LitElement>>(
superclass: T,
subscribe = true,
) {
abstract class CapabilitiesContext extends superclass {
@consume({ context: authentikConfigContext, subscribe })
private [WCC.capabilitiesConfig]!: Config;
public can(capability: CapabilitiesEnum) {
const config = this[kCapabilitiesConfig];
can(c: CapabilitiesEnum) {
if (!this[WCC.capabilitiesConfig]) {
throw new Error(
"ConfigContext: Attempted to access site configuration before initialization.",
);
if (!config) {
throw new Error(
"ConfigContext: Attempted to access site configuration before initialization.",
);
}
return config.capabilities.includes(capability);
}
return this[WCC.capabilitiesConfig].capabilities.includes(c);
}
}
return CapabilitiesContext;
}
return CapabilitiesProvider;
},
);
// Re-export `CapabilitiesEnum`, so you won't have to import it on a separate line if you
// don't need anything else from the API.
export { CapabilitiesEnum };

View File

@ -1,23 +1,40 @@
import { authentikEnterpriseContext } from "@goauthentik/elements/AuthentikContexts";
import { Constructor } from "@goauthentik/elements/types.js";
import { createMixin } from "@goauthentik/elements/types";
import { consume } from "@lit/context";
import type { LitElement } from "lit";
import { type LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api";
export function WithLicenseSummary<T extends Constructor<LitElement>>(
superclass: T,
subscribe = true,
) {
abstract class WithEnterpriseProvider extends superclass {
@consume({ context: authentikEnterpriseContext, subscribe })
public licenseSummary!: LicenseSummary;
/**
* A consumer that provides license information to the element.
*/
export interface LicenseMixin {
/**
* Summary of the current license.
*/
readonly licenseSummary: LicenseSummary;
/**
* Whether or not the current license is an enterprise license.
*/
readonly hasEnterpriseLicense: boolean;
}
/**
* A mixin that provides the license information to the element.
*/
export const WithLicenseSummary = createMixin<LicenseMixin>(({ SuperClass, subscribe = true }) => {
abstract class LicenseProvider extends SuperClass implements LicenseMixin {
@consume({
context: authentikEnterpriseContext,
subscribe,
})
public readonly licenseSummary!: LicenseSummary;
get hasEnterpriseLicense() {
return this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed;
}
}
return WithEnterpriseProvider;
}
return LicenseProvider;
});

View File

@ -1,18 +1,35 @@
import { authentikVersionContext } from "@goauthentik/elements/AuthentikContexts";
import type { AbstractConstructor } from "@goauthentik/elements/types.js";
import { consume } from "@lit/context";
import { Constructor } from "@lit/reactive-element/decorators/base";
import type { LitElement } from "lit";
import type { Version } from "@goauthentik/api";
export function WithVersion<T extends AbstractConstructor<LitElement>>(
superclass: T,
/**
* A consumer that provides version information to the element.
*/
export declare class VersionConsumer {
/**
* The current version of the application.
*/
public readonly version: Version;
}
export function WithVersion<T extends Constructor<LitElement>>(
/**
* The superclass constructor to extend.
*/
SuperClass: T,
/**
* Whether or not to subscribe to the context.
*/
subscribe = true,
) {
abstract class WithBrandProvider extends superclass {
class VersionProvider extends SuperClass {
@consume({ context: authentikVersionContext, subscribe })
public version!: Version;
}
return WithBrandProvider;
return VersionProvider as Constructor<VersionConsumer> & T;
}

View File

@ -30,9 +30,9 @@ export type DataProvision = {
export type DataProvider = (page: number, search?: string) => Promise<DataProvision>;
export interface SearchbarEvent extends CustomEvent {
detail: {
source: string;
value: string;
};
export interface SearchbarEventDetail {
source: string;
value: string;
}
export type SearchbarEvent = CustomEvent<SearchbarEventDetail>;

View File

@ -1,41 +1,107 @@
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult, nothing } from "lit";
import { ReactiveControllerHost } from "lit";
import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit";
import "lit";
export type ReactiveElementHost<T = AKElement> = Partial<ReactiveControllerHost> & T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructor<T = object> = new (...args: any[]) => T;
export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
export type LitElementConstructor = new (...args: never[]) => LitElement;
// authentik Search/List types
//
// authentik's list types (ak-dual-select, ak-list-select, ak-search-select) all take a tuple of two
// or three items, or a collection of groups of such tuples. In order to push dynamic checking
// around, we also allow the inclusion of a fourth component, which is just a scratchpad the
// developer can use for their own reasons.
/**
* A constructor that has been extended with a mixin.
*/
export type ConstructorWithMixin<SuperClass, Mixin> =
// Is the superclass abstract?
SuperClass extends abstract new (...args: never[]) => unknown
? // Lift the abstractness to of the mixin.
new (...args: ConstructorParameters<SuperClass>) => InstanceType<SuperClass> & Mixin
: // Is the superclass **not** abstract?
SuperClass extends new (...args: never[]) => unknown
? // So shall be the mixin.
new (...args: ConstructorParameters<SuperClass>) => InstanceType<SuperClass> & Mixin
: never;
// The displayed element for our list can be a TemplateResult. If it is, we *strongly* recommend
// that you include the `sortBy` string as well, which is used for sorting but is also used for our
// autocomplete element (ak-search-select) both for tracking the user's input and for what we
// display in the autocomplete input box.
/**
* The init object passed to the `createMixin` callback.
*/
export interface CreateMixinInit<T extends LitElementConstructor = LitElementConstructor> {
/**
* The superclass constructor to extend.
*/
SuperClass: T;
/**
* Whether or not to subscribe to the context.
*
* Should the context be explicitly reset, all active web components that are
* currently active and subscribed to the context will automatically have a `requestUpdate()`
* triggered with the new configuration.
*/
subscribe?: boolean;
}
// - key: string
// - label (string). This is the field that will be sorted and used for filtering and searching.
// - desc (optional) A string or TemplateResult used to describe the option.
// - localMapping: The object the key represents; used by some specific apps. API layers may use
// this as a way to find the referenced object, rather than the string and keeping a local map.
//
// Note that this is a *tuple*, not a record or map!
/**
* Create a mixin for a LitElement.
*
* @param mixinCallback The callback that will be called to create the mixin.
* @template Mixin The mixin class to union with the superclass.
*/
export function createMixin<Mixin>(mixinCallback: (init: CreateMixinInit) => unknown) {
return <T extends LitElementConstructor | AbstractLitElementConstructor>(
/**
* The superclass constructor to extend.
*/ SuperClass: T,
/**
* Whether or not to subscribe to the context.
*
* Should the context be explicitly reset, all active web components that are
* currently active and subscribed to the context will automatically have a `requestUpdate()`
* triggered with the new configuration.
*/
subscribe?: boolean,
) => {
const MixinClass = mixinCallback({
SuperClass: SuperClass as LitElementConstructor,
subscribe,
});
// prettier-ignore
return MixinClass as ConstructorWithMixin<T, Mixin>;
};
}
//#region Search/List types
/**
* authentik's list types (ak-dual-select, ak-list-select, ak-search-select) all take a tuple of two
* or three items, or a collection of groups of such tuples. In order to push dynamic checking
* around, we also allow the inclusion of a fourth component, which is just a scratchpad the
* developer can use for their own reasons.
*
* The displayed element for our list can be a TemplateResult.
*
* If it is, we *strongly* recommend that you include the `sortBy` string as well, which is used for sorting but is also used for our autocomplete element (ak-search-select),
* both for tracking the user's input and for what we display in the autocomplete input box.
*
* Note that this is a *tuple*, not a record or map!
*/
export type SelectOption<T = never> = [
/**
* The key that will be used for sorting and filtering.
*/
key: string,
/**
* The field that will be sorted and used for filtering and searching.
*/
label: string,
/**
* A string or TemplateResult used to describe the option.
*/
desc?: string | TemplateResult,
/**
* The object the key represents; used by some specific apps. API layers may use
* this as a way to find the referenced object, rather than the string and keeping a local map.
*/
localMapping?: T,
];
@ -44,8 +110,8 @@ export type SelectOption<T = never> = [
* `grouped: false` flag. Note that it *is* possible to pass to any of the rendering components an
* array of SelectTuples; they will be automatically mapped to a SelectFlat object.
*
* @internal
*/
/* PRIVATE */
export type SelectFlat<T = never> = {
grouped: false;
options: SelectOption<T>[];
@ -74,5 +140,14 @@ export type SelectGrouped<T = never> = {
export type GroupedOptions<T = never> = SelectGrouped<T> | SelectFlat<T>;
export type SelectOptions<T = never> = SelectOption<T>[] | GroupedOptions<T>;
//#endregion
/**
* A convenience type representing the result of a slotted template, i.e.
*
* - A string, which will be rendered as text.
* - A TemplateResult, which will be rendered as HTML.
* - `nothing`, which will not be rendered.
*/
export type SlottedTemplateResult = string | TemplateResult | typeof nothing;
export type Spread = { [key: string]: unknown };

View File

@ -5,9 +5,15 @@ export const customEvent = (name: string, details = {}) =>
detail: details,
});
// "Unknown" seems to violate some obscure Typescript rule and doesn't work here, although it
// should.
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isCustomEvent = (v: any): v is CustomEvent =>
v instanceof CustomEvent && "detail" in v;
export type SerializablePrimitive = number | string;
export type SerializableArray = SerializablePrimitive[];
export type CustomEventDetail = SerializablePrimitive | SerializableArray | object;
/**
* Type guard to determine if an event has a `detail` property.
*/
export function isCustomEvent<D = CustomEventDetail>(
eventLike: Event,
): eventLike is CustomEvent<D> {
return eventLike instanceof CustomEvent && "detail" in eventLike;
}

View File

@ -1,38 +1,41 @@
import type { LitElement } from "lit";
import { createMixin } from "@goauthentik/elements/types";
import { CustomEventDetail, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Constructor<T = object> = new (...args: any[]) => T;
export interface EmmiterElementHandler {
dispatchCustomEvent<T>(
eventName: string,
detail?: T extends CustomEvent<infer U> ? U : T,
eventInit?: EventInit,
): void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isCustomEvent = (v: any): v is CustomEvent =>
v instanceof CustomEvent && "detail" in v;
export function CustomEmitterElement<T extends Constructor<LitElement>>(superclass: T) {
return class EmmiterElementHandler extends superclass {
dispatchCustomEvent<F extends CustomEvent>(
export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperClass }) => {
return class EmmiterElementHandler extends SuperClass {
public dispatchCustomEvent<D extends CustomEventDetail>(
eventName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
detail: any = {},
options = {},
detail: D = {} as D,
eventInit: EventInit = {},
) {
const fullDetail =
typeof detail === "object" && !Array.isArray(detail)
? {
...detail,
}
: detail;
let normalizedDetail: CustomEventDetail;
if (detail && typeof detail === "object" && !Array.isArray(detail)) {
// TODO: Is this destructuring still necessary to shallow copy the object?
normalizedDetail = { ...detail };
} else {
normalizedDetail = detail;
}
this.dispatchEvent(
new CustomEvent(eventName, {
composed: true,
bubbles: true,
...options,
detail: fullDetail,
}) as F,
...eventInit,
detail: normalizedDetail,
}),
);
}
};
}
});
/**
* Mixin that enables Lit Elements to handle custom events in a more straightforward manner.
@ -56,8 +59,19 @@ class HK {
type EventHandler = (ev: CustomEvent) => void;
type EventMap = WeakMap<EventHandler, EventHandler>;
export function CustomListenerElement<T extends Constructor<LitElement>>(superclass: T) {
return class ListenerElementHandler extends superclass {
export interface CustomEventTarget {
addCustomListener(eventName: string, handler: EventHandler): void;
removeCustomListener(eventName: string, handler: EventHandler): void;
}
/**
* A mixin that enables Lit Elements to handle custom events in a more straightforward manner.
*
* @todo Can we lean on the native `EventTarget` class for this?
* @category Mixin
*/
export const CustomListenerElement = createMixin<CustomEventTarget>(({ SuperClass }) => {
return class ListenerElementHandler extends SuperClass implements CustomEventTarget {
private [HK.listenHandlers] = new Map<string, EventMap>();
private [HK.getHandler](eventName: string, handler: EventHandler) {
@ -93,14 +107,14 @@ export function CustomListenerElement<T extends Constructor<LitElement>>(supercl
}
addCustomListener(eventName: string, handler: EventHandler) {
const internalHandler = (ev: Event) => {
if (!isCustomEvent(ev)) {
const internalHandler = (event: Event) => {
if (!isCustomEvent(event)) {
console.error(
`Received a standard event for custom event ${eventName}; event will not be handled.`,
);
return;
}
handler(ev);
handler(event);
};
this[HK.addHandler](eventName, handler, internalHandler);
this.addEventListener(eventName, internalHandler);
@ -117,4 +131,4 @@ export function CustomListenerElement<T extends Constructor<LitElement>>(supercl
this[HK.removeHandler](eventName, handler);
}
};
}
});