web: Normalize client-side error handling (#13595)
web: Clean up error handling. Prep for permission checks. - Add clearer reporting for API and network errors. - Tidy error checking. - Partial type safety for events.
This commit is contained in:
@ -47,7 +47,7 @@ export class ActionButton extends BaseTaskButton {
|
||||
const message = error instanceof Error ? error.toString() : await error.text();
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ export class TokenCopyButton extends BaseTaskButton {
|
||||
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { EVENT_REFRESH, EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
||||
import {
|
||||
APIError,
|
||||
parseAPIResponseError,
|
||||
pluckErrorDetail,
|
||||
} from "@goauthentik/common/errors/network";
|
||||
import { getRelativeTime } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
@ -23,7 +28,7 @@ import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { ResponseError, UiThemeEnum } from "@goauthentik/api";
|
||||
import { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
Chart.register(Legend, Tooltip);
|
||||
Chart.register(LineController, BarController, DoughnutController);
|
||||
@ -67,7 +72,7 @@ export abstract class AKChart<T> extends AKElement {
|
||||
chart?: Chart;
|
||||
|
||||
@state()
|
||||
error?: ResponseError;
|
||||
error?: APIError;
|
||||
|
||||
@property()
|
||||
centerText?: string;
|
||||
@ -79,6 +84,9 @@ export abstract class AKChart<T> extends AKElement {
|
||||
css`
|
||||
.container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@ -92,6 +100,7 @@ export abstract class AKChart<T> extends AKElement {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
z-index: 1;
|
||||
cursor: crosshair;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@ -136,19 +145,24 @@ export abstract class AKChart<T> extends AKElement {
|
||||
this.apiRequest()
|
||||
.then((r) => {
|
||||
const canvas = this.shadowRoot?.querySelector<HTMLCanvasElement>("canvas");
|
||||
|
||||
if (!canvas) {
|
||||
console.warn("Failed to get canvas element");
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
console.warn("failed to get 2d context");
|
||||
return;
|
||||
}
|
||||
|
||||
this.chart = this.configureChart(r, ctx);
|
||||
})
|
||||
.catch((exc: ResponseError) => {
|
||||
this.error = exc;
|
||||
.catch(async (error: unknown) => {
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
this.error = parsedError;
|
||||
});
|
||||
}
|
||||
|
||||
@ -214,7 +228,7 @@ export abstract class AKChart<T> extends AKElement {
|
||||
${this.error
|
||||
? html`
|
||||
<ak-empty-state header="${msg("Failed to fetch data.")}" icon="fa-times">
|
||||
<p slot="body">${this.error.response.statusText}</p>
|
||||
<p slot="body">${pluckErrorDetail(this.error)}</p>
|
||||
</ak-empty-state>
|
||||
`
|
||||
: html`${this.chart
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
@ -33,9 +34,9 @@ export class ConfirmationForm extends ModalButton {
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
this.onError(e);
|
||||
throw e;
|
||||
.catch(async (error: unknown) => {
|
||||
await this.onError(error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@ -46,10 +47,12 @@ export class ConfirmationForm extends ModalButton {
|
||||
});
|
||||
}
|
||||
|
||||
onError(e: Error): void {
|
||||
showMessage({
|
||||
message: msg(str`${this.errorMessage}: ${e.toString()}`),
|
||||
level: MessageLevel.error,
|
||||
onError(error: unknown): Promise<void> {
|
||||
return parseAPIResponseError(error).then((parsedError) => {
|
||||
showMessage({
|
||||
message: msg(str`${this.errorMessage}: ${pluckErrorDetail(parsedError)}`),
|
||||
level: MessageLevel.error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
@ -36,6 +37,7 @@ export class DeleteForm extends ModalButton {
|
||||
.then(() => {
|
||||
this.onSuccess();
|
||||
this.open = false;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
@ -43,9 +45,10 @@ export class DeleteForm extends ModalButton {
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
this.onError(e);
|
||||
throw e;
|
||||
.catch(async (error: unknown) => {
|
||||
await this.onError(error);
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@ -56,10 +59,14 @@ export class DeleteForm extends ModalButton {
|
||||
});
|
||||
}
|
||||
|
||||
onError(e: Error): void {
|
||||
showMessage({
|
||||
message: msg(str`Failed to delete ${this.objectLabel}: ${e.toString()}`),
|
||||
level: MessageLevel.error,
|
||||
onError(error: unknown): Promise<void> {
|
||||
return parseAPIResponseError(error).then((parsedError) => {
|
||||
showMessage({
|
||||
message: msg(
|
||||
str`Failed to delete ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
|
||||
),
|
||||
level: MessageLevel.error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIError } from "@goauthentik/common/errors";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
@ -20,13 +20,7 @@ import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-gro
|
||||
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { ResponseError, ValidationError, instanceOfValidationError } from "@goauthentik/api";
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(public response: ValidationError) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
import { instanceOfValidationError } from "@goauthentik/api";
|
||||
|
||||
export interface KeyUnknown {
|
||||
[key: string]: unknown;
|
||||
@ -285,73 +279,82 @@ export abstract class Form<T> extends AKElement {
|
||||
* field-levels errors to the fields, and send the rest of them to the Notifications.
|
||||
*
|
||||
*/
|
||||
async submit(ev: Event): Promise<unknown | undefined> {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const data = this.serializeForm();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const response = await this.send(data);
|
||||
showMessage({
|
||||
level: MessageLevel.success,
|
||||
message: this.getSuccessMessage(),
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
} catch (ex) {
|
||||
if (ex instanceof ResponseError) {
|
||||
let errorMessage = ex.response.statusText;
|
||||
const error = await parseAPIError(ex);
|
||||
if (instanceOfValidationError(error)) {
|
||||
async submit(event: Event): Promise<unknown | undefined> {
|
||||
event.preventDefault();
|
||||
|
||||
const data = this.serializeForm();
|
||||
if (!data) return;
|
||||
|
||||
return this.send(data)
|
||||
.then((response) => {
|
||||
showMessage({
|
||||
level: MessageLevel.success,
|
||||
message: this.getSuccessMessage(),
|
||||
});
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
if (error instanceof PreventFormSubmit && error.element) {
|
||||
error.element.errorMessages = [error.message];
|
||||
error.element.invalid = true;
|
||||
}
|
||||
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
let errorMessage = pluckErrorDetail(error);
|
||||
|
||||
if (instanceOfValidationError(parsedError)) {
|
||||
// assign all input-related errors to their elements
|
||||
const elements =
|
||||
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
|
||||
"ak-form-element-horizontal",
|
||||
) || [];
|
||||
|
||||
elements.forEach((element) => {
|
||||
element.requestUpdate();
|
||||
|
||||
const elementName = element.name;
|
||||
if (!elementName) {
|
||||
return;
|
||||
}
|
||||
if (camelToSnake(elementName) in error) {
|
||||
element.errorMessages = (error as ValidationError)[
|
||||
camelToSnake(elementName)
|
||||
];
|
||||
if (!elementName) return;
|
||||
|
||||
const snakeProperty = camelToSnake(elementName);
|
||||
|
||||
if (snakeProperty in parsedError) {
|
||||
element.errorMessages = parsedError[snakeProperty];
|
||||
element.invalid = true;
|
||||
} else {
|
||||
element.errorMessages = [];
|
||||
element.invalid = false;
|
||||
}
|
||||
});
|
||||
if ((error as ValidationError).nonFieldErrors) {
|
||||
this.nonFieldErrors = (error as ValidationError).nonFieldErrors;
|
||||
|
||||
if (parsedError.nonFieldErrors) {
|
||||
this.nonFieldErrors = parsedError.nonFieldErrors;
|
||||
}
|
||||
|
||||
errorMessage = msg("Invalid update request.");
|
||||
|
||||
// Only change the message when we have `detail`.
|
||||
// Everything else is handled in the form.
|
||||
if ("detail" in (error as ValidationError)) {
|
||||
errorMessage = (error as ValidationError).detail;
|
||||
if ("detail" in parsedError) {
|
||||
errorMessage = parsedError.detail;
|
||||
}
|
||||
}
|
||||
|
||||
showMessage({
|
||||
message: errorMessage,
|
||||
level: MessageLevel.error,
|
||||
});
|
||||
}
|
||||
if (ex instanceof PreventFormSubmit && ex.element) {
|
||||
ex.element.errorMessages = [ex.message];
|
||||
ex.element.invalid = true;
|
||||
}
|
||||
// rethrow the error so the form doesn't close
|
||||
throw ex;
|
||||
}
|
||||
|
||||
// Rethrow the error so the form doesn't close.
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
renderFormWrapper(): TemplateResult {
|
||||
|
||||
@ -47,10 +47,11 @@ export class ModalForm extends ModalButton {
|
||||
this.loading = false;
|
||||
this.locked = false;
|
||||
})
|
||||
.catch((exc) => {
|
||||
.catch((error: unknown) => {
|
||||
this.loading = false;
|
||||
this.locked = false;
|
||||
throw exc;
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
|
||||
import {
|
||||
APIError,
|
||||
parseAPIResponseError,
|
||||
pluckErrorDetail,
|
||||
} from "@goauthentik/common/errors/network";
|
||||
import { groupBy } from "@goauthentik/common/utils";
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
|
||||
@ -13,8 +17,6 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { ResponseError } from "@goauthentik/api";
|
||||
|
||||
import "./ak-search-select-loading-indicator.js";
|
||||
import "./ak-search-select-view.js";
|
||||
import { SearchSelectView } from "./ak-search-select-view.js";
|
||||
@ -99,7 +101,7 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
isFetchingData = false;
|
||||
|
||||
@state()
|
||||
error?: APIErrorTypes;
|
||||
error?: APIError;
|
||||
|
||||
public toForm(): string {
|
||||
if (!this.objects) {
|
||||
@ -128,23 +130,26 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
}
|
||||
this.isFetchingData = true;
|
||||
this.dispatchEvent(new Event("loading"));
|
||||
|
||||
return this.fetchObjects(this.query)
|
||||
.then((objects) => {
|
||||
objects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, objects || [])) {
|
||||
.then((nextObjects) => {
|
||||
nextObjects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, nextObjects || [])) {
|
||||
this.selectedObject = obj;
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
});
|
||||
this.objects = objects;
|
||||
|
||||
this.objects = nextObjects;
|
||||
this.isFetchingData = false;
|
||||
})
|
||||
.catch((exc: ResponseError) => {
|
||||
.catch(async (error: unknown) => {
|
||||
this.isFetchingData = false;
|
||||
this.objects = undefined;
|
||||
parseAPIError(exc).then((err) => {
|
||||
this.error = err;
|
||||
});
|
||||
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
|
||||
this.error = parsedError;
|
||||
});
|
||||
}
|
||||
|
||||
@ -233,7 +238,9 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
|
||||
public override render() {
|
||||
if (this.error) {
|
||||
return html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
|
||||
return html`<em
|
||||
>${msg("Failed to fetch objects: ")} ${pluckErrorDetail(this.error)}</em
|
||||
>`;
|
||||
}
|
||||
|
||||
// `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a
|
||||
|
||||
@ -11,17 +11,16 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
export interface APIMessage {
|
||||
level: MessageLevel;
|
||||
tags?: string;
|
||||
message: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const LEVEL_ICON_MAP: { [key: string]: string } = {
|
||||
const LEVEL_ICON_MAP = {
|
||||
error: "fas fa-exclamation-circle",
|
||||
warning: "fas fa-exclamation-triangle",
|
||||
success: "fas fa-check-circle",
|
||||
info: "fas fa-info",
|
||||
};
|
||||
} as const satisfies Record<MessageLevel, string>;
|
||||
|
||||
@customElement("ak-message")
|
||||
export class Message extends AKElement {
|
||||
|
||||
@ -3,7 +3,9 @@ import {
|
||||
EVENT_WS_MESSAGE,
|
||||
WS_MSG_TYPE_MESSAGE,
|
||||
} from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { APIError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||
import { WSMessage } from "@goauthentik/common/ws";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/messages/Message";
|
||||
@ -16,18 +18,63 @@ import { customElement, property } from "lit/decorators.js";
|
||||
import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { instanceOfValidationError } from "@goauthentik/api";
|
||||
|
||||
/**
|
||||
* Adds a message to the message container, displaying it to the user.
|
||||
* @param message The message to display.
|
||||
* @param unique Whether to only display the message if the title is unique.
|
||||
*/
|
||||
export function showMessage(message: APIMessage, unique = false): void {
|
||||
const container = document.querySelector<MessageContainer>("ak-message-container");
|
||||
|
||||
if (!container) {
|
||||
throw new SentryIgnoredError("failed to find message container");
|
||||
}
|
||||
if (message.message.trim() === "") {
|
||||
|
||||
if (!message.message.trim()) {
|
||||
message.message = msg("Error");
|
||||
}
|
||||
|
||||
container.addMessage(message, unique);
|
||||
container.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an API error, display the error(s) to the user.
|
||||
*
|
||||
* @param error The API error to display.
|
||||
* @param unique Whether to only display the message if the title is unique.
|
||||
* @see {@link parseAPIResponseError} for more information on how to handle API errors.
|
||||
*/
|
||||
export function showAPIErrorMessage(error: APIError, unique = false): void {
|
||||
if (
|
||||
instanceOfValidationError(error) &&
|
||||
Array.isArray(error.nonFieldErrors) &&
|
||||
error.nonFieldErrors.length
|
||||
) {
|
||||
for (const nonFieldError of error.nonFieldErrors) {
|
||||
showMessage(
|
||||
{
|
||||
level: MessageLevel.error,
|
||||
message: nonFieldError,
|
||||
},
|
||||
unique,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage(
|
||||
{
|
||||
level: MessageLevel.error,
|
||||
message: pluckErrorDetail(error),
|
||||
},
|
||||
unique,
|
||||
);
|
||||
}
|
||||
|
||||
@customElement("ak-message-container")
|
||||
export class MessageContainer extends AKElement {
|
||||
@property({ attribute: false })
|
||||
@ -48,10 +95,13 @@ export class MessageContainer extends AKElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
window.addEventListener(EVENT_WS_MESSAGE, ((e: CustomEvent<WSMessage>) => {
|
||||
if (e.detail.message_type !== WS_MSG_TYPE_MESSAGE) return;
|
||||
|
||||
this.addMessage(e.detail as unknown as APIMessage);
|
||||
}) as EventListener);
|
||||
|
||||
window.addEventListener(EVENT_MESSAGE, ((e: CustomEvent<APIMessage>) => {
|
||||
this.addMessage(e.detail);
|
||||
}) as EventListener);
|
||||
@ -59,20 +109,20 @@ export class MessageContainer extends AKElement {
|
||||
|
||||
addMessage(message: APIMessage, unique = false): void {
|
||||
if (unique) {
|
||||
const matchingMessages = this.messages.filter((m) => m.message == message.message);
|
||||
if (matchingMessages.length > 0) {
|
||||
return;
|
||||
}
|
||||
const matchIndex = this.messages.findIndex((m) => m.message === message.message);
|
||||
|
||||
if (matchIndex !== -1) return;
|
||||
}
|
||||
|
||||
this.messages.push(message);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<ul class="pf-c-alert-group pf-m-toast">
|
||||
${this.messages.map((m) => {
|
||||
${this.messages.map((message) => {
|
||||
return html`<ak-message
|
||||
.message=${m}
|
||||
.message=${message}
|
||||
.onRemove=${(m: APIMessage) => {
|
||||
this.messages = this.messages.filter((v) => v !== m);
|
||||
this.requestUpdate();
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
|
||||
import {
|
||||
APIError,
|
||||
parseAPIResponseError,
|
||||
pluckErrorDetail,
|
||||
} from "@goauthentik/common/errors/network";
|
||||
import { uiConfig } from "@goauthentik/common/ui/config";
|
||||
import { groupBy } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
@ -10,9 +14,10 @@ import "@goauthentik/elements/chips/ChipGroup";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import "@goauthentik/elements/table/TablePagination";
|
||||
import "@goauthentik/elements/table/TableSearch";
|
||||
import { SlottedTemplateResult } from "@goauthentik/elements/types";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
@ -26,7 +31,7 @@ import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";
|
||||
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { Pagination, ResponseError } from "@goauthentik/api";
|
||||
import { Pagination } from "@goauthentik/api";
|
||||
|
||||
export interface TableLike {
|
||||
order?: string;
|
||||
@ -98,7 +103,7 @@ export interface PaginatedResponse<T> {
|
||||
export abstract class Table<T> extends AKElement implements TableLike {
|
||||
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
|
||||
abstract columns(): TableColumn[];
|
||||
abstract row(item: T): TemplateResult[];
|
||||
abstract row(item: T): SlottedTemplateResult[];
|
||||
|
||||
private isLoading = false;
|
||||
|
||||
@ -106,12 +111,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
renderExpanded(item: T): TemplateResult {
|
||||
renderExpanded(_item: T): SlottedTemplateResult {
|
||||
if (this.expandable) {
|
||||
throw new Error("Expandable is enabled but renderExpanded is not overridden!");
|
||||
}
|
||||
return html``;
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
@ -120,10 +125,11 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
@property({ type: Number })
|
||||
page = getURLParam("tablePage", 1);
|
||||
|
||||
/** @prop
|
||||
/**
|
||||
* Set if your `selectedElements` use of the selection box is to enable bulk-delete,
|
||||
* so that stale data is cleared out when the API returns a new list minus the deleted entries.
|
||||
*
|
||||
* Set if your `selectedElements` use of the selection box is to enable bulk-delete, so that
|
||||
* stale data is cleared out when the API returns a new list minus the deleted entries.
|
||||
* @prop
|
||||
*/
|
||||
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
|
||||
clearOnRefresh = false;
|
||||
@ -162,7 +168,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
expandedElements: T[] = [];
|
||||
|
||||
@state()
|
||||
error?: APIErrorTypes;
|
||||
error?: APIError;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
@ -187,6 +193,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
.pf-c-toolbar__item .pf-c-input-group {
|
||||
padding: 0 var(--pf-global--spacer--sm);
|
||||
}
|
||||
|
||||
.pf-c-table {
|
||||
--pf-c-table--m-striped__tr--BackgroundColor: var(
|
||||
--pf-global--BackgroundColor--dark-300
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@ -213,64 +225,74 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
};
|
||||
}
|
||||
|
||||
public groupBy(items: T[]): [string, T[]][] {
|
||||
public groupBy(items: T[]): [SlottedTemplateResult, T[]][] {
|
||||
return groupBy(items, () => {
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
public async fetch(): Promise<void> {
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.data = await this.apiEndpoint();
|
||||
this.error = undefined;
|
||||
this.page = this.data.pagination.current;
|
||||
const newExpanded: T[] = [];
|
||||
this.data.results.forEach((res) => {
|
||||
const jsonRes = JSON.stringify(res);
|
||||
// So because we're dealing with complex objects here, we can't use indexOf
|
||||
// since it checks strict equality, and we also can't easily check in findIndex()
|
||||
// Instead we default to comparing the JSON of both objects, which is quite slow
|
||||
// Hence we check if the objects have `pk` attributes set (as most models do)
|
||||
// and compare that instead, which will be much faster.
|
||||
let comp = (item: T) => {
|
||||
return JSON.stringify(item) === jsonRes;
|
||||
};
|
||||
if (Object.hasOwn(res as object, "pk")) {
|
||||
comp = (item: T) => {
|
||||
return (
|
||||
(item as unknown as { pk: string | number }).pk ===
|
||||
(res as unknown as { pk: string | number }).pk
|
||||
);
|
||||
|
||||
return this.apiEndpoint()
|
||||
.then((data) => {
|
||||
this.data = data;
|
||||
this.error = undefined;
|
||||
|
||||
this.page = this.data.pagination.current;
|
||||
const newExpanded: T[] = [];
|
||||
|
||||
this.data.results.forEach((res) => {
|
||||
const jsonRes = JSON.stringify(res);
|
||||
// So because we're dealing with complex objects here, we can't use indexOf
|
||||
// since it checks strict equality, and we also can't easily check in findIndex()
|
||||
// Instead we default to comparing the JSON of both objects, which is quite slow
|
||||
// Hence we check if the objects have `pk` attributes set (as most models do)
|
||||
// and compare that instead, which will be much faster.
|
||||
let comp = (item: T) => {
|
||||
return JSON.stringify(item) === jsonRes;
|
||||
};
|
||||
}
|
||||
const expandedIndex = this.expandedElements.findIndex(comp);
|
||||
if (expandedIndex > -1) {
|
||||
newExpanded.push(res);
|
||||
}
|
||||
|
||||
if (Object.hasOwn(res as object, "pk")) {
|
||||
comp = (item: T) => {
|
||||
return (
|
||||
(item as unknown as { pk: string | number }).pk ===
|
||||
(res as unknown as { pk: string | number }).pk
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const expandedIndex = this.expandedElements.findIndex(comp);
|
||||
|
||||
if (expandedIndex > -1) {
|
||||
newExpanded.push(res);
|
||||
}
|
||||
});
|
||||
|
||||
this.expandedElements = newExpanded;
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
this.error = await parseAPIResponseError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false;
|
||||
this.requestUpdate();
|
||||
});
|
||||
this.isLoading = false;
|
||||
this.expandedElements = newExpanded;
|
||||
} catch (ex) {
|
||||
this.isLoading = false;
|
||||
this.error = await parseAPIError(ex as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private renderLoading(): TemplateResult {
|
||||
return html`<tr role="row">
|
||||
<td role="cell" colspan="25">
|
||||
<div class="pf-l-bullseye">
|
||||
<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>
|
||||
<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
renderEmpty(inner?: TemplateResult): TemplateResult {
|
||||
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
|
||||
return html`<tbody role="rowgroup">
|
||||
<tr role="row">
|
||||
<td role="cell" colspan="8">
|
||||
@ -285,18 +307,16 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
</tbody>`;
|
||||
}
|
||||
|
||||
renderObjectCreate(): TemplateResult {
|
||||
return html``;
|
||||
renderObjectCreate(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
renderError(): TemplateResult {
|
||||
return this.error
|
||||
? html`<ak-empty-state header="${msg("Failed to fetch objects.")}" icon="fa-times">
|
||||
${this.error instanceof ResponseError
|
||||
? html` <div slot="body">${this.error.message}</div> `
|
||||
: html`<div slot="body">${this.error.detail}</div>`}
|
||||
</ak-empty-state>`
|
||||
: html``;
|
||||
renderError(): SlottedTemplateResult {
|
||||
if (!this.error) return nothing;
|
||||
|
||||
return html`<ak-empty-state header="${msg("Failed to fetch objects.")}" icon="fa-ban">
|
||||
<div slot="body">${pluckErrorDetail(this.error)}</div>
|
||||
</ak-empty-state>`;
|
||||
}
|
||||
|
||||
private renderRows(): TemplateResult[] | undefined {
|
||||
@ -404,15 +424,17 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
}
|
||||
: itemSelectHandler}
|
||||
>
|
||||
${this.checkbox ? renderCheckbox() : html``}
|
||||
${this.expandable ? renderExpansion() : html``}
|
||||
${this.row(item).map((col) => {
|
||||
return html`<td role="cell">${col}</td>`;
|
||||
${this.checkbox ? renderCheckbox() : nothing}
|
||||
${this.expandable ? renderExpansion() : nothing}
|
||||
${this.row(item).map((column, columnIndex) => {
|
||||
return html`<td data-column-index="${columnIndex}" role="cell">
|
||||
${column}
|
||||
</td>`;
|
||||
})}
|
||||
</tr>
|
||||
<tr class="pf-c-table__expandable-row ${classMap(expandedClass)}" role="row">
|
||||
<td></td>
|
||||
${this.expandedElements.includes(item) ? this.renderExpanded(item) : html``}
|
||||
${this.expandedElements.includes(item) ? this.renderExpanded(item) : nothing}
|
||||
</tr>
|
||||
</tbody>`;
|
||||
});
|
||||
@ -430,12 +452,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
>`;
|
||||
}
|
||||
|
||||
renderToolbarSelected(): TemplateResult {
|
||||
return html``;
|
||||
renderToolbarSelected(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
renderToolbarAfter(): TemplateResult {
|
||||
return html``;
|
||||
renderToolbarAfter(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
renderSearch(): TemplateResult {
|
||||
@ -504,9 +526,9 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
* chip-based subtable at the top that shows the list of selected entries. Long text result in
|
||||
* ellipsized chips, which is sub-optimal.
|
||||
*/
|
||||
renderSelectedChip(_item: T): TemplateResult {
|
||||
renderSelectedChip(_item: T): SlottedTemplateResult {
|
||||
// Override this for chip-based displays
|
||||
return html``;
|
||||
return nothing;
|
||||
}
|
||||
|
||||
get needChipGroup() {
|
||||
@ -547,7 +569,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
${this.renderToolbarContainer()}
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<tr role="row" class="pf-c-table__header-row">
|
||||
${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``}
|
||||
${this.expandable ? html`<td role="cell"></td>` : html``}
|
||||
${this.columns().map((col) => col.render(this))}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import "@goauthentik/elements/Spinner";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
@ -37,10 +38,14 @@ export class SourceSettingsOAuth extends BaseUserSettings {
|
||||
message: msg("Successfully disconnected source"),
|
||||
});
|
||||
})
|
||||
.catch((exc) => {
|
||||
.catch(async (error: unknown) => {
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: msg(str`Failed to disconnected source: ${exc}`),
|
||||
message: msg(
|
||||
str`Failed to disconnected source: ${pluckErrorDetail(parsedError)}`,
|
||||
),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { PlexAPIClient, popupCenterScreen } from "@goauthentik/common/helpers/plex";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import "@goauthentik/elements/Spinner";
|
||||
@ -58,10 +59,13 @@ export class SourceSettingsPlex extends BaseUserSettings {
|
||||
message: msg("Successfully disconnected source"),
|
||||
});
|
||||
})
|
||||
.catch((exc) => {
|
||||
.catch(async (error: unknown) => {
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: msg(str`Failed to disconnected source: ${exc}`),
|
||||
message: msg(
|
||||
str`Failed to disconnected source: ${pluckErrorDetail(parsedError)}`,
|
||||
),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import "@goauthentik/elements/Spinner";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
@ -37,10 +38,14 @@ export class SourceSettingsSAML extends BaseUserSettings {
|
||||
message: msg("Successfully disconnected source"),
|
||||
});
|
||||
})
|
||||
.catch((exc) => {
|
||||
.catch(async (error: unknown) => {
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: msg(str`Failed to disconnected source: ${exc}`),
|
||||
message: msg(
|
||||
str`Failed to disconnected source: ${pluckErrorDetail(parsedError)}`,
|
||||
),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@ -19,24 +19,27 @@ export class FormWizardPage extends WizardPage {
|
||||
this.activePageCallback(this);
|
||||
};
|
||||
|
||||
nextCallback = async () => {
|
||||
nextCallback = async (): Promise<boolean> => {
|
||||
const form = this.querySelector<Form<unknown>>("*");
|
||||
|
||||
if (!form) {
|
||||
return Promise.reject(msg("No form found"));
|
||||
}
|
||||
|
||||
const formPromise = form.submit(new Event("submit"));
|
||||
|
||||
if (!formPromise) {
|
||||
return Promise.reject(msg("Form didn't return a promise for submitting"));
|
||||
}
|
||||
|
||||
return formPromise
|
||||
.then((data) => {
|
||||
this.host.state[this.slot] = data;
|
||||
this.host.canBack = false;
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
return false;
|
||||
});
|
||||
.catch(() => false);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user