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:
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>`);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
7
web/src/components/ak-wizard/WizardContexts.ts
Normal file
7
web/src/components/ak-wizard/WizardContexts.ts
Normal 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"),
|
||||
);
|
||||
325
web/src/components/ak-wizard/WizardStep.ts
Normal file
325
web/src/components/ak-wizard/WizardStep.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
175
web/src/components/ak-wizard/ak-wizard-steps.ts
Normal file
175
web/src/components/ak-wizard/ak-wizard-steps.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
46
web/src/components/ak-wizard/events.ts
Normal file
46
web/src/components/ak-wizard/events.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
web/src/components/ak-wizard/types.ts
Normal file
23
web/src/components/ak-wizard/types.ts
Normal 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[];
|
||||
};
|
||||
Reference in New Issue
Block a user