web/admin: remove all special cases of slug handling, replace with a "smart slug" component (#14983)

* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* This (temporary) change is needed to prevent the unit tests from failing.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes

* Revert "This (temporary) change is needed to prevent the unit tests from failing."

This reverts commit dddde09be5.

* web/components: Remove all special cases of slug handling, replace with a "smart slug" component

This commit removes all special handling for the `slug` attribute in our text. A variant of the text
input control that can handle formatting-as-slugs has replaced all the slugificiation code; simply
drop it onto a page and tell it the (must be unique) selector from which to get the data to be
slugified. It only looks up one tier of the DOM so be careful that both the text input and its slug
accessory occupy the same DOM context.

## Details

### The Component

Now that we know a (lot) more about Lit, this component has been slightly updated to meet our
current standards.

- web/src/components/ak-slug-input.ts

Changes made:

- The "listen for the source object" has been moved to the `firstUpdated`, so that it no longer has
  to wait for the end of a render.
 - The `dirtyFlag` handler now uses the `@input` syntax.
- Updated the slug formatter to permit trailing dashes.
- Uses the `@bound` decorator, eliminating the need to do binding in the constructor (and so
  eliminating the constructor completely).

### Component uses:

The following components were revised to use `ak-slug-input` instead of a plain text input with the
slug-handling added by our forms manager.

- applications/ApplicationForm.ts
- flows/FlowForm.ts
- sources/kerberos/KerberosSourceForm.ts
- sources/ldap/LDAPSourceForm.ts
- sources/oauth/OAuthSourceForm.ts
- sources/plex/PlexSourceForm.ts
- sources/saml/SAMLSourceForm.ts
- sources/scim/SCIMSourceForm.ts

### Remove the redundant special slug handling code

- web/src/elements/forms/Form.ts
- web/src/elements/forms/HorizontalFormElement.ts

### A special case among special cases

- web/src/admin/stages/invitation/InvitationForm.ts

This form is our one case where we have a slug input field with no corresponding text source. Adding
a simple event handler to validate the value whenever it changed and write back a "clean" slug was
the most straightforward solution. I added a help line; it seemed "surprising" to ask someone for a
name and not follow the same rules as "names" everywhere else in our UI without explanation.

* After writing the commit message, I realized some of the comments I made MUST be added to the component.

* The `source` attribute needed its own comment to indicate that a `query()` compatible selector is expected.

* Added public/private/protected/# indicators to all fields.  Trying to balance between getting it 'right' and leaving an opening for harmonizing style-sharing and state-sharing between (text / textarea), slug, password and (visible / hidden / secret).

* Removed the ids as requested; the default "look for this" matches the original behavior without requiring it be hard-coded and unchangable.
This commit is contained in:
Ken Sternberg
2025-06-16 09:04:00 -07:00
committed by GitHub
parent e85d2d0096
commit bc4b07d57b
15 changed files with 155 additions and 157 deletions

View File

@ -14,7 +14,7 @@ declare module "module" {
* const relativeDirname = dirname(fileURLToPath(import.meta.url)); * const relativeDirname = dirname(fileURLToPath(import.meta.url));
* ``` * ```
*/ */
// eslint-disable-next-line no-var
var __dirname: string; var __dirname: string;
} }
} }

View File

@ -5,6 +5,7 @@ import { policyEngineModes } from "@goauthentik/admin/policies/PolicyEngineModes
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-file-input"; import "@goauthentik/components/ak-file-input";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/components/ak-textarea-input";
@ -130,14 +131,14 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
required required
help=${msg("Application's display Name.")} help=${msg("Application's display Name.")}
></ak-text-input> ></ak-text-input>
<ak-text-input <ak-slug-input
name="slug" name="slug"
value=${ifDefined(this.instance?.slug)} value=${ifDefined(this.instance?.slug)}
label=${msg("Slug")} label=${msg("Slug")}
required required
help=${msg("Internal application name used in URLs.")} help=${msg("Internal application name used in URLs.")}
input-hint="code" input-hint="code"
></ak-text-input> ></ak-slug-input>
<ak-text-input <ak-text-input
name="group" name="group"
value=${ifDefined(this.instance?.group)} value=${ifDefined(this.instance?.group)}

View File

