From 58bc1c36565d9567ee50d9c4c19f8ec20b7b9068 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 24 Aug 2023 14:22:32 -0700 Subject: [PATCH] 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.) --- .../admin/applications/ApplicationListPage.ts | 9 +- .../admin/applications/wizard/BasePanel.ts | 7 +- .../ak-application-wizard-context-name.ts | 5 +- .../wizard/ak-application-wizard.ts | 38 ++-- ...-application-wizard-application-details.ts | 26 ++- ...rd-authentication-method-choice.choices.ts | 100 +++++++++-- ...ion-wizard-authentication-method-choice.ts | 23 ++- ...k-application-wizard-commit-application.ts | 142 +++++++-------- .../wizard/methods/BaseProviderPanel.ts | 4 + ...pplication-wizard-authentication-method.ts | 2 +- web/src/admin/applications/wizard/steps.ts | 2 +- .../ak-application-wizard-main.stories.ts | 2 +- web/src/admin/applications/wizard/types.ts | 30 ++-- .../applications/wizard/wizardController.ts | 28 --- web/src/components/ak-slug-input.ts | 162 ++++++++++++++++++ .../ak-wizard-main/ak-wizard-frame.ts | 41 ++--- .../ak-wizard-main/ak-wizard-main.ts | 38 ++-- .../akWizardCurrentStepContextName.ts | 6 +- .../akWizardStepsContextName.ts | 4 +- web/src/components/ak-wizard-main/types.ts | 4 + .../stories/ak-radio-input.stories.ts | 1 - .../ak-locale-context/ak-locale-context.ts | 3 - 22 files changed, 463 insertions(+), 214 deletions(-) delete mode 100644 web/src/admin/applications/wizard/wizardController.ts create mode 100644 web/src/components/ak-slug-input.ts diff --git a/web/src/admin/applications/ApplicationListPage.ts b/web/src/admin/applications/ApplicationListPage.ts index e34c93b43..f1d00b1ef 100644 --- a/web/src/admin/applications/ApplicationListPage.ts +++ b/web/src/admin/applications/ApplicationListPage.ts @@ -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 { ]; } + /* renderObjectCreate(): TemplateResult { return html` ${msg("Create")} @@ -170,4 +172,9 @@ export class ApplicationListPage extends TablePage { `; } +*/ + + renderObjectCreate(): TemplateResult { + return html``; + } } diff --git a/web/src/admin/applications/wizard/BasePanel.ts b/web/src/admin/applications/wizard/BasePanel.ts index 33cd79f03..f0822e7ed 100644 --- a/web/src/admin/applications/wizard/BasePanel.ts +++ b/web/src/admin/applications/wizard/BasePanel.ts @@ -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; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts index 47e1d356d..5a8c95eb9 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts @@ -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( + Symbol("ak-application-wizard-state-context"), +); export default applicationWizardContext; diff --git a/web/src/admin/applications/wizard/ak-application-wizard.ts b/web/src/admin/applications/wizard/ak-application-wizard.ts index 0a2320f8e..fe5e60155 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard.ts @@ -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` - + > ${msg("UI settings")} @@ -67,14 +75,14 @@ export class ApplicationWizardApplicationDetails extends BasePanel { 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`` + () => html``, + ProviderModelEnum.Oauth2Oauth2provider, ], - [ "ldapprovider", msg("LDAP"), msg("Provide an LDAP interface for applications and users to authenticate against."), - () => html`` + () => html``, + ProviderModelEnum.LdapLdapprovider, ], - [ "proxyprovider-proxy", msg("Transparent Reverse Proxy"), msg("For transparent reverse proxies with required authentication"), - () => html`` + () => html``, + ProviderModelEnum.ProxyProxyprovider ], - [ "proxyprovider-forwardsingle", msg("Forward Single Proxy"), msg("For nginx's auth_request or traefix's forwardAuth"), - () => html`` - ], + () => html``, + ProviderModelEnum.ProxyProxyprovider + ], [ "samlprovider-manual", msg("SAML Manual configuration"), msg("Configure SAML provider manually"), - () => html`

