@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.
Files changed (93) hide show
  1. package/README.md +356 -0
  2. package/dist/runtime/views/core/atomic-components-registry.d.ts +63 -0
  3. package/dist/runtime/views/core/atomic-components-registry.d.ts.map +1 -0
  4. package/dist/runtime/views/core/atomic-components-registry.js +822 -0
  5. package/dist/runtime/views/core/atomic-components-registry.js.map +1 -0
  6. package/dist/runtime/views/core/composite-pattern-types.d.ts +171 -0
  7. package/dist/runtime/views/core/composite-pattern-types.d.ts.map +1 -0
  8. package/dist/runtime/views/core/composite-pattern-types.js +11 -0
  9. package/dist/runtime/views/core/composite-pattern-types.js.map +1 -0
  10. package/dist/runtime/views/core/composite-patterns.d.ts +67 -0
  11. package/dist/runtime/views/core/composite-patterns.d.ts.map +1 -0
  12. package/dist/runtime/views/core/composite-patterns.js +485 -0
  13. package/dist/runtime/views/core/composite-patterns.js.map +1 -0
  14. package/dist/runtime/views/core/entity-display.d.ts +28 -0
  15. package/dist/runtime/views/core/entity-display.d.ts.map +1 -1
  16. package/dist/runtime/views/core/entity-display.js +75 -0
  17. package/dist/runtime/views/core/entity-display.js.map +1 -1
  18. package/dist/runtime/views/core/index.d.ts +4 -1
  19. package/dist/runtime/views/core/index.d.ts.map +1 -1
  20. package/dist/runtime/views/core/index.js +5 -1
  21. package/dist/runtime/views/core/index.js.map +1 -1
  22. package/dist/runtime/views/core/pattern-engine.d.ts +2 -2
  23. package/dist/runtime/views/core/pattern-engine.d.ts.map +1 -1
  24. package/dist/runtime/views/core/pattern-engine.js.map +1 -1
  25. package/dist/runtime/views/core/types.d.ts +2 -0
  26. package/dist/runtime/views/core/types.d.ts.map +1 -1
  27. package/dist/runtime/views/index.d.ts +5 -2
  28. package/dist/runtime/views/index.d.ts.map +1 -1
  29. package/dist/runtime/views/index.js +5 -2
  30. package/dist/runtime/views/index.js.map +1 -1
  31. package/dist/runtime/views/react/components/DevShell.d.ts.map +1 -1
  32. package/dist/runtime/views/react/components/DevShell.js +6 -2
  33. package/dist/runtime/views/react/components/DevShell.js.map +1 -1
  34. package/dist/runtime/views/react/components/EntitySelect.d.ts +14 -0
  35. package/dist/runtime/views/react/components/EntitySelect.d.ts.map +1 -0
  36. package/dist/runtime/views/react/components/EntitySelect.js +29 -0
  37. package/dist/runtime/views/react/components/EntitySelect.js.map +1 -0
  38. package/dist/runtime/views/react/components/EventStream.d.ts +11 -0
  39. package/dist/runtime/views/react/components/EventStream.d.ts.map +1 -0
  40. package/dist/runtime/views/react/components/EventStream.js +49 -0
  41. package/dist/runtime/views/react/components/EventStream.js.map +1 -0
  42. package/dist/runtime/views/react/components/FieldInput.d.ts +23 -0
  43. package/dist/runtime/views/react/components/FieldInput.d.ts.map +1 -0
  44. package/dist/runtime/views/react/components/FieldInput.js +28 -0
  45. package/dist/runtime/views/react/components/FieldInput.js.map +1 -0
  46. package/dist/runtime/views/react/components/FormView.d.ts +21 -0
  47. package/dist/runtime/views/react/components/FormView.d.ts.map +1 -0
  48. package/dist/runtime/views/react/components/FormView.js +13 -0
  49. package/dist/runtime/views/react/components/FormView.js.map +1 -0
  50. package/dist/runtime/views/react/components/ModelManager.d.ts +6 -2
  51. package/dist/runtime/views/react/components/ModelManager.d.ts.map +1 -1
  52. package/dist/runtime/views/react/components/ModelManager.js +166 -61
  53. package/dist/runtime/views/react/components/ModelManager.js.map +1 -1
  54. package/dist/runtime/views/react/components/ModelSelector.d.ts.map +1 -1
  55. package/dist/runtime/views/react/components/ModelSelector.js +4 -1
  56. package/dist/runtime/views/react/components/ModelSelector.js.map +1 -1
  57. package/dist/runtime/views/react/components/OperationExecutor.d.ts +15 -0
  58. package/dist/runtime/views/react/components/OperationExecutor.d.ts.map +1 -0
  59. package/dist/runtime/views/react/components/OperationExecutor.js +86 -0
  60. package/dist/runtime/views/react/components/OperationExecutor.js.map +1 -0
  61. package/dist/runtime/views/react/components/OperationResultView.d.ts +10 -0
  62. package/dist/runtime/views/react/components/OperationResultView.d.ts.map +1 -0
  63. package/dist/runtime/views/react/components/OperationResultView.js +92 -0
  64. package/dist/runtime/views/react/components/OperationResultView.js.map +1 -0
  65. package/dist/runtime/views/react/components/OperationView.d.ts +21 -0
  66. package/dist/runtime/views/react/components/OperationView.d.ts.map +1 -0
  67. package/dist/runtime/views/react/components/OperationView.js +7 -0
  68. package/dist/runtime/views/react/components/OperationView.js.map +1 -0
  69. package/dist/runtime/views/react/components/RelationshipField.js +4 -4
  70. package/dist/runtime/views/react/components/RelationshipField.js.map +1 -1
  71. package/dist/runtime/views/react/components/RuntimeView.d.ts.map +1 -1
  72. package/dist/runtime/views/react/components/RuntimeView.js +93 -33
  73. package/dist/runtime/views/react/components/RuntimeView.js.map +1 -1
  74. package/dist/runtime/views/react/components/ViewRouter.d.ts +7 -1
  75. package/dist/runtime/views/react/components/ViewRouter.d.ts.map +1 -1
  76. package/dist/runtime/views/react/components/ViewRouter.js +57 -18
  77. package/dist/runtime/views/react/components/ViewRouter.js.map +1 -1
  78. package/dist/runtime/views/react/hooks/useEventStream.d.ts +29 -0
  79. package/dist/runtime/views/react/hooks/useEventStream.d.ts.map +1 -0
  80. package/dist/runtime/views/react/hooks/useEventStream.js +114 -0
  81. package/dist/runtime/views/react/hooks/useEventStream.js.map +1 -0
  82. package/dist/runtime/views/react/hooks/useResizableSidebar.d.ts.map +1 -1
  83. package/dist/runtime/views/react/hooks/useResizableSidebar.js +2 -0
  84. package/dist/runtime/views/react/hooks/useResizableSidebar.js.map +1 -1
  85. package/dist/runtime/views/react/index.d.ts +10 -1
  86. package/dist/runtime/views/react/index.d.ts.map +1 -1
  87. package/dist/runtime/views/react/index.js +11 -1
  88. package/dist/runtime/views/react/index.js.map +1 -1
  89. package/dist/runtime/views/react/react-pattern-adapter.d.ts +235 -0
  90. package/dist/runtime/views/react/react-pattern-adapter.d.ts.map +1 -0
  91. package/dist/runtime/views/react/react-pattern-adapter.js +1450 -0
  92. package/dist/runtime/views/react/react-pattern-adapter.js.map +1 -0
  93. 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, '&lt;').replace(/>/g, '&gt;')}</span>`;
893
+ }
894
+ else {
895
+ // Escape HTML for safe rendering
896
+ const escapedValue = String(value)
897
+ .replace(/&/g, '&amp;')
898
+ .replace(/</g, '&lt;')
899
+ .replace(/>/g, '&gt;')
900
+ .replace(/"/g, '&quot;');
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, '&amp;')
981
+ .replace(/</g, '&lt;')
982
+ .replace(/>/g, '&gt;')
983
+ .replace(/"/g, '&quot;');
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, '&amp;')
1060
+ .replace(/</g, '&lt;')
1061
+ .replace(/>/g, '&gt;')
1062
+ .replace(/"/g, '&quot;');
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, '&amp;')
1084
+ .replace(/</g, '&lt;')
1085
+ .replace(/>/g, '&gt;')
1086
+ .replace(/"/g, '&quot;');
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, '&amp;')
1261
+ .replace(/</g, '&lt;')
1262
+ .replace(/>/g, '&gt;')
1263
+ .replace(/"/g, '&quot;');
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, '&amp;')
1315
+ .replace(/</g, '&lt;')
1316
+ .replace(/>/g, '&gt;')
1317
+ .replace(/"/g, '&quot;');
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, '&amp;')
1336
+ .replace(/</g, '&lt;')
1337
+ .replace(/>/g, '&gt;')
1338
+ .replace(/"/g, '&quot;');
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