Skip to content

Commit

Permalink
Merge pull request #156 from mblajek/FZ-108-tquery-filter-errors
Browse files Browse the repository at this point in the history
FZ-108 Improved the handling of tquery table filter errors.
  • Loading branch information
TPReal authored Nov 15, 2023
2 parents 323303c + 5e8e486 commit 3484a95
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 60 deletions.
22 changes: 16 additions & 6 deletions resources/js/components/ui/Table/TQueryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {FilterH} from "data-access/memo-api/tquery/filter_utils";
import {ColumnConfig, createTableRequestCreator, tableHelper} from "data-access/memo-api/tquery/table";
import {createTQuery} from "data-access/memo-api/tquery/tquery";
import {BasicColumnSchema, ColumnName, ColumnType, DataItem} from "data-access/memo-api/tquery/types";
import {JSX, VoidComponent, createMemo} from "solid-js";
import {JSX, VoidComponent, createEffect, createMemo} from "solid-js";
import toast from "solid-toast";
import {
DisplayMode,
Header,
Expand All @@ -19,6 +20,7 @@ import {
getBaseTableOptions,
useTableCells,
} from ".";
import {toastMessages} from "../../utils/toast";
import {ColumnFilterController, FilteringParams} from "./tquery_filters/ColumnFilterController";

declare module "@tanstack/table-core" {
Expand Down Expand Up @@ -136,15 +138,24 @@ export const TQueryTable: VoidComponent<TQueryTableProps> = (props) => {
props.initialPageSize ||
(props.mode === "standalone" ? DEFAULT_STANDALONE_PAGE_SIZE : DEFAULT_EMBEDDED_PAGE_SIZE),
});
const {schema, requestController, dataQuery, data} = createTQuery({
const {schema, requestController, dataQuery} = createTQuery({
entityURL,
prefixQueryKey: props.staticPrefixQueryKey,
requestCreator,
dataQueryOptions: {meta: {tquery: {isTable: true}}},
});
const {columnVisibility, globalFilter, columnFilter, sorting, pagination} = requestController;
const {rowsCount, pageCount, scrollToTopSignal} = tableHelper({
const {rowsCount, pageCount, scrollToTopSignal, filterErrors} = tableHelper({
requestController,
response: () => dataQuery.data,
dataQuery,
translations: props.staticTranslations,
});
createEffect(() => {
const errors = filterErrors()?.values();
if (errors) {
// TODO: Consider showing the errors in the table header.
toastMessages([...errors], toast.error);
}
});

const columns = createMemo(() => {
Expand Down Expand Up @@ -195,8 +206,7 @@ export const TQueryTable: VoidComponent<TQueryTableProps> = (props) => {
const table = createSolidTable<DataItem>({
...getBaseTableOptions<DataItem>({features: {columnVisibility, sorting, globalFilter, pagination}}),
get data() {
// Remove readonly from the type.
return data() as DataItem[];
return (dataQuery.data?.data as DataItem[]) || [];
},
get columns() {
return columns();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {cx} from "components/utils";
import {cx, debouncedAccessor} from "components/utils";
import {NullColumnFilter, UuidColumnFilter} from "data-access/memo-api/tquery/types";
import {createComputed, createSignal} from "solid-js";
import s from "./ColumnFilterController.module.scss";
import {FilterControl} from "./types";

const UUID_LENGTH = 36;

export const UuidFilterControl: FilterControl<NullColumnFilter | UuidColumnFilter> = (props) => {
const [value, setValue] = createSignal("");
createComputed(() => {
Expand All @@ -24,6 +26,11 @@ export const UuidFilterControl: FilterControl<NullColumnFilter | UuidColumnFilte
return {type: "column", column: props.name, op: "=", val: value};
}
}
// eslint-disable-next-line solid/reactivity
const debouncedValue = debouncedAccessor(value, {
outputImmediately: (v) => !v || v === "*" || v === "''" || v.length === UUID_LENGTH,
});
createComputed(() => props.setFilter(buildFilter(debouncedValue())));
return (
<div class={s.filterLine}>
<div class={cx(s.wideEdit, "min-h-small-input flex items-baseline")}>
Expand All @@ -35,10 +42,7 @@ export const UuidFilterControl: FilterControl<NullColumnFilter | UuidColumnFilte
class="h-full w-full border border-input-border rounded"
style={{"font-family": "monospace"}}
value={value()}
onInput={({target: {value}}) => {
setValue(value);
props.setFilter(buildFilter(value));
}}
onInput={({target: {value}}) => setValue(value)}
/>
</div>
</div>
Expand Down
56 changes: 38 additions & 18 deletions resources/js/components/utils/InitializeTanstackQuery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,36 @@ import {
type QueryMeta,
} from "@tanstack/solid-query";
import {isAxiosError} from "axios";
import {translateError} from "data-access/memo-api/error_util";
import {System, User} from "data-access/memo-api/groups";
import {SolidQueryOpts} from "data-access/memo-api/query_utils";
import {isFilterValError} from "data-access/memo-api/tquery/table";
import {Api} from "data-access/memo-api/types";
import {translationsLoaded, translationsLoadedPromise} from "i18n_loader";
import {For, ParentComponent, Show, VoidComponent, createMemo, createSignal} from "solid-js";
import {ParentComponent, Show, VoidComponent, createMemo, createSignal} from "solid-js";
import toast from "solid-toast";
import {cx, useLangFunc} from ".";
import {useLangFunc} from ".";
import {MemoLoader} from "../ui/MemoLoader";
import {toastMessages} from "./toast";

/** A list of HTTP response status codes for which a toast should not be displayed. */
type QuietHTTPStatuses = number[];

declare module "@tanstack/query-core" {
interface QueryMeta {
quietHTTPStatuses?: QuietHTTPStatuses;
tquery?: TQueryMeta;
}
interface MutationMeta {
quietHTTPStatuses?: QuietHTTPStatuses;
isFormSubmit?: boolean;
}
}

export interface TQueryMeta {
isTable?: boolean;
}

/**
* Tanstack/solid-query initialization component
*
Expand All @@ -42,31 +50,43 @@ export const InitializeTanstackQuery: ParentComponent = (props) => {
const status = error.response?.status;
if (!status || !meta?.quietHTTPStatuses?.includes(status)) {
const respErrors = error.response?.data.errors;
const errors = meta?.isFormSubmit
? // Validation errors will be handled by the form.
respErrors?.filter((e) => !Api.isValidationError(e))
: respErrors;
if (errors?.length) {
function getErrorsToShow() {
if (!respErrors) {
return [];
}
if (meta?.isFormSubmit) {
// Validation errors will be handled by the form.
return respErrors.filter((e) => !Api.isValidationError(e));
} else if (meta?.tquery?.isTable) {
// Table filter value errors will be handled by the table.
/**
* A list of serious errors, not caused by bad filter val. Excludes also the exception.validation error
* which might be caused by just the filter val errors.
*/
const seriousErrors = respErrors.filter((e) => e.code !== "exception.validation" && !isFilterValError(e));
if (seriousErrors.length) {
// Include the exception.validation error again.
return respErrors.filter((e) => !isFilterValError(e));
} else {
return [];
}
} else {
return respErrors;
}
}
const errors = getErrorsToShow();
if (errors.length) {
if (!translationsLoaded()) {
for (const e of errors) {
console.warn("Error toast shown (translations not ready):", e);
}
}
translationsLoadedPromise.then(() => {
const messages = errors.map((e) => {
return t(e.code, {
...(Api.isValidationError(e) ? {attribute: e.field} : undefined),
...e.data,
});
});
const messages = errors.map((e) => translateError(e, t));
for (const msg of messages) {
console.warn(`Error toast shown: ${msg}`);
}
toast.error(() => (
<ul class={cx({"list-disc pl-6": messages.length > 1}, "wrapText")}>
<For each={messages}>{(msg) => <li>{msg}</li>}</For>
</ul>
));
toastMessages(messages, toast.error);
});
}
}
Expand Down
13 changes: 13 additions & 0 deletions resources/js/components/utils/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {For} from "solid-js";
import toast from "solid-toast";
import {cx} from "./classnames";

export function toastMessages(messages: string[], toastFunction = toast.success) {
if (messages.length) {
toastFunction(() => (
<ul class={cx({"list-disc pl-6": messages.length > 1}, "wrapText")}>
<For each={messages}>{(msg) => <li>{msg}</li>}</For>
</ul>
));
}
}
9 changes: 9 additions & 0 deletions resources/js/data-access/memo-api/error_util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {useLangFunc} from "components/utils";
import {Api} from "./types";

export function translateError(error: Api.Error, t = useLangFunc()) {
return t(error.code, {
...(Api.isValidationError(error) ? {attribute: error.field} : undefined),
...error.data,
});
}
9 changes: 6 additions & 3 deletions resources/js/data-access/memo-api/query_utils.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
/**
* @fileoverview Simplified types for use with TanStack Query. They assume that
* TError = Api.Error and TData = TQueryFnData, which is the common case.
* TError = AxiosError<Api.ErrorResponse> and TData = TQueryFnData, which is the common case.
*/

import {CreateQueryOptions, QueryKey, SolidQueryOptions} from "@tanstack/solid-query";
import {AxiosError} from "axios";
import {Api} from "./types";

export type QueryError = AxiosError<Api.ErrorResponse>;

export type SolidQueryOpts<DataType, QueryKeyType extends QueryKey = QueryKey> = SolidQueryOptions<
DataType,
Api.Error,
QueryError,
DataType,
QueryKeyType
>;

export type CreateQueryOpts<DataType, QueryKeyType extends QueryKey = QueryKey> = CreateQueryOptions<
DataType,
Api.Error,
QueryError,
DataType,
QueryKeyType
>;
89 changes: 83 additions & 6 deletions resources/js/data-access/memo-api/tquery/table.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import {CreateQueryResult} from "@tanstack/solid-query";
import {PaginationState, SortingState, VisibilityState} from "@tanstack/solid-table";
import {AxiosError} from "axios";
import {TableTranslations} from "components/ui/Table";
import {FuzzyGlobalFilterConfig, buildFuzzyGlobalFilter} from "components/ui/Table/tquery_filters/fuzzy_filter";
import {NON_NULLABLE, debouncedFilterTextAccessor} from "components/utils";
import {NON_NULLABLE, debouncedFilterTextAccessor, useLangFunc} from "components/utils";
import {Accessor, Signal, createComputed, createMemo, createSignal, on} from "solid-js";
import {translateError} from "../error_util";
import {Api} from "../types";
import {FilterH, FilterReductor} from "./filter_utils";
import {RequestCreator} from "./tquery";
import {Column, ColumnName, DataRequest, DataResponse} from "./types";
import {Column, ColumnName, DataRequest, DataResponse, Filter} from "./types";

export interface ColumnConfig {
readonly name: string;
Expand Down Expand Up @@ -169,19 +174,91 @@ interface TableHelperInterface {
pageCount: Accessor<number>;
/** A signal that changes whenever the table needs to be scrolled back to top. */
scrollToTopSignal: Accessor<unknown>;
/**
* A map of column filter errors resulting from bad input values, that the table
* should display somewhere. The key is the column name, or `"?"` for error(s) that
* could not be attributed to a specific column.
*/
filterErrors: Accessor<Map<ColumnName | typeof UNRECOGNIZED_FIELD_KEY, string> | undefined>;
}

export const UNRECOGNIZED_FIELD_KEY = "?";

const FILTER_VAL_ERROR_REGEX = /^filter\.(|.+?\.)val$/;

/**
* Checks whether the error is a validation error on a filter value. This kind of error is
* typically caused by user entering e.g. bad regex or bad UUID, not by a bug.
*
* The filter val errors are not automatically displayed as toasts, but are instead handled
* by the TQueryTable component (see tableHelper).
*/
export function isFilterValError(error: Api.Error): error is Api.ValidationError {
return Api.isValidationError(error) && FILTER_VAL_ERROR_REGEX.test(error.field);
}

export function tableHelper({
requestController,
response,
dataQuery,
translations,
}: {
requestController: RequestController;
response: Accessor<DataResponse | undefined>;
dataQuery: CreateQueryResult<DataResponse, AxiosError<Api.ErrorResponse>>;
translations?: TableTranslations;
}): TableHelperInterface {
const rowsCount = () => response()?.meta.totalDataSize;
const t = useLangFunc();
const rowsCount = () => dataQuery.data?.meta.totalDataSize;
const pageCount = createMemo(() =>
Math.ceil(Math.max(rowsCount() || 0, 1) / requestController.pagination[0]().pageSize),
);
const scrollToTopSignal = () => requestController.pagination[0]().pageIndex;
return {rowsCount, pageCount, scrollToTopSignal};
const filterErrors = createMemo(() => {
if (!dataQuery.error) {
return undefined;
}
const dataRequest: DataRequest = JSON.parse(dataQuery.error.config?.data || null);
const filterErrors = new Map<ColumnName, string>();
for (const e of dataQuery.error.response?.data.errors || []) {
if (isFilterValError(e)) {
// Other kinds of errors are already handled at a higher level.
// Find the problematic filter. The value of e.field will be something like `filter.val.0.val`,
// so traverse that path in the request object, minus the last `val` part.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let val: any = dataRequest;
for (const part of e.field.split(".").slice(0, -1)) {
if (val && typeof val === "object") {
val = val[part];
} else {
break;
}
}
if (val && typeof val === "object") {
const leafFilter: Filter = val;
if (leafFilter.type === "column") {
const translatedColumnName = translations?.columnNames(leafFilter.column) || leafFilter.column;
filterErrors.set(
leafFilter.column,
translateError({
...e,
field: t("tables.filter.filter_for", {data: translatedColumnName}),
}),
);
continue;
}
}
// The correct leaf column filter could not be determined. Just put the error in the `"?"` key.
filterErrors.set(
UNRECOGNIZED_FIELD_KEY,
[filterErrors.get(UNRECOGNIZED_FIELD_KEY), translateError(e)].filter(NON_NULLABLE).join("\n"),
);
}
}
return filterErrors.size ? filterErrors : undefined;
});
return {
rowsCount,
pageCount,
scrollToTopSignal,
filterErrors,
};
}
Loading

0 comments on commit 3484a95

Please sign in to comment.