web: Application Wizard

This commit combines a working (but very unpolished) version of the Application Wizard with Jen's
code for the CoreTransactionApplicationRequest, resulting in a successful round trip.

It fixes a number of bugs with the way ContextProducer decorators were being processed, such that
they just weren't working with our current configuration (although they did work fine in Storybook);
consumers didn't need to be fixed.

It also *removes* the steps-aware context from the Wizard.

That *may* be a mistake.  To re-iterate, the `WizardFrame` provides the chrome for a Wizard: the
button bar div, the breadcrumbs div, the header div, and it takes the steps object as its source of
truth for all of the content.  The `WizardContent` part of the application has two parts: The
`WizardMain`, which wraps the frame and supplies the context for all the `WizardPanels`, and the
`WizardPanels` themselves, which are dependent on a context from `WizardMain` for the data that
populates each panel. YAGNI right now that the panels need to know anything about the steps, and the
`WizardMain` can just pass a fresh `.steps` object to the `WizardFrame` when they need updating.
Using props drilling may make more sense here.

It certainy does *not* make sense for the panels.  They need to be renderable on-demand, and they
need to make sense of what they're rendering on-demand, so the function is

```
(panel code) => (context) => (rendered panel)
```

(Yes, that's curried notation. Deal.)
This commit is contained in:
Ken Sternberg 2023-08-24 14:22:32 -07:00
parent cbdca55e57
commit 58bc1c3656
22 changed files with 463 additions and 214 deletions

View File

@ -1,4 +1,5 @@
import "@goauthentik/admin/applications/ApplicationForm";
import "@goauthentik/admin/applications/wizard/ak-application-wizard";
import { PFSize } from "@goauthentik/app/elements/Spinner";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
@ -7,7 +8,7 @@ import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
// import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -162,6 +163,7 @@ export class ApplicationListPage extends TablePage<Application> {
];
}
/*
renderObjectCreate(): TemplateResult {
return html`<ak-forms-modal .open=${getURLParam("createForm", false)}>
<span slot="submit"> ${msg("Create")} </span>
@ -170,4 +172,9 @@ export class ApplicationListPage extends TablePage<Application> {
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>`;
}
*/
renderObjectCreate(): TemplateResult {
return html`<ak-application-wizard></ak-application-wizard>`;
}
}

View File

