Compare commits

...

9 Commits

Author SHA1 Message Date
Teffen Ellis
a80ead4ba9 web: Adjust pressable area for larger hitbox. 2025-07-14 17:31:34 +02:00
Teffen Ellis
1c4c0f3d45 web: Tidy form submission. 2025-07-14 17:13:47 +02:00
Teffen Ellis
b4fa1b260f web: Clarify types. 2025-07-14 17:12:50 +02:00
Teffen Ellis
eb78a55776 web: Clean up sidebar label. 2025-07-14 17:10:55 +02:00
Teffen Ellis
feda970a39 web: Clean up button dispatch. 2025-07-14 17:06:03 +02:00
Teffen Ellis
528a5c2e61 web: Normalize use of iteration. 2025-07-14 16:51:51 +02:00
Teffen Ellis
044a0620af web: Unify usage of "valid". 2025-07-14 16:45:24 +02:00
Teffen Ellis
d8e8442b68 web: Remove unused. 2025-07-14 16:31:38 +02:00
Teffen Ellis
a63e6946e9 web: Fix issue where wizard steps with refresh events trigger parent rerenders. 2025-07-14 16:19:45 +02:00
39 changed files with 584 additions and 605 deletions

View File

@@ -51,7 +51,7 @@ export class FooterLinkInput extends AkControlElement<FooterLink> {
) as unknown as FooterLink;
}
get isValid() {
get valid() {
const href = this.json()?.href ?? "";
return hasLegalScheme(href) && URL.canParse(href);
}

View File

@@ -34,7 +34,7 @@ const metadata: Meta<FooterLinkInput> = {
return;
}
const target = event.target as FooterLinkInput;
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`;
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.valid ? "Yes" : "No"}`;
});
}, 250);

View File

