Skip to content

Commit 1d9959e

Browse files
authored
Merge pull request #234 from objectstack-ai/copilot/evaluate-ui-service-implementation
2 parents f7f256d + b91bd57 commit 1d9959e

File tree

10 files changed

+823
-0
lines changed

10 files changed

+823
-0
lines changed

objectstack.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { NotificationPlugin } from '@objectos/notification';
2020
import { PermissionsPlugin } from '@objectos/permissions';
2121
import { createRealtimePlugin } from '@objectos/realtime';
2222
import { StoragePlugin } from '@objectos/storage';
23+
import { UIPlugin } from '@objectos/ui';
2324
import { WorkflowPlugin } from '@objectos/workflow';
2425
import { resolve } from 'path';
2526

@@ -77,6 +78,7 @@ export default defineStack({
7778
// Services
7879
new NotificationPlugin(),
7980
new I18nPlugin(),
81+
new UIPlugin(),
8082
// createRealtimePlugin(),
8183

8284
// Example Apps

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"@objectos/permissions": "workspace:*",
8686
"@objectos/realtime": "workspace:*",
8787
"@objectos/storage": "workspace:*",
88+
"@objectos/ui": "workspace:*",
8889
"@objectos/workflow": "workspace:*",
8990
"@objectql/core": "^4.2.0",
9091
"@objectql/driver-mongo": "^4.2.0",

packages/ui/jest.config.cjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module.exports = {
2+
preset: 'ts-jest/presets/default-esm',
3+
testEnvironment: 'node',
4+
extensionsToTreatAsEsm: ['.ts'],
5+
moduleNameMapper: {
6+
'^(\\.{1,2}/.*)\\.js$': '$1',
7+
},
8+
transform: {
9+
'^.+\\.ts$': [
10+
'ts-jest',
11+
{
12+
useESM: true,
13+
},
14+
],
15+
},
16+
roots: ['<rootDir>/test'],
17+
testMatch: ['**/*.test.ts'],
18+
collectCoverageFrom: [
19+
'src/**/*.ts',
20+
'!src/**/*.d.ts'
21+
]
22+
};

packages/ui/package.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@objectos/ui",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"license": "AGPL-3.0",
6+
"description": "UI metadata service for ObjectOS — manages view definitions stored in database via ObjectQL",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"scripts": {
10+
"build": "tsup src/index.ts --format esm,cjs --clean && tsc --emitDeclarationOnly --declaration",
11+
"test": "jest --forceExit --passWithNoTests",
12+
"clean": "rm -rf dist",
13+
"type-check": "tsc --noEmit"
14+
},
15+
"dependencies": {
16+
"@objectstack/runtime": "^2.0.4",
17+
"@objectstack/spec": "2.0.4"
18+
},
19+
"devDependencies": {
20+
"@types/jest": "^30.0.0",
21+
"@types/node": "^25.2.0",
22+
"jest": "^30.2.0",
23+
"ts-jest": "^29.4.6",
24+
"tsup": "^8.5.1",
25+
"typescript": "^5.9.3"
26+
},
27+
"files": [
28+
"dist"
29+
],
30+
"keywords": [
31+
"objectos",
32+
"ui",
33+
"metadata",
34+
"view",
35+
"objectql"
36+
]
37+
}

packages/ui/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* UI Plugin — Public API
3+
*
4+
* Export all public interfaces and classes
5+
*/
6+
7+
// Types
8+
export type {
9+
UIServiceConfig,
10+
ViewRecord,
11+
} from './types.js';
12+
13+
// Plugin
14+
export { UIPlugin, getUIAPI } from './plugin.js';

packages/ui/src/plugin.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/**
2+
* UI Plugin for ObjectOS
3+
*
4+
* Manages view-related metadata persisted in a database via ObjectQL.
5+
* On init the plugin registers a `sys_view` object in ObjectQL and exposes
6+
* CRUD helpers that other plugins and the Admin Console can call through
7+
* the kernel service registry (`kernel.getService('ui')`).
8+
*
9+
* Architecture reference:
10+
* @objectstack/spec examples/metadata-objectql
11+
*/
12+
13+
import type { Plugin, PluginContext } from '@objectstack/runtime';
14+
import type {
15+
UIServiceConfig,
16+
ViewRecord,
17+
PluginHealthReport,
18+
PluginCapabilityManifest,
19+
PluginSecurityManifest,
20+
PluginStartupResult,
21+
} from './types.js';
22+
23+
/**
24+
* UI Plugin
25+
* Implements the Plugin interface for @objectstack/runtime
26+
*/
27+
export class UIPlugin implements Plugin {
28+
name = '@objectos/ui';
29+
version = '0.1.0';
30+
dependencies: string[] = [];
31+
32+
private context?: PluginContext;
33+
private objectql: any;
34+
private startedAt?: number;
35+
private viewObjectName: string;
36+
37+
constructor(config: UIServiceConfig = {}) {
38+
this.viewObjectName = config.viewObjectName ?? 'sys_view';
39+
}
40+
41+
// ─── Lifecycle ─────────────────────────────────────────────────────────────
42+
43+
/**
44+
* Initialize plugin – register the UI service and define the sys_view object.
45+
*/
46+
init = async (context: PluginContext): Promise<void> => {
47+
this.context = context;
48+
this.startedAt = Date.now();
49+
50+
// Register as "ui" service (CoreServiceName)
51+
context.registerService('ui', this);
52+
53+
// Obtain ObjectQL service for database access
54+
try {
55+
this.objectql = context.getService('objectql') ?? context.getService('data');
56+
} catch {
57+
// ObjectQL might not be available yet; will try again in start()
58+
}
59+
60+
context.logger.info('[UI] Initialized successfully');
61+
};
62+
63+
/**
64+
* Start plugin – ensure ObjectQL is available and register the sys_view object.
65+
*/
66+
async start(context: PluginContext): Promise<void> {
67+
// Re-try ObjectQL lookup if it wasn't available during init
68+
if (!this.objectql) {
69+
try {
70+
this.objectql = context.getService('objectql') ?? context.getService('data');
71+
} catch {
72+
context.logger.warn('[UI] ObjectQL service not available – view persistence disabled');
73+
}
74+
}
75+
76+
if (this.objectql) {
77+
await this.registerViewObject();
78+
}
79+
80+
context.logger.info('[UI] Started successfully');
81+
}
82+
83+
// ─── View CRUD ─────────────────────────────────────────────────────────────
84+
85+
/**
86+
* Save (upsert) a view definition to the database.
87+
*/
88+
async saveView(viewName: string, objectName: string, definition: Record<string, unknown>): Promise<ViewRecord> {
89+
this.ensureObjectQL();
90+
91+
const record: Omit<ViewRecord, '_id'> = {
92+
name: viewName,
93+
object_name: objectName,
94+
label: (definition as any).label ?? viewName,
95+
type: (definition as any).type ?? 'grid',
96+
definition,
97+
is_default: false,
98+
is_public: true,
99+
};
100+
101+
const existing = await this.objectql.findOne(this.viewObjectName, {
102+
filters: [['name', '=', viewName]],
103+
});
104+
105+
if (existing) {
106+
return await this.objectql.update(this.viewObjectName, existing._id, record);
107+
}
108+
return await this.objectql.insert(this.viewObjectName, record);
109+
}
110+
111+
/**
112+
* Load a single view definition by name.
113+
*/
114+
async loadView(viewName: string): Promise<ViewRecord | null> {
115+
this.ensureObjectQL();
116+
117+
return await this.objectql.findOne(this.viewObjectName, {
118+
filters: [['name', '=', viewName]],
119+
});
120+
}
121+
122+
/**
123+
* List all views for a given object.
124+
*/
125+
async listViews(objectName: string): Promise<ViewRecord[]> {
126+
this.ensureObjectQL();
127+
128+
return await this.objectql.find(this.viewObjectName, {
129+
filters: [['object_name', '=', objectName]],
130+
sort: [{ field: 'name', order: 'asc' }],
131+
});
132+
}
133+
134+
/**
135+
* Delete a view by name.
136+
*/
137+
async deleteView(viewName: string): Promise<boolean> {
138+
this.ensureObjectQL();
139+
140+
const existing = await this.objectql.findOne(this.viewObjectName, {
141+
filters: [['name', '=', viewName]],
142+
});
143+
144+
if (!existing) return false;
145+
146+
await this.objectql.delete(this.viewObjectName, existing._id);
147+
return true;
148+
}
149+
150+
// ─── Kernel Compliance ─────────────────────────────────────────────────────
151+
152+
/**
153+
* Health check
154+
*/
155+
async healthCheck(): Promise<PluginHealthReport> {
156+
let checkStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
157+
let message = 'UI service operational';
158+
159+
if (!this.objectql) {
160+
checkStatus = 'degraded';
161+
message = 'ObjectQL service not available';
162+
}
163+
164+
return {
165+
status: checkStatus,
166+
timestamp: new Date().toISOString(),
167+
message,
168+
metrics: {
169+
uptime: this.startedAt ? Date.now() - this.startedAt : 0,
170+
},
171+
checks: [
172+
{
173+
name: 'objectql-backend',
174+
status: checkStatus === 'healthy' ? 'passed' : 'warning',
175+
message,
176+
},
177+
],
178+
};
179+
}
180+
181+
/**
182+
* Capability manifest
183+
*/
184+
getManifest(): { capabilities: PluginCapabilityManifest; security: PluginSecurityManifest } {
185+
return {
186+
capabilities: {},
187+
security: {
188+
pluginId: 'ui',
189+
trustLevel: 'trusted',
190+
permissions: { permissions: [], defaultGrant: 'deny' },
191+
sandbox: { enabled: false, level: 'none' },
192+
},
193+
};
194+
}
195+
196+
/**
197+
* Startup result
198+
*/
199+
getStartupResult(): PluginStartupResult {
200+
return {
201+
plugin: { name: this.name, version: this.version },
202+
success: !!this.context,
203+
duration: 0,
204+
};
205+
}
206+
207+
/**
208+
* Cleanup
209+
*/
210+
async destroy(): Promise<void> {
211+
this.objectql = undefined;
212+
this.context?.logger.info('[UI] Destroyed');
213+
}
214+
215+
// ─── Internal ──────────────────────────────────────────────────────────────
216+
217+
/**
218+
* Register the sys_view metadata object in ObjectQL.
219+
*/
220+
private async registerViewObject(): Promise<void> {
221+
if (!this.objectql) return;
222+
223+
// Only attempt if ObjectQL exposes registerObject (engine instance)
224+
if (typeof this.objectql.registerObject !== 'function') return;
225+
226+
try {
227+
const { ObjectSchema, Field } = await import('@objectstack/spec/data');
228+
229+
const SysView = ObjectSchema.create({
230+
name: this.viewObjectName,
231+
label: 'View Metadata',
232+
description: 'Stores UI view definitions',
233+
fields: {
234+
name: Field.text({ label: 'View Name', required: true, unique: true }),
235+
object_name: Field.text({ label: 'Object Name', required: true }),
236+
label: Field.text({ label: 'Label' }),
237+
type: Field.select(['grid', 'kanban', 'calendar', 'timeline', 'gantt'], {
238+
label: 'View Type',
239+
required: true,
240+
}),
241+
definition: Field.textarea({ label: 'View Definition', required: true }),
242+
is_default: Field.boolean({ label: 'Is Default' }),
243+
is_public: Field.boolean({ label: 'Is Public' }),
244+
},
245+
indexes: [
246+
{ fields: ['name'], unique: true },
247+
{ fields: ['object_name'], unique: false },
248+
],
249+
});
250+
251+
this.objectql.registerObject(SysView);
252+
this.context?.logger.info(`[UI] Registered object: ${this.viewObjectName}`);
253+
} catch (err) {
254+
this.context?.logger.warn(`[UI] Could not register ${this.viewObjectName}: ${(err as Error).message}`);
255+
}
256+
}
257+
258+
/**
259+
* Guard ensuring ObjectQL is available before data operations.
260+
*/
261+
private ensureObjectQL(): void {
262+
if (!this.objectql) {
263+
throw new Error('[UI] ObjectQL service not available. Cannot perform view operations.');
264+
}
265+
}
266+
}
267+
268+
/**
269+
* Helper to access the UI API from the kernel.
270+
*/
271+
export function getUIAPI(kernel: any): UIPlugin | null {
272+
try {
273+
return kernel.getService('ui');
274+
} catch {
275+
return null;
276+
}
277+
}

0 commit comments

Comments
 (0)