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:
parent
6a60174371
commit
48083ac380
|
@ -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>`;
|
||||
|
|
|
@ -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> `;
|
||||
}
|
108
web/src/admin/applications/renderers/ak-backchannel-input.ts
Normal file
108
web/src/admin/applications/renderers/ak-backchannel-input.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
85
web/src/admin/applications/renderers/ak-file-input.ts
Normal file
85
web/src/admin/applications/renderers/ak-file-input.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>`;
|
||||
}
|
||||
}
|
77
web/src/admin/applications/renderers/ak-radio-input.ts
Normal file
77
web/src/admin/applications/renderers/ak-radio-input.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
84
web/src/admin/applications/renderers/ak-switch-input.ts
Normal file
84
web/src/admin/applications/renderers/ak-switch-input.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
76
web/src/admin/applications/renderers/ak-text-input.ts
Normal file
76
web/src/admin/applications/renderers/ak-text-input.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
75
web/src/admin/applications/renderers/ak-textarea-input.ts
Normal file
75
web/src/admin/applications/renderers/ak-textarea-input.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
Reference in a new issue