@@ -20,7 +20,7 @@ describe("ak-admin-settings-footer-link", () => {
it("should render an empty control", async () => {
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
const link = await $("ak-admin-settings-footer-link");
await expect(await link.getProperty("isValid")).toStrictEqual(false);
await expect(await link.getProperty("valid")).toStrictEqual(false);
await expect(await link.getProperty("toJson")).toEqual({ name: "", href: "" });
});
@@ -28,7 +28,7 @@ describe("ak-admin-settings-footer-link", () => {
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
const link = await $("ak-admin-settings-footer-link");
await link.$('input[name="name"]').setValue("foo");
await expect(await link.getProperty("isValid")).toStrictEqual(false);
await expect(await link.getProperty("valid")).toStrictEqual(false);
await expect(await link.getProperty("toJson")).toEqual({ name: "foo", href: "" });
});
@@ -36,7 +36,7 @@ describe("ak-admin-settings-footer-link", () => {
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
const link = await $("ak-admin-settings-footer-link");
await link.$('input[name="href"]').setValue("https://foo.com");
await expect(await link.getProperty("isValid")).toStrictEqual(true);
await expect(await link.getProperty("valid")).toStrictEqual(true);
await expect(await link.getProperty("toJson")).toEqual({
name: "",
href: "https://foo.com",
@@ -48,7 +48,7 @@ describe("ak-admin-settings-footer-link", () => {
const link = await $("ak-admin-settings-footer-link");
await link.$('input[name="name"]').setValue("foo");
await link.$('input[name="href"]').setValue("https://foo.com");
await expect(await link.getProperty("isValid")).toStrictEqual(true);
await expect(await link.getProperty("valid")).toStrictEqual(true);
await expect(await link.getProperty("toJson")).toEqual({
name: "foo",
href: "https://foo.com",
@@ -64,6 +64,6 @@ describe("ak-admin-settings-footer-link", () => {
name: "foo",
href: "never://foo.com",
});
await expect(await link.getProperty("isValid")).toStrictEqual(false);
await expect(await link.getProperty("valid")).toStrictEqual(false);
});
});

View File

@@ -20,21 +20,23 @@ import { ValidationError } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { property, query } from "lit/decorators.js";
export class ApplicationWizardStep<T = Record<string, unknown>> extends WizardStep {
export abstract class ApplicationWizardStep<T = Record<string, unknown>> extends WizardStep {
static styles = [...WizardStep.styles, ...styles];
@property({ type: Object, attribute: false })
wizard!: ApplicationWizardState;
public wizard!: ApplicationWizardState;
// As recommended in [WizardStep](../../../components/ak-wizard/WizardStep.ts), we override
// these fields and provide them to all the child classes.
wizardTitle = msg("New application");
wizardDescription = msg("Create a new application and configure a provider for it.");
canCancel = true;
protected override wizardTitle = msg("New application");
protected override wizardDescription = msg(
"Create a new application and configure a provider for it.",
);
protected override cancelable = true;
// This should be overridden in the children for more precise targeting.
@query("form")
form!: HTMLFormElement;
protected form!: HTMLFormElement;
get formValues(): T {
return serializeForm<T>([

View File

@@ -24,89 +24,92 @@ import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
const autoTrim = (v: unknown) => (typeof v === "string" ? v.trim() : v);
const trimMany = (o: Record<string, unknown>, vs: string[]) =>
Object.fromEntries(vs.map((v) => [v, autoTrim(o[v])]));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isStr = (v: any): v is string => typeof v === "string";
@customElement("ak-application-wizard-application-step")
export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
label = msg("Application");
export class ApplicationWizardApplicationStep extends ApplicationWizardStep<
Partial<ApplicationRequest>
> {
public override label = msg("Application");
@state()
errors = new Map<string, string>();
protected errors = new Map<string, string>();
@query("form#applicationform")
form!: HTMLFormElement;
protected form!: HTMLFormElement;
constructor() {
super();
// This is the first step. Ensure it is always enabled.
this.enabled = true;
}
// This is the first step. Ensure it is always enabled.
public override enabled = false;
errorMessages(name: string) {
return this.errors.has(name)
? [this.errors.get(name)]
: (this.wizard.errors?.app?.[name] ??
this.wizard.errors?.app?.[camelToSnake(name)] ??
[]);
protected errorMessages(name: string) {
const message = this.errors.get(name);
if (message) {
return [message];
}
const appErrors = this.wizard.errors?.app;
if (!appErrors || typeof appErrors !== "object") {
return [];
}
return appErrors[name] ?? appErrors[camelToSnake(name)] ?? [];
}
get buttons(): WizardButton[] {
return [{ kind: "next", destination: "provider-choice" }, { kind: "cancel" }];
}
get valid() {
public reportValidity() {
this.errors = new Map();
const values = trimMany(this.formValues ?? {}, ["metaLaunchUrl", "name", "slug"]);
if (values.name === "") {
const name = this.formValues.name?.trim();
const slug = this.formValues.slug?.trim();
const metaLaunchUrl = this.formValues.metaLaunchUrl?.trim();
if (!name) {
this.errors.set("name", msg("An application name is required"));
}
if (
!(
isStr(values.metaLaunchUrl) &&
(values.metaLaunchUrl === "" || URL.canParse(values.metaLaunchUrl))
)
) {
if (!metaLaunchUrl || !URL.canParse(metaLaunchUrl)) {
this.errors.set("metaLaunchUrl", msg("Not a valid URL"));
}
if (!(isStr(values.slug) && values.slug !== "" && isSlug(values.slug))) {
if (!slug || !isSlug(slug)) {
this.errors.set("slug", msg("Not a valid slug"));
}
return this.errors.size === 0;
}
override handleButton(button: NavigableButton) {
if (button.kind === "next") {
if (!this.valid) {
this.handleEnabling({
disabled: ["provider-choice", "provider", "bindings", "submit"],
});
return;
}
const app: Partial<ApplicationRequest> = this.formValues as Partial<ApplicationRequest>;
protected override dispatchButtonEvent(button: NavigableButton) {
if (button.kind !== "next") {
return super.dispatchButtonEvent(button);
}
let payload: ApplicationWizardStateUpdate = {
app: this.formValues,
errors: this.removeErrors("app"),
};
if (app.name && (this.wizard.provider?.name ?? "").trim() === "") {
payload = {
...payload,
provider: { name: `Provider for ${app.name}` },
};
}
this.handleUpdate(payload, button.destination, {
enable: "provider-choice",
if (!this.reportValidity()) {
this.handleEnabling({
disabled: ["provider-choice", "provider", "bindings", "submit"],
});
return;
}
super.handleButton(button);
const app = this.formValues;
let payload: ApplicationWizardStateUpdate = {
app: this.formValues,
errors: this.removeErrors("app"),
};
if (app.name && (this.wizard.provider?.name ?? "").trim() === "") {
payload = {
...payload,
provider: { name: `Provider for ${app.name}` },
};
}
this.handleUpdate(payload, button.destination, {
enable: "provider-choice",
});
}
renderForm(app: Partial<ApplicationRequest>, errors: ValidationRecord) {

View File

@@ -44,18 +44,18 @@ const PASS_FAIL = [
@customElement("ak-application-wizard-edit-binding-step")
export class ApplicationWizardEditBindingStep extends ApplicationWizardStep {
label = msg("Edit Binding");
public label = msg("Edit Binding");
hide = true;
public override hide = true;
@query("form#bindingform")
form!: HTMLFormElement;
protected form!: HTMLFormElement;
@query(".policy-search-select")
searchSelect!: SearchSelectBase<Policy> | SearchSelectBase<Group> | SearchSelectBase<User>;
@state()
policyGroupUser: target = target.policy;
protected policyGroupUser: target = target.policy;
instanceId = -1;
@@ -69,7 +69,7 @@ export class ApplicationWizardEditBindingStep extends ApplicationWizardStep {
];
}
override handleButton(button: NavigableButton) {
protected override dispatchButtonEvent(button: NavigableButton) {
if (button.kind === "next") {
if (!this.form.checkValidity()) {
return;
@@ -93,7 +93,7 @@ export class ApplicationWizardEditBindingStep extends ApplicationWizardStep {
this.handleUpdate({ bindings }, "bindings");
return;
}
super.handleButton(button);
super.dispatchButtonEvent(button);
}
// The search select configurations for the three different types of fetches that we care about,

View File

@@ -24,10 +24,10 @@ import { customElement, state } from "lit/decorators.js";
@customElement("ak-application-wizard-provider-choice-step")
export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(ApplicationWizardStep) {
label = msg("Choose a Provider");
public override label = msg("Choose a Provider");
@state()
failureMessage = "";
protected failureMessage = "";
@consume({ context: applicationWizardProvidersContext, subscribe: true })
public providerModelsList!: LocalTypeCreate[];
@@ -40,18 +40,20 @@ export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(Appl
];
}
override handleButton(button: NavigableButton) {
protected override dispatchButtonEvent(button: NavigableButton) {
this.failureMessage = "";
if (button.kind === "next") {
if (!this.wizard.providerModel) {
this.failureMessage = msg("Please choose a provider type before proceeding.");
this.handleEnabling({ disabled: ["provider", "bindings", "submit"] });
return;
}
this.handleUpdate(undefined, button.destination, { enable: "provider" });
if (button.kind !== "next") {
return super.dispatchButtonEvent(button);
}
if (!this.wizard.providerModel) {
this.failureMessage = msg("Please choose a provider type before proceeding.");
this.handleEnabling({ disabled: ["provider", "bindings", "submit"] });
return;
}
super.handleButton(button);
this.handleUpdate(undefined, button.destination, { enable: "provider" });
}
@bound
@@ -62,31 +64,33 @@ export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(Appl
}
renderMain() {
if (this.providerModelsList.length === 0) {
return html`<ak-empty-state default-label></ak-empty-state>`;
}
const selectedTypes = this.providerModelsList.filter(
(t) => t.modelName === this.wizard.providerModel,
);
return this.providerModelsList.length > 0
? html` <ak-wizard-title>${msg("Choose a Provider Type")}</ak-wizard-title>
<form class="pf-c-form pf-m-horizontal">
<ak-wizard-page-type-create
.types=${this.providerModelsList}
name="selectProviderType"
layout=${TypeCreateWizardPageLayouts.grid}
.selectedType=${selectedTypes.length > 0 ? selectedTypes[0] : undefined}
@select=${(ev: CustomEvent<LocalTypeCreate>) => {
this.handleUpdate(
{
...this.wizard,
providerModel: ev.detail.modelName,
},
undefined,
{ enable: "provider" },
);
}}
></ak-wizard-page-type-create>
</form>`
: html`<ak-empty-state default-label></ak-empty-state>`;
return html` <ak-wizard-title>${msg("Choose a Provider Type")}</ak-wizard-title>
<form class="pf-c-form pf-m-horizontal">
<ak-wizard-page-type-create
.types=${this.providerModelsList}
name="selectProviderType"
layout=${TypeCreateWizardPageLayouts.grid}
.selectedType=${selectedTypes.length > 0 ? selectedTypes[0] : undefined}
@select=${(ev: CustomEvent<LocalTypeCreate>) => {
this.handleUpdate(
{
...this.wizard,
providerModel: ev.detail.modelName,
},
undefined,
{ enable: "provider" },
);
}}
></ak-wizard-page-type-create>
</form>`;
}
}

View File

@@ -43,27 +43,28 @@ export class ApplicationWizardProviderStep extends ApplicationWizardStep {
return this.element.formValues;
}
override handleButton(button: NavigableButton) {
if (button.kind === "next") {
if (!this.valid) {
this.handleEnabling({
disabled: ["bindings", "submit"],
});
return;
}
const payload = {
provider: {
...this.formValues,
mode: this.wizard.proxyMode,
},
errors: this.removeErrors("provider"),
};
this.handleUpdate(payload, button.destination, {
enable: ["bindings", "submit"],
protected override dispatchButtonEvent(button: NavigableButton) {
if (button.kind !== "next") {
return super.dispatchButtonEvent(button);
}
if (!this.valid) {
this.handleEnabling({
disabled: ["bindings", "submit"],
});
return;
}
super.handleButton(button);
const payload = {
provider: {
...this.formValues,
mode: this.wizard.proxyMode,
},
errors: this.removeErrors("provider"),
};
this.handleUpdate(payload, button.destination, {
enable: ["bindings", "submit"],
});
}
get buttons(): WizardButton[] {

View File

@@ -177,16 +177,16 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
});
}
override handleButton(button: WizardButton) {
match([button.kind, this.state])
protected override dispatchButtonEvent(button: WizardButton) {
return match([button.kind, this.state])
.with([P.union("back", "cancel"), P._], () => {
super.handleButton(button);
return super.dispatchButtonEvent(button);
})
.with(["close", "submitted"], () => {
super.handleButton(button);
return super.dispatchButtonEvent(button);
})
.with(["next", "reviewing"], () => {
this.send();
return this.send();
})
.with([P._, "running"], () => {
throw new Error("No buttons should be showing when running submit phase");

View File

@@ -19,13 +19,13 @@ import { property, query } from "lit/decorators.js";
export class ApplicationWizardProviderForm<T extends OneOfProvider> extends AKElement {
static styles: CSSResult[] = [...AwadStyles];
label = "";
public label = "";
@property({ type: Object, attribute: false })
wizard!: ApplicationWizardState;
public wizard!: ApplicationWizardState;
@property({ type: Object, attribute: false })
errors: Record<string | number | symbol, string> = {};
public errors: Record<string, string> = {};
@query("form#providerform")
form!: HTMLFormElement;
@@ -50,7 +50,7 @@ export class ApplicationWizardProviderForm<T extends OneOfProvider> extends AKEl
[]);
}
isValid(name: keyof T) {
isValid(name: Extract<keyof T, string>) {
return !(
(this.wizard.errors?.provider?.[name as string] ?? []).length > 0 ||
this.errors?.[name] !== undefined

View File

@@ -55,7 +55,7 @@ export class ServiceConnectionWizard extends AKElement {
"initial",
`type-${ev.detail.component}-${ev.detail.modelName}`,
];
this.wizard.isValid = true;
this.wizard.valid = true;
}}
>
</ak-wizard-page-type-create>
@@ -63,7 +63,7 @@ export class ServiceConnectionWizard extends AKElement {
return html`
<ak-wizard-page-form
slot=${`type-${type.component}-${type.modelName}`}
.sidebarLabel=${() => msg(str`Create ${type.name}`)}
.sidebarLabel=${msg(str`Create ${type.name}`)}
>
<ak-proxy-form type=${type.component}></ak-proxy-form>
</ak-wizard-page-form>

View File

@@ -66,7 +66,7 @@ export class PolicyWizard extends AKElement {
this.wizard.steps.splice(idx, 0, `type-${component}-${modelName}`);
this.wizard.isValid = true;
this.wizard.valid = true;
};
render(): TemplateResult {
@@ -87,7 +87,7 @@ export class PolicyWizard extends AKElement {
return html`
<ak-wizard-page-form
slot=${`type-${type.component}-${type.modelName}`}
.sidebarLabel=${() => msg(str`Create ${type.name}`)}
.sidebarLabel=${msg(str`Create ${type.name}`)}
>
<ak-proxy-form type=${type.component}></ak-proxy-form>
</ak-wizard-page-form>
@@ -96,7 +96,7 @@ export class PolicyWizard extends AKElement {
${this.showBindingPage
? html`<ak-wizard-page-form
slot="create-binding"
.sidebarLabel=${() => msg("Create Binding")}
.sidebarLabel=${msg("Create Binding")}
.activePageCallback=${async (context: FormWizardPage) => {
const createSlot = context.host.steps[1];
const bindingForm =

View File

@@ -65,7 +65,7 @@ export class PropertyMappingWizard extends AKElement {
"initial",
`type-${ev.detail.component}-${ev.detail.modelName}`,
];
this.wizard.isValid = true;
this.wizard.valid = true;
}}
>
</ak-wizard-page-type-create>
@@ -73,7 +73,7 @@ export class PropertyMappingWizard extends AKElement {
return html`
<ak-wizard-page-form
slot=${`type-${type.component}-${type.modelName}`}
.sidebarLabel=${() => msg(str`Create ${type.name}`)}
.sidebarLabel=${msg(str`Create ${type.name}`)}
>
<ak-proxy-form type=${type.component}></ak-proxy-form>
</ak-wizard-page-form>

View File

@@ -30,20 +30,18 @@ export class ProviderWizard extends AKElement {
static styles: CSSResult[] = [PFBase, PFButton];
@property()
createText = msg("Create");
public createText = msg("Create");
@property({ attribute: false })
providerTypes: TypeCreate[] = [];
public providerTypes: TypeCreate[] = [];
@property({ attribute: false })
finalHandler: () => Promise<void> = () => {
return Promise.resolve();
};
public finalHandler?: () => Promise<void>;
@query("ak-wizard")
wizard?: Wizard;
protected wizard?: Wizard;
connectedCallback() {
public connectedCallback() {
super.connectedCallback();
new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((providerTypes) => {
this.providerTypes = providerTypes;
@@ -56,9 +54,7 @@ export class ProviderWizard extends AKElement {
.steps=${["initial"]}
header=${msg("New provider")}
description=${msg("Create a new provider.")}
.finalHandler=${() => {
return this.finalHandler();
}}
.finalHandler=${() => this.finalHandler?.()}
>
<ak-wizard-page-type-create
name="selectProviderType"
@@ -68,7 +64,7 @@ export class ProviderWizard extends AKElement {
@select=${(ev: CustomEvent<TypeCreate>) => {
if (!this.wizard) return;
this.wizard.steps = ["initial", `type-${ev.detail.component}`];
this.wizard.isValid = true;
this.wizard.valid = true;
}}
>
</ak-wizard-page-type-create>
@@ -76,7 +72,7 @@ export class ProviderWizard extends AKElement {
return html`
<ak-wizard-page-form
slot=${`type-${type.component}`}
.sidebarLabel=${() => msg(str`Create ${type.name}`)}
.sidebarLabel=${msg(str`Create ${type.name}`)}
>
<ak-proxy-form type=${type.component}></ak-proxy-form>
</ak-wizard-page-form>

View File

@@ -48,7 +48,7 @@ export class OAuth2ProviderRedirectURI extends AkControlElement<RedirectURI> {
) as unknown as RedirectURI;
}
get isValid() {
get valid() {
return true;
}

View File

@@ -57,7 +57,7 @@ export class SourceWizard extends AKElement {
"initial",
`type-${ev.detail.component}-${ev.detail.modelName}`,
];
this.wizard.isValid = true;
this.wizard.valid = true;
}}
>
</ak-wizard-page-type-create>
@@ -65,7 +65,7 @@ export class SourceWizard extends AKElement {
return html`
<ak-wizard-page-form
slot=${`type-${type.component}-${type.modelName}`}
.sidebarLabel=${() => msg(str`Create ${type.name}`)}
.sidebarLabel=${msg(str`Create ${type.name}`)}
>
<ak-proxy-form
.args=${{

View File

@@ -93,7 +93,7 @@ export class StageWizard extends AKElement {
0,
`type-${ev.detail.component}-${ev.detail.modelName}`,
);
this.wizard.isValid = true;
this.wizard.valid = true;
}}
>
</ak-wizard-page-type-create>
@@ -101,7 +101,7 @@ export class StageWizard extends AKElement {
return html`
<ak-wizard-page-form
slot=${`type-${type.component}-${type.modelName}`}
.sidebarLabel=${() => msg(str`Create ${type.name}`)}
.sidebarLabel=${msg(str`Create ${type.name}`)}
>
<ak-proxy-form type=${type.component}></ak-proxy-form>
</ak-wizard-page-form>
@@ -110,7 +110,7 @@ export class StageWizard extends AKElement {
${this.showBindingPage
? html`<ak-wizard-page-form
slot="create-binding"
.sidebarLabel=${() => msg("Create Binding")}
.sidebarLabel=${msg("Create Binding")}
.activePageCallback=${async (context: FormWizardPage) => {
const createSlot = context.host.steps[1];
const bindingForm =

View File

@@ -77,6 +77,41 @@ html > form > input {
.pf-c-form {
--pf-c-form__group--m-action--MarginTop: var(--pf-global--spacer--form-element);
&.ak-m-radio-list {
--pf-c-form--GridGap: 0.5rem;
.pf-c-radio {
padding: 1rem;
}
}
}
.pf-c-radio {
--pf-c-radio__label--FontWeight: 500;
--pf-c-radio--GridGap: var(--pf-global--spacer--xs) var(--pf-global--spacer--lg);
user-select: none;
cursor: pointer;
border-radius: var(--pf-c-card--m-rounded--BorderRadius);
transition: background-color linear 100ms;
outline: 1px solid transparent;
&:hover {
background-color: var(
--radio-background-color--hover,
var(--pf-global--BackgroundColor--light-200)
);
}
&:has(input:checked) {
background-color: var(
--radio-background-color--selected,
var(--pf-global--BackgroundColor--light-200)
);
}
}
/* #region Icons */

View File

@@ -14,6 +14,8 @@
--ak-global--Color--100: var(--ak-dark-foreground) !important;
--pf-c-page__main-section--m-light--BackgroundColor: var(--ak-dark-background-darker);
--pf-global--BorderColor--100: var(--ak-dark-background-lighter) !important;
--radio-background-color--hover: rgba(3, 3, 3, 0.1);
--radio-background-color--selected: rgba(3, 3, 3, 0.3);
}
body {

View File

@@ -10,6 +10,7 @@ import { wizardStepContext } from "./WizardContexts.js";
import { AKElement } from "#elements/Base";
import { bound } from "#elements/decorators/bound";
import { SlottedTemplateResult } from "#elements/types";
import { match, P } from "ts-pattern";
@@ -41,6 +42,17 @@ const BUTTON_KIND_TO_LABEL: Record<ButtonKind, string> = {
close: msg("Close"),
};
function buttonLabel(button: WizardButton) {
return button.label ?? BUTTON_KIND_TO_LABEL[button.kind];
}
function buttonClasses(button: WizardButton) {
return {
"pf-c-button": true,
[BUTTON_KIND_TO_CLASS[button.kind]]: true,
};
}
/**
* @class WizardStep
*
@@ -60,7 +72,7 @@ const BUTTON_KIND_TO_LABEL: Record<ButtonKind, string> = {
* @fires WizardCloseEvent - request parent container (Wizard) to close the wizard
*/
export class WizardStep extends AKElement {
export abstract class WizardStep extends AKElement {
// These additions are necessary because we don't want to inherit *all* of the modal box
// modifiers, just the ones related to managing the height of the display box.
static styles = [
@@ -79,39 +91,43 @@ export class WizardStep extends AKElement {
`,
];
//#region Properties
@property({ type: Boolean, attribute: true, reflect: true })
enabled = false;
public enabled = false;
/**
* The name. Should match the slot. Reflected if not present.
*/
@property({ type: String, attribute: true, reflect: true })
name?: string;
public name?: string;
//#endregion
@consume({ context: wizardStepContext, subscribe: true })
wizardStepState: WizardStepState = { currentStep: undefined, stepLabels: [] };
protected wizardStepState: WizardStepState = { currentStep: undefined, stepLabels: [] };
/**
* What appears in the titlebar of the Wizard. Usually, but not necessarily, the same for all
* steps. Recommendation: Set this, the description, and `canCancel` in a subclass, and stop
* steps. Recommendation: Set this, the description, and `cancelable` in a subclass, and stop
* worrying about them.
*/
wizardTitle = "--unset--";
protected wizardTitle = "--unset--";
/**
* The text for a descriptive subtitle for the wizard
*/
wizardDescription?: string;
protected wizardDescription?: string;
/**
* Show the [Cancel] icon and offer the [Cancel] button
*/
canCancel = false;
protected cancelable = false;
/**
* The ID of the current step.
*/
id = "";
public id = "";
/**
*The label of the current step. Displayed in the navigation bar.
@@ -121,7 +137,7 @@ export class WizardStep extends AKElement {
/**
* If true, this step's label will not be shown in the navigation bar
*/
hide = false;
public hide = false;
// ___ _ _ _ _ ___ ___
// | _ \_ _| |__| (_)__ /_\ | _ \_ _|
@@ -137,13 +153,11 @@ export class WizardStep extends AKElement {
}
// Override this to provide the form.
public renderMain() {
throw new Error("This must be overridden in client classes");
}
public abstract renderMain(): SlottedTemplateResult;
// Override this to intercept 'next' and 'back' events, perform validation, and include enabling
// before allowing navigation to continue.
public handleButton(button: WizardButton, details?: NavigationEventInit) {
protected dispatchButtonEvent(button: WizardButton, details?: NavigationEventInit) {
if (["close", "cancel"].includes(button.kind)) {
this.dispatchEvent(new WizardCloseEvent());
return;
@@ -163,25 +177,32 @@ export class WizardStep extends AKElement {
// END Public API
connectedCallback() {
public connectedCallback() {
super.connectedCallback();
if (!this.name) {
const name = this.getAttribute("slot");
if (!name) {
throw new Error("Steps must have a unique slot attribute.");
throw new TypeError("Steps must have a unique slot attribute.");
}
this.name = name;
}
}
@bound
onWizardNavigationEvent(ev: Event, button: WizardButton) {
ev.stopPropagation();
//#endregion
//#region Event Listeners
#wizardNavigationListener = (event: Event, button: WizardButton) => {
event.stopPropagation();
if (!isNavigable(button)) {
throw new Error("Non-navigable button sent to handleNavigationEvent");
}
this.handleButton(button);
}
this.dispatchButtonEvent(button);
};
@bound
onWizardCloseEvent(ev: Event) {
@@ -189,46 +210,39 @@ export class WizardStep extends AKElement {
this.dispatchEvent(new WizardCloseEvent());
}
getButtonLabel(button: WizardButton) {
return button.label ?? BUTTON_KIND_TO_LABEL[button.kind];
}
//#endregion
getButtonClasses(button: WizardButton) {
return {
"pf-c-button": true,
[BUTTON_KIND_TO_CLASS[button.kind]]: true,
};
}
//#region Render
@bound
renderCloseButton(button: WizardButton) {
return html`<div class="pf-c-wizard__footer-cancel">
<button
class=${classMap(this.getButtonClasses(button))}
class=${classMap(buttonClasses(button))}
type="button"
@click=${this.onWizardCloseEvent}
>
${this.getButtonLabel(button)}
${buttonLabel(button)}
</button>
</div>`;
}
@bound
renderDisabledButton(button: WizardButton) {
return html`<button class=${classMap(this.getButtonClasses(button))} type="button" disabled>
${this.getButtonLabel(button)}
return html`<button class=${classMap(buttonClasses(button))} type="button" disabled>
${buttonLabel(button)}
</button>`;
}
@bound
renderNavigableButton(button: WizardButton) {
return html`<button
class=${classMap(this.getButtonClasses(button))}
class=${classMap(buttonClasses(button))}
type="button"
@click=${(ev: Event) => this.onWizardNavigationEvent(ev, button)}
@click=${(ev: Event) => this.#wizardNavigationListener(ev, button)}
data-ouid-button-kind="wizard-${button.kind}"
>
${this.getButtonLabel(button)}
${buttonLabel(button)}
</button>`;
}
@@ -277,46 +291,42 @@ export class WizardStep extends AKElement {
}
render() {
return this.wizardStepState.currentStep === this.getAttribute("slot")
? html` <div class="pf-c-modal-box ak-wizard-box">
<div class="pf-c-wizard">
<div class="pf-c-wizard__header" data-ouid-component-id="wizard-header">
${this.canCancel ? this.renderHeaderCancelIcon() : nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">
${this.wizardTitle}
</h1>
<p class="pf-c-wizard__description">${this.wizardDescription}</p>
</div>
if (this.wizardStepState.currentStep !== this.getAttribute("slot")) {
return nothing;
}
<div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">
<nav class="pf-c-wizard__nav" data-ouid-component-id="wizard-navbar">
<ol class="pf-c-wizard__nav-list">
${map(
this.wizardStepState.stepLabels,
this.renderSidebarStep,
)}
</ol>
</nav>
<main class="pf-c-wizard__main">
<div
id="main-content"
class="pf-c-wizard__main-body"
data-ouid-component-id="wizard-body"
>
${this.renderMain()}
</div>
</main>
</div>
<footer
class="pf-c-wizard__footer"
data-ouid-component-id="wizard-footer"
>
${this.buttons.map(this.renderButton)}
</footer>
</div>
</div>
</div>`
: nothing;
return html` <div class="pf-c-modal-box ak-wizard-box">
<div class="pf-c-wizard">
<div class="pf-c-wizard__header" data-ouid-component-id="wizard-header">
${this.cancelable ? this.renderHeaderCancelIcon() : nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.wizardTitle}</h1>
<p class="pf-c-wizard__description">${this.wizardDescription}</p>
</div>
<div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">
<nav class="pf-c-wizard__nav" data-ouid-component-id="wizard-navbar">
<ol class="pf-c-wizard__nav-list">
${map(this.wizardStepState.stepLabels, this.renderSidebarStep)}
</ol>
</nav>
<main class="pf-c-wizard__main">
<div
id="main-content"
class="pf-c-wizard__main-body"
data-ouid-component-id="wizard-body"
>
${this.renderMain()}
</div>
</main>
</div>
<footer class="pf-c-wizard__footer" data-ouid-component-id="wizard-footer">
${this.buttons.map(this.renderButton)}
</footer>
</div>
</div>
</div>`;
}
//#endregion
}

View File

@@ -22,7 +22,7 @@ export class AkControlElement<T = string | string[]> extends AKElement {
return this.json();
}
get isValid(): boolean {
get valid(): boolean {
return true;
}
}

View File

@@ -96,15 +96,16 @@ export class ArrayInput<T> extends AkControlElement<T[]> implements IArrayInput<
return this.items;
}
get isValid() {
get valid() {
if (!this.validate) {
return true;
}
const oneIsValid = (g: HTMLDivElement) =>
g.querySelector<HTMLInputElement & AkControlElement<T>>("[name]")?.isValid ?? true;
const allAreValid = Array.from(this.inputGroups ?? []).every(oneIsValid);
return allAreValid && (this.validator ? this.validator(this.items) : true);
const valid = Array.from(this.inputGroups || []).every((inputGroup) => {
return inputGroup.querySelector<AkControlElement>("[name]")?.valid ?? true;
});
return valid && (this.validator?.(this.items) ?? true);
}
itemsFromDom(): T[] {

View File

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

View File

@@ -7,7 +7,6 @@ import { camelToSnake } from "#common/utils";
import { isControlElement } from "#elements/AkControlElement";
import { AKElement } from "#elements/Base";
import { PreventFormSubmit } from "#elements/forms/helpers";
import { HorizontalFormElement } from "#elements/forms/HorizontalFormElement";
import { showMessage } from "#elements/messages/MessageContainer";
import { SlottedTemplateResult } from "#elements/types";
import { createFileMap, isNamedElement, NamedElement } from "#elements/utils/inputs";
@@ -170,9 +169,9 @@ export function serializeForm<T = Record<string, unknown>>(elements: Iterable<AK
*
*/
export abstract class Form<T = Record<string, unknown>> extends AKElement {
abstract send(data: T): Promise<unknown>;
public abstract send(data: T): Promise<unknown>;
viewportCheck = true;
public viewportCheck = true;
//#region Properties
@@ -298,16 +297,16 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
if (instanceOfValidationError(parsedError)) {
// assign all input-related errors to their elements
const elements =
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
"ak-form-element-horizontal",
) || [];
const elements = this.shadowRoot?.querySelectorAll(
"ak-form-element-horizontal",
);
elements.forEach((element) => {
for (const element of elements || []) {
element.requestUpdate();
const elementName = element.name;
if (!elementName) return;
if (!elementName) continue;
const snakeProperty = camelToSnake(elementName);
@@ -318,19 +317,13 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
element.errorMessages = [];
element.invalid = false;
}
});
}
if (parsedError.nonFieldErrors) {
this.nonFieldErrors = parsedError.nonFieldErrors;
}
errorMessage = msg("Invalid update request.");
// Only change the message when we have `detail`.
// Everything else is handled in the form.
if ("detail" in parsedError) {
errorMessage = parsedError.detail;
}
errorMessage = pluckErrorDetail(parsedError, msg("Invalid update request."));
}
showMessage({

View File

@@ -42,10 +42,6 @@ export class Radio<T> extends CustomEmitterElement(AKElement) {
var(--pf-c-form--m-horizontal__group-label--md--PaddingTop) * 1.3
);
}
.pf-c-radio label,
.pf-c-radio span {
user-select: none;
}
`,
];

View File

@@ -40,7 +40,7 @@ const metadata: Meta<IArrayInput<unknown>> = {
return;
}
const target = event.target as FooterLinkInput;
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`;
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.valid ? "Yes" : "No"}`;
});
}, 250);

View File

@@ -100,12 +100,16 @@ export class TableColumn {
}
}
export abstract class Table<T> extends WithLicenseSummary(AKElement) implements TableLike {
export abstract class Table<T extends object>
extends WithLicenseSummary(AKElement)
implements TableLike
{
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
abstract columns(): TableColumn[];
abstract row(item: T): SlottedTemplateResult[];
private isLoading = false;
@state()
protected loading = false;
#pageParam = `${this.tagName.toLowerCase()}-page`;
#searchParam = `${this.tagName.toLowerCase()}-search`;
@@ -126,10 +130,10 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
}
@property({ attribute: false })
data?: PaginatedResponse<T>;
public data?: PaginatedResponse<T>;
@property({ type: Number })
page = getURLParam(this.#pageParam, 1);
public page = getURLParam(this.#pageParam, 1);
/**
* Set if your `selectedElements` use of the selection box is to enable bulk-delete,
@@ -138,43 +142,43 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
* @prop
*/
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
clearOnRefresh = false;
public clearOnRefresh = false;
@property({ type: String })
order?: string;
public order?: string;
@property({ type: String })
search: string = "";
public search: string = "";
@property({ type: Boolean })
checkbox = false;
public checkbox = false;
@property({ type: Boolean })
clickable = false;
public clickable = false;
@property({ attribute: false })
clickHandler: (item: T) => void = () => {};
public clickHandler: (item: T) => void = () => {};
@property({ type: Boolean })
radioSelect = false;
public radioSelect = false;
@property({ type: Boolean })
checkboxChip = false;
public checkboxChip = false;
@property({ attribute: false })
selectedElements: T[] = [];
public selectedElements: T[] = [];
@property({ type: Boolean })
paginated = true;
public paginated = true;
@property({ type: Boolean })
expandable = false;
public expandable = false;
@property({ attribute: false })
expandedElements: T[] = [];
public expandedElements: T[] = [];
@state()
error?: APIError;
protected error: APIError | null = null;
static styles: CSSResult[] = [
PFBase,
@@ -213,16 +217,25 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
`,
];
constructor() {
super();
this.addEventListener(EVENT_REFRESH, async () => {
await this.fetch();
});
//#region Lifecycle
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener(EVENT_REFRESH, () => this.fetch());
if (this.searchEnabled()) {
this.search = getURLParam(this.#searchParam, "");
}
}
public override firstUpdated(): void {
this.fetch();
}
//#endregion
//#region API
async defaultEndpointConfig() {
return {
ordering: this.order,
@@ -239,14 +252,14 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
}
public async fetch(): Promise<void> {
if (this.isLoading) return;
if (this.loading) return;
this.isLoading = true;
this.loading = true;
return this.apiEndpoint()
.then((data) => {
this.data = data;
this.error = undefined;
this.error = null;
this.page = this.data.pagination.current;
const newExpanded: T[] = [];
@@ -289,11 +302,14 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
this.error = await parseAPIResponseError(error);
})
.finally(() => {
this.isLoading = false;
this.requestUpdate();
this.loading = false;
});
}
//#endregion
//#region Render
private renderLoading(): TemplateResult {
return html`<tr role="row">
<td role="cell" colspan="25">
@@ -337,7 +353,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
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) {
@@ -521,10 +537,6 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
</div>`;
}
firstUpdated(): void {
this.fetch();
}
/* 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.
*/
@@ -610,4 +622,6 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
render(): TemplateResult {
return this.renderTable();
}
//#endregion
}

View File

@@ -17,23 +17,24 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css";
export abstract class TableModal<T> extends Table<T> {
export abstract class TableModal<T extends object> extends Table<T> {
@property()
size: PFSize = PFSize.Large;
@property({ type: Boolean })
set open(value: boolean) {
this._open = value;
this.#open = value;
if (value) {
this.fetch();
}
}
get open(): boolean {
return this._open;
return this.#open;
}
_open = false;
#open = false;
static styles: CSSResult[] = [
...super.styles,
@@ -47,9 +48,8 @@ export abstract class TableModal<T> extends Table<T> {
];
public async fetch(): Promise<void> {
if (!this.open) {
return;
}
if (!this.open) return;
return super.fetch();
}

View File

@@ -10,7 +10,7 @@ import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFSidebar from "@patternfly/patternfly/components/Sidebar/sidebar.css";
export abstract class TablePage<T> extends Table<T> {
export abstract class TablePage<T extends object> extends Table<T> {
abstract pageTitle(): string;
abstract pageDescription(): string | undefined;
abstract pageIcon(): string;

View File

@@ -42,7 +42,7 @@ describe("ak-array-input", () => {
await component();
const link = await $("ak-array-input");
await browser.pause(500);
await expect(await link.getProperty("isValid")).toStrictEqual(true);
await expect(await link.getProperty("valid")).toStrictEqual(true);
await expect(await link.getProperty("toJson")).toEqual([]);
});
@@ -50,7 +50,7 @@ describe("ak-array-input", () => {
await component(sampleItems);
const link = await $("ak-array-input");
await browser.pause(500);
await expect(await link.getProperty("isValid")).toStrictEqual(true);
await expect(await link.getProperty("valid")).toStrictEqual(true);
await expect(await link.getProperty("toJson")).toEqual(sampleItems);
});
});

View File

@@ -1,4 +1,9 @@
import { ConstructorWithMixin, createMixin, LitElementConstructor } from "#elements/types";
import {
AbstractLitElementConstructor,
ConstructorWithMixin,
createMixin,
LitElementConstructor,
} from "#elements/types";
import { CustomEventDetail, isCustomEvent } from "#elements/utils/customEvents";
export interface CustomEventEmitterMixin<EventType extends string = string> {
@@ -11,7 +16,7 @@ export interface CustomEventEmitterMixin<EventType extends string = string> {
export function CustomEmitterElement<
EventType extends string = string,
T extends LitElementConstructor = LitElementConstructor,
T extends LitElementConstructor | AbstractLitElementConstructor = LitElementConstructor,
>(SuperClass: T) {
abstract class CustomEventEmmiter
extends SuperClass
@@ -31,7 +36,7 @@ export function CustomEmitterElement<
normalizedDetail = detail;
}
this.dispatchEvent(
(this as InstanceType<T>).dispatchEvent(
new CustomEvent(eventType, {
composed: true,
bubbles: true,

View File

@@ -15,6 +15,12 @@ export function isNamedElement(element: Element): element is NamedElement {
return "name" in element.attributes;
}
declare global {
interface HTMLElementTagNameMap {
"[name]": NamedElement<HTMLElement>;
}
}
/**
* Create a map of files provided by input elements within the given iterable.
*/

View File

@@ -1,10 +1,9 @@
import { EVENT_REFRESH } from "#common/constants";
import { pluckErrorDetail } from "#common/errors/network";
import { WizardAction } from "#elements/wizard/Wizard";
import { WizardPage } from "#elements/wizard/WizardPage";
import { ResponseError } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -33,65 +32,58 @@ export class ActionWizardPage extends WizardPage {
static styles: CSSResult[] = [PFBase, PFBullseye, PFEmptyState, PFTitle, PFProgressStepper];
@property({ attribute: false })
states: ActionStateBundle[] = [];
public states: ActionStateBundle[] = [];
@property({ attribute: false })
currentStep?: ActionStateBundle;
public currentStep?: ActionStateBundle;
activeCallback = async (): Promise<void> => {
this.states = [];
public override nextCallback = null;
this.host.actions.map((act, idx) => {
this.states.push({
action: act,
state: ActionState.pending,
idx: idx,
});
public override activeCallback = (): Promise<void> => {
this.states = this.host.actions.map((action, idx) => ({
action,
state: ActionState.pending,
idx: idx,
}));
this.host.previousNavigation = false;
this.host.cancelable = false;
return this.#run().finally(() => {
// Ensure wizard is closable, even when run() failed
this.host.valid = true;
});
this.host.canBack = false;
this.host.canCancel = false;
await this.run();
// Ensure wizard is closable, even when run() failed
this.host.isValid = true;
};
sidebarLabel = () => msg("Apply changes");
public override sidebarLabel = msg("Apply changes");
async run(): Promise<void> {
async #run(): Promise<void> {
this.currentStep = this.states[0];
await new Promise((r) => setTimeout(r, 500));
await new Promise((r) => requestAnimationFrame(r));
for await (const bundle of this.states) {
this.currentStep = bundle;
this.currentStep.state = ActionState.running;
this.requestUpdate();
try {
await bundle.action.run();
await new Promise((r) => setTimeout(r, 500));
await new Promise((r) => requestAnimationFrame(r));
this.currentStep.state = ActionState.done;
this.requestUpdate();
} catch (exc) {
if (exc instanceof ResponseError) {
this.currentStep.action.subText = await exc.response.text();
} else {
this.currentStep.action.subText = (exc as Error).toString();
}
} catch (error) {
this.currentStep.action.subText = pluckErrorDetail(error);
this.currentStep.state = ActionState.failed;
this.requestUpdate();
return;
this.requestUpdate();
}
}
this.host.isValid = true;
this.host.valid = true;
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {

View File

@@ -10,32 +10,37 @@ import { customElement } from "lit/decorators.js";
*/
@customElement("ak-wizard-page-form")
export class FormWizardPage extends WizardPage {
activePageCallback: (context: FormWizardPage) => Promise<void> = async () => {
return Promise.resolve();
public activePageCallback?: (context: FormWizardPage) => Promise<void>;
public override activeCallback = async () => {
this.host.valid = true;
this.activePageCallback?.(this);
};
activeCallback = async () => {
this.host.isValid = true;
this.activePageCallback(this);
};
nextCallback = async (): Promise<boolean> => {
const form = this.querySelector<Form<unknown>>("*");
public override nextCallback = (): Promise<boolean> => {
const form = this.querySelector("*");
if (!form) {
return Promise.reject(msg("No form found"));
throw new TypeError(msg("No child elements found in wizard page"));
}
const formPromise = form.submit(new SubmitEvent("submit"));
if (!formPromise) {
return Promise.reject(msg("Form didn't return a promise for submitting"));
if (!(form instanceof Form || form instanceof HTMLFormElement)) {
console.warn("authentik/wizard: form inside the form slot is not a Form", form);
throw new TypeError(msg("Wizard page doesn't contain a form"));
}
return formPromise
const validity = form.reportValidity();
if (!validity) {
return Promise.resolve(false);
}
const submitResult = form.submit(new SubmitEvent("submit"));
return Promise.resolve(submitResult)
.then((data) => {
this.host.state[this.slot] = data;
this.host.canBack = false;
this.host.previousNavigation = false;
return true;
})

View File

@@ -26,16 +26,18 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
//#region Properties
@property({ attribute: false })
types: TypeCreate[] = [];
public types: TypeCreate[] = [];
@property({ attribute: false })
selectedType?: TypeCreate;
public selectedType: TypeCreate | null = null;
@property({ type: String })
layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
public layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
//#endregion
public override nextCallback = null;
static styles: CSSResult[] = [
PFBase,
PFForm,
@@ -55,22 +57,21 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
//#region Refs
formRef: Ref<HTMLFormElement> = createRef();
#formRef: Ref<HTMLFormElement> = createRef();
//#endregion
public sidebarLabel = () => msg("Select type");
public override sidebarLabel = msg("Select type");
public reset = () => {
super.reset();
this.selectedType = undefined;
this.formRef.value?.reset();
public reset = (): void => {
this.selectedType = null;
this.#formRef.value?.reset();
};
activeCallback = (): void => {
const form = this.formRef.value;
const form = this.#formRef.value;
this.host.isValid = form?.checkValidity() ?? false;
this.host.valid = form?.checkValidity() ?? false;
if (this.selectedType) {
this.selectDispatch(this.selectedType);
@@ -138,7 +139,7 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
renderList(): TemplateResult {
return html`<form
${ref(this.formRef)}
${ref(this.#formRef)}
class="pf-c-form pf-m-horizontal"
data-ouid-component-type="ak-type-create-list"
>

View File

@@ -1,5 +1,7 @@
import "#elements/wizard/ActionWizardPage";
import { EVENT_REFRESH } from "#common/constants";
import { ModalButton } from "#elements/buttons/ModalButton";
import { WizardPage } from "#elements/wizard/WizardPage";
@@ -38,45 +40,43 @@ export class Wizard extends ModalButton {
* Whether the wizard can be cancelled.
*/
@property({ type: Boolean })
canCancel = true;
public cancelable = true;
/**
* Whether the wizard can go back to the previous step.
*/
@property({ type: Boolean })
canBack = true;
public previousNavigation = true;
/**
* Header title of the wizard.
*/
@property()
header?: string;
public header?: string;
/**
* Description of the wizard.
*/
@property()
description?: string;
public description?: string;
/**
* Whether the wizard is valid and can proceed to the next step.
*/
@property({ type: Boolean })
isValid = false;
public valid = false;
/**
* Actions to display at the end of the wizard.
*/
@property({ attribute: false })
actions: WizardAction[] = [];
public actions: WizardAction[] = [];
@property({ attribute: false })
finalHandler = () => {
return Promise.resolve();
};
public finalHandler?: () => Promise<void>;
@property({ attribute: false })
state: { [key: string]: unknown } = {};
public state: { [key: string]: unknown } = {};
//#endregion
@@ -86,7 +86,7 @@ export class Wizard extends ModalButton {
* Memoized step tag names.
*/
@state()
_steps: string[] = [];
protected _steps: string[] = [];
/**
* Step tag names present in the wizard.
@@ -123,25 +123,25 @@ export class Wizard extends ModalButton {
/**
* Initial steps to reset to.
*/
_initialSteps: string[] = [];
#initialSteps: string[] = [];
@state()
_activeStep?: WizardPage;
protected activeStep: WizardPage | null = null;
set activeStepElement(nextActiveStepElement: WizardPage | undefined) {
this._activeStep = nextActiveStepElement;
set activeStepElement(nextActiveStepElement: WizardPage | null) {
this.activeStep = nextActiveStepElement;
if (!this._activeStep) return;
if (!this.activeStep) return;
this._activeStep.activeCallback();
this._activeStep.requestUpdate();
this.activeStep.activeCallback();
this.activeStep.requestUpdate();
}
/**
* The active step element being displayed.
*/
get activeStepElement(): WizardPage | undefined {
return this._activeStep;
get activeStepElement(): WizardPage | null {
return this.activeStep;
}
getStepElementByIndex(stepIndex: number): WizardPage | null {
@@ -154,69 +154,7 @@ export class Wizard extends ModalButton {
return this.querySelector<WizardPage>(`[slot=${stepName}]`);
}
//#endregion
//#region Lifecycle
firstUpdated(): void {
this._initialSteps = this._steps;
}
/**
* Add action to the beginning of the list.
*/
addActionBefore(displayName: string, run: () => Promise<boolean>): void {
this.actions.unshift({
displayName,
run,
});
}
/**
* Add action at the end of the list.
*
* @todo: Is this used?
*/
addActionAfter(displayName: string, run: () => Promise<boolean>): void {
this.actions.push({
displayName,
run,
});
}
/**
* Reset the wizard to it's initial state.
*/
reset = (ev?: Event) => {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
this.open = false;
this.querySelectorAll("[data-wizardmanaged=true]").forEach((el) => {
el.remove();
});
for (const step of this.steps) {
const stepElement = this.getStepElementByName(step);
stepElement?.reset?.();
}
this.steps = this._initialSteps;
this.actions = [];
this.state = {};
this.activeStepElement = undefined;
this.canBack = true;
this.canCancel = true;
};
//#endregion
//#region Rendering
renderModalInner(): TemplateResult {
#gatherSteps() {
const firstPage = this.getStepElementByIndex(0);
if (!this.activeStepElement && firstPage) {
@@ -234,7 +172,78 @@ export class Wizard extends ModalButton {
lastPage = activeStepIndex === this.steps.length - 1;
}
const navigateToPreviousStep = () => {
return {
firstPage,
activeStepIndex,
lastPage,
};
}
//#endregion
//#region Lifecycle
public firstUpdated(): void {
this.#initialSteps = this._steps;
}
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener(EVENT_REFRESH, this.#refreshListener);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(EVENT_REFRESH, this.#refreshListener);
}
//#endregion
//#region Event Listeners
/**
* Reset the wizard to it's initial state.
*/
#resetListener = (event?: Event) => {
event?.preventDefault();
event?.stopPropagation();
this.open = false;
for (const element of this.querySelectorAll("[data-wizardmanaged=true]")) {
element.remove();
}
for (const step of this.steps) {
const stepElement = this.getStepElementByName(step);
stepElement?.reset?.();
}
this.steps = this.#initialSteps;
this.actions = [];
this.state = {};
this.activeStepElement = null;
this.previousNavigation = true;
this.cancelable = true;
};
#refreshListener = (event: Event) => {
const { lastPage } = this.#gatherSteps();
if (!lastPage) {
event.stopImmediatePropagation();
}
};
//#endregion
//#region Rendering
public renderModalInner(): TemplateResult {
const { activeStepIndex, lastPage } = this.#gatherSteps();
const navigatePreviousListener = () => {
const prevPage = this.getStepElementByIndex(activeStepIndex - 1);
if (prevPage) {
@@ -242,14 +251,37 @@ export class Wizard extends ModalButton {
}
};
const navigateNextListener = async (): Promise<void> => {
if (!this.activeStepElement) return;
if (this.activeStepElement.nextCallback) {
const completedStep = await this.activeStepElement.nextCallback();
if (!completedStep) return;
if (lastPage) {
await this.finalHandler?.();
this.#resetListener();
return;
}
}
const nextPage = this.getStepElementByIndex(activeStepIndex + 1);
if (nextPage) {
this.activeStepElement = nextPage;
}
};
return html`<div class="pf-c-wizard">
<div class="pf-c-wizard__header">
${this.canCancel
${this.cancelable
? html`<button
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
aria-label="${msg("Close")}"
@click=${(ev: Event) => this.reset(ev)}
@click=${this.#resetListener}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`
@@ -266,8 +298,6 @@ export class Wizard extends ModalButton {
if (!stepEl) return html`<p>Unexpected missing step: ${step}</p>`;
const sidebarLabel = stepEl.sidebarLabel();
return html`
<li class="pf-c-wizard__nav-item">
<button
@@ -275,12 +305,13 @@ export class Wizard extends ModalButton {
"pf-c-wizard__nav-link": true,
"pf-m-current": idx === activeStepIndex,
})}
type="button"
?disabled=${activeStepIndex < idx}
@click=${() => {
this.activeStepElement = stepEl;
}}
>
${sidebarLabel}
${stepEl.sidebarLabel || msg("Label Missing")}
</button>
</li>
`;
@@ -296,47 +327,31 @@ export class Wizard extends ModalButton {
<footer class="pf-c-wizard__footer">
<button
class="pf-c-button pf-m-primary"
type="submit"
?disabled=${!this.isValid}
@click=${async () => {
const completedStep = await this.activeStepElement?.nextCallback();
if (!completedStep) return;
if (lastPage) {
await this.finalHandler();
this.reset();
return;
}
const nextPage = this.getStepElementByIndex(activeStepIndex + 1);
if (nextPage) {
this.activeStepElement = nextPage;
}
}}
type="button"
?disabled=${!this.valid}
@click=${navigateNextListener}
>
${lastPage ? msg("Finish") : msg("Next")}
</button>
${(this.activeStepElement
? this.steps.indexOf(this.activeStepElement.slot)
: 0) > 0 && this.canBack
: 0) > 0 && this.previousNavigation
? html`
<button
class="pf-c-button pf-m-secondary"
type="button"
@click=${navigateToPreviousStep}
@click=${navigatePreviousListener}
>
${msg("Back")}
</button>
`
: nothing}
${this.canCancel
${this.cancelable
? html`<div class="pf-c-wizard__footer-cancel">
<button
class="pf-c-button pf-m-link"
type="button"
@click=${(ev: Event) => this.reset(ev)}
@click=${(ev: Event) => this.#resetListener(ev)}
>
${msg("Cancel")}
</button>

View File

@@ -1,94 +0,0 @@
import { Form } from "#elements/forms/Form";
import { WizardPage } from "#elements/wizard/WizardPage";
import { CSSResult, html, TemplateResult } from "lit";
import { property } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export abstract class WizardForm extends Form {
viewportCheck = false;
@property({ attribute: false })
nextDataCallback!: (data: Record<string, unknown>) => Promise<boolean>;
/* Override the traditional behavior of the form and instead simply serialize the form and push
* it's contents to the next page.
*/
async submit(): Promise<boolean | undefined> {
const data = this.serialize();
if (!data) return;
const files = this.files();
return this.nextDataCallback({
...data,
...files,
});
}
}
export class WizardFormPage extends WizardPage {
static styles: CSSResult[] = [
PFBase,
PFCard,
PFButton,
PFForm,
PFAlert,
PFInputGroup,
PFFormControl,
];
inputCallback(): void {
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
if (!form) return;
const state = form.checkValidity();
this.host.isValid = state;
}
nextCallback = async (): Promise<boolean> => {
const form = this.shadowRoot?.querySelector<WizardForm>("ak-wizard-form");
if (!form) {
console.warn("authentik/wizard: could not find form element");
return false;
}
const response = await form.submit();
return Boolean(response);
};
nextDataCallback: (data: Record<string, unknown>) => Promise<boolean> =
async (): Promise<boolean> => {
return false;
};
renderForm(): TemplateResult {
return html``;
}
activeCallback = async () => {
this.inputCallback();
};
render(): TemplateResult {
return html`
<ak-wizard-form
.nextDataCallback=${this.nextDataCallback}
@input=${() => this.inputCallback()}
>
${this.renderForm()}
</ak-wizard-form>
`;
}
}

View File

@@ -1,7 +1,7 @@
import { AKElement } from "#elements/Base";
import { Wizard } from "#elements/wizard/Wizard";
import { CSSResult, html, PropertyDeclaration, TemplateResult } from "lit";
import { CSSResult, html, LitElement, PropertyDeclaration, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@@ -19,21 +19,16 @@ export type WizardPageActiveCallback = () => void | Promise<void>;
export type WizardPageNextCallback = () => boolean | Promise<boolean>;
@customElement("ak-wizard-page")
export class WizardPage extends AKElement {
export abstract class WizardPage extends AKElement {
static styles: CSSResult[] = [PFBase];
/**
* The label to display in the sidebar for this page.
*
* Override this to provide a custom label.
* @todo: Should this be a getter or static property?
*/
@property()
sidebarLabel = (): string => {
return "UNNAMED";
};
public sidebarLabel?: string;
get host(): Wizard {
public get host(): Wizard {
return this.parentElement as Wizard;
}
@@ -42,15 +37,13 @@ export class WizardPage extends AKElement {
*
* @abstract
*/
public reset(): void | Promise<void> {
console.debug(`authentik/wizard ${this.localName}: reset)`);
}
public reset?: () => void | Promise<void>;
/**
* Called when this is the page brought into view.
*/
activeCallback: WizardPageActiveCallback = () => {
this.host.isValid = false;
public activeCallback: WizardPageActiveCallback = () => {
this.host.valid = false;
};
/**
@@ -60,24 +53,23 @@ export class WizardPage extends AKElement {
*
* @returns `true` if the wizard can proceed to the next page, `false` otherwise.
*/
nextCallback: WizardPageNextCallback = () => {
return Promise.resolve(true);
};
public abstract nextCallback: WizardPageNextCallback | null;
requestUpdate(
public requestUpdate(
name?: PropertyKey,
oldValue?: unknown,
options?: PropertyDeclaration<unknown, unknown>,
): void {
this.querySelectorAll("*").forEach((el) => {
if ("requestUpdate" in el) {
(el as AKElement).requestUpdate();
for (const element of this.querySelectorAll("*")) {
if (element instanceof LitElement) {
element.requestUpdate();
}
});
}
return super.requestUpdate(name, oldValue, options);
}
render(): TemplateResult {
public render(): TemplateResult {
return html`<slot></slot>`;
}
}

View File

@@ -59,7 +59,7 @@ export class AkRememberMeController implements ReactiveController {
) as HTMLInputElement | null;
}
get isValidChallenge() {
get validChallenge() {
return !(
this.host.challenge.responseErrors &&
this.host.challenge.responseErrors.non_field_errors &&