diff --git a/.changeset/7063-widget-empty-state-default.md b/.changeset/7063-widget-empty-state-default.md
new file mode 100644
index 0000000000..3de7b6ba56
--- /dev/null
+++ b/.changeset/7063-widget-empty-state-default.md
@@ -0,0 +1,41 @@
+---
+'@object-ui/plugin-dashboard': minor
+'@object-ui/i18n': minor
+---
+
+Dashboard/analytics widgets get a self-explaining DEFAULT empty state, stated
+once for the surface (objectui#7063).
+
+Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
+renders a bare row-placeholder on an empty result is the PLATFORM's defect and
+must be fixed uniformly — apps must not compensate widget by widget
+(objectstack#13848). The measured scenario is a fresh flagship-demo install:
+eleven populated tiles and one reading exactly `暂无数据行` mid-page, which reads
+as "the dashboard failed to load" even though the widget, its declaration and
+its (not yet produced) data are all legitimate.
+
+- New `WidgetEmptyState` is the seam the three dashboard surfaces now share.
+ There was no shared placeholder to fix: `DatasetWidget` wrote
+ `dashboard.noRows`, while `ObjectDataTable` and `PivotTable` wrote
+ `dashboard.noDataAvailable` — three renders, two strings, no common code.
+- The default now reads as a STATE, not a failure: `role="status"` (the empty
+ branches previously carried no role at all, while the failure branches beside
+ them are `role="alert"`), muted treatment with an inbox glyph rather than a
+ warning triangle, and a title plus an explanation where the placeholder was a
+ single terse fragment.
+- It names WHAT is empty with zero authored copy — the widget's data source,
+ which is the half the reader cannot already see (the tile's title is rendered
+ by the card header directly above). That is `widget.dataset` on the dataset
+ path and `schema.objectName` on the object-bound table/pivot; `PivotTable`
+ takes it as a new optional `sourceLabel` prop, which `ObjectPivotTable`
+ forwards.
+- Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel`
+ added to `en` and all nine sibling packs. No inline `defaultValue` and no
+ interpolation — the source renders as a labelled value, so no separator is
+ concatenated in code and every pack spells its own punctuation.
+
+No new authoring obligation and no new spec key. Note that the `emptyState`
+override the card assumes for this surface does not exist: `emptyState` is a
+LIST-view contract, and `@objectstack/spec`'s `DashboardWidgetSchema` declares no
+such key — so there is nothing here for an author to override, and adding one
+would be a contract question rather than a rider.
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 993e8356dc..5f51e825ea 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -1053,6 +1053,17 @@ const ar = {
},
dashboard: {
noRows: "لا توجد صفوف",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "لا توجد بيانات بعد",
+ message: "تم تحميل هذه الأداة بنجاح، لكن الاستعلام لم يُرجع أي سجلات بعد.",
+ sourceLabel: "مصدر البيانات:",
+ },
loading: "جارٍ التحميل…",
pickMeasures: "اختر المقاييس (القيم) لأداة مجموعة البيانات هذه.",
datasetUnsupported: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 60dce6ad7d..c7e206527e 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -1046,6 +1046,17 @@ const de = {
},
dashboard: {
noRows: "Keine Zeilen",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "Noch keine Daten",
+ message: "Dieses Widget wurde erfolgreich geladen, die Abfrage hat aber noch keine Datensätze zurückgegeben.",
+ sourceLabel: "Datenquelle:",
+ },
loading: "Wird geladen…",
pickMeasures: "Wählen Sie Kennzahlen (Werte) für dieses Dataset-Widget.",
datasetUnsupported: "Diese Datenquelle unterstützt keine Dataset-Abfragen.",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 7a05cfa800..44003f7a1b 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -1248,6 +1248,17 @@ const en = {
noDataAvailable: 'No data available',
noDataSourceFor: 'No data source available for',
noRows: 'No rows',
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: 'No data yet',
+ message: 'This widget loaded successfully and its query returned no records yet.',
+ sourceLabel: 'Source:',
+ },
loading: 'Loading…',
pickMeasures: 'Pick measures (values) for this dataset widget.',
datasetUnsupported: 'This data source does not support dataset queries.',
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index e91be2b5eb..98eea37077 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -1050,6 +1050,17 @@ const es = {
},
dashboard: {
noRows: "Sin filas",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "Aún no hay datos",
+ message: "Este widget se cargó correctamente, pero la consulta todavía no ha devuelto ningún registro.",
+ sourceLabel: "Origen de datos:",
+ },
loading: "Cargando…",
pickMeasures: "Elija medidas (valores) para este widget de dataset.",
datasetUnsupported: "Esta fuente de datos no admite consultas de dataset.",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index c06533e513..5a846f8642 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -1048,6 +1048,17 @@ const fr = {
},
dashboard: {
noRows: "Aucune ligne",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "Pas encore de données",
+ message: "Ce widget s'est chargé correctement, mais la requête n'a encore renvoyé aucun enregistrement.",
+ sourceLabel: "Source de données :",
+ },
loading: "Chargement…",
pickMeasures: "Choisissez des mesures (valeurs) pour ce widget de dataset.",
datasetUnsupported: "Cette source de données ne prend pas en charge les requêtes de dataset.",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 61c802a8bd..00ffc6ed8b 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -1046,6 +1046,17 @@ const ja = {
},
dashboard: {
noRows: "行がありません",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "まだデータがありません",
+ message: "このウィジェットは正常に読み込まれましたが、クエリはまだレコードを返していません。",
+ sourceLabel: "データソース:",
+ },
loading: "読み込み中…",
pickMeasures: "このデータセットウィジェットの指標(値)を選択してください。",
datasetUnsupported: "このデータソースはデータセットクエリに対応していません。",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index e9102bca29..bce8804861 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -1046,6 +1046,17 @@ const ko = {
},
dashboard: {
noRows: "행 없음",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "아직 데이터가 없습니다",
+ message: "이 위젯은 정상적으로 로드되었지만 쿼리가 아직 레코드를 반환하지 않았습니다.",
+ sourceLabel: "데이터 소스:",
+ },
loading: "로딩 중…",
pickMeasures: "이 데이터셋 위젯의 측정값(값)을 선택하세요.",
datasetUnsupported: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 9bc51b8244..b36dde1f1e 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -1045,6 +1045,17 @@ const pt = {
},
dashboard: {
noRows: "Sem linhas",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "Ainda sem dados",
+ message: "Este widget foi carregado com sucesso, mas a consulta ainda não retornou nenhum registro.",
+ sourceLabel: "Fonte de dados:",
+ },
loading: "Carregando…",
pickMeasures: "Escolha medidas (valores) para este widget de dataset.",
datasetUnsupported: "Esta fonte de dados não oferece suporte a consultas de dataset.",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index b41eb49060..7be7c8e5e8 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -1056,6 +1056,17 @@ const ru = {
},
dashboard: {
noRows: "Нет строк",
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: "Данных пока нет",
+ message: "Виджет успешно загружен, но запрос пока не вернул ни одной записи.",
+ sourceLabel: "Источник данных:",
+ },
loading: "Загрузка…",
pickMeasures: "Выберите меры (значения) для этого виджета набора данных.",
datasetUnsupported: "Этот источник данных не поддерживает запросы к наборам данных.",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 5d11042f4a..90dc6e7f53 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -1119,6 +1119,17 @@ const zh = {
noDataAvailable: '暂无数据',
noDataSourceFor: '没有可用的数据源:',
noRows: '暂无数据行',
+ // objectui#7063 — the DEFAULT empty state every dashboard widget renders
+ // when its query SUCCEEDED and returned nothing. `noRows` above is the
+ // terse fragment it replaces at the render site; the copy here has to
+ // read as a state rather than a failure, which is why it says the widget
+ // loaded. `sourceLabel` carries its own punctuation so the call site
+ // concatenates no separator (see `WidgetEmptyState`).
+ empty: {
+ title: '暂时还没有数据',
+ message: '该组件已成功加载,只是查询目前没有返回任何记录。',
+ sourceLabel: '数据源:',
+ },
loading: '加载中…',
pickMeasures: '请为该数据集组件选择度量(值)。',
datasetUnsupported: '当前数据源不支持数据集查询。',
diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx
index 43eff62dac..f8f61e7129 100644
--- a/packages/plugin-dashboard/src/DatasetWidget.tsx
+++ b/packages/plugin-dashboard/src/DatasetWidget.tsx
@@ -77,7 +77,10 @@ import {
} from '@object-ui/core';
import { cn, Skeleton, ChartSkeleton, GridSkeleton } from '@object-ui/components';
import { useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n';
-import { BarChart3, AlertTriangle, Download, ArrowUpIcon, ArrowDownIcon, MinusIcon, ChevronsUpDown, ChevronUp, ChevronDown } from 'lucide-react';
+import { AlertTriangle, Download, ArrowUpIcon, ArrowDownIcon, MinusIcon, ChevronsUpDown, ChevronUp, ChevronDown } from 'lucide-react';
+// objectui#7063 — the default empty state is stated ONCE for the dashboard
+// surface (see that component's header for why it is dashboard-local).
+import { WidgetEmptyState } from './WidgetEmptyState';
import { useFilterScope } from '@object-ui/react';
import { resolveFilterPlaceholders, computeMetricDelta } from './utils';
import { metricAccentTextClass } from './colorVariants';
@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
);
}
- // A metric (single value) over an empty dataset is 0, not "No rows" — the
- // latter reads as broken for KPIs like "Total Books" on a fresh app. Charts
- // and tables keep the empty state (there is genuinely nothing to plot).
+ // A metric (single value) over an empty dataset is 0, not an empty state —
+ // the latter reads as broken for KPIs like "Total Books" on a fresh app.
+ // Charts and tables keep the empty state (there is genuinely nothing to
+ // plot).
+ //
+ // objectui#7063: this used to be a one-line `dashboard.noRows` fragment in a
+ // dashed box — 'No rows' / `暂无数据行` — with no role, no explanation and no
+ // mention of WHAT was empty, which is why a legitimately young tile read as
+ // "the dashboard failed to load" beside eleven populated ones. `datasetName`
+ // is `widget.dataset` and is non-empty on every path that reaches here (a
+ // widget with no dataset never mounts this component), but it is passed
+ // defensively so a blank binding degrades to the un-sourced copy rather than
+ // printing an empty label.
if (state.rows.length === 0 && !isMetric) {
- return
{tt('dashboard.noRows', 'No rows')}
;
+ return (
+
+ );
}
// Measure metadata (label + format + currency) + header-label resolution,
diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx
index b844fe13e1..1255b523b5 100644
--- a/packages/plugin-dashboard/src/ObjectDataTable.tsx
+++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx
@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
+import { WidgetEmptyState } from './WidgetEmptyState';
export interface ObjectDataTableProps {
schema: {
@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo
const dataSource = propDataSource || context?.dataSource;
const boundData = useDataScope(schema.bind);
const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel();
- let noDataLabel = 'No data available';
let noDataSourceLabel = 'No data source available for';
// useObjectTranslation is provider-safe (react-i18next falls back to the
// global instance and never throws), so call it directly — no try/catch,
- // which would make the hook conditional. The English defaults above stand
+ // which would make the hook conditional. The English default above stands
// until a translation resolves.
+ //
+ // objectui#7063 removed this surface's second lookup (`noDataLabel` /
+ // `dashboard.noDataAvailable`): the empty branch now renders the shared
+ // `WidgetEmptyState`, which resolves its own copy. The no-data-SOURCE label
+ // below is a different state — a misconfigured binding, not an empty result
+ // — and keeps its own string.
const { t } = useObjectTranslation();
- const a = t('dashboard.noDataAvailable');
- if (a && a !== 'dashboard.noDataAvailable') noDataLabel = a;
const b = t('dashboard.noDataSourceFor');
if (b && b !== 'dashboard.noDataSourceFor') noDataSourceLabel = b;
@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo
);
}
- // Empty state
+ // Empty state — the shared dashboard default (objectui#7063). It used to be a
+ // grid glyph over a bare `dashboard.noDataAvailable` line ('No data
+ // available' / `暂无数据`) with no role and no explanation, which is the
+ // same "reads as a load failure" shape the ruling is about — and it sat
+ // directly above an error block that DOES announce itself (`role="alert"`,
+ // destructive colours). `schema.objectName` is what this surface can name:
+ // the object the table is bound to. The wrapper keeps `table-empty-state`,
+ // which `ObjectDataTable.stableEmptyRows` and app-shell's widget DOM leak
+ // sweep both select on.
if (finalData.length === 0) {
return (
-
-
-
{noDataLabel}
-
+
);
}
diff --git a/packages/plugin-dashboard/src/ObjectPivotTable.tsx b/packages/plugin-dashboard/src/ObjectPivotTable.tsx
index c759ad7ba0..f15f6c7554 100644
--- a/packages/plugin-dashboard/src/ObjectPivotTable.tsx
+++ b/packages/plugin-dashboard/src/ObjectPivotTable.tsx
@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC = ({ schema, data
return (
<>
+ {/* objectui#7063 — `sourceLabel` is the object this pivot is bound to,
+ named by the shared default empty state. Passed as a PROP because
+ `finalSchema` is a `PivotTableSchema`, which declares no
+ `objectName`; it survives the spread above only by accident and
+ reading it there would be reading a key the type denies. */}
{renderDrillDrawer()}
diff --git a/packages/plugin-dashboard/src/PivotTable.tsx b/packages/plugin-dashboard/src/PivotTable.tsx
index f3069d8eec..f2fb79a267 100644
--- a/packages/plugin-dashboard/src/PivotTable.tsx
+++ b/packages/plugin-dashboard/src/PivotTable.tsx
@@ -8,18 +8,15 @@
import React, { useMemo } from 'react';
import type { PivotTableSchema, PivotAggregation } from '@object-ui/types';
-import { cn, DataEmptyState } from '@object-ui/components';
+import { cn } from '@object-ui/components';
import { isDrillEnabled, type DrillEvent } from '@object-ui/core';
import { useSafeTranslate } from '@object-ui/i18n';
+import { WidgetEmptyState } from './WidgetEmptyState';
function useTotalLabel(): string {
return useSafeTranslate()('dashboard.total', 'Total');
}
-function useNoDataLabel(): string {
- return useSafeTranslate()('dashboard.noDataAvailable', 'No data available');
-}
-
export interface PivotTableProps {
schema: PivotTableSchema;
className?: string;
@@ -34,6 +31,15 @@ export interface PivotTableProps {
columnLabels?: Record;
/** Optional display label for the row field name (e.g. "Stage" for "stage"). */
rowFieldLabel?: string;
+ /**
+ * What this pivot is bound to, named in the default empty state
+ * (objectui#7063). `ObjectPivotTable` passes its `schema.objectName`; a pivot
+ * over inline `schema.data` has no source to name and omits it. NOT read off
+ * `schema`: `PivotTableSchema` declares no `objectName`, so reading one would
+ * be reading a key the type says cannot be there — it survives
+ * `ObjectPivotTable`'s spread only by accident.
+ */
+ sourceLabel?: string;
/**
* Drill-down click handler. When provided **and** `schema.drillDown` is
* enabled, cells / row & column headers / totals become interactive.
@@ -160,7 +166,7 @@ function aggregate(values: number[], fn: PivotAggregation): number {
* Renders a matrix where rows correspond to `rowField`, columns to
* `columnField`, and cells show the aggregated `valueField`.
*/
-export const PivotTable: React.FC = ({ schema, className, rowLabels, columnLabels, rowFieldLabel, onDrillDown }) => {
+export const PivotTable: React.FC = ({ schema, className, rowLabels, columnLabels, rowFieldLabel, sourceLabel, onDrillDown }) => {
const {
title,
rowField,
@@ -176,7 +182,6 @@ export const PivotTable: React.FC = ({ schema, className, rowLa
drillDown,
} = schema;
const totalLabel = useTotalLabel();
- const noDataLabel = useNoDataLabel();
const drillEnabled = isDrillEnabled(drillDown) && typeof onDrillDown === 'function';
const fireDrill = (ev: DrillEvent) => {
@@ -296,21 +301,11 @@ export const PivotTable: React.FC = ({ schema, className, rowLa
{title && (
{title}
)}
-
-
-
-
-
-
- }
- iconWrapperClassName=""
- title=""
- description={noDataLabel}
- />
+ {/* objectui#7063 — the shared dashboard default. The title used to be
+ suppressed outright (`title=""` plus `[&>h3]:hidden`), leaving a
+ grid glyph over a bare `dashboard.noDataAvailable` line: exactly
+ the "reads as a load failure" shape the ruling is about. */}
+
);
}
diff --git a/packages/plugin-dashboard/src/WidgetEmptyState.tsx b/packages/plugin-dashboard/src/WidgetEmptyState.tsx
new file mode 100644
index 0000000000..a62aaa96ec
--- /dev/null
+++ b/packages/plugin-dashboard/src/WidgetEmptyState.tsx
@@ -0,0 +1,127 @@
+/**
+ * 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.
+ */
+
+/**
+ * WidgetEmptyState — the DEFAULT empty state every dashboard/analytics widget
+ * renders when its query succeeded and returned nothing.
+ *
+ * ## Why this exists (objectui#7063)
+ *
+ * Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget
+ * that renders a bare row-placeholder on an empty result is the PLATFORM's
+ * defect, not the app's. The measured scenario is a fresh flagship-demo
+ * install: eleven populated tiles and one reading exactly `暂无数据行`
+ * (`dashboard.noRows`, 'No rows') mid-page. Nothing on screen says whether the
+ * dashboard failed or is simply young, so the tile reads as a load failure —
+ * and the only app-side remedy was authoring per-widget prose, which is the
+ * per-app tax objectstack#13848 rules against.
+ *
+ * Three widget surfaces had written that placeholder independently, in two
+ * different strings — `DatasetWidget` (`dashboard.noRows`), `ObjectDataTable`
+ * and `PivotTable` (both `dashboard.noDataAvailable`). There was no shared
+ * seam to fix; this component IS the seam, stated once for the dashboard
+ * surface so "uniformly" is a property of the code and not of three copies
+ * agreeing by hand.
+ *
+ * ## What makes it distinguishable from a load failure — the three properties
+ *
+ * 1. **`role="status"`, never `role="alert"`.** The failure paths beside each
+ * call site (`DatasetWidget`'s `state.status === 'error'` box, the table's
+ * error block) are `role="alert"` on destructive colours. Before this, the
+ * empty branches carried NO role at all, so assistive tech got a bare
+ * fragment with no state either way. The two roles are now the machine
+ * check that the states are distinct, which is why no invented
+ * `data-empty-*` attribute is added: the semantics already carry it.
+ * 2. **Muted treatment + an inbox glyph**, never destructive colour + a
+ * warning triangle.
+ * 3. **A title AND an explanation**, where the placeholder used to be a single
+ * terse fragment. The copy says the widget LOADED SUCCESSFULLY — the one
+ * fact the reader of a blank tile cannot otherwise get.
+ *
+ * ## `source` names what is empty, and it is a raw identifier on purpose
+ *
+ * The card asks the default to name the widget's label and/or its data source
+ * rather than the bare placeholder. The widget's own title is already rendered
+ * by the `CardHeader` directly above every tile (`DashboardRenderer`), so
+ * repeating it here says nothing new; the DATA SOURCE is the half the reader
+ * cannot see. What is reachable at each render site is the authored binding —
+ * `widget.dataset` for the dataset path, `schema.objectName` for the
+ * object-bound table/pivot — i.e. a raw metadata name (`crm_forecast`), not a
+ * localized label. It is rendered as a labelled monospace value rather than
+ * folded into a sentence for exactly that reason: a truthful narrow statement
+ * beats an invented friendly one, and a label/value pair translates cleanly in
+ * every pack (including RTL) where a concatenated sentence would not.
+ *
+ * That is also why the copy carries no `{{interpolation}}`. `useSafeTranslate`
+ * takes a positional fallback and no options bag, so an interpolated key would
+ * have to come from a raw `useObjectTranslation` `t()` — and with no
+ * `I18nProvider` mounted (the standalone-host and test configuration) that
+ * renders the raw KEY unless the call site also carries an inline
+ * `defaultValue`, which objectui#3517 rules out. Label + value needs neither.
+ *
+ * ⚠️ NOT a cross-surface abstraction. `packages/plugin-detail`'s empty SECTION
+ * default (objectui#7064) is a sibling card on the same maintainer principle,
+ * dispatched in parallel; this component is deliberately dashboard-local and
+ * `DataEmptyState` — the presentational primitive both surfaces already use —
+ * is consumed here UNCHANGED. Converging the two is its own sequenced card,
+ * not a rider on either.
+ */
+
+import { cn, DataEmptyState } from '@object-ui/components';
+import { useSafeTranslate } from '@object-ui/i18n';
+import { Inbox } from 'lucide-react';
+
+export interface WidgetEmptyStateProps {
+ /**
+ * The authored binding this widget is empty FOR — `widget.dataset` on the
+ * dataset path, `schema.objectName` on the object-bound one. Omitted when the
+ * render site genuinely has no source to name (an inline-data pivot), in
+ * which case the title and explanation still stand on their own.
+ */
+ source?: string;
+ /** Layout classes for the surrounding tile; the muted/centred look is fixed. */
+ className?: string;
+ /**
+ * Test id for the root. Defaults to `widget-empty-state`; the table and pivot
+ * surfaces pass the ids their existing pins already select on
+ * (`table-empty-state` / `pivot-empty-state`, read by
+ * `ObjectDataTable.stableEmptyRows` and app-shell's widget DOM leak sweep).
+ */
+ testId?: string;
+}
+
+export function WidgetEmptyState({ source, className, testId }: WidgetEmptyStateProps) {
+ const tt = useSafeTranslate();
+ return (
+ h3]:text-sm [&>h3]:font-medium [&>p]:text-xs',
+ className,
+ )}
+ icon={}
+ iconWrapperClassName="flex size-9 items-center justify-center rounded-lg bg-muted"
+ title={tt('dashboard.empty.title', 'No data yet')}
+ description={tt(
+ 'dashboard.empty.message',
+ 'This widget loaded successfully and its query returned no records yet.',
+ )}
+ >
+ {source ? (
+
+ {/* The label carries its own punctuation so no separator is
+ concatenated in code — `Source:` / `数据源:` / `:المصدر` each
+ spell it the way their language does. */}
+ {tt('dashboard.empty.sourceLabel', 'Source:')}{' '}
+ {source}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx
index 070e2828f1..5b33464eeb 100644
--- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx
@@ -253,13 +253,16 @@ describe('DatasetWidget', () => {
// as empty/0 (a 0-value axis / blank table) instead of "loading".
const pendingSource = () => ({ queryDataset: vi.fn(() => new Promise<{ rows: any[] }>(() => {})) });
- it('shows a chart skeleton (not "No rows", not a 0-axis) while a chart query is pending', () => {
+ it('shows a chart skeleton (not the empty state, not a 0-axis) while a chart query is pending', () => {
const src = pendingSource();
const { container } = render();
expect(screen.getByTestId('dataset-loading')).toBeInTheDocument();
expect(container.querySelector('[data-slot="chart-skeleton"]')).toBeInTheDocument();
// The empty state must NOT show while loading — that was the bug.
- expect(screen.queryByText('No rows')).not.toBeInTheDocument();
+ // Selected by test id, not by the copy: objectui#7063 replaced the bare
+ // 'No rows' string, and a `queryByText` for a string that no longer exists
+ // anywhere passes for the wrong reason.
+ expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument();
});
it('shows a row (grid) skeleton while a table query is pending', () => {
@@ -284,14 +287,14 @@ describe('DatasetWidget', () => {
expect(screen.getByTestId('dataset-loading')).toBeInTheDocument();
// …then it clears once data arrives (status → ok, rows present).
await waitFor(() => expect(screen.queryByTestId('dataset-loading')).not.toBeInTheDocument());
- expect(screen.queryByText('No rows')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument();
});
- it('shows 0 (not "No rows") for a metric over an empty dataset', async () => {
+ it('shows 0 (not an empty state) for a metric over an empty dataset', async () => {
const src = makeSource(async () => ({ rows: [] }));
render();
expect(await screen.findByText('0')).toBeInTheDocument();
- expect(screen.queryByText('No rows')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument();
});
it('formats the empty-metric zero (e.g. $0) using the measure format', async () => {
@@ -303,10 +306,52 @@ describe('DatasetWidget', () => {
expect(await screen.findByText('$0')).toBeInTheDocument();
});
- it('still shows "No rows" for a dimensioned chart over an empty dataset', async () => {
+ // ── objectui#7063: the DEFAULT empty state is self-explaining ────────────
+ // Maintainer ruling 2026-08-31 (hotcrm#1212): a legitimately young widget
+ // must not read as a load failure, and it must say what is empty without any
+ // authored copy. The measured shape was a bare `暂无数据行` / 'No rows'
+ // fragment mid-dashboard beside eleven populated tiles.
+ //
+ // These pin the three properties the ruling fixes, NOT the exact sentence
+ // (copy is review's to move): the state announces itself as a STATUS rather
+ // than an alert, it carries an explanation as well as a title, and it names
+ // the widget's data source.
+ it('renders a self-explaining empty state for a dimensioned chart over an empty dataset', async () => {
const src = makeSource(async () => ({ rows: [] }));
- render();
- expect(await screen.findByText('No rows')).toBeInTheDocument();
+ render();
+ const panel = await screen.findByTestId('widget-empty-state');
+ // (1) a state, not a failure. The error branch of this same component is
+ // `role="alert"`; before this card the empty branch carried NO role at all,
+ // so assistive tech could not tell the two apart either.
+ expect(panel).toHaveAttribute('role', 'status');
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ // (2) title AND explanation — the placeholder was a single fragment.
+ expect(panel.textContent).toContain('No data yet');
+ expect(panel.textContent).toContain('loaded successfully');
+ // (3) it names WHAT is empty, with zero authored copy on the widget.
+ expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast');
+ });
+
+ it('the empty state degrades to un-sourced copy rather than printing a blank source', async () => {
+ // `dataset` is what every path into this component carries, so this is the
+ // defensive half: a blank binding must drop the source LINE, not render
+ // "Source:" with nothing after it.
+ const src = makeSource(async () => ({ rows: [] }));
+ render();
+ const panel = await screen.findByTestId('widget-empty-state');
+ expect(panel.textContent).toContain('No data yet');
+ expect(screen.queryByTestId('widget-empty-source')).not.toBeInTheDocument();
+ });
+
+ it('a load FAILURE still reads as a failure, not as an empty state', async () => {
+ // The other half of property (1), measured on the same component: the card
+ // is only satisfied if empty and failed remain distinguishable, so a fix
+ // that softened the error path would defeat it.
+ const src = { queryDataset: vi.fn(async () => { throw new Error('dataset service unreachable'); }) };
+ render();
+ const alert = await screen.findByRole('alert');
+ expect(alert.textContent).toContain('dataset service unreachable');
+ expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument();
});
// ── #2 header labels ────────────────────────────────────────────────────
diff --git a/packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx b/packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx
new file mode 100644
index 0000000000..6613b9369d
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx
@@ -0,0 +1,141 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#7063 — the dashboard/analytics DEFAULT empty state, pinned at the
+ * SEAM rather than once per widget.
+ *
+ * Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget
+ * that renders a bare row-placeholder on an empty result is the platform's
+ * defect and must be fixed UNIFORMLY — apps must not compensate widget by
+ * widget (objectstack#13848).
+ *
+ * ## What "uniformly" had to mean here, measured
+ *
+ * The card's premise was that `暂无数据行` is ONE shared placeholder every
+ * affected widget reaches. It is not. Three dashboard surfaces wrote their own,
+ * in two different strings:
+ *
+ * - `DatasetWidget` -> `dashboard.noRows` ('No rows' / `暂无数据行`)
+ * - `ObjectDataTable` -> `dashboard.noDataAvailable` ('No data available')
+ * - `PivotTable` -> `dashboard.noDataAvailable`
+ *
+ * `emptyState` — the authored override the card assumes exists — appears ZERO
+ * times in `packages/plugin-dashboard` and `packages/plugin-charts` (control in
+ * the same sweep: `widget`, 1154 hits). It is a LIST-view contract
+ * (`ObjectGridSchema` / `NamedListView`, honoured in `plugin-list`), and
+ * `@objectstack/spec`'s `DashboardWidgetSchema` declares no such key at all.
+ *
+ * So there was no seam to fix; `WidgetEmptyState` IS the seam. This file pins
+ * that all three surfaces reach it, which is the property that decays first: a
+ * fourth widget added later can quietly write a fourth placeholder, and every
+ * per-widget test stays green while "uniformly" stops being true.
+ *
+ * ## It pins PROPERTIES, not the sentence
+ *
+ * The ruling fixes what the state must DO — read as a state and not a failure,
+ * describe itself with no authored copy, name what is empty, translate through
+ * the platform packs. Exact copy is review's to move, so the assertions here
+ * are the role, the presence of an explanation beside the title, and the source
+ * name — never a full-string equality.
+ */
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { I18nProvider } from '@object-ui/i18n';
+import React from 'react';
+
+import { DatasetWidget } from '../DatasetWidget';
+import { ObjectDataTable } from '../ObjectDataTable';
+import { PivotTable } from '../PivotTable';
+import { ObjectPivotTable } from '../ObjectPivotTable';
+
+afterEach(cleanup);
+
+/** A dataSource whose every read succeeds and returns nothing. */
+const emptySource = () => ({
+ queryDataset: vi.fn(async () => ({ rows: [] })),
+ find: vi.fn(async () => []),
+ getObject: vi.fn(async () => ({ name: 'crm_forecast', fields: {} })),
+});
+
+describe('objectui#7063 — one self-explaining default across the dashboard surface', () => {
+ it('DatasetWidget (the measured surface) names its dataset', async () => {
+ render(
+ ,
+ );
+ const panel = await screen.findByTestId('widget-empty-state');
+ expect(panel).toHaveAttribute('role', 'status');
+ expect(panel.textContent).toContain('No data yet');
+ expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast');
+ });
+
+ it('ObjectDataTable reaches the same default and names its object', async () => {
+ render();
+ // The wrapper keeps the id the pre-existing pins select on…
+ await waitFor(() => expect(screen.getByTestId('table-empty-state')).toBeInTheDocument());
+ // …and the shared default renders inside it.
+ const panel = screen.getByTestId('widget-empty-state');
+ expect(panel).toHaveAttribute('role', 'status');
+ expect(screen.getByTestId('widget-empty-source').textContent).toContain('quota_attainment');
+ });
+
+ it('PivotTable reaches the same default (id preserved for the app-shell sweep)', () => {
+ render(
+ ,
+ );
+ const panel = screen.getByTestId('pivot-empty-state');
+ expect(panel).toHaveAttribute('role', 'status');
+ // The pivot used to suppress its title outright (`title=""` plus a
+ // `[&>h3]:hidden` rule), leaving only the terse description.
+ expect(panel.textContent).toContain('No data yet');
+ expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast');
+ });
+
+ it('ObjectPivotTable forwards its objectName as the source', async () => {
+ render(
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('pivot-empty-state')).toBeInTheDocument());
+ expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast');
+ });
+
+ it('the copy comes from the platform packs, not from an inline English default', async () => {
+ // The i18n half of the ruling: mounting a zh provider must move the whole
+ // default, title and explanation alike. An inline `defaultValue` (or a
+ // hard-coded string) renders English here and is untranslatable everywhere
+ // else — objectui#3517's exact failure, which is why this asserts on a
+ // MOUNTED provider rather than on the pack file's contents.
+ render(
+
+
+ ,
+ );
+ const panel = await screen.findByTestId('widget-empty-state');
+ await waitFor(() => expect(panel.textContent).toContain('暂时还没有数据'));
+ expect(panel.textContent).toContain('已成功加载');
+ // The bare placeholder this card replaces must be gone from the rendered
+ // output — not merely joined by better copy.
+ expect(panel.textContent).not.toContain('暂无数据行');
+ // The source line keeps its own punctuation from the pack (`数据源:`), so
+ // no separator is concatenated in code.
+ expect(screen.getByTestId('widget-empty-source').textContent).toContain('数据源:');
+ });
+});