Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/list-rejected-query-panel.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ const ar = {
loadErrorForbiddenMessage: "ليس لديك إذن لعرض هذه السجلات. تواصل مع المسؤول إذا كنت تعتقد أنه يجب أن يكون لديك وصول.",
loadErrorUnauthorizedTitle: "يلزم تسجيل الدخول",
loadErrorUnauthorizedMessage: "انتهت صلاحية جلستك أو تم تسجيل خروجك. سجّل الدخول مرة أخرى لعرض هذه السجلات.",
loadErrorRejectedTitle: "تم رفض استعلام طريقة العرض هذه",
loadErrorRejectedMessage: "تعذّر على الخادم معالجة عامل التصفية أو خيارات الاستعلام لطريقة العرض هذه. مسح عوامل التصفية يحل المشكلة عادةً؛ وإذا كانت طريقة العرض محفوظة بهذا الشكل، فيجب على المسؤول تصحيحها.",
retry: "إعادة المحاولة",
managedBy: {
system: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ const ja = {
loadErrorForbiddenMessage: "これらのレコードを表示する権限がありません。必要な場合は管理者にお問い合わせください。",
loadErrorUnauthorizedTitle: "サインインが必要です",
loadErrorUnauthorizedMessage: "セッションの有効期限が切れたか、サインアウトしています。再度サインインしてください。",
loadErrorRejectedTitle: "このビューのクエリは拒否されました",
loadErrorRejectedMessage: "サーバーはこのビューのフィルターまたはクエリ設定を処理できませんでした。フィルターを解除すると解消することがほとんどです。ビューがこの状態で保存されている場合は、管理者による修正が必要です。",
retry: "再試行",
managedBy: {
system: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ const ko = {
loadErrorForbiddenMessage: "이 레코드를 볼 권한이 없습니다. 접근이 필요하면 관리자에게 문의하세요.",
loadErrorUnauthorizedTitle: "로그인이 필요합니다",
loadErrorUnauthorizedMessage: "세션이 만료되었거나 로그아웃되었습니다. 다시 로그인한 후 확인하세요.",
loadErrorRejectedTitle: "이 뷰의 쿼리가 거부되었습니다",
loadErrorRejectedMessage: "서버가 이 뷰의 필터 또는 쿼리 옵션을 처리하지 못했습니다. 필터를 지우면 대부분 해결됩니다. 뷰가 이 상태로 저장되어 있다면 관리자가 수정해야 합니다.",
retry: "다시 시도",
managedBy: {
system: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ const ru = {
loadErrorForbiddenMessage: "У вас нет прав на просмотр этих записей. Обратитесь к администратору, если считаете, что доступ вам нужен.",
loadErrorUnauthorizedTitle: "Требуется вход",
loadErrorUnauthorizedMessage: "Сессия истекла или вы вышли из системы. Войдите снова, чтобы просмотреть эти записи.",
loadErrorRejectedTitle: "Запрос этого представления отклонён",
loadErrorRejectedMessage: "Сервер не смог обработать фильтр или параметры запроса этого представления. Обычно помогает сброс фильтров; если представление сохранено в таком виде, исправить его должен администратор.",
retry: "Повторить",
managedBy: {
system: {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,8 @@ const zh = {
loadErrorForbiddenMessage: '你没有查看这些记录的权限。如需访问,请联系管理员。',
loadErrorUnauthorizedTitle: '需要登录',
loadErrorUnauthorizedMessage: '登录状态已过期或已退出。请重新登录后查看这些记录。',
loadErrorRejectedTitle: '该视图的查询被拒绝',
loadErrorRejectedMessage: '服务器无法处理该视图的筛选条件或查询参数。清除筛选条件通常即可恢复;如果视图本身就是这样保存的,需要管理员修正。',
retry: '重试',
managedBy: {
system: {
Expand Down
28 changes: 24 additions & 4 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, string> = {
'list.recordCount': '{{count}} records',
Expand All @@ -315,6 +328,11 @@ const LIST_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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',
Expand Down Expand Up @@ -531,7 +549,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// failed. Captured here so the render can show a retryable error panel.
const [loadError, setLoadError] = React.useState<string | null>(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.
Expand Down Expand Up @@ -2496,12 +2514,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
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={(
<Button
Expand Down
48 changes: 48 additions & 0 deletions packages/plugin-list/src/__tests__/ListView.loadErrorKind.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import type { ListViewSchema } from '@object-ui/types';
* denial and an outage were indistinguishable, sending users (and ops) to
* debug their network while the server was correctly denying access.
* The error panel must classify by HTTP status / error code.
*
* The same mistake was still being made one status code over. A **400** is the
* server saying it understood the request and will never accept it — a stored
* filter it cannot parse (objectstack#4121 now returns `INVALID_FILTER`), or an
* unsupported `$`-parameter. Retrying resends the identical bad request, so
* "check your connection and try again" is advice that cannot work.
*/

const schema: ListViewSchema = {
Expand Down Expand Up @@ -84,4 +90,46 @@ describe('ListView – load-error classification', () => {
expect(panel.getAttribute('data-error-kind')).toBe('network');
expect(panel.textContent).toMatch(/connection/i);
});

it('renders the rejected-query panel for a 400, not the connection copy', async () => {
const err = Object.assign(new Error('Malformed $filter'), { httpStatus: 400 });
const { container } = renderFailing(err);
const panel = await errorState(container);
expect(panel.getAttribute('data-error-kind')).toBe('rejected');
expect(panel.textContent).toMatch(/filter|query/i);
// The advice that cannot work: retrying resends the same bad request.
expect(panel.textContent).not.toMatch(/connection/i);
});

it('classifies the server\u2019s INVALID_FILTER code without a numeric status', async () => {
// objectstack#4121 — a `$filter` array that is not a filter AST.
const err = Object.assign(new Error('Malformed $filter'), { code: 'INVALID_FILTER' });
const { container } = renderFailing(err);
const panel = await errorState(container);
expect(panel.getAttribute('data-error-kind')).toBe('rejected');
});

it('classifies UNSUPPORTED_QUERY_PARAM the same way', async () => {
const err = Object.assign(new Error('Unsupported query parameter(s): $bogus'), {
code: 'UNSUPPORTED_QUERY_PARAM',
});
const { container } = renderFailing(err);
const panel = await errorState(container);
expect(panel.getAttribute('data-error-kind')).toBe('rejected');
});

it('classifies a 400 embedded in the message text', async () => {
const { container } = renderFailing(new Error('ApiDataSource: HTTP 400 Bad Request — {}'));
const panel = await errorState(container);
expect(panel.getAttribute('data-error-kind')).toBe('rejected');
});

it('still prefers 403/401 over the 400 branch', async () => {
// Ordering guard: a permission denial must never read as a bad request.
for (const [status, kind] of [[403, 'forbidden'], [401, 'unauthorized']] as const) {
const { container } = renderFailing(Object.assign(new Error('x'), { httpStatus: status }));
const panel = await errorState(container);
expect(panel.getAttribute('data-error-kind')).toBe(kind);
}
});
});
Loading