Skip to content

Commit c49153d

Browse files
Copilothotlong
andcommitted
Add composeStacks unit tests and update ROADMAP.md with plugin architecture changes
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c95629b commit c49153d

File tree

2 files changed

+297
-2
lines changed

2 files changed

+297
-2
lines changed

ROADMAP.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,7 +1166,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
11661166

11671167
### P2.6 Plugin Modularization & Dynamic Management
11681168

1169-
> **Status:** Phase 1 complete — Plugin class standard, install/uninstall API, example plugin classes.
1169+
> **Status:** Phase 1 complete — Plugin class standard, install/uninstall API, example plugin classes. Phase 1.5 complete — composeStacks, plugin isolation, duplicate merge removal.
11701170
11711171
Plugin architecture refactoring to support true modular development, plugin isolation, and dynamic plugin install/uninstall at runtime.
11721172

@@ -1181,15 +1181,26 @@ Plugin architecture refactoring to support true modular development, plugin isol
11811181
- [x] Refactor root `objectstack.config.ts` to use plugin-based config collection via `getConfig()`
11821182
- [x] Unit tests for `install()` / `uninstall()` (5 new tests, 18 total in PluginSystem)
11831183

1184+
**Phase 1.5 — Plugin Isolation & Config Composition ✅**
1185+
- [x] Add explicit `objectName` to all example plugin actions (CRM: 28 actions, Todo: 6 actions, Kitchen Sink: 3 actions)
1186+
- [x] Rename Kitchen Sink `account``ks_account` to eliminate same-name object conflicts across plugins
1187+
- [x] Create `composeStacks()` utility in `@object-ui/core` — declarative stack merging with `objectConflict` option, automatic views→objects and actions→objects mapping
1188+
- [x] Remove duplicate `mergeActionsIntoObjects()` from root config and console shared config
1189+
- [x] Remove duplicate `mergeViewsIntoObjects()` from root config and console shared config (moved into `composeStacks`)
1190+
- [x] Refactor root `objectstack.config.ts` and `apps/console/objectstack.shared.ts` to use `composeStacks()`
1191+
- [x] Unit tests for `composeStacks()` (13 tests covering merging, dedup, views, actions, cross-stack)
1192+
11841193
**Phase 2 — Dynamic Plugin Loading (Planned)**
11851194
- [ ] Hot-reload / lazy loading of plugins for development
11861195
- [ ] Runtime plugin discovery and loading from registry
11871196
- [ ] Plugin dependency graph visualization in Console
11881197

11891198
**Phase 3 — Plugin Identity & Isolation (Planned)**
1199+
- [x] Eliminate same-name object conflicts across plugins (Kitchen Sink `account``ks_account`)
11901200
- [ ] Preserve origin plugin metadata on objects, actions, dashboards for runtime inspection
11911201
- [ ] Per-plugin i18n namespace support
11921202
- [ ] Per-plugin permissions and data isolation
1203+
- [ ] Move `mergeViewsIntoObjects` from `composeStacks` to runtime/provider layer
11931204