@ -117,13 +117,11 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
?invalid=${this.errors.has("name")} ?invalid=${this.errors.has("name")}
.errorMessages=${errors.name ?? this.errorMessages("name")} .errorMessages=${errors.name ?? this.errorMessages("name")}
help=${msg("Application's display Name.")} help=${msg("Application's display Name.")}
id="ak-application-wizard-details-name"
></ak-text-input> ></ak-text-input>
<ak-slug-input <ak-slug-input
name="slug" name="slug"
value=${ifDefined(app.slug)} value=${ifDefined(app.slug)}
label=${msg("Slug")} label=${msg("Slug")}
source="#ak-application-wizard-details-name"
required required
?invalid=${errors.slug ?? this.errors.has("slug")} ?invalid=${errors.slug ?? this.errors.has("slug")}
.errorMessages=${this.errorMessages("slug")} .errorMessages=${this.errorMessages("slug")}

View File

@ -3,6 +3,7 @@ import { DesignationToLabel, LayoutToLabel } from "@goauthentik/admin/flows/util
import { policyEngineModes } from "@goauthentik/admin/policies/PolicyEngineModes"; import { policyEngineModes } from "@goauthentik/admin/policies/PolicyEngineModes";
import { AuthenticationEnum } from "@goauthentik/api/dist/models/AuthenticationEnum"; import { AuthenticationEnum } from "@goauthentik/api/dist/models/AuthenticationEnum";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-slug-input.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -91,17 +92,16 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm<Flow, string>) {
/> />
<p class="pf-c-form__helper-text">${msg("Shown as the Title in Flow pages.")}</p> <p class="pf-c-form__helper-text">${msg("Shown as the Title in Flow pages.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Slug")} required name="slug">
<input <ak-slug-input
type="text" name="slug"
value="${ifDefined(this.instance?.slug)}" value=${ifDefined(this.instance?.slug)}
class="pf-c-form-control pf-m-monospace" label=${msg("Slug")}
autocomplete="off"
spellcheck="false"
required required
/> help=${msg("Visible in the URL.")}
<p class="pf-c-form__helper-text">${msg("Visible in the URL.")}</p> input-hint="code"
</ak-form-element-horizontal> ></ak-slug-input>
<ak-form-element-horizontal label=${msg("Designation")} required name="designation"> <ak-form-element-horizontal label=${msg("Designation")} required name="designation">
<select class="pf-c-form-control"> <select class="pf-c-form-control">
<option value="" ?selected=${this.instance?.designation === undefined}> <option value="" ?selected=${this.instance?.designation === undefined}>

View File

@ -9,6 +9,7 @@ import {
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-secret-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/components/ak-secret-textarea-input.js"; import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/components/ak-slug-input.js";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/components/ak-textarea-input";
@ -87,12 +88,13 @@ export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm<Ke
value=${ifDefined(this.instance?.name)} value=${ifDefined(this.instance?.name)}
required required
></ak-text-input> ></ak-text-input>
<ak-text-input <ak-slug-input
name="slug" name="slug"
label=${msg("Slug")}
value=${ifDefined(this.instance?.slug)} value=${ifDefined(this.instance?.slug)}
label=${msg("Slug")}
required required
></ak-text-input> input-hint="code"
></ak-slug-input>
<ak-switch-input <ak-switch-input
name="enabled" name="enabled"
?checked=${this.instance?.enabled ?? true} ?checked=${this.instance?.enabled ?? true}

View File

@ -3,6 +3,7 @@ import { placeholderHelperText } from "@goauthentik/admin/helperText";
import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-secret-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/components/ak-slug-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -54,14 +55,15 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Slug")} required name="slug">
<input <ak-slug-input
type="text" name="slug"
value="${ifDefined(this.instance?.slug)}" value=${ifDefined(this.instance?.slug)}
class="pf-c-form-control" label=${msg("Slug")}
required required
/> input-hint="code"
</ak-form-element-horizontal> ></ak-slug-input>
<ak-form-element-horizontal name="enabled"> <ak-form-element-horizontal name="enabled">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -10,6 +10,7 @@ import {
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-secret-textarea-input.js"; import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/components/ak-slug-input.js";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
@ -267,16 +268,13 @@ export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm<OAuth
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Slug")} required name="slug"> <ak-slug-input
<input name="slug"
type="text" value=${ifDefined(this.instance?.slug)}
value="${ifDefined(this.instance?.slug)}" label=${msg("Slug")}
class="pf-c-form-control pf-m-monospace"
autocomplete="off"
spellcheck="false"
required required
/> input-hint="code"
</ak-form-element-horizontal> ></ak-slug-input>
<ak-form-element-horizontal name="enabled"> <ak-form-element-horizontal name="enabled">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -10,6 +10,7 @@ import {
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PlexAPIClient, PlexResource, popupCenterScreen } from "@goauthentik/common/helpers/plex"; import { PlexAPIClient, PlexResource, popupCenterScreen } from "@goauthentik/common/helpers/plex";
import { ascii_letters, digits, randomString } from "@goauthentik/common/utils"; import { ascii_letters, digits, randomString } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-slug-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
@ -183,14 +184,15 @@ export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm<PlexSo
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Slug")} required name="slug">
<input <ak-slug-input
type="text" name="slug"
value="${ifDefined(this.instance?.slug)}" value=${ifDefined(this.instance?.slug)}
class="pf-c-form-control" label=${msg("Slug")}
required required
/> input-hint="code"
</ak-form-element-horizontal> ></ak-slug-input>
<ak-form-element-horizontal name="enabled"> <ak-form-element-horizontal name="enabled">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -9,6 +9,7 @@ import {
UserMatchingModeToLabel, UserMatchingModeToLabel,
} from "@goauthentik/admin/sources/oauth/utils"; } from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-slug-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -89,14 +90,15 @@ export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm<SAMLSo
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Slug")} required name="slug">
<input <ak-slug-input
type="text" name="slug"
value="${ifDefined(this.instance?.slug)}" value=${ifDefined(this.instance?.slug)}
class="pf-c-form-control" label=${msg("Slug")}
required required
/> input-hint="code"
</ak-form-element-horizontal> ></ak-slug-input>
<ak-form-element-horizontal name="enabled"> <ak-form-element-horizontal name="enabled">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -1,6 +1,7 @@
import { placeholderHelperText } from "@goauthentik/admin/helperText"; import { placeholderHelperText } from "@goauthentik/admin/helperText";
import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-slug-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -48,14 +49,15 @@ export class SCIMSourceForm extends BaseSourceForm<SCIMSource> {
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Slug")} required name="slug">
<input <ak-slug-input
type="text" name="slug"
value="${ifDefined(this.instance?.slug)}" value=${ifDefined(this.instance?.slug)}
class="pf-c-form-control" label=${msg("Slug")}
required required
/> input-hint="code"
</ak-form-element-horizontal> ></ak-slug-input>
<ak-form-element-horizontal name="enabled"> <ak-form-element-horizontal name="enabled">
<div class="pf-c-check"> <div class="pf-c-check">
<input <input

View File

@ -41,14 +41,27 @@ export class InvitationForm extends ModelForm<Invitation, string> {
} }
renderForm(): TemplateResult { renderForm(): TemplateResult {
return html` <ak-form-element-horizontal slugMode label=${msg("Name")} required name="name"> 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` <ak-form-element-horizontal label=${msg("Name")} required name="name">
<input <input
type="text" type="text"
id="admin-stages-invitation-name"
value="${this.instance?.name || ""}" value="${this.instance?.name || ""}"
class="pf-c-form-control" class="pf-c-form-control"
required required
@input=${(ev: InputEvent) => checkSlug(ev)}
data-ak-slug="true" data-ak-slug="true"
/> />
<p class="pf-c-form__helper-text">
${msg(
"The name of an invitation must be a slug: only lower case letters, numbers, and the hyphen are permitted here.",
)}
</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Expires")} required name="expires"> <ak-form-element-horizontal label=${msg("Expires")} required name="expires">
<input <input

View File

@ -1,4 +1,5 @@
import { formatSlug } from "@goauthentik/elements/router/utils.js"; import { bound } from "@goauthentik/elements/decorators/bound.js";
import { kebabCase } from "change-case";
import { html } from "lit"; import { html } from "lit";
import { customElement, property, query } from "lit/decorators.js"; import { customElement, property, query } from "lit/decorators.js";
@ -6,59 +7,83 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { HorizontalLightComponent } from "./HorizontalLightComponent"; import { HorizontalLightComponent } from "./HorizontalLightComponent";
const slugify = (s: string) => 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") @customElement("ak-slug-input")
export class AkSlugInput extends HorizontalLightComponent<string> { export class AkSlugInput extends HorizontalLightComponent<string> {
@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 }) @property({ type: String })
source = ""; public source = "[name='name']";
origin?: HTMLInputElement | null; @property({ type: String, reflect: true })
public value = "";
@query("input") @query("input")
input!: HTMLInputElement; private input!: HTMLInputElement;
touched: boolean = false; #origin?: HTMLInputElement | null;
constructor() { #touched: boolean = false;
super();
this.slugify = this.slugify.bind(this);
this.handleTouch = this.handleTouch.bind(this);
}
firstUpdated() {
this.input.addEventListener("input", this.handleTouch);
}
// Do not stop propagation of this event; it must be sent up the tree so that a parent // 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. // component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) { protected handleTouch(ev: Event) {
this.input.value = formatSlug(this.input.value); this.value = this.input.value = slugify(this.input.value);
this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") { // Reset 'touched' status if the slug & target have been reset
this.touched = false; if (this.#origin && this.#origin.value === "" && this.input.value === "") {
this.#touched = false;
return; return;
} }
if (ev && ev.target && ev.target instanceof HTMLInputElement) { 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)) { if (!(ev && ev.target && ev.target instanceof HTMLInputElement)) {
return; return;
} }
// Reset 'touched' status if the slug & target have been reset // Reset 'touched' status if the slug & target have been reset
if (ev.target.value === "" && this.input.value === "") { if (ev.target.value === "" && this.input.value === "") {
this.touched = false; this.#touched = false;
} }
// Don't proceed if the user has hand-modified the slug // Don't proceed if the user has hand-modified the slug. (Note the order of statements: if
if (this.touched) { // 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; return;
} }
@ -67,7 +92,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// "any event which adds or removes a character but leaves the rest of the slug looking like // "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." // 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 oldSlug = this.input.value;
const [shorter, longer] = const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug]; newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];
@ -81,7 +106,6 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// to listeners, both the name and value of the host must match those of the target // 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 // 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. // forwarded to our templated input, but the value must also be set.
this.value = this.input.value = newSlug; this.value = this.input.value = newSlug;
this.dispatchEvent( this.dispatchEvent(
new Event("input", { new Event("input", {
@ -91,38 +115,36 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
); );
} }
connectedCallback() { public override disconnectedCallback() {
super.connectedCallback(); if (this.#origin) {
this.#origin.removeEventListener("input", this.slugify);
// 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(); super.disconnectedCallback();
} }
renderControl() { public override renderControl() {
return html`<input return html`<input
@input=${(ev: Event) => this.handleTouch(ev)}
type="text" type="text"
value=${ifDefined(this.value)} value=${ifDefined(this.value)}
class="pf-c-form-control" class="pf-c-form-control"
?required=${this.required} ?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; export default AkSlugInput;

View File

@ -7,7 +7,6 @@ import { AKElement } from "@goauthentik/elements/Base";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement"; import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatSlug } from "@goauthentik/elements/router/utils.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
@ -197,39 +196,6 @@ export abstract class Form<T> extends AKElement {
return this.successMessage; 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 { resetForm(): void {
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form"); const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
form?.reset(); form?.reset();

View File

@ -77,9 +77,6 @@ export class HorizontalFormElement extends AKElement {
@property({ attribute: false }) @property({ attribute: false })
errorMessages: string[] | string[][] = []; errorMessages: string[] | string[][] = [];
@property({ type: Boolean })
slugMode = false;
_invalid = false; _invalid = false;
/* If this property changes, we want to make sure the parent control is "opened" so /* 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<HTMLInputElement>("input[autofocus]").forEach((input) => { this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach((input) => {
input.focus(); input.focus();
}); });
if (this.name === "slug" || this.slugMode) {
this.querySelectorAll<HTMLInputElement>("input[type='text']").forEach((input) => {
input.addEventListener("keyup", () => {
input.value = formatSlug(input.value);
});
});
}
this.querySelectorAll("*").forEach((input) => { this.querySelectorAll("*").forEach((input) => {
if (isAkControl(input) && !input.getAttribute("name")) { if (isAkControl(input) && !input.getAttribute("name")) {
input.setAttribute("name", this.name); input.setAttribute("name", this.name);

2
web/types/node.d.ts vendored
View File

@ -14,7 +14,7 @@ declare module "module" {
* const relativeDirname = dirname(fileURLToPath(import.meta.url)); * const relativeDirname = dirname(fileURLToPath(import.meta.url));
* ``` * ```
*/ */
// eslint-disable-next-line no-var
var __dirname: string; var __dirname: string;
} }
} }