Under construction

` + () => html`

Under construction

`, + ProviderModelEnum.SamlSamlprovider ], - [ "samlprovider-import", msg("SAML Import Configuration"), msg("Create a SAML provider by importing its metadata"), - () => html`

Under construction

` + () => html`

Under construction

`, + ProviderModelEnum.SamlSamlprovider ], ]; -function mapProviders([modelName, name, description]: ProviderType): TypeCreate { +const converters = new Map([ + [ + 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( - _providerTypesTable.map(([modelName, _0, _1, renderer]) => [modelName, renderer]), + _providerModelsTable.map(([modelName, _0, _1, renderer]) => [modelName, renderer]), ); -export default providerTypesList; +export default providerModelsList; diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts index 5b64efacb..da9a50996 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts @@ -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`
- + ${type.description}
`; } render() { - return providerTypesList.length > 0 + return providerModelsList.length > 0 ? html`
- ${map(providerTypesList, this.renderProvider)} + ${map(providerModelsList, this.renderProvider)}
` : html``; } diff --git a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts index d0832356a..00ef284c4 100644 --- a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts +++ b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts @@ -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 { + return { + name: "", + slug: "", + ...app, + }; +} + +type ProviderModelType = Exclude; @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) { + 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 { + new CoreApi(DEFAULT_CONFIG) + .coreTransactionalApplicationsUpdate({ transactionApplicationRequest: data }) + .then( + (response) => { + this.response = response; + this.state = "done"; + }, + (error) => { + console.log(error); + }, + ); } render(): TemplateResult { - return html`
- - - - - - ${msg("UI settings")} -
- - - -
-
-
`; + return html` +
+

Current result:

+

State: ${this.state}

+
${JSON.stringify(this.wizard, null, 2)}
+

Response:

+
${JSON.stringify(this.response, null, 2)}
+
+ `; } } -export default ApplicationWizardApplicationDetails; +export default ApplicationWizardCommitApplication; diff --git a/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts b/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts index f0e44507c..d41a9392d 100644 --- a/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts +++ b/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts @@ -14,6 +14,10 @@ export class ApplicationWizardProviderPageBase extends BasePanel { }, }); } + + validator() { + return this.form.reportValidity(); + } } export default ApplicationWizardProviderPageBase; diff --git a/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts b/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts index b63741326..b9b1b7219 100644 --- a/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts +++ b/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts @@ -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", diff --git a/web/src/admin/applications/wizard/steps.ts b/web/src/admin/applications/wizard/steps.ts index 52e76e289..629402c25 100644 --- a/web/src/admin/applications/wizard/steps.ts +++ b/web/src/admin/applications/wizard/steps.ts @@ -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, }, - ]; diff --git a/web/src/admin/applications/wizard/stories/ak-application-wizard-main.stories.ts b/web/src/admin/applications/wizard/stories/ak-application-wizard-main.stories.ts index 76d0f1035..d0e7d8aec 100644 --- a/web/src/admin/applications/wizard/stories/ak-application-wizard-main.stories.ts +++ b/web/src/admin/applications/wizard/stories/ak-application-wizard-main.stories.ts @@ -47,7 +47,7 @@ const container = (testItem: TemplateResult) => { export const MainPage = () => { return container(html` - > +
`); diff --git a/web/src/admin/applications/wizard/types.ts b/web/src/admin/applications/wizard/types.ts index ca5705ece..12cce4f9c 100644 --- a/web/src/admin/applications/wizard/types.ts +++ b/web/src/admin/applications/wizard/types.ts @@ -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 - | Partial - | Partial - | Partial - | Partial - | Partial; + | Partial + | Partial + | Partial + | Partial + | Partial + | Partial; export interface WizardState { step: number; - providerType: string; - application: Partial; + providerModel: string; + app: Partial; provider: OneOfProvider; } diff --git a/web/src/admin/applications/wizard/wizardController.ts b/web/src/admin/applications/wizard/wizardController.ts deleted file mode 100644 index 86a42050d..000000000 --- a/web/src/admin/applications/wizard/wizardController.ts +++ /dev/null @@ -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; - } -} diff --git a/web/src/components/ak-slug-input.ts b/web/src/components/ak-slug-input.ts new file mode 100644 index 000000000..b236df9c0 --- /dev/null +++ b/web/src/components/ak-slug-input.ts @@ -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`