11941205
**Phase 4 — Cross-Repo Plugin Ecosystem (Planned)**
11951206
- [ ] Plugin marketplace / registry for third-party plugins
@@ -1227,7 +1238,7 @@ Plugin architecture refactoring to support true modular development, plugin isol
12271238
- [x] **P1: Chart Widget Server-Side Aggregation** — Fixed chart widgets (bar/line/area/pie/donut/scatter) downloading all raw data and aggregating client-side. Added optional `aggregate()` method to `DataSource` interface (`AggregateParams`, `AggregateResult` types) enabling server-side grouping/aggregation via analytics API (e.g. `GET /api/v1/analytics/{resource}?category=…&metric=…&agg=…`). `ObjectChart` now prefers `dataSource.aggregate()` when available, falling back to `dataSource.find()` + client-side aggregation for backward compatibility. Implemented `aggregate()` in `ValueDataSource` (in-memory), `ApiDataSource` (HTTP), and `ObjectStackAdapter` (analytics API with client-side fallback). Only detail widgets (grid/table/list) continue to fetch full data. 9 new tests.
12281239
- [x] **P1: Spec-Aligned CRM I18n** — Fixed CRM internationalization not taking effect on the console. Root cause: CRM metadata used plain string labels instead of spec-aligned `I18nLabel` objects. Fix: (1) Updated CRM app/dashboard/navigation metadata to use `I18nLabel` objects (`{ key, defaultValue }`) per spec. (2) Updated `NavigationItem` and `NavigationArea` types to support I18nLabel. (3) Added `resolveLabel()` helper in NavigationRenderer. (4) Updated `resolveI18nLabel()` to accept `t()` function for translation. (5) Added `loadLanguage` callback in I18nProvider for API-based translation loading. (6) Added `/api/v1/i18n/:lang` endpoint to mock server. Console contains zero CRM-specific code.
12291240
- [x] **P0: Opportunity List View & ObjectDef Column Enrichment** — Fixed ObjectGrid not using objectDef field metadata for type-aware rendering when columns are `string[]` or `ListColumn[]` without full options. (1) Schema resolution always fetches full schema from DataSource for field type metadata. (2) String[] column path enriched with objectDef types, options (with colors), currency, precision for proper CurrencyCellRenderer, SelectCellRenderer (colored badges), PercentCellRenderer, DateCellRenderer. (3) ListColumn[] fieldMeta deep-merged with objectDef field properties (select options with colors, currency code, precision). (4) Opportunity view columns upgraded from bare `string[]` to `ListColumn[]` with explicit types, alignment, and summary aggregation. 9 new tests.
1230-
- [x] **P1: Actions Merge into Object Definitions** — Fixed action buttons never showing in Console/Studio because example object definitions lacked `actions` field. Added `mergeActionsIntoObjects()` helper (mirrors existing `mergeViewsIntoObjects` pattern) to root config and console shared config. Uses longest-prefix name matching with explicit `objectName` fallback. Created todo task actions (6: complete, start, clone, defer, set_reminder, assign) and kitchen-sink showcase actions (3: change_status, assign_owner, archive). All CRM/Todo/Kitchen Sink objects now serve `actions` in metadata. Fixes #840.
1241+
- [x] **P1: Actions Merge into Object Definitions** — Fixed action buttons never showing in Console/Studio because example object definitions lacked `actions` field. Initially added `mergeActionsIntoObjects()` helper with longest-prefix name matching. Later refactored: all actions now declare explicit `objectName`, and merging is handled by `composeStacks()` in `@object-ui/core`. Created todo task actions (6: complete, start, clone, defer, set_reminder, assign) and kitchen-sink showcase actions (3: change_status, assign_owner, archive). All CRM/Todo/Kitchen Sink objects now serve `actions` in metadata. Fixes #840.
12311242
- [x] **P1: Unified Debug/Metadata Entry — Remove Redundant Metadata Button** — Removed the visible `<MetadataToggle>` button from RecordDetailView, DashboardView, PageView, and ReportView headers. End users no longer see a "</> Metadata" button that had no practical purpose. The MetadataInspector panel is now only accessible via `?__debug` URL parameter (auto-opens when debug mode is active). ObjectView retains its admin-only Design Tools menu entry for metadata inspection. This unifies the debug entry point and improves end-user UX by removing redundant UI elements.
12321243

