@specverse/runtime 4.1.0 → 4.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +356 -0
- package/dist/runtime/views/core/atomic-components-registry.d.ts +63 -0
- package/dist/runtime/views/core/atomic-components-registry.d.ts.map +1 -0
- package/dist/runtime/views/core/atomic-components-registry.js +822 -0
- package/dist/runtime/views/core/atomic-components-registry.js.map +1 -0
- package/dist/runtime/views/core/composite-pattern-types.d.ts +171 -0
- package/dist/runtime/views/core/composite-pattern-types.d.ts.map +1 -0
- package/dist/runtime/views/core/composite-pattern-types.js +11 -0
- package/dist/runtime/views/core/composite-pattern-types.js.map +1 -0
- package/dist/runtime/views/core/composite-patterns.d.ts +67 -0
- package/dist/runtime/views/core/composite-patterns.d.ts.map +1 -0
- package/dist/runtime/views/core/composite-patterns.js +485 -0
- package/dist/runtime/views/core/composite-patterns.js.map +1 -0
- package/dist/runtime/views/core/entity-display.d.ts +28 -0
- package/dist/runtime/views/core/entity-display.d.ts.map +1 -1
- package/dist/runtime/views/core/entity-display.js +75 -0
- package/dist/runtime/views/core/entity-display.js.map +1 -1
- package/dist/runtime/views/core/index.d.ts +4 -1
- package/dist/runtime/views/core/index.d.ts.map +1 -1
- package/dist/runtime/views/core/index.js +5 -1
- package/dist/runtime/views/core/index.js.map +1 -1
- package/dist/runtime/views/core/pattern-engine.d.ts +2 -2
- package/dist/runtime/views/core/pattern-engine.d.ts.map +1 -1
- package/dist/runtime/views/core/pattern-engine.js.map +1 -1
- package/dist/runtime/views/core/types.d.ts +2 -0
- package/dist/runtime/views/core/types.d.ts.map +1 -1
- package/dist/runtime/views/index.d.ts +5 -2
- package/dist/runtime/views/index.d.ts.map +1 -1
- package/dist/runtime/views/index.js +5 -2
- package/dist/runtime/views/index.js.map +1 -1
- package/dist/runtime/views/react/components/DevShell.d.ts.map +1 -1
- package/dist/runtime/views/react/components/DevShell.js +6 -2
- package/dist/runtime/views/react/components/DevShell.js.map +1 -1
- package/dist/runtime/views/react/components/EntitySelect.d.ts +14 -0
- package/dist/runtime/views/react/components/EntitySelect.d.ts.map +1 -0
- package/dist/runtime/views/react/components/EntitySelect.js +29 -0
- package/dist/runtime/views/react/components/EntitySelect.js.map +1 -0
- package/dist/runtime/views/react/components/EventStream.d.ts +11 -0
- package/dist/runtime/views/react/components/EventStream.d.ts.map +1 -0
- package/dist/runtime/views/react/components/EventStream.js +49 -0
- package/dist/runtime/views/react/components/EventStream.js.map +1 -0
- package/dist/runtime/views/react/components/FieldInput.d.ts +23 -0
- package/dist/runtime/views/react/components/FieldInput.d.ts.map +1 -0
- package/dist/runtime/views/react/components/FieldInput.js +28 -0
- package/dist/runtime/views/react/components/FieldInput.js.map +1 -0
- package/dist/runtime/views/react/components/FormView.d.ts +21 -0
- package/dist/runtime/views/react/components/FormView.d.ts.map +1 -0
- package/dist/runtime/views/react/components/FormView.js +13 -0
- package/dist/runtime/views/react/components/FormView.js.map +1 -0
- package/dist/runtime/views/react/components/ModelManager.d.ts +6 -2
- package/dist/runtime/views/react/components/ModelManager.d.ts.map +1 -1
- package/dist/runtime/views/react/components/ModelManager.js +166 -61
- package/dist/runtime/views/react/components/ModelManager.js.map +1 -1
- package/dist/runtime/views/react/components/ModelSelector.d.ts.map +1 -1
- package/dist/runtime/views/react/components/ModelSelector.js +4 -1
- package/dist/runtime/views/react/components/ModelSelector.js.map +1 -1
- package/dist/runtime/views/react/components/OperationExecutor.d.ts +15 -0
- package/dist/runtime/views/react/components/OperationExecutor.d.ts.map +1 -0
- package/dist/runtime/views/react/components/OperationExecutor.js +86 -0
- package/dist/runtime/views/react/components/OperationExecutor.js.map +1 -0
- package/dist/runtime/views/react/components/OperationResultView.d.ts +10 -0
- package/dist/runtime/views/react/components/OperationResultView.d.ts.map +1 -0
- package/dist/runtime/views/react/components/OperationResultView.js +92 -0
- package/dist/runtime/views/react/components/OperationResultView.js.map +1 -0
- package/dist/runtime/views/react/components/OperationView.d.ts +21 -0
- package/dist/runtime/views/react/components/OperationView.d.ts.map +1 -0
- package/dist/runtime/views/react/components/OperationView.js +7 -0
- package/dist/runtime/views/react/components/OperationView.js.map +1 -0
- package/dist/runtime/views/react/components/RelationshipField.js +4 -4
- package/dist/runtime/views/react/components/RelationshipField.js.map +1 -1
- package/dist/runtime/views/react/components/RuntimeView.d.ts.map +1 -1
- package/dist/runtime/views/react/components/RuntimeView.js +93 -33
- package/dist/runtime/views/react/components/RuntimeView.js.map +1 -1
- package/dist/runtime/views/react/components/ViewRouter.d.ts +7 -1
- package/dist/runtime/views/react/components/ViewRouter.d.ts.map +1 -1
- package/dist/runtime/views/react/components/ViewRouter.js +57 -18
- package/dist/runtime/views/react/components/ViewRouter.js.map +1 -1
- package/dist/runtime/views/react/hooks/useEventStream.d.ts +29 -0
- package/dist/runtime/views/react/hooks/useEventStream.d.ts.map +1 -0
- package/dist/runtime/views/react/hooks/useEventStream.js +114 -0
- package/dist/runtime/views/react/hooks/useEventStream.js.map +1 -0
- package/dist/runtime/views/react/hooks/useResizableSidebar.d.ts.map +1 -1
- package/dist/runtime/views/react/hooks/useResizableSidebar.js +2 -0
- package/dist/runtime/views/react/hooks/useResizableSidebar.js.map +1 -1
- package/dist/runtime/views/react/index.d.ts +10 -1
- package/dist/runtime/views/react/index.d.ts.map +1 -1
- package/dist/runtime/views/react/index.js +11 -1
- package/dist/runtime/views/react/index.js.map +1 -1
- package/dist/runtime/views/react/react-pattern-adapter.d.ts +235 -0
- package/dist/runtime/views/react/react-pattern-adapter.d.ts.map +1 -0
- package/dist/runtime/views/react/react-pattern-adapter.js +1450 -0
- package/dist/runtime/views/react/react-pattern-adapter.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Pattern Adapter
|
|
3
|
+
*
|
|
4
|
+
* Maps tech-independent composite view patterns from @specverse/lang
|
|
5
|
+
* to React-specific implementations with hooks and Tailwind styling.
|
|
6
|
+
*
|
|
7
|
+
* This adapter implements the unified view architecture by:
|
|
8
|
+
* 1. Using COMPOSITE_VIEW_PATTERNS as the single source of truth
|
|
9
|
+
* 2. Mapping semantic CURVED operations to React hooks/API calls
|
|
10
|
+
* 3. Rendering using ATOMIC_COMPONENTS_REGISTRY via Tailwind
|
|
11
|
+
*
|
|
12
|
+
* Stage 2: React Adapter Refactor
|
|
13
|
+
*/
|
|
14
|
+
import { useMemo } from 'react';
|
|
15
|
+
import { COMPOSITE_VIEW_PATTERNS, ATOMIC_COMPONENTS_REGISTRY, } from '../core/index.js';
|
|
16
|
+
import { createUniversalTailwindAdapter } from '../tailwind/universal-adapter.js';
|
|
17
|
+
import { getEntityDisplayName } from '../core/entity-display.js';
|
|
18
|
+
/**
|
|
19
|
+
* React-specific protocol mapping for CURVED operations
|
|
20
|
+
*
|
|
21
|
+
* Maps semantic operations to HTTP methods and endpoints.
|
|
22
|
+
* This is what lives in instance factories for code generation,
|
|
23
|
+
* but for runtime we need it here.
|
|
24
|
+
*/
|
|
25
|
+
export const REACT_PROTOCOL_MAPPING = {
|
|
26
|
+
create: {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
pathPattern: '/api/{resource}'
|
|
29
|
+
},
|
|
30
|
+
update: {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
pathPattern: '/api/{resource}/{id}'
|
|
33
|
+
},
|
|
34
|
+
retrieve: {
|
|
35
|
+
method: 'GET',
|
|
36
|
+
pathPattern: '/api/{resource}/{id}'
|
|
37
|
+
},
|
|
38
|
+
retrieve_many: {
|
|
39
|
+
method: 'GET',
|
|
40
|
+
pathPattern: '/api/{resource}'
|
|
41
|
+
},
|
|
42
|
+
validate: {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
pathPattern: '/api/{resource}/validate'
|
|
45
|
+
},
|
|
46
|
+
evolve: {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
pathPattern: '/api/{resource}/{id}/evolve'
|
|
49
|
+
},
|
|
50
|
+
delete: {
|
|
51
|
+
method: 'DELETE',
|
|
52
|
+
pathPattern: '/api/{resource}/{id}'
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* React Pattern Adapter
|
|
57
|
+
*
|
|
58
|
+
* Provides React-specific rendering of tech-independent composite patterns.
|
|
59
|
+
*/
|
|
60
|
+
export class ReactPatternAdapter {
|
|
61
|
+
tailwindAdapter;
|
|
62
|
+
constructor(config = {}) {
|
|
63
|
+
this.tailwindAdapter = config.tailwindAdapter || createUniversalTailwindAdapter();
|
|
64
|
+
// Store for future use
|
|
65
|
+
// this._config = config;
|
|
66
|
+
// this._protocolMapping = { ...REACT_PROTOCOL_MAPPING, ...config.protocolMapping };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Detect pattern type from view spec
|
|
70
|
+
*/
|
|
71
|
+
detectPattern(viewSpec) {
|
|
72
|
+
const viewType = viewSpec.type?.toLowerCase();
|
|
73
|
+
// Map view type to pattern ID
|
|
74
|
+
const typeToPattern = {
|
|
75
|
+
'form': 'form-view',
|
|
76
|
+
'list': 'list-view',
|
|
77
|
+
'detail': 'detail-view',
|
|
78
|
+
'dashboard': 'dashboard-view'
|
|
79
|
+
};
|
|
80
|
+
const patternId = typeToPattern[viewType];
|
|
81
|
+
if (!patternId)
|
|
82
|
+
return null;
|
|
83
|
+
return COMPOSITE_VIEW_PATTERNS[patternId];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Render a pattern to HTML
|
|
87
|
+
*/
|
|
88
|
+
renderPattern(context, options) {
|
|
89
|
+
const { pattern } = context;
|
|
90
|
+
// Store navigation option for use in child methods
|
|
91
|
+
this.navigationOptions = options;
|
|
92
|
+
// Render based on pattern category
|
|
93
|
+
switch (pattern.category) {
|
|
94
|
+
case 'data-entry':
|
|
95
|
+
return this.renderFormView(context);
|
|
96
|
+
case 'data-display':
|
|
97
|
+
if (pattern.id === 'list-view') {
|
|
98
|
+
return this.renderListView(context);
|
|
99
|
+
}
|
|
100
|
+
else if (pattern.id === 'detail-view') {
|
|
101
|
+
return this.renderDetailView(context);
|
|
102
|
+
}
|
|
103
|
+
return this.renderGenericDataDisplay(context);
|
|
104
|
+
case 'dashboard':
|
|
105
|
+
return this.renderDashboardView(context);
|
|
106
|
+
default:
|
|
107
|
+
return this.renderFallback(context);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Render FormView pattern
|
|
112
|
+
*/
|
|
113
|
+
renderFormView(context) {
|
|
114
|
+
const { viewSpec, modelData, modelSchemas, primaryModel } = context;
|
|
115
|
+
let components = viewSpec.uiComponents || {};
|
|
116
|
+
// FALLBACK: Generate default form component if none defined
|
|
117
|
+
if (Object.keys(components).length === 0 && primaryModel) {
|
|
118
|
+
components = {
|
|
119
|
+
[`${primaryModel}Form`]: {
|
|
120
|
+
type: 'form',
|
|
121
|
+
properties: {
|
|
122
|
+
model: primaryModel,
|
|
123
|
+
sections: [
|
|
124
|
+
{
|
|
125
|
+
title: `${primaryModel} Details`,
|
|
126
|
+
fields: this.inferFieldsFromSchema(modelSchemas, primaryModel)
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
let html = '<div class="space-y-4">';
|
|
134
|
+
// Render form components
|
|
135
|
+
for (const [componentName, componentDef] of Object.entries(components)) {
|
|
136
|
+
const def = componentDef;
|
|
137
|
+
const type = def.type?.toLowerCase();
|
|
138
|
+
const properties = def.properties || def;
|
|
139
|
+
if (type === 'form') {
|
|
140
|
+
html += this.renderFormComponent(componentName, def, modelData, modelSchemas, primaryModel, this.tailwindAdapter);
|
|
141
|
+
}
|
|
142
|
+
else if (this.tailwindAdapter.components[type]) {
|
|
143
|
+
html += this.renderAtomicComponent(componentName, type, properties, this.tailwindAdapter);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
html += '</div>';
|
|
147
|
+
return html;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Render ListView pattern
|
|
151
|
+
*/
|
|
152
|
+
renderListView(context) {
|
|
153
|
+
const { viewSpec, modelData, primaryModel, modelSchemas } = context;
|
|
154
|
+
let components = viewSpec.uiComponents || {};
|
|
155
|
+
// FALLBACK: Generate default table component if none defined
|
|
156
|
+
if (Object.keys(components).length === 0 && primaryModel) {
|
|
157
|
+
// Prefer schema-based inference (includes relationships) over data-based
|
|
158
|
+
const columns = modelSchemas && modelSchemas[primaryModel]
|
|
159
|
+
? this.inferFieldsFromSchema(modelSchemas, primaryModel)
|
|
160
|
+
: this.inferFieldsFromModel(modelData, primaryModel);
|
|
161
|
+
components = {
|
|
162
|
+
[`${primaryModel}Table`]: {
|
|
163
|
+
type: 'table',
|
|
164
|
+
properties: {
|
|
165
|
+
model: primaryModel,
|
|
166
|
+
columns: columns
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
let html = '<div class="space-y-4">';
|
|
172
|
+
// Render list components (filters, table/list, pagination)
|
|
173
|
+
for (const [componentName, componentDef] of Object.entries(components)) {
|
|
174
|
+
const def = componentDef;
|
|
175
|
+
const type = def.type?.toLowerCase();
|
|
176
|
+
const properties = def.properties || def;
|
|
177
|
+
if (type === 'table') {
|
|
178
|
+
html += this.renderTableComponent(componentName, def, modelData, primaryModel, context.modelSchemas, this.tailwindAdapter);
|
|
179
|
+
}
|
|
180
|
+
else if (type === 'list') {
|
|
181
|
+
html += this.renderListComponent(componentName, def, modelData, primaryModel, context.modelSchemas, this.tailwindAdapter);
|
|
182
|
+
}
|
|
183
|
+
else if (this.tailwindAdapter.components[type]) {
|
|
184
|
+
html += this.renderAtomicComponent(componentName, type, properties, this.tailwindAdapter);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
html += '</div>';
|
|
188
|
+
return html;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Render DetailView pattern
|
|
192
|
+
*/
|
|
193
|
+
renderDetailView(context) {
|
|
194
|
+
const { viewSpec, selectedEntity, primaryModel, modelData, modelSchemas } = context;
|
|
195
|
+
let components = viewSpec.uiComponents || {};
|
|
196
|
+
if (!selectedEntity) {
|
|
197
|
+
return '<div class="p-4 text-gray-500 dark:text-gray-400">No entity selected</div>';
|
|
198
|
+
}
|
|
199
|
+
// FALLBACK: Generate default components if none defined
|
|
200
|
+
if (Object.keys(components).length === 0 && primaryModel) {
|
|
201
|
+
// Get fields from schema or fall back to data inference
|
|
202
|
+
const fields = this.inferFieldsFromSchema(modelSchemas, primaryModel);
|
|
203
|
+
// Start with content component
|
|
204
|
+
components = {
|
|
205
|
+
[`${primaryModel}Content`]: {
|
|
206
|
+
type: 'content',
|
|
207
|
+
fields: fields,
|
|
208
|
+
properties: {
|
|
209
|
+
model: primaryModel
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
// Add list components for hasMany relationships (from schema)
|
|
214
|
+
if (modelSchemas && modelSchemas[primaryModel]?.relationships) {
|
|
215
|
+
const schemaRelationships = modelSchemas[primaryModel].relationships;
|
|
216
|
+
for (const [relName, relDef] of Object.entries(schemaRelationships)) {
|
|
217
|
+
const relDefObj = relDef;
|
|
218
|
+
// Only include hasMany relationships (these show as lists in detail view)
|
|
219
|
+
if (relDefObj.type === 'hasMany') {
|
|
220
|
+
const targetModel = relDefObj.targetModel || relDefObj.model || relName.charAt(0).toUpperCase() + relName.slice(1);
|
|
221
|
+
// Infer fields for the related model
|
|
222
|
+
const relatedFields = this.inferFieldsFromSchema(modelSchemas, targetModel);
|
|
223
|
+
components[`${relName}List`] = {
|
|
224
|
+
type: 'list',
|
|
225
|
+
fields: relatedFields.slice(0, 5), // Limit to first 5 fields for table
|
|
226
|
+
properties: {
|
|
227
|
+
model: targetModel,
|
|
228
|
+
relationship: relName
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Separate content and list components
|
|
236
|
+
const contentComponents = [];
|
|
237
|
+
const listComponents = [];
|
|
238
|
+
const otherComponents = [];
|
|
239
|
+
for (const [componentName, componentDef] of Object.entries(components)) {
|
|
240
|
+
const def = componentDef;
|
|
241
|
+
const type = def.type?.toLowerCase();
|
|
242
|
+
if (type === 'content') {
|
|
243
|
+
contentComponents.push([componentName, def]);
|
|
244
|
+
}
|
|
245
|
+
else if (type === 'list') {
|
|
246
|
+
listComponents.push([componentName, def]);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
otherComponents.push([componentName, def]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
let html = '<div class="space-y-4">';
|
|
253
|
+
// Render content components first
|
|
254
|
+
for (const [componentName, def] of contentComponents) {
|
|
255
|
+
html += this.renderContentComponent(componentName, def, selectedEntity, modelData, modelSchemas, primaryModel, this.tailwindAdapter);
|
|
256
|
+
}
|
|
257
|
+
// Render other components
|
|
258
|
+
for (const [componentName, def] of otherComponents) {
|
|
259
|
+
const type = def.type?.toLowerCase();
|
|
260
|
+
const properties = def.properties || def;
|
|
261
|
+
if (type === 'card') {
|
|
262
|
+
html += this.renderCardComponent(componentName, def, selectedEntity, this.tailwindAdapter);
|
|
263
|
+
}
|
|
264
|
+
else if (this.tailwindAdapter.components[type]) {
|
|
265
|
+
html += this.renderAtomicComponent(componentName, type, properties, this.tailwindAdapter);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Render list components in tabs if there are multiple, otherwise render normally
|
|
269
|
+
if (listComponents.length === 0) {
|
|
270
|
+
// No lists, nothing to render
|
|
271
|
+
}
|
|
272
|
+
else if (listComponents.length === 1) {
|
|
273
|
+
// Single list, render normally
|
|
274
|
+
const [componentName, def] = listComponents[0];
|
|
275
|
+
html += this.renderDetailListComponent(componentName, def, modelData, selectedEntity, primaryModel, this.tailwindAdapter, modelSchemas);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
// Multiple lists, render in tabs
|
|
279
|
+
html += this.renderTabbedLists(listComponents, modelData, selectedEntity, primaryModel, modelSchemas, this.tailwindAdapter);
|
|
280
|
+
}
|
|
281
|
+
html += '</div>';
|
|
282
|
+
return html;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Render multiple list components in a tabbed interface
|
|
286
|
+
*/
|
|
287
|
+
renderTabbedLists(listComponents, modelData, selectedEntity, primaryModel, modelSchemas, tailwindAdapter) {
|
|
288
|
+
if (listComponents.length === 0)
|
|
289
|
+
return '';
|
|
290
|
+
// Generate unique ID for this tab group
|
|
291
|
+
const tabGroupId = `tabs-${Math.random().toString(36).substr(2, 9)}`;
|
|
292
|
+
// Extract tab labels from component names (e.g., "userinstrumentList" -> "User Instruments")
|
|
293
|
+
const tabs = listComponents.map(([componentName, _def], index) => {
|
|
294
|
+
// Remove "List" suffix and convert camelCase to Title Case
|
|
295
|
+
const label = componentName
|
|
296
|
+
.replace(/List$/, '')
|
|
297
|
+
.replace(/([A-Z])/g, ' $1')
|
|
298
|
+
.trim()
|
|
299
|
+
.split(' ')
|
|
300
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
301
|
+
.join(' ');
|
|
302
|
+
return {
|
|
303
|
+
id: `${tabGroupId}-${index}`,
|
|
304
|
+
label,
|
|
305
|
+
index
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
let html = `
|
|
309
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
310
|
+
<!-- Tab Buttons -->
|
|
311
|
+
<div class="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900" data-tab-group="${tabGroupId}">
|
|
312
|
+
`;
|
|
313
|
+
tabs.forEach((tab, index) => {
|
|
314
|
+
const isActive = index === 0;
|
|
315
|
+
html += `
|
|
316
|
+
<button
|
|
317
|
+
class="px-4 py-3 text-sm font-medium transition-colors focus:outline-none ${isActive
|
|
318
|
+
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
|
319
|
+
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
|
320
|
+
data-tab-button="${tab.id}"
|
|
321
|
+
data-tab-index="${index}"
|
|
322
|
+
>
|
|
323
|
+
${tab.label}
|
|
324
|
+
</button>
|
|
325
|
+
`;
|
|
326
|
+
});
|
|
327
|
+
html += `
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<!-- Tab Panels -->
|
|
331
|
+
<div>
|
|
332
|
+
`;
|
|
333
|
+
listComponents.forEach(([componentName, def], index) => {
|
|
334
|
+
const isActive = index === 0;
|
|
335
|
+
html += `
|
|
336
|
+
<div
|
|
337
|
+
class="tab-panel ${isActive ? '' : 'hidden'}"
|
|
338
|
+
data-tab-panel="${tabs[index].id}"
|
|
339
|
+
>
|
|
340
|
+
`;
|
|
341
|
+
html += this.renderDetailListComponent(componentName, def, modelData, selectedEntity, primaryModel, tailwindAdapter, modelSchemas, true);
|
|
342
|
+
html += `
|
|
343
|
+
</div>
|
|
344
|
+
`;
|
|
345
|
+
});
|
|
346
|
+
html += `
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
`;
|
|
350
|
+
return html;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Render DashboardView pattern
|
|
354
|
+
*/
|
|
355
|
+
renderDashboardView(context) {
|
|
356
|
+
const { viewSpec, modelData } = context;
|
|
357
|
+
const components = viewSpec.uiComponents || {};
|
|
358
|
+
let html = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">';
|
|
359
|
+
// Render dashboard components (metrics, charts, etc.)
|
|
360
|
+
for (const [componentName, componentDef] of Object.entries(components)) {
|
|
361
|
+
const def = componentDef;
|
|
362
|
+
const type = def.type?.toLowerCase();
|
|
363
|
+
const properties = def.properties || def;
|
|
364
|
+
if (type === 'card' && properties.variant === 'metric') {
|
|
365
|
+
html += this.renderMetricCard(componentName, def, modelData, this.tailwindAdapter);
|
|
366
|
+
}
|
|
367
|
+
else if (this.tailwindAdapter.components[type]) {
|
|
368
|
+
html += this.renderAtomicComponent(componentName, type, properties, this.tailwindAdapter);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
html += '</div>';
|
|
372
|
+
return html;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Render generic data display (fallback)
|
|
376
|
+
*/
|
|
377
|
+
renderGenericDataDisplay(_context) {
|
|
378
|
+
return '<div class="p-4 text-gray-500 dark:text-gray-400">Generic data display</div>';
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Render fallback for unknown patterns
|
|
382
|
+
*/
|
|
383
|
+
renderFallback(context) {
|
|
384
|
+
const { pattern } = context;
|
|
385
|
+
return `<div class="p-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 rounded">
|
|
386
|
+
<p class="text-yellow-800 dark:text-yellow-200">Pattern not yet implemented: ${pattern.name}</p>
|
|
387
|
+
</div>`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Helper: Render form component
|
|
391
|
+
*
|
|
392
|
+
* Generates a complete form with:
|
|
393
|
+
* - Inferred field types from model data (text, number, boolean)
|
|
394
|
+
* - Proper input types and validation
|
|
395
|
+
* - Required field indicators
|
|
396
|
+
* - Submit and reset buttons
|
|
397
|
+
* - Professional styling matching admin-demo
|
|
398
|
+
*/
|
|
399
|
+
renderFormComponent(componentName, componentDef, modelData, modelSchemas, primaryModel, _tailwindAdapter) {
|
|
400
|
+
const properties = componentDef.properties || componentDef;
|
|
401
|
+
const sections = properties.sections || [];
|
|
402
|
+
const model = properties.model || primaryModel;
|
|
403
|
+
// Infer field types from model schema or data
|
|
404
|
+
const fieldTypes = this.inferFieldTypes(modelSchemas, modelData, model);
|
|
405
|
+
let html = `
|
|
406
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
407
|
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
408
|
+
<h4 class="font-semibold text-sm text-gray-700 dark:text-gray-200">${componentName}</h4>
|
|
409
|
+
</div>
|
|
410
|
+
<div class="p-6">
|
|
411
|
+
<form class="space-y-6">
|
|
412
|
+
`;
|
|
413
|
+
// Render form sections
|
|
414
|
+
for (const section of sections) {
|
|
415
|
+
const fields = section.fields || [];
|
|
416
|
+
html += `
|
|
417
|
+
<div>
|
|
418
|
+
${section.title ? `<h5 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">${section.title}</h5>` : ''}
|
|
419
|
+
<div class="space-y-4">
|
|
420
|
+
`;
|
|
421
|
+
// Show helpful message if no fields
|
|
422
|
+
if (fields.length === 0) {
|
|
423
|
+
html += `
|
|
424
|
+
<div class="text-sm text-gray-500 dark:text-gray-400 italic p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded">
|
|
425
|
+
No fields available for this form. Model data may be empty or all fields are system-generated.
|
|
426
|
+
</div>
|
|
427
|
+
`;
|
|
428
|
+
}
|
|
429
|
+
for (const field of fields) {
|
|
430
|
+
const fieldName = typeof field === 'string' ? field : field.name || field.fieldName;
|
|
431
|
+
const fieldLabel = typeof field === 'string'
|
|
432
|
+
? this.humanizeFieldName(fieldName)
|
|
433
|
+
: field.label || this.humanizeFieldName(fieldName);
|
|
434
|
+
const fieldType = fieldTypes[fieldName] || 'string';
|
|
435
|
+
const isRequired = field.required !== false; // Default to required
|
|
436
|
+
html += `<div>`;
|
|
437
|
+
html += `
|
|
438
|
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
|
439
|
+
${fieldLabel}
|
|
440
|
+
${isRequired ? '<span class="text-red-500 dark:text-red-400 ml-1">*</span>' : ''}
|
|
441
|
+
</label>
|
|
442
|
+
`;
|
|
443
|
+
// Generate appropriate input based on field type
|
|
444
|
+
if (fieldType === 'boolean') {
|
|
445
|
+
html += `
|
|
446
|
+
<div class="flex items-center">
|
|
447
|
+
<input
|
|
448
|
+
type="checkbox"
|
|
449
|
+
name="${fieldName}"
|
|
450
|
+
class="h-4 w-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
|
|
451
|
+
/>
|
|
452
|
+
<span class="ml-2 text-sm text-gray-600 dark:text-gray-300">Yes/No</span>
|
|
453
|
+
</div>
|
|
454
|
+
`;
|
|
455
|
+
}
|
|
456
|
+
else if (fieldType === 'number') {
|
|
457
|
+
html += `
|
|
458
|
+
<input
|
|
459
|
+
type="number"
|
|
460
|
+
name="${fieldName}"
|
|
461
|
+
placeholder="Enter ${fieldLabel.toLowerCase()}"
|
|
462
|
+
${isRequired ? 'required' : ''}
|
|
463
|
+
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
464
|
+
/>
|
|
465
|
+
`;
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
// Text input (default)
|
|
469
|
+
html += `
|
|
470
|
+
<input
|
|
471
|
+
type="text"
|
|
472
|
+
name="${fieldName}"
|
|
473
|
+
placeholder="Enter ${fieldLabel.toLowerCase()}"
|
|
474
|
+
${isRequired ? 'required' : ''}
|
|
475
|
+
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
476
|
+
/>
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
html += `</div>`;
|
|
480
|
+
}
|
|
481
|
+
html += `
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
`;
|
|
485
|
+
}
|
|
486
|
+
// Add relationship fields (belongsTo) as select dropdowns
|
|
487
|
+
const relationshipFields = this.inferRelationshipFields(modelSchemas, modelData, model);
|
|
488
|
+
if (relationshipFields.length > 0) {
|
|
489
|
+
html += `
|
|
490
|
+
<div>
|
|
491
|
+
<h5 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Relationships</h5>
|
|
492
|
+
<div class="space-y-4">
|
|
493
|
+
`;
|
|
494
|
+
for (const relField of relationshipFields) {
|
|
495
|
+
const relatedEntities = modelData[relField.targetModel] || [];
|
|
496
|
+
html += `
|
|
497
|
+
<div>
|
|
498
|
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
|
499
|
+
${this.humanizeFieldName(relField.name)}
|
|
500
|
+
</label>
|
|
501
|
+
<select
|
|
502
|
+
name="${relField.foreignKey}"
|
|
503
|
+
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
504
|
+
>
|
|
505
|
+
<option value="">-- Select ${relField.targetModel} --</option>
|
|
506
|
+
${relatedEntities.map((entity) => {
|
|
507
|
+
const displayName = entity.data?.name || entity.data?.title || entity.id;
|
|
508
|
+
return `<option value="${entity.id}">${displayName}</option>`;
|
|
509
|
+
}).join('')}
|
|
510
|
+
</select>
|
|
511
|
+
</div>
|
|
512
|
+
`;
|
|
513
|
+
}
|
|
514
|
+
html += `
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
`;
|
|
518
|
+
}
|
|
519
|
+
// Add form action buttons
|
|
520
|
+
html += `
|
|
521
|
+
<div class="flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-600">
|
|
522
|
+
<button
|
|
523
|
+
type="submit"
|
|
524
|
+
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded font-medium transition-colors"
|
|
525
|
+
>
|
|
526
|
+
Create ${model || 'Entity'}
|
|
527
|
+
</button>
|
|
528
|
+
<button
|
|
529
|
+
type="reset"
|
|
530
|
+
class="px-6 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-800 dark:text-gray-100 rounded font-medium transition-colors"
|
|
531
|
+
>
|
|
532
|
+
Reset
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
</form>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
`;
|
|
539
|
+
return html;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Helper: Infer relationship fields from model schema or data
|
|
543
|
+
*
|
|
544
|
+
* Reads relationship definitions from the model schema (like original FormView).
|
|
545
|
+
* Falls back to inferring from foreign keys in data if no schema available.
|
|
546
|
+
*
|
|
547
|
+
* Strategy:
|
|
548
|
+
* 1. Read from schema.relationships (preferred - matches original FormView)
|
|
549
|
+
* 2. If entities exist, scan their fields for foreign keys
|
|
550
|
+
* 3. If no entities, look at available models and infer common relationships
|
|
551
|
+
*/
|
|
552
|
+
inferRelationshipFields(modelSchemas, modelData, modelName) {
|
|
553
|
+
const relationships = [];
|
|
554
|
+
if (!modelName)
|
|
555
|
+
return relationships;
|
|
556
|
+
// Strategy 1: Read from schema.relationships (like original FormView)
|
|
557
|
+
if (modelSchemas && modelSchemas[modelName]?.relationships) {
|
|
558
|
+
const schemaRelationships = modelSchemas[modelName].relationships;
|
|
559
|
+
// Extract belongsTo relationships (these need form dropdowns)
|
|
560
|
+
for (const [relName, relDef] of Object.entries(schemaRelationships)) {
|
|
561
|
+
const relDefObj = relDef;
|
|
562
|
+
// Only include belongsTo relationships in forms
|
|
563
|
+
if (relDefObj.type === 'belongsTo') {
|
|
564
|
+
relationships.push({
|
|
565
|
+
name: relName,
|
|
566
|
+
foreignKey: relDefObj.foreignKey || `${relName}Id`,
|
|
567
|
+
targetModel: relDefObj.targetModel || relDefObj.model || relName.charAt(0).toUpperCase() + relName.slice(1)
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return relationships;
|
|
572
|
+
}
|
|
573
|
+
// Strategy 2: If we have entities, scan for foreign keys
|
|
574
|
+
const entities = modelData[modelName] || [];
|
|
575
|
+
if (entities.length > 0 && entities[0]?.data) {
|
|
576
|
+
const firstEntity = entities[0];
|
|
577
|
+
// Look for foreign key fields (ending in "Id")
|
|
578
|
+
for (const fieldName of Object.keys(firstEntity.data)) {
|
|
579
|
+
if (fieldName.endsWith('Id') && fieldName !== 'id') {
|
|
580
|
+
// Extract relationship name (e.g., "authorId" -> "author")
|
|
581
|
+
const relName = fieldName.slice(0, -2);
|
|
582
|
+
// Capitalize to get model name (e.g., "author" -> "Author")
|
|
583
|
+
const targetModel = relName.charAt(0).toUpperCase() + relName.slice(1);
|
|
584
|
+
// Check if this model exists in modelData
|
|
585
|
+
if (modelData[targetModel]) {
|
|
586
|
+
relationships.push({
|
|
587
|
+
name: relName,
|
|
588
|
+
foreignKey: fieldName,
|
|
589
|
+
targetModel: targetModel
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// Strategy 3: If no entities or no relationships found, infer from available models
|
|
596
|
+
// This ensures forms are usable even when creating the first entity
|
|
597
|
+
if (relationships.length === 0) {
|
|
598
|
+
const availableModels = Object.keys(modelData);
|
|
599
|
+
// Common relationship patterns based on model names
|
|
600
|
+
for (const targetModel of availableModels) {
|
|
601
|
+
if (targetModel !== modelName) {
|
|
602
|
+
// Create lowercase version for foreign key
|
|
603
|
+
const relName = targetModel.charAt(0).toLowerCase() + targetModel.slice(1);
|
|
604
|
+
const foreignKey = `${relName}Id`;
|
|
605
|
+
// Add as potential relationship
|
|
606
|
+
relationships.push({
|
|
607
|
+
name: relName,
|
|
608
|
+
foreignKey: foreignKey,
|
|
609
|
+
targetModel: targetModel
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return relationships;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Helper: Humanize field name
|
|
618
|
+
* Converts camelCase/snake_case to readable labels
|
|
619
|
+
*/
|
|
620
|
+
humanizeFieldName(fieldName) {
|
|
621
|
+
return fieldName
|
|
622
|
+
.replace(/([A-Z])/g, ' $1') // Add space before capitals
|
|
623
|
+
.replace(/_/g, ' ') // Replace underscores with spaces
|
|
624
|
+
.replace(/^./, (str) => str.toUpperCase()) // Capitalize first letter
|
|
625
|
+
.trim();
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Helper: Infer field types from model schema or data
|
|
629
|
+
* Prefers schema definitions, falls back to examining actual data
|
|
630
|
+
*/
|
|
631
|
+
inferFieldTypes(modelSchemas, modelData, modelName) {
|
|
632
|
+
const types = {};
|
|
633
|
+
if (!modelName)
|
|
634
|
+
return types;
|
|
635
|
+
// Strategy 1: Use schema if available
|
|
636
|
+
if (modelSchemas && modelSchemas[modelName]?.attributes) {
|
|
637
|
+
const attributes = modelSchemas[modelName].attributes;
|
|
638
|
+
for (const [fieldName, attrDef] of Object.entries(attributes)) {
|
|
639
|
+
const typeStr = typeof attrDef === 'string' ? attrDef : attrDef?.type || 'string';
|
|
640
|
+
if (typeStr.toLowerCase().includes('bool')) {
|
|
641
|
+
types[fieldName] = 'boolean';
|
|
642
|
+
}
|
|
643
|
+
else if (typeStr.toLowerCase().includes('int') || typeStr.toLowerCase().includes('number') || typeStr.toLowerCase().includes('float')) {
|
|
644
|
+
types[fieldName] = 'number';
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
types[fieldName] = 'string';
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return types;
|
|
651
|
+
}
|
|
652
|
+
// Strategy 2: Examine actual data if no schema
|
|
653
|
+
if (!modelData[modelName] || modelData[modelName].length === 0)
|
|
654
|
+
return types;
|
|
655
|
+
const firstEntity = modelData[modelName][0];
|
|
656
|
+
if (!firstEntity || !firstEntity.data)
|
|
657
|
+
return types;
|
|
658
|
+
for (const [fieldName, value] of Object.entries(firstEntity.data)) {
|
|
659
|
+
if (typeof value === 'boolean') {
|
|
660
|
+
types[fieldName] = 'boolean';
|
|
661
|
+
}
|
|
662
|
+
else if (typeof value === 'number') {
|
|
663
|
+
types[fieldName] = 'number';
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
types[fieldName] = 'string';
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return types;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Helper: Infer field names from model schema
|
|
673
|
+
* Reads schema.attributes to get field definitions
|
|
674
|
+
* Includes belongsTo relationships for display
|
|
675
|
+
*/
|
|
676
|
+
inferFieldsFromSchema(modelSchemas, modelName) {
|
|
677
|
+
if (!modelName || !modelSchemas || !modelSchemas[modelName]) {
|
|
678
|
+
// No schema - return common default fields
|
|
679
|
+
return ['name', 'title', 'description'];
|
|
680
|
+
}
|
|
681
|
+
const schema = modelSchemas[modelName];
|
|
682
|
+
if (!schema.attributes) {
|
|
683
|
+
return ['name', 'title', 'description'];
|
|
684
|
+
}
|
|
685
|
+
// Get field names from schema attributes, filter out system/metadata fields and foreign keys
|
|
686
|
+
const fields = Object.keys(schema.attributes).filter(field => {
|
|
687
|
+
// Filter out system fields
|
|
688
|
+
if (field === 'id')
|
|
689
|
+
return false;
|
|
690
|
+
// Filter out timestamp/metadata fields
|
|
691
|
+
if (['createdAt', 'updatedAt', 'deletedAt', 'appliedAt', 'publishedAt'].includes(field))
|
|
692
|
+
return false;
|
|
693
|
+
// Filter out foreign key fields (we'll add relationships instead)
|
|
694
|
+
if (field.endsWith('Id'))
|
|
695
|
+
return false;
|
|
696
|
+
// Filter out auto-generated fields
|
|
697
|
+
if (this.isAutoGeneratedField(field, schema.attributes[field]))
|
|
698
|
+
return false;
|
|
699
|
+
return true;
|
|
700
|
+
});
|
|
701
|
+
// Add belongsTo relationships (these will show as "Author" instead of "authorId")
|
|
702
|
+
if (schema.relationships) {
|
|
703
|
+
const belongsToRels = Object.entries(schema.relationships)
|
|
704
|
+
.filter(([_, relDef]) => relDef.type === 'belongsTo')
|
|
705
|
+
.map(([relName, _]) => relName);
|
|
706
|
+
// Add relationships at the end of the field list
|
|
707
|
+
fields.push(...belongsToRels);
|
|
708
|
+
}
|
|
709
|
+
return fields.length > 0 ? fields : ['name', 'title', 'description'];
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Helper: Check if field is auto-generated (shouldn't show in forms)
|
|
713
|
+
*/
|
|
714
|
+
isAutoGeneratedField(fieldName, attrDef) {
|
|
715
|
+
// System fields
|
|
716
|
+
if (['id', 'createdAt', 'updatedAt'].includes(fieldName))
|
|
717
|
+
return true;
|
|
718
|
+
// Check attribute definition for auto flag
|
|
719
|
+
if (typeof attrDef === 'object' && attrDef.auto)
|
|
720
|
+
return true;
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Helper: Get smart display name for an entity
|
|
725
|
+
*
|
|
726
|
+
* Wrapper around shared getEntityDisplayName utility.
|
|
727
|
+
* Maintained for backward compatibility with excludeRelationship parameter.
|
|
728
|
+
*/
|
|
729
|
+
getSmartDisplayName(entity, _excludeRelationship, _modelSchemas, modelData) {
|
|
730
|
+
// Use shared utility function (modelData is allEntities)
|
|
731
|
+
return getEntityDisplayName(entity, modelData || {});
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Helper: Resolve relationship value for display
|
|
735
|
+
*
|
|
736
|
+
* If fieldName is a belongsTo relationship, looks up the related entity
|
|
737
|
+
* and returns its display name. Otherwise returns the raw value.
|
|
738
|
+
*/
|
|
739
|
+
resolveFieldValue(fieldName, entity, modelSchemas, modelData, currentModel) {
|
|
740
|
+
// First check if this is a direct data field
|
|
741
|
+
if (entity.data && entity.data.hasOwnProperty(fieldName)) {
|
|
742
|
+
return entity.data[fieldName];
|
|
743
|
+
}
|
|
744
|
+
// Check if this is a relationship field
|
|
745
|
+
if (!currentModel || !modelSchemas || !modelSchemas[currentModel]?.relationships) {
|
|
746
|
+
return undefined;
|
|
747
|
+
}
|
|
748
|
+
const relationships = modelSchemas[currentModel].relationships;
|
|
749
|
+
const relationshipDef = relationships[fieldName];
|
|
750
|
+
if (!relationshipDef || relationshipDef.type !== 'belongsTo') {
|
|
751
|
+
return undefined;
|
|
752
|
+
}
|
|
753
|
+
// Get the foreign key value (e.g., authorId)
|
|
754
|
+
const foreignKey = relationshipDef.foreignKey || `${fieldName}Id`;
|
|
755
|
+
const foreignKeyValue = entity.data?.[foreignKey];
|
|
756
|
+
if (!foreignKeyValue) {
|
|
757
|
+
return null; // No relationship set
|
|
758
|
+
}
|
|
759
|
+
// Get the target model (e.g., "Author")
|
|
760
|
+
const targetModel = relationshipDef.targetModel || relationshipDef.model ||
|
|
761
|
+
fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
|
|
762
|
+
// Look up the related entity by UUID (not internal ID)
|
|
763
|
+
const relatedEntities = modelData[targetModel] || [];
|
|
764
|
+
const relatedEntity = relatedEntities.find((e) => e.data?.id === foreignKeyValue || e.id === foreignKeyValue);
|
|
765
|
+
if (!relatedEntity) {
|
|
766
|
+
return foreignKeyValue; // Return ID if entity not found
|
|
767
|
+
}
|
|
768
|
+
// Return a smart display name for the related entity
|
|
769
|
+
// Don't exclude any relationships - we want to show the full entity (e.g., "Guitar (intermediate)" for requirements)
|
|
770
|
+
return this.getSmartDisplayName(relatedEntity, undefined, modelSchemas, modelData);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Helper: Get navigation info for a relationship field
|
|
774
|
+
*
|
|
775
|
+
* Returns navigation attributes if the field is a belongsTo relationship,
|
|
776
|
+
* null otherwise. Used to make relationship values clickable.
|
|
777
|
+
*/
|
|
778
|
+
getRelationshipNavInfo(fieldName, entity, modelSchemas, currentModel) {
|
|
779
|
+
// Check if this is a relationship field
|
|
780
|
+
if (!currentModel || !modelSchemas || !modelSchemas[currentModel]?.relationships) {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
const relationships = modelSchemas[currentModel].relationships;
|
|
784
|
+
const relationshipDef = relationships[fieldName];
|
|
785
|
+
if (!relationshipDef || relationshipDef.type !== 'belongsTo') {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
// Get the foreign key value (e.g., authorId)
|
|
789
|
+
const foreignKey = relationshipDef.foreignKey || `${fieldName}Id`;
|
|
790
|
+
const foreignKeyValue = entity.data?.[foreignKey];
|
|
791
|
+
if (!foreignKeyValue) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
// Get the target model (e.g., "Author")
|
|
795
|
+
const targetModel = relationshipDef.targetModel || relationshipDef.model ||
|
|
796
|
+
fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
|
|
797
|
+
return {
|
|
798
|
+
entityId: foreignKeyValue,
|
|
799
|
+
targetModel: targetModel
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Helper: Render table component
|
|
804
|
+
*
|
|
805
|
+
* Generates a data table with:
|
|
806
|
+
* - Column headers with proper formatting
|
|
807
|
+
* - Data type-aware value rendering (objects, booleans, etc.)
|
|
808
|
+
* - Relationship field resolution (shows related entity names)
|
|
809
|
+
* - Empty state handling
|
|
810
|
+
* - Hover effects and professional styling
|
|
811
|
+
* - Responsive scrolling
|
|
812
|
+
*/
|
|
813
|
+
renderTableComponent(componentName, componentDef, modelData, primaryModel, modelSchemas, _tailwindAdapter) {
|
|
814
|
+
const properties = componentDef.properties || componentDef;
|
|
815
|
+
const tableModel = properties.model || primaryModel;
|
|
816
|
+
const entities = modelData[tableModel || ''] || [];
|
|
817
|
+
const columns = properties.columns || [];
|
|
818
|
+
let html = `
|
|
819
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
820
|
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
821
|
+
<h4 class="font-semibold text-sm text-gray-700 dark:text-gray-200">${componentName}</h4>
|
|
822
|
+
</div>
|
|
823
|
+
<div class="overflow-auto max-h-96">
|
|
824
|
+
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
825
|
+
<thead class="bg-gray-50 dark:bg-gray-700 sticky top-0">
|
|
826
|
+
<tr>
|
|
827
|
+
${columns.map((col) => `
|
|
828
|
+
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider">
|
|
829
|
+
${this.humanizeFieldName(col)}
|
|
830
|
+
</th>
|
|
831
|
+
`).join('')}
|
|
832
|
+
</tr>
|
|
833
|
+
</thead>
|
|
834
|
+
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
835
|
+
`;
|
|
836
|
+
if (entities.length === 0) {
|
|
837
|
+
html += `
|
|
838
|
+
<tr>
|
|
839
|
+
<td colspan="${columns.length}" class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400 italic">
|
|
840
|
+
No ${tableModel || 'data'} entities yet
|
|
841
|
+
</td>
|
|
842
|
+
</tr>
|
|
843
|
+
`;
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
// Check if navigation is enabled
|
|
847
|
+
const enableNavigation = this.navigationOptions?.enableListNavigation;
|
|
848
|
+
for (const entity of entities) {
|
|
849
|
+
const navAttrs = enableNavigation
|
|
850
|
+
? `data-nav-entity-id="${entity.data?.id || entity.id}" style="cursor: pointer;" title="Click to view details"`
|
|
851
|
+
: '';
|
|
852
|
+
html += `<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" ${navAttrs}>`;
|
|
853
|
+
for (const col of columns) {
|
|
854
|
+
// Resolve value (handles both direct fields and relationships)
|
|
855
|
+
const value = this.resolveFieldValue(col, entity, modelSchemas, modelData, tableModel);
|
|
856
|
+
// Check if this is a relationship field for navigation
|
|
857
|
+
const navInfo = this.getRelationshipNavInfo(col, entity, modelSchemas, tableModel);
|
|
858
|
+
const enableRelatedNavigation = this.navigationOptions?.enableRelatedNavigation;
|
|
859
|
+
let displayValue;
|
|
860
|
+
// Format value based on type
|
|
861
|
+
if (value === undefined || value === null || value === '') {
|
|
862
|
+
displayValue = '<span class="text-gray-400 dark:text-gray-500">—</span>';
|
|
863
|
+
}
|
|
864
|
+
else if (typeof value === 'boolean') {
|
|
865
|
+
displayValue = `
|
|
866
|
+
<span class="inline-block px-2 py-1 rounded text-xs font-medium ${value
|
|
867
|
+
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
|
868
|
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'}">
|
|
869
|
+
${value ? 'Yes' : 'No'}
|
|
870
|
+
</span>
|
|
871
|
+
`;
|
|
872
|
+
}
|
|
873
|
+
else if (typeof value === 'object') {
|
|
874
|
+
// Format objects — show field summary instead of [Object]
|
|
875
|
+
const keys = Object.keys(value);
|
|
876
|
+
let summary;
|
|
877
|
+
if (Array.isArray(value)) {
|
|
878
|
+
summary = `[${value.length} items]`;
|
|
879
|
+
}
|
|
880
|
+
else if (keys.length === 0) {
|
|
881
|
+
summary = '{}';
|
|
882
|
+
}
|
|
883
|
+
else if (keys.length <= 5) {
|
|
884
|
+
summary = keys.map(k => {
|
|
885
|
+
const v = value[k];
|
|
886
|
+
return `${k}: ${v === null || v === undefined ? '-' : typeof v === 'object' ? '{...}' : String(v)}`;
|
|
887
|
+
}).join(', ');
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
summary = `{${keys.length} fields}`;
|
|
891
|
+
}
|
|
892
|
+
displayValue = `<span class="text-gray-600 dark:text-gray-400 text-xs">${summary.replace(/</g, '<').replace(/>/g, '>')}</span>`;
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
// Escape HTML for safe rendering
|
|
896
|
+
const escapedValue = String(value)
|
|
897
|
+
.replace(/&/g, '&')
|
|
898
|
+
.replace(/</g, '<')
|
|
899
|
+
.replace(/>/g, '>')
|
|
900
|
+
.replace(/"/g, '"');
|
|
901
|
+
// Wrap in navigation span if this is a relationship field
|
|
902
|
+
if (navInfo && enableRelatedNavigation) {
|
|
903
|
+
displayValue = `<span data-nav-entity-id="${navInfo.entityId}" data-nav-model="${navInfo.targetModel}" style="cursor: pointer; color: rgb(37, 99, 235); text-decoration: underline;" title="Click to view ${navInfo.targetModel} details">${escapedValue}</span>`;
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
displayValue = escapedValue;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
html += `<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">${displayValue}</td>`;
|
|
910
|
+
}
|
|
911
|
+
html += `</tr>`;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
html += `
|
|
915
|
+
</tbody>
|
|
916
|
+
</table>
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
`;
|
|
920
|
+
return html;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Helper: Render list component
|
|
924
|
+
*
|
|
925
|
+
* Generates a list view with:
|
|
926
|
+
* - Flexible field display (comma-separated or multi-line)
|
|
927
|
+
* - Data type-aware rendering
|
|
928
|
+
* - Relationship field resolution (shows related entity names)
|
|
929
|
+
* - Empty state handling
|
|
930
|
+
* - Professional card-based styling
|
|
931
|
+
*/
|
|
932
|
+
renderListComponent(componentName, componentDef, modelData, primaryModel, modelSchemas, _tailwindAdapter) {
|
|
933
|
+
const properties = componentDef.properties || componentDef;
|
|
934
|
+
const listModel = properties.model || primaryModel;
|
|
935
|
+
const entities = modelData[listModel || ''] || [];
|
|
936
|
+
const fields = properties.fields || [];
|
|
937
|
+
let html = `
|
|
938
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
939
|
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
940
|
+
<h4 class="font-semibold text-sm text-gray-700 dark:text-gray-200">${componentName}</h4>
|
|
941
|
+
</div>
|
|
942
|
+
<div class="p-4">
|
|
943
|
+
`;
|
|
944
|
+
if (entities.length === 0) {
|
|
945
|
+
html += `
|
|
946
|
+
<div class="text-sm text-gray-500 dark:text-gray-400 italic text-center py-4">
|
|
947
|
+
No ${listModel || 'items'} yet
|
|
948
|
+
</div>
|
|
949
|
+
`;
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
html += '<ul class="space-y-2">';
|
|
953
|
+
// Check if navigation is enabled
|
|
954
|
+
const enableNavigation = this.navigationOptions?.enableListNavigation;
|
|
955
|
+
for (const entity of entities) {
|
|
956
|
+
const navAttrs = enableNavigation
|
|
957
|
+
? `data-nav-entity-id="${entity.data?.id || entity.id}" style="cursor: pointer;" title="Click to view details"`
|
|
958
|
+
: '';
|
|
959
|
+
html += `
|
|
960
|
+
<li class="p-3 border border-gray-200 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" ${navAttrs}>
|
|
961
|
+
<div class="text-sm text-gray-900 dark:text-gray-100">
|
|
962
|
+
`;
|
|
963
|
+
// Render each field
|
|
964
|
+
const fieldValues = [];
|
|
965
|
+
for (const field of fields) {
|
|
966
|
+
// Resolve value (handles both direct fields and relationships)
|
|
967
|
+
const value = this.resolveFieldValue(field, entity, modelSchemas, modelData, listModel);
|
|
968
|
+
if (value === undefined || value === null || value === '') {
|
|
969
|
+
continue; // Skip empty values
|
|
970
|
+
}
|
|
971
|
+
let displayValue;
|
|
972
|
+
if (typeof value === 'boolean') {
|
|
973
|
+
displayValue = value ? 'Yes' : 'No';
|
|
974
|
+
}
|
|
975
|
+
else if (typeof value === 'object') {
|
|
976
|
+
displayValue = '[Object]';
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
displayValue = String(value)
|
|
980
|
+
.replace(/&/g, '&')
|
|
981
|
+
.replace(/</g, '<')
|
|
982
|
+
.replace(/>/g, '>')
|
|
983
|
+
.replace(/"/g, '"');
|
|
984
|
+
}
|
|
985
|
+
fieldValues.push(`<strong>${this.humanizeFieldName(field)}:</strong> ${displayValue}`);
|
|
986
|
+
}
|
|
987
|
+
html += fieldValues.join(' • ');
|
|
988
|
+
html += `
|
|
989
|
+
</div>
|
|
990
|
+
</li>
|
|
991
|
+
`;
|
|
992
|
+
}
|
|
993
|
+
html += '</ul>';
|
|
994
|
+
}
|
|
995
|
+
html += `
|
|
996
|
+
</div>
|
|
997
|
+
</div>
|
|
998
|
+
`;
|
|
999
|
+
return html;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Helper: Render content component (for detail views)
|
|
1003
|
+
*
|
|
1004
|
+
* Displays specific fields from an entity with rich formatting.
|
|
1005
|
+
* Resolves relationship fields to show related entity names.
|
|
1006
|
+
* This matches the original DetailView's "content" component behavior.
|
|
1007
|
+
*/
|
|
1008
|
+
renderContentComponent(componentName, componentDef, selectedEntity, modelData, modelSchemas, primaryModel, _tailwindAdapter) {
|
|
1009
|
+
let fields = componentDef.fields || [];
|
|
1010
|
+
const entityData = selectedEntity?.data || selectedEntity || {};
|
|
1011
|
+
// FALLBACK: If no fields, infer from schema (includes relationships)
|
|
1012
|
+
if (fields.length === 0) {
|
|
1013
|
+
if (primaryModel && modelSchemas) {
|
|
1014
|
+
// Use schema to get fields + relationships
|
|
1015
|
+
fields = this.inferFieldsFromSchema(modelSchemas, primaryModel);
|
|
1016
|
+
}
|
|
1017
|
+
else if (entityData) {
|
|
1018
|
+
// Last resort: infer from entity data
|
|
1019
|
+
fields = Object.keys(entityData).filter(key => key !== 'id' &&
|
|
1020
|
+
!key.endsWith('Id') &&
|
|
1021
|
+
!['createdAt', 'updatedAt'].includes(key));
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
let html = `
|
|
1025
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
1026
|
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
1027
|
+
<h4 class="font-semibold text-sm text-gray-700 dark:text-gray-200 capitalize">${componentName}</h4>
|
|
1028
|
+
</div>
|
|
1029
|
+
<div class="p-4">
|
|
1030
|
+
`;
|
|
1031
|
+
// Show debug info if still no fields
|
|
1032
|
+
if (fields.length === 0) {
|
|
1033
|
+
html += `
|
|
1034
|
+
<p class="text-sm text-yellow-600 dark:text-yellow-400 italic">
|
|
1035
|
+
No fields available (entity keys: ${Object.keys(entityData).join(', ')})
|
|
1036
|
+
</p>
|
|
1037
|
+
`;
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
html += '<div class="space-y-3">';
|
|
1041
|
+
let displayedFields = 0;
|
|
1042
|
+
for (const fieldName of fields) {
|
|
1043
|
+
// Skip id fields
|
|
1044
|
+
if (fieldName === 'id')
|
|
1045
|
+
continue;
|
|
1046
|
+
// Resolve value (handles both direct fields and relationships)
|
|
1047
|
+
const value = this.resolveFieldValue(fieldName, selectedEntity, modelSchemas, modelData, primaryModel);
|
|
1048
|
+
// Check if this is a relationship field for navigation
|
|
1049
|
+
const navInfo = this.getRelationshipNavInfo(fieldName, selectedEntity, modelSchemas, primaryModel);
|
|
1050
|
+
const enableRelatedNavigation = this.navigationOptions?.enableRelatedNavigation;
|
|
1051
|
+
// Format the value (show even if null/undefined/empty)
|
|
1052
|
+
let formattedValue;
|
|
1053
|
+
if (value === undefined || value === null) {
|
|
1054
|
+
formattedValue = '<span class="text-gray-400 dark:text-gray-500 italic">Not set</span>';
|
|
1055
|
+
}
|
|
1056
|
+
else if (typeof value === 'object') {
|
|
1057
|
+
// Objects: Show as formatted JSON
|
|
1058
|
+
const jsonStr = JSON.stringify(value, null, 2)
|
|
1059
|
+
.replace(/&/g, '&')
|
|
1060
|
+
.replace(/</g, '<')
|
|
1061
|
+
.replace(/>/g, '>')
|
|
1062
|
+
.replace(/"/g, '"');
|
|
1063
|
+
formattedValue = `
|
|
1064
|
+
<pre class="bg-gray-50 dark:bg-gray-900 p-2 rounded border border-gray-200 dark:border-gray-600 text-xs overflow-auto text-gray-900 dark:text-gray-100">${jsonStr}</pre>
|
|
1065
|
+
`;
|
|
1066
|
+
}
|
|
1067
|
+
else if (typeof value === 'boolean') {
|
|
1068
|
+
// Booleans: Show as Yes/No badge
|
|
1069
|
+
formattedValue = `
|
|
1070
|
+
<span class="inline-block px-2 py-1 rounded text-xs font-medium ${value
|
|
1071
|
+
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
|
1072
|
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'}">
|
|
1073
|
+
${value ? 'Yes' : 'No'}
|
|
1074
|
+
</span>
|
|
1075
|
+
`;
|
|
1076
|
+
}
|
|
1077
|
+
else if (value === '') {
|
|
1078
|
+
formattedValue = '<span class="text-gray-400 dark:text-gray-500 italic">Empty</span>';
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
// Other values: Escape HTML and show as text
|
|
1082
|
+
const escapedValue = String(value)
|
|
1083
|
+
.replace(/&/g, '&')
|
|
1084
|
+
.replace(/</g, '<')
|
|
1085
|
+
.replace(/>/g, '>')
|
|
1086
|
+
.replace(/"/g, '"');
|
|
1087
|
+
// Wrap in navigation span if this is a relationship field
|
|
1088
|
+
if (navInfo && enableRelatedNavigation) {
|
|
1089
|
+
formattedValue = `<span data-nav-entity-id="${navInfo.entityId}" data-nav-model="${navInfo.targetModel}" style="cursor: pointer; color: rgb(37, 99, 235); text-decoration: underline;" title="Click to view ${navInfo.targetModel} details">${escapedValue}</span>`;
|
|
1090
|
+
}
|
|
1091
|
+
else {
|
|
1092
|
+
formattedValue = escapedValue;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
html += `
|
|
1096
|
+
<div class="grid grid-cols-[120px_1fr] gap-4 items-start">
|
|
1097
|
+
<label class="font-semibold text-sm text-gray-700 dark:text-gray-200 capitalize text-left">${fieldName}</label>
|
|
1098
|
+
<div class="text-sm text-gray-900 dark:text-gray-100">${formattedValue}</div>
|
|
1099
|
+
</div>
|
|
1100
|
+
`;
|
|
1101
|
+
displayedFields++;
|
|
1102
|
+
}
|
|
1103
|
+
if (displayedFields === 0) {
|
|
1104
|
+
html += '<p class="text-sm text-gray-500 dark:text-gray-400 italic">No fields to display</p>';
|
|
1105
|
+
}
|
|
1106
|
+
html += '</div>'; // Close space-y-3
|
|
1107
|
+
}
|
|
1108
|
+
html += `
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
`;
|
|
1112
|
+
return html;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Helper: Render list component in detail view (for related entities)
|
|
1116
|
+
*
|
|
1117
|
+
* Shows related entities in a table format.
|
|
1118
|
+
* This matches the original DetailView's "list" component behavior.
|
|
1119
|
+
*/
|
|
1120
|
+
renderDetailListComponent(componentName, componentDef, modelData, selectedEntity, primaryModel, _tailwindAdapter, modelSchemas, insideTab) {
|
|
1121
|
+
const properties = componentDef.properties || componentDef;
|
|
1122
|
+
let fields = componentDef.fields || properties.fields || [];
|
|
1123
|
+
let relatedModel = properties.model;
|
|
1124
|
+
// FALLBACK: Infer model from component name if not specified
|
|
1125
|
+
// E.g., "commentsList" -> "Comment", "postsList" -> "Post"
|
|
1126
|
+
if (!relatedModel) {
|
|
1127
|
+
const lowerName = componentName.toLowerCase();
|
|
1128
|
+
// Try to extract model name from component name
|
|
1129
|
+
// Sort by length descending to match longer names first (e.g., "UserInstrument" before "User")
|
|
1130
|
+
const modelNames = Object.keys(modelData).sort((a, b) => b.length - a.length);
|
|
1131
|
+
for (const modelName of modelNames) {
|
|
1132
|
+
if (lowerName.includes(modelName.toLowerCase())) {
|
|
1133
|
+
relatedModel = modelName;
|
|
1134
|
+
break;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Get all entities for the related model
|
|
1139
|
+
const allRelatedEntities = relatedModel ? modelData[relatedModel] || [] : [];
|
|
1140
|
+
// Filter by foreign key relationship
|
|
1141
|
+
// For example, if viewing Post (primaryModel), filter Comments by postId
|
|
1142
|
+
// Use selectedEntity.data.id (the UUID) not selectedEntity.id (the internal ID)
|
|
1143
|
+
// Find the correct foreign key by looking at the related model's belongsTo relationships
|
|
1144
|
+
let foreignKey = null;
|
|
1145
|
+
if (primaryModel && relatedModel && modelSchemas?.[relatedModel]?.relationships) {
|
|
1146
|
+
const relatedSchema = modelSchemas[relatedModel];
|
|
1147
|
+
// Find the belongsTo relationship that targets the primary model
|
|
1148
|
+
for (const [relName, relDef] of Object.entries(relatedSchema.relationships)) {
|
|
1149
|
+
const rel = relDef;
|
|
1150
|
+
if (rel.type === 'belongsTo' && rel.targetModel === primaryModel) {
|
|
1151
|
+
foreignKey = `${relName}Id`;
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
// Fallback: derive from primary model name
|
|
1157
|
+
if (!foreignKey && primaryModel) {
|
|
1158
|
+
foreignKey = `${primaryModel.charAt(0).toLowerCase()}${primaryModel.slice(1)}Id`;
|
|
1159
|
+
}
|
|
1160
|
+
const relatedEntities = foreignKey && selectedEntity?.data?.id
|
|
1161
|
+
? allRelatedEntities.filter((e) => e.data?.[foreignKey] === selectedEntity.data.id)
|
|
1162
|
+
: allRelatedEntities;
|
|
1163
|
+
// If no fields specified, infer from schema or first entity
|
|
1164
|
+
if (fields.length === 0) {
|
|
1165
|
+
// Try schema-based inference first (includes relationships)
|
|
1166
|
+
if (relatedModel && modelSchemas) {
|
|
1167
|
+
fields = this.inferFieldsFromSchema(modelSchemas, relatedModel);
|
|
1168
|
+
// Filter out the parent foreign key (redundant in detail view lists)
|
|
1169
|
+
// E.g., in UserInstrument list under User detail, don't show "user" field
|
|
1170
|
+
if (primaryModel && foreignKey) {
|
|
1171
|
+
// Find the relationship name from the foreign key
|
|
1172
|
+
// E.g., "userId" -> "user", "organizerId" -> "organizer"
|
|
1173
|
+
const relationshipName = foreignKey.replace(/Id$/, '');
|
|
1174
|
+
fields = fields.filter((f) => f !== relationshipName);
|
|
1175
|
+
}
|
|
1176
|
+
// Limit to reasonable number for tables
|
|
1177
|
+
if (fields.length > 6) {
|
|
1178
|
+
fields = fields.slice(0, 6);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
else if (relatedEntities.length > 0) {
|
|
1182
|
+
// Fallback to data-based inference (won't include relationships)
|
|
1183
|
+
const firstEntity = relatedEntities[0];
|
|
1184
|
+
if (firstEntity.data) {
|
|
1185
|
+
fields = Object.keys(firstEntity.data)
|
|
1186
|
+
.filter(key => key !== 'id' && !key.endsWith('Id'))
|
|
1187
|
+
.slice(0, 6); // Limit to first 6 fields
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
let html = '';
|
|
1192
|
+
// Only add outer container if not inside a tab (tabs provide their own container)
|
|
1193
|
+
if (!insideTab) {
|
|
1194
|
+
html += `
|
|
1195
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
1196
|
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
1197
|
+
<h4 class="font-semibold text-sm text-gray-700 dark:text-gray-200 capitalize">${componentName}</h4>
|
|
1198
|
+
</div>
|
|
1199
|
+
<div class="p-4">
|
|
1200
|
+
`;
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
html += '<div class="p-4">';
|
|
1204
|
+
}
|
|
1205
|
+
if (relatedEntities.length === 0) {
|
|
1206
|
+
html += `
|
|
1207
|
+
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
|
1208
|
+
No ${relatedModel || 'related'} entities ${foreignKey ? `with ${foreignKey} = ${selectedEntity?.id}` : ''}
|
|
1209
|
+
</p>
|
|
1210
|
+
`;
|
|
1211
|
+
}
|
|
1212
|
+
else if (fields.length === 0) {
|
|
1213
|
+
html += `
|
|
1214
|
+
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
|
1215
|
+
${relatedEntities.length} ${relatedModel} entities found but no fields to display
|
|
1216
|
+
</p>
|
|
1217
|
+
`;
|
|
1218
|
+
}
|
|
1219
|
+
else {
|
|
1220
|
+
// Check if navigation is enabled for related items
|
|
1221
|
+
const enableRelatedNavigation = this.navigationOptions?.enableRelatedNavigation;
|
|
1222
|
+
html += `
|
|
1223
|
+
<div class="overflow-auto max-h-96">
|
|
1224
|
+
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
1225
|
+
<thead class="bg-gray-50 dark:bg-gray-700 sticky top-0">
|
|
1226
|
+
<tr>
|
|
1227
|
+
${fields.map((fieldName) => `
|
|
1228
|
+
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider">
|
|
1229
|
+
${this.humanizeFieldName(fieldName)}
|
|
1230
|
+
</th>
|
|
1231
|
+
`).join('')}
|
|
1232
|
+
</tr>
|
|
1233
|
+
</thead>
|
|
1234
|
+
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
1235
|
+
${relatedEntities.map((entity) => {
|
|
1236
|
+
const navAttrs = enableRelatedNavigation && relatedModel
|
|
1237
|
+
? `data-nav-entity-id="${entity.data?.id || entity.id}" data-nav-model="${relatedModel}" style="cursor: pointer;" title="Click to view ${relatedModel} details"`
|
|
1238
|
+
: '';
|
|
1239
|
+
return `
|
|
1240
|
+
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" ${navAttrs}>
|
|
1241
|
+
${fields.map((fieldName) => {
|
|
1242
|
+
// Resolve value (handles both direct fields and relationships)
|
|
1243
|
+
const value = this.resolveFieldValue(fieldName, entity, modelSchemas, modelData, relatedModel);
|
|
1244
|
+
// Format the value
|
|
1245
|
+
let displayValue;
|
|
1246
|
+
if (value === undefined || value === null || value === '') {
|
|
1247
|
+
displayValue = '<span class="text-gray-400 dark:text-gray-500">—</span>';
|
|
1248
|
+
}
|
|
1249
|
+
else if (typeof value === 'boolean') {
|
|
1250
|
+
displayValue = `<span class="inline-block px-2 py-1 rounded text-xs font-medium ${value
|
|
1251
|
+
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
|
1252
|
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'}">${value ? 'Yes' : 'No'}</span>`;
|
|
1253
|
+
}
|
|
1254
|
+
else if (typeof value === 'object') {
|
|
1255
|
+
displayValue = '<span class="text-gray-500 dark:text-gray-400 italic">[Object]</span>';
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
// Escape HTML for safe rendering
|
|
1259
|
+
displayValue = String(value)
|
|
1260
|
+
.replace(/&/g, '&')
|
|
1261
|
+
.replace(/</g, '<')
|
|
1262
|
+
.replace(/>/g, '>')
|
|
1263
|
+
.replace(/"/g, '"');
|
|
1264
|
+
}
|
|
1265
|
+
return `
|
|
1266
|
+
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
|
1267
|
+
${displayValue}
|
|
1268
|
+
</td>
|
|
1269
|
+
`;
|
|
1270
|
+
}).join('')}
|
|
1271
|
+
</tr>
|
|
1272
|
+
`;
|
|
1273
|
+
}).join('')}
|
|
1274
|
+
</tbody>
|
|
1275
|
+
</table>
|
|
1276
|
+
</div>
|
|
1277
|
+
`;
|
|
1278
|
+
}
|
|
1279
|
+
// Close containers
|
|
1280
|
+
html += '</div>'; // Close p-4 div
|
|
1281
|
+
if (!insideTab) {
|
|
1282
|
+
html += '</div>'; // Close outer container
|
|
1283
|
+
}
|
|
1284
|
+
return html;
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Helper: Render card component
|
|
1288
|
+
*
|
|
1289
|
+
* Displays entity fields with sophisticated formatting:
|
|
1290
|
+
* - Objects: Formatted JSON in code block
|
|
1291
|
+
* - Booleans: Yes/No badges
|
|
1292
|
+
* - Other values: Plain text
|
|
1293
|
+
*/
|
|
1294
|
+
renderCardComponent(componentName, _componentDef, selectedEntity, _tailwindAdapter) {
|
|
1295
|
+
const entityData = selectedEntity.data || {};
|
|
1296
|
+
const fields = Object.keys(entityData);
|
|
1297
|
+
let html = `
|
|
1298
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
1299
|
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
1300
|
+
<h4 class="font-semibold text-sm text-gray-700 dark:text-gray-200">${componentName}</h4>
|
|
1301
|
+
</div>
|
|
1302
|
+
<div class="p-4">
|
|
1303
|
+
<div class="space-y-3">
|
|
1304
|
+
${fields.map(field => {
|
|
1305
|
+
const value = entityData[field];
|
|
1306
|
+
// Skip undefined/null values
|
|
1307
|
+
if (value === undefined || value === null)
|
|
1308
|
+
return '';
|
|
1309
|
+
// Format value based on type
|
|
1310
|
+
let formattedValue;
|
|
1311
|
+
if (typeof value === 'object') {
|
|
1312
|
+
// Objects: Show as formatted JSON
|
|
1313
|
+
const jsonStr = JSON.stringify(value, null, 2)
|
|
1314
|
+
.replace(/&/g, '&')
|
|
1315
|
+
.replace(/</g, '<')
|
|
1316
|
+
.replace(/>/g, '>')
|
|
1317
|
+
.replace(/"/g, '"');
|
|
1318
|
+
formattedValue = `
|
|
1319
|
+
<pre class="bg-gray-50 dark:bg-gray-900 p-2 rounded border border-gray-200 dark:border-gray-600 text-xs overflow-auto text-gray-900 dark:text-gray-100">${jsonStr}</pre>
|
|
1320
|
+
`;
|
|
1321
|
+
}
|
|
1322
|
+
else if (typeof value === 'boolean') {
|
|
1323
|
+
// Booleans: Show as Yes/No badge
|
|
1324
|
+
formattedValue = `
|
|
1325
|
+
<span class="inline-block px-2 py-1 rounded text-xs font-medium ${value
|
|
1326
|
+
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
|
1327
|
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'}">
|
|
1328
|
+
${value ? 'Yes' : 'No'}
|
|
1329
|
+
</span>
|
|
1330
|
+
`;
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
// Other values: Escape HTML and show as text
|
|
1334
|
+
const escaped = String(value)
|
|
1335
|
+
.replace(/&/g, '&')
|
|
1336
|
+
.replace(/</g, '<')
|
|
1337
|
+
.replace(/>/g, '>')
|
|
1338
|
+
.replace(/"/g, '"');
|
|
1339
|
+
formattedValue = escaped || '—';
|
|
1340
|
+
}
|
|
1341
|
+
return `
|
|
1342
|
+
<div class="grid grid-cols-[140px_1fr] gap-4 items-start">
|
|
1343
|
+
<label class="font-semibold text-sm text-gray-700 dark:text-gray-200 capitalize">${field}:</label>
|
|
1344
|
+
<div class="text-sm text-gray-900 dark:text-gray-100">${formattedValue}</div>
|
|
1345
|
+
</div>
|
|
1346
|
+
`;
|
|
1347
|
+
}).join('')}
|
|
1348
|
+
</div>
|
|
1349
|
+
</div>
|
|
1350
|
+
</div>
|
|
1351
|
+
`;
|
|
1352
|
+
return html;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Helper: Render metric card
|
|
1356
|
+
*/
|
|
1357
|
+
renderMetricCard(componentName, componentDef, modelData, _tailwindAdapter) {
|
|
1358
|
+
const properties = componentDef.properties || componentDef;
|
|
1359
|
+
const metric = properties.metric;
|
|
1360
|
+
const metricModel = properties.model;
|
|
1361
|
+
const entities = modelData[metricModel] || [];
|
|
1362
|
+
// Calculate metric value
|
|
1363
|
+
let metricValue = '0';
|
|
1364
|
+
if (entities.length > 0 && metric) {
|
|
1365
|
+
const values = entities
|
|
1366
|
+
.map((e) => e.data?.[metric])
|
|
1367
|
+
.filter((v) => v !== undefined && v !== null);
|
|
1368
|
+
if (values.length > 0 && typeof values[0] === 'number') {
|
|
1369
|
+
const sum = values.reduce((acc, v) => acc + v, 0);
|
|
1370
|
+
metricValue = String(Math.round(sum));
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
metricValue = String(values.length);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return `
|
|
1377
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
|
1378
|
+
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
|
1379
|
+
${metric || componentName}
|
|
1380
|
+
</p>
|
|
1381
|
+
<p class="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">
|
|
1382
|
+
${metricValue}
|
|
1383
|
+
</p>
|
|
1384
|
+
</div>
|
|
1385
|
+
`;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Helper: Render atomic component
|
|
1389
|
+
*/
|
|
1390
|
+
renderAtomicComponent(componentName, type, properties, _tailwindAdapter) {
|
|
1391
|
+
if (!this.tailwindAdapter.components[type]) {
|
|
1392
|
+
return `<div class="p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded">
|
|
1393
|
+
<p class="text-red-800 dark:text-red-200">Unknown component type: ${type}</p>
|
|
1394
|
+
</div>`;
|
|
1395
|
+
}
|
|
1396
|
+
return `
|
|
1397
|
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
1398
|
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
1399
|
+
<h4 class="font-semibold text-sm text-gray-700 dark:text-gray-200">${componentName}</h4>
|
|
1400
|
+
</div>
|
|
1401
|
+
<div class="p-4">
|
|
1402
|
+
${this.tailwindAdapter.components[type].render({ properties })}
|
|
1403
|
+
</div>
|
|
1404
|
+
</div>
|
|
1405
|
+
`;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Helper: Infer field names from model data
|
|
1409
|
+
*
|
|
1410
|
+
* Extracts field names from the first entity in modelData for the given model.
|
|
1411
|
+
* Filters out system fields like id, createdAt, updatedAt, and foreign key fields.
|
|
1412
|
+
*
|
|
1413
|
+
* @param modelData - Map of model names to entity arrays
|
|
1414
|
+
* @param modelName - Name of the model to extract fields from
|
|
1415
|
+
* @returns Array of field names suitable for display
|
|
1416
|
+
*/
|
|
1417
|
+
inferFieldsFromModel(modelData, modelName) {
|
|
1418
|
+
if (!modelName || !modelData[modelName]) {
|
|
1419
|
+
// No model data - return common default fields
|
|
1420
|
+
return ['name', 'title', 'description'];
|
|
1421
|
+
}
|
|
1422
|
+
const entities = modelData[modelName];
|
|
1423
|
+
if (entities.length === 0) {
|
|
1424
|
+
// No entities yet - return common default fields for this model
|
|
1425
|
+
return ['name', 'title', 'description'];
|
|
1426
|
+
}
|
|
1427
|
+
const firstEntity = entities[0];
|
|
1428
|
+
if (!firstEntity || !firstEntity.data) {
|
|
1429
|
+
return ['name', 'title', 'description'];
|
|
1430
|
+
}
|
|
1431
|
+
// Get field names from entity data, filter out system fields
|
|
1432
|
+
const fields = Object.keys(firstEntity.data).filter(field => field !== 'id' &&
|
|
1433
|
+
!field.endsWith('Id') &&
|
|
1434
|
+
field !== 'createdAt' &&
|
|
1435
|
+
field !== 'updatedAt');
|
|
1436
|
+
// If after filtering we have no fields, return common defaults
|
|
1437
|
+
return fields.length > 0 ? fields : ['name', 'title', 'description'];
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Hook: Use pattern adapter
|
|
1442
|
+
*/
|
|
1443
|
+
export function usePatternAdapter(config = {}) {
|
|
1444
|
+
return useMemo(() => new ReactPatternAdapter(config), [config]);
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Export pattern registry for external use
|
|
1448
|
+
*/
|
|
1449
|
+
export { COMPOSITE_VIEW_PATTERNS, ATOMIC_COMPONENTS_REGISTRY };
|
|
1450
|
+
//# sourceMappingURL=react-pattern-adapter.js.map
|