diff --git a/.changeset/list-rejected-query-panel.md b/.changeset/list-rejected-query-panel.md new file mode 100644 index 000000000..19acd0589 --- /dev/null +++ b/.changeset/list-rejected-query-panel.md @@ -0,0 +1,39 @@ +--- +"@object-ui/plugin-list": minor +"@object-ui/i18n": minor +--- + +fix(list,i18n): a 400 from the server no longer reads as "check your connection" + +`classifyLoadError` was written because a 403 rendered the same +"check your connection and try again" panel as a genuine outage — its own doc +comment says users "were told to debug their network when the server had +(correctly) denied them access." It made that distinction for 401 and 403 and +then sent **everything else**, including 4xx, to the network branch. + +A **400** is the server saying it understood the request and will never accept +it. Retrying resends the identical bad request, so "check your connection and +try again" is advice that cannot work — the same mistake the function exists to +prevent, one status code over. + +This became reachable from ordinary stored metadata with +objectstack-ai/objectstack#4121: a `$filter` array that is not a filter AST is +now rejected at the protocol with `400 INVALID_FILTER`, where it previously +reached a driver (and, for a lone `['and']`, silently returned every row). A +view saved with such a filter now answers 400 on every load. + +Adds a fourth classification, `rejected`, for `status === 400` and for the +server's 400-class codes (`INVALID_FILTER`, `UNSUPPORTED_QUERY_PARAM`, +`INVALID_QUERY`). Its copy points at the filter rather than the network, and +says who can fix it when the view is saved that way. 403/401 keep priority, so a +permission denial can never read as a bad request — pinned by a test. + +The two new strings are added to **all ten locale packs**, not just `en`: the +neighbouring panels are translated, and `fallbackLng: 'en'` would have rendered +this one in English beside them. The full-parity gate +(`all-locales-key-parity.test.ts`) caught the pack I missed. + +Verified: 5 new tests — numeric status, error code without a status, a status +embedded in the message text, and the 403/401 ordering guard. Reverting the +branch fails four of them. `plugin-list` + `i18n`: **403 tests across 29 files**, +green. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 888db6107..bfaa688ff 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -457,6 +457,8 @@ const ar = { loadErrorForbiddenMessage: "ليس لديك إذن لعرض هذه السجلات. تواصل مع المسؤول إذا كنت تعتقد أنه يجب أن يكون لديك وصول.", loadErrorUnauthorizedTitle: "يلزم تسجيل الدخول", loadErrorUnauthorizedMessage: "انتهت صلاحية جلستك أو تم تسجيل خروجك. سجّل الدخول مرة أخرى لعرض هذه السجلات.", + loadErrorRejectedTitle: "تم رفض استعلام طريقة العرض هذه", + loadErrorRejectedMessage: "تعذّر على الخادم معالجة عامل التصفية أو خيارات الاستعلام لطريقة العرض هذه. مسح عوامل التصفية يحل المشكلة عادةً؛ وإذا كانت طريقة العرض محفوظة بهذا الشكل، فيجب على المسؤول تصحيحها.", retry: "إعادة المحاولة", managedBy: { system: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 2fd104ab9..cdda3d17f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -457,6 +457,8 @@ const de = { loadErrorForbiddenMessage: "Sie haben keine Berechtigung, diese Datensätze anzuzeigen. Wenden Sie sich an Ihren Administrator, wenn Sie Zugriff benötigen.", loadErrorUnauthorizedTitle: "Anmeldung erforderlich", loadErrorUnauthorizedMessage: "Ihre Sitzung ist abgelaufen oder Sie sind abgemeldet. Melden Sie sich erneut an, um diese Datensätze anzuzeigen.", + loadErrorRejectedTitle: "Die Abfrage dieser Ansicht wurde abgelehnt", + loadErrorRejectedMessage: "Der Server konnte den Filter oder die Abfrageoptionen dieser Ansicht nicht verarbeiten. Das Zurücksetzen der Filter behebt das meist; ist die Ansicht so gespeichert, muss ein Administrator sie korrigieren.", retry: "Erneut versuchen", managedBy: { system: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index ff055cea5..58b7412a1 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -486,6 +486,8 @@ const en = { loadErrorForbiddenMessage: 'You don\u2019t have permission to view these records. Contact your administrator if you think you should have access.', loadErrorUnauthorizedTitle: 'Sign in required', loadErrorUnauthorizedMessage: 'Your session has expired or you are signed out. Sign in again to view these records.', + loadErrorRejectedTitle: 'This view’s query was rejected', + loadErrorRejectedMessage: 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.', retry: 'Retry', managedBy: { system: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 62c109c74..45abebca3 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -457,6 +457,8 @@ const es = { loadErrorForbiddenMessage: "No tiene permiso para ver estos registros. Contacte a su administrador si cree que debería tener acceso.", loadErrorUnauthorizedTitle: "Se requiere iniciar sesión", loadErrorUnauthorizedMessage: "Su sesión ha expirado o ha cerrado sesión. Inicie sesión de nuevo para ver estos registros.", + loadErrorRejectedTitle: "La consulta de esta vista fue rechazada", + loadErrorRejectedMessage: "El servidor no pudo procesar el filtro ni las opciones de consulta de esta vista. Borrar los filtros suele resolverlo; si la vista está guardada así, un administrador debe corregirla.", retry: "Reintentar", managedBy: { system: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f8c295326..1f6254c84 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -457,6 +457,8 @@ const fr = { loadErrorForbiddenMessage: "Vous n'avez pas la permission de consulter ces enregistrements. Contactez votre administrateur si vous pensez devoir y accéder.", loadErrorUnauthorizedTitle: "Connexion requise", loadErrorUnauthorizedMessage: "Votre session a expiré ou vous êtes déconnecté. Reconnectez-vous pour consulter ces enregistrements.", + loadErrorRejectedTitle: "La requête de cette vue a été rejetée", + loadErrorRejectedMessage: "Le serveur n’a pas pu traiter le filtre ou les options de requête de cette vue. Effacer les filtres suffit généralement ; si la vue est enregistrée ainsi, un administrateur doit la corriger.", retry: "Réessayer", managedBy: { system: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 680c8325a..4bb8ed56b 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -457,6 +457,8 @@ const ja = { loadErrorForbiddenMessage: "これらのレコードを表示する権限がありません。必要な場合は管理者にお問い合わせください。", loadErrorUnauthorizedTitle: "サインインが必要です", loadErrorUnauthorizedMessage: "セッションの有効期限が切れたか、サインアウトしています。再度サインインしてください。", + loadErrorRejectedTitle: "このビューのクエリは拒否されました", + loadErrorRejectedMessage: "サーバーはこのビューのフィルターまたはクエリ設定を処理できませんでした。フィルターを解除すると解消することがほとんどです。ビューがこの状態で保存されている場合は、管理者による修正が必要です。", retry: "再試行", managedBy: { system: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 8d354b02c..898c1d2fa 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -457,6 +457,8 @@ const ko = { loadErrorForbiddenMessage: "이 레코드를 볼 권한이 없습니다. 접근이 필요하면 관리자에게 문의하세요.", loadErrorUnauthorizedTitle: "로그인이 필요합니다", loadErrorUnauthorizedMessage: "세션이 만료되었거나 로그아웃되었습니다. 다시 로그인한 후 확인하세요.", + loadErrorRejectedTitle: "이 뷰의 쿼리가 거부되었습니다", + loadErrorRejectedMessage: "서버가 이 뷰의 필터 또는 쿼리 옵션을 처리하지 못했습니다. 필터를 지우면 대부분 해결됩니다. 뷰가 이 상태로 저장되어 있다면 관리자가 수정해야 합니다.", retry: "다시 시도", managedBy: { system: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 806ed673e..22e8c038a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -457,6 +457,8 @@ const pt = { loadErrorForbiddenMessage: "Você não tem permissão para ver estes registros. Contate seu administrador se acha que deveria ter acesso.", loadErrorUnauthorizedTitle: "É necessário entrar", loadErrorUnauthorizedMessage: "Sua sessão expirou ou você saiu. Entre novamente para ver estes registros.", + loadErrorRejectedTitle: "A consulta desta visualização foi rejeitada", + loadErrorRejectedMessage: "O servidor não conseguiu processar o filtro ou as opções de consulta desta visualização. Limpar os filtros costuma resolver; se a visualização estiver salva assim, um administrador precisa corrigi-la.", retry: "Tentar novamente", managedBy: { system: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index c091e3fdd..fb996fbbd 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -457,6 +457,8 @@ const ru = { loadErrorForbiddenMessage: "У вас нет прав на просмотр этих записей. Обратитесь к администратору, если считаете, что доступ вам нужен.", loadErrorUnauthorizedTitle: "Требуется вход", loadErrorUnauthorizedMessage: "Сессия истекла или вы вышли из системы. Войдите снова, чтобы просмотреть эти записи.", + loadErrorRejectedTitle: "Запрос этого представления отклонён", + loadErrorRejectedMessage: "Сервер не смог обработать фильтр или параметры запроса этого представления. Обычно помогает сброс фильтров; если представление сохранено в таком виде, исправить его должен администратор.", retry: "Повторить", managedBy: { system: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b9a34e210..ab5bf84a4 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -494,6 +494,8 @@ const zh = { loadErrorForbiddenMessage: '你没有查看这些记录的权限。如需访问,请联系管理员。', loadErrorUnauthorizedTitle: '需要登录', loadErrorUnauthorizedMessage: '登录状态已过期或已退出。请重新登录后查看这些记录。', + loadErrorRejectedTitle: '该视图的查询被拒绝', + loadErrorRejectedMessage: '服务器无法处理该视图的筛选条件或查询参数。清除筛选条件通常即可恢复;如果视图本身就是这样保存的,需要管理员修正。', retry: '重试', managedBy: { system: { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index aec152c47..c8a34b06d 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -276,7 +276,7 @@ export function evaluateConditionalFormatting( * is indistinguishable from a real outage — users were told to debug their * network when the server had (correctly) denied them access. */ -function classifyLoadError(err: unknown): 'forbidden' | 'unauthorized' | 'network' { +function classifyLoadError(err: unknown): 'forbidden' | 'unauthorized' | 'rejected' | 'network' { const e = err as any; // The ObjectStack client decorates errors with `httpStatus`; raw fetch // wrappers surface `status` / `statusCode`; some adapters only embed the @@ -290,9 +290,22 @@ function classifyLoadError(err: unknown): 'forbidden' | 'unauthorized' | 'networ const code = typeof e?.code === 'string' ? e.code.toUpperCase() : ''; if (status === 403 || code === 'PERMISSION_DENIED' || code === 'FORBIDDEN') return 'forbidden'; if (status === 401 || code === 'UNAUTHORIZED' || code === 'UNAUTHENTICATED') return 'unauthorized'; + // The server understood the request and refused it as malformed. Retrying + // sends the identical bad request, so "check your connection and try again" + // is the same wrong advice this function exists to stop giving — one status + // code over. The server now rejects a `$filter` that is not a filter AST + // (objectstack#4121) and an unsupported `$`-parameter, both as 400. + if (status === 400 || REJECTED_REQUEST_CODES.has(code)) return 'rejected'; return 'network'; } +/** 400-class error codes the data API returns for a request it will never accept. */ +const REJECTED_REQUEST_CODES = new Set([ + 'INVALID_FILTER', + 'UNSUPPORTED_QUERY_PARAM', + 'INVALID_QUERY', +]); + // Default English translations for fallback when I18nProvider is not available const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.recordCount': '{{count}} records', @@ -315,6 +328,11 @@ const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.loadErrorForbiddenMessage': 'You don’t have permission to view these records. Contact your administrator if you think you should have access.', 'list.loadErrorUnauthorizedTitle': 'Sign in required', 'list.loadErrorUnauthorizedMessage': 'Your session has expired or you are signed out. Sign in again to view these records.', + // Load REJECTED — the server answered 400: the request itself is malformed + // (usually a stored filter it cannot parse). Retrying resends the same bad + // request, so the copy points at the filter instead of the network. + 'list.loadErrorRejectedTitle': 'This view’s query was rejected', + 'list.loadErrorRejectedMessage': 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.', 'list.retry': 'Retry', 'list.search': 'Search', 'list.filter': 'Filter', @@ -531,7 +549,7 @@ export const ListView = React.forwardRef(({ // failed. Captured here so the render can show a retryable error panel. const [loadError, setLoadError] = React.useState(null); // What KIND of failure `loadError` is — drives which error panel copy shows. - const [loadErrorKind, setLoadErrorKind] = React.useState<'forbidden' | 'unauthorized' | 'network'>('network'); + const [loadErrorKind, setLoadErrorKind] = React.useState<'forbidden' | 'unauthorized' | 'rejected' | 'network'>('network'); // Start in loading state when we will fetch from a dataSource so the empty // state doesn't flash before the first effect runs. Inline data (schema.data // as an array or a `value` provider) starts as not-loading. @@ -2496,12 +2514,14 @@ export const ListView = React.forwardRef(({ title={t( loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenTitle' : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedTitle' - : 'list.loadErrorTitle', + : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedTitle' + : 'list.loadErrorTitle', )} description={t( loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenMessage' : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedMessage' - : 'list.loadErrorMessage', + : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedMessage' + : 'list.loadErrorMessage', )} action={(