web: Legibility in the ApplicationForm.

This is a pretty good result.  By using the LightDOM setting, this
provides the existing Authentik form manager with access to the
ak-form-horizontal-element components without having to do any
cross-border magic.  It's not ideal, and it shows up just how badly
we've got patternfly splattered everywhere, but the actual results
are remarkable.  The patterns for text, switch, radio, textarea,
file, and even select are smaller and easier here.

I'm still noodling on what an unspread search-select element would
look like.  It's just dependency injection, so it ought to be as
straightforward as that.
This commit is contained in:
Ken Sternberg 2023-07-30 09:51:19 -07:00
parent 6a60174371
commit 48083ac380
11 changed files with 734 additions and 230 deletions

View File

@ -1,7 +1,7 @@
import "@goauthentik/admin/applications/ProviderSelectModal";
import { iconHelperText } from "@goauthentik/admin/helperText";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import { first, groupBy } from "@goauthentik/common/utils";
import { first } from "@goauthentik/common/utils";
import { rootInterface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
@ -22,14 +22,39 @@ import {
CoreApi,
PolicyEngineMode,
Provider,
ProvidersAllListRequest,
ProvidersApi,
} from "@goauthentik/api";
import { akText } from "./renderers";
import "./renderers/ak-backchannel-input";
import "./renderers/ak-file-input";
import "./renderers/ak-provider-search-input";
import "./renderers/ak-radio-input";
import "./renderers/ak-switch-input";
import "./renderers/ak-text-input";
import "./renderers/ak-textarea-input";
const policyOptions = [
{
label: "any",
value: PolicyEngineMode.Any,
default: true,
description: html`${msg("Any policy must match to grant access")}`,
},
{
label: "all",
value: PolicyEngineMode.All,
description: html`${msg("All policies must match to grant access")}`,
},
];
@customElement("ak-application-form")
export class ApplicationForm extends ModelForm<Application, string> {
constructor() {
super();
this.handleConfirmBackchannelProviders = this.handleConfirmBackchannelProviders.bind(this);
this.makeRemoveBackchannelProviderHandler =
this.makeRemoveBackchannelProviderHandler.bind(this);
}
async loadInstance(pk: string): Promise<Application> {
const app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsRetrieve({
slug: pk,
@ -90,200 +115,131 @@ export class ApplicationForm extends ModelForm<Application, string> {
return app;
}
handleConfirmBackchannelProviders({ items }: { items: Provider[] }) {
this.backchannelProviders = items;
this.requestUpdate();
return Promise.resolve();
}
makeRemoveBackchannelProviderHandler(provider: Provider) {
return () => {
const idx = this.backchannelProviders.indexOf(provider);
this.backchannelProviders.splice(idx, 1);
this.requestUpdate();
};
}
handleClearIcon(ev: Event) {
ev.stopPropagation();
if (!(ev instanceof InputEvent) || !ev.target) {
return;
}
this.clearIcon = !!(ev.target as HTMLInputElement).checked;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
${akText({
name: "name",
value: this.instance?.name,
label: msg("Name"),
required: true,
help: msg("Application's display Name."),
})}
${akText({
name: "slug",
value: this.instance?.slug,
label: msg("Slug"),
required: true,
help: msg("Internal application name, used in URLs."),
})}
${akText({
name: "group",
value: this.instance?.group,
label: msg("Group"),
help: msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together."
),
})}
<ak-form-element-horizontal
label=${msg("Backchannel providers")}
<ak-text-input
name="name"
value=${this.instance?.name}
label=${msg("Name")}
required
help=${msg("Application's display Name.")}
></ak-text-input>
<ak-text-input
name="slug"
value=${this.instance?.slug}
label=${msg("Slug")}
required
help=${msg("Internal application name used in URLs.")}
></ak-text-input>
<ak-text-input
name="group"
value=${this.instance?.group}
label=${msg("Group")}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
)}
></ak-text-input>
<ak-provider-search-input
name="provider"
label=${msg("Provider")}
value=${this.instance?.provider}
help=${msg("Select a provider that this application should use.")}
blankable
></ak-provider-search-input>
<ak-backchannel-providers-input
name="backchannelProviders"
label=${msg("Backchannel Providers")}
help=${msg(
"Select backchannel providers which augment the functionality of the main provider.",
)}
.providers=${this.backchannelProviders}
.confirm=${this.handleConfirmBackchannelProviders}
.remover=${this.makeRemoveBackchannelProviderHandler}
>
<div class="pf-c-input-group">
<ak-provider-select-table
?backchannelOnly=${true}
.confirm=${(items: Provider[]) => {
this.backchannelProviders = items;
this.requestUpdate();
return Promise.resolve();
}}
>
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
<i class="fas fa-plus" aria-hidden="true"></i>
</button>
</ak-provider-select-table>
<div class="pf-c-form-control">
<ak-chip-group>
${this.backchannelProviders.map((provider) => {
return html`<ak-chip
.removable=${true}
value=${ifDefined(provider.pk)}
@remove=${() => {
const idx = this.backchannelProviders.indexOf(provider);
this.backchannelProviders.splice(idx, 1);
this.requestUpdate();
}}
>
${provider.name}
</ak-chip>`;
})}
</ak-chip-group>
</div>
</div>
<p class="pf-c-form__helper-text">
${msg(
"Select backchannel providers which augment the functionality of the main provider."
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
</ak-backchannel-providers-input>
<ak-radio-input
label=${msg("Policy engine mode")}
?required=${true}
required
name="policyEngineMode"
>
<ak-radio
.options=${[
{
label: "any",
value: PolicyEngineMode.Any,
default: true,
description: html`${msg("Any policy must match to grant access")}`,
},
{
label: "all",
value: PolicyEngineMode.All,
description: html`${msg("All policies must match to grant access")}`,
},
]}
.value=${this.instance?.policyEngineMode}
>
</ak-radio>
</ak-form-element-horizontal>
.options=${policyOptions}
.value=${this.instance?.policyEngineMode}
></ak-radio-input>
<ak-form-group>
<span slot="header"> ${msg("UI settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("Launch URL")} name="metaLaunchUrl">
<input
type="text"
value="${ifDefined(this.instance?.metaLaunchUrl)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider."
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="openInNewTab">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.openInNewTab, false)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Open in new tab")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"If checked, the launch URL will open in a new browser tab or window from the user's application library."
)}
</p>
</ak-form-element-horizontal>
${
rootInterface()?.config?.capabilities.includes(
CapabilitiesEnum.CanSaveMedia
)
? html`<ak-form-element-horizontal
label="${msg("Icon")}"
name="metaIcon"
>
<input type="file" value="" class="pf-c-form-control" />
${this.instance?.metaIcon
? html`
<p class="pf-c-form__helper-text">
${msg("Currently set to:")}
${this.instance?.metaIcon}
</p>
`
: html``}
</ak-form-element-horizontal>
${this.instance?.metaIcon
? html`
<ak-form-element-horizontal>
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
@change=${(ev: Event) => {
const target =
ev.target as HTMLInputElement;
this.clearIcon = target.checked;
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i
class="fas fa-check"
aria-hidden="true"
></i>
</span>
</span>
<span class="pf-c-switch__label">
${msg("Clear icon")}
</span>
</label>
<p class="pf-c-form__helper-text">
${msg("Delete currently set icon.")}
</p>
</ak-form-element-horizontal>
`
: html``}`
: html`<ak-form-element-horizontal label=${msg("Icon")} name="metaIcon">
<input
type="text"
value="${first(this.instance?.metaIcon, "")}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">${iconHelperText}</p>
</ak-form-element-horizontal>`
}
<ak-form-element-horizontal label=${msg("Publisher")} name="metaPublisher">
<input
type="text"
value="${ifDefined(this.instance?.metaPublisher)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Description")} name="metaDescription">
<textarea class="pf-c-form-control">
${ifDefined(this.instance?.metaDescription)}</textarea
>
</ak-form-element-horizontal>
<ak-text-input
name="metaLaunchUrl"
label=${msg("Launch URL")}
value=${ifDefined(this.instance?.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.instance?.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>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia)
? html`<ak-file-input
label="${msg("Icon")}"
name="metaIcon"
value=${this.instance?.metaIcon}
current=${msg("Currently set to:")}
></ak-file-input>
${this.instance?.metaIcon
? html`
<ak-switch-input
name=""
label=${msg("Clear icon")}
help=${msg("Delete currently set icon.")}
@change=${this.handleClearIcon}
></ak-switch-input>
`
: html``}`
: html` <ak-text-input
label=${msg("Icon")}
name="metaIcon"
value=${first(this.instance?.metaIcon, "")}
help=${iconHelperText}
>
</ak-text-input>`}
<ak-text-input
label=${msg("Publisher")}
name="metaPublisher"
value="${ifDefined(this.instance?.metaPublisher)}"
></ak-text-input>
<ak-textarea-input
label=${msg("Description")}
name="metaDescription"
value=${ifDefined(this.instance?.metaDescription)}
></ak-textarea-input>
</div>
</ak-form-group>
</form>`;

View File

@ -1,35 +0,0 @@
import { msg } from "@lit/localize";
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
type AkTextInput = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: any;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akTextDefaults = {
required: false,
};
export function akText(args: AkTextInput) {
const { name, label, value, required, help } = {
...akTextDefaults,
...args
}
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<input
type="text"
value=${ifDefined(value)}
class="pf-c-form-control"
?required=${required}
/>
<p class="pf-c-form__helper-text">${help}</p>
</ak-form-element-horizontal> `;
}

View File

@ -0,0 +1,108 @@
import "@goauthentik/admin/applications/ProviderSelectModal";
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { map } from "lit/directives/map.js";
import { Provider } from "@goauthentik/api";
type AkBackchannelProvidersArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value?: any;
// The help message, shown at the bottom.
help?: string;
providers: Provider[];
confirm: ({ items }: { items: Provider[] }) => Promise<void>;
remove: (provider: Provider) => () => void;
};
const akBackchannelProvidersDefaults = {
required: false,
};
export function akBackchannelProvidersInput(args: AkBackchannelProvidersArgs) {
const { name, label, help, providers, confirm, remove } = {
...akBackchannelProvidersDefaults,
...args,
};
const renderOneChip = (provider: Provider) =>
html`<ak-chip .removable=${true} value=${ifDefined(provider.pk)} @remove=${remove(provider)}
>${provider.name}</ak-chip
>`;
return html`
<ak-form-element-horizontal label=${label} name=${name}>
<div class="pf-c-input-group">
<ak-provider-select-table ?backchannelOnly=${true} .confirm=${confirm}>
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
<i class="fas fa-plus" aria-hidden="true"></i>
</button>
</ak-provider-select-table>
<div class="pf-c-form-control">
<ak-chip-group> ${map(providers, renderOneChip)} </ak-chip-group>
</div>
</div>
${help ? html`<p class="pf-c-form__helper-radio">${help}</p>` : nothing}
</ak-form-element-horizontal>
`;
}
@customElement("ak-backchannel-providers-input")
export class AkBackchannelProvidersInput 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.
//
// This field is so highly specialized that it would make more sense if we put the API and the
// fetcher here.
//
// 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: Array })
providers: Provider[] = [];
@property({ attribute: false, type: Object })
confirm!: ({ items }: { items: Provider[] }) => Promise<void>;
@property({ attribute: false, type: Object })
remover!: (provider: Provider) => () => void;
@property({ type: String })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
render() {
return akBackchannelProvidersInput({
name: this.name,
label: this.label,
help: this.help.trim() !== "" ? this.help : undefined,
providers: this.providers,
confirm: this.confirm,
remove: this.remover,
});
}
}

View File

@ -0,0 +1,85 @@
import { AKElement } from "@goauthentik/elements/Base";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
type AkFileArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value?: any;
required: boolean;
// The message to show next to the "current icon".
current: string;
// The help message, shown at the bottom.
help?: string;
};
const akFileDefaults = {
name: "",
required: false,
current: msg("Currently set to:"),
};
export function akFile(args: AkFileArgs) {
const { name, label, required, value, help, current } = {
...akFileDefaults,
...args,
};
const currentMsg =
value && current
? html` <p class="pf-c-form__helper-text">${current} ${value}</p> `
: nothing;
return html`<ak-form-element-horizontal ?required="${required}" label=${label} name=${name}>
<input type="file" value="" class="pf-c-form-control" />
${currentMsg} ${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
@customElement("ak-file-input")
export class AkFileInput 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 })
current = msg("Currently set to:");
@property({ type: String })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
render() {
return akFile({
name: this.name,
label: this.label,
value: this.value,
current: this.current,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
}
}

View File

@ -0,0 +1,80 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/SearchSelect";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/api";
const renderElement = (item: Provider) => item.name;
const renderValue = (item: Provider | undefined) => item?.pk;
const doGroupBy = (items: Provider[]) => groupBy(items, (item) => item.verboseName);
async function fetch(query?: string) {
const args: ProvidersAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const items = await new ProvidersApi(DEFAULT_CONFIG).providersAllList(args);
return items.results;
}
@customElement("ak-provider-search-input")
export class AkProviderInput 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: Number })
value?: number;
@property({ type: Boolean })
required = false;
@property({ type: Boolean })
blankable = false;
@property({ type: String })
help = "";
constructor() {
super();
this.selected = this.selected.bind(this);
}
selected(item: Provider) {
return this.value !== undefined && this.value === item.pk;
}
render() {
return html` <ak-form-element-horizontal label=${this.label} name=${this.name}>
<ak-search-select
.selected=${this.selected}
.fetchObjects=${fetch}
.renderElement=${renderElement}
.value=${renderValue}
.groupBy=${doGroupBy}
?blankable=${this.blankable}
>
</ak-search-select>
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
}

