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
25 changes: 25 additions & 0 deletions .changeset/action-confirm-text-translation-4265.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
---
'@object-ui/react': patch
'@object-ui/components': patch
'@object-ui/plugin-detail': patch
'@object-ui/app-shell': patch
---

Action confirm dialogs and success toasts now honour the bundle's translated
`confirmText` / `successMessage`, not just `label` (objectui#4265).

A TranslationBundle entry for an action carries three keys under one
`_actions.<name>` node — `label`, `confirmText`, `successMessage` — and
`useObjectLabel()` has always exposed a resolver for each. What had drifted was
the call sites: `page:header` (authored record pages), `record:quick_actions`
and the related-list row menu resolved the button `label` only and dispatched
the authored `confirmText` / `successMessage` untouched. One bundle entry met
two fates: the button rendered the translation, the confirm dialog rendered the
authored English.

All action-rendering surfaces now go through one resolver,
`useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
resolvers over the three keys together. Fallback is unchanged: with no bundle
entry — or an entry lacking a key — the authored text renders. A bundle cannot
introduce a `confirmText` or `successMessage` the metadata never declared.
32 changes: 14 additions & 18 deletions packages/app-shell/src/views/DeclaredActionsBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,9 +38,10 @@ import {
useCondition,
toPredicateInput,
usePredicateRecordContext,
useActionTextLocalizer,
} from '@object-ui/react';
import type { ActionDef } from '@object-ui/core';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2 } from 'lucide-react';
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
import { useAdapter } from '../providers/AdapterProvider';
Expand DownExpand Up@@ -111,9 +112,11 @@ const DeclaredActionButton: React.FC<{
// Localize the SERVER-DECLARED strings through the `_actions.<name>.*`
// translation convention (objectui#2762 P0-3) — the metadata's literal
// label/confirmText/successMessage are the fallback, exactly like
// ObjectView/RecordDetailView do for their toolbars. The param dialog's
// labels localize downstream in useConsoleActionRuntime.
const { actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
// ObjectView/RecordDetailView do for their toolbars. Since objectui#4265 the
// three keys go through ONE call, so no surface can localize the button and
// leave the confirm dialog behind. The param dialog's labels localize
// downstream in useConsoleActionRuntime.
const localizeActionTexts = useActionTextLocalizer();
// Chrome strings the bar itself authors — as opposed to the declared metadata
// above — go through the normal locale bundle. The decision-output params are
// synthesized here from `decision_output_defs`, so their key path is dynamic
Expand DownExpand Up@@ -200,20 +203,12 @@ const DeclaredActionButton: React.FC<{
? decisionOutputParams(decisionOutputDefs(recordData), t, { decision })
: [];
const dispatch: any = {
...rest,
// Localized copies ride the dispatch: the runner reads `label` for the
// param-dialog title, `confirmText` for the confirm prompt and
// `successMessage` for the toast. A nameless action has no translation
// key, so it keeps its literal strings.
...(action.name && {
label: actionLabel(objectName, action.name, action.label || action.name),
...(rest.confirmText !== undefined && {
confirmText: actionConfirm(objectName, action.name, (rest as any).confirmText),
}),
...(rest.successMessage !== undefined && {
successMessage: actionSuccess(objectName, action.name, (rest as any).successMessage),
}),
}),
// key, so it keeps its literal strings — that rule lives in the
// localizer now rather than being re-spelled per surface.
...localizeActionTexts(objectName, rest as Record<string, any>),
objectName,
params: { _rowRecord: record },
};
Expand All@@ -225,7 +220,7 @@ const DeclaredActionButton: React.FC<{
} finally {
setLoading(false);
}
}, [action, execute, loading, objectName, record, actionLabel, actionConfirm, actionSuccess, t]);
}, [action, execute, loading, objectName, record, localizeActionTexts, t]);

// Does the action DECLARE a `visible` gate? `hasDeclaredVisibilityGate`
// (`!= null && !== ''`) is the one definition on the question, imported rather
Expand DownExpand Up@@ -259,8 +254,9 @@ const DeclaredActionButton: React.FC<{
: declaredVariant === 'danger'
? 'destructive'
: (declaredVariant || 'outline');
const fallbackLabel = action.label || action.name || '';
const label = action.name ? actionLabel(objectName, action.name, fallbackLabel) : fallbackLabel;
// Same resolver as the dispatch above, so the button text and the confirm
// dialog body can never come from different bundle reads (objectui#4265).
const label = (localizeActionTexts(objectName, action as Record<string, any>).label as string) || '';

return (
<Button
Expand Down
35 changes: 12 additions & 23 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { usePermissions } from '@object-ui/permissions';
import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth';
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
import { ActionProvider, useNavigationOverlay, SchemaRenderer } from '@object-ui/react';
import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer } from '@object-ui/react';
import { toast } from 'sonner';
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
import { useEnvironmentEntitlements } from '../environment/useEnvironmentEntitlements';
Expand DownExpand Up@@ -521,7 +521,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
const location = useLocation();
const { showDebug } = useMetadataInspector();
const { t } = useObjectTranslation();
const { objectLabel, objectDescription: objectDesc, viewLabel, viewEmptyState, actionLabel, actionConfirm, actionSuccess, actionParamText, fieldLabel, fieldOptionLabel } = useObjectLabel();
const { objectLabel, objectDescription: objectDesc, viewLabel, viewEmptyState, actionParamText, fieldLabel, fieldOptionLabel } = useObjectLabel();
// label + confirmText + successMessage through ONE call (objectui#4265).
const localizeActionTexts = useActionTextLocalizer();
const { isFavorite, toggleFavorite } = useFavorites();
// ADR-0105: under group posture default list columns get a trailing
// organization_id attribution column (reads span all the user's orgs).
Expand DownExpand Up@@ -743,13 +745,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
// Localized `list_toolbar` actions, shared by the generic action bar and the
// environment-aware toolbar (the action:bar renderer filters by location).
const localizedToolbarActions = useMemo(
() => (objectDef.actions || []).map((a: any) => ({
...a,
label: actionLabel(objectDef.name, a.name, a.label || a.name),
...(a.confirmText !== undefined && { confirmText: actionConfirm(objectDef.name, a.name, a.confirmText) }),
...(a.successMessage !== undefined && { successMessage: actionSuccess(objectDef.name, a.name, a.successMessage) }),
})),
[objectDef, actionLabel, actionConfirm, actionSuccess],
() => (objectDef.actions || []).map((a: any) => localizeActionTexts(objectDef.name, a)),
[objectDef, localizeActionTexts],
);

// Resolve which generic CRUD affordances belong in the toolbar for
Expand DownExpand Up@@ -1637,20 +1634,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
.filter((a: any) =>
Array.isArray(a?.locations) && a.locations.includes('list_item'))
// Localize label / confirm / success the same way the
// record_header and list_toolbar paths do — the row kebab
// previously rendered raw English `a.label`. The `visible`
// CEL is forwarded untouched (spread) and evaluated per-row
// at render time inside RowActionMenu.
.map((a: any) => ({
...a,
label: actionLabel(objectDef.name, a.name, a.label || a.name),
...(a.confirmText !== undefined && {
confirmText: actionConfirm(objectDef.name, a.name, a.confirmText),
}),
...(a.successMessage !== undefined && {
successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
}),
}))
// record_header and list_toolbar paths do — through the ONE
// shared resolver (objectui#4265), so this surface cannot
// drift back to a label-only resolution. The `visible` CEL
// is forwarded untouched (spread inside the localizer) and
// evaluated per-row at render time inside RowActionMenu.
.map((a: any) => localizeActionTexts(objectDef.name, a))
: []),
/**
* Selection-bar actions. Unlike `rowActionDefs` above, these are
Expand Down
20 changes: 7 additions & 13 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ import { RecordChatterPanel, InlineEditSaveBar, buildDefaultPageSchema, deriveFi
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
import { usePermissions } from '@object-ui/permissions';
import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged } from '@object-ui/react';
import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged } from '@object-ui/react';
import { buildExpandFields } from '@object-ui/core';
import { toast } from 'sonner';
import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration';
Expand DownExpand Up@@ -263,7 +263,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
};
}, [originFromState, location.search, appName]);
const { t, language } = useObjectTranslation();
const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, actionParamText, actionParamOptionLabel, actionDescription, actionResultDialog, fieldLabel, fieldOptionLabel } = useObjectLabel();
const { objectLabel, viewLabel: _vLabel, sectionLabel, actionParamText, actionParamOptionLabel, actionDescription, actionResultDialog, fieldLabel, fieldOptionLabel } = useObjectLabel();
// label + confirmText + successMessage through ONE call (objectui#4265) —
// the three keys of an `_actions.<name>` bundle entry can no longer be
// localized apart from one another on this surface.
const localizeActionTexts = useActionTextLocalizer();
const { isFavorite, toggleFavorite, refreshLabel: refreshFavoriteLabel } = useFavorites();
const { addRecentItem } = useRecentItems();
const [isLoading, setIsLoading] = useState(true);
Expand DownExpand Up@@ -1725,16 +1729,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
if (seen.has(a.name)) return false;
seen.add(a.name);
return true;
}).map((a: any) => ({
...a,
label: actionLabel(objectDef.name, a.name, a.label || a.name),
...(a.confirmText !== undefined && {
confirmText: actionConfirm(objectDef.name, a.name, a.confirmText),
}),
...(a.successMessage !== undefined && {
successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
}),
}));
}).map((a: any) => localizeActionTexts(objectDef.name, a));

// ⛔ No approval actions are injected here any more (objectui#3055).
// They used to be two hand-written buttons (approve/reject only, no
Expand DownExpand Up@@ -2163,7 +2158,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
appName={appName}
objects={objects}
dataSource={dataSource}
actionLabel={actionLabel}
parentObjectName={objectName}
parentRecordId={pureRecordId ?? undefined}
parentTitle={recordTitle}
Expand Down
31 changes: 17 additions & 14 deletions packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,8 @@ import {
RelatedRecordActionsProvider,
notifyDataChanged,
useAction,
useActionTextLocalizer,
type ActionTextLocalizer,
type RelatedRecordActionsValue,
type RelatedRecordHandlers,
type RelatedRowActionDef,
Expand All@@ -67,18 +69,13 @@ export function notifyRelatedChanged(objectName: string): void {
notifyDataChanged({ objectName });
}

/** i18n label resolver signature (matches `useObjectLabel().actionLabel`). */
type ActionLabelFn = (objectName: string | undefined, actionName: string, fallback: string) => string;

export interface RelatedRecordActionsBridgeProps {
/** Current app segment used to build `/apps/:appName/...` routes. */
appName?: string;
/** All object definitions (to resolve the child object + its actions). */
objects: any[];
/** Data source for delete + action dispatch. */
dataSource: any;
/** Localizes a child action's label (falls back to the raw label). */
actionLabel: ActionLabelFn;
/**
* The record this bridge is mounted under — the PARENT of any related row a
* user drills into. Threaded into the child record's `?from=` trail so the
Expand All@@ -98,23 +95,23 @@ export interface RelatedRecordActionsBridgeProps {
*/
function deriveActions(
childDef: any,
actionLabel: ActionLabelFn,
localizeActionTexts: ActionTextLocalizer,
location: 'list_item' | 'list_toolbar',
): RelatedRowActionDef[] {
const actions = Array.isArray(childDef?.actions) ? childDef.actions : [];
return actions
.filter((a: any) => Array.isArray(a?.locations) && a.locations.includes(location))
.map((a: any) => ({
...a,
label: actionLabel(childDef.name, a.name, a.label || a.name),
}));
// One bundle entry, one fate (objectui#4265): the row menu's label used to
// be the ONLY string resolved here, so `runRowAction` below dispatched the
// child action's `confirmText` / `successMessage` in the authored language
// next to a translated menu item.
.map((a: any) => localizeActionTexts(childDef.name, a) as RelatedRowActionDef);
}

export function RelatedRecordActionsBridge({
appName,
objects,
dataSource,
actionLabel,
parentObjectName,
parentRecordId,
parentTitle,
Expand All@@ -124,6 +121,12 @@ export function RelatedRecordActionsBridge({
const { execute } = useAction();
const [, setSearchParams] = useSearchParams();
const { getObjectApiOperations, can } = usePermissions();
// The child action's authored strings go through the ONE shared resolver
// (objectui#4265). This used to arrive as an `actionLabel` prop injected by
// RecordDetailView — an injection point that could only ever carry the LABEL,
// which is precisely how the row menu ended up translated while its confirm
// dialog stayed in the authored language.
const localizeActionTexts = useActionTextLocalizer();
const base = appName ? `/apps/${appName}` : '';

// #2604 D3 — open a child create/edit task as the console's global record
Expand DownExpand Up@@ -247,7 +250,7 @@ export function RelatedRecordActionsBridge({
};
}

const rowActions = deriveActions(childDef, actionLabel, 'list_item');
const rowActions = deriveActions(childDef, localizeActionTexts, 'list_item');
if (rowActions.length > 0) {
handlers.rowActions = rowActions;
handlers.onRowAction = (action, record) =>
Expand All@@ -258,7 +261,7 @@ export function RelatedRecordActionsBridge({
// header buttons — the related-list equivalent of the object list's
// toolbar. Executed through the same dispatch as row actions, just
// without a row record.
const toolbarActions = deriveActions(childDef, actionLabel, 'list_toolbar');
const toolbarActions = deriveActions(childDef, localizeActionTexts, 'list_toolbar');
if (toolbarActions.length > 0) {
handlers.toolbarActions = toolbarActions;
handlers.onToolbarAction = (action) =>
Expand All@@ -268,7 +271,7 @@ export function RelatedRecordActionsBridge({
return handlers;
},
}),
[objects, base, navigate, dataSource, actionLabel, runRowAction, openChildForm, parentObjectName, parentRecordId, parentTitle, getObjectApiOperations, can],
[objects, base, navigate, dataSource, localizeActionTexts, runRowAction, openChildForm, parentObjectName, parentRecordId, parentTitle, getObjectApiOperations, can],
);

return (
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,13 +59,17 @@ vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));
// authored literal, which is what the render assertions below expect); the
// bar's OWN chrome resolves through `t`. Marking `t` output makes it visible
// whether a string went through the locale bundle or was baked in English.
// (`useActionTextLocalizer` — the shared action-text resolver the bar calls
// since objectui#4265 — is the REAL one from `@object-ui/react`; it reads these
// three resolvers plus `pickLocalized`, so the double has to carry all four.)
vi.mock('@object-ui/i18n', () => ({
useObjectLabel: () => ({
actionLabel: (_o: unknown, _n: unknown, fallback: string) => fallback,
actionConfirm: (_o: unknown, _n: unknown, fallback?: string) => fallback,
actionSuccess: (_o: unknown, _n: unknown, fallback?: string) => fallback,
}),
useObjectTranslation: () => ({ t: (key: string) => `t:${key}` }),
useObjectTranslation: () => ({ t: (key: string) => `t:${key}`, language: 'en' }),
pickLocalized: (value: unknown) => (typeof value === 'string' ? value : ''),
}));

// The components barrel stays doubled (its full graph is what the light `dom`
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,6 @@ function resolveHandlers(): Record<string, boolean> {
appName="crm"
objects={objects}
dataSource={{ delete: vi.fn() }}
actionLabel={(_o, _n, fallback) => fallback}
>
<Probe onResolve={(p) => { captured = p; }} />
</RelatedRecordActionsBridge>
Expand Down
Loading
Loading