Skip to content

Commit 8082e2e

Browse files
authored
Merge pull request #1022 from objectstack-ai/copilot/remove-duplicate-merge-logic
2 parents d929735 + 59e6ce9 commit 8082e2e

19 files changed

+522
-214
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

apps/console/objectstack.shared.ts

Lines changed: 22 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defineStack } from '@objectstack/spec';
22
import type { ObjectStackDefinition } from '@objectstack/spec';
3+
import { composeStacks } from '@object-ui/core';
34
import crmConfigImport from '@object-ui/example-crm/objectstack.config';
45
import todoConfigImport from '@object-ui/example-todo/objectstack.config';
56
import kitchenSinkConfigImport from '@object-ui/example-kitchen-sink/objectstack.config';
@@ -36,84 +37,13 @@ if (crmApps.length > 0) {
3637
}
3738
}
3839

39-
// ---------------------------------------------------------------------------
40-
// Merge stack-level views into object definitions.
41-
// The @objectstack/spec defines views at the stack level (views[].listViews),
42-
// but the runtime protocol serves objects without listViews. This helper
43-
// merges listViews from the views array into the corresponding objects so
44-
// the console can render the correct view type when switching views.
45-
// ---------------------------------------------------------------------------
46-
function mergeViewsIntoObjects(objects: any[], configs: any[]): any[] {
47-
// Collect all listViews grouped by object name
48-
const viewsByObject: Record<string, Record<string, any>> = {};
49-
for (const config of configs) {
50-
if (!Array.isArray(config.views)) continue;
51-
for (const view of config.views) {
52-
if (!view.listViews) continue;
53-
for (const [viewName, listView] of Object.entries(view.listViews as Record<string, any>)) {
54-
const objectName = listView?.data?.object;
55-
if (!objectName) continue;
56-
if (!viewsByObject[objectName]) viewsByObject[objectName] = {};
57-
viewsByObject[objectName][viewName] = listView;
58-
}
59-
}
60-
}
61-
62-
// Merge into objects
63-
return objects.map((obj: any) => {
64-
const views = viewsByObject[obj.name];
65-
if (!views) return obj;
66-
return { ...obj, listViews: { ...(obj.listViews || {}), ...views } };
67-
});
68-
}
69-
70-
// ---------------------------------------------------------------------------
71-
// Merge stack-level actions into object definitions.
72-
// Actions declared at the stack level (actions[]) need to be merged into
73-
// individual object definitions so the runtime protocol (and Console/Studio)
74-
// can render action buttons directly from objectDef.actions.
75-
// Matching uses explicit objectName on the action, or longest object-name
76-
// prefix of the action name (e.g. "account_send_email" → "account").
77-
// ---------------------------------------------------------------------------
78-
function mergeActionsIntoObjects(objects: any[], configs: any[]): any[] {
79-
const allActions: any[] = [];
80-
for (const config of configs) {
81-
if (Array.isArray(config.actions)) {
82-
allActions.push(...config.actions);
83-
}
84-
}
85-
if (allActions.length === 0) return objects;
86-
87-
// Sort object names longest-first so "order_item" matches before "order"
88-
const objectNames = objects.map((o: any) => o.name as string)
89-
.filter(Boolean)
90-
.sort((a, b) => b.length - a.length);
91-
92-
const actionsByObject: Record<string, any[]> = {};
93-
for (const action of allActions) {
94-
let target: string | undefined = action.objectName;
95-
if (!target) {
96-
for (const name of objectNames) {
97-
if (action.name.startsWith(name + '_')) {
98-
target = name;
99-
break;
100-
}
101-
}
102-
}
103-
if (target) {
104-
if (!actionsByObject[target]) actionsByObject[target] = [];
105-
actionsByObject[target].push(action);
106-
}
107-
}
108-
109-
return objects.map((obj: any) => {
110-
const actions = actionsByObject[obj.name];
111-
if (!actions) return obj;
112-
return { ...obj, actions: [...(obj.actions || []), ...actions] };
113-
});
114-
}
115-
116-
const allConfigs = [crmConfig, todoConfig, kitchenSinkConfig];
40+
// Compose all example stacks into a single merged definition.
41+
// composeStacks handles object deduplication (override), views→objects mapping,
42+
// and actions→objects assignment via objectName.
43+
const composed = composeStacks(
44+
[crmConfig, todoConfig, kitchenSinkConfig] as Record<string, any>[],
45+
{ objectConflict: 'override' },
46+
);
11747

