From 5785685ddff637bfc810b88968845af514ba1da7 Mon Sep 17 00:00:00 2001 From: TPReal Date: Mon, 30 Oct 2023 23:35:48 +0100 Subject: [PATCH 1/3] FZ-108 Improved the handling of tquery table filter errors. For errors that result from bad user input, a more helpful information containing the column name is retrieved and displayed (as a toast for now). --- .../js/components/ui/Table/TQueryTable.tsx | 20 +++-- .../tquery_filters/UuidFilterControl.tsx | 12 +-- .../utils/InitializeTanstackQuery.tsx | 56 +++++++++---- resources/js/components/utils/toast.tsx | 13 +++ .../js/data-access/memo-api/error_util.ts | 9 ++ .../js/data-access/memo-api/query_utils.ts | 9 +- .../js/data-access/memo-api/tquery/table.ts | 82 +++++++++++++++++-- .../js/data-access/memo-api/tquery/tquery.ts | 37 ++++----- resources/lang/pl_PL/tables.yml | 1 + 9 files changed, 180 insertions(+), 59 deletions(-) create mode 100644 resources/js/components/utils/toast.tsx create mode 100644 resources/js/data-access/memo-api/error_util.ts diff --git a/resources/js/components/ui/Table/TQueryTable.tsx b/resources/js/components/ui/Table/TQueryTable.tsx index e579ad507..798915b8c 100644 --- a/resources/js/components/ui/Table/TQueryTable.tsx +++ b/resources/js/components/ui/Table/TQueryTable.tsx @@ -11,7 +11,8 @@ import {FilterH} from "data-access/memo-api/tquery/filter_utils"; import {createTableRequestCreator, tableHelper} from "data-access/memo-api/tquery/table"; import {createTQuery} from "data-access/memo-api/tquery/tquery"; import {ColumnType, DataItem, isDataType} 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, @@ -27,6 +28,7 @@ import { getBaseTableOptions, useTableCells, } from "."; +import {toastMessages} from "../../utils/toast"; import {ColumnFilterController, FilteringParams} from "./tquery_filters/ColumnFilterController"; export interface ColumnOptions { @@ -128,15 +130,23 @@ export const TQueryTable: VoidComponent = (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, + }); + createEffect(() => { + const errors = filterErrors()?.values(); + if (errors) { + // TODO: Consider showing the errors in the table header. + toastMessages([...errors], toast.error); + } }); const h = createColumnHelper(); @@ -230,7 +240,7 @@ export const TQueryTable: VoidComponent = (props) => { const table = createSolidTable({ ...getBaseTableOptions({features: {columnVisibility, sorting, globalFilter, pagination}}), get data() { - return data(); + return dataQuery.data?.data || []; }, get columns() { return columns(); diff --git a/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx b/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx index 3c197d48c..df451d188 100644 --- a/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx +++ b/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx @@ -1,4 +1,4 @@ -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"; @@ -24,6 +24,11 @@ export const UuidFilterControl: FilterControl props.setFilter(buildFilter(debouncedValue()))); return (
@@ -35,10 +40,7 @@ export const UuidFilterControl: FilterControl { - setValue(value); - props.setFilter(buildFilter(value)); - }} + onInput={({target: {value}}) => setValue(value)} />
diff --git a/resources/js/components/utils/InitializeTanstackQuery.tsx b/resources/js/components/utils/InitializeTanstackQuery.tsx index 079113c93..6025cb10b 100644 --- a/resources/js/components/utils/InitializeTanstackQuery.tsx +++ b/resources/js/components/utils/InitializeTanstackQuery.tsx @@ -8,14 +8,17 @@ 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[]; @@ -23,6 +26,7 @@ type QuietHTTPStatuses = number[]; declare module "@tanstack/query-core" { interface QueryMeta { quietHTTPStatuses?: QuietHTTPStatuses; + tquery?: TQueryMeta; } interface MutationMeta { quietHTTPStatuses?: QuietHTTPStatuses; @@ -30,6 +34,10 @@ declare module "@tanstack/query-core" { } } +export interface TQueryMeta { + isTable?: boolean; +} + /** * Tanstack/solid-query initialization component * @@ -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(() => ( -
    1}, "wrapText")}> - {(msg) =>
  • {msg}
  • }
    -
- )); + toastMessages(messages, toast.error); }); } } diff --git a/resources/js/components/utils/toast.tsx b/resources/js/components/utils/toast.tsx new file mode 100644 index 000000000..efb6eb61b --- /dev/null +++ b/resources/js/components/utils/toast.tsx @@ -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(() => ( +
    1}, "wrapText")}> + {(msg) =>
  • {msg}
  • }
    +
+ )); + } +} diff --git a/resources/js/data-access/memo-api/error_util.ts b/resources/js/data-access/memo-api/error_util.ts new file mode 100644 index 000000000..6ba538fe6 --- /dev/null +++ b/resources/js/data-access/memo-api/error_util.ts @@ -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, + }); +} diff --git a/resources/js/data-access/memo-api/query_utils.ts b/resources/js/data-access/memo-api/query_utils.ts index efbbd7277..e2c625f24 100644 --- a/resources/js/data-access/memo-api/query_utils.ts +++ b/resources/js/data-access/memo-api/query_utils.ts @@ -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 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; + export type SolidQueryOpts = SolidQueryOptions< DataType, - Api.Error, + QueryError, DataType, QueryKeyType >; export type CreateQueryOpts = CreateQueryOptions< DataType, - Api.Error, + QueryError, DataType, QueryKeyType >; diff --git a/resources/js/data-access/memo-api/tquery/table.ts b/resources/js/data-access/memo-api/tquery/table.ts index aff47e3dc..2b8ff1bbe 100644 --- a/resources/js/data-access/memo-api/tquery/table.ts +++ b/resources/js/data-access/memo-api/tquery/table.ts @@ -1,10 +1,14 @@ +import {CreateQueryResult} from "@tanstack/solid-query"; import {PaginationState, SortingState, VisibilityState} from "@tanstack/solid-table"; +import {AxiosError} from "axios"; 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, Schema} from "./types"; +import {Column, ColumnName, DataRequest, DataResponse, Filter, Schema} from "./types"; /** A collection of column filters, keyed by column name. The undefined value denotes a disabled filter. */ export type ColumnFilters = Record>; @@ -195,19 +199,85 @@ interface TableHelperInterface { pageCount: Accessor; /** A signal that changes whenever the table needs to be scrolled back to top. */ scrollToTopSignal: Accessor; + /** + * 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 | 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, }: { requestController: RequestController; - response: Accessor; + dataQuery: CreateQueryResult>; }): 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(); + 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") { + filterErrors.set( + leafFilter.column, + translateError({...e, field: t("tables.filter.filter_for", {data: leafFilter.column})}), + ); + 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, + }; } diff --git a/resources/js/data-access/memo-api/tquery/tquery.ts b/resources/js/data-access/memo-api/tquery/tquery.ts index 45d5f750a..73370c399 100644 --- a/resources/js/data-access/memo-api/tquery/tquery.ts +++ b/resources/js/data-access/memo-api/tquery/tquery.ts @@ -1,9 +1,11 @@ -import {createQuery, keepPreviousData} from "@tanstack/solid-query"; +import {QueryMeta, createQuery, keepPreviousData} from "@tanstack/solid-query"; +import {AxiosError} from "axios"; import {Accessor, createComputed, createSignal} from "solid-js"; import {SetStoreFunction, createStore} from "solid-js/store"; import {V1} from "../config"; -import {CreateQueryOpts, SolidQueryOpts} from "../query_utils"; -import {DataItem, DataRequest, DataResponse, Schema} from "./types"; +import {CreateQueryOpts} from "../query_utils"; +import {Api} from "../types"; +import {DataRequest, DataResponse, Schema} from "./types"; type EntityURL = string; @@ -18,8 +20,6 @@ function getRequestFromQueryKey(queryKey: DataQueryKey const INITIAL_PAGE_SIZE = 50; -const EMPTY_DATA: DataItem[] = []; - /** A utility that creates and helps with managing the request object. */ export interface RequestCreator { (schema: Accessor): { @@ -58,33 +58,26 @@ export function createTQuery({ prefixQueryKey: K; entityURL: EntityURL; requestCreator: RequestCreator; - dataQueryOptions?: CreateQueryOpts>; + dataQueryOptions?: Partial> & {meta: QueryMeta}>; }) { const schemaQuery = createQuery(() => ({ queryKey: ["tquery-schema", entityURL] satisfies SchemaQueryKey, queryFn: () => V1.get(`${entityURL}/tquery`).then((res) => res.data), - staleTime: Number.POSITIVE_INFINITY, + staleTime: 3600 * 1000, })); const schema = () => schemaQuery.data; const {request, requestController} = requestCreator(schema); - const dataQuery = createQuery( - () => - ({ - enabled: !!request(), - queryKey: [...prefixQueryKey, "tquery", entityURL, request()!] satisfies DataQueryKey, - queryFn: (context) => - V1.post(`${entityURL}/tquery`, getRequestFromQueryKey(context.queryKey)).then( - (res) => res.data, - ), - placeholderData: keepPreviousData, - ...dataQueryOptions, - }) satisfies SolidQueryOpts>, - ); - const data = () => dataQuery.data?.data || EMPTY_DATA; + const dataQuery = createQuery, DataResponse, DataQueryKey>(() => ({ + enabled: !!request(), + queryKey: [...prefixQueryKey, "tquery", entityURL, request()!] satisfies DataQueryKey, + queryFn: (context) => + V1.post(`${entityURL}/tquery`, getRequestFromQueryKey(context.queryKey)).then((res) => res.data), + placeholderData: keepPreviousData, + ...dataQueryOptions, + })); return { schema, requestController, dataQuery, - data, }; } diff --git a/resources/lang/pl_PL/tables.yml b/resources/lang/pl_PL/tables.yml index a4a6d8443..be9b1cedf 100644 --- a/resources/lang/pl_PL/tables.yml +++ b/resources/lang/pl_PL/tables.yml @@ -56,6 +56,7 @@ choose_columns: "Wybierz kolumny" sort_tooltip: "Sortuj" filter: + filter_for: "filtr {{data}}" filter_cleared: "Brak filtra" filter_set: "Filtr aktywny" click_to_clear: "Kliknij aby wyczyścić" From f3b08f1e4d199c48893faa295e8fff15d89224cb Mon Sep 17 00:00:00 2001 From: TPReal Date: Tue, 31 Oct 2023 10:24:26 +0100 Subject: [PATCH 2/3] Extracted UUID length to a constant. (review) --- .../components/ui/Table/tquery_filters/UuidFilterControl.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx b/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx index df451d188..3d1fc2990 100644 --- a/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx +++ b/resources/js/components/ui/Table/tquery_filters/UuidFilterControl.tsx @@ -4,6 +4,8 @@ import {createComputed, createSignal} from "solid-js"; import s from "./ColumnFilterController.module.scss"; import {FilterControl} from "./types"; +const UUID_LENGTH = 36; + export const UuidFilterControl: FilterControl = (props) => { const [value, setValue] = createSignal(""); createComputed(() => { @@ -26,7 +28,7 @@ export const UuidFilterControl: FilterControl !v || v === "*" || v === "''" || v.length === 36, + outputImmediately: (v) => !v || v === "*" || v === "''" || v.length === UUID_LENGTH, }); createComputed(() => props.setFilter(buildFilter(debouncedValue()))); return ( From 88a16826717682ba47532904dadc9376d0bb38ed Mon Sep 17 00:00:00 2001 From: TPReal Date: Tue, 14 Nov 2023 21:52:14 +0100 Subject: [PATCH 3/3] FZ-108 Fixed the error that the column name in the error toast was not translated. --- resources/js/components/ui/Table/TQueryTable.tsx | 1 + resources/js/data-access/memo-api/tquery/table.ts | 9 ++++++++- resources/lang/pl_PL/tables.yml | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/resources/js/components/ui/Table/TQueryTable.tsx b/resources/js/components/ui/Table/TQueryTable.tsx index 798915b8c..2f1f2eb9a 100644 --- a/resources/js/components/ui/Table/TQueryTable.tsx +++ b/resources/js/components/ui/Table/TQueryTable.tsx @@ -140,6 +140,7 @@ export const TQueryTable: VoidComponent = (props) => { const {rowsCount, pageCount, scrollToTopSignal, filterErrors} = tableHelper({ requestController, dataQuery, + translations: props.staticTranslations, }); createEffect(() => { const errors = filterErrors()?.values(); diff --git a/resources/js/data-access/memo-api/tquery/table.ts b/resources/js/data-access/memo-api/tquery/table.ts index 53303b665..38ef2ff65 100644 --- a/resources/js/data-access/memo-api/tquery/table.ts +++ b/resources/js/data-access/memo-api/tquery/table.ts @@ -1,6 +1,7 @@ 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, useLangFunc} from "components/utils"; import {Accessor, Signal, createComputed, createMemo, createSignal, on} from "solid-js"; @@ -223,9 +224,11 @@ export function isFilterValError(error: Api.Error): error is Api.ValidationError export function tableHelper({ requestController, dataQuery, + translations, }: { requestController: RequestController; dataQuery: CreateQueryResult>; + translations?: TableTranslations; }): TableHelperInterface { const t = useLangFunc(); const rowsCount = () => dataQuery.data?.meta.totalDataSize; @@ -256,9 +259,13 @@ export function tableHelper({ 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: leafFilter.column})}), + translateError({ + ...e, + field: t("tables.filter.filter_for", {data: translatedColumnName}), + }), ); continue; } diff --git a/resources/lang/pl_PL/tables.yml b/resources/lang/pl_PL/tables.yml index 916825ed0..fc565b7aa 100644 --- a/resources/lang/pl_PL/tables.yml +++ b/resources/lang/pl_PL/tables.yml @@ -56,7 +56,7 @@ choose_columns: "Wybierz kolumny" sort_tooltip: "Sortuj" filter: - filter_for: "filtr {{data}}" + filter_for: "filtr kolumny {{data}}" filter_cleared: "Brak filtra" filter_set: "Filtr aktywny" click_to_clear: "Kliknij aby wyczyścić"