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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7063-widget-empty-state-default.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "このデータソースはデータセットクエリに対応していません。",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: "Этот источник данных не поддерживает запросы к наборам данных.",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '当前数据源不支持数据集查询。',
Expand Down
28 changes: 23 additions & 5 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
</div>
);
}
// 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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />{tt('dashboard.noRows', 'No rows')}</div>;
return (
<WidgetEmptyState
className="rounded border border-dashed bg-muted/20"
source={datasetName || undefined}
/>
);
}

// Measure metadata (label + format + currency) + header-label resolution,
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import {
isLookupType,
} from './recordFields';
import { RecordDetailDrawer } from './RecordDetailDrawer';
import { WidgetEmptyState } from './WidgetEmptyState';

export interface ObjectDataTableProps {
schema: {
Expand DownExpand Up@@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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;

Expand DownExpand Up@@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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 (
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p className="text-xs">{noDataLabel}</p>
</div>
<WidgetEmptyState source={schema.objectName || undefined} className="py-8" />
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ 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. */}
<PivotTable
schema={finalSchema}
className={className}
rowLabels={rowLabels}
columnLabels={colLabels}
rowFieldLabel={rowFieldLabel}
sourceLabel={schema.objectName}
onDrillDown={handleDrillDown}
/>
{renderDrillDrawer()}
Expand Down
Loading
Loading