${this.help}

` : 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` + + ${this.renderHelp()} + `; + } +} diff --git a/web/src/components/ak-wizard-main/ak-wizard-frame.ts b/web/src/components/ak-wizard-main/ak-wizard-frame.ts index 712e9c777..51d93d3b7 100644 --- a/web/src/components/ak-wizard-main/ak-wizard-frame.ts +++ b/web/src/components/ak-wizard-main/ak-wizard-frame.ts @@ -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`
-
${this.currentStep.renderer()}
+
+ ${this.currentStep.renderer()} +
`; } @@ -159,23 +157,22 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) { return html``; } renderFooterBackButton(backStep: WizardStep) { - return html` - - `; + return html` `; } renderFooterCancelButton() { diff --git a/web/src/components/ak-wizard-main/ak-wizard-main.ts b/web/src/components/ak-wizard-main/ak-wizard-main.ts index f4be5734a..1189882cc 100644 --- a/web/src/components/ak-wizard-main/ak-wizard-main.ts +++ b/web/src/components/ak-wizard-main/ak-wizard-main.ts @@ -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> => + "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} > diff --git a/web/src/components/ak-wizard-main/akWizardCurrentStepContextName.ts b/web/src/components/ak-wizard-main/akWizardCurrentStepContextName.ts index 3a5c289d8..35ebe97a9 100644 --- a/web/src/components/ak-wizard-main/akWizardCurrentStepContextName.ts +++ b/web/src/components/ak-wizard-main/akWizardCurrentStepContextName.ts @@ -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( + Symbol("ak-wizard-current-step"), +); export default akWizardCurrentStepContextName; diff --git a/web/src/components/ak-wizard-main/akWizardStepsContextName.ts b/web/src/components/ak-wizard-main/akWizardStepsContextName.ts index fd0613e99..43ef4867c 100644 --- a/web/src/components/ak-wizard-main/akWizardStepsContextName.ts +++ b/web/src/components/ak-wizard-main/akWizardStepsContextName.ts @@ -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(Symbol("ak-wizard-steps")); export default akWizardStepsContextName; diff --git a/web/src/components/ak-wizard-main/types.ts b/web/src/components/ak-wizard-main/types.ts index f295a77f4..2c629f1b2 100644 --- a/web/src/components/ak-wizard-main/types.ts +++ b/web/src/components/ak-wizard-main/types.ts @@ -9,3 +9,7 @@ export interface WizardStep { nextButtonLabel?: string; backButtonLabel?: string; } + +export interface WizardPanel extends HTMLElement { + validator?: () => boolean; +} diff --git a/web/src/components/stories/ak-radio-input.stories.ts b/web/src/components/stories/ak-radio-input.stories.ts index 640c4502b..12e423eee 100644 --- a/web/src/components/stories/ak-radio-input.stories.ts +++ b/web/src/components/stories/ak-radio-input.stories.ts @@ -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, diff --git a/web/src/elements/ak-locale-context/ak-locale-context.ts b/web/src/elements/ak-locale-context/ak-locale-context.ts index 304ddacac..af69fbf19 100644 --- a/web/src/elements/ak-locale-context/ak-locale-context.ts +++ b/web/src/elements/ak-locale-context/ak-locale-context.ts @@ -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;