From 64f18120efbeb1705b53dded1499c95b132eb609 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:26:42 +0800 Subject: [PATCH] fix(list,i18n): a 400 from the server no longer reads as "check your connection" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyLoadError exists 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, then sent everything else, 4xx included, 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 the connection copy is advice that cannot work — the same mistake the function exists to prevent, one status code over. Reachable from ordinary stored metadata since 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 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 strings go into ALL TEN locale packs, not just en: the neighbouring panels are translated and fallbackLng would have rendered this one in English beside them. all-locales-key-parity.test.ts caught the pack I missed. 5 new tests (numeric status, code without status, status in message text, 403/401 ordering); reverting the branch fails four. plugin-list + i18n: 403 tests / 29 files green. Refs objectstack-ai/objectstack#4121, #2945 Co-Authored-By: Claude Opus 5 --- .changeset/list-rejected-query-panel.md | 39 +++++++++++++++ packages/i18n/src/locales/ar.ts | 2 + packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + packages/i18n/src/locales/es.ts | 2 + packages/i18n/src/locales/fr.ts | 2 + packages/i18n/src/locales/ja.ts | 2 + packages/i18n/src/locales/ko.ts | 2 + packages/i18n/src/locales/pt.ts | 2 + packages/i18n/src/locales/ru.ts | 2 + packages/i18n/src/locales/zh.ts | 2 + packages/plugin-list/src/ListView.tsx | 28 +++++++++-- .../__tests__/ListView.loadErrorKind.test.tsx | 48 +++++++++++++++++++ 13 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 .changeset/list-rejected-query-panel.md 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={(