From bb29cfc7e6538a785d9545ad681fb2fa6b37a4b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:02:18 +0000 Subject: [PATCH 1/4] fix(ui): KPI tiles two across on a phone, calendar footer names its window count, not-found copy names no access A bare layout-grid count of four or more keeps two tiles across below sm; a windowed calendar's footer reads "N records loaded for these dates"; the record not-found description says the record may also be unreadable, since the server answers both with 404 RECORD_NOT_FOUND. Claude-Session: https://claude.ai/code/session_01Utp6CXNi3EtSwLHRSornGu Co-authored-by: Claude --- .changeset/27355-console-walkthrough-rows.md | 17 +++ ...etailView.recordReadFailure-11902.test.tsx | 2 +- .../grid-breakpoint-columns-7097.test.tsx | 8 +- .../grid-flat-columns-retired-11505.test.tsx | 4 +- .../__tests__/grid-phone-tiles-27355.test.tsx | 63 ++++++++ .../components/src/renderers/layout/grid.tsx | 13 +- ui/packages/i18n/src/locales/ar.ts | 13 +- ui/packages/i18n/src/locales/de.ts | 9 +- ui/packages/i18n/src/locales/en.ts | 9 +- ui/packages/i18n/src/locales/es.ts | 10 +- ui/packages/i18n/src/locales/fr.ts | 10 +- ui/packages/i18n/src/locales/ja.ts | 9 +- ui/packages/i18n/src/locales/ko.ts | 9 +- ui/packages/i18n/src/locales/pt.ts | 10 +- ui/packages/i18n/src/locales/ru.ts | 11 +- ui/packages/i18n/src/locales/zh.ts | 9 +- .../plugin-detail/src/useDetailTranslation.ts | 2 +- ui/packages/plugin-list/src/ListView.tsx | 10 +- ...istView.calendarFooterCount-27355.test.tsx | 143 ++++++++++++++++++ 19 files changed, 332 insertions(+), 29 deletions(-) create mode 100644 .changeset/27355-console-walkthrough-rows.md create mode 100644 ui/packages/components/src/__tests__/grid-phone-tiles-27355.test.tsx create mode 100644 ui/packages/plugin-list/src/__tests__/ListView.calendarFooterCount-27355.test.tsx diff --git a/.changeset/27355-console-walkthrough-rows.md b/.changeset/27355-console-walkthrough-rows.md new file mode 100644 index 00000000000..24c1d56b38d --- /dev/null +++ b/.changeset/27355-console-walkthrough-rows.md @@ -0,0 +1,17 @@ +--- +'@object-ui/components': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +fix(ui): four KPI tiles sit two across on a phone, a calendar's footer says its count is the loaded dates', and the record not-found page says the record may also be one you cannot read + +Clause-②: no +Metadata-protocol: none + +Three console rows from a full HotCRM browser pass: + +- **A layout `grid` with a bare `columns` of 4 or more draws two tiles across on a phone** (below `sm`), then the authored count from `md`. A home page's KPI strip (`columns: 4` over `object-metric` tiles) took a row per tile and filled the first screen; it now reads as the dashboard's own phone layout for metric tiles. A bare 2 or 3 still stacks on a phone and pairs from `sm`, and an authored breakpoint object (`columns: { xs: 1, md: 4 }`) is drawn as written. +- **A calendar view's footer says `N records loaded for these dates`.** The calendar fetches the window of dates it draws, so the footer's count is that window's records, not the view's total; it read "N records", the words a grid uses for the whole view. A grid, a board and a calendar over rows handed in whole keep "N records". New count family `list.calendarRecordCount` in all ten packs. +- **The record not-found page says the record "does not exist, may have been deleted, or you may not have access to it".** The server answers a record the reader may not see the same as a missing one (`404 RECORD_NOT_FOUND`, so existence is never revealed), so the page cannot tell the two apart and now names both. `empty.recordNotFoundDescription` and `detail.recordNotFoundDescription` change in all ten packs. diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx index 2b03e71eeb1..c81d3e3dc71 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx @@ -128,7 +128,7 @@ const ZH: I18nConfig = { }; const NOT_FOUND_EN = 'Record not found'; -const NOT_FOUND_DESCRIPTION_EN = 'The record you are looking for does not exist or may have been deleted.'; +const NOT_FOUND_DESCRIPTION_EN = 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.'; const NOT_FOUND_ZH = '未找到记录'; /** Open the record page; returns the adapter's `findOne`, spied, to count the page's reads. */ diff --git a/ui/packages/components/src/__tests__/grid-breakpoint-columns-7097.test.tsx b/ui/packages/components/src/__tests__/grid-breakpoint-columns-7097.test.tsx index 773c62c3568..f5db7fb771a 100644 --- a/ui/packages/components/src/__tests__/grid-breakpoint-columns-7097.test.tsx +++ b/ui/packages/components/src/__tests__/grid-breakpoint-columns-7097.test.tsx @@ -168,9 +168,13 @@ describe('ui:grid emits a column class for every breakpoint in the vocabulary (# expect(classOf({ type: 'grid', columns: { xs: 1, xl: 5 }, gap: 4 })).toBe( 'grid grid-cols-1 xl:grid-cols-5 gap-4', ); - // A bare number keeps its mobile-first ramp (baseCols collapses to 1). + // A bare number keeps its mobile-first ramp (a phone draws two of four + // tiles across, objectstack#27355; two and three across collapse to 1). expect(classOf({ type: 'grid', columns: 4, gap: 4 })).toBe( - 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4', + 'grid grid-cols-2 md:grid-cols-4 gap-4', + ); + expect(classOf({ type: 'grid', columns: 3, gap: 4 })).toBe( + 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4', ); }); diff --git a/ui/packages/components/src/__tests__/grid-flat-columns-retired-11505.test.tsx b/ui/packages/components/src/__tests__/grid-flat-columns-retired-11505.test.tsx index efa7c2c75fc..6318ca61374 100644 --- a/ui/packages/components/src/__tests__/grid-flat-columns-retired-11505.test.tsx +++ b/ui/packages/components/src/__tests__/grid-flat-columns-retired-11505.test.tsx @@ -164,7 +164,7 @@ describe('`grid` reads and publishes no flat column key (objectui#11505)', () => it('a flat key no longer switches the bare count\'s ramp off', () => { // Lit control for the rows above: the ramp is what a bare `4` draws. - expect(classesOf({ columns: 4 })).toBe('grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4'); - expect(classesOf({ columns: 4, mdColumns: 2 })).toBe('grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4'); + expect(classesOf({ columns: 4 })).toBe('grid grid-cols-2 md:grid-cols-4 gap-4'); + expect(classesOf({ columns: 4, mdColumns: 2 })).toBe('grid grid-cols-2 md:grid-cols-4 gap-4'); }); }); diff --git a/ui/packages/components/src/__tests__/grid-phone-tiles-27355.test.tsx b/ui/packages/components/src/__tests__/grid-phone-tiles-27355.test.tsx new file mode 100644 index 00000000000..0afeaa5c1c6 --- /dev/null +++ b/ui/packages/components/src/__tests__/grid-phone-tiles-27355.test.tsx @@ -0,0 +1,63 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A bare `columns` of four or more keeps two tiles across on a phone + * (objectstack#27355, row 11). + * + * A home page's KPI strip is a layout `grid` with a bare count (`columns: 4`) + * over `object-metric` tiles. The bare count's mobile-first ramp collapsed + * every N-across row to one column below `sm`, so four KPI tiles took a row + * each and filled a phone's first screen. A row of four or more holds tiles + * sized for a quarter of the row or less, so the ramp now keeps two across on + * a phone, as the dashboard does for its metric tiles. + * + * The emitted class string is the renderer's whole output on this path (it + * reads no viewport), so each case compares it whole — see + * `grid-breakpoint-columns-7097.test.tsx` for why that observation is sound. + * + * Module-scope import of the renderers, not `beforeAll` (AGENTS.md §测试纪律). + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import '../renderers'; +import { SchemaRenderer } from '@object-ui/react'; + +const classOf = (schema: unknown): string => { + const { container } = render(); + const el = container.firstElementChild as HTMLElement | null; + expect(el, 'the grid rendered no element').not.toBeNull(); + return el!.className; +}; + +describe('a bare column count of four or more keeps two tiles across on a phone (objectstack#27355)', () => { + it('four across: two on a phone, four from md', () => { + expect(classOf({ type: 'grid', columns: 4, gap: 4 })).toBe('grid grid-cols-2 md:grid-cols-4 gap-4'); + }); + + it.each([5, 6, 8, 12])('%i across: two on a phone, the authored count from md', (n) => { + expect(classOf({ type: 'grid', columns: n, gap: 4 })).toBe(`grid grid-cols-2 md:grid-cols-${n} gap-4`); + }); + + it('control: two and three across still stack on a phone and pair from sm', () => { + expect(classOf({ type: 'grid', columns: 2, gap: 4 })).toBe('grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 gap-4'); + expect(classOf({ type: 'grid', columns: 3, gap: 4 })).toBe('grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4'); + }); + + it('control: one across is one across everywhere', () => { + expect(classOf({ type: 'grid', columns: 1, gap: 4 })).toBe('grid grid-cols-1 gap-4'); + }); + + it('control: an authored breakpoint object is drawn as written, at four across too', () => { + expect(classOf({ type: 'grid', columns: { xs: 1, md: 2, lg: 4 }, gap: 4 })).toBe( + 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4', + ); + expect(classOf({ type: 'grid', columns: { xs: 1, md: 4 }, gap: 4 })).toBe('grid grid-cols-1 md:grid-cols-4 gap-4'); + expect(classOf({ type: 'grid', columns: { md: 4 }, gap: 4 })).toBe('grid grid-cols-1 md:grid-cols-4 gap-4'); + }); +}); diff --git a/ui/packages/components/src/renderers/layout/grid.tsx b/ui/packages/components/src/renderers/layout/grid.tsx index d3d2166997c..41b7636b1aa 100644 --- a/ui/packages/components/src/renderers/layout/grid.tsx +++ b/ui/packages/components/src/renderers/layout/grid.tsx @@ -119,10 +119,19 @@ ComponentRegistry.register('grid', // an N-across row doesn't render as unreadable slivers on a phone. Authors // who pass the breakpoint object keep full control. The bare-number branch // sets no breakpoint count, so the ramp's only condition is that branch. + // + // Four or more across is a row of tiles sized for a quarter of the row or + // less (a KPI strip): a phone keeps two side by side, the dashboard's own + // phone layout for its metric tiles, instead of one tile per row filling + // the first screen. Two and three across stack on a phone and pair from `sm`. if (typeof schema.columns === 'number' && baseCols > 1) { mdCols = baseCols; - smCols = Math.min(2, baseCols); - baseCols = 1; + if (baseCols >= 4) { + baseCols = 2; + } else { + smCols = 2; + baseCols = 1; + } } const gap = schema.gap ?? 4; diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2c9a18a3400..9c88aab554a 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -890,6 +890,15 @@ const ar = { recordCount_few: "{{count}} سجلات", recordCount_many: "{{count}} سجلًا", recordCount_other: "{{count}} سجل", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "عدد السجلات المحمّلة لهذه التواريخ: {{count}}", + calendarRecordCount_zero: "{{count}} سجل محمّل لهذه التواريخ", + calendarRecordCount_one: "{{count}} سجل محمّل لهذه التواريخ", + calendarRecordCount_two: "سجلان محمّلان لهذه التواريخ ({{count}})", + calendarRecordCount_few: "{{count}} سجلات محمّلة لهذه التواريخ", + calendarRecordCount_many: "{{count}} سجلًا محمّلًا لهذه التواريخ", + calendarRecordCount_other: "{{count}} سجل محمّل لهذه التواريخ", addRecord: "إضافة سجل", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "عرض الكل", @@ -1260,7 +1269,7 @@ const ar = { nextRecord: "السجل التالي", recordOf: "{{current}} من {{total}}", recordNotFound: "لم يتم العثور على السجل", - recordNotFoundDescription: "السجل الذي تبحث عنه غير موجود أو ربما تم حذفه.", + recordNotFoundDescription: "السجل الذي تبحث عنه غير موجود، أو ربما تم حذفه، أو ربما لا تملك صلاحية الوصول إليه.", goBack: "رجوع", details: "التفاصيل", related: "ذات صلة", @@ -3434,7 +3443,7 @@ const ar = { systemSettings: "إعدادات النظام", back: "رجوع", recordNotFound: "السجل غير موجود", - recordNotFoundDescription: "السجل الذي تبحث عنه غير موجود أو ربما تم حذفه.", + recordNotFoundDescription: "السجل الذي تبحث عنه غير موجود، أو ربما تم حذفه، أو ربما لا تملك صلاحية الوصول إليه.", recordAccessDenied: "ليس لديك صلاحية الوصول إلى سجلات {{object}}", recordAccessDeniedDescription: "ليس لديك إذن لعرض السجلات من هذا النوع. تواصل مع المسؤول إذا كنت تعتقد أنه يجب أن يكون لديك وصول.", recordLoadFailed: "تعذّر تحميل هذا السجل", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 1d4852d5e3a..74a2b392fd8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -793,6 +793,11 @@ const de = { recordCount: "{{count}} Datensätze", recordCount_one: "{{count}} Datensatz", recordCount_other: "{{count}} Datensätze", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "{{count}} Datensätze für diesen Zeitraum geladen", + calendarRecordCount_one: "{{count}} Datensatz für diesen Zeitraum geladen", + calendarRecordCount_other: "{{count}} Datensätze für diesen Zeitraum geladen", addRecord: "Datensatz hinzufügen", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "Alle anzeigen", @@ -1134,7 +1139,7 @@ const de = { nextRecord: "Nächster Datensatz", recordOf: "{{current}} von {{total}}", recordNotFound: "Datensatz nicht gefunden", - recordNotFoundDescription: "Der gesuchte Datensatz existiert nicht oder wurde möglicherweise gelöscht.", + recordNotFoundDescription: "Der gesuchte Datensatz existiert nicht, wurde möglicherweise gelöscht, oder Sie haben keinen Zugriff darauf.", goBack: "Zurück", details: "Details", related: "Verknüpft", @@ -3185,7 +3190,7 @@ const de = { systemSettings: "Systemeinstellungen", back: "Zurück", recordNotFound: "Datensatz nicht gefunden", - recordNotFoundDescription: "Der gesuchte Datensatz existiert nicht oder wurde möglicherweise gelöscht.", + recordNotFoundDescription: "Der gesuchte Datensatz existiert nicht, wurde möglicherweise gelöscht, oder Sie haben keinen Zugriff darauf.", recordAccessDenied: "Kein Zugriff auf {{object}}-Datensätze", recordAccessDeniedDescription: "Sie haben keine Berechtigung, Datensätze dieses Typs anzuzeigen. Wenden Sie sich an Ihren Administrator, wenn Sie Zugriff benötigen.", recordLoadFailed: "Dieser Datensatz konnte nicht geladen werden", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 0618a93e6fb..39aa370b5b7 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1004,6 +1004,11 @@ const en = { recordCount: '{{count}} records', recordCount_one: '{{count}} record', recordCount_other: '{{count}} records', + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: '{{count}} records loaded for these dates', + calendarRecordCount_one: '{{count}} record loaded for these dates', + calendarRecordCount_other: '{{count}} records loaded for these dates', addRecord: 'Add record', // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: 'View all', @@ -1479,7 +1484,7 @@ const en = { nextRecord: 'Next record', recordOf: '{{current}} of {{total}}', recordNotFound: 'Record not found', - recordNotFoundDescription: 'The record you are looking for does not exist or may have been deleted.', + recordNotFoundDescription: 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.', goBack: 'Go back', details: 'Details', related: 'Related', @@ -3749,7 +3754,7 @@ const en = { objectNotFoundDescription: 'Object "{{name}}" definition missing. Check your configuration or navigate back to select a valid object.', interfacePageSourceMissing: 'This interface page references "{{name}}", which is not available.', recordNotFound: 'Record not found', - recordNotFoundDescription: 'The record you are looking for does not exist or may have been deleted.', + recordNotFoundDescription: 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.', recordAccessDenied: 'You don’t have access to {{object}} records', recordAccessDeniedDescription: 'You don’t have permission to view records of this type. Contact your administrator if you think you should have access.', recordLoadFailed: 'Couldn’t load this record', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index d83075e2e62..9109c819b88 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -820,6 +820,12 @@ const es = { recordCount_one: "{{count}} registro", recordCount_many: "{{count}} de registros", recordCount_other: "{{count}} registros", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "{{count}} registros cargados para estas fechas", + calendarRecordCount_one: "{{count}} registro cargado para estas fechas", + calendarRecordCount_many: "{{count}} de registros cargados para estas fechas", + calendarRecordCount_other: "{{count}} registros cargados para estas fechas", addRecord: "Agregar registro", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "Ver todo", @@ -1167,7 +1173,7 @@ const es = { nextRecord: "Siguiente registro", recordOf: "{{current}} de {{total}}", recordNotFound: "Registro no encontrado", - recordNotFoundDescription: "El registro que busca no existe o puede haber sido eliminado.", + recordNotFoundDescription: "El registro que busca no existe, puede haber sido eliminado o es posible que no tenga acceso a él.", goBack: "Volver", details: "Detalles", related: "Relacionados", @@ -3248,7 +3254,7 @@ const es = { systemSettings: "Configuración del sistema", back: "Atrás", recordNotFound: "Registro no encontrado", - recordNotFoundDescription: "El registro que busca no existe o puede haber sido eliminado.", + recordNotFoundDescription: "El registro que busca no existe, puede haber sido eliminado o es posible que no tenga acceso a él.", recordAccessDenied: "No tiene acceso a los registros de {{object}}", recordAccessDeniedDescription: "No tiene permiso para ver registros de este tipo. Contacte a su administrador si cree que debería tener acceso.", recordLoadFailed: "No se pudo cargar este registro", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index aa3bbe0b7f0..046d1eaee43 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -815,6 +815,12 @@ const fr = { recordCount_one: "{{count}} enregistrement", recordCount_many: "{{count}} d'enregistrements", recordCount_other: "{{count}} enregistrements", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "{{count}} enregistrements chargés pour ces dates", + calendarRecordCount_one: "{{count}} enregistrement chargé pour ces dates", + calendarRecordCount_many: "{{count}} d'enregistrements chargés pour ces dates", + calendarRecordCount_other: "{{count}} enregistrements chargés pour ces dates", addRecord: "Ajouter un enregistrement", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "Tout afficher", @@ -1164,7 +1170,7 @@ const fr = { nextRecord: "Enregistrement suivant", recordOf: "{{current}} sur {{total}}", recordNotFound: "Enregistrement introuvable", - recordNotFoundDescription: "L'enregistrement que vous recherchez n'existe pas ou a peut-être été supprimé.", + recordNotFoundDescription: "L'enregistrement que vous recherchez n'existe pas, a peut-être été supprimé, ou vous n'y avez peut-être pas accès.", goBack: "Retour", details: "Détails", related: "Associés", @@ -3244,7 +3250,7 @@ const fr = { systemSettings: "Paramètres système", back: "Retour", recordNotFound: "Enregistrement introuvable", - recordNotFoundDescription: "L'enregistrement que vous recherchez n'existe pas ou a peut-être été supprimé.", + recordNotFoundDescription: "L'enregistrement que vous recherchez n'existe pas, a peut-être été supprimé, ou vous n'y avez peut-être pas accès.", recordAccessDenied: "Vous n'avez pas accès aux enregistrements {{object}}", recordAccessDeniedDescription: "Vous n'avez pas la permission de consulter les enregistrements de ce type. Contactez votre administrateur si vous pensez devoir y accéder.", recordLoadFailed: "Impossible de charger cet enregistrement", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 8a279cbc577..ae2fc673ee1 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -793,6 +793,11 @@ const ja = { recordCount: "{{count}} 件のレコード", recordCount_one: "{{count}} 件のレコード", recordCount_other: "{{count}} 件のレコード", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "この期間に読み込んだレコード {{count}} 件", + calendarRecordCount_one: "この期間に読み込んだレコード {{count}} 件", + calendarRecordCount_other: "この期間に読み込んだレコード {{count}} 件", addRecord: "レコードを追加", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "すべて表示", @@ -1134,7 +1139,7 @@ const ja = { nextRecord: "次のレコード", recordOf: "{{current}} / {{total}}", recordNotFound: "レコードが見つかりません", - recordNotFoundDescription: "お探しのレコードは存在しないか、削除された可能性があります。", + recordNotFoundDescription: "お探しのレコードは存在しないか、削除されたか、アクセス権がない可能性があります。", goBack: "戻る", details: "詳細", related: "関連", @@ -3187,7 +3192,7 @@ const ja = { systemSettings: "システム設定", back: "戻る", recordNotFound: "レコードが見つかりません", - recordNotFoundDescription: "お探しのレコードは存在しないか、削除された可能性があります。", + recordNotFoundDescription: "お探しのレコードは存在しないか、削除されたか、アクセス権がない可能性があります。", recordAccessDenied: "{{object}}レコードへのアクセス権がありません", recordAccessDeniedDescription: "この種類のレコードを表示する権限がありません。必要な場合は管理者にお問い合わせください。", recordLoadFailed: "このレコードを読み込めませんでした", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index dac66918156..8acb9b98af4 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -793,6 +793,11 @@ const ko = { recordCount: "{{count}}개 레코드", recordCount_one: "{{count}}개 레코드", recordCount_other: "{{count}}개 레코드", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "이 기간에 불러온 레코드 {{count}}개", + calendarRecordCount_one: "이 기간에 불러온 레코드 {{count}}개", + calendarRecordCount_other: "이 기간에 불러온 레코드 {{count}}개", addRecord: "레코드 추가", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "모두 보기", @@ -1134,7 +1139,7 @@ const ko = { nextRecord: "다음 레코드", recordOf: "{{current}} / {{total}}", recordNotFound: "레코드를 찾을 수 없음", - recordNotFoundDescription: "찾으시는 레코드가 존재하지 않거나 삭제되었을 수 있습니다.", + recordNotFoundDescription: "찾으시는 레코드가 존재하지 않거나, 삭제되었거나, 접근 권한이 없을 수 있습니다.", goBack: "뒤로", details: "상세정보", related: "관련", @@ -3184,7 +3189,7 @@ const ko = { systemSettings: "시스템 설정", back: "뒤로", recordNotFound: "레코드를 찾을 수 없습니다", - recordNotFoundDescription: "찾고 있는 레코드가 존재하지 않거나 삭제되었을 수 있습니다.", + recordNotFoundDescription: "찾고 있는 레코드가 존재하지 않거나, 삭제되었거나, 접근 권한이 없을 수 있습니다.", recordAccessDenied: "{{object}} 레코드에 접근할 권한이 없습니다", recordAccessDeniedDescription: "이 유형의 레코드를 볼 권한이 없습니다. 접근이 필요하면 관리자에게 문의하세요.", recordLoadFailed: "이 레코드를 불러오지 못했습니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index def65c2b726..37712e5357d 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -815,6 +815,12 @@ const pt = { recordCount_one: "{{count}} registro", recordCount_many: "{{count}} de registros", recordCount_other: "{{count}} registros", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "{{count}} registros carregados para estas datas", + calendarRecordCount_one: "{{count}} registro carregado para estas datas", + calendarRecordCount_many: "{{count}} de registros carregados para estas datas", + calendarRecordCount_other: "{{count}} registros carregados para estas datas", addRecord: "Adicionar registro", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "Ver tudo", @@ -1164,7 +1170,7 @@ const pt = { nextRecord: "Próximo registro", recordOf: "{{current}} de {{total}}", recordNotFound: "Registro não encontrado", - recordNotFoundDescription: "O registro que você está procurando não existe ou pode ter sido excluído.", + recordNotFoundDescription: "O registro que você está procurando não existe, pode ter sido excluído ou você pode não ter acesso a ele.", goBack: "Voltar", details: "Detalhes", related: "Relacionados", @@ -3243,7 +3249,7 @@ const pt = { systemSettings: "Configurações do sistema", back: "Voltar", recordNotFound: "Registro não encontrado", - recordNotFoundDescription: "O registro que você está procurando não existe ou pode ter sido excluído.", + recordNotFoundDescription: "O registro que você está procurando não existe, pode ter sido excluído ou você pode não ter acesso a ele.", recordAccessDenied: "Você não tem acesso aos registros de {{object}}", recordAccessDeniedDescription: "Você não tem permissão para ver registros deste tipo. Contate seu administrador se acha que deveria ter acesso.", recordLoadFailed: "Não foi possível carregar este registro", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 13363f2483d..d76c8612264 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -843,6 +843,13 @@ const ru = { recordCount_few: "{{count}} записи", recordCount_many: "{{count}} записей", recordCount_other: "{{count}} записи", + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: "Загружено записей за эти даты: {{count}}", + calendarRecordCount_one: "За эти даты загружена {{count}} запись", + calendarRecordCount_few: "За эти даты загружены {{count}} записи", + calendarRecordCount_many: "За эти даты загружено {{count}} записей", + calendarRecordCount_other: "За эти даты загружено {{count}} записи", addRecord: "Добавить запись", // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: "Показать все", @@ -1201,7 +1208,7 @@ const ru = { nextRecord: "Следующая запись", recordOf: "{{current}} из {{total}}", recordNotFound: "Запись не найдена", - recordNotFoundDescription: "Запись, которую вы ищете, не существует или была удалена.", + recordNotFoundDescription: "Запись, которую вы ищете, не существует, была удалена или у вас нет к ней доступа.", goBack: "Назад", details: "Детали", related: "Связанные", @@ -3320,7 +3327,7 @@ const ru = { systemSettings: "Системные настройки", back: "Назад", recordNotFound: "Запись не найдена", - recordNotFoundDescription: "Искомая запись не существует или была удалена.", + recordNotFoundDescription: "Искомая запись не существует, была удалена или у вас нет к ней доступа.", recordAccessDenied: "Нет доступа к записям «{{object}}»", recordAccessDeniedDescription: "У вас нет прав на просмотр записей этого типа. Обратитесь к администратору, если считаете, что доступ вам нужен.", recordLoadFailed: "Не удалось загрузить эту запись", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index cfe6b9f64f2..df0b662cc98 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -768,6 +768,11 @@ const zh = { recordCount: '{{count}} 条记录', recordCount_one: '{{count}} 条记录', recordCount_other: '{{count}} 条记录', + // objectstack#27355 — a windowed calendar's footer: the count is the records + // loaded for the dates it draws, not the records the view matches. + calendarRecordCount: '已加载此日期范围内的 {{count}} 条记录', + calendarRecordCount_one: '已加载此日期范围内的 {{count}} 条记录', + calendarRecordCount_other: '已加载此日期范围内的 {{count}} 条记录', addRecord: '添加记录', // objectui#12703 — the compact list's footer link to the object's list or the block's view. viewAll: '查看全部', @@ -1213,7 +1218,7 @@ const zh = { nextRecord: '下一条记录', recordOf: '第 {{current}} 条,共 {{total}} 条', recordNotFound: '未找到记录', - recordNotFoundDescription: '您查找的记录不存在或已被删除。', + recordNotFoundDescription: '您查找的记录不存在、已被删除,或您没有访问权限。', goBack: '返回', details: '详情', related: '相关', @@ -3235,7 +3240,7 @@ const zh = { objectNotFoundDescription: '对象 “{{name}}” 的定义不存在。请检查配置或返回选择有效的对象。', interfacePageSourceMissing: '此界面页引用了 “{{name}}”,但该来源不可用。', recordNotFound: '未找到记录', - recordNotFoundDescription: '您查找的记录不存在或已被删除。', + recordNotFoundDescription: '您查找的记录不存在、已被删除,或您没有访问权限。', recordAccessDenied: '无权访问{{object}}记录', recordAccessDeniedDescription: '你没有查看此类记录的权限。如需访问,请联系管理员。', recordLoadFailed: '无法加载此记录', diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index ab423dd671e..ea5ca4f66f6 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -85,7 +85,7 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.nextRecord': 'Next record', 'detail.recordOf': '{{current}} of {{total}}', 'detail.recordNotFound': 'Record not found', - 'detail.recordNotFoundDescription': 'The record you are looking for does not exist or may have been deleted.', + 'detail.recordNotFoundDescription': 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.', 'detail.goBack': 'Go back', 'detail.details': 'Details', // objectstack#26678 — the record page's related column and its two-column diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 13692c8d913..8866406e8d9 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -1015,6 +1015,9 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.recordCount': '{{count}} records', 'list.recordCount_one': '{{count}} record', 'list.recordCount_other': '{{count}} records', + 'list.calendarRecordCount': '{{count}} records loaded for these dates', + 'list.calendarRecordCount_one': '{{count}} record loaded for these dates', + 'list.calendarRecordCount_other': '{{count}} records loaded for these dates', 'list.noItems': 'No items found', 'list.noItemsMessage': 'There are no records to display. Try adjusting your filters or adding new data.', // First-run (truly empty, no filter/search) vs filtered-to-empty. Showing @@ -6767,7 +6770,12 @@ export const ListView = React.forwardRef(({ whole result set is in memory, serverTotal is null and data.length already IS the total. */} {/* One count family (objectui#11445): i18next picks the CLDR slot. */} - {t('list.recordCount', { count: serverTotal ?? data.length })} + {/* A windowed calendar's rows are the dates it draws, not the view + (objectstack#27355): its count says so rather than read as the + view's total. */} + {calendarWindowed + ? t('list.calendarRecordCount', { count: data.length }) + : t('list.recordCount', { count: serverTotal ?? data.length })} )} {/* The cap warning is about rows the user CANNOT REACH. A paged grid diff --git a/ui/packages/plugin-list/src/__tests__/ListView.calendarFooterCount-27355.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.calendarFooterCount-27355.test.tsx new file mode 100644 index 00000000000..81b076077ef --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ListView.calendarFooterCount-27355.test.tsx @@ -0,0 +1,143 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27355 row 8 — a calendar view's footer count says it counts the + * records loaded for the dates on screen, not the records the view matches. + * + * A hosted calendar fetches a date window (objectui#12081), so the footer's + * count is the window's rows. It read "N records", the same words a grid uses + * for the view's total, so a reader took a month's rows for the view's size + * (HotCRM 18.2.0 browser pass, Marketing S6). Counting the view would be a + * second query; the window's rows are what this view already holds, so the + * footer now names them as such. + * + * The calendar is a registry stub (as in `ListView.calendarDateWindow-12081`): + * `plugin-calendar` is not a dependency of this package. The rows live in a + * `ValueDataSource`, which evaluates `$filter` / `$skip` / `$top` the way a + * backend does, so the window really selects the month. + */ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry, ValueDataSource } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +/** "Today" for every case here: the calendar opens on October 2026. */ +const NOW = new Date(2026, 9, 14, 10, 0, 0); + +type Row = Record; + +/** `count` contracts ending on days of `month` (0-based) in 2026. */ +function contractsIn(month: number, count: number, prefix: string): Row[] { + const days = new Date(2026, month + 1, 0).getDate(); + return Array.from({ length: count }, (_, i) => { + const mm = String(month + 1).padStart(2, '0'); + const dd = String((i % days) + 1).padStart(2, '0'); + return { id: `${prefix}-${i}`, name: `${prefix} ${i}`, end_date: `2026-${mm}-${dd}`, status: 'active' }; + }); +} + +const STUBBED = ['object-calendar', 'object-grid'] as const; +const previous = new Map(); +beforeAll(() => { + for (const type of STUBBED) { + previous.set(type, ComponentRegistry.get(type)); + ComponentRegistry.register(type, () =>
); + } +}); +afterAll(() => { + for (const type of STUBBED) { + const prev = previous.get(type); + if (prev) ComponentRegistry.register(type, prev as any); + else ComponentRegistry.unregister(type); + } +}); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function makeDataSource(rows: Row[]) { + const store = new ValueDataSource({ items: rows }); + return { + find: vi.fn(async (object: string, params: any) => store.find(object, params)), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + name: { type: 'text' }, + end_date: { type: 'date' }, + status: { type: 'select', options: [{ label: 'Active', value: 'active' }] }, + }, + }), + } as any; +} + +const renderList = (schemaExtra: Record, ds: any) => + render( + + + , + ); + +const footerText = () => screen.getByTestId('record-count-bar').textContent ?? ''; + +// 12 in October (the month on screen) and 30 in March, outside the window. +const ROWS = [...contractsIn(2, 30, 'mar'), ...contractsIn(9, 12, 'oct')]; + +describe('a calendar view\'s footer counts the records loaded for its dates (objectstack#27355)', () => { + it('a windowed calendar names its count as the window\'s, and counts the window', async () => { + renderList({ viewType: 'calendar', calendar: { startDateField: 'end_date', titleField: 'name' } }, makeDataSource(ROWS)); + await waitFor(() => expect(footerText()).toContain('12 records loaded for these dates')); + expect(footerText()).not.toContain('42'); + }); + + it('one record reads in the singular', async () => { + renderList( + { viewType: 'calendar', calendar: { startDateField: 'end_date', titleField: 'name' } }, + makeDataSource([...contractsIn(2, 30, 'mar'), ...contractsIn(9, 1, 'oct')]), + ); + await waitFor(() => expect(footerText()).toContain('1 record loaded for these dates')); + }); + + it('control: a grid over the same object still counts the view, in the same words as before', async () => { + renderList({ viewType: 'grid' }, makeDataSource(ROWS)); + await waitFor(() => expect(footerText()).toContain('42 records')); + expect(footerText()).not.toContain('loaded for these dates'); + }); + + it('control: a calendar over rows handed in whole counts them as the view', async () => { + renderList( + { viewType: 'calendar', calendar: { startDateField: 'end_date', titleField: 'name' }, data: ROWS }, + makeDataSource([]), + ); + await waitFor(() => expect(footerText()).toContain('42 records')); + expect(footerText()).not.toContain('loaded for these dates'); + }); +}); From b693cde20c994b365829c109a8726712bdbc252e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:23:21 +0000 Subject: [PATCH 2/4] test(ui): follow the two-across phone ramp in the grid whitelist and schema-catalog pins Claude-Session: https://claude.ai/code/session_01Utp6CXNi3EtSwLHRSornGu Co-authored-by: Claude --- .../schema-catalog/test/grid-columns-key.test.tsx | 9 +++++---- .../src/__tests__/grid-dom-attribute-whitelist.test.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/examples/schema-catalog/test/grid-columns-key.test.tsx b/ui/examples/schema-catalog/test/grid-columns-key.test.tsx index c65f1654fc5..5ec6a8d8589 100644 --- a/ui/examples/schema-catalog/test/grid-columns-key.test.tsx +++ b/ui/examples/schema-catalog/test/grid-columns-key.test.tsx @@ -129,20 +129,21 @@ describe('repaired grid examples render the responsive ramp (#4001)', () => { 'components-data-display-statistic/metrics-grid', 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4', ], - // columns: 4 — was `grid grid-cols-2 gap-4` + // columns: 4 — was `grid grid-cols-2 gap-4`; two across on a phone + // since objectstack#27355 [ 'report/report-header-with-kpis', - 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4', + 'grid grid-cols-2 md:grid-cols-4 gap-4', ], // columns: 4 + className passthrough — was `grid grid-cols-2 gap-4 mb-6` [ 'components-layout-page/full-dashboard', - 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6', + 'grid grid-cols-2 md:grid-cols-4 gap-4 mb-6', ], // columns: 4 with a non-default gap — was `grid grid-cols-2 gap-3 p-4` [ 'theme/semantic-color-palette', - 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 p-4', + 'grid grid-cols-2 md:grid-cols-4 gap-3 p-4', ], // columns: 2 — the `div` → `grid` migration exemplar that // `content/docs/components/basic/div.mdx` points authors at. Its column count diff --git a/ui/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx b/ui/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx index 49de45450ac..613f4fbaaac 100644 --- a/ui/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx +++ b/ui/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx @@ -157,11 +157,11 @@ describe('ui:grid — schema keys never reach the DOM (objectui#4787)', () => { // Computed layout classes — the renderer's real output, merged with the // authored className. A filter that dropped `className` would fail here. - // The bare `columns: 4` draws its mobile-first ramp: the canary's flat - // `mdColumns: 2` is read by nothing (objectui#11505), so `md` is the bare - // count, not the flat one. + // The bare `columns: 4` draws its mobile-first ramp (two across on a + // phone, objectstack#27355): the canary's flat `mdColumns: 2` is read by + // nothing (objectui#11505), so `md` is the bare count, not the flat one. expect(el.className).toContain('grid'); - expect(el.className).toContain('sm:grid-cols-2'); + expect(el.className).toContain('grid-cols-2'); expect(el.className).toContain('md:grid-cols-4'); expect(el.className).not.toContain('md:grid-cols-2'); expect(el.className).toContain('gap-4'); From 40a769cc03f298f8c9d0c2e0941190f1c934e3a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:06:33 +0000 Subject: [PATCH 3/4] test(plugin-detail): follow the not-found sentence that now names no access Claude-Session: https://claude.ai/code/session_01Utp6CXNi3EtSwLHRSornGu Co-authored-by: Claude --- ui/packages/plugin-detail/src/__tests__/DetailView.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/plugin-detail/src/__tests__/DetailView.test.tsx b/ui/packages/plugin-detail/src/__tests__/DetailView.test.tsx index 5f19399b42d..38fb0653258 100644 --- a/ui/packages/plugin-detail/src/__tests__/DetailView.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/DetailView.test.tsx @@ -574,7 +574,7 @@ describe('DetailView', () => { const { findByText } = render(); expect(await findByText('Record not found')).toBeInTheDocument(); - expect(await findByText(/does not exist or may have been deleted/)).toBeInTheDocument(); + expect(await findByText(/does not exist, may have been deleted, or you may not have access to it/)).toBeInTheDocument(); }); it('should show "Go back" button in "Record not found" state when showBack is true', async () => { From 04a597742f29a34c547ee8401b13c784821f3c8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:25:29 +0000 Subject: [PATCH 4/4] fix(app-shell): the record page's not-found fallback follows the en sentence Claude-Session: https://claude.ai/code/session_01Utp6CXNi3EtSwLHRSornGu Co-authored-by: Claude --- ui/packages/app-shell/src/views/RecordDetailView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 71f66224427..ad4cce4df35 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -3610,7 +3610,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri {t('empty.recordNotFoundDescription', { defaultValue: - 'The record you are looking for does not exist or may have been deleted.', + 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.', })}