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
52 changes: 52 additions & 0 deletions .changeset/7759-dashboard-header-global-filters-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@object-ui/types': minor
'@object-ui/plugin-dashboard': patch
---

`DashboardComponentSchema.header` and `.globalFilters` now state
`@objectstack/spec`'s `DashboardSchema` member on both faces, the TypeScript
interface and the Zod validator (objectui#7759 group A). Both keys are declared
by the spec, and each face used to refuse something the other accepted.

**`header` (TypeScript interface).** The hand-written restatement is gone. The
interface inherits the spec's `DashboardHeader` through the same
`Omit` projection of the spec `Dashboard` type as its other spec-owned keys. Breaking for code
that builds header actions in TypeScript:

- `actions[].label` is `I18nLabel`: a string or an inline per-locale map. It used
to be `string`.
- `actions[].actionUrl` is required. It used to be optional.
- `actions[].actionType` is the spec's `ActionType` (`url`, `script`, `modal`,
`flow`, `api` or `form`). It used to be any string.

The validator is unchanged here: it always took the spec's `header`.

**`globalFilters` (Zod validator).** `GlobalFilterSchema` is now the spec's
`GlobalFilterSchema` by reference, and its local `options` / `optionsFrom`
restatement is gone. Breaking for documents that relied on the looser validator:

- the bare-string option shorthand (`options: ['EMEA']`) is refused with
`invalid_type` at `globalFilters.N.options.M`. Write
`{ "value": "EMEA", "label": "EMEA" }`;
- an option with no `label` is refused at `globalFilters.N.options.M.label`;
- an `optionsFrom` with no `labelField` is refused with `invalid_type` at
`globalFilters.N.optionsFrom.labelField`. Name the value field again if the
options have no separate label column;
- an `optionsFrom.filter` must be a filter-condition object. An array is
refused;
- an unknown key in a filter is refused with `unrecognized_keys` at
`globalFilters.N`. It used to be removed without a message.

