-
-
Notifications
You must be signed in to change notification settings - Fork 993
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
web/admin: better footer links (#12004)
* 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. * First things first: save the blueprint that initializes the test runner. * Committing to having the PKs be a string, and streamlining an event handler. Type solidity needed for the footer control. * web/admin/better-footer-links # What - A data control that takes two string fields and returns the JSON object for a FooterLink - A data control that takes a control like the one above and assists the user in entering a collection of such objects. # Why We're trying to move away from CodeMirror for the simple things, like tables of what is essentially data entry. Jens proposed this ArrayInput thing, and I've simplified it so you define what "a row" is as a small, lightweight custom Component that returns and validates the datatype for that row, and ArrayInput creates a table of rows, and that's that. We're still working out the details, but the demo is to replace the "Name & URL" table in AdminSettingsForm with this, since it was silly to ask the customer to hand-write JSON or YAML, getting the keys right every time, for an `Array<Record<{ name: string, href: string }>>`. And some client-side validation can't hurt. Storybook included. Tests to come. * Not ready for prime time. * One lint. Other lints are still in progress. * web: lots of 'as unknown as Foo' I know this is considered bad practice, but we use Lit and Lit.spread to send initialization arguments to functions that create DOM objects, and Lit's prefix convention of '.' for object, '?' for boolean, and '@' for event handler doesn't map at all to the Interface declarations of Typescript. So we have to cast these types when sending them via functions to constructors. * web/admin/better-footer-links # What - Remove the "JSON or YAML" language from the AdminSettings page for describing FooterLinks inputs. - Add unit tests for ArrayInput and AdminSettingsFooterLinks. - Provide a property for accessing a component's value # Why Providing a property by which the JSONified version of the value can be accessed enhances the ability of tests to independently check that the value is in a state we desire, since properties can easily be accessed across the wire protocol used by browser-based testing environments. * Ensure the UI is built from _current_ before running tests.
- Loading branch information
1 parent
afc2998
commit b9faae8
Showing
14 changed files
with
646 additions
and
29 deletions.
There are no files selected for viewing
100 changes: 100 additions & 0 deletions
100
web/src/admin/admin-settings/AdminSettingsFooterLinks.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; | ||
import { type Spread } from "@goauthentik/elements/types"; | ||
import { spread } from "@open-wc/lit-helpers"; | ||
|
||
import { msg } from "@lit/localize"; | ||
import { css, html } from "lit"; | ||
import { customElement, property, queryAll } from "lit/decorators.js"; | ||
import { ifDefined } from "lit/directives/if-defined.js"; | ||
|
||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; | ||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css"; | ||
import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||
|
||
import { FooterLink } from "@goauthentik/api"; | ||
|
||
export interface IFooterLinkInput { | ||
footerLink: FooterLink; | ||
} | ||
|
||
const LEGAL_SCHEMES = ["http://", "https://", "mailto:"]; | ||
const hasLegalScheme = (url: string) => | ||
LEGAL_SCHEMES.some((scheme) => url.substr(0, scheme.length).toLowerCase() === scheme); | ||
|
||
@customElement("ak-admin-settings-footer-link") | ||
export class FooterLinkInput extends AkControlElement<FooterLink> { | ||
static get styles() { | ||
return [ | ||
PFBase, | ||
PFInputGroup, | ||
PFFormControl, | ||
css` | ||
.pf-c-input-group input#linkname { | ||
flex-grow: 1; | ||
width: 8rem; | ||
} | ||
`, | ||
]; | ||
} | ||
|
||
@property({ type: Object, attribute: false }) | ||
footerLink: FooterLink = { | ||
name: "", | ||
href: "", | ||
}; | ||
|
||
@queryAll(".ak-form-control") | ||
controls?: HTMLInputElement[]; | ||
|
||
json() { | ||
return Object.fromEntries( | ||
Array.from(this.controls ?? []).map((control) => [control.name, control.value]), | ||
) as unknown as FooterLink; | ||
} | ||
|
||
get isValid() { | ||
const href = this.json()?.href ?? ""; | ||
return hasLegalScheme(href) && URL.canParse(href); | ||
} | ||
|
||
render() { | ||
const onChange = () => { | ||
this.dispatchEvent(new Event("change", { composed: true, bubbles: true })); | ||
}; | ||
|
||
return html` <div class="pf-c-input-group"> | ||
<input | ||
type="text" | ||
@change=${onChange} | ||
value=${this.footerLink.name} | ||
id="linkname" | ||
class="pf-c-form-control ak-form-control" | ||
name="name" | ||
placeholder=${msg("Link Title")} | ||
tabindex="1" | ||
/> | ||
<input | ||
type="text" | ||
@change=${onChange} | ||
value="${ifDefined(this.footerLink.href ?? undefined)}" | ||
class="pf-c-form-control ak-form-control" | ||
required | ||
placeholder=${msg("URL")} | ||
name="href" | ||
tabindex="1" | ||
/> | ||
</div>`; | ||
} | ||
} | ||
|
||
export function akFooterLinkInput(properties: IFooterLinkInput) { | ||
return html`<ak-admin-settings-footer-link | ||
${spread(properties as unknown as Spread)} | ||
></ak-admin-settings-footer-link>`; | ||
} | ||
|
||
declare global { | ||
interface HTMLElementTagNameMap { | ||
"ak-admin-settings-footer-link": FooterLinkInput; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
web/src/admin/admin-settings/stories/AdminSettingsFooterLinks.stories.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import "@goauthentik/elements/messages/MessageContainer"; | ||
import { Meta, StoryObj, WebComponentsRenderer } from "@storybook/web-components"; | ||
import { DecoratorFunction } from "storybook/internal/types"; | ||
|
||
import { html } from "lit"; | ||
|
||
import { FooterLinkInput } from "../AdminSettingsFooterLinks.js"; | ||
import "../AdminSettingsFooterLinks.js"; | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
type Decorator = DecoratorFunction<WebComponentsRenderer, any>; | ||
|
||
const metadata: Meta<FooterLinkInput> = { | ||
title: "Components / Footer Link Input", | ||
component: "ak-admin-settings-footer-link", | ||
parameters: { | ||
docs: { | ||
description: { | ||
component: "A stylized control for the footer links", | ||
}, | ||
}, | ||
}, | ||
decorators: [ | ||
(story: Decorator) => { | ||
window.setTimeout(() => { | ||
const control = document.getElementById("footer-link"); | ||
if (!control) { | ||
throw new Error("Test was not initialized correctly."); | ||
} | ||
const messages = document.getElementById("reported-value"); | ||
control.addEventListener("change", (event: Event) => { | ||
if (!event.target) { | ||
return; | ||
} | ||
const target = event.target as FooterLinkInput; | ||
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`; | ||
}); | ||
}, 250); | ||
|
||
return html`<div | ||
style="background: #fff; padding: 2em; position: relative" | ||
id="the-main-event" | ||
> | ||
<style> | ||
li { | ||
display: block; | ||
} | ||
p { | ||
margin-top: 1em; | ||
} | ||
#the-answer-block { | ||
padding-top: 3em; | ||
} | ||
</style> | ||
<div> | ||
${ | ||
// @ts-expect-error The types for web components are not well-defined } | ||
story() | ||
} | ||
</div> | ||
<div style="margin-top: 2rem"> | ||
<p>Reported value:</p> | ||
<pre id="reported-value"></pre> | ||
</div> | ||
</div>`; | ||
}, | ||
], | ||
}; | ||
|
||
export default metadata; | ||
|
||
type Story = StoryObj; | ||
|
||
export const Default: Story = { | ||
render: () => | ||
html` <ak-admin-settings-footer-link | ||
id="footer-link" | ||
name="the-footer" | ||
></ak-admin-settings-footer-link>`, | ||
}; |
68 changes: 68 additions & 0 deletions
68
web/src/admin/admin-settings/stories/AdminSettingsFooterLinks.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { render } from "@goauthentik/elements/tests/utils.js"; | ||
import { $, expect } from "@wdio/globals"; | ||
|
||
import { html } from "lit"; | ||
|
||
import "../AdminSettingsFooterLinks.js"; | ||
|
||
describe("ak-admin-settings-footer-link", () => { | ||
afterEach(async () => { | ||
await browser.execute(async () => { | ||
await document.body.querySelector("ak-admin-settings-footer-link")?.remove(); | ||
if (document.body["_$litPart$"]) { | ||
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit | ||
await delete document.body["_$litPart$"]; | ||
} | ||
}); | ||
}); | ||
|
||
it("should render an empty control", async () => { | ||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`); | ||
const link = await $("ak-admin-settings-footer-link"); | ||
await expect(await link.getProperty("isValid")).toStrictEqual(false); | ||
await expect(await link.getProperty("toJson")).toEqual({ name: "", href: "" }); | ||
}); | ||
|
||
it("should not be valid if just a name is filled in", async () => { | ||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`); | ||
const link = await $("ak-admin-settings-footer-link"); | ||
await link.$('input[name="name"]').setValue("foo"); | ||
await expect(await link.getProperty("isValid")).toStrictEqual(false); | ||
await expect(await link.getProperty("toJson")).toEqual({ name: "foo", href: "" }); | ||
}); | ||
|
||
it("should be valid if just a URL is filled in", async () => { | ||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`); | ||
const link = await $("ak-admin-settings-footer-link"); | ||
await link.$('input[name="href"]').setValue("https://foo.com"); | ||
await expect(await link.getProperty("isValid")).toStrictEqual(true); | ||
await expect(await link.getProperty("toJson")).toEqual({ | ||
name: "", | ||
href: "https://foo.com", | ||
}); | ||
}); | ||
|
||
it("should be valid if both are filled in", async () => { | ||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`); | ||
const link = await $("ak-admin-settings-footer-link"); | ||
await link.$('input[name="name"]').setValue("foo"); | ||
await link.$('input[name="href"]').setValue("https://foo.com"); | ||
await expect(await link.getProperty("isValid")).toStrictEqual(true); | ||
await expect(await link.getProperty("toJson")).toEqual({ | ||
name: "foo", | ||
href: "https://foo.com", | ||
}); | ||
}); | ||
|
||
it("should not be valid if the URL is not valid", async () => { | ||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`); | ||
const link = await $("ak-admin-settings-footer-link"); | ||
await link.$('input[name="name"]').setValue("foo"); | ||
await link.$('input[name="href"]').setValue("never://foo.com"); | ||
await expect(await link.getProperty("toJson")).toEqual({ | ||
name: "foo", | ||
href: "never://foo.com", | ||
}); | ||
await expect(await link.getProperty("isValid")).toStrictEqual(false); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.