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/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/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/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx
index c8977c232ec..e806f8f7ca0 100644
--- a/ui/packages/app-shell/src/views/RecordDetailView.tsx
+++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx
@@ -3643,7 +3643,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.',
})}
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-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');
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/__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 () => {
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');
+ });
+});