12331244
### Ecosystem & Marketplace
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
/**
2+
* Tests for composeStacks utility
3+
*
4+
* Validates that composeStacks correctly merges multiple stack definitions:
5+
* - Concatenates arrays (objects, views, actions, apps, dashboards, reports, pages)
6+
* - Handles duplicate object names via objectConflict option
7+
* - Merges listViews from views into objects
8+
* - Merges actions into objects via objectName field
9+
* - Merges manifest.data arrays
10+
*/
11+
import { describe, it, expect } from 'vitest';
12+
import { composeStacks } from '../compose-stacks';
13+
14+
describe('composeStacks', () => {
15+
// ── Basic merging ─────────────────────────────────────────────────────
16+
17+
it('should merge objects from multiple stacks', () => {
18+
const a = { objects: [{ name: 'account', label: 'Account', fields: {} }] };
19+
const b = { objects: [{ name: 'contact', label: 'Contact', fields: {} }] };
20+
21+
const result = composeStacks([a, b]);
22+
expect(result.objects).toHaveLength(2);
23+
expect(result.objects.map((o: any) => o.name)).toEqual(['account', 'contact']);
24+
});
25+
26+
it('should merge arrays (apps, dashboards, reports, pages, views)', () => {
27+
const a = {
28+
apps: [{ name: 'app_a' }],
29+
dashboards: [{ name: 'dash_a' }],
30+
reports: [{ name: 'report_a' }],
31+
pages: [{ name: 'page_a' }],
32+
views: [{ listViews: {} }],
33+
};
34+
const b = {
35+
apps: [{ name: 'app_b' }],
36+
dashboards: [{ name: 'dash_b' }],
37+
reports: [{ name: 'report_b' }],
38+
pages: [{ name: 'page_b' }],
39+
views: [{ listViews: {} }],
40+
};
41+
42+
const result = composeStacks([a, b]);
43+
expect(result.apps).toHaveLength(2);
44+
expect(result.dashboards).toHaveLength(2);
45+
expect(result.reports).toHaveLength(2);
46+
expect(result.pages).toHaveLength(2);
47+
expect(result.views).toHaveLength(2);
48+
});
49+
50+
it('should merge manifest.data from multiple stacks', () => {
51+
const a = { manifest: { data: [{ object: 'account', mode: 'upsert', records: [] }] } };
52+
const b = { manifest: { data: [{ object: 'contact', mode: 'upsert', records: [] }] } };
53+
54+
const result = composeStacks([a, b]);
55+
expect(result.manifest.data).toHaveLength(2);
56+
expect(result.manifest.data[0].object).toBe('account');
57+
expect(result.manifest.data[1].object).toBe('contact');
58+
});
59+
60+
it('should handle empty stacks gracefully', () => {
61+
const result = composeStacks([{}, {}]);
62+
expect(result.objects).toEqual([]);
63+
expect(result.apps).toEqual([]);
64+
expect(result.manifest.data).toEqual([]);
65+
});
66+
67+
// ── Object deduplication ──────────────────────────────────────────────
68+
69+
it('should override duplicate objects by default (last wins)', () => {
70+
const a = { objects: [{ name: 'account', label: 'CRM Account', fields: { name: {} } }] };
71+
const b = { objects: [{ name: 'account', label: 'KS Account', fields: { name: {} } }] };
72+
73+
const result = composeStacks([a, b]);
74+
expect(result.objects).toHaveLength(1);
75+
expect(result.objects[0].label).toBe('KS Account');
76+
});
77+
78+
it('should throw on duplicate objects when objectConflict is "error"', () => {
79+
const a = { objects: [{ name: 'account', label: 'CRM Account', fields: {} }] };
80+
const b = { objects: [{ name: 'account', label: 'KS Account', fields: {} }] };
81+
82+
expect(() => composeStacks([a, b], { objectConflict: 'error' })).toThrow(
83+
'duplicate object name "account"'
84+
);
85+
});
86+
87+
// ── Views → Objects merging ───────────────────────────────────────────
88+
89+
it('should merge listViews from views into corresponding objects', () => {
90+
const stack = {
91+
objects: [{ name: 'todo_task', label: 'Task', fields: {} }],
92+
views: [
93+
{
94+
listViews: {
95+
all: {
96+
name: 'all',
97+
label: 'All Tasks',
98+
type: 'grid',
99+
data: { provider: 'object', object: 'todo_task' },
100+
},
101+
board: {
102+
name: 'board',
103+
label: 'Board',
104+
type: 'kanban',
105+
data: { provider: 'object', object: 'todo_task' },
106+
},
107+
},
108+
},
109+
],
110+
};
111+
112+
const result = composeStacks([stack]);
113+
const taskObj = result.objects.find((o: any) => o.name === 'todo_task');
114+
expect(taskObj.listViews).toBeDefined();
115+
expect(taskObj.listViews.all.label).toBe('All Tasks');
116+
expect(taskObj.listViews.board.label).toBe('Board');
117+
});
118+
119+
it('should not overwrite existing listViews on objects', () => {
120+
const stack = {
121+
objects: [
122+
{
123+
name: 'todo_task',
124+
label: 'Task',
125+
fields: {},
126+
listViews: { existing: { name: 'existing', label: 'Existing View', type: 'grid', data: { provider: 'object', object: 'todo_task' } } },
127+
},
128+
],
129+
views: [
130+
{
131+
listViews: {
132+
new_view: {
133+
name: 'new_view',
134+
label: 'New View',
135+
type: 'grid',
136+
data: { provider: 'object', object: 'todo_task' },
137+
},
138+
},
139+
},
140+
],
141+
};
142+
143+
const result = composeStacks([stack]);
144+
const taskObj = result.objects.find((o: any) => o.name === 'todo_task');
145+
expect(taskObj.listViews.existing).toBeDefined();
146+
expect(taskObj.listViews.new_view).toBeDefined();
147+
});
148+
149+
// ── Actions → Objects merging ─────────────────────────────────────────
150+
151+
it('should merge actions into objects via objectName', () => {
152+
const stack = {
153+
objects: [{ name: 'account', label: 'Account', fields: {} }],
154+
actions: [
155+
{ name: 'account_send_email', objectName: 'account', label: 'Send Email' },
156+
{ name: 'account_merge', objectName: 'account', label: 'Merge' },
157+
],
158+
};
159+
160+
const result = composeStacks([stack]);
161+
const accountObj = result.objects.find((o: any) => o.name === 'account');
162+
expect(accountObj.actions).toHaveLength(2);
163+
expect(accountObj.actions[0].label).toBe('Send Email');
164+
expect(accountObj.actions[1].label).toBe('Merge');
165+
});
166+
167+
it('should not merge actions that lack objectName', () => {
168+
const stack = {
169+
objects: [{ name: 'account', label: 'Account', fields: {} }],
170+
actions: [
171+
{ name: 'orphan_action', label: 'No Target' },
172+
],
173+
};
174+
175+
const result = composeStacks([stack]);
176+
const accountObj = result.objects.find((o: any) => o.name === 'account');
177+
expect(accountObj.actions).toBeUndefined();
178+
});
179+
180+
it('should append to existing actions on objects', () => {
181+
const stack = {
182+
objects: [
183+
{ name: 'account', label: 'Account', fields: {}, actions: [{ name: 'existing', label: 'Existing' }] },
184+
],
185+
actions: [
186+
{ name: 'account_new', objectName: 'account', label: 'New Action' },
187+
],
188+
};
189+
190+
const result = composeStacks([stack]);
191+
const accountObj = result.objects.find((o: any) => o.name === 'account');
192+
expect(accountObj.actions).toHaveLength(2);
193+
expect(accountObj.actions[0].label).toBe('Existing');
194+
expect(accountObj.actions[1].label).toBe('New Action');
195+
});
196+
197+
// ── Cross-stack merging ───────────────────────────────────────────────
198+
199+
it('should merge actions from one stack into objects from another', () => {
200+
const objectStack = { objects: [{ name: 'contact', label: 'Contact', fields: {} }] };
201+
const actionStack = {
202+
actions: [
203+
{ name: 'contact_send_email', objectName: 'contact', label: 'Send Email' },
204+
],
205+
};
206+
207+
const result = composeStacks([objectStack, actionStack]);
208+
const contactObj = result.objects.find((o: any) => o.name === 'contact');
209+
expect(contactObj.actions).toHaveLength(1);
210+
});
211+
212+
// ── Integration: CRM + Todo + Kitchen Sink pattern ────────────────────
213+
214+
it('should compose three example-like stacks without conflict', () => {
215+
const crm = {
216+
objects: [
217+
{ name: 'account', label: 'Account', fields: {} },
218+
{ name: 'contact', label: 'Contact', fields: {} },
219+
],
220+
views: [{
221+
listViews: {
222+
all_accounts: { name: 'all_accounts', label: 'All Accounts', type: 'grid', data: { provider: 'object', object: 'account' } },
223+
},
224+
}],
225+
actions: [
226+
{ name: 'account_send_email', objectName: 'account', label: 'Send Email' },
227+
{ name: 'contact_log_call', objectName: 'contact', label: 'Log Call' },
228+
],
229+
apps: [{ name: 'crm_app' }],
230+
manifest: { data: [{ object: 'account', mode: 'upsert', records: [{ name: 'Acme' }] }] },
231+
};
232+
233+
const todo = {
234+
objects: [{ name: 'todo_task', label: 'Task', fields: {} }],
235+
views: [{
236+
listViews: {
237+
all: { name: 'all', label: 'All Tasks', type: 'grid', data: { provider: 'object', object: 'todo_task' } },
238+
},
239+
}],
240+
actions: [
241+
{ name: 'todo_task_complete', objectName: 'todo_task', label: 'Complete' },
242+
],
243+
apps: [{ name: 'todo_app' }],
244+
manifest: { data: [{ object: 'todo_task', mode: 'upsert', records: [{ subject: 'Test' }] }] },
245+
};
246+
247+
const ks = {
248+
objects: [
249+
{ name: 'ks_account', label: 'Account', fields: {} },
250+
{ name: 'showcase', label: 'Showcase', fields: {} },
251+
],
252+
actions: [
253+
{ name: 'showcase_archive', objectName: 'showcase', label: 'Archive' },
254+
],
255+
apps: [{ name: 'showcase_app' }],
256+
};
257+
258+
const result = composeStacks([crm, todo, ks]);
259+
260+
// No duplicate account objects — CRM has 'account', KS has 'ks_account'
261+
expect(result.objects).toHaveLength(5);
262+
expect(result.objects.map((o: any) => o.name).sort()).toEqual([
263+
'account', 'contact', 'ks_account', 'showcase', 'todo_task',
264+
]);
265+
266+
// Actions merged correctly
267+
const account = result.objects.find((o: any) => o.name === 'account');
268+
expect(account.actions).toHaveLength(1);
269+
expect(account.listViews.all_accounts).toBeDefined();
270+
271+
const task = result.objects.find((o: any) => o.name === 'todo_task');
272+
expect(task.actions).toHaveLength(1);
273+
expect(task.listViews.all).toBeDefined();
274+
275+
const showcase = result.objects.find((o: any) => o.name === 'showcase');
276+
expect(showcase.actions).toHaveLength(1);
277+
278+
// Apps merged
279+
expect(result.apps).toHaveLength(3);
280+
281+
// Manifest data merged
282+
expect(result.manifest.data).toHaveLength(2);
283+
});
284+
});

0 commit comments

Comments
 (0)