From 79accd66f8801309d111a4b24d0e7313264dadfd Mon Sep 17 00:00:00 2001 From: os-warren Date: Tue, 1 Sep 2026 03:04:52 +0000 Subject: [PATCH] fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). There was no shared placeholder to fix. Three dashboard surfaces wrote their own, in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' / 暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`. `WidgetEmptyState` is the seam they now share. The default reads as a STATE rather than a failure — `role="status"` where the branches beside it are `role="alert"` and the empty branches previously carried no role at all — carries a title plus an explanation instead of one terse fragment, and names the widget's data source with zero authored copy. Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in `en` and all nine sibling packs. No inline defaultValue, no interpolation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .changeset/7063-widget-empty-state-default.md | 41 +++++ packages/i18n/src/locales/ar.ts | 11 ++ packages/i18n/src/locales/de.ts | 11 ++ packages/i18n/src/locales/en.ts | 11 ++ packages/i18n/src/locales/es.ts | 11 ++ packages/i18n/src/locales/fr.ts | 11 ++ packages/i18n/src/locales/ja.ts | 11 ++ packages/i18n/src/locales/ko.ts | 11 ++ packages/i18n/src/locales/pt.ts | 11 ++ packages/i18n/src/locales/ru.ts | 11 ++ packages/i18n/src/locales/zh.ts | 11 ++ .../plugin-dashboard/src/DatasetWidget.tsx | 28 +++- .../plugin-dashboard/src/ObjectDataTable.tsx | 31 ++-- .../plugin-dashboard/src/ObjectPivotTable.tsx | 6 + packages/plugin-dashboard/src/PivotTable.tsx | 39 +++-- .../plugin-dashboard/src/WidgetEmptyState.tsx | 127 ++++++++++++++++ .../src/__tests__/DatasetWidget.test.tsx | 61 +++++++- .../WidgetEmptyState.uniformDefault.test.tsx | 141 ++++++++++++++++++ 18 files changed, 536 insertions(+), 48 deletions(-) create mode 100644 .changeset/7063-widget-empty-state-default.md create mode 100644 packages/plugin-dashboard/src/WidgetEmptyState.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx diff --git a/.changeset/7063-widget-empty-state-default.md b/.changeset/7063-widget-empty-state-default.md new file mode 100644 index 0000000000..3de7b6ba56 --- /dev/null +++ b/.changeset/7063-widget-empty-state-default.md @@ -0,0 +1,41 @@ +--- +'@object-ui/plugin-dashboard': minor +'@object-ui/i18n': minor +--- + +Dashboard/analytics widgets get a self-explaining DEFAULT empty state, stated +once for the surface (objectui#7063). + +Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that +renders a bare row-placeholder on an empty result is the PLATFORM's defect and +must be fixed uniformly — apps must not compensate widget by widget +(objectstack#13848). The measured scenario is a fresh flagship-demo install: +eleven populated tiles and one reading exactly `暂无数据行` mid-page, which reads +as "the dashboard failed to load" even though the widget, its declaration and +its (not yet produced) data are all legitimate. + +- New `WidgetEmptyState` is the seam the three dashboard surfaces now share. + There was no shared placeholder to fix: `DatasetWidget` wrote + `dashboard.noRows`, while `ObjectDataTable` and `PivotTable` wrote + `dashboard.noDataAvailable` — three renders, two strings, no common code. +- The default now reads as a STATE, not a failure: `role="status"` (the empty + branches previously carried no role at all, while the failure branches beside + them are `role="alert"`), muted treatment with an inbox glyph rather than a + warning triangle, and a title plus an explanation where the placeholder was a + single terse fragment. +- It names WHAT is empty with zero authored copy — the widget's data source, + which is the half the reader cannot already see (the tile's title is rendered + by the card header directly above). That is `widget.dataset` on the dataset + path and `schema.objectName` on the object-bound table/pivot; `PivotTable` + takes it as a new optional `sourceLabel` prop, which `ObjectPivotTable` + forwards. +- Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` + added to `en` and all nine sibling packs. No inline `defaultValue` and no + interpolation — the source renders as a labelled value, so no separator is + concatenated in code and every pack spells its own punctuation. + +No new authoring obligation and no new spec key. Note that the `emptyState` +override the card assumes for this surface does not exist: `emptyState` is a +LIST-view contract, and `@objectstack/spec`'s `DashboardWidgetSchema` declares no +such key — so there is nothing here for an author to override, and adding one +would be a contract question rather than a rider. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 993e8356dc..5f51e825ea 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1053,6 +1053,17 @@ const ar = { }, dashboard: { noRows: "لا توجد صفوف", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "لا توجد بيانات بعد", + message: "تم تحميل هذه الأداة بنجاح، لكن الاستعلام لم يُرجع أي سجلات بعد.", + sourceLabel: "مصدر البيانات:", + }, loading: "جارٍ التحميل…", pickMeasures: "اختر المقاييس (القيم) لأداة مجموعة البيانات هذه.", datasetUnsupported: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 60dce6ad7d..c7e206527e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1046,6 +1046,17 @@ const de = { }, dashboard: { noRows: "Keine Zeilen", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "Noch keine Daten", + message: "Dieses Widget wurde erfolgreich geladen, die Abfrage hat aber noch keine Datensätze zurückgegeben.", + sourceLabel: "Datenquelle:", + }, loading: "Wird geladen…", pickMeasures: "Wählen Sie Kennzahlen (Werte) für dieses Dataset-Widget.", datasetUnsupported: "Diese Datenquelle unterstützt keine Dataset-Abfragen.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7a05cfa800..44003f7a1b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1248,6 +1248,17 @@ const en = { noDataAvailable: 'No data available', noDataSourceFor: 'No data source available for', noRows: 'No rows', + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: 'No data yet', + message: 'This widget loaded successfully and its query returned no records yet.', + sourceLabel: 'Source:', + }, loading: 'Loading…', pickMeasures: 'Pick measures (values) for this dataset widget.', datasetUnsupported: 'This data source does not support dataset queries.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e91be2b5eb..98eea37077 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1050,6 +1050,17 @@ const es = { }, dashboard: { noRows: "Sin filas", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "Aún no hay datos", + message: "Este widget se cargó correctamente, pero la consulta todavía no ha devuelto ningún registro.", + sourceLabel: "Origen de datos:", + }, loading: "Cargando…", pickMeasures: "Elija medidas (valores) para este widget de dataset.", datasetUnsupported: "Esta fuente de datos no admite consultas de dataset.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index c06533e513..5a846f8642 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1048,6 +1048,17 @@ const fr = { }, dashboard: { noRows: "Aucune ligne", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "Pas encore de données", + message: "Ce widget s'est chargé correctement, mais la requête n'a encore renvoyé aucun enregistrement.", + sourceLabel: "Source de données :", + }, loading: "Chargement…", pickMeasures: "Choisissez des mesures (valeurs) pour ce widget de dataset.", datasetUnsupported: "Cette source de données ne prend pas en charge les requêtes de dataset.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 61c802a8bd..00ffc6ed8b 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1046,6 +1046,17 @@ const ja = { }, dashboard: { noRows: "行がありません", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "まだデータがありません", + message: "このウィジェットは正常に読み込まれましたが、クエリはまだレコードを返していません。", + sourceLabel: "データソース:", + }, loading: "読み込み中…", pickMeasures: "このデータセットウィジェットの指標(値)を選択してください。", datasetUnsupported: "このデータソースはデータセットクエリに対応していません。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e9102bca29..bce8804861 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1046,6 +1046,17 @@ const ko = { }, dashboard: { noRows: "행 없음", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "아직 데이터가 없습니다", + message: "이 위젯은 정상적으로 로드되었지만 쿼리가 아직 레코드를 반환하지 않았습니다.", + sourceLabel: "데이터 소스:", + }, loading: "로딩 중…", pickMeasures: "이 데이터셋 위젯의 측정값(값)을 선택하세요.", datasetUnsupported: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9bc51b8244..b36dde1f1e 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1045,6 +1045,17 @@ const pt = { }, dashboard: { noRows: "Sem linhas", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "Ainda sem dados", + message: "Este widget foi carregado com sucesso, mas a consulta ainda não retornou nenhum registro.", + sourceLabel: "Fonte de dados:", + }, loading: "Carregando…", pickMeasures: "Escolha medidas (valores) para este widget de dataset.", datasetUnsupported: "Esta fonte de dados não oferece suporte a consultas de dataset.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index b41eb49060..7be7c8e5e8 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1056,6 +1056,17 @@ const ru = { }, dashboard: { noRows: "Нет строк", + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: "Данных пока нет", + message: "Виджет успешно загружен, но запрос пока не вернул ни одной записи.", + sourceLabel: "Источник данных:", + }, loading: "Загрузка…", pickMeasures: "Выберите меры (значения) для этого виджета набора данных.", datasetUnsupported: "Этот источник данных не поддерживает запросы к наборам данных.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 5d11042f4a..90dc6e7f53 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1119,6 +1119,17 @@ const zh = { noDataAvailable: '暂无数据', noDataSourceFor: '没有可用的数据源:', noRows: '暂无数据行', + // objectui#7063 — the DEFAULT empty state every dashboard widget renders + // when its query SUCCEEDED and returned nothing. `noRows` above is the + // terse fragment it replaces at the render site; the copy here has to + // read as a state rather than a failure, which is why it says the widget + // loaded. `sourceLabel` carries its own punctuation so the call site + // concatenates no separator (see `WidgetEmptyState`). + empty: { + title: '暂时还没有数据', + message: '该组件已成功加载,只是查询目前没有返回任何记录。', + sourceLabel: '数据源:', + }, loading: '加载中…', pickMeasures: '请为该数据集组件选择度量(值)。', datasetUnsupported: '当前数据源不支持数据集查询。', diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 43eff62dac..f8f61e7129 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -77,7 +77,10 @@ import { } from '@object-ui/core'; import { cn, Skeleton, ChartSkeleton, GridSkeleton } from '@object-ui/components'; import { useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n'; -import { BarChart3, AlertTriangle, Download, ArrowUpIcon, ArrowDownIcon, MinusIcon, ChevronsUpDown, ChevronUp, ChevronDown } from 'lucide-react'; +import { AlertTriangle, Download, ArrowUpIcon, ArrowDownIcon, MinusIcon, ChevronsUpDown, ChevronUp, ChevronDown } from 'lucide-react'; +// objectui#7063 — the default empty state is stated ONCE for the dashboard +// surface (see that component's header for why it is dashboard-local). +import { WidgetEmptyState } from './WidgetEmptyState'; import { useFilterScope } from '@object-ui/react'; import { resolveFilterPlaceholders, computeMetricDelta } from './utils'; import { metricAccentTextClass } from './colorVariants'; @@ -720,11 +723,26 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: ); } - // A metric (single value) over an empty dataset is 0, not "No rows" — the - // latter reads as broken for KPIs like "Total Books" on a fresh app. Charts - // and tables keep the empty state (there is genuinely nothing to plot). + // A metric (single value) over an empty dataset is 0, not an empty state — + // the latter reads as broken for KPIs like "Total Books" on a fresh app. + // Charts and tables keep the empty state (there is genuinely nothing to + // plot). + // + // objectui#7063: this used to be a one-line `dashboard.noRows` fragment in a + // dashed box — 'No rows' / `暂无数据行` — with no role, no explanation and no + // mention of WHAT was empty, which is why a legitimately young tile read as + // "the dashboard failed to load" beside eleven populated ones. `datasetName` + // is `widget.dataset` and is non-empty on every path that reaches here (a + // widget with no dataset never mounts this component), but it is passed + // defensively so a blank binding degrades to the un-sourced copy rather than + // printing an empty label. if (state.rows.length === 0 && !isMetric) { - return
{tt('dashboard.noRows', 'No rows')}
; + return ( + + ); } // Measure metadata (label + format + currency) + header-label resolution, diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index b844fe13e1..1255b523b5 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -30,6 +30,7 @@ import { isLookupType, } from './recordFields'; import { RecordDetailDrawer } from './RecordDetailDrawer'; +import { WidgetEmptyState } from './WidgetEmptyState'; export interface ObjectDataTableProps { schema: { @@ -612,15 +613,18 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo const dataSource = propDataSource || context?.dataSource; const boundData = useDataScope(schema.bind); const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel(); - let noDataLabel = 'No data available'; let noDataSourceLabel = 'No data source available for'; // useObjectTranslation is provider-safe (react-i18next falls back to the // global instance and never throws), so call it directly — no try/catch, - // which would make the hook conditional. The English defaults above stand + // which would make the hook conditional. The English default above stands // until a translation resolves. + // + // objectui#7063 removed this surface's second lookup (`noDataLabel` / + // `dashboard.noDataAvailable`): the empty branch now renders the shared + // `WidgetEmptyState`, which resolves its own copy. The no-data-SOURCE label + // below is a different state — a misconfigured binding, not an empty result + // — and keeps its own string. const { t } = useObjectTranslation(); - const a = t('dashboard.noDataAvailable'); - if (a && a !== 'dashboard.noDataAvailable') noDataLabel = a; const b = t('dashboard.noDataSourceFor'); if (b && b !== 'dashboard.noDataSourceFor') noDataSourceLabel = b; @@ -928,18 +932,19 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo ); } - // Empty state + // Empty state — the shared dashboard default (objectui#7063). It used to be a + // grid glyph over a bare `dashboard.noDataAvailable` line ('No data + // available' / `暂无数据`) with no role and no explanation, which is the + // same "reads as a load failure" shape the ruling is about — and it sat + // directly above an error block that DOES announce itself (`role="alert"`, + // destructive colours). `schema.objectName` is what this surface can name: + // the object the table is bound to. The wrapper keeps `table-empty-state`, + // which `ObjectDataTable.stableEmptyRows` and app-shell's widget DOM leak + // sweep both select on. if (finalData.length === 0) { return (
-
- - - - - -

