web/admin: add application bindings to the application wizard (#11462)

* web: fix Flash of Unstructured Content while SearchSelect is loading from the backend

Provide an alternative, readonly, disabled, unindexed input object with the text "Loading...", to be
replaced with the _real_ input element after the content is loaded.

This provides the correct appearance and spacing so the content doesn't jiggle about between the
start of loading and the SearchSelect element being finalized.  It was visually distracting and
unappealing.

* web: comment on state management in API layer, move file to point to correct component under test.

* web: test for flash of unstructured content

- Add a unit test to ensure the "Loading..." element is displayed correctly before data arrives
- Demo how to mock a `fetchObjects()` call in testing. Very cool.
- Make distinguishing rule sets for code, tests, and scripts in nightmare mode
- In SearchSelect, Move the `styles()` declaration to the top of the class for consistency.

- To test for the FLOUC issue in SearchSelect.

This is both an exercise in mocking @beryju's `fetchObjects()` protocol, and shows how we can unit
test generic components that render API objects.

* web: interim commit of the basic sortable & selectable table.

* web: added basic unit testing to API-free tables

Mostly these tests assert that the table renders and that the content we give it
is where we expect it to be after sorting. For select tables, it also asserts that
the overall value of the table is what we expect it to be when we click on a
single row, or on the "select all" button.

* web: finalize testing for tables

Includes documentation updates and better tests for select-table.

* Provide unit test accessibility to Firefox and Safari; wrap calls to manipulate test DOMs directly in a browser.exec call so they run in the proper context and be await()ed properly

* web: repeat is needed to make sure sub-elements move around correctly. Map does not do full tracking.

* web: Update HorizontalLightComponent to accurately convey its value "upwards."

* interim commit, gods, the CSS is finally working.

* web: update

Got the binding editor in.  The tests complete.  Removed sonarjs.

* web: fixed tests to complete.

* web: fixed round-trip between binding list and binding editor. Fixed 'delete'.  TODO: Fix error reporting on home page, the edit button is ugly, and the height is off somehow, but I'm not yet sure how. I just know it bugs my eyes.

* core: add support to set policy bindings in transactional endpoint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve permission checks

especially since we'll be using the wizard as default in the future, it shouldn't be superuser only

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: update api-less tables

- Replace `th` with `td` in `thead` components. Because Patternfly.
- Add @beryju's styling to the tables, which make it much better looking

* web: wizard for applications, now with bindings!

- Add policy bindings to the application wizard

- Restructures the Wizard base code.
  - ak-wizard-steps holds the steps and listens for NavigationRequest events to move
    from one step to the next.
  - WizardStep is a base class (no component registration provided) that provides the *whole frame*,
    not just the form.  It receives the navigation content for the sidebar from ak-wizard-steps,
    and provides the styling for the header, footer, sidebar, and main form.  It has abstractions
    for `buttons`, `renderMain()`, `handleButton()`, `handleEnable()`, in a section well-marked as
    "Public API".  Steps inherit from this class.

Conceptually:

- A wizard is a series of pages ("steps") with a distinct beginning and end, linked in a series,
  to complete a task.
- Later steps in the series are inaccessible until an earlier steps has granted access to it.
- Access is predicated on the earlier step being complete and valid. The developer is responsible
  for determining what "complete and valid" means.
- The series is visible, giving the customer a sense of how much effort is needed to complete the
  task.
- A parent object maintains (and can modify as needed) the list of steps. It *can* maintain the
  information being collected from the user. Alternatively, that information can be kept in each
  step.

Details:

- Keeping with the Lit paradigm, "requests to change the system flow up, information changed by
  valid requests flows down."
- The information flows up using events: WizardNavigation, WizardUpdate, WizardClose.
- The information flows down using properties.

- ak-application-wizard-main holds the list of steps, providing a unique slot name for each.
  - It maintains the ApplicationWizardState object.
- ApplicationWizardStep inherits from WizardStep and provides:
  - A means of extraction information from forms
  - A convenience method for updating the ApplicationWizardState object, enabling future steps, and
    navigating to a future step, in the correct order.
  - A method for cleaning error from the error reporting mechanism as the user navigates from an
    error-handling state.
  - The title, description, and cancelability of the wizard.
- Steps:
  - step: Handles the application. A good starting point for understanding the point of
    the Wizard.  Check the `handleButton()` method to understand how we enable or disable access to
    future steps.
  - provider-choice: Just a list. Shows validation without the form.
  - provider: Uses a *very* esoteric Lit feature, `unsafeStaticTag`, which enables
    the display to show anything that conforms to the expectations of ApplicationWizardProviderForm.
    - ApplicationWizardProviderForm repeats some of the base of ApplicationWizardStep, but allows us
      to provide multiple variants on a single form without having to create separate steps for each
      form.
    - The forms (`provider-for-ldap`, `provider-for-radius`) are therefore *just* the form and any
      fetchers needed to populate it.
  - bindings: Shows the table of bindings.  Has a custom display for "This table is empty."
  - edit-binding: Showcase for the `SearchSelectEZ` configuration format. Has an override on the
    `handleButton` feature to figure out which binding is about to be overridden. Is also a
    `.hidden` page; it doesn't show up on the navigation sidebar, as is only navigable-to by buttons
    not associated with the button bar at the bottom.
  - submit: Has a lot of machinery of state: Reviewing with errors, reviewing without errors,
    running submission, and success. Uses `ts-pattern` a lot to make sure the state/request pairs
    make sense.

The key insight is that, even though a wizard is a series in order, that order can't be simply
maintained in a list. The parent needs various strategies for swapping pages in and out of the
sequence, while still maintaining a coherent idea of "flow" and providing the visual cues the user
needs to feel confident that the work can be completed and completed quickly. The entire mechanism
for using an array and index to navigate, with index numbering, blocked the implementation of the
bindings pages.

One thing led to another.  *Sigh*  Really wish this hadn't been as much of a mess as it turned out.
The end result is pretty good, though.  Definitely re-usable.

One important feature to note is that the wizard is *not* tied to the ModalButton object; it's
simply embedded in a modal as-needed.  This allows us to use wizards in other places, such as just
being in a DIV, or just a page on its own.

* web: rollback dependabot "upgrade" that broke testing

Dependabot rolled us into WebdriverIO 9.  While that's probably the
right thing to do, right now it breaks out end-to-end tests badly.
Dependabot's mucking with infrastructure should not be taken lightly,
especially in cases when the infrastructure is for DX, not UX, and
doesn't create a bigger attack surface on the running product.

* web: small fixes for wdio and lint

- Roll back another dependabot breaking change, this time to WebdriverIO
- Remove the redundant scripts wrapping ESLint for Precommit mode. Access to those modes is
  available through the flags to the `./web/scripts/eslint.mjs` script.
- Remove SonarJS checks until SonarJS is ESLint 9 compatible.
- Minor nitpicking.

* web: not sure where all these getElement() additions come from; did I add them?  Anyway, they were breaking the tests, they're a Wdio9-ism.

* package-lock.json update

* web: small fixes for wdio and lint

**PLEASE** Stop trying to upgrade WebdriverIO following Dependabot's instructions. The changes
between wdio8 and wdio9 are extensive enough to require a lot more manual intervention. The unit
tests fail in wdio 9, with the testbed driver Wdio uses to compile content to push to the browser
([vite](https://vitejs.dev) complaining:

```
2024-09-27T15:30:03.672Z WARN @wdio/browser-runner:vite: warning: Unrecognized default export in file /Users/ken/projects/dev/web/node_modules/@patternfly/patternfly/components/Dropdown/dropdown.css
  Plugin: postcss-lit
  File: /Users/ken/projects/dev/web/node_modules/@patternfly/patternfly/components/Dropdown/dropdown.css
[0-6] 2024-09-27T15:30:04.083Z INFO webdriver: BIDI COMMAND script.callFunction {"functionDeclaration":"<Function[976 bytes]>","awaitPromise":true,"arguments":[],"target":{"context":"8E608E6D13E355DFFC28112C236B73AF"}}
[0-6]  Error:  Test failed due to following error(s):
  - ak-search-select.test.ts: The requested module '/src/common/styles/authentik.css' does not provide an export named 'default': SyntaxError: The requested module '/src/common/styles/authentik.css' does not provide an export named 'default'

```

So until we can figure out why the Vite installation isn't liking our CSS import scheme, we'll
have to soldier on with what we have.  At least with Wdio 8, we get:

```
Spec Files:      7 passed, 7 total (100% completed) in 00:00:19
```

* Forgot to run prettier.

* web: small fixes for elements and forms

- provides a new utility, `_isSlug_`, used to verify a user input
- extends the ak-horizontal-component wrapper to have a stronger identity and available value
- updates the types that use the wrapper to be typed more strongly
  - (Why) The above are used in the wizard to get and store values
- fixes a bug in SearchSelectEZ that broke the display if the user didn't supply a `groupBy` field.
- Adds `@wdio/types` to the package file so eslint is satisfied wdio builds correctly
- updates the end-to-end test to understand the revised button identities on the login page
  - Running the end-to-end tests verifies that changes to the components listed above did not break
    the semantics of those components.

* Prettier had opinions

* Fix the oauth2 provider test.

* web: fix oauth2 provider.  Fix resolutions in package-lock.json

* Provide an error field for the form errors on the OAuth2 form.  Unfortunately, this does not solve the general problem that we have a UX issue with which stage bindings to show where now that we've introduced the Invalidation Stage.

* 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.

* web/admin: provide default invalidation flows for LDAP provider.

* admin/web: the default invalidation flows for LDAP and Radius are different from the others.

* Updating the SAML Wizard page to correspond to the provider page.  *This is an intermediate fix to get the tests passing. It will probably be mooted with the next revision.*

* Making progress...

* web/admin: provider formectomy complete

* fix minor issues

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* custom ordering for provider types

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix css

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing PFBase causing wrong font

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing card for type select

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix padding on last page

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add card to bindings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web/element/wizard: fix the CSS cascade so the modifications to the title display don't affect the wiard header.

* web/elements/wizard: fix logic on unavailable / available / current indicators in nav bar.

* Debugging code is not needed.

* web: small visual fixes

As requested by reviewers:

- Fixed the height to 75% of the viewport
- Put 1rem of whitespace between the hint label and the Wizard startup button.

* web: disable lint check for cAsEfUnNy AtTrIbUtE nAmEs.

* Apply suggestions from code review

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Jens L. <jens@beryju.org>

* rework title

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Ken Sternberg
2024-12-18 09:44:27 -08:00
committed by GitHub
parent 821f06ffdf
commit c528a6c336
99 changed files with 6300 additions and 5950 deletions

View File

@ -1,123 +0,0 @@
import "@goauthentik/components/ak-wizard-main/ak-wizard-frame";
import { AKElement } from "@goauthentik/elements/Base";
import { msg } from "@lit/localize";
import { ReactiveControllerHost, html } from "lit";
import { state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { AkWizardController } from "./AkWizardController";
import { AkWizardFrame } from "./ak-wizard-frame";
import { type WizardStep, type WizardStepLabel } from "./types";
/**
* Abstract parent class for wizards. This Class activates the Controller, provides the default
* renderer and handleNav() functions, and organizes the various texts used to describe a Modal
* Wizard's interaction: its prompt, header, and description.
*/
export class AkWizard<D, Step extends WizardStep = WizardStep>
extends AKElement
implements ReactiveControllerHost
{
// prettier-ignore
static get styles() { return [PFBase, PFButton]; }
@state()
steps: Step[] = [];
@state()
currentStep = 0;
/**
* A reference to the frame. Since the frame implements and inherits from ModalButton,
* you will need either a reference to or query to the frame in order to call
* `.close()` on it.
*/
frame: Ref<AkWizardFrame> = createRef();
get step() {
return this.steps[this.currentStep];
}
prompt = msg("Create");
header: string;
description?: string;
wizard: AkWizardController<D>;
constructor(prompt: string, header: string, description?: string) {
super();
this.header = header;
this.prompt = prompt;
this.description = description;
this.wizard = new AkWizardController(this);
}
/**
* Derive the labels used by the frame's Breadcrumbs display.
*/
get stepLabels(): WizardStepLabel[] {
let disabled = false;
return this.steps.map((step, index) => {
disabled = disabled || step.disabled;
return {
label: step.label,
active: index === this.currentStep,
index,
disabled,
};
});
}
/**
* You should still consider overriding this if you need to consider details like "Is the step
* requested valid?"
*/
handleNav(stepId: number | undefined) {
if (stepId === undefined || this.steps[stepId] === undefined) {
throw new Error(`Attempt to navigate to undefined step: ${stepId}`);
}
this.currentStep = stepId;
this.requestUpdate();
}
close() {
throw new Error("This function must be overridden in the child class.");
}
/**
* This is where all the business logic and special cases go. The Wizard Controller intercepts
* updates tagged `ak-wizard-update` and forwards the event content here. Business logic about
* "is the current step valid?" and "should the Next button be made enabled" are controlled
* here. (Any step implementing WizardStep can do it anyhow it pleases, putting "is the current
* form valid" and so forth into the step object itself.)
*/
handleUpdate(_detail: D) {
throw new Error("This function must be overridden in the child class.");
}
render() {
const step = this.step.render.bind(this.step);
return html`
<ak-wizard-frame
${ref(this.frame)}
header=${this.header}
description=${ifDefined(this.description)}
prompt=${this.prompt}
.buttons=${this.step.buttons}
.stepLabels=${this.stepLabels}
.form=${step}
>
<button slot="trigger" class="pf-c-button pf-m-primary">${this.prompt}</button>
</ak-wizard-frame>
`;
}
}

View File

@ -1,104 +0,0 @@
import { type ReactiveController } from "lit";
import { type AkWizard, type WizardNavCommand } from "./types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isCustomEvent = (v: any): v is CustomEvent =>
v instanceof CustomEvent && "detail" in v;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isNavEvent = (v: any): v is CustomEvent<WizardNavCommand> =>
isCustomEvent(v) && "command" in v.detail;
/**
* AkWizardController
*
* A ReactiveController that plugs into any wizard and provides a somewhat more convenient API for
* interacting with that wizard. It expects three different events from the wizard frame, each of
* which has a corresponding method that then forwards the necessary information to the host:
*
* - nav: A request to navigate to different step. Calls the host's `handleNav()` with the requested
step number.
* - update: A request to update the content of the current step. Forwarded to the host's
* `handleUpdate()` method.
* - close: A request to end the wizard interaction. Forwarded to the host's `close()` method.
*
*/
export class AkWizardController<Data> implements ReactiveController {
private host: AkWizard<Data>;
constructor(host: AkWizard<Data>) {
this.host = host;
this.handleNavRequest = this.handleNavRequest.bind(this);
this.handleUpdateRequest = this.handleUpdateRequest.bind(this);
host.addController(this);
}
get maxStep() {
return this.host.steps.length - 1;
}
get nextStep() {
return this.host.currentStep < this.maxStep ? this.host.currentStep + 1 : undefined;
}
get backStep() {
return this.host.currentStep > 0 ? this.host.currentStep - 1 : undefined;
}
get step() {
return this.host.steps[this.host.currentStep];
}
hostConnected() {
this.host.addEventListener("ak-wizard-nav", this.handleNavRequest);
this.host.addEventListener("ak-wizard-update", this.handleUpdateRequest);
this.host.addEventListener("ak-wizard-closed", this.handleCloseRequest);
}
hostDisconnected() {
this.host.removeEventListener("ak-wizard-nav", this.handleNavRequest);
this.host.removeEventListener("ak-wizard-update", this.handleUpdateRequest);
this.host.removeEventListener("ak-wizard-closed", this.handleCloseRequest);
}
handleNavRequest(event: Event) {
if (!isNavEvent(event)) {
throw new Error(`Unexpected event received by nav handler: ${event}`);
}
if (event.detail.command === "close") {
this.host.close();
return;
}
const navigate = (): number | undefined => {
switch (event.detail.command) {
case "next":
return this.nextStep;
case "back":
return this.backStep;
case "goto":
return event.detail.step;
default:
throw new Error(
`Unrecognized command passed to ak-wizard-controller:handleNavRequest: ${event.detail.command}`,
);
}
};
this.host.handleNav(navigate());
}
handleUpdateRequest(event: Event) {
if (!isCustomEvent(event)) {
throw new Error(`Unexpected event received by nav handler: ${event}`);
}
this.host.handleUpdate(event.detail);
}
handleCloseRequest() {
this.host.close();
}
}

View File

@ -1,215 +0,0 @@
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { customElement, property, query } from "@lit/reactive-element/decorators.js";
import { TemplateResult, css, html, nothing } from "lit";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
import { type WizardButton, WizardStepLabel } from "./types";
/**
* AKWizardFrame is the main container for displaying Wizard pages.
*
* AKWizardFrame is one component of a Wizard development environment. It provides the header,
* titled navigation sidebar, and bottom row button bar. It takes its cues about what to render from
* two data structure, `this.steps: WizardStep[]`, which lists all the current steps *in order* and
* doesn't care otherwise about their structure, and `this.currentStep: WizardStep` which must be a
* _reference_ to a member of `this.steps`.
*
* @element ak-wizard-frame
*
* @slot trigger - (Inherited from ModalButton) Define the "summon modal" button here
*
* @fires ak-wizard-nav - Tell the orchestrator what page the user wishes to move to.
*
*/
@customElement("ak-wizard-frame")
export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
static get styles() {
return [
...super.styles,
PFWizard,
css`
.pf-c-modal-box {
height: 75%;
}
`,
];
}
/**
* The text for the title of the wizard
*/
@property()
header?: string;
/**
* The text for a descriptive subtitle for the wizard
*/
@property()
description?: string;
/**
* The labels for all current steps, including their availability
*/
@property({ attribute: false, type: Array })
stepLabels!: WizardStepLabel[];
/**
* What buttons to Show
*/
@property({ attribute: false, type: Array })
buttons: WizardButton[] = [];
/**
* Show the [Cancel] icon and offer the [Cancel] button
*/
@property({ type: Boolean, attribute: "can-cancel" })
canCancel = false;
/**
* The form renderer, passed as a function
*/
@property({ type: Object })
form!: () => TemplateResult;
@query("#main-content *:first-child")
content!: HTMLElement;
constructor() {
super();
this.renderButtons = this.renderButtons.bind(this);
}
renderModalInner() {
// prettier-ignore
return html`<div class="pf-c-wizard">
${this.renderHeader()}
<div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">
${this.renderNavigation()}
${this.renderMainSection()}
</div>
${this.renderFooter()}
</div>
</div>`;
}
renderHeader() {
return html`<div class="pf-c-wizard__header">
${this.canCancel ? this.renderHeaderCancelIcon() : nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.header}</h1>
<p class="pf-c-wizard__description">${this.description}</p>
</div>`;
}
renderHeaderCancelIcon() {
return html`<button
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
aria-label="${msg("Close")}"
@click=${() => this.dispatchCustomEvent("ak-wizard-nav", { command: "close" })}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
}
renderNavigation() {
return html`<nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list">
${this.stepLabels.map((step) => {
return this.renderNavigationStep(step);
})}
</ol>
</nav>`;
}
renderNavigationStep(step: WizardStepLabel) {
const buttonClasses = {
"pf-c-wizard__nav-link": true,
"pf-m-current": step.active,
};
return html`
<li class="pf-c-wizard__nav-item">
<button
class=${classMap(buttonClasses)}
?disabled=${step.disabled}
@click=${() =>
this.dispatchCustomEvent("ak-wizard-nav", {
command: "goto",
step: step.index,
})}
>
${step.label}
</button>
</li>
`;
}
// This is where the panel is shown. We expect the panel to get its information from an
// independent context.
renderMainSection() {
return html`<main class="pf-c-wizard__main">
<div id="main-content" class="pf-c-wizard__main-body">${this.form()}</div>
</main>`;
}
renderFooter() {
return html`
<footer class="pf-c-wizard__footer">${map(this.buttons, this.renderButtons)}</footer>
`;
}
renderButtons([label, command]: WizardButton) {
switch (command.command) {
case "next":
return this.renderButton(label, "pf-m-primary", command.command);
case "back":
return this.renderButton(label, "pf-m-secondary", command.command);
case "close":
return this.renderLink(label, "pf-m-link");
default:
throw new Error(`Button type not understood: ${command} for ${label}`);
}
}
renderButton(label: string, classname: string, command: string) {
const buttonClasses = { "pf-c-button": true, [classname]: true };
return html`<button
class=${classMap(buttonClasses)}
type="button"
@click=${() => {
this.dispatchCustomEvent("ak-wizard-nav", { command });
}}
>
${label}
</button>`;
}
renderLink(label: string, classname: string) {
const buttonClasses = { "pf-c-button": true, [classname]: true };
return html`<div class="pf-c-wizard__footer-cancel">
<button
class=${classMap(buttonClasses)}
type="button"
@click=${() => this.dispatchCustomEvent("ak-wizard-nav", { command: "close" })}
>
${label}
</button>
</div>`;
}
}
export default AkWizardFrame;
declare global {
interface HTMLElementTagNameMap {
"ak-wizard-frame": AkWizardFrame;
}
}

View File

@ -1,15 +0,0 @@
import { msg } from "@lit/localize";
import { WizardButton } from "./types";
export const NextStep: WizardButton = [msg("Next"), { command: "next" }];
export const BackStep: WizardButton = [msg("Back"), { command: "back" }];
export const SubmitStep: WizardButton = [msg("Submit"), { command: "next" }];
export const CancelWizard: WizardButton = [msg("Cancel"), { command: "close" }];
export const CloseWizard: WizardButton = [msg("Close"), { command: "close" }];
export const DisabledNextStep: WizardButton = [msg("Next"), { command: "next" }, true];

View File

@ -1,53 +0,0 @@
import type { WizardStep } from "@goauthentik/components/ak-wizard-main/types";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { html } from "lit";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { AkWizard } from "../AkWizard";
import { BackStep, CancelWizard, CloseWizard, NextStep } from "../commonWizardButtons";
type WizardStateUpdate = {
message: string;
};
const dummySteps: WizardStep[] = [
{
label: "Test Step1",
render: () => html`<h2>This space intentionally left blank today</h2>`,
disabled: false,
buttons: [NextStep, CancelWizard],
},
{
label: "Test Step 2",
render: () => html`<h2>This space also intentionally left blank</h2>`,
disabled: false,
buttons: [BackStep, CloseWizard],
},
];
@customElement("ak-demo-wizard")
export class ApplicationWizard extends AkWizard<WizardStateUpdate, WizardStep> {
static get styles() {
return [PFBase, PFButton, PFRadio];
}
constructor() {
super(msg("Open Wizard"), msg("Demo Wizard"), msg("Run the demo wizard"));
this.steps = [...dummySteps];
}
close() {
this.frame.value!.open = false;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-demo-wizard": ApplicationWizard;
}
}

View File

@ -1,40 +0,0 @@
import type { WizardStep } from "@goauthentik/components/ak-wizard-main/types";
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import { AkWizard } from "../AkWizard";
import "./ak-demo-wizard";
const metadata: Meta<AkWizard<string, WizardStep>> = {
title: "Components / Wizard / Basic",
component: "ak-wizard-main",
parameters: {
docs: {
description: {
component: "A container for our wizard.",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
</div>`;
export const OnePageWizard = () => {
return container(html` <ak-demo-wizard></ak-demo-wizard>`);
};

View File

@ -1,79 +0,0 @@
import { type LitElement, type ReactiveControllerHost, type TemplateResult } from "lit";
/** These are the navigation commands that the frame will send up to the controller. In the
* accompanying file, `./commonWizardButtons.ts`, you'll find a variety of Next, Back, Close,
* Cancel, and Submit buttons that can be used to send these, but these commands are also
* used by the breadcrumbs to hop around the wizard (if the wizard client so chooses to allow),
*/
export type WizardNavCommand =
| { command: "next" }
| { command: "back" }
| { command: "close" }
| { command: "goto"; step: number };
/**
* The pattern for buttons being passed to the wizard. See `./commonWizardButtons.ts` for
* example implementations. The details are: Label, Command, and Disabled.
*/
export type WizardButton = [string, WizardNavCommand, boolean?];
/**
* Objects of this type are produced by the Controller, and are used in the Breadcrumbs to
* indicate the name of the step, whether or not it is the current step ("active"), and
* whether or not it is disabled. It is up to WizardClients to ensure that a step is
* not both "active" and "disabled".
*/
export type WizardStepLabel = {
label: string;
index: number;
active: boolean;
disabled: boolean;
};
type LitControllerHost = ReactiveControllerHost & LitElement;
export interface AkWizard<D> extends LitControllerHost {
// Every wizard must provide a list of the steps to show. This list can change, but if it does,
// note that the *first* page must never change, and it's the responsibility of the developer to
// ensure that if the list changes that the currentStep points to the right place.
steps: WizardStep[];
// The index of the current step;
currentStep: number;
// An accessor to the current step;
step: WizardStep;
// Handle pressing the "close," "cancel," or "done" buttons.
close: () => void;
// When a navigation event such as "next," "back," or "go to" (from the breadcrumbs) occurs.
handleNav: (_1: number | undefined) => void;
// When a notification that the data on the live form has changed.
handleUpdate: (_1: D) => void;
}
export interface WizardStep {
// The name of the step, as shown in the navigation.
label: string;
// A function which returns the html for rendering the actual content of the step, its form and
// such.
render: () => TemplateResult;
// A collection of buttons, in render order, that are to be shown in the button bar. If you can,
// always lead with the [Back] button and ensure it's in the same place every time. The
// controller's current behavior is to call the host's `handleNav()` command with the index of
// the requested step, or undefined if the command is nonsensical.
buttons: WizardButton[];
// If this step is "disabled," the prior step's next button will be disabled.
disabled: boolean;
}
export interface WizardPanel extends HTMLElement {
validator?: () => boolean;
}

View File

@ -0,0 +1,7 @@
import { createContext } from "@lit/context";
import type { WizardStepState } from "./types";
export const wizardStepContext = createContext<WizardStepState>(
Symbol("authentik-wizard-step-labels"),
);

View File

@ -0,0 +1,325 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { bound } from "@goauthentik/elements/decorators/bound";
import { P, match } from "ts-pattern";
import { consume } from "@lit/context";
import { msg } from "@lit/localize";
import { css, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
import { wizardStepContext } from "./WizardContexts.js";
import { NavigationUpdate, WizardCloseEvent, WizardNavigationEvent } from "./events.js";
import { WizardStepLabel, WizardStepState } from "./types";
import { type ButtonKind, type NavigableButton, type WizardButton } from "./types";
const isNavigable = (b: WizardButton): b is NavigableButton =>
"destination" in b && typeof b.destination === "string" && b.destination.length > 0;
const BUTTON_KIND_TO_CLASS: Record<ButtonKind, string> = {
next: "pf-m-primary",
back: "pf-m-secondary",
close: "pf-m-link",
cancel: "pf-m-link",
};
const BUTTON_KIND_TO_LABEL: Record<ButtonKind, string> = {
next: msg("Next"),
back: msg("Back"),
cancel: msg("Cancel"),
close: msg("Close"),
};
/**
* @class WizardStep
*
* Superclass for a single step in the wizard. Contains all of the styling for the Patternfly
* wizard pattern. Child classes must:
*
* - Specify the *Wizard* title, optional description, and if to show the cancel icon in the upper
* right hand corner. Ideally, this is the same for all child classes, so for simplicity these
* could be overridden
* - Specify what goes into the main content for this step.
* - Specify what buttons to render for this step, and to what step(s) the navigable button(s) must go.
* - Specify what validation must be done before the 'next' button can be honored.
*
* Events
*
* @fires WizardNavigationEvent - request ak-wizard-steps to move to another step
* @fires WizardCloseEvent - request parent container (Wizard) to close the wizard
*/
export class WizardStep extends AKElement {
// These additions are necessary because we don't want to inherit *all* of the modal box
// modifiers, just the ones related to managing the height of the display box.
static get styles() {
return [
PFWizard,
PFContent,
PFTitle,
css`
.ak-wizard-box {
height: 75%;
height: 75vh;
display: flex;
flex-direction: column;
position: relative;
z-index: 500;
}
`,
];
}
@property({ type: Boolean, attribute: true, reflect: true })
enabled = false;
/**
* The name. Should match the slot. Reflected if not present.
*/
@property({ type: String, attribute: true, reflect: true })
name?: string;
@consume({ context: wizardStepContext, subscribe: true })
wizardStepState: WizardStepState = { currentStep: undefined, stepLabels: [] };
/**
* What appears in the titlebar of the Wizard. Usually, but not necessarily, the same for all
* steps. Recommendation: Set this, the description, and `canCancel` in a subclass, and stop
* worrying about them.
*/
wizardTitle = "--unset--";
/**
* The text for a descriptive subtitle for the wizard
*/
wizardDescription?: string;
/**
* Show the [Cancel] icon and offer the [Cancel] button
*/
canCancel = false;
/**
* The ID of the current step.
*/
id = "";
/**
*The label of the current step. Displayed in the navigation bar.
*/
label: string = "--unset--";
/**
* If true, this step's label will not be shown in the navigation bar
*/
hide = false;
// ___ _ _ _ _ ___ ___
// | _ \_ _| |__| (_)__ /_\ | _ \_ _|
// | _/ || | '_ \ | / _| / _ \| _/| |
// |_| \_,_|_.__/_|_\__| /_/ \_\_| |___|
//
// Override this and provide the buttons for this step. The button type is documented in the
// [types](./types.ts) file, but in short, there are four "kinds": "next", "back", "cancel", and
// "close."
public get buttons(): WizardButton[] {
return [];
}
// Override this to provide the form.
public renderMain() {
throw new Error("This must be overridden in client classes");
}
// Override this to intercept 'next' and 'back' events, perform validation, and include enabling
// before allowing navigation to continue.
public handleButton(button: WizardButton, details?: NavigationUpdate) {
if (["close", "cancel"].includes(button.kind)) {
this.dispatchEvent(new WizardCloseEvent());
return;
}
if (isNavigable(button)) {
this.dispatchEvent(new WizardNavigationEvent(button.destination, details));
return;
}
throw new Error(`Incoherent button passed: ${JSON.stringify(button, null, 2)}`);
}
public handleEnabling(details: NavigationUpdate) {
this.dispatchEvent(new WizardNavigationEvent(undefined, details));
}
// END Public API
connectedCallback() {
super.connectedCallback();
if (!this.name) {
const name = this.getAttribute("slot");
if (!name) {
throw new Error("Steps must have a unique slot attribute.");
}
this.name = name;
}
}
@bound
onWizardNavigationEvent(ev: Event, button: WizardButton) {
ev.stopPropagation();
if (!isNavigable(button)) {
throw new Error("Non-navigable button sent to handleNavigationEvent");
}
this.handleButton(button);
}
@bound
onWizardCloseEvent(ev: Event) {
ev.stopPropagation();
this.dispatchEvent(new WizardCloseEvent());
}
@bound
onSidebarNav(ev: PointerEvent) {
ev.stopPropagation();
const target = (ev.target as HTMLButtonElement).value;
this.dispatchEvent(new WizardNavigationEvent(target));
}
getButtonLabel(button: WizardButton) {
return button.label ?? BUTTON_KIND_TO_LABEL[button.kind];
}
getButtonClasses(button: WizardButton) {
return {
"pf-c-button": true,
[BUTTON_KIND_TO_CLASS[button.kind]]: true,
};
}
@bound
renderCloseButton(button: WizardButton) {
return html`<div class="pf-c-wizard__footer-cancel">
<button
class=${classMap(this.getButtonClasses(button))}
type="button"
@click=${this.onWizardCloseEvent}
>
${this.getButtonLabel(button)}
</button>
</div>`;
}
@bound
renderDisabledButton(button: WizardButton) {
return html`<button class=${classMap(this.getButtonClasses(button))} type="button" disabled>
${this.getButtonLabel(button)}
</button>`;
}
@bound
renderNavigableButton(button: WizardButton) {
return html`<button
class=${classMap(this.getButtonClasses(button))}
type="button"
@click=${(ev: Event) => this.onWizardNavigationEvent(ev, button)}
data-ouid-button-kind="wizard-${button.kind}"
>
${this.getButtonLabel(button)}
</button>`;
}
@bound
renderButton(button: WizardButton) {
return match(button)
.with({ kind: P.union("close", "cancel") }, () => this.renderCloseButton(button))
.with({ destination: P.string }, () => this.renderNavigableButton(button))
.otherwise(() => {
throw new Error("Button type is not close, disabled, or navigable?");
});
}
renderHeaderCancelIcon() {
return html`<button
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
aria-label="${msg("Close")}"
@click=${this.onWizardCloseEvent}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
}
@bound
renderSidebarStep(step: WizardStepLabel) {
const buttonClasses = {
"pf-c-wizard__nav-link": true,
"pf-m-disabled": !step.enabled,
"pf-m-current": step.id === this.wizardStepState.currentStep,
};
return html`
<li class="pf-c-wizard__nav-item">
<button
class=${classMap(buttonClasses)}
?disabled=${!step.enabled}
@click=${this.onSidebarNav}
value=${step.id}
>
${step.label}
</button>
</li>
</div>
`;
}
render() {
return this.wizardStepState.currentStep === this.getAttribute("slot")
? html` <div class="pf-c-modal-box ak-wizard-box">
<div class="pf-c-wizard">
<div class="pf-c-wizard__header" data-ouid-component-id="wizard-header">
${this.canCancel ? this.renderHeaderCancelIcon() : nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">
${this.wizardTitle}
</h1>
<p class="pf-c-wizard__description">${this.wizardDescription}</p>
</div>
<div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">
<nav class="pf-c-wizard__nav" data-ouid-component-id="wizard-navbar">
<ol class="pf-c-wizard__nav-list">
${map(
this.wizardStepState.stepLabels,
this.renderSidebarStep,
)}
</ol>
</nav>
<main class="pf-c-wizard__main">
<div
id="main-content"
class="pf-c-wizard__main-body"
data-ouid-component-id="wizard-body"
>
${this.renderMain()}
</div>
</main>
</div>
<footer
class="pf-c-wizard__footer"
data-ouid-component-id="wizard-footer"
>
${this.buttons.map(this.renderButton)}
</footer>
</div>
</div>
</div>`
: nothing;
}
}

View File

@ -0,0 +1,175 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { bound } from "@goauthentik/elements/decorators/bound";
import { ContextProvider } from "@lit/context";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { wizardStepContext } from "./WizardContexts";
import { type WizardStep } from "./WizardStep";
import { NavigationUpdate, WizardNavigationEvent } from "./events";
import { WizardStepState } from "./types";
/**
* @class WizardStepsManager
* @component ak-wizard-Steps
*
* This class keeps *all* the steps of the wizard, and knows the identity of the "current" step.
* When a navigation event reaches it, it changes the view to show that step. Optionally, it can
* process a "details" object from the WizardNavigationEvent that assigns enabled/disabled flags to
* children that inherit from WizardStep. It can determine the slot names and identities dynamically
* from the slot names of its immediate children, and will recalculate the slots if the component
* using this class changes them.
*
*/
@customElement("ak-wizard-steps")
export class WizardStepsManager extends AKElement {
@property({ type: String, attribute: true })
currentStep?: string;
wizardStepContext!: ContextProvider<{ __context__: WizardStepState | undefined }>;
slots: WizardStep[] = [];
constructor() {
super();
this.wizardStepContext = new ContextProvider(this, {
context: wizardStepContext,
initialValue: {
currentStep: undefined,
stepLabels: [],
},
});
this.addEventListener(WizardNavigationEvent.eventName, this.onNavigation);
this.addEventListener("slotchange", this.onSlotchange);
}
findSlots() {
this.slots = Array.from(this.querySelectorAll("[slot]")) as WizardStep[];
}
findSlot(name?: string) {
const target = this.slots.find((slot) => slot.slot === name);
if (!target) {
throw new Error(`Request for wizard panel that does not exist: ${name}`);
}
return target;
}
get stepLabels() {
return this.slots
.filter((slot) => !slot.hide)
.map((slot) => ({
label: slot.label,
id: slot.slot,
active: true,
enabled: slot.enabled,
}));
}
findStepLabels() {
this.wizardStepContext.setValue({
...this.wizardStepContext.value,
stepLabels: this.stepLabels,
});
}
connectedCallback() {
super.connectedCallback();
this.findSlots();
this.findStepLabels();
if (!this.currentStep && this.slots.length > 0) {
const currentStep = this.slots[0].getAttribute("slot");
if (!currentStep) {
throw new Error("All steps managed by this component must have a slot definition.");
}
this.currentStep = currentStep;
this.wizardStepContext.setValue({
stepLabels: this.stepLabels,
currentStep: currentStep,
});
}
}
@bound
onSlotchange(ev: Event) {
ev.stopPropagation();
this.findSlots();
this.findSlot(this.currentStep);
this.findStepLabels();
}
// This event sequence handles the following possibilities:
// - The user on a step validated and wants to move forward. We want to make sure the *next*
// step in enabled.
// - The user went *back* and changed a step and it is no longer valid. We want to disable all
// future steps until that is corrected. Yes, in this case the flow is "Now you have to go
// through the entire wizard," but since the user invalidated a prior, that shouldn't be
// unexpected. None of the data will have been lost.
updateStepAvailability(details: NavigationUpdate) {
const asArr = (v?: string[] | string) =>
v === undefined ? [] : Array.isArray(v) ? v : [v];
const enabled = asArr(details.enable);
enabled.forEach((name) => {
this.findSlot(name).enabled = true;
});
if (details.disabled !== undefined) {
const disabled = asArr(details.disabled);
this.slots.forEach((slot) => {
slot.enabled = !disabled.includes(slot.slot);
});
}
if (details.hidden !== undefined) {
const hidden = asArr(details.hidden);
this.slots.forEach((slot) => {
slot.hide = hidden.includes(slot.slot);
});
}
}
@bound
onNavigation(ev: WizardNavigationEvent) {
ev.stopPropagation();
const { destination, details } = ev;
if (details) {
this.updateStepAvailability(details);
}
if (!destination) {
return;
}
const target = this.slots.find((slot) => slot.slot === destination);
if (!target) {
throw new Error(`Attempted to navigate to unknown step: ${destination}`);
}
if (!target.enabled) {
throw new Error(`Attempted to navigate to disabled step: ${destination}`);
}
if (target.slot === this.currentStep) {
return;
}
this.currentStep = target.slot;
this.wizardStepContext.setValue({
stepLabels: this.stepLabels,
currentStep: target.slot,
});
}
render() {
return this.currentStep ? html`<slot name=${this.currentStep}></slot>` : nothing;
}
firstUpdated() {
this.findStepLabels();
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-wizard-steps": WizardStepsManager;
}
}

View File

@ -0,0 +1,46 @@
export type NavigationUpdate = {
disabled?: string[];
enable?: string | string[];
hidden?: string[];
};
export class WizardNavigationEvent extends Event {
static readonly eventName = "ak-wizard-navigation";
destination?: string;
details?: NavigationUpdate;
constructor(destination?: string, details?: NavigationUpdate) {
super(WizardNavigationEvent.eventName, { bubbles: true, composed: true });
this.destination = destination;
this.details = details;
}
}
export class WizardUpdateEvent<T> extends Event {
static readonly eventName = "ak-wizard-update";
content: T;
constructor(content: T) {
super(WizardUpdateEvent.eventName, { bubbles: true, composed: true });
this.content = content;
}
}
export class WizardCloseEvent extends Event {
static readonly eventName = "ak-wizard-close";
constructor() {
super(WizardCloseEvent.eventName, { bubbles: true, composed: true });
}
}
declare global {
interface GlobalEventHandlersEventMap {
[WizardNavigationEvent.eventName]: WizardNavigationEvent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[WizardUpdateEvent.eventName]: WizardUpdateEvent<any>;
[WizardCloseEvent.eventName]: WizardCloseEvent;
}
}

View File

@ -0,0 +1,23 @@
export type EnabledWizardButton =
| { kind: "back"; label?: string; destination: string }
| { kind: "cancel"; label?: string }
| { kind: "close"; label?: string }
| { kind: "next"; label?: string; destination: string };
export type WizardButton = EnabledWizardButton;
export type NavigableButton = Extract<WizardButton, { destination: string }>;
export type ButtonKind = Extract<WizardButton["kind"], PropertyKey>;
export type WizardStepLabel = {
label: string;
id: string;
active: boolean;
enabled: boolean;
};
export type WizardStepState = {
currentStep?: string;
stepLabels: WizardStepLabel[];
};