Widening: an option `label` may now be an inline per-locale map, which the spec
has always allowed and the filter bar already resolves. The TypeScript type is
unchanged, because it already bound the spec's `GlobalFilter`. The runtime is
unchanged too: `@object-ui/core` still converts a STORED bare-string option into
a pair when it reads the document, and logs a deprecation warning. That
conversion keeps its own retirement schedule (objectstack#7917).

**`@object-ui/plugin-dashboard`.** Before this change, a header action whose
`label` was an inline per-locale map crashed the renderer: React reported
`Objects are not valid as a React child`. The mirror accepted that document.
`DashboardRenderer` now shows the label in the active language, both as the
button text and as the fallback for the
`dashboards.NAME.actions.KEY.label` bundle lookup.
14 changes: 11 additions & 3 deletions content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,12 @@ deliberately not compared as-is: `field = "last_7_dayz"` matches no row, and
the widget would render a perfectly healthy-looking `0`.

Static `options` are `@objectstack/spec` object pairs —
`{ "value": "amer", "label": "AMER" }`. This is the only form the platform
accepts: a dashboard is validated against `GlobalFilterSchema` when it is
published, and anything else is refused there.
`{ "value": "amer", "label": "AMER" }`, where `label` is a string or an inline
per-locale map (`{ "en": "AMER", "zh-CN": "美洲" }`). This is the only form the
platform accepts: a dashboard is validated against `GlobalFilterSchema` when it is
published, and anything else is refused there. objectui's own validator
(`@object-ui/types`, which `objectui validate` runs) uses the same spec schema, so
it refuses the same documents before they reach the platform.

> **Deprecated: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is
> still lifted by the runtime to `{ "value": "EMEA", "label": "EMEA" }` pairs so
Expand All @@ -202,6 +205,11 @@ Options can also be fetched from an object at runtime:
}
```

`object`, `valueField` and `labelField` are all required; when the options have
no separate label column, name the value field again, as above. An optional
`filter` narrows the source records and is a filter-condition object
(`{ "status": "active" }`), not an array.

With a dataset-capable data source, `optionsFrom` resolves distinct values
**server-side** (a GROUP BY over the source object), so the option list is
complete regardless of row count. Data sources without dataset queries fall
Expand Down
7 changes: 6 additions & 1 deletion content/docs/plugins/plugin-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ npm install @object-ui/plugin-dashboard
header?: { // Header block — strict: exactly these keys
showTitle?: boolean,
showDescription?: boolean,
actions?: { label, actionUrl?, actionType?, icon? }[]
actions?: {
label: string | LocaleMap, // button text; a map resolves to the active language
actionUrl: string, // required: the URL, or the target the action runner dispatches
actionType?: 'url' | 'script' | 'modal' | 'flow' | 'api' | 'form',
icon?: string // Lucide icon name
}[]
},
globalFilters?: GlobalFilter[], // Dashboard-level filter bar — see "Dashboard-level filters"
dateRange?: { // Built-in date-range filter — see "Dashboard-level filters"
Expand Down
8 changes: 5 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,14 +387,16 @@ into each bound widget's inline query (`AND`-combined with the widget's own
// optional "object": the object `field` lives on — its fields.<object>.<field> / fieldOptions.<object>.<field>.<value> bundle entries then win, "label" is the fallback
"type": "select", // text | select | date | number | lookup
// Canonical @objectstack/spec pair form — the only form the platform
// accepts at publish. The bare-string shorthand (["EMEA", …]) is
// deprecated: still lifted at runtime, now warns (objectui#4356).
// accepts at publish, and the only form `@object-ui/types` validates
// (objectui#7759). The bare-string shorthand (["EMEA", …]) is
// deprecated: a STORED one is still lifted at runtime, and warns (objectui#4356).
"options": [
{ "value": "EMEA", "label": "EMEA" },
{ "value": "APAC", "label": "APAC" },
{ "value": "AMER", "label": "AMER" }
]
// or dynamic: "optionsFrom": { "object": "accounts", "valueField": "region" }
// or dynamic: "optionsFrom": { "object": "accounts", "valueField": "region", "labelField": "region" }
// (`labelField` is required by the spec, even when it names the value field)
}
],
"widgets": [
Expand Down
68 changes: 46 additions & 22 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ import { DatasetWidget } from './DatasetWidget';
import { useWidgetSubCaption } from './widgetSubCaption';
import { DashboardFilterBar } from './DashboardFilterBar';

/**
* One `header.actions[]` entry, as the node's declaration types it: the spec's
* `DashboardHeaderAction` by reference since objectui#7759, so `label` is an
* `I18nLabel` and `actionUrl` is required. Named for the node, not the spec
* type, because it is read off `DashboardComponentSchema` and a local alias may
* not wear a spec export's name (`check:spec-symbols`).
*/
type DashboardNodeHeaderAction = NonNullable<NonNullable<DashboardComponentSchema['header']>['actions']>[number];

interface SortableWidgetWrapperProps {
id: string;
disabled?: boolean;
Expand Down Expand Up @@ -273,19 +282,6 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
);
const { variables: filterValues, setVariable: setFilterValue, resetVariables: resetFilterValues } = usePageVariables();

// Build ActionDef[] from header actions so useActionEngine can dispatch by name.
const headerActionDefs = useMemo<ActionDef[]>(() => {
const actions = schema.header?.actions ?? [];
return actions.map((a: { label: string; actionUrl?: string; actionType?: string; icon?: string }) => ({
name: a.actionUrl || a.label,
type: (a.actionType as ActionDef['type']) || 'url',
target: a.actionUrl,
label: a.label,
}));
}, [schema.header?.actions]);

const { executeAction, engine } = useActionEngine({ actions: headerActionDefs });

// ── i18n: convention-based label resolution for dashboard / widget /
// action text. The dashboard name (`schema.name`) keys all lookups; when
// it's missing we silently degrade to the raw English fallbacks.
Expand Down Expand Up @@ -340,18 +336,44 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
/**
* Translate a header-action label using the
* `{ns}.dashboards.{dashName}.actions.{actionKey}.label` convention.
* Falls back to the action's English label when no translation exists or
* Falls back to the action's authored label when no translation exists or
* the dashboard schema has no `name`.
*
* The authored label is the spec's `I18nLabel` — a string or an inline
* per-locale map — so it is collapsed to the active language BEFORE it is
* used as the fallback or the button text (objectui#7759). This read used
* to return the authored value as-is, typed `string` by a hand-written
* restatement of `header`, and a map reached React as a button child.
*/
const tActionLabel = useCallback(
(action: { label: string; actionUrl?: string }): string => {
if (!dashName) return action.label;
const key = action.actionUrl || action.label;
return dashboardActionLabel(dashName, key, action.label);
(action: DashboardNodeHeaderAction): string => {
const authored = pickLocalized(action.label, language) || '';
if (!dashName) return authored;
const key = action.actionUrl || authored;
return dashboardActionLabel(dashName, key, authored);
},
[dashName, dashboardActionLabel],
[dashName, dashboardActionLabel, language],
);

// Build ActionDef[] from header actions so useActionEngine can dispatch by
// name. `ActionDef.label` is a `string`, so the def carries the label
// resolved against the active language, exactly as the button shows it.
// Keyed on `language`, not on a memoised resolver's identity (AGENTS.md #10).
const headerActionDefs = useMemo<ActionDef[]>(() => {
const actions = schema.header?.actions ?? [];
return actions.map((a) => {
const label = pickLocalized(a.label, language) || undefined;
return {
name: a.actionUrl || label,
type: (a.actionType as ActionDef['type']) || 'url',
target: a.actionUrl,
label,
};
});
}, [schema.header?.actions, language]);

const { executeAction, engine } = useActionEngine({ actions: headerActionDefs });

/**
* Translate a widget title / description using the
* `{ns}.dashboards.{dashName}.widgets.{widgetId}.title|description`
Expand Down Expand Up @@ -1067,10 +1089,10 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
)}
{headerActions.length > 0 && (
<div className="flex gap-2 mt-3">
{headerActions.map((action: { label: string; actionUrl?: string; actionType?: string; icon?: string }, i: number) => {
{headerActions.map((action, i) => {
const Icon = resolveLucideIcon(action.icon);
const handleClick = async () => {
const { actionType, actionUrl, label } = action;
const { actionType, actionUrl } = action;
if (!actionType || !actionUrl) {
console.warn('[DashboardRenderer] Header action missing actionType/actionUrl:', action);
return;
Expand All @@ -1092,7 +1114,9 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
// never dispatched — a screen flow could not even be launched
// from a dashboard (framework#3528). The runner reports an
// unknown type itself, so there is nothing to second-guess here.
const result = await executeAction(actionUrl || label);
// `actionUrl` is non-empty here (the guard above returns
// otherwise), and it is the name the def registered under.
const result = await executeAction(actionUrl);
if (!result?.success) console.warn('[DashboardRenderer] action failed', result?.error);
};
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardComponentSchema } from '@object-ui/types';
import { ActionProvider } from '@object-ui/react';
import { I18nProvider } from '@object-ui/i18n';
import { DashboardRenderer } from '../DashboardRenderer';

afterEach(cleanup);
Expand Down Expand Up @@ -84,6 +85,53 @@ describe('DashboardRenderer header actions', () => {
});
});

/**
* A header action's `label` is the spec's `I18nLabel` — a plain string OR an
* inline per-locale map (`DashboardHeaderActionSchema.label`), and the zod
* mirror has always admitted the map because it takes the spec's `header` by
* reference. The TypeScript twin restated it as `string` until objectui#7759
* group A, so this read site handed the authored value straight to React: an
* unnamed dashboard rendered the map as a button child, a named one passed it
* as the bundle lookup's `fallback`. Both render the resolved string now.
*/
function localizedActionDashboard(name?: string): DashboardComponentSchema {
return {
type: 'dashboard',
...(name ? { name } : {}),
label: 'Ops',
widgets: [],
header: {
actions: [{
label: { en: 'Convert Lead', 'zh-CN': '转换线索' },
actionUrl: 'convert_lead_wizard',
actionType: 'flow',
}],
},
} satisfies DashboardComponentSchema;
}

describe('DashboardRenderer header actions — inline per-locale label (objectui#7759)', () => {
it.each([
['an unnamed dashboard', undefined],
['a named dashboard with no bundle entry', 'ops_board'],
])('renders the active-language string for %s, and dispatches by actionUrl', async (_what, name) => {
const handler = vi.fn().mockResolvedValue({ success: true });
render(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false, resources: {} }}>
<ActionProvider handlers={{ flow: handler }}>
<DashboardRenderer schema={localizedActionDashboard(name)} />
</ActionProvider>
</I18nProvider>,
);

const button = screen.getByRole('button', { name: '转换线索' });
expect(button.textContent).not.toContain('[object Object]');
await userEvent.setup().click(button);
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
expect(handler.mock.calls[0][0]).toMatchObject({ type: 'flow', target: 'convert_lead_wizard' });
});
});

/**
* A dashboard authored the way the flagship exemplar authors them: `header`
* declared with both text flags ON, because a standalone embed genuinely does
Expand Down
Loading
Loading