View File

@ -0,0 +1,77 @@
import { AKElement } from "@goauthentik/elements/Base";
import { RadioOption } from "@goauthentik/elements/forms/Radio";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
type AkRadioArgs<T> = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: T;
required?: boolean;
options: RadioOption<T>[];
// The help message, shown at the bottom.
help?: string;
};
const akRadioDefaults = {
required: false,
options: [],
};
export function akRadioInput<T>(args: AkRadioArgs<T>) {
const { name, label, help, required, options, value } = {
...akRadioDefaults,
...args,
};
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<ak-radio .options=${options} .value=${value}></ak-radio>
${help ? html`<p class="pf-c-form__helper-radio">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-radio-input")
export class AkRadioInput<T> 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 })
help = "";
@property({ type: Boolean })
required = false;
@property({ type: Object })
value!: T;
@property({ type: Array })
options: RadioOption<T>[] = [];
render() {
return akRadioInput({
name: this.name,
label: this.label,
value: this.value,
options: this.options,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
}
}

View File

@ -0,0 +1,84 @@
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
type AkSwitchArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
checked: boolean;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akSwitchDefaults = {
checked: false,
required: false,
};
export function akSwitch(args: AkSwitchArgs) {
const { name, label, checked, required, help } = {
...akSwitchDefaults,
...args,
};
const doCheck = checked ? checked : undefined;
return html` <ak-form-element-horizontal name=${name} ?required=${required}>
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" ?checked=${doCheck} />
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${label}</span>
</label>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
@customElement("ak-switch-input")
export class AkSwitchInput 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: Boolean })
checked: boolean = false;
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
@query("input.pf-c-switch__input[type=checkbox]")
checkbox!: HTMLInputElement;
render() {
return akSwitch({
name: this.name,
label: this.label,
checked: this.checked,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
}
}

View File

@ -0,0 +1,76 @@
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
type AkTextArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: string;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akTextDefaults = {
required: false,
};
export function akText(args: AkTextArgs) {
const { name, label, value, required, help } = {
...akTextDefaults,
...args,
};
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<input
type="text"
value=${ifDefined(value)}
class="pf-c-form-control"
?required=${required}
/>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-text-input")
export class AkTextInput 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 })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
render() {
return akText({
name: this.name,
label: this.label,
value: this.value,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
}
}

View File

@ -0,0 +1,75 @@
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
type AkTextareaArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: string;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akTextareaDefaults = {
required: false,
};
export function akTextarea(args: AkTextareaArgs) {
const { name, label, value, required, help } = {
...akTextareaDefaults,
...args,
};
// `<textarea>` is highly sensitive to whitespace. When editing this control, take care that the
// provided value has no whitespace between the `textarea` open and close tags.
//
// prettier-ignore
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<textarea class="pf-c-form-control" ?required=${required}
name=${name}>${value !== undefined ? value : ""}</textarea>
${help ? html`<p class="pf-c-form__helper-textarea">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-textarea-input")
export class AkTextareaInput 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 })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
render() {
return akTextarea({
name: this.name,
label: this.label,
value: this.value,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
}
}

View File

@ -68,11 +68,9 @@ export interface KeyUnknown {
* Consider refactoring serializeForm() so that the conversions are on
* the input types, rather than here. (i.e. "Polymorphism is better than
* switch.")
*
*
*
*
*/
@customElement("ak-form")
export abstract class Form<T> extends AKElement {

View File

@ -16,7 +16,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
* Horizontal Form Element Container.
*
* This element provides the interface between elements of our forms and the
* form itself.
* form itself.
* @custom-element ak-form-element-horizontal
*/
@ -79,7 +79,7 @@ export class HorizontalFormElement extends AKElement {
/* If this property changes, we want to make sure the parent control is "opened" so
* that users can see the change.[1]
*/
*/
@property({ type: Boolean })
set invalid(v: boolean) {
this._invalid = v;