8 Commits

Author SHA1 Message Date
Francisco Gaona
97fc235636 WIP - configure list views 2026-01-31 02:46:33 +01:00
Francisco Gaona
c7282ee2a0 WIP - do not display id on list views and correctly format datetime fields 2026-01-26 19:37:17 +01:00
Francisco Gaona
ce65817670 WIP - do not trim history 2026-01-26 19:14:33 +01:00
Francisco Gaona
fe51355d29 WIP - use AI assistant to create records in the system 2026-01-18 09:15:06 +01:00
Francisco Gaona
4f466d7992 WIP - create records via AI Assistant more flexible and dynamic 2026-01-18 08:47:51 +01:00
Francisco Gaona
c822017ef1 WIP - some success creating related records 2026-01-18 05:24:41 +01:00
Francisco Gaona
8b192ba7f5 WIP - using deep agent to create complex workflow 2026-01-18 04:45:15 +01:00
Francisco Gaona
20fc90a3fb Add Contact standard object, related lists, meilisearch, pagination, search, AI assistant 2026-01-16 18:01:26 +01:00
29 changed files with 4295 additions and 264 deletions

View File

@@ -0,0 +1,25 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.alterTable('page_layouts', (table) => {
// Add layout_type column to distinguish between detail/edit layouts and list view layouts
// Default to 'detail' for existing layouts
table.enum('layout_type', ['detail', 'list']).notNullable().defaultTo('detail').after('name');
// Update the unique index to include layout_type so we can have both a default detail and default list layout
table.dropIndex(['object_id', 'is_default']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.alterTable('page_layouts', (table) => {
table.dropColumn('layout_type');
table.index(['object_id', 'is_default']);
});
};

View File

@@ -11,7 +11,7 @@
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.5", "@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.12", "@langchain/core": "^1.1.15",
"@langchain/langgraph": "^1.0.15", "@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1", "@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0", "@nestjs/bullmq": "^10.1.0",
@@ -29,9 +29,10 @@
"bullmq": "^5.1.0", "bullmq": "^5.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"deepagents": "^1.5.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"langchain": "^1.2.7", "langchain": "^1.2.10",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"objection": "^3.1.5", "objection": "^3.1.5",
"openai": "^6.15.0", "openai": "^6.15.0",
@@ -228,6 +229,26 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.71.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -689,6 +710,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -1689,10 +1719,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@langchain/anthropic": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.10.tgz",
"integrity": "sha512-VXq5fsEJ4FB5XGrnoG+bfm0I7OlmYLI4jZ6cX9RasyqhGo9wcDyKw1+uEQ1H7Og7jWrTa1bfXCun76wttewJnw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "1.1.15"
}
},
"node_modules/@langchain/core": { "node_modules/@langchain/core": {
"version": "1.1.12", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.12.tgz", "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.15.tgz",
"integrity": "sha512-sHWLvhyLi3fntlg3MEPB89kCjxEX7/+imlIYJcp6uFGCAZfGxVWklqp22HwjT1szorUBYrkO8u0YA554ReKxGQ==", "integrity": "sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.0.2", "@cfworker/json-schema": "^4.0.2",
@@ -2487,7 +2533,6 @@
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "2.0.5", "@nodelib/fs.stat": "2.0.5",
@@ -2501,7 +2546,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@@ -2511,7 +2555,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.scandir": "2.1.5", "@nodelib/fs.scandir": "2.1.5",
@@ -4029,7 +4072,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@@ -4754,6 +4796,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepagents": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/deepagents/-/deepagents-1.5.0.tgz",
"integrity": "sha512-tjZLOISPMpqfk+k/iE1uIZavXW9j4NrhopUmH5ARqzmk95EEtGDyN++tgnY+tdVOOZTjE2LHjOVV7or58dtx8A==",
"license": "MIT",
"dependencies": {
"@langchain/anthropic": "^1.3.7",
"@langchain/core": "^1.1.12",
"@langchain/langgraph": "^1.0.14",
"fast-glob": "^3.3.3",
"langchain": "^1.2.7",
"micromatch": "^4.0.8",
"yaml": "^2.8.2",
"zod": "^4.3.5"
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -5514,7 +5572,6 @@
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.stat": "^2.0.2",
@@ -5740,7 +5797,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -6145,7 +6201,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
@@ -6594,7 +6649,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6623,7 +6677,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -6658,7 +6711,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@@ -7609,6 +7661,19 @@
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
} }
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -7811,9 +7876,9 @@
} }
}, },
"node_modules/langchain": { "node_modules/langchain": {
"version": "1.2.7", "version": "1.2.10",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.7.tgz", "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.10.tgz",
"integrity": "sha512-G+3Ftz/08CurJaE7LukQGBf3mCSz7XM8LZeAaFPg391Ru4lT8eLYfG6Fv4ZI0u6EBsPVcOQfaS9ig8nCRmJeqA==", "integrity": "sha512-9uVxOJE/RTECvNutQfOLwH7f6R9mcq0G/IMHwA2eptDA86R/Yz2zWMz4vARVFPxPrdSJ9nJFDPAqRQlRFwdHBw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@langchain/langgraph": "^1.0.0", "@langchain/langgraph": "^1.0.0",
@@ -7826,7 +7891,7 @@
"node": ">=20" "node": ">=20"
}, },
"peerDependencies": { "peerDependencies": {
"@langchain/core": "1.1.12" "@langchain/core": "1.1.15"
} }
}, },
"node_modules/langchain/node_modules/uuid": { "node_modules/langchain/node_modules/uuid": {
@@ -8201,7 +8266,6 @@
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@@ -8211,7 +8275,6 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@@ -8225,7 +8288,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -9388,7 +9450,6 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9727,7 +9788,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -10657,7 +10717,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@@ -10709,6 +10768,12 @@
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
@@ -11455,6 +11520,21 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -11508,9 +11588,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@@ -28,7 +28,7 @@
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.5", "@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.12", "@langchain/core": "^1.1.15",
"@langchain/langgraph": "^1.0.15", "@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1", "@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0", "@nestjs/bullmq": "^10.1.0",
@@ -46,9 +46,10 @@
"bullmq": "^5.1.0", "bullmq": "^5.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"deepagents": "^1.5.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"langchain": "^1.2.7", "langchain": "^1.2.10",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"objection": "^3.1.5", "objection": "^3.1.5",
"openai": "^6.15.0", "openai": "^6.15.0",

View File

@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator'; import { TenantId } from '../tenant/tenant.decorator';
import { AiAssistantService } from './ai-assistant.service'; import { AiAssistantService } from './ai-assistant.service';
import { AiChatRequestDto } from './dto/ai-chat.dto'; import { AiChatRequestDto } from './dto/ai-chat.dto';
import { AiSearchRequestDto } from './dto/ai-search.dto';
@Controller('ai') @Controller('ai')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -24,4 +25,17 @@ export class AiAssistantController {
payload.context, payload.context,
); );
} }
@Post('search')
async search(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: AiSearchRequestDto,
) {
return this.aiAssistantService.searchRecords(
tenantId,
user.userId,
payload,
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -12,15 +12,94 @@ export interface AiChatContext {
export interface AiAssistantReply { export interface AiAssistantReply {
reply: string; reply: string;
action?: 'create_record' | 'collect_fields' | 'clarify'; action?: 'create_record' | 'collect_fields' | 'clarify' | 'plan_complete' | 'plan_pending';
missingFields?: string[]; missingFields?: string[];
record?: any; record?: any;
records?: any[]; // Multiple records when plan execution completes
plan?: RecordCreationPlan;
} }
// ============================================
// Entity Discovery Types
// ============================================
export interface EntityFieldInfo {
apiName: string;
label: string;
type: string;
isRequired: boolean;
isSystem: boolean;
referenceObject?: string; // For LOOKUP fields, the target entity
description?: string;
}
export interface EntityRelationship {
fieldApiName: string;
fieldLabel: string;
targetEntity: string;
relationshipType: 'lookup' | 'master-detail' | 'polymorphic';
}
export interface EntityInfo {
apiName: string;
label: string;
pluralLabel?: string;
description?: string;
fields: EntityFieldInfo[];
requiredFields: string[]; // Field apiNames that are required
relationships: EntityRelationship[];
}
export interface SystemEntities {
entities: EntityInfo[];
entityByApiName: Record<string, EntityInfo>; // Changed from Map for state serialization
loadedAt: number;
}
// ============================================
// Planning Types
// ============================================
export interface PlannedRecord {
id: string; // Temporary ID for planning (e.g., "temp_account_1")
entityApiName: string;
entityLabel: string;
fields: Record<string, any>;
resolvedFields?: Record<string, any>; // Fields after dependency resolution
missingRequiredFields: string[];
dependsOn: string[]; // IDs of other planned records this depends on
status: 'pending' | 'ready' | 'created' | 'failed';
createdRecordId?: string; // Actual ID after creation
wasExisting?: boolean; // True if record already existed in database
error?: string;
}
export interface RecordCreationPlan {
id: string;
records: PlannedRecord[];
executionOrder: string[]; // Ordered list of planned record IDs
status: 'building' | 'incomplete' | 'ready' | 'executing' | 'completed' | 'failed';
createdRecords: any[];
errors: string[];
}
// ============================================
// State Types
// ============================================
export interface AiAssistantState { export interface AiAssistantState {
message: string; message: string;
messages?: any[]; // BaseMessage[] from langchain - used when invoked by Deep Agent
history?: AiChatMessage[]; history?: AiChatMessage[];
context: AiChatContext; context: AiChatContext;
// Entity discovery
systemEntities?: SystemEntities;
// Planning
plan?: RecordCreationPlan;
// Legacy fields (kept for compatibility during transition)
objectDefinition?: any; objectDefinition?: any;
pageLayout?: any; pageLayout?: any;
extractedFields?: Record<string, any>; extractedFields?: Record<string, any>;

View File

@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
export class AiSearchRequestDto {
@IsString()
@IsNotEmpty()
objectApiName: string;
@IsString()
@IsNotEmpty()
query: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
}

View File

@@ -1,7 +1,38 @@
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection'; import { Model, ModelOptions, QueryContext } from 'objection';
export class BaseModel extends Model { export class BaseModel extends Model {
static columnNameMappers = snakeCaseMappers(); /**
* Use a minimal column mapper: keep property names as-is, but handle
* timestamp fields that are stored as created_at/updated_at in the DB.
*/
static columnNameMappers = {
parse(dbRow: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(dbRow || {})) {
if (key === 'created_at') {
mapped.createdAt = value;
} else if (key === 'updated_at') {
mapped.updatedAt = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
format(model: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(model || {})) {
if (key === 'createdAt') {
mapped.created_at = value;
} else if (key === 'updatedAt') {
mapped.updated_at = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
};
id: string; id: string;
createdAt: Date; createdAt: Date;

View File

@@ -79,6 +79,10 @@ export class FieldMapperService {
const frontendType = this.mapFieldType(field.type); const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup'); const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
// Hide 'id' field from list view by default
const isIdField = field.apiName === 'id';
const defaultShowOnList = isIdField ? false : true;
return { return {
id: field.id, id: field.id,
apiName: field.apiName, apiName: field.apiName,
@@ -95,7 +99,7 @@ export class FieldMapperService {
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false, isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
// View visibility // View visibility
showOnList: uiMetadata.showOnList !== false, showOnList: uiMetadata.showOnList !== undefined ? uiMetadata.showOnList : defaultShowOnList,
showOnDetail: uiMetadata.showOnDetail !== false, showOnDetail: uiMetadata.showOnDetail !== false,
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem, showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
sortable: uiMetadata.sortable !== false, sortable: uiMetadata.sortable !== false,
@@ -141,6 +145,7 @@ export class FieldMapperService {
'boolean': 'boolean', 'boolean': 'boolean',
'date': 'date', 'date': 'date',
'datetime': 'datetime', 'datetime': 'datetime',
'date_time': 'datetime',
'time': 'time', 'time': 'time',
'email': 'email', 'email': 'email',
'url': 'url', 'url': 'url',

View File

@@ -179,7 +179,8 @@ export class DynamicModelFactory {
* Convert a field definition to JSON schema property * Convert a field definition to JSON schema property
*/ */
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> { private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
switch (field.type.toUpperCase()) { const baseSchema = () => {
switch (field.type.toUpperCase()) {
case 'TEXT': case 'TEXT':
case 'STRING': case 'STRING':
case 'EMAIL': case 'EMAIL':
@@ -187,45 +188,57 @@ export class DynamicModelFactory {
case 'PHONE': case 'PHONE':
case 'PICKLIST': case 'PICKLIST':
case 'MULTI_PICKLIST': case 'MULTI_PICKLIST':
return { return {
type: 'string', type: 'string',
...(field.isUnique && { uniqueItems: true }), ...(field.isUnique && { uniqueItems: true }),
}; };
case 'LONG_TEXT': case 'LONG_TEXT':
return { type: 'string' }; return { type: 'string' };
case 'NUMBER': case 'NUMBER':
case 'DECIMAL': case 'DECIMAL':
case 'CURRENCY': case 'CURRENCY':
case 'PERCENT': case 'PERCENT':
return { return {
type: 'number', type: 'number',
...(field.isUnique && { uniqueItems: true }), ...(field.isUnique && { uniqueItems: true }),
}; };
case 'INTEGER': case 'INTEGER':
return { return {
type: 'integer', type: 'integer',
...(field.isUnique && { uniqueItems: true }), ...(field.isUnique && { uniqueItems: true }),
}; };
case 'BOOLEAN': case 'BOOLEAN':
return { type: 'boolean', default: false }; return { type: 'boolean', default: false };
case 'DATE': case 'DATE':
return { type: 'string', format: 'date' }; return { type: 'string', format: 'date' };
case 'DATE_TIME': case 'DATE_TIME':
return { type: 'string', format: 'date-time' }; return { type: 'string', format: 'date-time' };
case 'LOOKUP': case 'LOOKUP':
case 'BELONGS_TO': case 'BELONGS_TO':
return { type: 'string' }; return { type: 'string' };
default: default:
return { type: 'string' }; return { type: 'string' };
}
};
const schema = baseSchema();
// Allow null for non-required fields so optional strings/numbers don't fail validation
if (!field.isRequired) {
return {
anyOf: [schema, { type: 'null' }],
};
} }
return schema;
} }
/** /**

View File

@@ -10,6 +10,25 @@ import { User } from '../models/user.model';
import { ObjectMetadata } from './models/dynamic-model.factory'; import { ObjectMetadata } from './models/dynamic-model.factory';
import { MeilisearchService } from '../search/meilisearch.service'; import { MeilisearchService } from '../search/meilisearch.service';
type SearchFilter = {
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
};
type SearchSort = {
field: string;
direction: 'asc' | 'desc';
};
type SearchPagination = {
page?: number;
pageSize?: number;
};
@Injectable() @Injectable()
export class ObjectService { export class ObjectService {
private readonly logger = new Logger(ObjectService.name); private readonly logger = new Logger(ObjectService.name);
@@ -62,8 +81,10 @@ export class ObjectService {
.where({ objectDefinitionId: obj.id }) .where({ objectDefinitionId: obj.id })
.orderBy('label', 'asc'); .orderBy('label', 'asc');
// Normalize all fields to ensure system fields are properly marked // Normalize all fields to ensure system fields are properly marked and add any missing system fields
const normalizedFields = fields.map((field: any) => this.normalizeField(field)); const normalizedFields = this.addMissingSystemFields(
fields.map((field: any) => this.normalizeField(field)),
);
// Get app information if object belongs to an app // Get app information if object belongs to an app
let app = null; let app = null;
@@ -509,48 +530,41 @@ export class ObjectService {
} }
} }
// Runtime endpoints - CRUD operations private async buildAuthorizedQuery(
async getRecords(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,
userId: string, userId: string,
filters?: any,
) { ) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex) const user = await User.query(knex)
.findById(userId) .findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex) const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName }) .findOne({ apiName: objectApiName })
.withGraphFetched('fields'); .withGraphFetched('fields');
if (!objectDefModel) { if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`); throw new NotFoundException(`Object ${objectApiName} not found`);
} }
const tableName = this.getTableName( // Normalize and enrich fields to include system fields for downstream permissions/search
objectDefModel.apiName, const normalizedFields = this.addMissingSystemFields(
objectDefModel.label, (objectDefModel.fields || []).map((field: any) => this.normalizeField(field)),
objectDefModel.pluralLabel,
); );
objectDefModel.fields = normalizedFields;
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query(); let query = boundModel.query();
// Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery( await this.authService.applyScopeToQuery(
query, query,
objectDefModel, objectDefModel,
@@ -558,23 +572,190 @@ export class ObjectService {
'read', 'read',
knex, knex,
); );
// Build graph expression for lookup fields const lookupFields = objectDefModel.fields?.filter((field) =>
const lookupFields = objectDefModel.fields?.filter(f => field.type === 'LOOKUP' && field.referenceObject,
f.type === 'LOOKUP' && f.referenceObject
) || []; ) || [];
if (lookupFields.length > 0) { if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase()) .map((field) => field.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean) .filter(Boolean)
.join(', '); .join(', ');
if (relationExpression) { if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`); query = query.withGraphFetched(`[${relationExpression}]`);
} }
} }
return {
query,
objectDefModel,
user,
knex,
};
}
private applySearchFilters(
query: any,
filters: SearchFilter[],
validFields: Set<string>,
) {
if (!Array.isArray(filters) || filters.length === 0) {
return query;
}
for (const filter of filters) {
const field = filter?.field;
if (!field || !validFields.has(field)) {
continue;
}
const operator = String(filter.operator || 'eq').toLowerCase();
const value = filter.value;
const values = Array.isArray(filter.values) ? filter.values : undefined;
switch (operator) {
case 'eq':
query.where(field, value);
break;
case 'neq':
query.whereNot(field, value);
break;
case 'gt':
query.where(field, '>', value);
break;
case 'gte':
query.where(field, '>=', value);
break;
case 'lt':
query.where(field, '<', value);
break;
case 'lte':
query.where(field, '<=', value);
break;
case 'contains':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}%`]);
}
break;
case 'startswith':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `${String(value).toLowerCase()}%`]);
}
break;
case 'endswith':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}`]);
}
break;
case 'in':
if (values?.length) {
query.whereIn(field, values);
} else if (Array.isArray(value)) {
query.whereIn(field, value);
}
break;
case 'notin':
if (values?.length) {
query.whereNotIn(field, values);
} else if (Array.isArray(value)) {
query.whereNotIn(field, value);
}
break;
case 'isnull':
query.whereNull(field);
break;
case 'notnull':
query.whereNotNull(field);
break;
case 'between': {
const from = filter.from ?? (Array.isArray(value) ? value[0] : undefined);
const to = filter.to ?? (Array.isArray(value) ? value[1] : undefined);
if (from !== undefined && to !== undefined) {
query.whereBetween(field, [from, to]);
} else if (from !== undefined) {
query.where(field, '>=', from);
} else if (to !== undefined) {
query.where(field, '<=', to);
}
break;
}
default:
break;
}
}
return query;
}
private applyKeywordFilter(
query: any,
keyword: string,
fields: string[],
) {
const trimmed = keyword?.trim();
if (!trimmed || fields.length === 0) {
return query;
}
query.where((builder: any) => {
const lowered = trimmed.toLowerCase();
for (const field of fields) {
builder.orWhereRaw('LOWER(??) like ?', [field, `%${lowered}%`]);
}
});
return query;
}
private async finalizeRecordQuery(
query: any,
objectDefModel: any,
user: any,
pagination?: SearchPagination,
) {
const parsedPage = Number.isFinite(Number(pagination?.page)) ? Number(pagination?.page) : 1;
const parsedPageSize = Number.isFinite(Number(pagination?.pageSize))
? Number(pagination?.pageSize)
: 0;
const safePage = parsedPage > 0 ? parsedPage : 1;
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
const shouldPaginate = safePageSize > 0;
const totalCount = await query.clone().resultSize();
if (shouldPaginate) {
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
}
const records = await query.select('*');
const filteredRecords = await Promise.all(
records.map((record: any) =>
this.authService.filterReadableFields(record, objectDefModel.fields, user),
),
);
return {
data: filteredRecords,
totalCount,
page: shouldPaginate ? safePage : 1,
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
};
}
// Runtime endpoints - CRUD operations
async getRecords(
tenantId: string,
objectApiName: string,
userId: string,
filters?: any,
) {
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
// Extract pagination and sorting parameters from query string // Extract pagination and sorting parameters from query string
const { const {
@@ -603,36 +784,98 @@ export class ObjectService {
} }
if (sortField) { if (sortField) {
query = query.orderBy(sortField, sortDirection === 'desc' ? 'desc' : 'asc'); const direction = sortDirection === 'desc' ? 'desc' : 'asc';
query = query.orderBy(sortField, direction);
} }
const parsedPage = Number.isFinite(Number(page)) ? Number(page) : 1; return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
const parsedPageSize = Number.isFinite(Number(pageSize)) ? Number(pageSize) : 0; }
const safePage = parsedPage > 0 ? parsedPage : 1;
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
const shouldPaginate = safePageSize > 0;
const totalCount = await query.clone().resultSize(); async searchRecordsByIds(
tenantId: string,
if (shouldPaginate) { objectApiName: string,
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize); userId: string,
recordIds: string[],
pagination?: SearchPagination,
) {
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
data: [],
totalCount: 0,
page: pagination?.page ?? 1,
pageSize: pagination?.pageSize ?? 0,
};
} }
const records = await query.select('*'); const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
// Filter fields based on field-level permissions objectApiName,
const filteredRecords = await Promise.all( userId,
records.map(record =>
this.authService.filterReadableFields(record, objectDefModel.fields, user)
)
); );
return { query.whereIn('id', recordIds);
data: filteredRecords, const orderBindings: any[] = ['id'];
totalCount, const cases = recordIds
page: shouldPaginate ? safePage : 1, .map((id, index) => {
pageSize: shouldPaginate ? safePageSize : filteredRecords.length, orderBindings.push(id, index);
}; return 'when ? then ?';
})
.join(' ');
if (cases) {
query.orderByRaw(`case ?? ${cases} end`, orderBindings);
}
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
}
async searchRecordsWithFilters(
tenantId: string,
objectApiName: string,
userId: string,
filters: SearchFilter[],
pagination?: SearchPagination,
sort?: SearchSort,
) {
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
const validFields = new Set([
...(objectDefModel.fields?.map((field: any) => field.apiName) || []),
...this.getSystemFieldNames(),
]);
this.applySearchFilters(query, filters, validFields);
if (sort?.field && validFields.has(sort.field)) {
query.orderBy(sort.field, sort.direction === 'desc' ? 'desc' : 'asc');
}
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
}
async searchRecordsByKeyword(
tenantId: string,
objectApiName: string,
userId: string,
keyword: string,
pagination?: SearchPagination,
) {
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
const keywordFields = (objectDefModel.fields || [])
.filter((field: any) => this.isKeywordField(field.type))
.map((field: any) => field.apiName);
this.applyKeywordFilter(query, keyword, keywordFields);
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
} }
async getRecord( async getRecord(
@@ -936,7 +1179,8 @@ export class ObjectService {
objectApiName, objectApiName,
editableData, editableData,
); );
await boundModel.query().where({ id: recordId }).update(normalizedEditableData); // Use patch to avoid validating or overwriting fields that aren't present in the edit view
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
const record = await boundModel.query().where({ id: recordId }).first(); const record = await boundModel.query().where({ id: recordId }).first();
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record); await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
return record; return record;
@@ -1040,10 +1284,23 @@ export class ObjectService {
if (missingIds.length > 0) { if (missingIds.length > 0) {
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`); throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
} }
// Check if user can delete each record const deletableIds: string[] = [];
const deniedIds: string[] = [];
for (const record of records) { for (const record of records) {
await this.authService.assertCanPerformAction('delete', objectDefModel, record, user, knex); const canDelete = await this.authService.canPerformAction(
'delete',
objectDefModel,
record,
user,
knex,
);
if (canDelete) {
deletableIds.push(record.id);
} else {
deniedIds.push(record.id);
}
} }
// Ensure model is registered // Ensure model is registered
@@ -1051,14 +1308,23 @@ export class ObjectService {
// Use Objection model // Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().whereIn('id', recordIds).delete(); if (deletableIds.length > 0) {
await boundModel.query().whereIn('id', deletableIds).delete();
}
// Remove from search index // Remove from search index
await Promise.all( await Promise.all(
recordIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id)), deletableIds.map((id) =>
this.removeIndexedRecord(resolvedTenantId, objectApiName, id),
),
); );
return { success: true, deleted: recordIds.length }; return {
success: true,
deleted: deletableIds.length,
deletedIds: deletableIds,
deniedIds,
};
} }
private async indexRecord( private async indexRecord(
@@ -1073,12 +1339,32 @@ export class ObjectService {
.map((field: any) => field.apiName) .map((field: any) => field.apiName)
.filter((apiName) => apiName && !this.isSystemField(apiName)); .filter((apiName) => apiName && !this.isSystemField(apiName));
console.log('Indexing record', {
tenantId,
objectApiName,
recordId: record.id,
fieldsToIndex,
});
await this.meilisearchService.upsertRecord( await this.meilisearchService.upsertRecord(
tenantId, tenantId,
objectApiName, objectApiName,
record, record,
fieldsToIndex, fieldsToIndex,
); );
console.log('Indexed record successfully');
const meiliResults = await this.meilisearchService.searchRecords(
tenantId,
objectApiName,
record.name,
{ limit: 10 },
);
console.log('Meilisearch results:', meiliResults);
} }
private async removeIndexedRecord( private async removeIndexedRecord(
@@ -1091,15 +1377,48 @@ export class ObjectService {
} }
private isSystemField(apiName: string): boolean { private isSystemField(apiName: string): boolean {
return this.getSystemFieldNames().includes(apiName);
}
private getSystemFieldNames(): string[] {
return ['id', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'tenantId'];
}
private addMissingSystemFields(fields: any[]): any[] {
const existing = new Map((fields || []).map((field) => [field.apiName, field]));
const systemDefaults = [
{ apiName: 'id', label: 'ID', type: 'STRING' },
{ apiName: 'created_at', label: 'Created At', type: 'DATE_TIME' },
{ apiName: 'updated_at', label: 'Updated At', type: 'DATE_TIME' },
{ apiName: 'ownerId', label: 'Owner', type: 'LOOKUP', referenceObject: 'User' },
];
const merged = [...fields];
for (const sysField of systemDefaults) {
if (!existing.has(sysField.apiName)) {
merged.push({
...sysField,
isSystem: true,
isCustom: false,
isRequired: false,
});
}
}
return merged;
}
private isKeywordField(type: string | undefined): boolean {
const normalized = String(type || '').toUpperCase();
return [ return [
'id', 'STRING',
'ownerId', 'TEXT',
'created_at', 'EMAIL',
'updated_at', 'PHONE',
'createdAt', 'URL',
'updatedAt', 'RICH_TEXT',
'tenantId', 'TEXTAREA',
].includes(apiName); ].includes(normalized);
} }
private async normalizePolymorphicRelatedObject( private async normalizePolymorphicRelatedObject(

View File

@@ -1,4 +1,6 @@
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator'; import { IsString, IsUUID, IsBoolean, IsOptional, IsObject, IsIn } from 'class-validator';
export type PageLayoutType = 'detail' | 'list';
export class CreatePageLayoutDto { export class CreatePageLayoutDto {
@IsString() @IsString()
@@ -7,18 +9,25 @@ export class CreatePageLayoutDto {
@IsUUID() @IsUUID()
objectId: string; objectId: string;
@IsIn(['detail', 'list'])
@IsOptional()
layoutType?: PageLayoutType = 'detail';
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isDefault?: boolean; isDefault?: boolean;
@IsObject() @IsObject()
layoutConfig: { layoutConfig: {
// For detail layouts: grid-based field positions
fields: Array<{ fields: Array<{
fieldId: string; fieldId: string;
x: number; x?: number;
y: number; y?: number;
w: number; w?: number;
h: number; h?: number;
// For list layouts: field order (optional, defaults to array index)
order?: number;
}>; }>;
relatedLists?: string[]; relatedLists?: string[];
}; };
@@ -42,10 +51,11 @@ export class UpdatePageLayoutDto {
layoutConfig?: { layoutConfig?: {
fields: Array<{ fields: Array<{
fieldId: string; fieldId: string;
x: number; x?: number;
y: number; y?: number;
w: number; w?: number;
h: number; h?: number;
order?: number;
}>; }>;
relatedLists?: string[]; relatedLists?: string[];
}; };

View File

@@ -10,7 +10,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { PageLayoutService } from './page-layout.service'; import { PageLayoutService } from './page-layout.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto'; import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator'; import { TenantId } from '../tenant/tenant.decorator';
@@ -25,13 +25,21 @@ export class PageLayoutController {
} }
@Get() @Get()
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) { findAll(
return this.pageLayoutService.findAll(tenantId, objectId); @TenantId() tenantId: string,
@Query('objectId') objectId?: string,
@Query('layoutType') layoutType?: PageLayoutType,
) {
return this.pageLayoutService.findAll(tenantId, objectId, layoutType);
} }
@Get('default/:objectId') @Get('default/:objectId')
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) { findDefaultByObject(
return this.pageLayoutService.findDefaultByObject(tenantId, objectId); @TenantId() tenantId: string,
@Param('objectId') objectId: string,
@Query('layoutType') layoutType?: PageLayoutType,
) {
return this.pageLayoutService.findDefaultByObject(tenantId, objectId, layoutType || 'detail');
} }
@Get(':id') @Get(':id')

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto'; import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
@Injectable() @Injectable()
export class PageLayoutService { export class PageLayoutService {
@@ -8,17 +8,19 @@ export class PageLayoutService {
async create(tenantId: string, createDto: CreatePageLayoutDto) { async create(tenantId: string, createDto: CreatePageLayoutDto) {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnex(tenantId);
const layoutType = createDto.layoutType || 'detail';
// If this layout is set as default, unset other defaults for the same object // If this layout is set as default, unset other defaults for the same object and layout type
if (createDto.isDefault) { if (createDto.isDefault) {
await knex('page_layouts') await knex('page_layouts')
.where({ object_id: createDto.objectId }) .where({ object_id: createDto.objectId, layout_type: layoutType })
.update({ is_default: false }); .update({ is_default: false });
} }
const [id] = await knex('page_layouts').insert({ const [id] = await knex('page_layouts').insert({
name: createDto.name, name: createDto.name,
object_id: createDto.objectId, object_id: createDto.objectId,
layout_type: layoutType,
is_default: createDto.isDefault || false, is_default: createDto.isDefault || false,
layout_config: JSON.stringify(createDto.layoutConfig), layout_config: JSON.stringify(createDto.layoutConfig),
description: createDto.description || null, description: createDto.description || null,
@@ -29,7 +31,7 @@ export class PageLayoutService {
return result; return result;
} }
async findAll(tenantId: string, objectId?: string) { async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnex(tenantId);
let query = knex('page_layouts'); let query = knex('page_layouts');
@@ -38,6 +40,10 @@ export class PageLayoutService {
query = query.where({ object_id: objectId }); query = query.where({ object_id: objectId });
} }
if (layoutType) {
query = query.where({ layout_type: layoutType });
}
const layouts = await query.orderByRaw('is_default DESC, name ASC'); const layouts = await query.orderByRaw('is_default DESC, name ASC');
return layouts; return layouts;
} }
@@ -54,11 +60,11 @@ export class PageLayoutService {
return layout; return layout;
} }
async findDefaultByObject(tenantId: string, objectId: string) { async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnex(tenantId);
const layout = await knex('page_layouts') const layout = await knex('page_layouts')
.where({ object_id: objectId, is_default: true }) .where({ object_id: objectId, is_default: true, layout_type: layoutType })
.first(); .first();
return layout || null; return layout || null;
@@ -68,13 +74,12 @@ export class PageLayoutService {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnex(tenantId);
// Check if layout exists // Check if layout exists
await this.findOne(tenantId, id); const layout = await this.findOne(tenantId, id);
// If setting as default, unset other defaults for the same object // If setting as default, unset other defaults for the same object and layout type
if (updateDto.isDefault) { if (updateDto.isDefault) {
const layout = await this.findOne(tenantId, id);
await knex('page_layouts') await knex('page_layouts')
.where({ object_id: layout.object_id }) .where({ object_id: layout.object_id, layout_type: layout.layout_type })
.whereNot({ id }) .whereNot({ id })
.update({ is_default: false }); .update({ is_default: false });
} }

View File

@@ -28,6 +28,8 @@ export class MeilisearchService {
const indexName = this.buildIndexName(config, tenantId, objectApiName); const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`; const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
console.log('querying Meilisearch index:', { indexName, query, displayField });
try { try {
const response = await this.requestJson('POST', url, { const response = await this.requestJson('POST', url, {
q: query, q: query,
@@ -66,6 +68,48 @@ export class MeilisearchService {
return null; return null;
} }
async searchRecords(
tenantId: string,
objectApiName: string,
query: string,
options?: { limit?: number; offset?: number },
): Promise<{ hits: any[]; total: number }> {
const config = this.getConfig();
if (!config) return { hits: [], total: 0 };
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20;
const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0;
try {
const response = await this.requestJson('POST', url, {
q: query,
limit,
offset,
}, this.buildHeaders(config));
console.log('Meilisearch response body:', response.body);
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch query failed for index ${indexName}: ${response.status}`,
);
return { hits: [], total: 0 };
}
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
const total =
response.body?.estimatedTotalHits ??
response.body?.nbHits ??
hits.length;
return { hits, total };
} catch (error) {
this.logger.warn(`Meilisearch query failed: ${error.message}`);
return { hits: [], total: 0 };
}
}
async upsertRecord( async upsertRecord(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,

View File

@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Toaster } from 'vue-sonner' import { Toaster } from 'vue-sonner'
import BottomDrawer from '@/components/BottomDrawer.vue'
</script> </script>
<template> <template>
<div> <div>
<Toaster position="top-right" :duration="4000" richColors /> <Toaster position="top-right" :duration="4000" richColors />
<NuxtPage /> <NuxtPage />
<BottomDrawer />
</div> </div>
</template> </template>

View File

@@ -11,6 +11,9 @@ import { useSoftphone } from '~/composables/useSoftphone'
const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false) const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false)
const activeTab = useState<string>('bottomDrawerTab', () => 'softphone') const activeTab = useState<string>('bottomDrawerTab', () => 'softphone')
const drawerHeight = useState<number>('bottomDrawerHeight', () => 240) const drawerHeight = useState<number>('bottomDrawerHeight', () => 240)
const props = defineProps<{
bounds?: { left: number; width: number }
}>()
const softphone = useSoftphone() const softphone = useSoftphone()
@@ -190,9 +193,17 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-30 flex justify-center px-2"> <div
class="pointer-events-none fixed bottom-0 z-30 flex justify-center px-2"
:style="{
left: props.bounds?.left ? `${props.bounds.left}px` : '0',
width: props.bounds?.width ? `${props.bounds.width}px` : '100vw',
right: props.bounds?.width ? 'auto' : '0',
}"
>
<div <div
class="pointer-events-auto w-full border border-border bg-background shadow-xl transition-all duration-200" class="pointer-events-auto w-full border border-border bg-background transition-all duration-200"
:class="{ 'shadow-2xl': isDrawerOpen }"
:style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }" :style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }"
> >
<div class="grid grid-cols-3 items-center justify-between border-border px-2 py-2"> <div class="grid grid-cols-3 items-center justify-between border-border px-2 py-2">

View File

@@ -0,0 +1,264 @@
<template>
<div class="list-view-layout-editor">
<div class="flex h-full">
<!-- Selected Fields Area -->
<div class="flex-1 p-4 overflow-auto">
<div class="mb-4 flex justify-between items-center">
<h3 class="text-lg font-semibold">{{ layoutName || 'List View Layout' }}</h3>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="handleClear">
Clear All
</Button>
<Button size="sm" @click="handleSave">
Save Layout
</Button>
</div>
</div>
<div class="border rounded-lg bg-slate-50 dark:bg-slate-900 p-4 min-h-[400px]">
<p class="text-sm text-muted-foreground mb-4">
Drag fields to reorder them. Fields will appear in the list view in this order.
</p>
<div v-if="selectedFields.length === 0" class="text-center py-8 text-muted-foreground">
<p>No fields selected.</p>
<p class="text-sm">Click or drag fields from the right panel to add them.</p>
</div>
<div
v-else
ref="sortableContainer"
class="space-y-2"
>
<div
v-for="(field, index) in selectedFields"
:key="field.id"
class="p-3 border rounded cursor-move bg-white dark:bg-slate-800 hover:border-primary transition-colors flex items-center justify-between"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragover.prevent="handleDragOver($event, index)"
@drop="handleDrop($event, index)"
>
<div class="flex items-center gap-3">
<span class="text-muted-foreground cursor-grab">
<GripVertical class="w-4 h-4" />
</span>
<div>
<div class="font-medium text-sm">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">
{{ field.apiName }} {{ formatFieldType(field.type) }}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive"
@click="removeField(field.id)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
<!-- Available Fields Sidebar -->
<div class="w-80 border-l bg-white dark:bg-slate-950 p-4 overflow-auto">
<h3 class="text-lg font-semibold mb-4">Available Fields</h3>
<p class="text-xs text-muted-foreground mb-4">Click or drag to add field to list</p>
<div v-if="availableFields.length === 0" class="text-center py-4 text-muted-foreground text-sm">
All fields have been added to the layout.
</div>
<div v-else class="space-y-2">
<div
v-for="field in availableFields"
:key="field.id"
class="p-3 border rounded cursor-pointer bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
draggable="true"
@dragstart="handleAvailableFieldDragStart($event, field)"
@click="addField(field)"
>
<div class="font-medium text-sm">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">
{{ field.apiName }} {{ formatFieldType(field.type) }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { GripVertical, X } from 'lucide-vue-next'
import type { FieldLayoutItem } from '~/types/page-layout'
import type { FieldConfig } from '~/types/field-types'
import { Button } from '@/components/ui/button'
const props = defineProps<{
fields: FieldConfig[]
initialLayout?: FieldLayoutItem[]
layoutName?: string
}>()
const emit = defineEmits<{
save: [layout: { fields: FieldLayoutItem[] }]
}>()
// Selected fields in order
const selectedFieldIds = ref<string[]>([])
const draggedIndex = ref<number | null>(null)
const draggedAvailableField = ref<FieldConfig | null>(null)
// Initialize with initial layout
watch(() => props.initialLayout, (layout) => {
if (layout && layout.length > 0) {
// Sort by order if available, otherwise use array order
const sorted = [...layout].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
selectedFieldIds.value = sorted.map(item => item.fieldId)
}
}, { immediate: true })
// Computed selected fields in order
const selectedFields = computed(() => {
return selectedFieldIds.value
.map(id => props.fields.find(f => f.id === id))
.filter((f): f is FieldConfig => f !== undefined)
})
// Available fields (not selected)
const availableFields = computed(() => {
const selectedSet = new Set(selectedFieldIds.value)
return props.fields.filter(field => !selectedSet.has(field.id))
})
const formatFieldType = (type: string): string => {
const typeNames: Record<string, string> = {
'TEXT': 'Text',
'LONG_TEXT': 'Textarea',
'EMAIL': 'Email',
'PHONE': 'Phone',
'NUMBER': 'Number',
'CURRENCY': 'Currency',
'PERCENT': 'Percent',
'PICKLIST': 'Picklist',
'MULTI_PICKLIST': 'Multi-select',
'BOOLEAN': 'Checkbox',
'DATE': 'Date',
'DATE_TIME': 'DateTime',
'TIME': 'Time',
'URL': 'URL',
'LOOKUP': 'Lookup',
'FILE': 'File',
'IMAGE': 'Image',
'JSON': 'JSON',
'text': 'Text',
'textarea': 'Textarea',
'email': 'Email',
'number': 'Number',
'currency': 'Currency',
'select': 'Picklist',
'multiSelect': 'Multi-select',
'boolean': 'Checkbox',
'date': 'Date',
'datetime': 'DateTime',
'url': 'URL',
'lookup': 'Lookup',
'belongsTo': 'Lookup',
}
return typeNames[type] || type
}
const addField = (field: FieldConfig) => {
if (!selectedFieldIds.value.includes(field.id)) {
selectedFieldIds.value.push(field.id)
}
}
const removeField = (fieldId: string) => {
selectedFieldIds.value = selectedFieldIds.value.filter(id => id !== fieldId)
}
// Drag and drop for reordering
const handleDragStart = (event: DragEvent, index: number) => {
draggedIndex.value = index
draggedAvailableField.value = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const handleDragOver = (event: DragEvent, index: number) => {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
const handleDrop = (event: DragEvent, targetIndex: number) => {
event.preventDefault()
// Handle drop from available fields
if (draggedAvailableField.value) {
addField(draggedAvailableField.value)
// Move the newly added field to the target position
const newFieldId = draggedAvailableField.value.id
const currentIndex = selectedFieldIds.value.indexOf(newFieldId)
if (currentIndex !== -1 && currentIndex !== targetIndex) {
const ids = [...selectedFieldIds.value]
ids.splice(currentIndex, 1)
ids.splice(targetIndex, 0, newFieldId)
selectedFieldIds.value = ids
}
draggedAvailableField.value = null
return
}
// Handle reordering within selected fields
if (draggedIndex.value === null || draggedIndex.value === targetIndex) {
draggedIndex.value = null
return
}
const ids = [...selectedFieldIds.value]
const [removed] = ids.splice(draggedIndex.value, 1)
ids.splice(targetIndex, 0, removed)
selectedFieldIds.value = ids
draggedIndex.value = null
}
// Drag from available fields
const handleAvailableFieldDragStart = (event: DragEvent, field: FieldConfig) => {
draggedAvailableField.value = field
draggedIndex.value = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy'
}
}
const handleClear = () => {
if (confirm('Are you sure you want to clear all fields from the layout?')) {
selectedFieldIds.value = []
}
}
const handleSave = () => {
const layout: FieldLayoutItem[] = selectedFieldIds.value.map((fieldId, index) => ({
fieldId,
order: index,
}))
emit('save', { fields: layout })
}
</script>
<style scoped>
.list-view-layout-editor {
height: calc(100vh - 300px);
min-height: 500px;
}
</style>

View File

@@ -85,9 +85,31 @@ const formatValue = (val: any): string => {
case FieldType.BELONGS_TO: case FieldType.BELONGS_TO:
return relationshipDisplayValue.value return relationshipDisplayValue.value
case FieldType.DATE: case FieldType.DATE:
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString() try {
const date = val instanceof Date ? val : new Date(val)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
} catch {
return String(val)
}
case FieldType.DATETIME: case FieldType.DATETIME:
return val instanceof Date ? val.toLocaleString() : new Date(val).toLocaleString() try {
const date = val instanceof Date ? val : new Date(val)
return date.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
} catch {
return String(val)
}
case FieldType.BOOLEAN: case FieldType.BOOLEAN:
return val ? 'Yes' : 'No' return val ? 'Yes' : 'No'
case FieldType.CURRENCY: case FieldType.CURRENCY:

View File

@@ -18,7 +18,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CheckboxRoot <CheckboxRoot
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class)" props.class)"
> >
<CheckboxIndicator class="grid place-content-center text-current"> <CheckboxIndicator class="grid place-content-center text-current">

View File

@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import FieldRenderer from '@/components/fields/FieldRenderer.vue' import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types' import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next' import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
@@ -23,6 +24,7 @@ interface Props {
selectable?: boolean selectable?: boolean
baseUrl?: string baseUrl?: string
totalCount?: number totalCount?: number
searchSummary?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -30,6 +32,7 @@ const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
selectable: false, selectable: false,
baseUrl: '/runtime/objects', baseUrl: '/runtime/objects',
searchSummary: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -47,11 +50,13 @@ const emit = defineEmits<{
}>() }>()
// State // State
const selectedRows = ref<Set<string>>(new Set()) const normalizeId = (id: any) => String(id)
const selectedRowIds = ref<string[]>([])
const searchQuery = ref('') const searchQuery = ref('')
const sortField = ref<string>('') const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc') const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1) const currentPage = ref(1)
const bulkAction = ref('delete')
// Computed // Computed
const visibleFields = computed(() => const visibleFields = computed(() =>
@@ -92,27 +97,39 @@ const showLoadMore = computed(() => (
)) ))
const allSelected = computed({ const allSelected = computed({
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length, get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
set: (val: boolean) => { set: (val: boolean) => {
if (val) { if (val) {
selectedRows.value = new Set(props.data.map(row => row.id)) selectedRowIds.value = props.data.map(row => normalizeId(row.id))
} else { } else {
selectedRows.value.clear() selectedRowIds.value = []
} }
emit('row-select', getSelectedRows()) emit('row-select', getSelectedRows())
}, },
}) })
const getSelectedRows = () => { const getSelectedRows = () => {
return props.data.filter(row => selectedRows.value.has(row.id)) const idSet = new Set(selectedRowIds.value)
return props.data.filter(row => idSet.has(normalizeId(row.id)))
} }
const toggleRowSelection = (rowId: string) => { const toggleRowSelection = (rowId: string) => {
if (selectedRows.value.has(rowId)) { const normalizedId = normalizeId(rowId)
selectedRows.value.delete(rowId) const nextSelection = new Set(selectedRowIds.value)
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
const setRowSelection = (rowId: string, checked: boolean) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
if (checked) {
nextSelection.add(normalizedId)
} else { } else {
selectedRows.value.add(rowId) nextSelection.delete(normalizedId)
} }
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows()) emit('row-select', getSelectedRows())
} }
@@ -134,6 +151,14 @@ const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows()) emit('action', actionId, getSelectedRows())
} }
const handleBulkAction = () => {
if (bulkAction.value === 'delete') {
emit('delete', getSelectedRows())
return
}
emit('action', bulkAction.value, getSelectedRows())
}
const goToPage = (page: number) => { const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), availablePages.value) const nextPage = Math.min(Math.max(page, 1), availablePages.value)
if (nextPage !== currentPage.value) { if (nextPage !== currentPage.value) {
@@ -155,6 +180,19 @@ watch(
} }
} }
) )
watch(
() => props.data,
(rows) => {
const rowIds = new Set(rows.map(row => normalizeId(row.id)))
const nextSelection = selectedRowIds.value.filter(id => rowIds.has(id))
if (nextSelection.length !== selectedRowIds.value.length) {
selectedRowIds.value = nextSelection
emit('row-select', getSelectedRows())
}
},
{ deep: true }
)
</script> </script>
<template> <template>
@@ -172,18 +210,31 @@ watch(
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
</div> </div>
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
{{ searchSummary }}
</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Bulk Actions --> <!-- Bulk Actions -->
<template v-if="selectedRows.size > 0"> <template v-if="selectedRowIds.length > 0">
<Badge variant="secondary" class="px-3 py-1"> <Badge variant="secondary" class="px-3 py-1">
{{ selectedRows.size }} selected {{ selectedRowIds.length }} selected
</Badge> </Badge>
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())"> <div class="flex items-center gap-2">
<Trash2 class="h-4 w-4 mr-2" /> <Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
Delete <SelectTrigger class="h-8 w-[180px]">
</Button> <SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete selected</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" @click="handleBulkAction">
<Trash2 class="h-4 w-4 mr-2" />
Run
</Button>
</div>
</template> </template>
<!-- Custom Actions --> <!-- Custom Actions -->
@@ -217,7 +268,10 @@ watch(
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead v-if="selectable" class="w-12"> <TableHead v-if="selectable" class="w-12">
<Checkbox v-model:checked="allSelected" /> <Checkbox
:model-value="allSelected"
@update:model-value="(value: boolean) => (allSelected = value)"
/>
</TableHead> </TableHead>
<TableHead <TableHead
v-for="field in visibleFields" v-for="field in visibleFields"
@@ -258,8 +312,8 @@ watch(
> >
<TableCell v-if="selectable" @click.stop> <TableCell v-if="selectable" @click.stop>
<Checkbox <Checkbox
:checked="selectedRows.has(row.id)" :model-value="selectedRowIds.includes(normalizeId(row.id))"
@update:checked="toggleRowSelection(row.id)" @update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
/> />
</TableCell> </TableCell>
<TableCell v-for="field in visibleFields" :key="field.id"> <TableCell v-for="field in visibleFields" :key="field.id">

View File

@@ -20,6 +20,9 @@ export const useFields = () => {
// Hide system fields and auto-generated fields on edit // Hide system fields and auto-generated fields on edit
const shouldHideOnEdit = isSystemField || isAutoGeneratedField const shouldHideOnEdit = isSystemField || isAutoGeneratedField
// Hide 'id' field from list view by default (check both apiName and id field)
const shouldHideOnList = fieldDef.apiName === 'id' || fieldDef.label === 'Id' || fieldDef.label === 'ID'
return { return {
id: fieldDef.id, id: fieldDef.id,
apiName: fieldDef.apiName, apiName: fieldDef.apiName,
@@ -37,7 +40,7 @@ export const useFields = () => {
validationRules: fieldDef.validationRules || [], validationRules: fieldDef.validationRules || [],
// View options - only hide system and auto-generated fields by default // View options - only hide system and auto-generated fields by default
showOnList: fieldDef.showOnList ?? true, showOnList: fieldDef.showOnList ?? !shouldHideOnList,
showOnDetail: fieldDef.showOnDetail ?? true, showOnDetail: fieldDef.showOnDetail ?? true,
showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit, showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
sortable: fieldDef.sortable ?? true, sortable: fieldDef.sortable ?? true,
@@ -67,12 +70,36 @@ export const useFields = () => {
/** /**
* Build a ListView configuration from object definition * Build a ListView configuration from object definition
* @param objectDef - The object definition containing fields
* @param customConfig - Optional custom configuration
* @param listLayoutConfig - Optional list view layout configuration from page_layouts
*/ */
const buildListViewConfig = ( const buildListViewConfig = (
objectDef: any, objectDef: any,
customConfig?: Partial<ListViewConfig> customConfig?: Partial<ListViewConfig>,
listLayoutConfig?: { fields: Array<{ fieldId: string; order?: number }> } | null
): ListViewConfig => { ): ListViewConfig => {
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || [] let fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
// If a list layout is provided, filter and order fields according to it
if (listLayoutConfig && listLayoutConfig.fields && listLayoutConfig.fields.length > 0) {
// Sort layout fields by order
const sortedLayoutFields = [...listLayoutConfig.fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
// Map layout fields to actual field configs, preserving order
const orderedFields: FieldConfig[] = []
for (const layoutField of sortedLayoutFields) {
const fieldConfig = fields.find((f: FieldConfig) => f.id === layoutField.fieldId)
if (fieldConfig) {
orderedFields.push(fieldConfig)
}
}
// Use ordered fields if we found any, otherwise fall back to all fields
if (orderedFields.length > 0) {
fields = orderedFields
}
}
return { return {
objectApiName: objectDef.apiName, objectApiName: objectDef.apiName,
@@ -330,9 +357,25 @@ export const useViewState = <T extends { id?: string }>(
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const useBulkEndpoint = apiEndpoint.includes('/runtime/objects/')
if (useBulkEndpoint) {
const response = await api.post(`${apiEndpoint}/bulk-delete`, { ids })
const deletedIds = Array.isArray(response?.deletedIds) ? response.deletedIds : ids
records.value = records.value.filter(r => !deletedIds.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - deletedIds.length)
return {
deletedIds,
deniedIds: Array.isArray(response?.deniedIds) ? response.deniedIds : [],
}
}
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`))) await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
records.value = records.value.filter(r => !ids.includes(r.id!)) records.value = records.value.filter(r => !ids.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - ids.length) totalCount.value = Math.max(0, totalCount.value - ids.length)
return {
deletedIds: ids,
deniedIds: [],
}
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
console.error('Failed to delete records:', e) console.error('Failed to delete records:', e)

View File

@@ -1,11 +1,13 @@
import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest } from '~/types/page-layout' import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest, PageLayoutType } from '~/types/page-layout'
export const usePageLayouts = () => { export const usePageLayouts = () => {
const { api } = useApi() const { api } = useApi()
const getPageLayouts = async (objectId?: string) => { const getPageLayouts = async (objectId?: string, layoutType?: PageLayoutType) => {
try { try {
const params = objectId ? { objectId } : {} const params: Record<string, string> = {}
if (objectId) params.objectId = objectId
if (layoutType) params.layoutType = layoutType
const response = await api.get('/page-layouts', { params }) const response = await api.get('/page-layouts', { params })
return response return response
} catch (error) { } catch (error) {
@@ -24,9 +26,11 @@ export const usePageLayouts = () => {
} }
} }
const getDefaultPageLayout = async (objectId: string) => { const getDefaultPageLayout = async (objectId: string, layoutType: PageLayoutType = 'detail') => {
try { try {
const response = await api.get(`/page-layouts/default/${objectId}`) const response = await api.get(`/page-layouts/default/${objectId}`, {
params: { layoutType }
})
return response return response
} catch (error) { } catch (error) {
console.error('Error fetching default page layout:', error) console.error('Error fetching default page layout:', error)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import AppSidebar from '@/components/AppSidebar.vue' import AppSidebar from '@/components/AppSidebar.vue'
import BottomDrawer from '@/components/BottomDrawer.vue' import BottomDrawer from '@/components/BottomDrawer.vue'
import { import {
@@ -15,6 +15,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
const route = useRoute() const route = useRoute()
const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs() const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs()
const drawerBounds = useState('bottomDrawerBounds', () => ({ left: 0, width: 0 }))
const insetRef = ref<any>(null)
let resizeObserver: ResizeObserver | null = null
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
// If custom breadcrumbs are set by the page, use those // If custom breadcrumbs are set by the page, use those
@@ -30,12 +33,47 @@ const breadcrumbs = computed(() => {
isLast: index === paths.length - 1, isLast: index === paths.length - 1,
})) }))
}) })
const resolveInsetEl = (): HTMLElement | null => {
const maybeComponent = insetRef.value as any
if (!maybeComponent) return null
return maybeComponent.$el ? maybeComponent.$el as HTMLElement : (maybeComponent as HTMLElement)
}
const updateBounds = () => {
const el = resolveInsetEl()
if (!el || typeof el.getBoundingClientRect !== 'function') return
const rect = el.getBoundingClientRect()
drawerBounds.value = {
left: rect.left,
width: rect.width,
}
}
onMounted(() => {
updateBounds()
const el = resolveInsetEl()
if (el && 'ResizeObserver' in window) {
resizeObserver = new ResizeObserver(updateBounds)
resizeObserver.observe(el)
}
window.addEventListener('resize', updateBounds)
})
onBeforeUnmount(() => {
const el = resolveInsetEl()
if (resizeObserver && el) {
resizeObserver.unobserve(el)
}
resizeObserver = null
window.removeEventListener('resize', updateBounds)
})
</script> </script>
<template> <template>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset class="flex flex-col"> <SidebarInset ref="insetRef" class="relative flex flex-col">
<header <header
class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md" class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md"
> >
@@ -73,7 +111,8 @@ const breadcrumbs = computed(() => {
<slot /> <slot />
</div> </div>
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
<BottomDrawer :bounds="drawerBounds" />
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
</template> </template>

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,24 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useFields, useViewState } from '@/composables/useFieldViews' import { useFields, useViewState } from '@/composables/useFieldViews'
import { usePageLayouts } from '@/composables/usePageLayouts'
import ListView from '@/components/views/ListView.vue' import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue' import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue' import EditView from '@/components/views/EditViewEnhanced.vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { api } = useApi() const { api } = useApi()
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields() const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
const { getDefaultPageLayout } = usePageLayouts()
// Use breadcrumbs composable // Use breadcrumbs composable
const { setBreadcrumbs } = useBreadcrumbs() const { setBreadcrumbs } = useBreadcrumbs()
@@ -32,6 +42,7 @@ const view = computed(() => {
// State // State
const objectDefinition = ref<any>(null) const objectDefinition = ref<any>(null)
const listViewLayout = ref<any>(null)
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -126,11 +137,13 @@ watch([objectDefinition, currentRecord, recordId], () => {
// View configs // View configs
const listConfig = computed(() => { const listConfig = computed(() => {
if (!objectDefinition.value) return null if (!objectDefinition.value) return null
// Pass the list view layout config to buildListViewConfig if available
const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig
return buildListViewConfig(objectDefinition.value, { return buildListViewConfig(objectDefinition.value, {
searchable: true, searchable: true,
exportable: true, exportable: true,
filterable: true, filterable: true,
}) }, layoutConfig)
}) })
const detailConfig = computed(() => { const detailConfig = computed(() => {
@@ -145,6 +158,17 @@ const editConfig = computed(() => {
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25) const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500) const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
const searchQuery = ref('')
const searchSummary = ref('')
const searchLoading = ref(false)
const deleteDialogOpen = ref(false)
const deleteSubmitting = ref(false)
const pendingDeleteRows = ref<any[]>([])
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
const deniedDeleteCount = computed(() => deleteSummary.value?.deniedIds.length ?? 0)
// Fetch object definition // Fetch object definition
const fetchObjectDefinition = async () => { const fetchObjectDefinition = async () => {
@@ -153,6 +177,16 @@ const fetchObjectDefinition = async () => {
error.value = null error.value = null
const response = await api.get(`/setup/objects/${objectApiName.value}`) const response = await api.get(`/setup/objects/${objectApiName.value}`)
objectDefinition.value = response objectDefinition.value = response
// Fetch the default list view layout for this object
if (response?.id) {
try {
listViewLayout.value = await getDefaultPageLayout(response.id, 'list')
} catch (e) {
// No list view layout configured, will use default behavior
listViewLayout.value = null
}
}
} catch (e: any) { } catch (e: any) {
error.value = e.message || 'Failed to load object definition' error.value = e.message || 'Failed to load object definition'
console.error('Error fetching object definition:', e) console.error('Error fetching object definition:', e)
@@ -189,16 +223,42 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
} }
const handleDelete = async (rows: any[]) => { const handleDelete = async (rows: any[]) => {
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) { pendingDeleteRows.value = rows
try { deleteSummary.value = null
const ids = rows.map(r => r.id) deleteDialogOpen.value = true
await deleteRecords(ids) }
const resetDeleteDialog = () => {
deleteDialogOpen.value = false
deleteSubmitting.value = false
pendingDeleteRows.value = []
deleteSummary.value = null
}
const confirmDelete = async () => {
if (pendingDeleteRows.value.length === 0) {
resetDeleteDialog()
return
}
deleteSubmitting.value = true
try {
const ids = pendingDeleteRows.value.map(r => r.id)
const result = await deleteRecords(ids)
const deletedIds = result?.deletedIds ?? []
const deniedIds = result?.deniedIds ?? []
deleteSummary.value = { deletedIds, deniedIds }
if (deniedIds.length === 0) {
resetDeleteDialog()
if (view.value !== 'list') { if (view.value !== 'list') {
await router.push(`/${objectApiName.value.toLowerCase()}/`) await router.push(`/${objectApiName.value.toLowerCase()}/`)
} }
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
} }
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
} finally {
deleteSubmitting.value = false
} }
} }
@@ -235,6 +295,36 @@ const loadListRecords = async (
return result return result
} }
const searchListRecords = async (
page = 1,
options?: { append?: boolean; pageSize?: number }
) => {
if (!isSearchActive.value) {
return initializeListRecords()
}
searchLoading.value = true
try {
const pageSize = options?.pageSize ?? listPageSize.value
const response = await api.post('/ai/search', {
objectApiName: objectApiName.value,
query: searchQuery.value.trim(),
page,
pageSize,
})
const data = response?.data ?? []
const total = response?.totalCount ?? data.length
records.value = options?.append ? [...records.value, ...data] : data
totalCount.value = total
searchSummary.value = response?.explanation || ''
return response
} catch (e: any) {
error.value = e.message || 'Failed to search records'
return null
} finally {
searchLoading.value = false
}
}
const initializeListRecords = async () => { const initializeListRecords = async () => {
const firstResult = await loadListRecords(1) const firstResult = await loadListRecords(1)
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
@@ -247,6 +337,10 @@ const initializeListRecords = async () => {
} }
const handlePageChange = async (page: number, pageSize: number) => { const handlePageChange = async (page: number, pageSize: number) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: page > 1, pageSize })
return
}
const loadedPages = Math.ceil(records.value.length / pageSize) const loadedPages = Math.ceil(records.value.length / pageSize)
if (page > loadedPages && totalCount.value > records.value.length) { if (page > loadedPages && totalCount.value > records.value.length) {
await loadListRecords(page, { append: true, pageSize }) await loadListRecords(page, { append: true, pageSize })
@@ -254,9 +348,24 @@ const handlePageChange = async (page: number, pageSize: number) => {
} }
const handleLoadMore = async (page: number, pageSize: number) => { const handleLoadMore = async (page: number, pageSize: number) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: true, pageSize })
return
}
await loadListRecords(page, { append: true, pageSize }) await loadListRecords(page, { append: true, pageSize })
} }
const handleSearch = async (query: string) => {
const trimmed = query.trim()
searchQuery.value = trimmed
if (!trimmed) {
searchSummary.value = ''
await initializeListRecords()
return
}
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
}
// Watch for route changes // Watch for route changes
watch(() => route.params, async (newParams, oldParams) => { watch(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new' // Reset current record when navigating to 'new'
@@ -325,14 +434,16 @@ onMounted(async () => {
v-else-if="view === 'list' && listConfig" v-else-if="view === 'list' && listConfig"
:config="listConfig" :config="listConfig"
:data="records" :data="records"
:loading="dataLoading" :loading="dataLoading || searchLoading"
:total-count="totalCount" :total-count="totalCount"
:search-summary="searchSummary"
:base-url="`/runtime/objects`" :base-url="`/runtime/objects`"
selectable selectable
@row-click="handleRowClick" @row-click="handleRowClick"
@create="handleCreate" @create="handleCreate"
@edit="handleEdit" @edit="handleEdit"
@delete="handleDelete" @delete="handleDelete"
@search="handleSearch"
@page-change="handlePageChange" @page-change="handlePageChange"
@load-more="handleLoadMore" @load-more="handleLoadMore"
/> />
@@ -366,6 +477,46 @@ onMounted(async () => {
@back="handleBack" @back="handleBack"
/> />
</div> </div>
<Dialog v-model:open="deleteDialogOpen">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Delete records</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div class="space-y-2 text-sm">
<p>
You are about to delete {{ pendingDeleteCount }} record<span v-if="pendingDeleteCount !== 1">s</span>.
</p>
<p v-if="deleteSummary" class="text-muted-foreground">
Deleted {{ deleteSummary.deletedIds.length }} record<span v-if="deleteSummary.deletedIds.length !== 1">s</span>.
</p>
<p v-if="deniedDeleteCount > 0" class="text-destructive">
{{ deniedDeleteCount }} record<span v-if="deniedDeleteCount !== 1">s</span> could not be deleted due to missing permissions.
</p>
<p v-if="!deleteSummary" class="text-muted-foreground">
Records you do not have permission to delete will be skipped.
</p>
</div>
<DialogFooter>
<Button variant="outline" @click="resetDeleteDialog" :disabled="deleteSubmitting">
{{ deleteSummary ? 'Close' : 'Cancel' }}
</Button>
<Button
v-if="!deleteSummary"
variant="destructive"
@click="confirmDelete"
:disabled="deleteSubmitting"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</NuxtLayout> </NuxtLayout>
</template> </template>

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useFields, useViewState } from '@/composables/useFieldViews' import { useFields, useViewState } from '@/composables/useFieldViews'
import { usePageLayouts } from '@/composables/usePageLayouts'
import ListView from '@/components/views/ListView.vue' import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailView.vue' import DetailView from '@/components/views/DetailView.vue'
import EditView from '@/components/views/EditView.vue' import EditView from '@/components/views/EditView.vue'
@@ -11,6 +12,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { api } = useApi() const { api } = useApi()
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields() const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
const { getDefaultPageLayout } = usePageLayouts()
// Get object API name from route // Get object API name from route
const objectApiName = computed(() => route.params.objectName as string) const objectApiName = computed(() => route.params.objectName as string)
@@ -25,6 +27,7 @@ const view = computed(() => {
// State // State
const objectDefinition = ref<any>(null) const objectDefinition = ref<any>(null)
const listViewLayout = ref<any>(null)
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -66,11 +69,13 @@ onBeforeUnmount(() => {
// View configs // View configs
const listConfig = computed(() => { const listConfig = computed(() => {
if (!objectDefinition.value) return null if (!objectDefinition.value) return null
// Pass the list view layout config to buildListViewConfig if available
const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig
return buildListViewConfig(objectDefinition.value, { return buildListViewConfig(objectDefinition.value, {
searchable: true, searchable: true,
exportable: true, exportable: true,
filterable: true, filterable: true,
}) }, layoutConfig)
}) })
const detailConfig = computed(() => { const detailConfig = computed(() => {
@@ -93,6 +98,16 @@ const fetchObjectDefinition = async () => {
error.value = null error.value = null
const response = await api.get(`/setup/objects/${objectApiName.value}`) const response = await api.get(`/setup/objects/${objectApiName.value}`)
objectDefinition.value = response objectDefinition.value = response
// Fetch the default list view layout for this object
if (response?.id) {
try {
listViewLayout.value = await getDefaultPageLayout(response.id, 'list')
} catch (e) {
// No list view layout configured, will use default behavior
listViewLayout.value = null
}
}
} catch (e: any) { } catch (e: any) {
error.value = e.message || 'Failed to load object definition' error.value = e.message || 'Failed to load object definition'
console.error('Error fetching object definition:', e) console.error('Error fetching object definition:', e)

View File

@@ -16,10 +16,11 @@
<!-- Tabs --> <!-- Tabs -->
<div class="mb-8"> <div class="mb-8">
<Tabs v-model="activeTab" default-value="fields" class="w-full"> <Tabs v-model="activeTab" default-value="fields" class="w-full">
<TabsList class="grid w-full grid-cols-3 max-w-2xl"> <TabsList class="grid w-full grid-cols-4 max-w-2xl">
<TabsTrigger value="fields">Fields</TabsTrigger> <TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="access">Access</TabsTrigger> <TabsTrigger value="access">Access</TabsTrigger>
<TabsTrigger value="layouts">Page Layouts</TabsTrigger> <TabsTrigger value="layouts">Page Layouts</TabsTrigger>
<TabsTrigger value="listLayouts">List View Layouts</TabsTrigger>
</TabsList> </TabsList>
<!-- Fields Tab --> <!-- Fields Tab -->
@@ -148,7 +149,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
v-if="layout.isDefault" v-if="layout.isDefault || layout.is_default"
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs" class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
> >
Default Default
@@ -185,6 +186,84 @@
/> />
</div> </div>
</TabsContent> </TabsContent>
<!-- List View Layouts Tab -->
<TabsContent value="listLayouts" class="mt-6">
<div v-if="!selectedListLayout" class="space-y-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">List View Layouts</h2>
<Button @click="handleCreateListLayout">
<Plus class="w-4 h-4 mr-2" />
New List Layout
</Button>
</div>
<p class="text-sm text-muted-foreground mb-4">
Configure which fields appear in list views and their order.
</p>
<div v-if="loadingListLayouts" class="text-center py-8">
Loading list layouts...
</div>
<div v-else-if="listLayouts.length === 0" class="text-center py-8 text-muted-foreground">
No list view layouts yet. Create one to customize your list views.
</div>
<div v-else class="space-y-2">
<div
v-for="layout in listLayouts"
:key="layout.id"
class="p-4 border rounded-lg bg-card hover:border-primary cursor-pointer transition-colors"
@click="handleSelectListLayout(layout)"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{{ layout.name }}</h3>
<p v-if="layout.description" class="text-sm text-muted-foreground">
{{ layout.description }}
</p>
<p class="text-xs text-muted-foreground mt-1">
{{ getListLayoutFieldCount(layout) }} fields configured
</p>
</div>
<div class="flex items-center gap-2">
<span
v-if="layout.isDefault || layout.is_default"
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
>
Default
</span>
<Button
variant="ghost"
size="sm"
@click.stop="handleDeleteListLayout(layout.id)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- List Layout Editor -->
<div v-else>
<div class="mb-4">
<Button variant="outline" @click="selectedListLayout = null">
<ArrowLeft class="w-4 h-4 mr-2" />
Back to List Layouts
</Button>
</div>
<ListViewLayoutEditor
:fields="object.fields"
:initial-layout="(selectedListLayout.layoutConfig || selectedListLayout.layout_config)?.fields || []"
:layout-name="selectedListLayout.name"
@save="handleSaveListLayout"
/>
</div>
</TabsContent>
</Tabs> </Tabs>
</div> </div>
</div> </div>
@@ -299,6 +378,7 @@ import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import PageLayoutEditor from '@/components/PageLayoutEditor.vue' import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
import ListViewLayoutEditor from '@/components/ListViewLayoutEditor.vue'
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue' import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
import FieldTypeSelector from '@/components/fields/FieldTypeSelector.vue' import FieldTypeSelector from '@/components/fields/FieldTypeSelector.vue'
import FieldAttributesCommon from '@/components/fields/FieldAttributesCommon.vue' import FieldAttributesCommon from '@/components/fields/FieldAttributesCommon.vue'
@@ -315,11 +395,16 @@ const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const activeTab = ref('fields') const activeTab = ref('fields')
// Page layouts state // Page layouts state (detail/edit layouts)
const layouts = ref<PageLayout[]>([]) const layouts = ref<PageLayout[]>([])
const loadingLayouts = ref(false) const loadingLayouts = ref(false)
const selectedLayout = ref<PageLayout | null>(null) const selectedLayout = ref<PageLayout | null>(null)
// List view layouts state
const listLayouts = ref<PageLayout[]>([])
const loadingListLayouts = ref(false)
const selectedListLayout = ref<PageLayout | null>(null)
// Field management state // Field management state
const showFieldDialog = ref(false) const showFieldDialog = ref(false)
const fieldDialogMode = ref<'create' | 'edit'>('create') const fieldDialogMode = ref<'create' | 'edit'>('create')
@@ -420,7 +505,8 @@ const fetchLayouts = async () => {
try { try {
loadingLayouts.value = true loadingLayouts.value = true
layouts.value = await getPageLayouts(object.value.id) // Fetch only detail layouts (default type)
layouts.value = await getPageLayouts(object.value.id, 'detail')
} catch (e: any) { } catch (e: any) {
console.error('Error fetching layouts:', e) console.error('Error fetching layouts:', e)
toast.error('Failed to load page layouts') toast.error('Failed to load page layouts')
@@ -429,6 +515,20 @@ const fetchLayouts = async () => {
} }
} }
const fetchListLayouts = async () => {
if (!object.value) return
try {
loadingListLayouts.value = true
listLayouts.value = await getPageLayouts(object.value.id, 'list')
} catch (e: any) {
console.error('Error fetching list layouts:', e)
toast.error('Failed to load list view layouts')
} finally {
loadingListLayouts.value = false
}
}
const openFieldDialog = async (mode: 'create' | 'edit', field?: any) => { const openFieldDialog = async (mode: 'create' | 'edit', field?: any) => {
fieldDialogMode.value = mode fieldDialogMode.value = mode
fieldDialogError.value = null fieldDialogError.value = null
@@ -684,6 +784,7 @@ const handleCreateLayout = async () => {
const newLayout = await createPageLayout({ const newLayout = await createPageLayout({
name, name,
objectId: object.value.id, objectId: object.value.id,
layoutType: 'detail',
isDefault: layouts.value.length === 0, isDefault: layouts.value.length === 0,
layoutConfig: { fields: [], relatedLists: [] }, layoutConfig: { fields: [], relatedLists: [] },
}) })
@@ -736,6 +837,73 @@ const handleDeleteLayout = async (layoutId: string) => {
} }
} }
// List View Layout methods
const handleCreateListLayout = async () => {
const name = prompt('Enter a name for the new list view layout:')
if (!name) return
try {
const newLayout = await createPageLayout({
name,
objectId: object.value.id,
layoutType: 'list',
isDefault: listLayouts.value.length === 0,
layoutConfig: { fields: [] },
})
listLayouts.value.push(newLayout)
selectedListLayout.value = newLayout
toast.success('List view layout created successfully')
} catch (e: any) {
console.error('Error creating list layout:', e)
toast.error('Failed to create list view layout')
}
}
const handleSelectListLayout = (layout: PageLayout) => {
selectedListLayout.value = layout
}
const handleSaveListLayout = async (layoutConfig: { fields: FieldLayoutItem[] }) => {
if (!selectedListLayout.value) return
try {
const updated = await updatePageLayout(selectedListLayout.value.id, {
layoutConfig,
})
// Update the layout in the list
const index = listLayouts.value.findIndex(l => l.id === selectedListLayout.value!.id)
if (index !== -1) {
listLayouts.value[index] = updated
}
selectedListLayout.value = updated
toast.success('List view layout saved successfully')
} catch (e: any) {
console.error('Error saving list layout:', e)
toast.error('Failed to save list view layout')
}
}
const handleDeleteListLayout = async (layoutId: string) => {
if (!confirm('Are you sure you want to delete this list view layout?')) return
try {
await deletePageLayout(layoutId)
listLayouts.value = listLayouts.value.filter(l => l.id !== layoutId)
toast.success('List view layout deleted successfully')
} catch (e: any) {
console.error('Error deleting list layout:', e)
toast.error('Failed to delete list view layout')
}
}
const getListLayoutFieldCount = (layout: PageLayout): number => {
const config = layout.layoutConfig || layout.layout_config
return config?.fields?.length || 0
}
const handleAccessUpdate = (orgWideDefault: string) => { const handleAccessUpdate = (orgWideDefault: string) => {
if (object.value) { if (object.value) {
object.value.orgWideDefault = orgWideDefault object.value.orgWideDefault = orgWideDefault
@@ -747,6 +915,9 @@ watch(activeTab, (newTab) => {
if (newTab === 'layouts' && layouts.value.length === 0 && !loadingLayouts.value) { if (newTab === 'layouts' && layouts.value.length === 0 && !loadingLayouts.value) {
fetchLayouts() fetchLayouts()
} }
if (newTab === 'listLayouts' && listLayouts.value.length === 0 && !loadingListLayouts.value) {
fetchListLayouts()
}
}) })
onMounted(async () => { onMounted(async () => {
@@ -755,5 +926,8 @@ onMounted(async () => {
if (activeTab.value === 'layouts') { if (activeTab.value === 'layouts') {
await fetchLayouts() await fetchLayouts()
} }
if (activeTab.value === 'listLayouts') {
await fetchListLayouts()
}
}) })
</script> </script>

View File

@@ -1,11 +1,15 @@
export interface FieldLayoutItem { export interface FieldLayoutItem {
fieldId: string; fieldId: string;
x: number; x?: number;
y: number; y?: number;
w: number; w?: number;
h: number; h?: number;
// For list layouts: field order (optional)
order?: number;
} }
export type PageLayoutType = 'detail' | 'list';
export interface PageLayoutConfig { export interface PageLayoutConfig {
fields: FieldLayoutItem[]; fields: FieldLayoutItem[];
relatedLists?: string[]; relatedLists?: string[];
@@ -15,16 +19,23 @@ export interface PageLayout {
id: string; id: string;
name: string; name: string;
objectId: string; objectId: string;
layoutType: PageLayoutType;
isDefault: boolean; isDefault: boolean;
layoutConfig: PageLayoutConfig; layoutConfig: PageLayoutConfig;
description?: string; description?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
// Database column names (snake_case) - used when data comes directly from DB
layout_type?: PageLayoutType;
layout_config?: PageLayoutConfig;
object_id?: string;
is_default?: boolean;
} }
export interface CreatePageLayoutRequest { export interface CreatePageLayoutRequest {
name: string; name: string;
objectId: string; objectId: string;
layoutType?: PageLayoutType;
isDefault?: boolean; isDefault?: boolean;
layoutConfig: PageLayoutConfig; layoutConfig: PageLayoutConfig;
description?: string; description?: string;