Skip to content

Commit c95629b

Browse files
Copilothotlong
andcommitted
Create composeStacks utility and refactor config files to remove duplicate merge logic
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent cf7ba65 commit c95629b

File tree

4 files changed

+180
-204
lines changed

4 files changed

+180
-204
lines changed

apps/console/objectstack.shared.ts

Lines changed: 21 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,16 @@ 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. Re-compose after validation so the
106+
// runtime protocol serves objects with their view and action definitions.
197107
const validated = defineStack(sharedConfig as Parameters<typeof defineStack>[0]);
198108
export default {
199109
...validated,
200-
objects: mergeActionsIntoObjects(
201-
mergeViewsIntoObjects(validated.objects || [], allConfigs),
202-
allConfigs,
203-
),
110+
objects: composeStacks([
111+
{ objects: validated.objects || [] },
112+
...allConfigs.map((cfg: any) => ({ views: cfg.views || [], actions: cfg.actions || [] })),
113+
]).objects,
204114
};

objectstack.config.ts

Lines changed: 20 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,19 @@
88
* - MSW: `pnpm dev` — Vite dev server with MSW intercepting API calls in browser
99
* - Server: `pnpm dev:server` — Real ObjectStack API server + Vite console proxying to it
1010
*
11-
* Note: Examples are merged into a single AppPlugin (rather than separate AppPlugins)
12-
* because CRM and Kitchen Sink both define an `account` object, which would
13-
* trigger an ownership conflict in the ObjectQL Schema Registry.
14-
*
1511
* Plugins: Each example app exports a plugin class (CRMPlugin, TodoPlugin,
1612
* KitchenSinkPlugin) that implements the AppMetadataPlugin interface.
1713
* For standalone use, each plugin can be loaded independently via
1814
* `kernel.use(new CRMPlugin())`. In the dev workspace, we collect their
19-
* configs via `getConfig()` and merge them into a single AppPlugin.
15+
* configs via `getConfig()` and merge them with `composeStacks()`.
2016
*/
2117
import { defineStack } from '@objectstack/spec';
2218
import { AppPlugin, DriverPlugin } from '@objectstack/runtime';
2319
import { ObjectQLPlugin } from '@objectstack/objectql';
2420
import { InMemoryDriver } from '@objectstack/driver-memory';
2521
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
2622
import { ConsolePlugin } from '@object-ui/console';
23+
import { composeStacks } from '@object-ui/core';
2724
import { CRMPlugin } from './examples/crm/plugin';
2825
import { TodoPlugin } from './examples/todo/plugin';
2926
import { KitchenSinkPlugin } from './examples/kitchen-sink/plugin';
@@ -37,107 +34,37 @@ const allConfigs = plugins.map((p) => {
3734
return (raw as any).default || raw;
3835
});
3936

40-
// Base objects from all plugins
41-
const baseObjects = allConfigs.flatMap((cfg: any) => cfg.objects || []);
42-
43-
// ---------------------------------------------------------------------------
44-
// Merge stack-level views into object definitions.
45-
// defineStack() strips non-standard properties like listViews from objects.
46-
// Re-merge listViews after validation so the runtime protocol serves objects
47-
// with their view definitions (calendar, kanban, etc.).
48-
// ---------------------------------------------------------------------------
49-
function mergeViewsIntoObjects(objects: any[], configs: any[]): any[] {
50-
const viewsByObject: Record<string, Record<string, any>> = {};
51-
for (const config of configs) {
52-
if (!Array.isArray(config.views)) continue;
53-
for (const view of config.views) {
54-
if (!view.listViews) continue;
55-
for (const [viewName, listView] of Object.entries(view.listViews as Record<string, any>)) {
56-
const objectName = listView?.data?.object;
57-
if (!objectName) continue;
58-
if (!viewsByObject[objectName]) viewsByObject[objectName] = {};
59-
viewsByObject[objectName][viewName] = listView;
60-
}
61-
}
62-
}
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-
}
37+
// Compose all plugin configs into a single stack definition.
38+
// composeStacks handles object deduplication, views→objects mapping,
39+
// and actions→objects assignment via objectName.
40+
const composed = composeStacks(allConfigs, { objectConflict: 'override' });
11541

116-
// Merge all plugin configs into a single app bundle for AppPlugin
42+
// Validate via defineStack, then re-apply runtime properties (listViews, actions)
43+
// that defineStack strips during validation.
11744
const mergedApp = defineStack({
11845
manifest: {
11946
id: 'dev-workspace',
12047
name: 'dev_workspace',
12148
version: '0.0.0',
12249
description: 'ObjectUI monorepo development workspace',
12350
type: 'app',
124-
data: allConfigs.flatMap((cfg: any) => cfg.manifest?.data || []),
51+
data: composed.manifest.data,
12552
},
126-
objects: baseObjects,
127-
views: allConfigs.flatMap((cfg: any) => cfg.views || []),
128-
apps: allConfigs.flatMap((cfg: any) => cfg.apps || []),
129-
dashboards: allConfigs.flatMap((cfg: any) => cfg.dashboards || []),
130-
reports: allConfigs.flatMap((cfg: any) => cfg.reports || []),
131-
pages: allConfigs.flatMap((cfg: any) => cfg.pages || []),
53+
objects: composed.objects,
54+
views: composed.views,
55+
apps: composed.apps,
56+
dashboards: composed.dashboards,
57+
reports: composed.reports,
58+
pages: composed.pages,
13259
} as any);
13360

134-
// Re-merge listViews and actions that defineStack stripped from objects
61+
// Re-compose after defineStack validation to restore listViews and actions
13562
const mergedAppWithViews = {
13663
...mergedApp,
137-
objects: mergeActionsIntoObjects(
138-
mergeViewsIntoObjects(mergedApp.objects || [], allConfigs),
139-
allConfigs,
140-
),
64+
objects: composeStacks([
65+
{ objects: mergedApp.objects || [] },
66+
...allConfigs.map((cfg: any) => ({ views: cfg.views || [], actions: cfg.actions || [] })),
67+
]).objects,
14168
};
14269

14370
// Export only plugins — no top-level objects/manifest/apps.

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ export * from './data-scope/index.js';
2626
export * from './errors/index.js';
2727
export * from './utils/debug.js';
2828
export * from './utils/debug-collector.js';
29+
export * from './utils/compose-stacks.js';
2930
export * from './protocols/index.js';

0 commit comments

Comments
 (0)