@ -1,3 +1,4 @@
import { WizardPanel } from "@goauthentik/components/ak-wizard-main/types";
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
@ -9,7 +10,10 @@ import { styles as AwadStyles } from "./BasePanel.css";
import { applicationWizardContext } from "./ak-application-wizard-context-name";
import type { WizardState } from "./types";
export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) {
export class ApplicationWizardPageBase
extends CustomEmitterElement(AKElement)
implements WizardPanel
{
static get styles() {
return AwadStyles;
}
@ -19,7 +23,6 @@ export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) {
rendered = false;
// @ts-expect-error
@consume({ context: applicationWizardContext })
public wizard!: WizardState;

View File

@ -1,5 +1,8 @@
import { createContext } from "@lit-labs/context";
export const applicationWizardContext = createContext(Symbol("ak-application-wizard-context"));
import { WizardState } from "./types";
export const applicationWizardContext = createContext<WizardState>(
Symbol("ak-application-wizard-state-context"),
);
export default applicationWizardContext;

View File

@ -3,9 +3,9 @@ import "@goauthentik/components/ak-wizard-main";
import { AKElement } from "@goauthentik/elements/Base";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { provide } from "@lit-labs/context";
import { ContextProvider, ContextRoot } from "@lit-labs/context";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { CSSResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -27,19 +27,22 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
return [PFBase, PFButton, PFRadio];
}
/**
* Providing a context at the root element
*/
@provide({ context: applicationWizardContext })
@state()
wizardState: WizardState = {
step: 0,
providerType: "",
application: {},
providerModel: "",
app: {},
provider: {},
};
@state()
/**
* Providing a context at the root element
*/
wizardStateProvider = new ContextProvider(this, {
context: applicationWizardContext,
initialValue: this.wizardState,
});
steps = steps;
@property()
@ -54,6 +57,7 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
connectedCallback() {
super.connectedCallback();
new ContextRoot().attach(this.parentElement!);
this.addCustomListener("ak-application-wizard-update", this.handleUpdate);
}
@ -68,14 +72,14 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
// Are we changing provider type? If so, swap the caches of the various provider types the
// user may have filled in, and enable the next step.
const providerType = update.providerType;
const providerModel = update.providerModel;
if (
providerType &&
typeof providerType === "string" &&
providerType !== this.wizardState.providerType
providerModel &&
typeof providerModel === "string" &&
providerModel !== this.wizardState.providerModel
) {
this.providerCache.set(this.wizardState.providerType, this.wizardState.provider);
const prevProvider = this.providerCache.get(providerType);
this.providerCache.set(this.wizardState.providerModel, this.wizardState.provider);
const prevProvider = this.providerCache.get(providerModel);
this.wizardState.provider = prevProvider ?? {};
const newSteps = [...this.steps];
const method = newSteps.find(({ id }) => id === "auth-method");
@ -87,10 +91,10 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
}
this.wizardState = merge(this.wizardState, update) as WizardState;
console.log(JSON.stringify(this.wizardState, null, 2));
this.wizardStateProvider.setValue(this.wizardState);
}
render(): TemplateResult {
render() {
return html`
<ak-wizard-main
.steps=${this.steps}

View File

@ -1,6 +1,7 @@
import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import "@goauthentik/elements/forms/FormGroup";
@ -21,34 +22,41 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
console.warn(`Received event with no target: ${ev}`);
return;
}
const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({
application: {
app: {
[target.name]: value,
},
});
}
validator() {
return this.form.reportValidity();
}
render(): TemplateResult {
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input
name="name"
value=${ifDefined(this.wizard.application?.name)}
value=${ifDefined(this.wizard.app?.name)}
label=${msg("Name")}
required
help=${msg("Application's display Name.")}
id="ak-application-wizard-details-name"
></ak-text-input>
<ak-text-input
<ak-slug-input
name="slug"
value=${ifDefined(this.wizard.application?.slug)}
value=${ifDefined(this.wizard.app?.slug)}
label=${msg("Slug")}
source="#ak-application-wizard-details-name"
required
help=${msg("Internal application name used in URLs.")}
></ak-text-input>
></ak-slug-input>
<ak-text-input
name="group"
value=${ifDefined(this.wizard.application?.group)}
value=${ifDefined(this.wizard.app?.group)}
label=${msg("Group")}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
@ -59,7 +67,7 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
required
name="policyEngineMode"
.options=${policyOptions}
.value=${this.wizard.application?.policyEngineMode}
.value=${this.wizard.app?.policyEngineMode}
></ak-radio-input>
<ak-form-group>
<span slot="header"> ${msg("UI settings")} </span>
@ -67,14 +75,14 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
<ak-text-input
name="metaLaunchUrl"
label=${msg("Launch URL")}
value=${ifDefined(this.wizard.application?.metaLaunchUrl)}
value=${ifDefined(this.wizard.app?.metaLaunchUrl)}
help=${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider.",
)}
></ak-text-input>
<ak-switch-input
name="openInNewTab"
?checked=${first(this.wizard.application?.openInNewTab, false)}
?checked=${first(this.wizard.app?.openInNewTab, false)}
label=${msg("Open in new tab")}
help=${msg(
"If checked, the launch URL will open in a new browser tab or window from the user's application library.",

View File

@ -1,70 +1,132 @@
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import type { TypeCreate } from "@goauthentik/api";
import type { ProviderModelEnum as ProviderModelEnumType, TypeCreate } from "@goauthentik/api";
import { ProviderModelEnum } from "@goauthentik/api";
import type {
LDAPProviderRequest,
ModelRequest,
OAuth2ProviderRequest,
ProxyProviderRequest,
SAMLProviderRequest,
} from "@goauthentik/api";
import { OneOfProvider } from "../types";
type ProviderRenderer = () => TemplateResult;
type ProviderType = [string, string, string, ProviderRenderer];
type ProviderType = [string, string, string, ProviderRenderer, ProviderModelEnumType];
type ModelConverter = (provider: OneOfProvider) => ModelRequest;
export type LocalTypeCreate = TypeCreate & {
formName: string;
modelName: ProviderModelEnumType;
converter: ModelConverter;
};
// prettier-ignore
const _providerTypesTable: ProviderType[] = [
const _providerModelsTable: ProviderType[] = [
[
"oauth2provider",
msg("OAuth2/OpenID"),
msg("Modern applications, APIs and Single-page applications."),
() => html`<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>`
() => html`<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>`,
ProviderModelEnum.Oauth2Oauth2provider,
],
[
"ldapprovider",
msg("LDAP"),
msg("Provide an LDAP interface for applications and users to authenticate against."),
() => html`<ak-application-wizard-authentication-by-ldap></ak-application-wizard-authentication-by-ldap>`
() => html`<ak-application-wizard-authentication-by-ldap></ak-application-wizard-authentication-by-ldap>`,
ProviderModelEnum.LdapLdapprovider,
],
[
"proxyprovider-proxy",
msg("Transparent Reverse Proxy"),
msg("For transparent reverse proxies with required authentication"),
() => html`<ak-application-wizard-authentication-for-reverse-proxy></ak-application-wizard-authentication-for-reverse-proxy>`
() => html`<ak-application-wizard-authentication-for-reverse-proxy></ak-application-wizard-authentication-for-reverse-proxy>`,
ProviderModelEnum.ProxyProxyprovider
],
[
"proxyprovider-forwardsingle",
msg("Forward Single Proxy"),
msg("For nginx's auth_request or traefix's forwardAuth"),
() => html`<ak-application-wizard-authentication-for-single-forward-proxy></ak-application-wizard-authentication-for-single-forward-proxy>`
],
() => html`<ak-application-wizard-authentication-for-single-forward-proxy></ak-application-wizard-authentication-for-single-forward-proxy>`,
ProviderModelEnum.ProxyProxyprovider
],
[
"samlprovider-manual",
msg("SAML Manual configuration"),
msg("Configure SAML provider manually"),
() => html`<p>Under construction</p>`
() => html`<p>Under construction</p>`,
ProviderModelEnum.SamlSamlprovider
],
[
"samlprovider-import",
msg("SAML Import Configuration"),
msg("Create a SAML provider by importing its metadata"),
() => html`<p>Under construction</p>`
() => html`<p>Under construction</p>`,
ProviderModelEnum.SamlSamlprovider
],
];
function mapProviders([modelName, name, description]: ProviderType): TypeCreate {
const converters = new Map<ProviderModelEnumType, ModelConverter>([
[
ProviderModelEnum.Oauth2Oauth2provider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.Oauth2Oauth2provider,
...(provider as OAuth2ProviderRequest),
}),
],
[
ProviderModelEnum.LdapLdapprovider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.LdapLdapprovider,
...(provider as LDAPProviderRequest),
}),
],
[
ProviderModelEnum.ProxyProxyprovider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.ProxyProxyprovider,
...(provider as ProxyProviderRequest),
}),
],
[
ProviderModelEnum.SamlSamlprovider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.SamlSamlprovider,
...(provider as SAMLProviderRequest),
}),
],
]);
// Contract enforcement
const getConverter = (modelName: ProviderModelEnumType): ModelConverter => {
const maybeConverter = converters.get(modelName);
if (!maybeConverter) {
throw new Error(`ModelName lookup failed in model converter definition: ${"modelName"}`);
}
return maybeConverter;
};
function mapProviders([formName, name, description, _, modelName]: ProviderType): LocalTypeCreate {
return {
modelName,
formName,
name,
description,
component: "",
modelName,
converter: getConverter(modelName),
};
}
export const providerTypesList = _providerTypesTable.map(mapProviders);
export const providerModelsList = _providerModelsTable.map(mapProviders);
export const providerRendererList = new Map<string, ProviderRenderer>(
_providerTypesTable.map(([modelName, _0, _1, renderer]) => [modelName, renderer]),
_providerModelsTable.map(([modelName, _0, _1, renderer]) => [modelName, renderer]),
);
export default providerTypesList;
export default providerModelsList;

View File

@ -10,10 +10,9 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j
import { html } from "lit";
import { map } from "lit/directives/map.js";
import type { TypeCreate } from "@goauthentik/api";
import BasePanel from "../BasePanel";
import providerTypesList from "./ak-application-wizard-authentication-method-choice.choices";
import providerModelsList from "./ak-application-wizard-authentication-method-choice.choices";
import type { LocalTypeCreate } from "./ak-application-wizard-authentication-method-choice.choices";
@customElement("ak-application-wizard-authentication-method-choice")
export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
@ -25,31 +24,31 @@ export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
handleChoice(ev: InputEvent) {
const target = ev.target as HTMLInputElement;
this.dispatchWizardUpdate({ providerType: target.value });
this.dispatchWizardUpdate({ providerModel: target.value });
}
renderProvider(type: TypeCreate) {
const method = this.wizard.providerType;
renderProvider(type: LocalTypeCreate) {
const method = this.wizard.providerModel;
return html`<div class="pf-c-radio">
<input
class="pf-c-radio__input"
type="radio"
name="type"
id="provider-${type.modelName}"
value=${type.modelName}
?checked=${type.modelName === method}
id="provider-${type.formName}"
value=${type.formName}
?checked=${type.formName === method}
@change=${this.handleChoice}
/>
<label class="pf-c-radio__label" for="provider-${type.modelName}">${type.name}</label>
<label class="pf-c-radio__label" for="provider-${type.formName}">${type.name}</label>
<span class="pf-c-radio__description">${type.description}</span>
</div>`;
}
render() {
return providerTypesList.length > 0
return providerModelsList.length > 0
? html`<form class="pf-c-form pf-m-horizontal">
${map(providerTypesList, this.renderProvider)}
${map(providerModelsList, this.renderProvider)}
</form>`
: html`<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>`;
}

View File

@ -1,5 +1,4 @@
import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm";
import { first } from "@goauthentik/common/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
@ -7,84 +6,91 @@ import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { TemplateResult, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import {
ApplicationRequest,
CoreApi,
TransactionApplicationRequest,
TransactionApplicationResponse,
} from "@goauthentik/api";
import type { ModelRequest } from "@goauthentik/api";
import BasePanel from "../BasePanel";
import providerModelsList from "../auth-method-choice/ak-application-wizard-authentication-method-choice.choices";
function cleanApplication(app: Partial<ApplicationRequest>): ApplicationRequest {
return {
name: "",
slug: "",
...app,
};
}
type ProviderModelType = Exclude<ModelRequest["providerModel"], "11184809">;
@customElement("ak-application-wizard-commit-application")
export class ApplicationWizardCommitApplication extends BasePanel {
handleChange(ev: Event) {
if (!ev.target) {
console.warn(`Received event with no target: ${ev}`);
state: "idle" | "running" | "done" = "idle";
response?: TransactionApplicationResponse;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
willUpdate(_changedProperties: Map<string, any>) {
if (this.state === "idle") {
this.response = undefined;
this.state = "running";
const provider = providerModelsList.find(
({ formName }) => formName === this.wizard.providerModel,
);
if (!provider) {
throw new Error(
`Could not determine provider model from user request: ${JSON.stringify(
this.wizard,
null,
2,
)}`,
);
}
const request: TransactionApplicationRequest = {
providerModel: provider.modelName as ProviderModelType,
app: cleanApplication(this.wizard.app),
provider: provider.converter(this.wizard.provider),
};
this.send(request);
return;
}
const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({
application: {
[target.name]: value,
},
});
}
async send(
data: TransactionApplicationRequest,
): Promise<TransactionApplicationResponse | void> {
new CoreApi(DEFAULT_CONFIG)
.coreTransactionalApplicationsUpdate({ transactionApplicationRequest: data })
.then(
(response) => {
this.response = response;
this.state = "done";
},
(error) => {
console.log(error);
},
);
}
render(): TemplateResult {
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input
name="name"
value=${ifDefined(this.wizard.application?.name)}
label=${msg("Name")}
required
help=${msg("Application's display Name.")}
></ak-text-input>
<ak-text-input
name="slug"
value=${ifDefined(this.wizard.application?.slug)}
label=${msg("Slug")}
required
help=${msg("Internal application name used in URLs.")}
></ak-text-input>
<ak-text-input
name="group"
value=${ifDefined(this.wizard.application?.group)}
label=${msg("Group")}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
)}
></ak-text-input>
<ak-radio-input
label=${msg("Policy engine mode")}
required
name="policyEngineMode"
.options=${policyOptions}
.value=${this.wizard.application?.policyEngineMode}
></ak-radio-input>
<ak-form-group>
<span slot="header"> ${msg("UI settings")} </span>
<div slot="body" class="pf-c-form">
<ak-text-input
name="metaLaunchUrl"
label=${msg("Launch URL")}
value=${ifDefined(this.wizard.application?.metaLaunchUrl)}
help=${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider.",
)}
></ak-text-input>
<ak-switch-input
name="openInNewTab"
?checked=${first(this.wizard.application?.openInNewTab, false)}
label=${msg("Open in new tab")}
help=${msg(
"If checked, the launch URL will open in a new browser tab or window from the user's application library.",
)}
>
</ak-switch-input>
</div>
</ak-form-group>
</form>`;
return html`
<div>
<h3>Current result:</h3>
<p>State: ${this.state}</p>
<pre>${JSON.stringify(this.wizard, null, 2)}</pre>
<p>Response:</p>
<pre>${JSON.stringify(this.response, null, 2)}</pre>
</div>
`;
}
}
export default ApplicationWizardApplicationDetails;
export default ApplicationWizardCommitApplication;

View File

@ -14,6 +14,10 @@ export class ApplicationWizardProviderPageBase extends BasePanel {
},
});
}
validator() {
return this.form.reportValidity();
}
}
export default ApplicationWizardProviderPageBase;

