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/retire-action-engine-event-mapping-3368.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/core': minor
---

Retire `ActionEngine`'s event-mapping API (objectui#3368). `ActionEngine.addMapping()`,
`ActionEngine.dispatch()`, the private `mappings` registry behind them, and the exported
`ActionMapping` interface are removed under enforce-or-remove: all four were public surface
of `@object-ui/core` with zero production callers. Nothing in the repo ever registered a
mapping, so `dispatch()` had no reachable caller either, and every call site was in the
engine's own test file.

Breaking for anyone who typed against or called the removed declarations, marked `minor`
per this repository's version-alignment convention (the major tracks `@objectstack`, never
an API-break count). Actions are still entered by name (`executeAction`), by location
(`getActionsForLocation`), by shortcut (`handleShortcut`) and in bulk (`executeBulk`) —
only the event-keyed entry point is gone, and no runtime behaviour changes because no
runtime path reached it.

The three ways the retired condition gate had drifted from the `visible` contract that
`getActionsForLocation` implements die with the path rather than being fixed on it: it
entered on a raw truthy check (`condition: false` dispatched anyway), typed `condition` as
`string` only (a `{ dialect: 'cel', source }` envelope could not reach the canonical
`@objectstack/formula` engine), and evaluated without `throwOnError` (a throwing predicate
failed OPEN, the opposite of `visible`'s fail-closed posture). Aligning the contract of an
API nobody calls would only have widened behaviour nobody uses.
76 changes: 30 additions & 46 deletions packages/core/src/actions/ActionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
/**
* @object-ui/core - Action Engine
*
* Declarative action dispatch engine. Manages action registration,
* event-to-action mapping, location-based filtering, keyboard shortcuts,
* and bulk operation support.
* Declarative action engine. Manages action registration, location-based
* filtering, keyboard shortcuts, and bulk operation support.
*
* Replaces callback-based patterns with a schema-driven pipeline:
* ActionSchema[] → ActionEngine → ActionRunner → ActionResult
Expand Down Expand Up @@ -44,16 +43,6 @@ export interface RegisteredAction {
priority?: number;
}

/** Event-to-action mapping */
export interface ActionMapping {
/** Event name (e.g., 'row:click', 'toolbar:save', 'keyboard:ctrl+s') */
event: string;
/** Action name to execute */
actionName: string;
/** Optional condition expression */
condition?: string;
}

/** Keyboard shortcut handler */
export interface ShortcutBinding {
/** Key combination (e.g., 'ctrl+s', 'meta+k', 'shift+n') */
Expand Down Expand Up @@ -91,9 +80,35 @@ function warnHiddenPredicate(name: unknown, raw: unknown, err: unknown): void {
);
}

/**
* Registration + filtering + execution for declared actions.
*
* Entry points are by NAME (`executeAction`), by LOCATION
* (`getActionsForLocation`), by SHORTCUT (`handleShortcut`) and in BULK
* (`executeBulk`). There is deliberately no entry point by arbitrary EVENT
* name: this class used to carry a second, parallel one — `addMapping()`
* registered `{ event, actionName, condition }` triples into a private
* `mappings` array and `dispatch(event)` ran every triple matching an event
* string. It was retired under enforce-or-remove (objectui#3368) because it
* was a public export of `@object-ui/core` with zero production callers: in
* its whole lifetime nothing ever registered a mapping, so `dispatch()` had
* no reachable caller either, and its four call sites were all in this
* class's own test file.
*
* Its condition gate had drifted three ways from the `visible` contract
* `getActionsForLocation` below implements — it entered on a raw truthy check
* (so `condition: false` dispatched anyway), typed `condition` as `string`
* only (so a `{ dialect: 'cel', source }` envelope could not reach the
* canonical engine), and evaluated without `throwOnError` (so a throwing
* predicate failed OPEN, the opposite of `visible`'s fail-closed posture).
* All three were retired WITH the path rather than fixed on it: aligning the
* contract of an API nobody calls only widens behaviour nobody uses. If an
* event-keyed entry point is ever genuinely needed, it must be built on the
* shared predicate definitions (`hasDeclaredPredicate` + `toPredicateInput`)
* that the surviving gate below already uses, not on a fourth spelling.
*/
export class ActionEngine {
private actions = new Map<string, RegisteredAction>();
private mappings: ActionMapping[] = [];
private shortcuts: ShortcutBinding[] = [];
private normalizedShortcutMap = new Map<string, string>();
private runner: ActionRunner;
Expand Down Expand Up @@ -174,12 +189,6 @@ export class ActionEngine {
}
this.actions.delete(name);
this.shortcuts = this.shortcuts.filter(s => s.actionName !== name);
this.mappings = this.mappings.filter(m => m.actionName !== name);
}

/** Add an event-to-action mapping */
addMapping(mapping: ActionMapping): void {
this.mappings.push(mapping);
}

/**
Expand Down Expand Up @@ -320,30 +329,6 @@ export class ActionEngine {
return this.runner.execute(registered.action);
}

/** Dispatch an event — finds mapped actions and executes them */
async dispatch(event: string, contextOverride?: Partial<ActionContext>): Promise<ActionResult[]> {
const matchingMappings = this.mappings.filter(m => m.event === event);

if (matchingMappings.length === 0) {
return [];
}

const results: ActionResult[] = [];
for (const mapping of matchingMappings) {
// Check condition if present
if (mapping.condition) {
const evaluator = this.runner.getEvaluator();
const shouldRun = evaluator.evaluateCondition(mapping.condition);
if (!shouldRun) continue;
}

const result = await this.executeAction(mapping.actionName, contextOverride);
results.push(result);
}

return results;
}

/** Handle a keyboard shortcut event — returns true if handled */
async handleShortcut(keys: string, contextOverride?: Partial<ActionContext>): Promise<ActionResult | null> {
const actionName = this.normalizedShortcutMap.get(normalizeShortcut(keys));
Expand Down Expand Up @@ -407,10 +392,9 @@ export class ActionEngine {
this.runner.updateContext(context);
}

/** Clear all registered actions and mappings */
/** Clear all registered actions and shortcuts */
clear(): void {
this.actions.clear();
this.mappings = [];
this.shortcuts = [];
this.normalizedShortcutMap.clear();
}
Expand Down
41 changes: 5 additions & 36 deletions packages/core/src/actions/__tests__/ActionEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,11 @@ describe('ActionEngine', () => {
});

describe('unregisterAction', () => {
it('removes action and its shortcuts/mappings', () => {
it('removes action and its shortcuts', () => {
engine.registerAction({ name: 'save', type: 'api' }, { shortcut: 'ctrl+s' });
engine.addMapping({ event: 'toolbar:save', actionName: 'save' });


engine.unregisterAction('save');

expect(engine.getAction('save')).toBeUndefined();
expect(engine.getShortcuts()).toHaveLength(0);
});
Expand Down Expand Up @@ -107,35 +106,6 @@ describe('ActionEngine', () => {
});
});

describe('dispatch', () => {
it('executes mapped actions for an event', async () => {
engine.registerAction({ name: 'log', type: 'script', target: '"logged"' });
engine.addMapping({ event: 'row:click', actionName: 'log' });

const results = await engine.dispatch('row:click');
expect(results).toHaveLength(1);
expect(results[0].success).toBe(true);
});

it('returns empty array for unmapped events', async () => {
const results = await engine.dispatch('unknown:event');
expect(results).toEqual([]);
});

it('skips actions when mapping condition is false', async () => {
engine = new ActionEngine({ data: { status: 'locked' } });
engine.registerAction({ name: 'edit', type: 'script', target: '"edited"' });
engine.addMapping({
event: 'row:click',
actionName: 'edit',
condition: '${data.status === "active"}'
});

const results = await engine.dispatch('row:click');
expect(results).toHaveLength(0);
});
});

describe('handleShortcut', () => {
it('executes action for matching shortcut', async () => {
engine.registerAction(
Expand Down Expand Up @@ -206,10 +176,9 @@ describe('ActionEngine', () => {
});

describe('clear', () => {
it('removes all actions, mappings, and shortcuts', () => {
it('removes all actions and shortcuts', () => {
engine.registerAction({ name: 'save', type: 'api' }, { shortcut: 'ctrl+s' });
engine.addMapping({ event: 'test', actionName: 'save' });


engine.clear();

expect(engine.getAction('save')).toBeUndefined();
Expand Down
Loading