11848
export const sharedConfig = {
11949
// ============================================================================
@@ -125,29 +55,15 @@ export const sharedConfig = {
12555
description: 'ObjectStack Console',
12656

12757
// ============================================================================
128-
// Merged Stack Configuration (CRM + Todo + Kitchen Sink + Mock Metadata)
58+
// Merged Stack Configuration (CRM + Todo + Kitchen Sink)
12959
// ============================================================================
130-
objects: mergeActionsIntoObjects(
131-
mergeViewsIntoObjects(
132-
[
133-
...(crmConfig.objects || []),
134-
...(todoConfig.objects || []),
135-
...(kitchenSinkConfig.objects || []),
136-
],
137-
allConfigs,
138-
),
139-
allConfigs,
140-
),
60+
objects: composed.objects,
14161
apps: [
14262
...crmApps,
14363
...(todoConfig.apps || []),
14464
...(kitchenSinkConfig.apps || []),
14565
],
146-
dashboards: [
147-
...(crmConfig.dashboards || []),
148-
...(todoConfig.dashboards || []),
149-
...(kitchenSinkConfig.dashboards || [])
150-
],
66+
dashboards: composed.dashboards,
15167
reports: [
15268
...(crmConfig.reports || []),
15369
// Manually added report since CRM config validation prevents it
@@ -165,21 +81,13 @@ export const sharedConfig = {
16581
]
16682
}
16783
],
168-
pages: [
169-
...(crmConfig.pages || []),
170-
...(todoConfig.pages || []),
171-
...(kitchenSinkConfig.pages || [])
172-
],
84+
pages: composed.pages,
17385
manifest: {
17486
id: 'com.objectui.console',
17587
version: '0.1.0',
17688
type: 'app',
17789
name: '@object-ui/console',
178-
data: [
179-
...(crmConfig.manifest?.data || []),
180-
...(todoConfig.manifest?.data || []),
181-
...(kitchenSinkConfig.manifest?.data || [])
182-
]
90+
data: composed.manifest.data,
18391
},
18492
plugins: [],
18593
datasources: [
@@ -191,14 +99,17 @@ export const sharedConfig = {
19199
]
192100
};
193101

102+
const allConfigs = [crmConfig, todoConfig, kitchenSinkConfig];
103+
194104
// defineStack() validates the config but strips non-standard properties like
195-
// listViews and actions from objects. Re-merge after validation so the runtime
196-
// protocol serves objects with their view and action definitions.
105+
// listViews and actions from objects. A second composeStacks pass restores
106+
// these runtime properties onto the validated objects. This double-pass is
107+
// necessary because defineStack's Zod schema doesn't preserve custom fields.
197108
const validated = defineStack(sharedConfig as Parameters<typeof defineStack>[0]);
198109
export default {
199110
...validated,
200-
objects: mergeActionsIntoObjects(
201-
mergeViewsIntoObjects(validated.objects || [], allConfigs),
202-
allConfigs,
203-
),
111+
objects: composeStacks([
112+
{ objects: validated.objects || [] },
113+
...allConfigs.map((cfg: any) => ({ views: cfg.views || [], actions: cfg.actions || [] })),
114+
]).objects,
204115
};

examples/crm/src/actions/account.actions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const AccountActions = [
55
icon: 'mail',
66
type: 'api' as const,
77
target: 'account_send_email',
8+
objectName: 'account',
89
locations: ['record_header' as const, 'list_item' as const],
910
params: [
1011
{ name: 'to', label: 'To Email', type: 'email' as const, required: true },
@@ -19,6 +20,7 @@ export const AccountActions = [
1920
icon: 'user-plus',
2021
type: 'api' as const,
2122
target: 'account_assign_owner',
23+
objectName: 'account',
2224
locations: ['record_header' as const, 'list_item' as const, 'list_toolbar' as const],
2325
bulkEnabled: true,
2426
params: [
@@ -33,6 +35,7 @@ export const AccountActions = [
3335
icon: 'git-merge',
3436
type: 'api' as const,
3537
target: 'account_merge',
38+
objectName: 'account',
3639
locations: ['record_more' as const],
3740
confirmText: 'Are you sure you want to merge these accounts? This action cannot be undone.',
3841
variant: 'danger' as const,

examples/crm/src/actions/contact.actions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const ContactActions = [
55
icon: 'mail',
66
type: 'api' as const,
77
target: 'contact_send_email',
8+
objectName: 'contact',
89
locations: ['record_header' as const, 'list_item' as const, 'list_toolbar' as const],
910
bulkEnabled: true,
1011
params: [
@@ -19,6 +20,7 @@ export const ContactActions = [
1920
icon: 'user-check',
2021
type: 'api' as const,
2122
target: 'contact_convert_to_customer',
23+
objectName: 'contact',
2224
locations: ['record_header' as const],
2325
visible: "type !== 'Customer'",
2426
confirmText: 'Convert this contact to a customer?',
@@ -31,6 +33,7 @@ export const ContactActions = [
3133
icon: 'phone',
3234
type: 'api' as const,
3335
target: 'contact_log_call',
36+
objectName: 'contact',
3437
locations: ['record_header' as const, 'list_item' as const],
3538
params: [
3639
{ name: 'call_subject', label: 'Subject', type: 'text' as const, required: true },

examples/crm/src/actions/event.actions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const EventActions = [
55
icon: 'send',
66
type: 'api' as const,
77
target: 'event_send_invitation',
8+
objectName: 'event',
89
locations: ['record_header' as const],
910
params: [
1011
{ name: 'message', label: 'Optional Message', type: 'textarea' as const },
@@ -17,6 +18,7 @@ export const EventActions = [
1718
icon: 'check-circle',
1819
type: 'api' as const,
1920
target: 'event_mark_completed',
21+
objectName: 'event',
2022
locations: ['record_header' as const],
2123
confirmText: 'Mark this event as completed?',
2224
refreshAfter: true,
@@ -28,6 +30,7 @@ export const EventActions = [
2830
icon: 'x-circle',
2931
type: 'api' as const,
3032
target: 'event_cancel',
33+
objectName: 'event',
3134
locations: ['record_more' as const],
3235
variant: 'danger' as const,
3336
params: [

examples/crm/src/actions/opportunity.actions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const OpportunityActions = [
55
icon: 'arrow-right-circle',
66
type: 'api' as const,
77
target: 'opportunity_change_stage',
8+
objectName: 'opportunity',
89
locations: ['record_header' as const, 'list_item' as const, 'list_toolbar' as const],
910
bulkEnabled: true,
1011
visible: "stage !== 'closed_won' && stage !== 'closed_lost'",
@@ -30,6 +31,7 @@ export const OpportunityActions = [
3031
icon: 'trophy',
3132
type: 'api' as const,
3233
target: 'opportunity_mark_won',
34+
objectName: 'opportunity',
3335
locations: ['record_header' as const],
3436
variant: 'primary' as const,
3537
visible: "stage !== 'closed_won' && stage !== 'closed_lost'",
@@ -43,6 +45,7 @@ export const OpportunityActions = [
4345
icon: 'x-circle',
4446
type: 'api' as const,
4547
target: 'opportunity_mark_lost',
48+
objectName: 'opportunity',
4649
locations: ['record_more' as const],
4750
variant: 'danger' as const,
4851
visible: "stage !== 'closed_won' && stage !== 'closed_lost'",

examples/crm/src/actions/opportunity_contact.actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const OpportunityContactActions = [
55
icon: 'star',
66
type: 'api' as const,
77
target: 'opportunity_contact_set_primary',
8+
objectName: 'opportunity_contact',
89
locations: ['record_header' as const, 'list_item' as const],
910
refreshAfter: true,
1011
successMessage: 'Primary contact updated',

examples/crm/src/actions/order.actions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const OrderActions = [
55
icon: 'refresh-cw',
66
type: 'api' as const,
77
target: 'order_change_status',
8+
objectName: 'order',
89
locations: ['record_header' as const, 'list_item' as const],
910
params: [
1011
{
@@ -28,6 +29,7 @@ export const OrderActions = [
2829
icon: 'file-text',
2930
type: 'api' as const,
3031
target: 'order_generate_invoice',
32+
objectName: 'order',
3133
locations: ['record_header' as const],
3234
confirmText: 'Generate an invoice for this order?',
3335
successMessage: 'Invoice generated successfully',
@@ -38,6 +40,7 @@ export const OrderActions = [
3840
icon: 'truck',
3941
type: 'api' as const,
4042
target: 'order_mark_shipped',
43+
objectName: 'order',
4144
locations: ['record_header' as const],
4245
params: [
4346
{ name: 'tracking_number', label: 'Tracking Number', type: 'text' as const, required: true },

examples/crm/src/actions/order_item.actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const OrderItemActions = [
55
icon: 'hash',
66
type: 'api' as const,
77
target: 'order_item_adjust_quantity',
8+
objectName: 'order_item',
89
locations: ['record_header' as const, 'list_item' as const],
910
params: [
1011
{ name: 'new_quantity', label: 'New Quantity', type: 'number' as const, required: true },

examples/crm/src/actions/product.actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const ProductActions = [
55
icon: 'toggle-left',
66
type: 'api' as const,
77
target: 'product_toggle_active',
8+
objectName: 'product',
89
locations: ['record_header' as const, 'list_item' as const],
910
refreshAfter: true,
1011
successMessage: 'Product status updated',
@@ -15,6 +16,7 @@ export const ProductActions = [
1516
icon: 'dollar-sign',
1617
type: 'api' as const,
1718
target: 'product_update_price',
19+
objectName: 'product',
1820
locations: ['record_header' as const],
1921
params: [
2022
{ name: 'new_price', label: 'New Price', type: 'currency' as const, required: true },

0 commit comments

Comments
 (0)