@agentlang/cli 0.6.6
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/.pnpmrc +10 -0
- package/LICENSE +83 -0
- package/README.md +690 -0
- package/bin/cli.js +4 -0
- package/out/docs.js +296 -0
- package/out/docs.js.map +1 -0
- package/out/main.js +544 -0
- package/out/main.js.map +1 -0
- package/out/repl.js +785 -0
- package/out/repl.js.map +1 -0
- package/out/ui-generator/specFinder.js +50 -0
- package/out/ui-generator/specFinder.js.map +1 -0
- package/out/ui-generator/specLoader.js +30 -0
- package/out/ui-generator/specLoader.js.map +1 -0
- package/out/ui-generator/uiGenerator.js +1859 -0
- package/out/ui-generator/uiGenerator.js.map +1 -0
- package/package.json +97 -0
|
@@ -0,0 +1,1859 @@
|
|
|
1
|
+
var __asyncValues = (this && this.__asyncValues) || function (o) {
|
|
2
|
+
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
|
|
3
|
+
var m = o[Symbol.asyncIterator], i;
|
|
4
|
+
return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
|
|
5
|
+
function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
|
|
6
|
+
function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
|
|
7
|
+
};
|
|
8
|
+
import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import ora from 'ora';
|
|
14
|
+
/* eslint-disable no-console */
|
|
15
|
+
/**
|
|
16
|
+
* Analyzes the existing UI directory to determine if it exists and what's in it
|
|
17
|
+
*/
|
|
18
|
+
async function analyzeExistingProject(projectDir) {
|
|
19
|
+
const analysis = {
|
|
20
|
+
exists: false,
|
|
21
|
+
isEmpty: true,
|
|
22
|
+
fileCount: 0,
|
|
23
|
+
hasPackageJson: false,
|
|
24
|
+
hasSourceFiles: false,
|
|
25
|
+
structure: '',
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
// Check if directory exists
|
|
29
|
+
if (!(await fs.pathExists(projectDir))) {
|
|
30
|
+
return analysis;
|
|
31
|
+
}
|
|
32
|
+
analysis.exists = true;
|
|
33
|
+
// Get all files in the directory (excluding node_modules, etc.)
|
|
34
|
+
const files = [];
|
|
35
|
+
async function scanDirectory(dir, prefix = '') {
|
|
36
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
// Skip common directories
|
|
39
|
+
if (['node_modules', '.git', 'dist', 'build', '.vscode'].includes(entry.name)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const fullPath = path.join(dir, entry.name);
|
|
43
|
+
const relativePath = path.relative(projectDir, fullPath);
|
|
44
|
+
files.push(relativePath);
|
|
45
|
+
if (entry.name === 'package.json') {
|
|
46
|
+
analysis.hasPackageJson = true;
|
|
47
|
+
}
|
|
48
|
+
if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts') || entry.name.endsWith('.jsx')) {
|
|
49
|
+
analysis.hasSourceFiles = true;
|
|
50
|
+
}
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
await scanDirectory(fullPath, `${prefix} `);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await scanDirectory(projectDir);
|
|
57
|
+
analysis.fileCount = files.length;
|
|
58
|
+
analysis.isEmpty = files.length === 0;
|
|
59
|
+
// Generate structure string (show first 20 files)
|
|
60
|
+
if (files.length > 0) {
|
|
61
|
+
const displayFiles = files.slice(0, 20).sort();
|
|
62
|
+
analysis.structure = displayFiles.join('\n');
|
|
63
|
+
if (files.length > 20) {
|
|
64
|
+
analysis.structure += `\n... and ${files.length - 20} more files`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return analysis;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.log(chalk.yellow(` ⚠️ Error analyzing directory: ${error instanceof Error ? error.message : String(error)}`));
|
|
71
|
+
return analysis;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/* eslint-enable no-console */
|
|
75
|
+
/* eslint-disable no-console */
|
|
76
|
+
export async function generateUI(uiSpec, outputBaseDir, apiKey, shouldPush = false, userMessage) {
|
|
77
|
+
var _a, e_1, _b, _c;
|
|
78
|
+
const spinner = ora('Initializing UI generation...').start();
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
try {
|
|
81
|
+
// Create output directory as 'ui' in the specified base directory
|
|
82
|
+
const projectDir = path.join(outputBaseDir, 'ui');
|
|
83
|
+
// Analyze existing project
|
|
84
|
+
spinner.text = 'Analyzing existing project...';
|
|
85
|
+
const projectAnalysis = await analyzeExistingProject(projectDir);
|
|
86
|
+
// Determine the generation mode
|
|
87
|
+
let mode;
|
|
88
|
+
if (userMessage) {
|
|
89
|
+
// User provided a message
|
|
90
|
+
if (projectAnalysis.exists && !projectAnalysis.isEmpty) {
|
|
91
|
+
mode = 'update'; // Update existing project based on user message
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
mode = 'fresh'; // Generate fresh, then apply user message
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// No user message
|
|
99
|
+
if (projectAnalysis.exists && !projectAnalysis.isEmpty) {
|
|
100
|
+
mode = 'incremental'; // Add missing files based on spec
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
mode = 'fresh'; // Fresh generation
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Display mode info on separate line
|
|
107
|
+
if (mode === 'fresh') {
|
|
108
|
+
spinner.text = `Creating new project: ${projectDir}`;
|
|
109
|
+
console.log(''); // Empty line for spacing
|
|
110
|
+
// Warn if directory exists with files but we're in fresh mode (shouldn't happen)
|
|
111
|
+
if (projectAnalysis.exists && projectAnalysis.fileCount > 0) {
|
|
112
|
+
console.log(chalk.yellow(` ⚠️ Warning: Directory exists with ${projectAnalysis.fileCount} files`));
|
|
113
|
+
console.log(chalk.yellow(' ⚠️ Switching to incremental mode to preserve existing files'));
|
|
114
|
+
mode = 'incremental';
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
console.log(chalk.cyan(' 📦 Mode: Fresh generation'));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (mode === 'incremental') {
|
|
121
|
+
spinner.succeed('Project analyzed');
|
|
122
|
+
console.log(''); // Empty line for spacing
|
|
123
|
+
console.log(chalk.cyan(' 🔄 Mode: Incremental update'));
|
|
124
|
+
console.log(chalk.gray(` 📂 Found existing project with ${projectAnalysis.fileCount} files`));
|
|
125
|
+
console.log(chalk.gray(' 📝 Will add missing files based on spec'));
|
|
126
|
+
spinner.start('Preparing incremental update...');
|
|
127
|
+
}
|
|
128
|
+
else if (mode === 'update') {
|
|
129
|
+
spinner.succeed('Project analyzed');
|
|
130
|
+
console.log(''); // Empty line for spacing
|
|
131
|
+
console.log(chalk.cyan(' ✏️ Mode: User-directed update'));
|
|
132
|
+
console.log(chalk.gray(` 📂 Found existing project with ${projectAnalysis.fileCount} files`));
|
|
133
|
+
console.log(chalk.gray(` 💬 User message: "${userMessage}"`));
|
|
134
|
+
// Check if request is vague
|
|
135
|
+
const vagueKeywords = ['fix', 'make sure', 'properly', 'work', 'working', 'issue', 'problem', 'error'];
|
|
136
|
+
const isVague = vagueKeywords.some(keyword => userMessage === null || userMessage === void 0 ? void 0 : userMessage.toLowerCase().includes(keyword));
|
|
137
|
+
if (isVague) {
|
|
138
|
+
console.log(chalk.yellow(' ⚠️ Note: Request is general - agent will first diagnose issues'));
|
|
139
|
+
}
|
|
140
|
+
spinner.start('Preparing update...');
|
|
141
|
+
}
|
|
142
|
+
await fs.ensureDir(projectDir);
|
|
143
|
+
// Track generated files and operations
|
|
144
|
+
const generatedFiles = [];
|
|
145
|
+
const filesCreated = [];
|
|
146
|
+
const directoriesCreated = [];
|
|
147
|
+
let lastFileCreated = '';
|
|
148
|
+
// Define tools for the agent using the correct API
|
|
149
|
+
const writeFile = tool('write_file', 'Write content to a file in the project directory', z.object({
|
|
150
|
+
file_path: z.string().describe('Relative path from project root (e.g., "src/App.tsx")'),
|
|
151
|
+
content: z.string().describe('The content to write to the file'),
|
|
152
|
+
}).shape, async (args) => {
|
|
153
|
+
const fullPath = path.join(projectDir, args.file_path);
|
|
154
|
+
// Ensure directory exists
|
|
155
|
+
await fs.ensureDir(path.dirname(fullPath));
|
|
156
|
+
// Write the file
|
|
157
|
+
await fs.writeFile(fullPath, args.content, 'utf-8');
|
|
158
|
+
// Track file creation
|
|
159
|
+
generatedFiles.push(args.file_path);
|
|
160
|
+
filesCreated.push(args.file_path);
|
|
161
|
+
lastFileCreated = args.file_path;
|
|
162
|
+
return {
|
|
163
|
+
content: [
|
|
164
|
+
{
|
|
165
|
+
type: 'text',
|
|
166
|
+
text: `Successfully wrote file: ${args.file_path}`,
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
const createDirectory = tool('create_directory', 'Create a directory in the project', z.object({
|
|
172
|
+
dir_path: z.string().describe('Relative directory path from project root'),
|
|
173
|
+
}).shape, async (args) => {
|
|
174
|
+
const fullPath = path.join(projectDir, args.dir_path);
|
|
175
|
+
await fs.ensureDir(fullPath);
|
|
176
|
+
// Track silently
|
|
177
|
+
directoriesCreated.push(args.dir_path);
|
|
178
|
+
return {
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: 'text',
|
|
182
|
+
text: `Successfully created directory: ${args.dir_path}`,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
const listFiles = tool('list_files', 'List files that have been generated so far', z.object({}).shape, () => Promise.resolve({
|
|
188
|
+
content: [
|
|
189
|
+
{
|
|
190
|
+
type: 'text',
|
|
191
|
+
text: `Generated files:\n${generatedFiles.map(f => `- ${f}`).join('\n')}`,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
}));
|
|
195
|
+
// Create MCP server with our tools
|
|
196
|
+
const mcpServer = createSdkMcpServer({
|
|
197
|
+
name: 'ui-generator-tools',
|
|
198
|
+
version: '1.0.0',
|
|
199
|
+
tools: [writeFile, createDirectory, listFiles],
|
|
200
|
+
});
|
|
201
|
+
// Create the generation prompt
|
|
202
|
+
const prompt = createGenerationPrompt(uiSpec, projectDir, mode, projectAnalysis, userMessage);
|
|
203
|
+
// Configure SDK with API key
|
|
204
|
+
process.env.ANTHROPIC_API_KEY = apiKey;
|
|
205
|
+
// Change working directory to projectDir so Write tool creates files in the right place
|
|
206
|
+
const originalCwd = process.cwd();
|
|
207
|
+
process.chdir(projectDir);
|
|
208
|
+
// Start clean generation
|
|
209
|
+
console.log('');
|
|
210
|
+
spinner.start(chalk.cyan('Starting agent...'));
|
|
211
|
+
// Query Claude with our MCP server
|
|
212
|
+
const session = query({
|
|
213
|
+
prompt,
|
|
214
|
+
options: {
|
|
215
|
+
mcpServers: {
|
|
216
|
+
'ui-generator-tools': mcpServer,
|
|
217
|
+
},
|
|
218
|
+
systemPrompt: {
|
|
219
|
+
type: 'preset',
|
|
220
|
+
preset: 'claude_code',
|
|
221
|
+
},
|
|
222
|
+
permissionMode: 'bypassPermissions', // Allow all tool operations without asking
|
|
223
|
+
// Allow all tools - agent can use MCP tools, Write, Read, Edit, Bash, etc.
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
// Helper function to count files in real-time
|
|
227
|
+
const countCurrentFiles = async () => {
|
|
228
|
+
try {
|
|
229
|
+
let count = 0;
|
|
230
|
+
const scan = async (dir) => {
|
|
231
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name))
|
|
234
|
+
continue;
|
|
235
|
+
if (entry.isDirectory()) {
|
|
236
|
+
await scan(path.join(dir, entry.name));
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
count++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
await scan(projectDir);
|
|
244
|
+
return count;
|
|
245
|
+
}
|
|
246
|
+
catch (_a) {
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
// Process messages from the agent
|
|
251
|
+
let toolCallCount = 0;
|
|
252
|
+
let lastProgressUpdate = Date.now();
|
|
253
|
+
let currentThinking = '';
|
|
254
|
+
let currentTool = '';
|
|
255
|
+
let cachedFileCount = 0;
|
|
256
|
+
let lastFileCountUpdate = Date.now();
|
|
257
|
+
let sessionSucceeded = false;
|
|
258
|
+
let sessionError;
|
|
259
|
+
const PROGRESS_UPDATE_INTERVAL = 10000; // Update every 10 seconds
|
|
260
|
+
const FILE_COUNT_UPDATE_INTERVAL = 2000; // Update file count every 2 seconds
|
|
261
|
+
try {
|
|
262
|
+
for (var _d = true, session_1 = __asyncValues(session), session_1_1; session_1_1 = await session_1.next(), _a = session_1_1.done, !_a; _d = true) {
|
|
263
|
+
_c = session_1_1.value;
|
|
264
|
+
_d = false;
|
|
265
|
+
const message = _c;
|
|
266
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
if (message.type === 'assistant') {
|
|
269
|
+
// Extract text content and tool calls from assistant message
|
|
270
|
+
const content = message.message.content;
|
|
271
|
+
if (Array.isArray(content)) {
|
|
272
|
+
for (const block of content) {
|
|
273
|
+
if (block.type === 'text') {
|
|
274
|
+
// Extract thinking message
|
|
275
|
+
const text = block.text;
|
|
276
|
+
if (text.trim()) {
|
|
277
|
+
// Clean up the text: take first sentence or first 60 chars
|
|
278
|
+
const firstSentence = text.split(/[.!?]\s/)[0];
|
|
279
|
+
const cleaned = firstSentence.replace(/\n/g, ' ').slice(0, 60);
|
|
280
|
+
currentThinking = cleaned;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (block.type === 'tool_use') {
|
|
284
|
+
toolCallCount++;
|
|
285
|
+
// Extract tool name
|
|
286
|
+
const toolName = block.name;
|
|
287
|
+
currentTool = toolName;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Update file count periodically (not on every message to avoid slowdown)
|
|
292
|
+
if (now - lastFileCountUpdate > FILE_COUNT_UPDATE_INTERVAL) {
|
|
293
|
+
cachedFileCount = await countCurrentFiles();
|
|
294
|
+
lastFileCountUpdate = now;
|
|
295
|
+
}
|
|
296
|
+
// Update spinner with clean progress info
|
|
297
|
+
let spinnerText = chalk.cyan(`Generating... ${cachedFileCount} files • ${elapsed}s`);
|
|
298
|
+
// Show current tool being used
|
|
299
|
+
if (currentTool) {
|
|
300
|
+
spinnerText += chalk.blue(` • Tool: ${currentTool}`);
|
|
301
|
+
}
|
|
302
|
+
// Show current thinking or last file created
|
|
303
|
+
if (currentThinking) {
|
|
304
|
+
spinnerText += chalk.gray(` • ${currentThinking}${currentThinking.length >= 60 ? '...' : ''}`);
|
|
305
|
+
}
|
|
306
|
+
else if (lastFileCreated) {
|
|
307
|
+
spinnerText += chalk.gray(` • ${lastFileCreated}`);
|
|
308
|
+
}
|
|
309
|
+
spinner.text = spinnerText;
|
|
310
|
+
// Show periodic progress updates (every 10 seconds)
|
|
311
|
+
if (now - lastProgressUpdate > PROGRESS_UPDATE_INTERVAL && cachedFileCount > 0) {
|
|
312
|
+
spinner.stop();
|
|
313
|
+
console.log(chalk.gray(` 📊 Progress: ${cachedFileCount} files created, ${toolCallCount} operations, ${elapsed}s elapsed`));
|
|
314
|
+
spinner.start(spinnerText);
|
|
315
|
+
lastProgressUpdate = now;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else if (message.type === 'result') {
|
|
319
|
+
// Final result
|
|
320
|
+
spinner.stop();
|
|
321
|
+
const finalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
322
|
+
if (message.subtype === 'success') {
|
|
323
|
+
sessionSucceeded = true;
|
|
324
|
+
console.log(chalk.green('\n✅ Agent completed successfully'));
|
|
325
|
+
console.log(chalk.gray(` ⏱ Time: ${finalElapsed}s`));
|
|
326
|
+
console.log(chalk.gray(` 🔄 Turns: ${message.num_turns}`));
|
|
327
|
+
console.log(chalk.gray(` 🔧 Operations: ${toolCallCount}`));
|
|
328
|
+
console.log(chalk.gray(` 💰 Cost: $${message.total_cost_usd.toFixed(4)}`));
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
sessionSucceeded = false;
|
|
332
|
+
sessionError = message.subtype;
|
|
333
|
+
console.log(chalk.yellow(`\n⚠️ Agent finished with status: ${message.subtype}`));
|
|
334
|
+
console.log(chalk.gray(` ⏱ Time: ${finalElapsed}s`));
|
|
335
|
+
// Check if agent did no work
|
|
336
|
+
if (toolCallCount === 0) {
|
|
337
|
+
console.log(chalk.yellow('\n⚠️ Warning: Agent completed but performed no operations.'));
|
|
338
|
+
console.log(chalk.gray(' This might indicate:'));
|
|
339
|
+
console.log(chalk.gray(' • The task description was unclear or too vague'));
|
|
340
|
+
console.log(chalk.gray(' • The agent thought no changes were needed'));
|
|
341
|
+
console.log(chalk.gray(' • An error occurred before tools could be used'));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
348
|
+
finally {
|
|
349
|
+
try {
|
|
350
|
+
if (!_d && !_a && (_b = session_1.return)) await _b.call(session_1);
|
|
351
|
+
}
|
|
352
|
+
finally { if (e_1) throw e_1.error; }
|
|
353
|
+
}
|
|
354
|
+
// Check if session failed
|
|
355
|
+
if (!sessionSucceeded) {
|
|
356
|
+
throw new Error(`Agent session failed with status: ${sessionError || 'unknown'}. ` +
|
|
357
|
+
`The agent completed ${toolCallCount} operations before stopping.`);
|
|
358
|
+
}
|
|
359
|
+
// Restore original working directory
|
|
360
|
+
process.chdir(originalCwd);
|
|
361
|
+
// Count actual files generated in the ui/ directory
|
|
362
|
+
const actualFileCount = await countGeneratedFiles(projectDir);
|
|
363
|
+
console.log(chalk.green('\n✅ Generation complete!'));
|
|
364
|
+
console.log(chalk.green('\n📊 Summary:'));
|
|
365
|
+
console.log(chalk.gray(' • Files created: ') + chalk.white(actualFileCount));
|
|
366
|
+
console.log(chalk.gray(' • Time elapsed: ') + chalk.white(`${((Date.now() - startTime) / 1000).toFixed(1)}s`));
|
|
367
|
+
console.log(chalk.gray(' • Output location: ') + chalk.white(projectDir));
|
|
368
|
+
// Show sample files created (first 8)
|
|
369
|
+
if (filesCreated.length > 0) {
|
|
370
|
+
console.log(chalk.cyan('\n📄 Sample files created:'));
|
|
371
|
+
const sampleFiles = filesCreated.slice(0, 8);
|
|
372
|
+
sampleFiles.forEach(file => {
|
|
373
|
+
console.log(chalk.gray(` • ${file}`));
|
|
374
|
+
});
|
|
375
|
+
if (filesCreated.length > 8) {
|
|
376
|
+
console.log(chalk.gray(` ... and ${filesCreated.length - 8} more files`));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Git operations if requested
|
|
380
|
+
if (shouldPush) {
|
|
381
|
+
console.log(''); // Add newline
|
|
382
|
+
await performGitOperations(projectDir, outputBaseDir, uiSpec.appInfo.title);
|
|
383
|
+
}
|
|
384
|
+
console.log(chalk.cyan('\n📝 Next steps:'));
|
|
385
|
+
console.log(chalk.white(` cd ${projectDir}`));
|
|
386
|
+
console.log(chalk.white(' npm install'));
|
|
387
|
+
console.log(chalk.white(' npm run build # Verify build succeeds'));
|
|
388
|
+
console.log(chalk.white(' npm run dev # Start development server'));
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
spinner.fail('UI generation failed');
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/* eslint-enable no-console */
|
|
396
|
+
/**
|
|
397
|
+
* Count all files (recursively) in the generated project directory
|
|
398
|
+
*/
|
|
399
|
+
async function countGeneratedFiles(projectDir) {
|
|
400
|
+
let count = 0;
|
|
401
|
+
async function countInDirectory(dir) {
|
|
402
|
+
try {
|
|
403
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
const fullPath = path.join(dir, entry.name);
|
|
406
|
+
if (entry.isDirectory()) {
|
|
407
|
+
// Skip node_modules and other common directories
|
|
408
|
+
if (!['node_modules', '.git', 'dist', 'build', '.vscode'].includes(entry.name)) {
|
|
409
|
+
await countInDirectory(fullPath);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else if (entry.isFile()) {
|
|
413
|
+
count++;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (_a) {
|
|
418
|
+
// Ignore errors
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
await countInDirectory(projectDir);
|
|
422
|
+
return count;
|
|
423
|
+
}
|
|
424
|
+
/* eslint-disable no-console */
|
|
425
|
+
async function performGitOperations(projectDir, repoRoot, appTitle) {
|
|
426
|
+
const { exec } = await import('child_process');
|
|
427
|
+
const { promisify } = await import('util');
|
|
428
|
+
const execAsync = promisify(exec);
|
|
429
|
+
const gitSpinner = ora('Preparing git operations...').start();
|
|
430
|
+
try {
|
|
431
|
+
// Save original directory
|
|
432
|
+
const originalCwd = process.cwd();
|
|
433
|
+
// Change to repo root directory
|
|
434
|
+
process.chdir(repoRoot);
|
|
435
|
+
gitSpinner.text = 'Checking git status...';
|
|
436
|
+
// Check if it's a git repository
|
|
437
|
+
try {
|
|
438
|
+
await execAsync('git rev-parse --git-dir');
|
|
439
|
+
}
|
|
440
|
+
catch (_a) {
|
|
441
|
+
gitSpinner.fail('Not a git repository');
|
|
442
|
+
console.log(chalk.yellow(' ⚠️ Skipping git operations - not a git repository'));
|
|
443
|
+
process.chdir(originalCwd);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Check for uncommitted changes in ui/
|
|
447
|
+
gitSpinner.text = 'Checking for changes...';
|
|
448
|
+
const { stdout: statusOutput } = await execAsync('git status --porcelain ui/');
|
|
449
|
+
if (!statusOutput.trim()) {
|
|
450
|
+
gitSpinner.info('No changes to commit in ui/');
|
|
451
|
+
process.chdir(originalCwd);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Add all files in the ui directory
|
|
455
|
+
gitSpinner.text = 'Adding files to git...';
|
|
456
|
+
await execAsync('git add ui/');
|
|
457
|
+
gitSpinner.succeed('Added ui/ to git');
|
|
458
|
+
// Commit changes
|
|
459
|
+
gitSpinner.start('Committing changes...');
|
|
460
|
+
const commitMessage = `Add generated UI for ${appTitle}\n\n🤖 Generated with Agentlang CLI`;
|
|
461
|
+
await execAsync(`git commit -m "${commitMessage}"`);
|
|
462
|
+
gitSpinner.succeed('Committed changes');
|
|
463
|
+
// Check if remote exists
|
|
464
|
+
gitSpinner.start('Checking remote...');
|
|
465
|
+
try {
|
|
466
|
+
await execAsync('git remote get-url origin');
|
|
467
|
+
}
|
|
468
|
+
catch (_b) {
|
|
469
|
+
gitSpinner.warn('No remote repository configured');
|
|
470
|
+
console.log(chalk.yellow(' ⚠️ Skipping push - no remote configured'));
|
|
471
|
+
process.chdir(originalCwd);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// Get current branch
|
|
475
|
+
const { stdout: branchOutput } = await execAsync('git branch --show-current');
|
|
476
|
+
const currentBranch = branchOutput.trim();
|
|
477
|
+
// Push to remote
|
|
478
|
+
gitSpinner.text = `Pushing to remote (${currentBranch})...`;
|
|
479
|
+
await execAsync(`git push origin ${currentBranch}`);
|
|
480
|
+
gitSpinner.succeed(`Pushed to remote (${currentBranch})`);
|
|
481
|
+
console.log(chalk.green('\n✅ Successfully committed and pushed UI changes'));
|
|
482
|
+
// Restore original directory
|
|
483
|
+
process.chdir(originalCwd);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
gitSpinner.fail('Git operations failed');
|
|
487
|
+
console.log(chalk.yellow('\n⚠️ Warning: Git operations encountered an error'));
|
|
488
|
+
if (error instanceof Error) {
|
|
489
|
+
// Extract the meaningful part of the error message
|
|
490
|
+
const errorMessage = error.message.split('\n')[0];
|
|
491
|
+
console.log(chalk.gray(` ${errorMessage}`));
|
|
492
|
+
}
|
|
493
|
+
console.log(chalk.yellow(' 💡 You may need to commit and push manually:'));
|
|
494
|
+
console.log(chalk.gray(' git add ui/'));
|
|
495
|
+
console.log(chalk.gray(` git commit -m "Add generated UI for ${appTitle}"`));
|
|
496
|
+
console.log(chalk.gray(' git push'));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/* eslint-enable no-console */
|
|
500
|
+
function createGenerationPrompt(uiSpec, projectDir, mode, projectAnalysis, userMessage) {
|
|
501
|
+
// Mode-specific instructions
|
|
502
|
+
let modeInstructions = '';
|
|
503
|
+
if (mode === 'fresh') {
|
|
504
|
+
modeInstructions = `
|
|
505
|
+
# MODE: Fresh Generation
|
|
506
|
+
|
|
507
|
+
You are creating a NEW React + TypeScript + Vite application from scratch.
|
|
508
|
+
|
|
509
|
+
⚠️ CRITICAL: You MUST generate files. Do not skip file generation thinking the task is unclear.
|
|
510
|
+
|
|
511
|
+
Your task:
|
|
512
|
+
1. Generate ALL required files for a complete working application
|
|
513
|
+
2. Follow the template structure exactly as specified below
|
|
514
|
+
3. Use the Write tool to create each file with its full content
|
|
515
|
+
4. Do not stop until all files are created
|
|
516
|
+
|
|
517
|
+
${userMessage ? `\n# ADDITIONAL REQUIREMENT\n\nAfter generating the complete base application, also implement this:\n${userMessage}` : ''}
|
|
518
|
+
|
|
519
|
+
IMPORTANT: Start creating files immediately. This is a fresh project with no existing files.`;
|
|
520
|
+
}
|
|
521
|
+
else if (mode === 'incremental') {
|
|
522
|
+
modeInstructions = `
|
|
523
|
+
# MODE: Incremental Update
|
|
524
|
+
|
|
525
|
+
An existing UI project was found at: ${projectDir}
|
|
526
|
+
Existing project has ${projectAnalysis.fileCount} files.
|
|
527
|
+
|
|
528
|
+
⚠️ CRITICAL: You MUST take action. Do not complete without making changes.
|
|
529
|
+
|
|
530
|
+
Your task:
|
|
531
|
+
1. Use Read tool to examine the existing project structure (start with package.json)
|
|
532
|
+
2. Compare it with the UI spec below
|
|
533
|
+
3. Identify MISSING files or features based on the spec
|
|
534
|
+
4. Add ONLY the missing files/features using Write tool
|
|
535
|
+
5. If files already exist and are complete, DO NOT regenerate them
|
|
536
|
+
6. Update existing files ONLY if they're missing required features from the spec (use Edit tool)
|
|
537
|
+
|
|
538
|
+
Existing files (showing first 20):
|
|
539
|
+
\`\`\`
|
|
540
|
+
${projectAnalysis.structure}
|
|
541
|
+
\`\`\`
|
|
542
|
+
|
|
543
|
+
IMPORTANT: Read the existing files first, then make necessary additions/updates. Do not complete without doing any work.`;
|
|
544
|
+
}
|
|
545
|
+
else if (mode === 'update') {
|
|
546
|
+
// Check if user message is vague/generic
|
|
547
|
+
const vagueKeywords = ['fix', 'make sure', 'properly', 'work', 'working', 'issue', 'problem', 'error'];
|
|
548
|
+
const isVagueRequest = vagueKeywords.some(keyword => userMessage === null || userMessage === void 0 ? void 0 : userMessage.toLowerCase().includes(keyword));
|
|
549
|
+
modeInstructions = `
|
|
550
|
+
# MODE: User-Directed Update
|
|
551
|
+
|
|
552
|
+
An existing UI project was found at: ${projectDir}
|
|
553
|
+
Existing project has ${projectAnalysis.fileCount} files.
|
|
554
|
+
|
|
555
|
+
# USER REQUEST:
|
|
556
|
+
${userMessage}
|
|
557
|
+
|
|
558
|
+
Your task:
|
|
559
|
+
1. Use Read tool to examine relevant existing files (start with package.json, key config files, and entry points)
|
|
560
|
+
2. Understand the user's request: "${userMessage}"
|
|
561
|
+
${isVagueRequest
|
|
562
|
+
? `
|
|
563
|
+
3. IMPORTANT: The user's request is general/vague. First DIAGNOSE issues:
|
|
564
|
+
- Read package.json to check dependencies and scripts
|
|
565
|
+
- Read configuration files (vite.config.ts, tsconfig.json, .env)
|
|
566
|
+
- Read main entry point (src/main.tsx, src/App.tsx)
|
|
567
|
+
- Look for common issues: missing dependencies, incorrect imports, configuration errors
|
|
568
|
+
- Check if the project structure matches the UI spec
|
|
569
|
+
- Identify any incomplete features or broken components
|
|
570
|
+
4. Once you've identified specific issues, FIX them:
|
|
571
|
+
- Add missing dependencies to package.json
|
|
572
|
+
- Fix incorrect imports or paths
|
|
573
|
+
- Complete incomplete features based on the UI spec
|
|
574
|
+
- Fix configuration issues
|
|
575
|
+
- Ensure all required files exist and are properly implemented`
|
|
576
|
+
: `
|
|
577
|
+
3. Make TARGETED changes to implement the request
|
|
578
|
+
4. Modify existing files as needed using Edit tool
|
|
579
|
+
5. Add new files only if necessary
|
|
580
|
+
6. Test that your changes work with the existing codebase`}
|
|
581
|
+
|
|
582
|
+
Existing files (showing first 20):
|
|
583
|
+
\`\`\`
|
|
584
|
+
${projectAnalysis.structure}
|
|
585
|
+
\`\`\`
|
|
586
|
+
|
|
587
|
+
${isVagueRequest ? 'Start by reading and diagnosing, then fix the issues you find.' : "Focus on the user's specific request. Be surgical - only change what's necessary."}`;
|
|
588
|
+
}
|
|
589
|
+
return `
|
|
590
|
+
You are a UI generation agent for creating React + TypeScript + Tailwind applications from AgentLang specs.
|
|
591
|
+
|
|
592
|
+
${modeInstructions}
|
|
593
|
+
|
|
594
|
+
# UI SPEC
|
|
595
|
+
\`\`\`json
|
|
596
|
+
${JSON.stringify(uiSpec, null, 2)}
|
|
597
|
+
\`\`\`
|
|
598
|
+
|
|
599
|
+
# GOAL
|
|
600
|
+
Generate a complete, working, polished React admin UI that looks deployment-ready.
|
|
601
|
+
|
|
602
|
+
# APPLICATION STRUCTURE
|
|
603
|
+
|
|
604
|
+
**Navigation (Sidebar):**
|
|
605
|
+
- **Home Button** → Dashboard page
|
|
606
|
+
- **Entities Section** → List of all entities
|
|
607
|
+
* Click entity → Entity list page
|
|
608
|
+
* Click instance → Entity detail page (shows relationships if any)
|
|
609
|
+
- **Workflows Section** → List of all workflows
|
|
610
|
+
* Click workflow → Opens workflow dialog
|
|
611
|
+
|
|
612
|
+
**Dashboard:**
|
|
613
|
+
- Stat cards (entity counts)
|
|
614
|
+
- Quick Actions (workflow cards)
|
|
615
|
+
- Recent activity/status
|
|
616
|
+
|
|
617
|
+
**Entity Detail Page (when entity has relationships):**
|
|
618
|
+
- Entity details at top
|
|
619
|
+
- Embedded relationship tables below
|
|
620
|
+
- Each relationship table has Search + Create button
|
|
621
|
+
|
|
622
|
+
**Chatbot Bubble (floating, bottom-right):**
|
|
623
|
+
- Click to open chat panel
|
|
624
|
+
- Agent dropdown INSIDE chat panel header
|
|
625
|
+
- Chat interface with message history
|
|
626
|
+
|
|
627
|
+
# PHASE 1: GENERATE ALL FILES
|
|
628
|
+
|
|
629
|
+
## Step 1: Project Setup
|
|
630
|
+
1. Create package.json with dependencies:
|
|
631
|
+
- react, react-dom, react-router-dom
|
|
632
|
+
- typescript, @types/react, @types/react-dom
|
|
633
|
+
- vite, @vitejs/plugin-react
|
|
634
|
+
- tailwindcss, postcss, autoprefixer
|
|
635
|
+
- @iconify/react, formik, yup
|
|
636
|
+
|
|
637
|
+
2. Create configuration files:
|
|
638
|
+
- tsconfig.json (strict mode)
|
|
639
|
+
- vite.config.ts (standard React setup)
|
|
640
|
+
- tailwind.config.js (with content paths)
|
|
641
|
+
- postcss.config.js
|
|
642
|
+
- index.html
|
|
643
|
+
|
|
644
|
+
3. Create **.env** file:
|
|
645
|
+
\`\`\`env
|
|
646
|
+
# Backend API URL (AgentLang server)
|
|
647
|
+
VITE_BACKEND_URL=http://localhost:8080/
|
|
648
|
+
|
|
649
|
+
# Mock data mode - set to true by default so app works without backend
|
|
650
|
+
VITE_USE_MOCK_DATA=true
|
|
651
|
+
|
|
652
|
+
# Agent chat is backend-powered - no LLM keys in frontend!
|
|
653
|
+
# Keys are configured in backend only
|
|
654
|
+
\`\`\`
|
|
655
|
+
|
|
656
|
+
Also create **.env.example** with same structure (without sensitive values).
|
|
657
|
+
|
|
658
|
+
## Step 2: API Client Setup
|
|
659
|
+
|
|
660
|
+
Create **src/api/client.ts**:
|
|
661
|
+
\`\`\`typescript
|
|
662
|
+
const API_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
|
|
663
|
+
const USE_MOCK = import.meta.env.VITE_USE_MOCK_DATA === 'true';
|
|
664
|
+
|
|
665
|
+
export const apiClient = {
|
|
666
|
+
baseURL: API_URL,
|
|
667
|
+
useMock: USE_MOCK
|
|
668
|
+
};
|
|
669
|
+
\`\`\`
|
|
670
|
+
|
|
671
|
+
Create **src/api/endpoints.ts** - AgentLang API patterns:
|
|
672
|
+
\`\`\`typescript
|
|
673
|
+
// Authentication endpoints (special)
|
|
674
|
+
export const authEndpoints = {
|
|
675
|
+
login: '/agentlang.auth/login', // POST { email, password }
|
|
676
|
+
signUp: '/agentlang.auth/signUp', // POST { email, password, name }
|
|
677
|
+
forgotPassword: '/agentlang.auth/forgotPassword' // POST { email }
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
// Entity CRUD endpoints (dynamic)
|
|
681
|
+
export const entityEndpoints = {
|
|
682
|
+
list: (model: string, entity: string) => \`/\${model}/\${entity}\`, // GET
|
|
683
|
+
get: (model: string, entity: string, id: string) => \`/\${model}/\${entity}/\${id}\`, // GET
|
|
684
|
+
create: (model: string, entity: string) => \`/\${model}/\${entity}\`, // POST
|
|
685
|
+
update: (model: string, entity: string, id: string) => \`/\${model}/\${entity}/\${id}\`, // PUT
|
|
686
|
+
delete: (model: string, entity: string, id: string) => \`/\${model}/\${entity}/\${id}\` // DELETE
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// Workflow endpoints (dynamic)
|
|
690
|
+
export const workflowEndpoints = {
|
|
691
|
+
execute: (model: string, workflow: string) => \`/\${model}/\${workflow}\` // POST
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
// Agent chat endpoint
|
|
695
|
+
export const agentEndpoints = {
|
|
696
|
+
chat: (agentName: string) => \`/agents/\${agentName}/chat\` // POST (backend handles LLM)
|
|
697
|
+
};
|
|
698
|
+
\`\`\`
|
|
699
|
+
|
|
700
|
+
**Examples:**
|
|
701
|
+
- Create customer: \`POST /CarDealership/Customer\` with \`{ name: "John", contactDetails: "john@email.com" }\`
|
|
702
|
+
- Get all dealers: \`GET /CarDealership/Dealer\`
|
|
703
|
+
- Execute workflow: \`POST /CarDealership/ProcessSale\` with workflow inputs
|
|
704
|
+
- Login: \`POST /agentlang.auth/login\` with \`{ email: "...", password: "..." }\`
|
|
705
|
+
|
|
706
|
+
## Step 3: Mock Data Layer
|
|
707
|
+
|
|
708
|
+
Create **src/data/mockData.ts**:
|
|
709
|
+
\`\`\`typescript
|
|
710
|
+
// Generate mock data for ALL entities in the spec
|
|
711
|
+
export const mockData = {
|
|
712
|
+
'ModelName/EntityName': [
|
|
713
|
+
{ id: '1', field1: 'value', field2: 'value', ... },
|
|
714
|
+
{ id: '2', ...},
|
|
715
|
+
// At least 3-5 records per entity
|
|
716
|
+
],
|
|
717
|
+
// ... all other entities
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// Mock users for authentication (CRITICAL for login/signup to work!)
|
|
721
|
+
export const mockUsers = [
|
|
722
|
+
{
|
|
723
|
+
id: '1',
|
|
724
|
+
email: 'admin@example.com',
|
|
725
|
+
password: 'admin123',
|
|
726
|
+
name: 'Admin User',
|
|
727
|
+
role: 'admin',
|
|
728
|
+
token: 'mock-token-admin-123'
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
id: '2',
|
|
732
|
+
email: 'user@example.com',
|
|
733
|
+
password: 'user123',
|
|
734
|
+
name: 'Demo User',
|
|
735
|
+
role: 'user',
|
|
736
|
+
token: 'mock-token-user-456'
|
|
737
|
+
}
|
|
738
|
+
];
|
|
739
|
+
|
|
740
|
+
// Mock API following AgentLang patterns
|
|
741
|
+
export const mockApi = {
|
|
742
|
+
// Authentication endpoints
|
|
743
|
+
async login(email: string, password: string) {
|
|
744
|
+
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate network
|
|
745
|
+
const user = mockUsers.find(u => u.email === email && u.password === password);
|
|
746
|
+
if (!user) return { error: 'Invalid credentials', status: 'error' };
|
|
747
|
+
return {
|
|
748
|
+
data: {
|
|
749
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
750
|
+
token: user.token
|
|
751
|
+
},
|
|
752
|
+
status: 'success'
|
|
753
|
+
};
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
async signUp(email: string, password: string, name: string) {
|
|
757
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
758
|
+
// Check if user already exists
|
|
759
|
+
if (mockUsers.find(u => u.email === email)) {
|
|
760
|
+
return { error: 'User already exists', status: 'error' };
|
|
761
|
+
}
|
|
762
|
+
const newUser = {
|
|
763
|
+
id: Date.now().toString(),
|
|
764
|
+
email,
|
|
765
|
+
password,
|
|
766
|
+
name,
|
|
767
|
+
role: 'user',
|
|
768
|
+
token: \`mock-token-\${Date.now()}\`
|
|
769
|
+
};
|
|
770
|
+
mockUsers.push(newUser);
|
|
771
|
+
return {
|
|
772
|
+
data: {
|
|
773
|
+
user: { id: newUser.id, email: newUser.email, name: newUser.name, role: newUser.role },
|
|
774
|
+
token: newUser.token
|
|
775
|
+
},
|
|
776
|
+
status: 'success'
|
|
777
|
+
};
|
|
778
|
+
},
|
|
779
|
+
|
|
780
|
+
async forgotPassword(email: string) {
|
|
781
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
782
|
+
const user = mockUsers.find(u => u.email === email);
|
|
783
|
+
if (!user) return { error: 'User not found', status: 'error' };
|
|
784
|
+
return { data: { message: 'Password reset email sent' }, status: 'success' };
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
// Entity CRUD endpoints
|
|
788
|
+
async list(model: string, entity: string) {
|
|
789
|
+
const key = \`\${model}/\${entity}\`;
|
|
790
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
791
|
+
return { data: mockData[key] || [], status: 'success' };
|
|
792
|
+
},
|
|
793
|
+
|
|
794
|
+
async get(model: string, entity: string, id: string) {
|
|
795
|
+
const key = \`\${model}/\${entity}\`;
|
|
796
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
797
|
+
const item = (mockData[key] || []).find(i => i.id === id);
|
|
798
|
+
if (!item) return { error: 'Not found', status: 'error' };
|
|
799
|
+
return { data: item, status: 'success' };
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
async create(model: string, entity: string, data: any) {
|
|
803
|
+
const key = \`\${model}/\${entity}\`;
|
|
804
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
805
|
+
const newItem = { id: Date.now().toString(), ...data };
|
|
806
|
+
if (!mockData[key]) mockData[key] = [];
|
|
807
|
+
mockData[key].push(newItem);
|
|
808
|
+
return { data: newItem, status: 'success' };
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
async update(model: string, entity: string, id: string, data: any) {
|
|
812
|
+
const key = \`\${model}/\${entity}\`;
|
|
813
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
814
|
+
const index = (mockData[key] || []).findIndex(i => i.id === id);
|
|
815
|
+
if (index === -1) return { error: 'Not found', status: 'error' };
|
|
816
|
+
mockData[key][index] = { ...mockData[key][index], ...data };
|
|
817
|
+
return { data: mockData[key][index], status: 'success' };
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
async delete(model: string, entity: string, id: string) {
|
|
821
|
+
const key = \`\${model}/\${entity}\`;
|
|
822
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
823
|
+
mockData[key] = (mockData[key] || []).filter(i => i.id !== id);
|
|
824
|
+
return { status: 'success' };
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
// Workflow execution endpoint
|
|
828
|
+
async executeWorkflow(model: string, workflowName: string, inputs: any) {
|
|
829
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
830
|
+
console.log(\`Executing workflow: \${model}/\${workflowName}\`, inputs);
|
|
831
|
+
// Mock successful execution
|
|
832
|
+
return { status: 'success', data: { message: 'Workflow executed successfully' } };
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
\`\`\`
|
|
836
|
+
|
|
837
|
+
## Step 4: Core Utilities
|
|
838
|
+
|
|
839
|
+
Create **src/data/uiSpec.ts** - export the UI spec
|
|
840
|
+
|
|
841
|
+
Create **src/utils/specParser.ts**:
|
|
842
|
+
\`\`\`typescript
|
|
843
|
+
export function getFormSpec(entityName: string) {
|
|
844
|
+
return spec[\`\${entityName}.ui.form\`];
|
|
845
|
+
}
|
|
846
|
+
export function getDashboardSpec(entityName: string) {
|
|
847
|
+
return spec[\`\${entityName}.ui.dashboard\`];
|
|
848
|
+
}
|
|
849
|
+
export function getInstanceSpec(entityName: string) {
|
|
850
|
+
return spec[\`\${entityName}.ui.instance\`];
|
|
851
|
+
}
|
|
852
|
+
export function getChildRelationships(parentEntity: string) {
|
|
853
|
+
return spec.relationships?.filter(r => r.parent === parentEntity) || [];
|
|
854
|
+
}
|
|
855
|
+
\`\`\`
|
|
856
|
+
|
|
857
|
+
Create **src/utils/workflowParser.ts**:
|
|
858
|
+
\`\`\`typescript
|
|
859
|
+
export function getWorkflows(spec: any) {
|
|
860
|
+
return (spec.workflows || []).map(name => ({
|
|
861
|
+
name,
|
|
862
|
+
displayName: spec[name].displayName,
|
|
863
|
+
description: spec[name].description,
|
|
864
|
+
icon: spec[name].icon,
|
|
865
|
+
ui: spec[\`\${name}.ui\`],
|
|
866
|
+
inputs: spec[\`\${name}.inputs\`] || {}
|
|
867
|
+
}));
|
|
868
|
+
}
|
|
869
|
+
\`\`\`
|
|
870
|
+
|
|
871
|
+
Create **src/hooks/useEntityData.ts** - CRUD hook with defensive patterns:
|
|
872
|
+
\`\`\`typescript
|
|
873
|
+
export function useEntityData(model: string, entity: string) {
|
|
874
|
+
const [data, setData] = useState<any[]>([]);
|
|
875
|
+
const [loading, setLoading] = useState(false);
|
|
876
|
+
const [error, setError] = useState<string | null>(null);
|
|
877
|
+
|
|
878
|
+
const fetchData = async () => {
|
|
879
|
+
setLoading(true);
|
|
880
|
+
try {
|
|
881
|
+
const useMock = import.meta.env.VITE_USE_MOCK_DATA === 'true';
|
|
882
|
+
const result = useMock
|
|
883
|
+
? await mockApi.list(model, entity)
|
|
884
|
+
: await fetch(\`\${API_URL}/\${model}/\${entity}\`).then(r => r.json());
|
|
885
|
+
|
|
886
|
+
// DEFENSIVE: Always ensure array
|
|
887
|
+
setData(Array.isArray(result.data) ? result.data : []);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
setError(err.message);
|
|
890
|
+
setData([]); // DEFENSIVE: Empty array on error
|
|
891
|
+
} finally {
|
|
892
|
+
setLoading(false);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
data: Array.isArray(data) ? data : [], // DEFENSIVE: Always return array
|
|
898
|
+
loading,
|
|
899
|
+
error,
|
|
900
|
+
fetchData
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
\`\`\`
|
|
904
|
+
|
|
905
|
+
## Step 5: Dynamic Components
|
|
906
|
+
|
|
907
|
+
Create **src/components/dynamic/DynamicTable.tsx** - Reusable table with row actions:
|
|
908
|
+
|
|
909
|
+
**CRITICAL - Must include Actions column:**
|
|
910
|
+
\`\`\`typescript
|
|
911
|
+
export function DynamicTable({ data, spec, onRowClick, onCreateClick, onEdit, onDelete, showCreateButton = true }: Props) {
|
|
912
|
+
// DEFENSIVE: Always validate array first
|
|
913
|
+
const safeData = Array.isArray(data) ? data : [];
|
|
914
|
+
|
|
915
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
916
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
917
|
+
const [pageSize, setPageSize] = useState(25);
|
|
918
|
+
|
|
919
|
+
// DEFENSIVE: Filter safely
|
|
920
|
+
const filteredData = useMemo(() => {
|
|
921
|
+
if (!searchTerm) return safeData;
|
|
922
|
+
return safeData.filter(item =>
|
|
923
|
+
Object.values(item || {}).some(v =>
|
|
924
|
+
String(v || '').toLowerCase().includes(searchTerm.toLowerCase())
|
|
925
|
+
)
|
|
926
|
+
);
|
|
927
|
+
}, [safeData, searchTerm]);
|
|
928
|
+
|
|
929
|
+
// DEFENSIVE: Always check before spreading
|
|
930
|
+
const sortedData = useMemo(() => {
|
|
931
|
+
if (!Array.isArray(filteredData)) return [];
|
|
932
|
+
return [...filteredData];
|
|
933
|
+
}, [filteredData]);
|
|
934
|
+
|
|
935
|
+
const paginatedData = useMemo(() => {
|
|
936
|
+
if (!Array.isArray(sortedData)) return [];
|
|
937
|
+
const start = (currentPage - 1) * pageSize;
|
|
938
|
+
return sortedData.slice(start, start + pageSize);
|
|
939
|
+
}, [sortedData, currentPage, pageSize]);
|
|
940
|
+
|
|
941
|
+
if (safeData.length === 0) {
|
|
942
|
+
return (
|
|
943
|
+
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
|
944
|
+
<Icon icon="mdi:database-off" className="text-4xl text-gray-400 mb-2" />
|
|
945
|
+
<p className="text-gray-600">No data available</p>
|
|
946
|
+
</div>
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return (
|
|
951
|
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
952
|
+
{/* Header with Search + Create Button TOGETHER on right */}
|
|
953
|
+
<div className="flex justify-between items-center p-4 border-b border-gray-200">
|
|
954
|
+
<h2 className="text-xl font-semibold text-gray-900">{spec.title}</h2>
|
|
955
|
+
|
|
956
|
+
{/* Search and Create together on the right */}
|
|
957
|
+
<div className="flex gap-3 items-center">
|
|
958
|
+
<div className="relative">
|
|
959
|
+
<Icon icon="mdi:magnify" className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
960
|
+
<input
|
|
961
|
+
type="text"
|
|
962
|
+
placeholder="Search..."
|
|
963
|
+
value={searchTerm}
|
|
964
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
965
|
+
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg w-64 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
966
|
+
/>
|
|
967
|
+
</div>
|
|
968
|
+
{showCreateButton && (
|
|
969
|
+
<button
|
|
970
|
+
onClick={onCreateClick}
|
|
971
|
+
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
|
972
|
+
>
|
|
973
|
+
<Icon icon="mdi:plus" />
|
|
974
|
+
<span>Create</span>
|
|
975
|
+
</button>
|
|
976
|
+
)}
|
|
977
|
+
</div>
|
|
978
|
+
</div>
|
|
979
|
+
|
|
980
|
+
{/* Table */}
|
|
981
|
+
<div className="overflow-x-auto">
|
|
982
|
+
<table className="w-full">
|
|
983
|
+
<thead className="bg-gray-50 border-b border-gray-200">
|
|
984
|
+
<tr>
|
|
985
|
+
{spec.columns.map(col => (
|
|
986
|
+
<th key={col.key} className="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
|
987
|
+
{col.label}
|
|
988
|
+
</th>
|
|
989
|
+
))}
|
|
990
|
+
{/* CRITICAL: Actions column header */}
|
|
991
|
+
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
|
992
|
+
Actions
|
|
993
|
+
</th>
|
|
994
|
+
</tr>
|
|
995
|
+
</thead>
|
|
996
|
+
<tbody className="divide-y divide-gray-200">
|
|
997
|
+
{paginatedData.map((row, idx) => (
|
|
998
|
+
<tr key={row.id || idx} className="hover:bg-gray-50 transition-colors">
|
|
999
|
+
{spec.columns.map(col => (
|
|
1000
|
+
<td
|
|
1001
|
+
key={col.key}
|
|
1002
|
+
onClick={() => onRowClick(row)}
|
|
1003
|
+
className="px-6 py-4 text-sm text-gray-900 cursor-pointer"
|
|
1004
|
+
>
|
|
1005
|
+
{row[col.key]}
|
|
1006
|
+
</td>
|
|
1007
|
+
))}
|
|
1008
|
+
{/* CRITICAL: Actions column with Edit and Delete buttons */}
|
|
1009
|
+
<td className="px-6 py-4 text-right whitespace-nowrap">
|
|
1010
|
+
<div className="flex justify-end gap-2">
|
|
1011
|
+
<button
|
|
1012
|
+
onClick={(e) => {
|
|
1013
|
+
e.stopPropagation();
|
|
1014
|
+
onEdit(row);
|
|
1015
|
+
}}
|
|
1016
|
+
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
1017
|
+
title="Edit"
|
|
1018
|
+
>
|
|
1019
|
+
<Icon icon="mdi:pencil" className="text-lg" />
|
|
1020
|
+
</button>
|
|
1021
|
+
<button
|
|
1022
|
+
onClick={(e) => {
|
|
1023
|
+
e.stopPropagation();
|
|
1024
|
+
onDelete(row);
|
|
1025
|
+
}}
|
|
1026
|
+
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
1027
|
+
title="Delete"
|
|
1028
|
+
>
|
|
1029
|
+
<Icon icon="mdi:delete" className="text-lg" />
|
|
1030
|
+
</button>
|
|
1031
|
+
</div>
|
|
1032
|
+
</td>
|
|
1033
|
+
</tr>
|
|
1034
|
+
))}
|
|
1035
|
+
</tbody>
|
|
1036
|
+
</table>
|
|
1037
|
+
</div>
|
|
1038
|
+
|
|
1039
|
+
{/* Pagination */}
|
|
1040
|
+
<div className="flex justify-between items-center px-6 py-4 border-t border-gray-200 bg-gray-50">
|
|
1041
|
+
<div className="text-sm text-gray-700">
|
|
1042
|
+
Showing <span className="font-medium">{Math.min((currentPage - 1) * pageSize + 1, sortedData.length)}</span> to{' '}
|
|
1043
|
+
<span className="font-medium">{Math.min(currentPage * pageSize, sortedData.length)}</span> of{' '}
|
|
1044
|
+
<span className="font-medium">{sortedData.length}</span> results
|
|
1045
|
+
</div>
|
|
1046
|
+
<div className="flex gap-2">
|
|
1047
|
+
<button
|
|
1048
|
+
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
1049
|
+
disabled={currentPage === 1}
|
|
1050
|
+
className="px-3 py-1 border border-gray-300 rounded-md hover:bg-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1051
|
+
>
|
|
1052
|
+
Previous
|
|
1053
|
+
</button>
|
|
1054
|
+
<button
|
|
1055
|
+
onClick={() => setCurrentPage(p => p + 1)}
|
|
1056
|
+
disabled={currentPage * pageSize >= sortedData.length}
|
|
1057
|
+
className="px-3 py-1 border border-gray-300 rounded-md hover:bg-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1058
|
+
>
|
|
1059
|
+
Next
|
|
1060
|
+
</button>
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
</div>
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
\`\`\`
|
|
1067
|
+
|
|
1068
|
+
**CRITICAL Requirements:**
|
|
1069
|
+
1. **Header Layout**: Title on left, Search + Create button on right (TOGETHER)
|
|
1070
|
+
2. **Actions Column**: MUST have "Actions" column at the end with Edit and Delete icon buttons
|
|
1071
|
+
3. **Edit Button**: Blue pencil icon (mdi:pencil), opens edit form dialog
|
|
1072
|
+
4. **Delete Button**: Red trash icon (mdi:delete), shows confirmation dialog then deletes
|
|
1073
|
+
5. **Stop Propagation**: Action buttons must call \`e.stopPropagation()\` to prevent row click
|
|
1074
|
+
6. **Search Input**: Has search icon, placeholder "Search...", 256px width
|
|
1075
|
+
7. **Empty State**: Show nice empty state with icon and message
|
|
1076
|
+
8. **Pagination**: Show count and prev/next buttons
|
|
1077
|
+
9. **Defensive**: Always check \`Array.isArray(data)\` before operations
|
|
1078
|
+
|
|
1079
|
+
Create **src/components/dynamic/DynamicForm.tsx** - Standard form with Formik
|
|
1080
|
+
Create **src/components/dynamic/DynamicCard.tsx** - Card layout for entity details
|
|
1081
|
+
Create **src/components/dynamic/ComponentResolver.tsx** - Resolves spec references to components
|
|
1082
|
+
|
|
1083
|
+
## Step 6: Entity Components
|
|
1084
|
+
|
|
1085
|
+
Create **src/components/entity/EntityList.tsx** - Uses DynamicTable to show entities
|
|
1086
|
+
|
|
1087
|
+
Create **src/components/entity/EntityDetail.tsx** - Detail page with embedded relationship tables:
|
|
1088
|
+
|
|
1089
|
+
**CRITICAL - Structure:**
|
|
1090
|
+
\`\`\`typescript
|
|
1091
|
+
export function EntityDetail() {
|
|
1092
|
+
const { model, entity, id } = useParams();
|
|
1093
|
+
const { data: item, loading } = useEntityDetail(model, entity, id);
|
|
1094
|
+
|
|
1095
|
+
// Get child relationships for this entity
|
|
1096
|
+
const relationships = getChildRelationships(\`\${model}/\${entity}\`);
|
|
1097
|
+
|
|
1098
|
+
return (
|
|
1099
|
+
<div className="space-y-6 p-6">
|
|
1100
|
+
{/* Entity Instance Details Card */}
|
|
1101
|
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
1102
|
+
<h2 className="text-2xl font-bold text-gray-900 mb-4">{entity} Details</h2>
|
|
1103
|
+
<DynamicCard data={item} spec={getInstanceSpec(\`\${model}/\${entity}\`)} />
|
|
1104
|
+
</div>
|
|
1105
|
+
|
|
1106
|
+
{/* Child Relationships - EMBEDDED TABLES */}
|
|
1107
|
+
{relationships.map(rel => {
|
|
1108
|
+
const childData = useRelationshipData(rel, item.id);
|
|
1109
|
+
return (
|
|
1110
|
+
<div key={rel.name} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
1111
|
+
<h3 className="text-xl font-semibold text-gray-900 mb-4">{rel.displayName}</h3>
|
|
1112
|
+
|
|
1113
|
+
{/* CRITICAL: DynamicTable with Create button */}
|
|
1114
|
+
<DynamicTable
|
|
1115
|
+
data={childData} {/* NEVER JSON.stringify! Must be array */}
|
|
1116
|
+
spec={getDashboardSpec(rel.child)}
|
|
1117
|
+
onRowClick={(child) => navigate(\`/\${rel.child}/\${child.id}\`)}
|
|
1118
|
+
onCreateClick={() => openCreateDialog(rel.child)}
|
|
1119
|
+
showCreateButton={true} {/* CRITICAL: Show create button beside search */}
|
|
1120
|
+
/>
|
|
1121
|
+
</div>
|
|
1122
|
+
);
|
|
1123
|
+
})}
|
|
1124
|
+
</div>
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
\`\`\`
|
|
1128
|
+
|
|
1129
|
+
**CRITICAL Requirements:**
|
|
1130
|
+
1. **Entity Details**: Show in card at top with field labels and values
|
|
1131
|
+
2. **Relationship Tables**: Each relationship shown as DynamicTable (NOT JSON)
|
|
1132
|
+
3. **Create Button**: MUST appear beside search input in EACH relationship table
|
|
1133
|
+
- Position: Top-right, next to search input
|
|
1134
|
+
- Label: "Add {RelationshipName}" or "Create"
|
|
1135
|
+
- Opens form dialog to create new child record
|
|
1136
|
+
4. **Click Behavior**: Clicking row navigates to child detail page
|
|
1137
|
+
5. **Spacing**: Each section in separate white card with border
|
|
1138
|
+
6. **No Relationships?**: If entity has no relationships, only show details card
|
|
1139
|
+
|
|
1140
|
+
## Step 7: Workflows
|
|
1141
|
+
|
|
1142
|
+
Create **src/components/workflows/WorkflowDialog.tsx** - Modal dialog for workflow execution:
|
|
1143
|
+
|
|
1144
|
+
**CRITICAL - Must read form fields from spec:**
|
|
1145
|
+
\`\`\`typescript
|
|
1146
|
+
interface WorkflowDialogProps {
|
|
1147
|
+
workflow: WorkflowInfo; // Has name, displayName, description, icon, inputs
|
|
1148
|
+
onClose: () => void;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export function WorkflowDialog({ workflow, onClose }: WorkflowDialogProps) {
|
|
1152
|
+
const [formData, setFormData] = useState({});
|
|
1153
|
+
const [loading, setLoading] = useState(false);
|
|
1154
|
+
const [error, setError] = useState(null);
|
|
1155
|
+
|
|
1156
|
+
// CRITICAL: Read input fields from spec["WorkflowName.inputs"]
|
|
1157
|
+
const inputFields = workflow.inputs || {};
|
|
1158
|
+
const fieldNames = Object.keys(inputFields);
|
|
1159
|
+
|
|
1160
|
+
const handleSubmit = async (e) => {
|
|
1161
|
+
e.preventDefault();
|
|
1162
|
+
setLoading(true);
|
|
1163
|
+
|
|
1164
|
+
try {
|
|
1165
|
+
// Extract model and workflow name
|
|
1166
|
+
const [model, workflowName] = workflow.name.split('/');
|
|
1167
|
+
|
|
1168
|
+
// Submit to workflow endpoint
|
|
1169
|
+
const useMock = import.meta.env.VITE_USE_MOCK_DATA === 'true';
|
|
1170
|
+
const result = useMock
|
|
1171
|
+
? await mockApi.executeWorkflow(model, workflowName, formData)
|
|
1172
|
+
: await fetch(\`\${API_URL}/\${model}/\${workflowName}\`, {
|
|
1173
|
+
method: 'POST',
|
|
1174
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1175
|
+
body: JSON.stringify(formData)
|
|
1176
|
+
}).then(r => r.json());
|
|
1177
|
+
|
|
1178
|
+
if (result.status === 'success') {
|
|
1179
|
+
// Show success toast
|
|
1180
|
+
toast.success('Workflow executed successfully!');
|
|
1181
|
+
onClose();
|
|
1182
|
+
} else {
|
|
1183
|
+
setError(result.error || 'Workflow execution failed');
|
|
1184
|
+
}
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
setError(err.message);
|
|
1187
|
+
} finally {
|
|
1188
|
+
setLoading(false);
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
return (
|
|
1193
|
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
1194
|
+
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
1195
|
+
{/* Header */}
|
|
1196
|
+
<div className="flex justify-between items-center p-6 border-b">
|
|
1197
|
+
<div className="flex items-center gap-3">
|
|
1198
|
+
<Icon icon={workflow.icon || 'mdi:lightning-bolt'} className="text-3xl text-blue-600" />
|
|
1199
|
+
<div>
|
|
1200
|
+
<h2 className="text-2xl font-bold text-gray-900">{workflow.displayName}</h2>
|
|
1201
|
+
<p className="text-sm text-gray-600">{workflow.description}</p>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
1205
|
+
<Icon icon="mdi:close" className="text-2xl" />
|
|
1206
|
+
</button>
|
|
1207
|
+
</div>
|
|
1208
|
+
|
|
1209
|
+
{/* Form */}
|
|
1210
|
+
<form onSubmit={handleSubmit} className="p-6">
|
|
1211
|
+
<div className="space-y-4">
|
|
1212
|
+
{fieldNames.map(fieldName => {
|
|
1213
|
+
const field = inputFields[fieldName];
|
|
1214
|
+
return (
|
|
1215
|
+
<div key={fieldName}>
|
|
1216
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
1217
|
+
{field.label || fieldName}
|
|
1218
|
+
{field.required && <span className="text-red-600">*</span>}
|
|
1219
|
+
</label>
|
|
1220
|
+
|
|
1221
|
+
{/* Render input based on field type */}
|
|
1222
|
+
{field.inputType === 'select' || field.dataSource ? (
|
|
1223
|
+
<select
|
|
1224
|
+
required={field.required}
|
|
1225
|
+
value={formData[fieldName] || ''}
|
|
1226
|
+
onChange={(e) => setFormData({ ...formData, [fieldName]: e.target.value })}
|
|
1227
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
1228
|
+
>
|
|
1229
|
+
<option value="">Select...</option>
|
|
1230
|
+
{/* Options from field.dataSource or field.options */}
|
|
1231
|
+
</select>
|
|
1232
|
+
) : field.inputType === 'textarea' ? (
|
|
1233
|
+
<textarea
|
|
1234
|
+
required={field.required}
|
|
1235
|
+
value={formData[fieldName] || ''}
|
|
1236
|
+
onChange={(e) => setFormData({ ...formData, [fieldName]: e.target.value })}
|
|
1237
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
1238
|
+
rows={3}
|
|
1239
|
+
/>
|
|
1240
|
+
) : (
|
|
1241
|
+
<input
|
|
1242
|
+
type={field.inputType || 'text'}
|
|
1243
|
+
required={field.required}
|
|
1244
|
+
value={formData[fieldName] || ''}
|
|
1245
|
+
onChange={(e) => setFormData({ ...formData, [fieldName]: e.target.value })}
|
|
1246
|
+
placeholder={field.placeholder}
|
|
1247
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
1248
|
+
/>
|
|
1249
|
+
)}
|
|
1250
|
+
|
|
1251
|
+
{field.helpText && (
|
|
1252
|
+
<p className="text-xs text-gray-500 mt-1">{field.helpText}</p>
|
|
1253
|
+
)}
|
|
1254
|
+
</div>
|
|
1255
|
+
);
|
|
1256
|
+
})}
|
|
1257
|
+
</div>
|
|
1258
|
+
|
|
1259
|
+
{error && (
|
|
1260
|
+
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
|
1261
|
+
{error}
|
|
1262
|
+
</div>
|
|
1263
|
+
)}
|
|
1264
|
+
|
|
1265
|
+
{/* Actions */}
|
|
1266
|
+
<div className="flex justify-end gap-3 mt-6">
|
|
1267
|
+
<button
|
|
1268
|
+
type="button"
|
|
1269
|
+
onClick={onClose}
|
|
1270
|
+
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
1271
|
+
>
|
|
1272
|
+
Cancel
|
|
1273
|
+
</button>
|
|
1274
|
+
<button
|
|
1275
|
+
type="submit"
|
|
1276
|
+
disabled={loading}
|
|
1277
|
+
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
|
|
1278
|
+
>
|
|
1279
|
+
{loading && <Icon icon="mdi:loading" className="animate-spin" />}
|
|
1280
|
+
Execute
|
|
1281
|
+
</button>
|
|
1282
|
+
</div>
|
|
1283
|
+
</form>
|
|
1284
|
+
</div>
|
|
1285
|
+
</div>
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
\`\`\`
|
|
1289
|
+
|
|
1290
|
+
**CRITICAL Requirements:**
|
|
1291
|
+
1. **Read inputs from spec**: workflow.inputs contains all form fields from spec["\${workflowName}.inputs"]
|
|
1292
|
+
2. **Dynamic form fields**: Render inputs based on field.inputType (text, select, textarea, number, etc.)
|
|
1293
|
+
3. **Required fields**: Show red asterisk, enforce with HTML5 required attribute
|
|
1294
|
+
4. **Field types**:
|
|
1295
|
+
- \`inputType: 'text'\` → text input
|
|
1296
|
+
- \`inputType: 'select'\` or \`dataSource\` → dropdown select
|
|
1297
|
+
- \`inputType: 'textarea'\` → textarea
|
|
1298
|
+
- \`inputType: 'number'\` → number input
|
|
1299
|
+
5. **Submit**: POST to \`/:model/:workflowName\` with form data
|
|
1300
|
+
6. **Show errors**: Display error message if execution fails
|
|
1301
|
+
7. **Success**: Show success toast and close dialog
|
|
1302
|
+
8. **Loading state**: Disable submit button, show spinner while executing
|
|
1303
|
+
|
|
1304
|
+
Create **src/components/dashboard/QuickActions.tsx** - Workflow cards for dashboard:
|
|
1305
|
+
\`\`\`typescript
|
|
1306
|
+
import { getWorkflows } from '@/utils/workflowParser';
|
|
1307
|
+
import { uiSpec } from '@/data/uiSpec';
|
|
1308
|
+
import { Icon } from '@iconify/react';
|
|
1309
|
+
import { useState } from 'react';
|
|
1310
|
+
import { WorkflowDialog } from '@/components/workflows/WorkflowDialog';
|
|
1311
|
+
|
|
1312
|
+
export function QuickActions() {
|
|
1313
|
+
// Get workflows that should show on dashboard
|
|
1314
|
+
const workflows = getWorkflows(uiSpec).filter(w => w.ui?.showOnDashboard === true);
|
|
1315
|
+
const [selectedWorkflow, setSelectedWorkflow] = useState(null);
|
|
1316
|
+
|
|
1317
|
+
if (workflows.length === 0) {
|
|
1318
|
+
return (
|
|
1319
|
+
<div className="text-center py-8 text-gray-500">
|
|
1320
|
+
<p>No quick actions configured</p>
|
|
1321
|
+
</div>
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
return (
|
|
1326
|
+
<>
|
|
1327
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
1328
|
+
{workflows.map(workflow => (
|
|
1329
|
+
<button
|
|
1330
|
+
key={workflow.name}
|
|
1331
|
+
onClick={() => setSelectedWorkflow(workflow)}
|
|
1332
|
+
className="p-6 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all text-left bg-white"
|
|
1333
|
+
>
|
|
1334
|
+
<div className="flex items-start gap-4">
|
|
1335
|
+
<div className="bg-blue-50 p-3 rounded-lg">
|
|
1336
|
+
<Icon icon={workflow.icon || 'mdi:lightning-bolt'} className="text-3xl text-blue-600" />
|
|
1337
|
+
</div>
|
|
1338
|
+
<div className="flex-1">
|
|
1339
|
+
<h3 className="font-semibold text-gray-900 mb-1">{workflow.displayName}</h3>
|
|
1340
|
+
<p className="text-sm text-gray-600 line-clamp-2">{workflow.description}</p>
|
|
1341
|
+
</div>
|
|
1342
|
+
</div>
|
|
1343
|
+
</button>
|
|
1344
|
+
))}
|
|
1345
|
+
</div>
|
|
1346
|
+
|
|
1347
|
+
{/* Workflow Dialog */}
|
|
1348
|
+
{selectedWorkflow && (
|
|
1349
|
+
<WorkflowDialog
|
|
1350
|
+
workflow={selectedWorkflow}
|
|
1351
|
+
onClose={() => setSelectedWorkflow(null)}
|
|
1352
|
+
/>
|
|
1353
|
+
)}
|
|
1354
|
+
</>
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
\`\`\`
|
|
1358
|
+
|
|
1359
|
+
**Requirements:**
|
|
1360
|
+
- Filter workflows where \`showOnDashboard === true\`
|
|
1361
|
+
- Grid layout: 3 columns on desktop, 2 on tablet, 1 on mobile
|
|
1362
|
+
- Each card: Icon + displayName + description
|
|
1363
|
+
- Click opens WorkflowDialog
|
|
1364
|
+
- Hover effect: border color change + shadow
|
|
1365
|
+
- Empty state if no workflows configured
|
|
1366
|
+
|
|
1367
|
+
## Step 8: Dashboard Page (CRITICAL - Polished & Deployment-Ready)
|
|
1368
|
+
|
|
1369
|
+
Create **src/pages/Dashboard.tsx** - Professional, polished dashboard:
|
|
1370
|
+
\`\`\`typescript
|
|
1371
|
+
export function Dashboard() {
|
|
1372
|
+
const entities = Object.keys(mockData);
|
|
1373
|
+
|
|
1374
|
+
return (
|
|
1375
|
+
<div className="space-y-8 p-6">
|
|
1376
|
+
{/* Welcome Header */}
|
|
1377
|
+
<div>
|
|
1378
|
+
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
|
1379
|
+
<p className="text-gray-600 mt-1">Welcome back! Here's an overview of your system.</p>
|
|
1380
|
+
</div>
|
|
1381
|
+
|
|
1382
|
+
{/* Stat Cards - Show ALL entity counts with icons */}
|
|
1383
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
1384
|
+
{entities.map(entityKey => {
|
|
1385
|
+
const count = Array.isArray(mockData[entityKey]) ? mockData[entityKey].length : 0;
|
|
1386
|
+
const entityName = entityKey.split('/')[1];
|
|
1387
|
+
return (
|
|
1388
|
+
<div key={entityKey} className="bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow p-6 border border-gray-200">
|
|
1389
|
+
<div className="flex items-center justify-between">
|
|
1390
|
+
<div>
|
|
1391
|
+
<p className="text-sm font-medium text-gray-600">{entityName}</p>
|
|
1392
|
+
<p className="text-3xl font-bold text-gray-900 mt-2">{count}</p>
|
|
1393
|
+
<p className="text-xs text-gray-500 mt-1">Total records</p>
|
|
1394
|
+
</div>
|
|
1395
|
+
<div className="bg-blue-50 p-3 rounded-lg">
|
|
1396
|
+
<Icon icon="mdi:database" className="text-3xl text-blue-600" />
|
|
1397
|
+
</div>
|
|
1398
|
+
</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
);
|
|
1401
|
+
})}
|
|
1402
|
+
</div>
|
|
1403
|
+
|
|
1404
|
+
{/* Workflow Quick Actions Section */}
|
|
1405
|
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
1406
|
+
<div className="mb-6">
|
|
1407
|
+
<h2 className="text-xl font-semibold text-gray-900">Quick Actions</h2>
|
|
1408
|
+
<p className="text-sm text-gray-600 mt-1">Execute workflows directly from the dashboard</p>
|
|
1409
|
+
</div>
|
|
1410
|
+
<QuickActions />
|
|
1411
|
+
</div>
|
|
1412
|
+
|
|
1413
|
+
{/* Recent Activity Section (Optional) */}
|
|
1414
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
1415
|
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
1416
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
|
|
1417
|
+
<div className="space-y-3">
|
|
1418
|
+
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
|
1419
|
+
<Icon icon="mdi:check-circle" className="text-green-600 text-xl" />
|
|
1420
|
+
<div>
|
|
1421
|
+
<p className="text-sm font-medium text-gray-900">System ready</p>
|
|
1422
|
+
<p className="text-xs text-gray-600">All services operational</p>
|
|
1423
|
+
</div>
|
|
1424
|
+
</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
</div>
|
|
1427
|
+
|
|
1428
|
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
1429
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-4">System Status</h3>
|
|
1430
|
+
<div className="space-y-3">
|
|
1431
|
+
<div className="flex justify-between items-center">
|
|
1432
|
+
<span className="text-sm text-gray-600">Database</span>
|
|
1433
|
+
<span className="text-sm font-medium text-green-600">Connected</span>
|
|
1434
|
+
</div>
|
|
1435
|
+
<div className="flex justify-between items-center">
|
|
1436
|
+
<span className="text-sm text-gray-600">Mock Mode</span>
|
|
1437
|
+
<span className="text-sm font-medium text-blue-600">Active</span>
|
|
1438
|
+
</div>
|
|
1439
|
+
</div>
|
|
1440
|
+
</div>
|
|
1441
|
+
</div>
|
|
1442
|
+
</div>
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
\`\`\`
|
|
1446
|
+
|
|
1447
|
+
**Dashboard Requirements:**
|
|
1448
|
+
- **Professional Header**: Welcome message with page title
|
|
1449
|
+
- **Stat Cards**: Show ALL entities with:
|
|
1450
|
+
* Entity count in large, bold text
|
|
1451
|
+
* Icon in colored background circle
|
|
1452
|
+
* "Total records" label
|
|
1453
|
+
* Hover effect with shadow transition
|
|
1454
|
+
- **Quick Actions Section**:
|
|
1455
|
+
* White card with border
|
|
1456
|
+
* Section header with description
|
|
1457
|
+
* Grid of workflow cards (3-4 per row)
|
|
1458
|
+
- **Recent Activity/Status**: Optional cards showing system info
|
|
1459
|
+
- **Spacing**: Generous spacing (32px between sections)
|
|
1460
|
+
- **Colors**: Professional palette (gray-900 text, blue-600 accents, subtle borders)
|
|
1461
|
+
- **Polish**: Shadows, borders, hover effects, icons in colored backgrounds
|
|
1462
|
+
|
|
1463
|
+
## Step 9: Layout & Navigation
|
|
1464
|
+
|
|
1465
|
+
Create **src/components/layout/Sidebar.tsx** - Toggleable sidebar with clear structure:
|
|
1466
|
+
|
|
1467
|
+
**Structure (top to bottom):**
|
|
1468
|
+
1. **App Title/Logo** at top
|
|
1469
|
+
2. **User Menu** (at top):
|
|
1470
|
+
- User avatar/icon (mdi:account-circle)
|
|
1471
|
+
- User name from auth context
|
|
1472
|
+
- Dropdown menu on click:
|
|
1473
|
+
* "Profile" → Opens profile settings dialog
|
|
1474
|
+
* "Logout" → Clears auth token, redirects to login
|
|
1475
|
+
3. **Home Button** - Links to dashboard (\`/\`) with \`mdi:home\` icon
|
|
1476
|
+
4. **Entities Section**:
|
|
1477
|
+
- Section header: "Entities"
|
|
1478
|
+
- List ALL entities from spec
|
|
1479
|
+
- Each entity: icon + name, links to \`/entity-list/:model/:entity\`
|
|
1480
|
+
- When clicked, shows entity list view
|
|
1481
|
+
- Clicking an entity instance → shows detail page with relationships
|
|
1482
|
+
5. **Workflows Section** (separate from entities):
|
|
1483
|
+
- Section header: "Workflows"
|
|
1484
|
+
- List ALL workflows from spec.workflows
|
|
1485
|
+
- **CRITICAL**: Each workflow is a CLICKABLE button that opens WorkflowDialog
|
|
1486
|
+
- Each workflow: icon + displayName
|
|
1487
|
+
- onClick handler: Opens WorkflowDialog component with workflow data
|
|
1488
|
+
- Same workflows as dashboard Quick Actions
|
|
1489
|
+
|
|
1490
|
+
**Behavior:**
|
|
1491
|
+
- **Hamburger toggle** (mdi:menu icon) at top - collapses/expands sidebar
|
|
1492
|
+
- **State persisted**: Save collapsed state to localStorage
|
|
1493
|
+
- **Mobile**: Hidden by default on mobile (< 768px), overlay when opened
|
|
1494
|
+
- **Desktop**: Side-by-side with content, collapsible
|
|
1495
|
+
- **Animation**: Smooth 200-300ms transition
|
|
1496
|
+
|
|
1497
|
+
**Example Structure:**
|
|
1498
|
+
\`\`\`
|
|
1499
|
+
┌─────────────────┐
|
|
1500
|
+
│ App Name │
|
|
1501
|
+
│ 👤 John Doe ▾ │ ← User menu (Profile/Logout)
|
|
1502
|
+
├─────────────────┤
|
|
1503
|
+
│ 🏠 Home │ ← Links to dashboard
|
|
1504
|
+
├─────────────────┤
|
|
1505
|
+
│ ENTITIES │
|
|
1506
|
+
│ 📦 Customers │
|
|
1507
|
+
│ 📦 Orders │
|
|
1508
|
+
│ 📦 Products │
|
|
1509
|
+
├─────────────────┤
|
|
1510
|
+
│ WORKFLOWS │
|
|
1511
|
+
│ ⚡ Create Order │ ← CLICKABLE button, opens WorkflowDialog
|
|
1512
|
+
│ ⚡ Process Sale │ ← CLICKABLE button, opens WorkflowDialog
|
|
1513
|
+
└─────────────────┘
|
|
1514
|
+
\`\`\`
|
|
1515
|
+
|
|
1516
|
+
**User Profile Dialog:**
|
|
1517
|
+
- Modal/dialog that opens when "Profile" clicked
|
|
1518
|
+
- Shows user info: name, email, role
|
|
1519
|
+
- Allows editing name
|
|
1520
|
+
- "Save" and "Cancel" buttons
|
|
1521
|
+
|
|
1522
|
+
**Logout:**
|
|
1523
|
+
- Clear localStorage token
|
|
1524
|
+
- Clear any auth context state
|
|
1525
|
+
- Redirect to \`/login\` page
|
|
1526
|
+
|
|
1527
|
+
Create **src/components/layout/ChatbotBubble.tsx** - Floating chat with agent selection:
|
|
1528
|
+
|
|
1529
|
+
**CRITICAL - Agent Selection Inside Bubble:**
|
|
1530
|
+
- **Fixed position**: bottom-right corner (20px from right, 20px from bottom)
|
|
1531
|
+
- **z-index**: 9999 (above all content)
|
|
1532
|
+
- **Closed state**: Small circular button with chat icon (mdi:message)
|
|
1533
|
+
- **Opened state**: Expands to chat panel (400px width, 600px height)
|
|
1534
|
+
- **Agent Dropdown**: INSIDE the chat panel header
|
|
1535
|
+
* Dropdown to select agent from spec.agents array
|
|
1536
|
+
* Label: "Talk to:" or "Select Agent:"
|
|
1537
|
+
* Default to first agent
|
|
1538
|
+
- **Chat Interface**:
|
|
1539
|
+
* Messages area (scrollable)
|
|
1540
|
+
* Input field at bottom
|
|
1541
|
+
* Send button
|
|
1542
|
+
* Backend-powered: POST to \`/agents/:agentName/chat\` with message
|
|
1543
|
+
- **Close button**: X button in chat panel header
|
|
1544
|
+
|
|
1545
|
+
**DO NOT** put agent selection in sidebar - it belongs in the chatbot bubble!
|
|
1546
|
+
|
|
1547
|
+
Create **src/components/ErrorBoundary.tsx** - Error boundary component
|
|
1548
|
+
|
|
1549
|
+
Create **src/components/auth/Login.tsx** - Modern login page with social auth:
|
|
1550
|
+
|
|
1551
|
+
**Layout Structure:**
|
|
1552
|
+
1. **NO SIDEBAR** on auth pages
|
|
1553
|
+
2. **Centered card** design (max-width: 400px) with logo/app name at top
|
|
1554
|
+
3. **Social Sign-In Buttons** (top section):
|
|
1555
|
+
- Google button: White background, Google logo, "Continue with Google"
|
|
1556
|
+
- GitHub button: Dark background, GitHub logo, "Continue with GitHub"
|
|
1557
|
+
- Microsoft button: White background, Microsoft logo, "Continue with Microsoft"
|
|
1558
|
+
- Each button full-width, proper brand colors
|
|
1559
|
+
- Click shows toast: "Social sign-in coming soon!" (not implemented yet)
|
|
1560
|
+
4. **Divider**: Horizontal line with "or" text in center
|
|
1561
|
+
5. **Email/Password Form**:
|
|
1562
|
+
- Email input with icon
|
|
1563
|
+
- Password input with show/hide toggle
|
|
1564
|
+
- "Remember me" checkbox
|
|
1565
|
+
- "Forgot password?" link
|
|
1566
|
+
6. **Sign In Button**: Primary button, full-width
|
|
1567
|
+
7. **Mock credentials**: Small text below: "Demo: admin@example.com / admin123"
|
|
1568
|
+
8. **Sign Up Link**: "Don't have an account? Sign up" at bottom
|
|
1569
|
+
|
|
1570
|
+
**Code Example:**
|
|
1571
|
+
\`\`\`tsx
|
|
1572
|
+
<div className="space-y-4">
|
|
1573
|
+
{/* Social Auth Buttons */}
|
|
1574
|
+
<button
|
|
1575
|
+
onClick={() => toast.info('Google sign-in coming soon!')}
|
|
1576
|
+
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
1577
|
+
>
|
|
1578
|
+
<Icon icon="mdi:google" className="text-xl" />
|
|
1579
|
+
<span className="font-medium">Continue with Google</span>
|
|
1580
|
+
</button>
|
|
1581
|
+
|
|
1582
|
+
<button
|
|
1583
|
+
onClick={() => toast.info('GitHub sign-in coming soon!')}
|
|
1584
|
+
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
|
1585
|
+
>
|
|
1586
|
+
<Icon icon="mdi:github" className="text-xl" />
|
|
1587
|
+
<span className="font-medium">Continue with GitHub</span>
|
|
1588
|
+
</button>
|
|
1589
|
+
|
|
1590
|
+
{/* Divider */}
|
|
1591
|
+
<div className="relative my-6">
|
|
1592
|
+
<div className="absolute inset-0 flex items-center">
|
|
1593
|
+
<div className="w-full border-t border-gray-300"></div>
|
|
1594
|
+
</div>
|
|
1595
|
+
<div className="relative flex justify-center text-sm">
|
|
1596
|
+
<span className="px-2 bg-white text-gray-500">or continue with email</span>
|
|
1597
|
+
</div>
|
|
1598
|
+
</div>
|
|
1599
|
+
|
|
1600
|
+
{/* Email/Password Form */}
|
|
1601
|
+
<form onSubmit={handleEmailLogin}>
|
|
1602
|
+
{/* Email and password inputs */}
|
|
1603
|
+
</form>
|
|
1604
|
+
</div>
|
|
1605
|
+
\`\`\`
|
|
1606
|
+
|
|
1607
|
+
**CRITICAL Requirements:**
|
|
1608
|
+
- **Social buttons**: Google (white), GitHub (dark), Microsoft (white) - use \`mdi:google\`, \`mdi:github\`, \`mdi:microsoft\`
|
|
1609
|
+
- **Click behavior**: Show toast "Coming soon!" - NOT implemented yet
|
|
1610
|
+
- **Divider**: "or continue with email" between social and email form
|
|
1611
|
+
- **Email login**: Must call \`mockApi.login(email, password)\` on submit
|
|
1612
|
+
- **Handle response**: Check \`result.status === 'success'\`, store token, navigate to dashboard
|
|
1613
|
+
- **Show errors**: Display \`result.error\` if login fails
|
|
1614
|
+
- **Mock credentials**: Display "Demo: admin@example.com / admin123" below form
|
|
1615
|
+
|
|
1616
|
+
Create **src/components/auth/SignUp.tsx** - Modern signup page with social auth:
|
|
1617
|
+
|
|
1618
|
+
**Layout Structure:**
|
|
1619
|
+
1. **Similar design** to Login page
|
|
1620
|
+
2. **Social Sign-Up Buttons** (same as login):
|
|
1621
|
+
- Google, GitHub, Microsoft buttons
|
|
1622
|
+
- Click shows toast: "Social sign-up coming soon!"
|
|
1623
|
+
3. **Divider**: "or sign up with email"
|
|
1624
|
+
4. **Form fields**: Name, Email, Password, Confirm Password
|
|
1625
|
+
5. **Terms checkbox**: "I agree to Terms of Service and Privacy Policy"
|
|
1626
|
+
6. **Sign Up Button**: Primary button, full-width
|
|
1627
|
+
7. **Login Link**: "Already have an account? Sign in" at bottom
|
|
1628
|
+
|
|
1629
|
+
**CRITICAL Requirements:**
|
|
1630
|
+
- **Social buttons**: Same styling as login page
|
|
1631
|
+
- **Form validation**: Check password match, email format, terms accepted
|
|
1632
|
+
- **Email signup**: Must call \`mockApi.signUp(email, password, name)\` on submit
|
|
1633
|
+
- **Handle response**: Check \`result.status === 'success'\`, store token, navigate to dashboard
|
|
1634
|
+
- **Show errors**: Display \`result.error\` if signup fails (e.g., "User already exists")
|
|
1635
|
+
|
|
1636
|
+
## Step 10: Main App
|
|
1637
|
+
|
|
1638
|
+
Create **src/App.tsx**:
|
|
1639
|
+
- Setup React Router with entity routes
|
|
1640
|
+
- Include Sidebar, ChatbotBubble in layout
|
|
1641
|
+
- Wrap with ErrorBoundary in main.tsx
|
|
1642
|
+
|
|
1643
|
+
Create **src/main.tsx**:
|
|
1644
|
+
\`\`\`typescript
|
|
1645
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
1646
|
+
<React.StrictMode>
|
|
1647
|
+
<ErrorBoundary>
|
|
1648
|
+
<App />
|
|
1649
|
+
</ErrorBoundary>
|
|
1650
|
+
</React.StrictMode>
|
|
1651
|
+
);
|
|
1652
|
+
\`\`\`
|
|
1653
|
+
|
|
1654
|
+
Create **src/index.css** - Tailwind imports
|
|
1655
|
+
|
|
1656
|
+
# UI POLISH & MINIMALISM GUIDELINES
|
|
1657
|
+
|
|
1658
|
+
⚠️ **CRITICAL**: The generated UI must be polished, professional, and deployment-ready.
|
|
1659
|
+
|
|
1660
|
+
## Visual Polish
|
|
1661
|
+
|
|
1662
|
+
**Clean Modern Aesthetic:**
|
|
1663
|
+
- **Whitespace**: Generous spacing between sections (24-32px)
|
|
1664
|
+
- **Borders**: Subtle borders (\`border-gray-200\`) or shadows instead of heavy lines
|
|
1665
|
+
- **Corners**: Consistent border-radius (\`rounded-lg\` for cards, \`rounded-md\` for buttons/inputs)
|
|
1666
|
+
- **Shadows**: Subtle shadows for depth
|
|
1667
|
+
* Cards: \`shadow\` or \`shadow-sm\`
|
|
1668
|
+
* Hover: \`hover:shadow-md\`
|
|
1669
|
+
* Modals: \`shadow-xl\`
|
|
1670
|
+
- **Colors**: Limited, consistent palette
|
|
1671
|
+
* Primary: \`bg-blue-500\`, \`text-blue-600\`
|
|
1672
|
+
* Success: \`bg-green-500\`, \`text-green-600\`
|
|
1673
|
+
* Danger: \`bg-red-500\`, \`text-red-600\`
|
|
1674
|
+
* Neutral: \`text-gray-600\`, \`bg-gray-50\`
|
|
1675
|
+
|
|
1676
|
+
**Typography:**
|
|
1677
|
+
- **Font sizes**:
|
|
1678
|
+
* h1: \`text-3xl\` (page titles)
|
|
1679
|
+
* h2: \`text-xl\` (section titles)
|
|
1680
|
+
* h3: \`text-lg\` (card titles)
|
|
1681
|
+
* body: \`text-base\` (content)
|
|
1682
|
+
* small: \`text-sm\` (meta info)
|
|
1683
|
+
- **Line height**: Use Tailwind defaults (\`leading-normal\`, \`leading-relaxed\`)
|
|
1684
|
+
- **Contrast**: Ensure readable text (dark text on light backgrounds)
|
|
1685
|
+
|
|
1686
|
+
**Smooth Interactions:**
|
|
1687
|
+
- **Transitions**: \`transition-all duration-200\` on interactive elements
|
|
1688
|
+
- **Hover effects**: Background darken, shadow changes
|
|
1689
|
+
* Buttons: \`hover:bg-blue-600\`, \`hover:shadow-md\`
|
|
1690
|
+
* Table rows: \`hover:bg-gray-50\`
|
|
1691
|
+
- **Loading states**:
|
|
1692
|
+
* Disable buttons + show spinner during submit
|
|
1693
|
+
* Skeleton screens for content loading
|
|
1694
|
+
- **Animations**: Subtle, purposeful (not distracting)
|
|
1695
|
+
|
|
1696
|
+
## Minimalism & Clutter Reduction
|
|
1697
|
+
|
|
1698
|
+
**NO Unnecessary Elements:**
|
|
1699
|
+
- **One primary action** per section
|
|
1700
|
+
- **Don't add features** not in spec (no "Export", "Print", "Share" unless specified)
|
|
1701
|
+
- **Hide advanced features** in dropdowns/"More" menus
|
|
1702
|
+
- **Progressive disclosure**: Show simple interface first
|
|
1703
|
+
|
|
1704
|
+
**Clean Tables:**
|
|
1705
|
+
- **Essential columns only**: 4-6 most important columns
|
|
1706
|
+
- **Icon actions**: Use icon buttons for row actions (edit, delete, view)
|
|
1707
|
+
- **Bulk actions**: Only show when rows selected
|
|
1708
|
+
- **Compact filters**: Collapse into "Filters" button if many
|
|
1709
|
+
|
|
1710
|
+
**Simple Forms:**
|
|
1711
|
+
- **Group related fields** visually
|
|
1712
|
+
- **Single column** layout (easier to scan)
|
|
1713
|
+
- **Inline validation**: Show errors after field blur
|
|
1714
|
+
- **Help text**: Use placeholder text or tooltips
|
|
1715
|
+
|
|
1716
|
+
## Consistency
|
|
1717
|
+
|
|
1718
|
+
**Component Patterns:**
|
|
1719
|
+
- **Same spacing** everywhere (use \`space-y-4\`, \`gap-4\`, \`p-6\` consistently)
|
|
1720
|
+
- **Same colors**: Stick to the palette above
|
|
1721
|
+
- **Same patterns**: If one table has search at top-right, ALL tables should
|
|
1722
|
+
|
|
1723
|
+
**Icon Usage:**
|
|
1724
|
+
- **Consistent icon set**: ONLY Material Design Icons via \`@iconify/react\`
|
|
1725
|
+
- **Consistent sizes**: \`text-xl\` for buttons, \`text-2xl\` for features, \`text-base\` inline
|
|
1726
|
+
- **Standard icons**:
|
|
1727
|
+
* Create: \`mdi:plus\`
|
|
1728
|
+
* Edit: \`mdi:pencil\`
|
|
1729
|
+
* Delete: \`mdi:delete\`
|
|
1730
|
+
* Search: \`mdi:magnify\`
|
|
1731
|
+
* Filter: \`mdi:filter-variant\`
|
|
1732
|
+
* Menu: \`mdi:menu\`
|
|
1733
|
+
* Close: \`mdi:close\`
|
|
1734
|
+
* Check: \`mdi:check\`
|
|
1735
|
+
* Alert: \`mdi:alert-circle\`
|
|
1736
|
+
|
|
1737
|
+
## Mobile Responsiveness
|
|
1738
|
+
|
|
1739
|
+
**Responsive Patterns:**
|
|
1740
|
+
- **Tables**: Use responsive grid classes (\`grid-cols-1 md:grid-cols-2 lg:grid-cols-4\`)
|
|
1741
|
+
- **Sidebars**: Overlay on mobile (\`< 768px\`), side-by-side on desktop
|
|
1742
|
+
- **Forms**: Full-width inputs on mobile
|
|
1743
|
+
- **Touch targets**: Min 44px height for buttons (\`py-2\` or \`py-3\`)
|
|
1744
|
+
|
|
1745
|
+
## Component-Specific Polish
|
|
1746
|
+
|
|
1747
|
+
**Buttons:**
|
|
1748
|
+
- ✅ Hover states: \`hover:bg-blue-600\`, \`hover:shadow-md\`
|
|
1749
|
+
- ✅ Disabled state: \`disabled:opacity-50\`, \`disabled:cursor-not-allowed\`
|
|
1750
|
+
- ✅ Loading state: Show spinner + disable
|
|
1751
|
+
|
|
1752
|
+
**Inputs:**
|
|
1753
|
+
- ✅ Focus state: \`focus:ring-2\`, \`focus:ring-blue-500\`, \`focus:border-blue-500\`
|
|
1754
|
+
- ✅ Error state: \`border-red-500\`, error message below
|
|
1755
|
+
- ✅ Disabled state: \`bg-gray-100\`, \`cursor-not-allowed\`
|
|
1756
|
+
|
|
1757
|
+
**Tables:**
|
|
1758
|
+
- ✅ Hover rows: \`hover:bg-gray-50\`
|
|
1759
|
+
- ✅ Clickable rows: \`cursor-pointer\`
|
|
1760
|
+
- ✅ Empty state: "No data available" message
|
|
1761
|
+
- ✅ Loading: Skeleton or spinner
|
|
1762
|
+
|
|
1763
|
+
**Modals:**
|
|
1764
|
+
- ✅ Smooth fade-in: \`transition-opacity\`
|
|
1765
|
+
- ✅ Backdrop: \`bg-black bg-opacity-50\`
|
|
1766
|
+
- ✅ Click outside to close
|
|
1767
|
+
- ✅ Escape key to close
|
|
1768
|
+
|
|
1769
|
+
**Remember:**
|
|
1770
|
+
- **Quality over quantity**: Better to have fewer, well-polished features
|
|
1771
|
+
- **Minimalism**: When in doubt, leave it out
|
|
1772
|
+
- **Consistency**: Every page should feel like part of the same app
|
|
1773
|
+
- **Professional**: Should look like a commercial SaaS product
|
|
1774
|
+
|
|
1775
|
+
# PHASE 2: VERIFY & FIX
|
|
1776
|
+
|
|
1777
|
+
After generating ALL files above:
|
|
1778
|
+
|
|
1779
|
+
1. Run \`npm install\`
|
|
1780
|
+
|
|
1781
|
+
2. Run \`tsc --noEmit\`
|
|
1782
|
+
- Fix any TypeScript errors
|
|
1783
|
+
- Common fixes: add missing imports, fix type annotations
|
|
1784
|
+
|
|
1785
|
+
3. Run \`npm run build\`
|
|
1786
|
+
- Fix any build errors
|
|
1787
|
+
- Common fixes: resolve import paths, add missing dependencies
|
|
1788
|
+
|
|
1789
|
+
4. Check for common issues:
|
|
1790
|
+
- **"not iterable" errors?** → Add \`Array.isArray()\` checks in DynamicTable
|
|
1791
|
+
- **Relationship tables showing JSON?** → Ensure EntityDetail uses DynamicTable component
|
|
1792
|
+
- **Missing search in tables?** → Verify DynamicTable has search input + create button together
|
|
1793
|
+
- **Missing mock data?** → Add all entities to mockData.ts
|
|
1794
|
+
- **TypeScript errors?** → Fix type annotations and imports
|
|
1795
|
+
|
|
1796
|
+
5. Fix all issues found, then run \`npm run build\` again
|
|
1797
|
+
|
|
1798
|
+
6. **DO NOT run \`npm run dev\`** - Only verify build succeeds
|
|
1799
|
+
|
|
1800
|
+
# CRITICAL RULES - READ BEFORE STARTING
|
|
1801
|
+
|
|
1802
|
+
## Navigation & Layout
|
|
1803
|
+
1. **Sidebar Structure**:
|
|
1804
|
+
- User menu at top (avatar + name + dropdown with Profile/Logout)
|
|
1805
|
+
- Home button (links to dashboard)
|
|
1806
|
+
- Entities section (only entities, NOT workflows)
|
|
1807
|
+
- **Workflows section (CLICKABLE buttons that open WorkflowDialog)**
|
|
1808
|
+
- Toggleable with localStorage persistence
|
|
1809
|
+
2. **Dashboard**: Stat cards + Quick Actions (workflows) + status cards
|
|
1810
|
+
3. **Entity Detail**: Show details card + embedded relationship tables (NOT JSON)
|
|
1811
|
+
4. **Chatbot**: Agent selection dropdown INSIDE chatbot panel, not in sidebar
|
|
1812
|
+
|
|
1813
|
+
## Tables & Data
|
|
1814
|
+
5. **All tables MUST have**:
|
|
1815
|
+
- Search input + Create button (TOGETHER on right)
|
|
1816
|
+
- **Actions column with Edit and Delete icon buttons for EACH row**
|
|
1817
|
+
- Pagination
|
|
1818
|
+
6. **Table Actions**:
|
|
1819
|
+
- Edit button: Blue pencil icon, opens edit form
|
|
1820
|
+
- Delete button: Red trash icon, shows confirmation then deletes
|
|
1821
|
+
- Use \`e.stopPropagation()\` on action buttons to prevent row click
|
|
1822
|
+
7. **Relationship tables**: MUST have Create button beside search in each table
|
|
1823
|
+
8. **Never use JSON.stringify**: Always render relationships as DynamicTable
|
|
1824
|
+
9. **Always validate arrays**: \`Array.isArray(data) ? data : []\` before operations
|
|
1825
|
+
10. **Mock data**: Create 3-5 realistic records for ALL entities
|
|
1826
|
+
|
|
1827
|
+
## Workflows
|
|
1828
|
+
11. **Sidebar workflows**: MUST be CLICKABLE buttons that open WorkflowDialog
|
|
1829
|
+
12. **WorkflowDialog MUST read**: \`workflow.inputs\` from spec["\${workflowName}.inputs"]
|
|
1830
|
+
13. **Dynamic form fields**: Render inputs based on \`inputType\` (text, select, textarea, number)
|
|
1831
|
+
14. **Submit workflow**: POST to \`/:model/:workflowName\` with form data
|
|
1832
|
+
|
|
1833
|
+
## User Management
|
|
1834
|
+
15. **User menu**: Show in sidebar at top with avatar, name, dropdown
|
|
1835
|
+
16. **Logout**: Clear localStorage token, redirect to /login
|
|
1836
|
+
17. **Profile**: Opens dialog to edit user name, email (read-only), role (read-only)
|
|
1837
|
+
|
|
1838
|
+
## Authentication
|
|
1839
|
+
18. **Social auth buttons**: Show Google, GitHub, Microsoft buttons (UI only, not functional yet)
|
|
1840
|
+
19. **Social button click**: Show toast "Coming soon!" - functionality to be added later
|
|
1841
|
+
20. **Email login/signup MUST call**: \`mockApi.login()\` and \`mockApi.signUp()\`
|
|
1842
|
+
21. **Show mock credentials**: Display "Demo: admin@example.com / admin123" on login page
|
|
1843
|
+
22. **Handle responses**: Check \`result.status === 'success'\`, show errors
|
|
1844
|
+
23. **Divider**: "or continue with email" between social buttons and email form
|
|
1845
|
+
|
|
1846
|
+
## Styling & Polish
|
|
1847
|
+
24. **Tailwind only**: No inline styles, no CSS-in-JS
|
|
1848
|
+
25. **Professional polish**: Borders, shadows, hover effects, spacing (24-32px)
|
|
1849
|
+
26. **Consistent colors**: Blue-600 primary, gray-900 text, gray-50 backgrounds
|
|
1850
|
+
27. **Icons**: Only Material Design Icons (mdi:*) via @iconify/react
|
|
1851
|
+
|
|
1852
|
+
## Build Requirements
|
|
1853
|
+
28. **Build must succeed**: Fix all errors until \`npm run build\` passes
|
|
1854
|
+
29. **NO dev server**: Only verify with \`tsc --noEmit\` and \`npm run build\`
|
|
1855
|
+
30. **Workflows from spec**: Read from spec.workflows array, access metadata with spec[workflowName]
|
|
1856
|
+
|
|
1857
|
+
START NOW! Generate all files, then verify and fix any issues.`;
|
|
1858
|
+
}
|
|
1859
|
+
//# sourceMappingURL=uiGenerator.js.map
|