web/admin: application wizard (part 1) (#2745)
* initial Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * remove log Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * start oauth Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * use form for all type wizard pages Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * more oauth Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * basic wizard actions Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * make resets work Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add hint in provider wizard Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * render correct icon in empty state in table page Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * improve empty state Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * more Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add more pages Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * fix Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add group PK to service account creation response Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * use wizard-level isValid prop Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * re-add old buttons Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
147
web/src/elements/wizard/ActionWizardPage.ts
Normal file
147
web/src/elements/wizard/ActionWizardPage.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import AKGlobal from "../../authentik.css";
|
||||
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
|
||||
import PFProgressStepper from "@patternfly/patternfly/components/ProgressStepper/progress-stepper.css";
|
||||
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { ResponseError } from "@goauthentik/api";
|
||||
|
||||
import { EVENT_REFRESH } from "../../constants";
|
||||
import { WizardAction } from "./Wizard";
|
||||
import { WizardPage } from "./WizardPage";
|
||||
|
||||
export enum ActionState {
|
||||
pending = "pending",
|
||||
running = "running",
|
||||
done = "done",
|
||||
failed = "failed",
|
||||
}
|
||||
|
||||
export interface ActionStateBundle {
|
||||
action: WizardAction;
|
||||
state: ActionState;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("ak-wizard-page-action")
|
||||
export class ActionWizardPage extends WizardPage {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFBullseye, PFEmptyState, PFTitle, PFProgressStepper, AKGlobal];
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
states: ActionStateBundle[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
currentStep?: ActionStateBundle;
|
||||
|
||||
activeCallback = async (): Promise<void> => {
|
||||
this.states = [];
|
||||
this.host.actions.map((act, idx) => {
|
||||
this.states.push({
|
||||
action: act,
|
||||
state: ActionState.pending,
|
||||
idx: idx,
|
||||
});
|
||||
});
|
||||
this.host.canBack = false;
|
||||
this.host.canCancel = false;
|
||||
await this.run();
|
||||
// Ensure wizard is closable, even when run() failed
|
||||
this.host.isValid = true;
|
||||
};
|
||||
|
||||
sidebarLabel = () => t`Apply changes`;
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.currentStep = this.states[0];
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
for await (const bundle of this.states) {
|
||||
this.currentStep = bundle;
|
||||
this.currentStep.state = ActionState.running;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
await bundle.action.run();
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
this.currentStep.state = ActionState.done;
|
||||
this.requestUpdate();
|
||||
} catch (exc) {
|
||||
if (exc instanceof ResponseError) {
|
||||
this.currentStep.action.subText = await exc.response.text();
|
||||
} else {
|
||||
this.currentStep.action.subText = (exc as Error).toString();
|
||||
}
|
||||
this.currentStep.state = ActionState.failed;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.host.isValid = true;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="pf-l-bullseye">
|
||||
<div class="pf-c-empty-state pf-m-lg">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="fas fa- fa-cogs pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">${this.currentStep?.action.displayName}</h1>
|
||||
<div class="pf-c-empty-state__body">
|
||||
<ol class="pf-c-progress-stepper pf-m-vertical">
|
||||
${this.states.map((state) => {
|
||||
let cls = "";
|
||||
switch (state.state) {
|
||||
case ActionState.pending:
|
||||
cls = "pf-m-pending";
|
||||
break;
|
||||
case ActionState.done:
|
||||
cls = "pf-m-success";
|
||||
break;
|
||||
case ActionState.running:
|
||||
cls = "pf-m-info";
|
||||
break;
|
||||
case ActionState.failed:
|
||||
cls = "pf-m-danger";
|
||||
break;
|
||||
}
|
||||
if (state.idx === this.currentStep?.idx) {
|
||||
cls += " pf-m-current";
|
||||
}
|
||||
return html` <li class="pf-c-progress-stepper__step ${cls}">
|
||||
<div class="pf-c-progress-stepper__step-connector">
|
||||
<span class="pf-c-progress-stepper__step-icon">
|
||||
<i class="fas fa-check-circle" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pf-c-progress-stepper__step-main">
|
||||
<div class="pf-c-progress-stepper__step-title">
|
||||
${state.action.displayName}
|
||||
</div>
|
||||
${state.action.subText
|
||||
? html`<div
|
||||
class="pf-c-progress-stepper__step-description"
|
||||
>
|
||||
${state.action.subText}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
</li>`;
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,6 @@ import { WizardPage } from "./WizardPage";
|
||||
|
||||
@customElement("ak-wizard-page-form")
|
||||
export class FormWizardPage extends WizardPage {
|
||||
_isValid = true;
|
||||
|
||||
isValid(): boolean {
|
||||
return this._isValid;
|
||||
}
|
||||
|
||||
nextCallback = async () => {
|
||||
const form = this.querySelector<Form<unknown>>("*");
|
||||
if (!form) {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ModalButton } from "@goauthentik/web/elements/buttons/ModalButton";
|
||||
import "@goauthentik/web/elements/wizard/ActionWizardPage";
|
||||
import { WizardPage } from "@goauthentik/web/elements/wizard/WizardPage";
|
||||
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
@ -9,22 +11,64 @@ import { state } from "lit/decorators.js";
|
||||
|
||||
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
|
||||
|
||||
import { WizardPage } from "./WizardPage";
|
||||
export interface WizardAction {
|
||||
displayName: string;
|
||||
subText?: string;
|
||||
run: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const ApplyActionsSlot = "apply-actions";
|
||||
|
||||
@customElement("ak-wizard")
|
||||
export class Wizard extends ModalButton {
|
||||
@property({ type: Boolean })
|
||||
canCancel = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
canBack = true;
|
||||
|
||||
@property()
|
||||
header?: string;
|
||||
|
||||
@property()
|
||||
description?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
isValid = false;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return super.styles.concat(PFWizard);
|
||||
}
|
||||
|
||||
@state()
|
||||
_steps: string[] = [];
|
||||
|
||||
get steps(): string[] {
|
||||
return this._steps;
|
||||
}
|
||||
|
||||
set steps(steps: string[]) {
|
||||
const addApplyActionsSlot = this.steps.includes(ApplyActionsSlot);
|
||||
this._steps = steps;
|
||||
if (addApplyActionsSlot) {
|
||||
this.steps.push(ApplyActionsSlot);
|
||||
}
|
||||
this.steps.forEach((step) => {
|
||||
const exists = this.querySelector(`[slot=${step}]`) !== null;
|
||||
if (!exists) {
|
||||
const el = document.createElement(step);
|
||||
el.slot = step;
|
||||
el.dataset["wizardmanaged"] = "true";
|
||||
this.appendChild(el);
|
||||
}
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_initialSteps: string[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
steps: string[] = [];
|
||||
actions: WizardAction[] = [];
|
||||
|
||||
@state()
|
||||
_currentStep?: WizardPage;
|
||||
@ -41,39 +85,63 @@ export class Wizard extends ModalButton {
|
||||
return this._currentStep;
|
||||
}
|
||||
|
||||
setSteps(...steps: string[]): void {
|
||||
this.steps = steps;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
finalHandler: () => Promise<void> = () => {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
state: { [key: string]: unknown } = {};
|
||||
|
||||
firstUpdated(): void {
|
||||
this._initialSteps = this._steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add action to the beginning of the list
|
||||
*/
|
||||
addActionBefore(displayName: string, run: () => Promise<boolean>): void {
|
||||
this.actions.unshift({
|
||||
displayName,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add action at the end of the list
|
||||
*/
|
||||
addActionAfter(displayName: string, run: () => Promise<boolean>): void {
|
||||
this.actions.push({
|
||||
displayName,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
renderModalInner(): TemplateResult {
|
||||
const firstPage = this.querySelector<WizardPage>(`[slot=${this.steps[0]}]`);
|
||||
if (!this.currentStep && firstPage) {
|
||||
this.currentStep = firstPage;
|
||||
}
|
||||
this.currentStep?.requestUpdate();
|
||||
const currentIndex = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0;
|
||||
let lastPage = currentIndex === this.steps.length - 1;
|
||||
if (lastPage && !this.steps.includes("ak-wizard-page-action") && this.actions.length > 0) {
|
||||
this.steps = this.steps.concat("ak-wizard-page-action");
|
||||
lastPage = currentIndex === this.steps.length - 1;
|
||||
}
|
||||
return html`<div class="pf-c-wizard">
|
||||
<div class="pf-c-wizard__header">
|
||||
<button
|
||||
class="pf-c-button pf-m-plain pf-c-wizard__close"
|
||||
type="button"
|
||||
aria-label="${t`Close`}"
|
||||
@click=${() => {
|
||||
this.open = false;
|
||||
const firstPage = this.querySelector<WizardPage>(`[slot=${this.steps[0]}]`);
|
||||
if (firstPage) {
|
||||
this.currentStep = firstPage;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
${this.canCancel
|
||||
? html`<button
|
||||
class="pf-c-button pf-m-plain pf-c-wizard__close"
|
||||
type="button"
|
||||
aria-label="${t`Close`}"
|
||||
@click=${() => {
|
||||
this.reset();
|
||||
}}
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>`
|
||||
: html``}
|
||||
<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>
|
||||
@ -120,15 +188,15 @@ export class Wizard extends ModalButton {
|
||||
<button
|
||||
class="pf-c-button pf-m-primary"
|
||||
type="submit"
|
||||
?disabled=${!this._currentStep?.isValid()}
|
||||
?disabled=${!this.isValid}
|
||||
@click=${async () => {
|
||||
const cb = await this.currentStep?.nextCallback();
|
||||
if (!cb) {
|
||||
return;
|
||||
}
|
||||
if (currentIndex === this.steps.length - 1) {
|
||||
if (lastPage) {
|
||||
await this.finalHandler();
|
||||
this.open = false;
|
||||
this.reset();
|
||||
} else {
|
||||
const nextPage = this.querySelector<WizardPage>(
|
||||
`[slot=${this.steps[currentIndex + 1]}]`,
|
||||
@ -139,9 +207,10 @@ export class Wizard extends ModalButton {
|
||||
}
|
||||
}}
|
||||
>
|
||||
${currentIndex === this.steps.length - 1 ? t`Finish` : t`Next`}
|
||||
${lastPage ? t`Finish` : t`Next`}
|
||||
</button>
|
||||
${(this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0) > 0
|
||||
${(this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0) > 0 &&
|
||||
this.canBack
|
||||
? html`
|
||||
<button
|
||||
class="pf-c-button pf-m-secondary"
|
||||
@ -159,25 +228,34 @@ export class Wizard extends ModalButton {
|
||||
</button>
|
||||
`
|
||||
: html``}
|
||||
<div class="pf-c-wizard__footer-cancel">
|
||||
<button
|
||||
class="pf-c-button pf-m-link"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
this.open = false;
|
||||
const firstPage = this.querySelector<WizardPage>(
|
||||
`[slot=${this.steps[0]}]`,
|
||||
);
|
||||
if (firstPage) {
|
||||
this.currentStep = firstPage;
|
||||
}
|
||||
}}
|
||||
>
|
||||
${t`Cancel`}
|
||||
</button>
|
||||
</div>
|
||||
${this.canCancel
|
||||
? html`<div class="pf-c-wizard__footer-cancel">
|
||||
<button
|
||||
class="pf-c-button pf-m-link"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
this.reset();
|
||||
}}
|
||||
>
|
||||
${t`Cancel`}
|
||||
</button>
|
||||
</div>`
|
||||
: html``}
|
||||
</footer>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.open = false;
|
||||
this.querySelectorAll("[data-wizardmanaged=true]").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
this.steps = this._initialSteps;
|
||||
this.actions = [];
|
||||
this.state = {};
|
||||
this.currentStep = undefined;
|
||||
this.canBack = true;
|
||||
this.canCancel = true;
|
||||
}
|
||||
}
|
||||
|
||||
85
web/src/elements/wizard/WizardFormPage.ts
Normal file
85
web/src/elements/wizard/WizardFormPage.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import AKGlobal from "../../authentik.css";
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { Form, KeyUnknown } from "../forms/Form";
|
||||
import { WizardPage } from "./WizardPage";
|
||||
|
||||
@customElement("ak-wizard-form")
|
||||
export class WizardForm extends Form<KeyUnknown> {
|
||||
viewportCheck = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
nextDataCallback!: (data: KeyUnknown) => Promise<boolean>;
|
||||
|
||||
submit(): Promise<boolean> | undefined {
|
||||
const data = this.serializeForm();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const files = this.getFormFiles();
|
||||
const finalData = Object.assign({}, data, files);
|
||||
return this.nextDataCallback(finalData);
|
||||
}
|
||||
}
|
||||
|
||||
export class WizardFormPage extends WizardPage {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFCard, PFButton, PFForm, PFAlert, PFInputGroup, PFFormControl, AKGlobal];
|
||||
}
|
||||
|
||||
inputCallback(): void {
|
||||
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const state = form.checkValidity();
|
||||
this.host.isValid = state;
|
||||
}
|
||||
|
||||
nextCallback = async (): Promise<boolean> => {
|
||||
const form = this.shadowRoot?.querySelector<WizardForm>("ak-wizard-form");
|
||||
if (!form) {
|
||||
console.warn("authentik/wizard: could not find form element");
|
||||
return false;
|
||||
}
|
||||
const response = await form.submit();
|
||||
if (response === undefined) {
|
||||
return false;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
nextDataCallback: (data: KeyUnknown) => Promise<boolean> = async (data): Promise<boolean> => {
|
||||
return false;
|
||||
};
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html``;
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.inputCallback();
|
||||
this.host.isValid = false;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<ak-wizard-form
|
||||
.nextDataCallback=${this.nextDataCallback}
|
||||
@input=${() => this.inputCallback()}
|
||||
>
|
||||
${this.renderForm()}
|
||||
</ak-wizard-form>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@ -1,28 +1,31 @@
|
||||
import { LitElement, PropertyDeclaration, TemplateResult, html } from "lit";
|
||||
import { CSSResult, LitElement, PropertyDeclaration, TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import AKGlobal from "../../authentik.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { Wizard } from "./Wizard";
|
||||
|
||||
@customElement("ak-wizard-page")
|
||||
export class WizardPage extends LitElement {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, AKGlobal];
|
||||
}
|
||||
|
||||
@property()
|
||||
sidebarLabel: () => string = () => {
|
||||
return "UNNAMED";
|
||||
};
|
||||
|
||||
isValid(): boolean {
|
||||
return this._isValid;
|
||||
}
|
||||
|
||||
get host(): Wizard {
|
||||
return this.parentElement as Wizard;
|
||||
}
|
||||
|
||||
_isValid = false;
|
||||
|
||||
activeCallback: () => Promise<void> = () => {
|
||||
this.host.isValid = false;
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
nextCallback: () => Promise<boolean> = async () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user