{noDataLabel}

-
+
); } diff --git a/packages/plugin-dashboard/src/ObjectPivotTable.tsx b/packages/plugin-dashboard/src/ObjectPivotTable.tsx index c759ad7ba0..f15f6c7554 100644 --- a/packages/plugin-dashboard/src/ObjectPivotTable.tsx +++ b/packages/plugin-dashboard/src/ObjectPivotTable.tsx @@ -286,12 +286,18 @@ export const ObjectPivotTable: React.FC = ({ schema, data return ( <> + {/* objectui#7063 — `sourceLabel` is the object this pivot is bound to, + named by the shared default empty state. Passed as a PROP because + `finalSchema` is a `PivotTableSchema`, which declares no + `objectName`; it survives the spread above only by accident and + reading it there would be reading a key the type denies. */} {renderDrillDrawer()} diff --git a/packages/plugin-dashboard/src/PivotTable.tsx b/packages/plugin-dashboard/src/PivotTable.tsx index f3069d8eec..f2fb79a267 100644 --- a/packages/plugin-dashboard/src/PivotTable.tsx +++ b/packages/plugin-dashboard/src/PivotTable.tsx @@ -8,18 +8,15 @@ import React, { useMemo } from 'react'; import type { PivotTableSchema, PivotAggregation } from '@object-ui/types'; -import { cn, DataEmptyState } from '@object-ui/components'; +import { cn } from '@object-ui/components'; import { isDrillEnabled, type DrillEvent } from '@object-ui/core'; import { useSafeTranslate } from '@object-ui/i18n'; +import { WidgetEmptyState } from './WidgetEmptyState'; function useTotalLabel(): string { return useSafeTranslate()('dashboard.total', 'Total'); } -function useNoDataLabel(): string { - return useSafeTranslate()('dashboard.noDataAvailable', 'No data available'); -} - export interface PivotTableProps { schema: PivotTableSchema; className?: string; @@ -34,6 +31,15 @@ export interface PivotTableProps { columnLabels?: Record; /** Optional display label for the row field name (e.g. "Stage" for "stage"). */ rowFieldLabel?: string; + /** + * What this pivot is bound to, named in the default empty state + * (objectui#7063). `ObjectPivotTable` passes its `schema.objectName`; a pivot + * over inline `schema.data` has no source to name and omits it. NOT read off + * `schema`: `PivotTableSchema` declares no `objectName`, so reading one would + * be reading a key the type says cannot be there — it survives + * `ObjectPivotTable`'s spread only by accident. + */ + sourceLabel?: string; /** * Drill-down click handler. When provided **and** `schema.drillDown` is * enabled, cells / row & column headers / totals become interactive. @@ -160,7 +166,7 @@ function aggregate(values: number[], fn: PivotAggregation): number { * Renders a matrix where rows correspond to `rowField`, columns to * `columnField`, and cells show the aggregated `valueField`. */ -export const PivotTable: React.FC = ({ schema, className, rowLabels, columnLabels, rowFieldLabel, onDrillDown }) => { +export const PivotTable: React.FC = ({ schema, className, rowLabels, columnLabels, rowFieldLabel, sourceLabel, onDrillDown }) => { const { title, rowField, @@ -176,7 +182,6 @@ export const PivotTable: React.FC = ({ schema, className, rowLa drillDown, } = schema; const totalLabel = useTotalLabel(); - const noDataLabel = useNoDataLabel(); const drillEnabled = isDrillEnabled(drillDown) && typeof onDrillDown === 'function'; const fireDrill = (ev: DrillEvent) => { @@ -296,21 +301,11 @@ export const PivotTable: React.FC = ({ schema, className, rowLa {title && (

{title}

)} - - - - - - - } - iconWrapperClassName="" - title="" - description={noDataLabel} - /> + {/* objectui#7063 — the shared dashboard default. The title used to be + suppressed outright (`title=""` plus `[&>h3]:hidden`), leaving a + grid glyph over a bare `dashboard.noDataAvailable` line: exactly + the "reads as a load failure" shape the ruling is about. */} + ); } diff --git a/packages/plugin-dashboard/src/WidgetEmptyState.tsx b/packages/plugin-dashboard/src/WidgetEmptyState.tsx new file mode 100644 index 0000000000..a62aaa96ec --- /dev/null +++ b/packages/plugin-dashboard/src/WidgetEmptyState.tsx @@ -0,0 +1,127 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * WidgetEmptyState — the DEFAULT empty state every dashboard/analytics widget + * renders when its query succeeded and returned nothing. + * + * ## Why this exists (objectui#7063) + * + * Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget + * that renders a bare row-placeholder on an empty result is the PLATFORM's + * defect, not the app's. The measured scenario is a fresh flagship-demo + * install: eleven populated tiles and one reading exactly `暂无数据行` + * (`dashboard.noRows`, 'No rows') mid-page. Nothing on screen says whether the + * dashboard failed or is simply young, so the tile reads as a load failure — + * and the only app-side remedy was authoring per-widget prose, which is the + * per-app tax objectstack#13848 rules against. + * + * Three widget surfaces had written that placeholder independently, in two + * different strings — `DatasetWidget` (`dashboard.noRows`), `ObjectDataTable` + * and `PivotTable` (both `dashboard.noDataAvailable`). There was no shared + * seam to fix; this component IS the seam, stated once for the dashboard + * surface so "uniformly" is a property of the code and not of three copies + * agreeing by hand. + * + * ## What makes it distinguishable from a load failure — the three properties + * + * 1. **`role="status"`, never `role="alert"`.** The failure paths beside each + * call site (`DatasetWidget`'s `state.status === 'error'` box, the table's + * error block) are `role="alert"` on destructive colours. Before this, the + * empty branches carried NO role at all, so assistive tech got a bare + * fragment with no state either way. The two roles are now the machine + * check that the states are distinct, which is why no invented + * `data-empty-*` attribute is added: the semantics already carry it. + * 2. **Muted treatment + an inbox glyph**, never destructive colour + a + * warning triangle. + * 3. **A title AND an explanation**, where the placeholder used to be a single + * terse fragment. The copy says the widget LOADED SUCCESSFULLY — the one + * fact the reader of a blank tile cannot otherwise get. + * + * ## `source` names what is empty, and it is a raw identifier on purpose + * + * The card asks the default to name the widget's label and/or its data source + * rather than the bare placeholder. The widget's own title is already rendered + * by the `CardHeader` directly above every tile (`DashboardRenderer`), so + * repeating it here says nothing new; the DATA SOURCE is the half the reader + * cannot see. What is reachable at each render site is the authored binding — + * `widget.dataset` for the dataset path, `schema.objectName` for the + * object-bound table/pivot — i.e. a raw metadata name (`crm_forecast`), not a + * localized label. It is rendered as a labelled monospace value rather than + * folded into a sentence for exactly that reason: a truthful narrow statement + * beats an invented friendly one, and a label/value pair translates cleanly in + * every pack (including RTL) where a concatenated sentence would not. + * + * That is also why the copy carries no `{{interpolation}}`. `useSafeTranslate` + * takes a positional fallback and no options bag, so an interpolated key would + * have to come from a raw `useObjectTranslation` `t()` — and with no + * `I18nProvider` mounted (the standalone-host and test configuration) that + * renders the raw KEY unless the call site also carries an inline + * `defaultValue`, which objectui#3517 rules out. Label + value needs neither. + * + * ⚠️ NOT a cross-surface abstraction. `packages/plugin-detail`'s empty SECTION + * default (objectui#7064) is a sibling card on the same maintainer principle, + * dispatched in parallel; this component is deliberately dashboard-local and + * `DataEmptyState` — the presentational primitive both surfaces already use — + * is consumed here UNCHANGED. Converging the two is its own sequenced card, + * not a rider on either. + */ + +import { cn, DataEmptyState } from '@object-ui/components'; +import { useSafeTranslate } from '@object-ui/i18n'; +import { Inbox } from 'lucide-react'; + +export interface WidgetEmptyStateProps { + /** + * The authored binding this widget is empty FOR — `widget.dataset` on the + * dataset path, `schema.objectName` on the object-bound one. Omitted when the + * render site genuinely has no source to name (an inline-data pivot), in + * which case the title and explanation still stand on their own. + */ + source?: string; + /** Layout classes for the surrounding tile; the muted/centred look is fixed. */ + className?: string; + /** + * Test id for the root. Defaults to `widget-empty-state`; the table and pivot + * surfaces pass the ids their existing pins already select on + * (`table-empty-state` / `pivot-empty-state`, read by + * `ObjectDataTable.stableEmptyRows` and app-shell's widget DOM leak sweep). + */ + testId?: string; +} + +export function WidgetEmptyState({ source, className, testId }: WidgetEmptyStateProps) { + const tt = useSafeTranslate(); + return ( + h3]:text-sm [&>h3]:font-medium [&>p]:text-xs', + className, + )} + icon={} + iconWrapperClassName="flex size-9 items-center justify-center rounded-lg bg-muted" + title={tt('dashboard.empty.title', 'No data yet')} + description={tt( + 'dashboard.empty.message', + 'This widget loaded successfully and its query returned no records yet.', + )} + > + {source ? ( +

+ {/* The label carries its own punctuation so no separator is + concatenated in code — `Source:` / `数据源:` / `:المصدر` each + spell it the way their language does. */} + {tt('dashboard.empty.sourceLabel', 'Source:')}{' '} + {source} +

+ ) : null} +
+ ); +} diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx index 070e2828f1..5b33464eeb 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx @@ -253,13 +253,16 @@ describe('DatasetWidget', () => { // as empty/0 (a 0-value axis / blank table) instead of "loading". const pendingSource = () => ({ queryDataset: vi.fn(() => new Promise<{ rows: any[] }>(() => {})) }); - it('shows a chart skeleton (not "No rows", not a 0-axis) while a chart query is pending', () => { + it('shows a chart skeleton (not the empty state, not a 0-axis) while a chart query is pending', () => { const src = pendingSource(); const { container } = render(); expect(screen.getByTestId('dataset-loading')).toBeInTheDocument(); expect(container.querySelector('[data-slot="chart-skeleton"]')).toBeInTheDocument(); // The empty state must NOT show while loading — that was the bug. - expect(screen.queryByText('No rows')).not.toBeInTheDocument(); + // Selected by test id, not by the copy: objectui#7063 replaced the bare + // 'No rows' string, and a `queryByText` for a string that no longer exists + // anywhere passes for the wrong reason. + expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument(); }); it('shows a row (grid) skeleton while a table query is pending', () => { @@ -284,14 +287,14 @@ describe('DatasetWidget', () => { expect(screen.getByTestId('dataset-loading')).toBeInTheDocument(); // …then it clears once data arrives (status → ok, rows present). await waitFor(() => expect(screen.queryByTestId('dataset-loading')).not.toBeInTheDocument()); - expect(screen.queryByText('No rows')).not.toBeInTheDocument(); + expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument(); }); - it('shows 0 (not "No rows") for a metric over an empty dataset', async () => { + it('shows 0 (not an empty state) for a metric over an empty dataset', async () => { const src = makeSource(async () => ({ rows: [] })); render(); expect(await screen.findByText('0')).toBeInTheDocument(); - expect(screen.queryByText('No rows')).not.toBeInTheDocument(); + expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument(); }); it('formats the empty-metric zero (e.g. $0) using the measure format', async () => { @@ -303,10 +306,52 @@ describe('DatasetWidget', () => { expect(await screen.findByText('$0')).toBeInTheDocument(); }); - it('still shows "No rows" for a dimensioned chart over an empty dataset', async () => { + // ── objectui#7063: the DEFAULT empty state is self-explaining ──────────── + // Maintainer ruling 2026-08-31 (hotcrm#1212): a legitimately young widget + // must not read as a load failure, and it must say what is empty without any + // authored copy. The measured shape was a bare `暂无数据行` / 'No rows' + // fragment mid-dashboard beside eleven populated tiles. + // + // These pin the three properties the ruling fixes, NOT the exact sentence + // (copy is review's to move): the state announces itself as a STATUS rather + // than an alert, it carries an explanation as well as a title, and it names + // the widget's data source. + it('renders a self-explaining empty state for a dimensioned chart over an empty dataset', async () => { const src = makeSource(async () => ({ rows: [] })); - render(); - expect(await screen.findByText('No rows')).toBeInTheDocument(); + render(); + const panel = await screen.findByTestId('widget-empty-state'); + // (1) a state, not a failure. The error branch of this same component is + // `role="alert"`; before this card the empty branch carried NO role at all, + // so assistive tech could not tell the two apart either. + expect(panel).toHaveAttribute('role', 'status'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + // (2) title AND explanation — the placeholder was a single fragment. + expect(panel.textContent).toContain('No data yet'); + expect(panel.textContent).toContain('loaded successfully'); + // (3) it names WHAT is empty, with zero authored copy on the widget. + expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast'); + }); + + it('the empty state degrades to un-sourced copy rather than printing a blank source', async () => { + // `dataset` is what every path into this component carries, so this is the + // defensive half: a blank binding must drop the source LINE, not render + // "Source:" with nothing after it. + const src = makeSource(async () => ({ rows: [] })); + render(); + const panel = await screen.findByTestId('widget-empty-state'); + expect(panel.textContent).toContain('No data yet'); + expect(screen.queryByTestId('widget-empty-source')).not.toBeInTheDocument(); + }); + + it('a load FAILURE still reads as a failure, not as an empty state', async () => { + // The other half of property (1), measured on the same component: the card + // is only satisfied if empty and failed remain distinguishable, so a fix + // that softened the error path would defeat it. + const src = { queryDataset: vi.fn(async () => { throw new Error('dataset service unreachable'); }) }; + render(); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('dataset service unreachable'); + expect(screen.queryByTestId('widget-empty-state')).not.toBeInTheDocument(); }); // ── #2 header labels ──────────────────────────────────────────────────── diff --git a/packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx b/packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx new file mode 100644 index 0000000000..6613b9369d --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx @@ -0,0 +1,141 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7063 — the dashboard/analytics DEFAULT empty state, pinned at the + * SEAM rather than once per widget. + * + * Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget + * that renders a bare row-placeholder on an empty result is the platform's + * defect and must be fixed UNIFORMLY — apps must not compensate widget by + * widget (objectstack#13848). + * + * ## What "uniformly" had to mean here, measured + * + * The card's premise was that `暂无数据行` is ONE shared placeholder every + * affected widget reaches. It is not. Three dashboard surfaces wrote their own, + * in two different strings: + * + * - `DatasetWidget` -> `dashboard.noRows` ('No rows' / `暂无数据行`) + * - `ObjectDataTable` -> `dashboard.noDataAvailable` ('No data available') + * - `PivotTable` -> `dashboard.noDataAvailable` + * + * `emptyState` — the authored override the card assumes exists — appears ZERO + * times in `packages/plugin-dashboard` and `packages/plugin-charts` (control in + * the same sweep: `widget`, 1154 hits). It is a LIST-view contract + * (`ObjectGridSchema` / `NamedListView`, honoured in `plugin-list`), and + * `@objectstack/spec`'s `DashboardWidgetSchema` declares no such key at all. + * + * So there was no seam to fix; `WidgetEmptyState` IS the seam. This file pins + * that all three surfaces reach it, which is the property that decays first: a + * fourth widget added later can quietly write a fourth placeholder, and every + * per-widget test stays green while "uniformly" stops being true. + * + * ## It pins PROPERTIES, not the sentence + * + * The ruling fixes what the state must DO — read as a state and not a failure, + * describe itself with no authored copy, name what is empty, translate through + * the platform packs. Exact copy is review's to move, so the assertions here + * are the role, the presence of an explanation beside the title, and the source + * name — never a full-string equality. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import React from 'react'; + +import { DatasetWidget } from '../DatasetWidget'; +import { ObjectDataTable } from '../ObjectDataTable'; +import { PivotTable } from '../PivotTable'; +import { ObjectPivotTable } from '../ObjectPivotTable'; + +afterEach(cleanup); + +/** A dataSource whose every read succeeds and returns nothing. */ +const emptySource = () => ({ + queryDataset: vi.fn(async () => ({ rows: [] })), + find: vi.fn(async () => []), + getObject: vi.fn(async () => ({ name: 'crm_forecast', fields: {} })), +}); + +describe('objectui#7063 — one self-explaining default across the dashboard surface', () => { + it('DatasetWidget (the measured surface) names its dataset', async () => { + render( + , + ); + const panel = await screen.findByTestId('widget-empty-state'); + expect(panel).toHaveAttribute('role', 'status'); + expect(panel.textContent).toContain('No data yet'); + expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast'); + }); + + it('ObjectDataTable reaches the same default and names its object', async () => { + render(); + // The wrapper keeps the id the pre-existing pins select on… + await waitFor(() => expect(screen.getByTestId('table-empty-state')).toBeInTheDocument()); + // …and the shared default renders inside it. + const panel = screen.getByTestId('widget-empty-state'); + expect(panel).toHaveAttribute('role', 'status'); + expect(screen.getByTestId('widget-empty-source').textContent).toContain('quota_attainment'); + }); + + it('PivotTable reaches the same default (id preserved for the app-shell sweep)', () => { + render( + , + ); + const panel = screen.getByTestId('pivot-empty-state'); + expect(panel).toHaveAttribute('role', 'status'); + // The pivot used to suppress its title outright (`title=""` plus a + // `[&>h3]:hidden` rule), leaving only the terse description. + expect(panel.textContent).toContain('No data yet'); + expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast'); + }); + + it('ObjectPivotTable forwards its objectName as the source', async () => { + render( + , + ); + await waitFor(() => expect(screen.getByTestId('pivot-empty-state')).toBeInTheDocument()); + expect(screen.getByTestId('widget-empty-source').textContent).toContain('crm_forecast'); + }); + + it('the copy comes from the platform packs, not from an inline English default', async () => { + // The i18n half of the ruling: mounting a zh provider must move the whole + // default, title and explanation alike. An inline `defaultValue` (or a + // hard-coded string) renders English here and is untranslatable everywhere + // else — objectui#3517's exact failure, which is why this asserts on a + // MOUNTED provider rather than on the pack file's contents. + render( + + + , + ); + const panel = await screen.findByTestId('widget-empty-state'); + await waitFor(() => expect(panel.textContent).toContain('暂时还没有数据')); + expect(panel.textContent).toContain('已成功加载'); + // The bare placeholder this card replaces must be gone from the rendered + // output — not merely joined by better copy. + expect(panel.textContent).not.toContain('暂无数据行'); + // The source line keeps its own punctuation from the pack (`数据源:`), so + // no separator is concatenated in code. + expect(screen.getByTestId('widget-empty-source').textContent).toContain('数据源:'); + }); +});