diff --git a/web/packages/core/types/node.d.ts b/web/packages/core/types/node.d.ts index 07d3b8119a..bf46982a96 100644 --- a/web/packages/core/types/node.d.ts +++ b/web/packages/core/types/node.d.ts @@ -14,7 +14,7 @@ declare module "module" { * const relativeDirname = dirname(fileURLToPath(import.meta.url)); * ``` */ - // eslint-disable-next-line no-var + var __dirname: string; } } diff --git a/web/src/admin/applications/ApplicationForm.ts b/web/src/admin/applications/ApplicationForm.ts index 49ddfb9cf5..90d758de4f 100644 --- a/web/src/admin/applications/ApplicationForm.ts +++ b/web/src/admin/applications/ApplicationForm.ts @@ -5,6 +5,7 @@ import { policyEngineModes } from "@goauthentik/admin/policies/PolicyEngineModes import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/components/ak-file-input"; 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/components/ak-textarea-input"; @@ -130,14 +131,14 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm - + > ) { />

${msg("Shown as the Title in Flow pages.")}

- - -

${msg("Visible in the URL.")}

-
+ + + - + + + - - - + - - - + + + - - - + + + - - - + + +
{ } renderForm(): TemplateResult { - return html` + const checkSlug = (ev: InputEvent) => { + if (ev && ev.target && ev.target instanceof HTMLInputElement) { + ev.target.value = (ev.target.value ?? "").replace(/[^a-z0-9-]/g, ""); + } + }; + + return html` checkSlug(ev)} data-ak-slug="true" /> +

+ ${msg( + "The name of an invitation must be a slug: only lower case letters, numbers, and the hyphen are permitted here.", + )} +

kebabCase(s, { suffixCharacters: "-" }); + +/** + * @element ak-slug-input + * @class AkSlugInput + * + * A wrapper around `ak-form-element-horizontal` and a text input control that listens for input on + * a peer text input control and automatically mirrors that control's value, transforming the value + * into a slug and displaying it separately. + * + * If the user manually changes the slug, mirroring and transformation stop. If, after that, both + * fields are cleared manually, mirroring and transformation resume. + * + * ## Limitations: + * + * Both the source text field and the slug field must be rendered in the same render pass (i.e., + * part of the same singular call to a `render` function) so that the slug field can find its + * source. + * + * For the same reason, both the source text field and the slug field must share the same immediate + * parent DOM object. + * + * Since we expect the source text field and the slug to be part of the same form and rendered not + * just in the same form but in the same form group, these are not considered burdensome + * restrictions. + */ @customElement("ak-slug-input") export class AkSlugInput extends HorizontalLightComponent { - @property({ type: String, reflect: true }) - value = ""; - + /** + * A selector indicating the source text input control. Must be unique within the whole DOM + * context of the slug and source controls. The most common use in authentik is the default: + * slugifying the "name" of something. + */ @property({ type: String }) - source = ""; + public source = "[name='name']"; - origin?: HTMLInputElement | null; + @property({ type: String, reflect: true }) + public value = ""; @query("input") - input!: HTMLInputElement; + private input!: HTMLInputElement; - touched: boolean = false; + #origin?: HTMLInputElement | null; - constructor() { - super(); - this.slugify = this.slugify.bind(this); - this.handleTouch = this.handleTouch.bind(this); - } - - firstUpdated() { - this.input.addEventListener("input", this.handleTouch); - } + #touched: boolean = false; // 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 = formatSlug(this.input.value); - this.value = this.input.value; + protected handleTouch(ev: Event) { + this.value = this.input.value = slugify(this.input.value); - if (this.origin && this.origin.value === "" && this.input.value === "") { - this.touched = false; + // Reset 'touched' status if the slug & target have been reset + if (this.#origin && this.#origin.value === "" && this.input.value === "") { + this.#touched = false; return; } if (ev && ev.target && ev.target instanceof HTMLInputElement) { - this.touched = true; + this.#touched = true; } } - slugify(ev: Event) { + @bound + protected slugify(ev: Event) { if (!(ev && ev.target && ev.target instanceof HTMLInputElement)) { return; } // Reset 'touched' status if the slug & target have been reset if (ev.target.value === "" && this.input.value === "") { - this.touched = false; + this.#touched = false; } - // Don't proceed if the user has hand-modified the slug - if (this.touched) { + // Don't proceed if the user has hand-modified the slug. (Note the order of statements: if + // the user hand modified the slug to be empty as part of resetting the slug/source + // relationship, that's a "not-touched" condition and falls through.) + if (this.#touched) { return; } @@ -67,7 +92,7 @@ export class AkSlugInput extends HorizontalLightComponent { // "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." - const newSlug = formatSlug(ev.target.value); + const newSlug = slugify(ev.target.value); const oldSlug = this.input.value; const [shorter, longer] = newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug]; @@ -81,7 +106,6 @@ export class AkSlugInput extends HorizontalLightComponent { // 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 = newSlug; this.dispatchEvent( new Event("input", { @@ -91,38 +115,36 @@ export class AkSlugInput extends HorizontalLightComponent { ); } - 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); + public override disconnectedCallback() { + if (this.#origin) { + this.#origin.removeEventListener("input", this.slugify); } super.disconnectedCallback(); } - renderControl() { + public override renderControl() { return html` this.handleTouch(ev)} type="text" value=${ifDefined(this.value)} class="pf-c-form-control" ?required=${this.required} />`; } + + public override firstUpdated() { + if (!this.source) { + return; + } + + 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); + } + } } export default AkSlugInput; diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index b026ecacef..e49c63ff0f 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -7,7 +7,6 @@ import { AKElement } from "@goauthentik/elements/Base"; import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement"; import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; -import { formatSlug } from "@goauthentik/elements/router/utils.js"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; @@ -197,39 +196,6 @@ export abstract class Form extends AKElement { return this.successMessage; } - /** - * After rendering the form, if there is both a `name` and `slug` element within the form, - * events the `name` element so that the slug will always have a slugified version of the - * `name.`. This duplicates functionality within ak-form-element-horizontal. - */ - updated(): void { - this.shadowRoot - ?.querySelectorAll("ak-form-element-horizontal[name=name]") - .forEach((nameInput) => { - const input = nameInput.firstElementChild as HTMLInputElement; - const form = nameInput.closest("form"); - if (form === null) { - return; - } - const slugFieldWrapper = form.querySelector( - "ak-form-element-horizontal[name=slug]", - ); - if (!slugFieldWrapper) { - return; - } - const slugField = slugFieldWrapper.firstElementChild as HTMLInputElement; - // Only attach handler if the slug is already equal to the name - // if not, they are probably completely different and shouldn't update - // each other - if (formatSlug(input.value) !== slugField.value) { - return; - } - nameInput.addEventListener("input", () => { - slugField.value = formatSlug(input.value); - }); - }); - } - resetForm(): void { const form = this.shadowRoot?.querySelector("form"); form?.reset(); diff --git a/web/src/elements/forms/HorizontalFormElement.ts b/web/src/elements/forms/HorizontalFormElement.ts index 0441196c35..bf72dcb401 100644 --- a/web/src/elements/forms/HorizontalFormElement.ts +++ b/web/src/elements/forms/HorizontalFormElement.ts @@ -77,9 +77,6 @@ export class HorizontalFormElement extends AKElement { @property({ attribute: false }) errorMessages: string[] | string[][] = []; - @property({ type: Boolean }) - slugMode = false; - _invalid = false; /* If this property changes, we want to make sure the parent control is "opened" so @@ -109,13 +106,6 @@ export class HorizontalFormElement extends AKElement { this.querySelectorAll("input[autofocus]").forEach((input) => { input.focus(); }); - if (this.name === "slug" || this.slugMode) { - this.querySelectorAll("input[type='text']").forEach((input) => { - input.addEventListener("keyup", () => { - input.value = formatSlug(input.value); - }); - }); - } this.querySelectorAll("*").forEach((input) => { if (isAkControl(input) && !input.getAttribute("name")) { input.setAttribute("name", this.name); diff --git a/web/types/node.d.ts b/web/types/node.d.ts index 22c8f25afa..6088589cc5 100644 --- a/web/types/node.d.ts +++ b/web/types/node.d.ts @@ -14,7 +14,7 @@ declare module "module" { * const relativeDirname = dirname(fileURLToPath(import.meta.url)); * ``` */ - // eslint-disable-next-line no-var + var __dirname: string; } }