View File

@ -12,7 +12,7 @@ import "./proxy/ak-application-wizard-authentication-for-single-forward-proxy";
@customElement("ak-application-wizard-authentication-method")
export class ApplicationWizardApplicationDetails extends BasePanel {
render() {
const handler = providerRendererList.get(this.wizard.providerType);
const handler = providerRendererList.get(this.wizard.providerModel);
if (!handler) {
throw new Error(
"Unrecognized authentication method in ak-application-wizard-authentication-method",

View File

@ -5,6 +5,7 @@ import { html } from "lit";
import "./application/ak-application-wizard-application-details";
import "./auth-method-choice/ak-application-wizard-authentication-method-choice";
import "./commit/ak-application-wizard-commit-application";
import "./methods/ak-application-wizard-authentication-method";
export const steps: WizardStep[] = [
@ -47,5 +48,4 @@ export const steps: WizardStep[] = [
backButtonLabel: msg("Back"),
valid: true,
},
];

View File

@ -47,7 +47,7 @@ const container = (testItem: TemplateResult) => {
export const MainPage = () => {
return container(html`
<ak-application-wizard>></ak-application-wizard>
<ak-application-wizard></ak-application-wizard>
<hr />
<ak-application-context-display-for-test></ak-application-context-display-for-test>
`);

View File

@ -1,25 +1,25 @@
import {
Application,
LDAPProvider,
OAuth2Provider,
ProxyProvider,
RadiusProvider,
SAMLProvider,
SCIMProvider,
ApplicationRequest,
LDAPProviderRequest,
OAuth2ProviderRequest,
ProxyProviderRequest,
RadiusProviderRequest,
SAMLProviderRequest,
SCIMProviderRequest,
} from "@goauthentik/api";
export type OneOfProvider =
| Partial<SCIMProvider>
| Partial<SAMLProvider>
| Partial<RadiusProvider>
| Partial<ProxyProvider>
| Partial<OAuth2Provider>
| Partial<LDAPProvider>;
| Partial<SCIMProviderRequest>
| Partial<SAMLProviderRequest>
| Partial<RadiusProviderRequest>
| Partial<ProxyProviderRequest>
| Partial<OAuth2ProviderRequest>
| Partial<LDAPProviderRequest>;
export interface WizardState {
step: number;
providerType: string;
application: Partial<Application>;
providerModel: string;
app: Partial<ApplicationRequest>;
provider: OneOfProvider;
}

View File

@ -1,28 +0,0 @@
import { ReactiveController } from 'lit';
import type { ReactiveControllerHost } from 'lit';
export class ApplicationWizardController implements ReactiveController {
host: ReactiveControllerHost;
value = new Date();
timeout: number;
private _timerID?: number;
constructor(host: ReactiveControllerHost, timeout = 1000) {
(this.host = host).addController(this);
this.timeout = timeout;
}
hostConnected() {
// Start a timer when the host is connected
this._timerID = setInterval(() => {
this.value = new Date();
// Update the host with new value
this.host.requestUpdate();
}, this.timeout);
}
hostDisconnected() {
// Clear the timer when the host is disconnected
clearInterval(this._timerID);
this._timerID = undefined;
}
}

View File

@ -0,0 +1,162 @@
import { convertToSlug } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-slug-input")
export class AkSlugInput extends AKElement {
// 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.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
@property({ type: String, reflect: true })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
@property({ type: Boolean })
hidden = false;
@property({ type: Object })
bighelp!: TemplateResult | TemplateResult[];
@property({ type: String })
source = "";
origin?: HTMLInputElement | null;
@query("input")
input!: HTMLInputElement;
touched: boolean = false;
constructor() {
super();
this.slugify = this.slugify.bind(this);
this.handleTouch = this.handleTouch.bind(this);
}
firstUpdated() {
this.input.addEventListener("input", this.handleTouch);
}
renderHelp() {
return [
this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing,
this.bighelp ? this.bighelp : nothing,
];
}
// Do not stop propagation of this event; it must be sent up the tree so that a parent
// component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) {
this.input.value = convertToSlug(this.input.value);
this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") {
this.touched = false;
return;
}
if (ev && ev.target && ev.target instanceof HTMLInputElement) {
this.touched = true;
}
}
slugify(ev: Event) {
// A very primitive heuristic: if the previous iteration of the slug and the current
// iteration are *similar enough*, set the input value. "Similar enough" here is defined as
// "any event which adds or removes a character but leaves the rest of the slug looking like
// the previous iteration, set it to the current iteration."
if (ev && ev.target && ev.target instanceof HTMLInputElement) {
if (this.touched) {
if (ev.target.value === "" && this.input.value === "") {
this.touched = false;
} else {
return;
}
}
const newSlug = convertToSlug(ev.target.value);
const oldSlug = this.input.value;
const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];
if (longer.substring(0, shorter.length) === shorter) {
this.input.value = newSlug;
// The browser, as a security measure, sets the originating HTML object to be the
// target; developers cannot change it. In order to provide a meaningful value
// to listeners, both the name and value of the host must match those of the target
// input. The name is already handled since it's both required and automatically
// forwarded to our templated input, but the value must also be set.
this.value = this.input.value;
this.dispatchEvent(
new Event("input", {
bubbles: true,
cancelable: true,
}),
);
}
}
}
connectedCallback() {
super.connectedCallback();
// Set up listener on source element, so we can slugify the content.
setTimeout(() => {
if (this.source) {
const rootNode = this.getRootNode();
if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
this.origin = rootNode.querySelector(this.source);
}
if (this.origin) {
this.origin.addEventListener("input", this.slugify);
}
}
}, 0);
}
disconnectedCallback() {
if (this.origin) {
this.origin.removeEventListener("input", this.slugify);
}
super.disconnectedCallback();
}
render() {
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
?hidden=${this.hidden}
name=${this.name}
>
<input
type="text"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}

View File

@ -1,16 +1,13 @@
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { consume } from "@lit-labs/context";
import { msg } from "@lit/localize";
import { customElement, property, state } from "@lit/reactive-element/decorators.js";
import { customElement, property, query } from "@lit/reactive-element/decorators.js";
import { html, nothing } from "lit";
import { classMap } from "lit/directives/class-map.js";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
import { akWizardCurrentStepContextName } from "./akWizardCurrentStepContextName";
import { akWizardStepsContextName } from "./akWizardStepsContextName";
import type { WizardStep } from "./types";
/**
@ -49,16 +46,15 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
@property()
eventName: string = "ak-wizard-nav";
// @ts-expect-error
@consume({ context: akWizardStepsContextName, subscribe: true })
@state()
@property({ attribute: false, type: Array })
steps!: WizardStep[];
// @ts-expect-error
@consume({ context: akWizardCurrentStepContextName, subscribe: true })
@state()
@property({ attribute: false, type: Object })
currentStep!: WizardStep;
@query("#main-content *:first-child")
content!: HTMLElement;
reset() {
this.open = false;
}
@ -141,7 +137,9 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
// independent context.
renderMainSection() {
return html`<main class="pf-c-wizard__main">
<div class="pf-c-wizard__main-body">${this.currentStep.renderer()}</div>
<div id="main-content" class="pf-c-wizard__main-body">
${this.currentStep.renderer()}
</div>
</main>`;
}
@ -159,23 +157,22 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
return html`<button
class="pf-c-button pf-m-primary"
type="submit"
?disabled=${!this.currentStep.valid}
@click=${() => this.dispatchCustomEvent(this.eventName, { step: nextStep.id })}
@click=${() =>
this.dispatchCustomEvent(this.eventName, { step: nextStep.id, action: "next" })}
>
${this.currentStep.nextButtonLabel}
</button>`;
}
renderFooterBackButton(backStep: WizardStep) {
return html`
<button
class="pf-c-button pf-m-secondary"
type="button"
@click=${() => this.dispatchCustomEvent(this.eventName, { step: backStep.id })}
>
${this.currentStep.backButtonLabel}
</button>
`;
return html`<button
class="pf-c-button pf-m-secondary"
type="button"
@click=${() =>
this.dispatchCustomEvent(this.eventName, { step: backStep.id, action: "back" })}
>
${this.currentStep.backButtonLabel}
</button> `;
}
renderFooterCancelButton() {

View File

@ -1,9 +1,8 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { provide } from "@lit-labs/context";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement, property, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -11,9 +10,16 @@ import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./ak-wizard-frame";
import { akWizardCurrentStepContextName } from "./akWizardCurrentStepContextName";
import { akWizardStepsContextName } from "./akWizardStepsContextName";
import type { WizardStep } from "./types";
import { AkWizardFrame } from "./ak-wizard-frame";
import type { WizardPanel, WizardStep } from "./types";
// Not just a check that it has a validator, but a check that satisfies Typescript that we're using
// it correctly; anything within the hasValidator conditional block will know it's dealing with
// a fully operational WizardPanel.
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasValidator = (v: any): v is Required<Pick<WizardPanel, "validator">> =>
"validator" in v && typeof v.validator === "function";
/**
* AKWizardMain
@ -41,7 +47,6 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
*
* @attribute
*/
@provide({ context: akWizardStepsContextName })
@property({ attribute: false })
steps: WizardStep[] = [];
@ -50,7 +55,6 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
*
* @attribute
*/
@provide({ context: akWizardCurrentStepContextName })
@state()
currentStep!: WizardStep;
@ -91,6 +95,9 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
@property()
description?: string;
@query("ak-wizard-frame")
frame!: AkWizardFrame;
// Guarantee that if the current step was not passed in by the client, that we know
// and set to the first step.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -117,7 +124,7 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
// before setting the currentStep. Especially since setting the currentStep triggers a second
// asynchronous event-- scheduling a re-render of everything interested in the currentStep
// object.
handleNavigation(event: CustomEvent<{ step: string }>) {
handleNavigation(event: CustomEvent<{ step: string; action: string }>) {
const requestedStep = event.detail.step;
if (!requestedStep) {
throw new Error("Request for next step when no next step is available");
@ -126,11 +133,18 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
if (!step) {
throw new Error("Request for next step when no next step is available.");
}
if (step.disabled) {
throw new Error("Request for next step when the next step is disabled.");
if (event.detail.action === "next" && !this.validated()) {
return false;
}
this.currentStep = step;
return;
return true;
}
validated() {
if (hasValidator(this.frame.content)) {
return this.frame.content.validator();
}
return true;
}
render() {
@ -140,6 +154,8 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
header=${this.header}
description=${ifDefined(this.description)}
eventName=${this.eventName}
.steps=${this.steps}
.currentStep=${this.currentStep}
>
<button slot="trigger" class="pf-c-button pf-m-primary">${this.prompt}</button>
</ak-wizard-frame>

View File

@ -1,5 +1,9 @@
import { createContext } from "@lit-labs/context";
export const akWizardCurrentStepContextName = createContext(Symbol("ak-wizard-current-step"));
import { WizardStep } from "./types";
export const akWizardCurrentStepContextName = createContext<WizardStep>(
Symbol("ak-wizard-current-step"),
);
export default akWizardCurrentStepContextName;

View File

@ -1,5 +1,7 @@
import { createContext } from "@lit-labs/context";
export const akWizardStepsContextName = createContext(Symbol("ak-wizard-steps"));
import { WizardStep } from "./types";
export const akWizardStepsContextName = createContext<WizardStep[]>(Symbol("ak-wizard-steps"));
export default akWizardStepsContextName;

View File

@ -9,3 +9,7 @@ export interface WizardStep {
nextButtonLabel?: string;
backButtonLabel?: string;
}
export interface WizardPanel extends HTMLElement {
validator?: () => boolean;
}

View File

@ -45,7 +45,6 @@ export const ButtonWithSuccess = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
console.log(ev.type, ev.target.name, ev.target.value, ev.detail);
document.getElementById("radio-message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.target.value,
null,

View File

@ -2,13 +2,11 @@ import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { customEvent, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
import { provide } from "@lit-labs/context";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { initializeLocalization } from "./configureLocale";
import type { LocaleGetter, LocaleSetter } from "./configureLocale";
import locale from "./context";
import {
DEFAULT_LOCALE,
autoDetectLanguage,
@ -32,7 +30,6 @@ import {
@customElement("ak-locale-context")
export class LocaleContext extends LitElement {
/// @attribute The text representation of the current locale */
@provide({ context: locale })
@property({ attribute: true, type: String })
locale = DEFAULT_LOCALE;