Compare commits
18 Commits
copilot-wo
...
730fddd181
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
730fddd181 | ||
|
|
a62f68fc10 | ||
|
|
d2b3fce4eb | ||
|
|
ca11c8cbe7 | ||
|
|
f8a3cffb64 | ||
|
|
852c4e28d2 | ||
|
|
2075fec183 | ||
|
|
9be98e4a09 | ||
|
|
43cae4289b | ||
|
|
4de9203fd5 | ||
|
|
8b9fa81594 | ||
|
|
a75b41fd0b | ||
|
|
7ae36411db | ||
|
|
c9a3e00a94 | ||
|
|
8ad3fac1b0 | ||
|
|
b34da6956c | ||
|
|
6c73eb1658 | ||
|
|
8e4690c9c9 |
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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']);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
136
backend/package-lock.json
generated
136
backend/package-lock.json
generated
@@ -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.15",
|
"@langchain/core": "^1.1.12",
|
||||||
"@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,10 +29,9 @@
|
|||||||
"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.10",
|
"langchain": "^1.2.7",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"objection": "^3.1.5",
|
"objection": "^3.1.5",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
@@ -229,26 +228,6 @@
|
|||||||
"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",
|
||||||
@@ -710,15 +689,6 @@
|
|||||||
"@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",
|
||||||
@@ -1719,26 +1689,10 @@
|
|||||||
"@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.15",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.12.tgz",
|
||||||
"integrity": "sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA==",
|
"integrity": "sha512-sHWLvhyLi3fntlg3MEPB89kCjxEX7/+imlIYJcp6uFGCAZfGxVWklqp22HwjT1szorUBYrkO8u0YA554ReKxGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.0.2",
|
"@cfworker/json-schema": "^4.0.2",
|
||||||
@@ -2533,6 +2487,7 @@
|
|||||||
"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",
|
||||||
@@ -2546,6 +2501,7 @@
|
|||||||
"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"
|
||||||
@@ -2555,6 +2511,7 @@
|
|||||||
"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",
|
||||||
@@ -4072,6 +4029,7 @@
|
|||||||
"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"
|
||||||
@@ -4796,22 +4754,6 @@
|
|||||||
"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",
|
||||||
@@ -5572,6 +5514,7 @@
|
|||||||
"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",
|
||||||
@@ -5797,6 +5740,7 @@
|
|||||||
"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"
|
||||||
@@ -6201,6 +6145,7 @@
|
|||||||
"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"
|
||||||
@@ -6649,6 +6594,7 @@
|
|||||||
"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"
|
||||||
@@ -6677,6 +6623,7 @@
|
|||||||
"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"
|
||||||
@@ -6711,6 +6658,7 @@
|
|||||||
"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"
|
||||||
@@ -7661,19 +7609,6 @@
|
|||||||
"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",
|
||||||
@@ -7876,9 +7811,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/langchain": {
|
"node_modules/langchain": {
|
||||||
"version": "1.2.10",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.7.tgz",
|
||||||
"integrity": "sha512-9uVxOJE/RTECvNutQfOLwH7f6R9mcq0G/IMHwA2eptDA86R/Yz2zWMz4vARVFPxPrdSJ9nJFDPAqRQlRFwdHBw==",
|
"integrity": "sha512-G+3Ftz/08CurJaE7LukQGBf3mCSz7XM8LZeAaFPg391Ru4lT8eLYfG6Fv4ZI0u6EBsPVcOQfaS9ig8nCRmJeqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/langgraph": "^1.0.0",
|
"@langchain/langgraph": "^1.0.0",
|
||||||
@@ -7891,7 +7826,7 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@langchain/core": "1.1.15"
|
"@langchain/core": "1.1.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/langchain/node_modules/uuid": {
|
"node_modules/langchain/node_modules/uuid": {
|
||||||
@@ -8266,6 +8201,7 @@
|
|||||||
"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"
|
||||||
@@ -8275,6 +8211,7 @@
|
|||||||
"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",
|
||||||
@@ -8288,6 +8225,7 @@
|
|||||||
"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"
|
||||||
@@ -9450,6 +9388,7 @@
|
|||||||
"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",
|
||||||
@@ -9788,6 +9727,7 @@
|
|||||||
"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",
|
||||||
@@ -10717,6 +10657,7 @@
|
|||||||
"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"
|
||||||
@@ -10768,12 +10709,6 @@
|
|||||||
"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",
|
||||||
@@ -11520,21 +11455,6 @@
|
|||||||
"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",
|
||||||
@@ -11588,9 +11508,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.5",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -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.15",
|
"@langchain/core": "^1.1.12",
|
||||||
"@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,10 +46,9 @@
|
|||||||
"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.10",
|
"langchain": "^1.2.7",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"objection": "^3.1.5",
|
"objection": "^3.1.5",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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)
|
||||||
@@ -25,17 +24,4 @@ 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
@@ -12,94 +12,15 @@ export interface AiChatContext {
|
|||||||
|
|
||||||
export interface AiAssistantReply {
|
export interface AiAssistantReply {
|
||||||
reply: string;
|
reply: string;
|
||||||
action?: 'create_record' | 'collect_fields' | 'clarify' | 'plan_complete' | 'plan_pending';
|
action?: 'create_record' | 'collect_fields' | 'clarify';
|
||||||
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>;
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,7 @@
|
|||||||
import { Model, ModelOptions, QueryContext } from 'objection';
|
import { Model, ModelOptions, QueryContext, snakeCaseMappers } 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;
|
||||||
|
|||||||
@@ -79,10 +79,6 @@ 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,
|
||||||
@@ -99,7 +95,7 @@ export class FieldMapperService {
|
|||||||
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
|
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
|
||||||
|
|
||||||
// View visibility
|
// View visibility
|
||||||
showOnList: uiMetadata.showOnList !== undefined ? uiMetadata.showOnList : defaultShowOnList,
|
showOnList: uiMetadata.showOnList !== false,
|
||||||
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,
|
||||||
@@ -145,7 +141,6 @@ 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',
|
||||||
|
|||||||
@@ -179,8 +179,7 @@ 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> {
|
||||||
const baseSchema = () => {
|
switch (field.type.toUpperCase()) {
|
||||||
switch (field.type.toUpperCase()) {
|
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
case 'STRING':
|
case 'STRING':
|
||||||
case 'EMAIL':
|
case 'EMAIL':
|
||||||
@@ -188,57 +187,45 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,25 +10,6 @@ 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);
|
||||||
@@ -81,10 +62,8 @@ 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 and add any missing system fields
|
// Normalize all fields to ensure system fields are properly marked
|
||||||
const normalizedFields = this.addMissingSystemFields(
|
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
|
||||||
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;
|
||||||
@@ -530,220 +509,6 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildAuthorizedQuery(
|
|
||||||
tenantId: string,
|
|
||||||
objectApiName: string,
|
|
||||||
userId: string,
|
|
||||||
) {
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
|
|
||||||
const user = await User.query(knex)
|
|
||||||
.findById(userId)
|
|
||||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectDefModel = await ObjectDefinition.query(knex)
|
|
||||||
.findOne({ apiName: objectApiName })
|
|
||||||
.withGraphFetched('fields');
|
|
||||||
|
|
||||||
if (!objectDefModel) {
|
|
||||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize and enrich fields to include system fields for downstream permissions/search
|
|
||||||
const normalizedFields = this.addMissingSystemFields(
|
|
||||||
(objectDefModel.fields || []).map((field: any) => this.normalizeField(field)),
|
|
||||||
);
|
|
||||||
objectDefModel.fields = normalizedFields;
|
|
||||||
|
|
||||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
|
||||||
|
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
|
||||||
let query = boundModel.query();
|
|
||||||
|
|
||||||
await this.authService.applyScopeToQuery(
|
|
||||||
query,
|
|
||||||
objectDefModel,
|
|
||||||
user,
|
|
||||||
'read',
|
|
||||||
knex,
|
|
||||||
);
|
|
||||||
|
|
||||||
const lookupFields = objectDefModel.fields?.filter((field) =>
|
|
||||||
field.type === 'LOOKUP' && field.referenceObject,
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
if (lookupFields.length > 0) {
|
|
||||||
const relationExpression = lookupFields
|
|
||||||
.map((field) => field.apiName.replace(/Id$/, '').toLowerCase())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
if (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
|
// Runtime endpoints - CRUD operations
|
||||||
async getRecords(
|
async getRecords(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -751,131 +516,81 @@ export class ObjectService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
filters?: any,
|
filters?: any,
|
||||||
) {
|
) {
|
||||||
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
tenantId,
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
objectApiName,
|
|
||||||
userId,
|
// Get user with roles and permissions
|
||||||
|
const user = await User.query(knex)
|
||||||
|
.findById(userId)
|
||||||
|
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get object definition with authorization settings
|
||||||
|
const objectDefModel = await ObjectDefinition.query(knex)
|
||||||
|
.findOne({ apiName: objectApiName })
|
||||||
|
.withGraphFetched('fields');
|
||||||
|
|
||||||
|
if (!objectDefModel) {
|
||||||
|
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = this.getTableName(
|
||||||
|
objectDefModel.apiName,
|
||||||
|
objectDefModel.label,
|
||||||
|
objectDefModel.pluralLabel,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract pagination and sorting parameters from query string
|
// Ensure model is registered
|
||||||
const {
|
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
sortField,
|
|
||||||
sortDirection,
|
|
||||||
...rawFilters
|
|
||||||
} = filters || {};
|
|
||||||
|
|
||||||
const reservedFilterKeys = new Set(['page', 'pageSize', 'sortField', 'sortDirection']);
|
// Use Objection model
|
||||||
const filterEntries = Object.entries(rawFilters || {}).filter(
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
([key, value]) =>
|
let query = boundModel.query();
|
||||||
!reservedFilterKeys.has(key) &&
|
|
||||||
value !== undefined &&
|
// Apply authorization scope (modifies query in place)
|
||||||
value !== null &&
|
await this.authService.applyScopeToQuery(
|
||||||
value !== '',
|
query,
|
||||||
|
objectDefModel,
|
||||||
|
user,
|
||||||
|
'read',
|
||||||
|
knex,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (filterEntries.length > 0) {
|
// Build graph expression for lookup fields
|
||||||
query = query.where(builder => {
|
const lookupFields = objectDefModel.fields?.filter(f =>
|
||||||
for (const [key, value] of filterEntries) {
|
f.type === 'LOOKUP' && f.referenceObject
|
||||||
builder.where(key, value as any);
|
) || [];
|
||||||
}
|
|
||||||
});
|
if (lookupFields.length > 0) {
|
||||||
|
// Build relation expression - use singular lowercase for relation name
|
||||||
|
const relationExpression = lookupFields
|
||||||
|
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
if (relationExpression) {
|
||||||
|
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortField) {
|
// Apply additional filters
|
||||||
const direction = sortDirection === 'desc' ? 'desc' : 'asc';
|
if (filters) {
|
||||||
query = query.orderBy(sortField, direction);
|
query = query.where(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
|
const records = await query.select('*');
|
||||||
}
|
|
||||||
|
// Filter fields based on field-level permissions
|
||||||
async searchRecordsByIds(
|
const filteredRecords = await Promise.all(
|
||||||
tenantId: string,
|
records.map(record =>
|
||||||
objectApiName: string,
|
this.authService.filterReadableFields(record, objectDefModel.fields, user)
|
||||||
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 { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
|
||||||
tenantId,
|
|
||||||
objectApiName,
|
|
||||||
userId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
query.whereIn('id', recordIds);
|
return filteredRecords;
|
||||||
const orderBindings: any[] = ['id'];
|
|
||||||
const cases = recordIds
|
|
||||||
.map((id, index) => {
|
|
||||||
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(
|
||||||
@@ -1179,8 +894,7 @@ export class ObjectService {
|
|||||||
objectApiName,
|
objectApiName,
|
||||||
editableData,
|
editableData,
|
||||||
);
|
);
|
||||||
// Use patch to avoid validating or overwriting fields that aren't present in the edit view
|
await boundModel.query().where({ id: recordId }).update(normalizedEditableData);
|
||||||
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;
|
||||||
@@ -1238,95 +952,6 @@ export class ObjectService {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRecords(
|
|
||||||
tenantId: string,
|
|
||||||
objectApiName: string,
|
|
||||||
recordIds: string[],
|
|
||||||
userId: string,
|
|
||||||
) {
|
|
||||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
|
||||||
throw new BadRequestException('No record IDs provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
|
|
||||||
// Get user with roles and permissions
|
|
||||||
const user = await User.query(knex)
|
|
||||||
.findById(userId)
|
|
||||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get object definition with authorization settings
|
|
||||||
const objectDefModel = await ObjectDefinition.query(knex)
|
|
||||||
.findOne({ apiName: objectApiName });
|
|
||||||
|
|
||||||
if (!objectDefModel) {
|
|
||||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableName = this.getTableName(
|
|
||||||
objectDefModel.apiName,
|
|
||||||
objectDefModel.label,
|
|
||||||
objectDefModel.pluralLabel,
|
|
||||||
);
|
|
||||||
|
|
||||||
const records = await knex(tableName).whereIn('id', recordIds);
|
|
||||||
if (records.length === 0) {
|
|
||||||
throw new NotFoundException('No records found to delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundIds = new Set(records.map((record: any) => record.id));
|
|
||||||
const missingIds = recordIds.filter(id => !foundIds.has(id));
|
|
||||||
if (missingIds.length > 0) {
|
|
||||||
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletableIds: string[] = [];
|
|
||||||
const deniedIds: string[] = [];
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
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
|
|
||||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
|
||||||
|
|
||||||
// Use Objection model
|
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
|
||||||
if (deletableIds.length > 0) {
|
|
||||||
await boundModel.query().whereIn('id', deletableIds).delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from search index
|
|
||||||
await Promise.all(
|
|
||||||
deletableIds.map((id) =>
|
|
||||||
this.removeIndexedRecord(resolvedTenantId, objectApiName, id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
deleted: deletableIds.length,
|
|
||||||
deletedIds: deletableIds,
|
|
||||||
deniedIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async indexRecord(
|
private async indexRecord(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
@@ -1339,32 +964,12 @@ 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(
|
||||||
@@ -1377,48 +982,15 @@ 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 [
|
||||||
'STRING',
|
'id',
|
||||||
'TEXT',
|
'ownerId',
|
||||||
'EMAIL',
|
'created_at',
|
||||||
'PHONE',
|
'updated_at',
|
||||||
'URL',
|
'createdAt',
|
||||||
'RICH_TEXT',
|
'updatedAt',
|
||||||
'TEXTAREA',
|
'tenantId',
|
||||||
].includes(normalized);
|
].includes(apiName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async normalizePolymorphicRelatedObject(
|
private async normalizePolymorphicRelatedObject(
|
||||||
|
|||||||
@@ -95,20 +95,4 @@ export class RuntimeObjectController {
|
|||||||
user.userId,
|
user.userId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':objectApiName/records/bulk-delete')
|
|
||||||
async deleteRecords(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('objectApiName') objectApiName: string,
|
|
||||||
@Body() body: { recordIds?: string[]; ids?: string[] },
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
) {
|
|
||||||
const recordIds: string[] = body?.recordIds || body?.ids || [];
|
|
||||||
return this.objectService.deleteRecords(
|
|
||||||
tenantId,
|
|
||||||
objectApiName,
|
|
||||||
recordIds,
|
|
||||||
user.userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject, IsIn } from 'class-validator';
|
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator';
|
||||||
|
|
||||||
export type PageLayoutType = 'detail' | 'list';
|
|
||||||
|
|
||||||
export class CreatePageLayoutDto {
|
export class CreatePageLayoutDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -9,25 +7,18 @@ 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[];
|
||||||
};
|
};
|
||||||
@@ -51,11 +42,10 @@ 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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, PageLayoutType } from './dto/page-layout.dto';
|
import { CreatePageLayoutDto, UpdatePageLayoutDto } 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,21 +25,13 @@ export class PageLayoutController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) {
|
||||||
@TenantId() tenantId: string,
|
return this.pageLayoutService.findAll(tenantId, objectId);
|
||||||
@Query('objectId') objectId?: string,
|
|
||||||
@Query('layoutType') layoutType?: PageLayoutType,
|
|
||||||
) {
|
|
||||||
return this.pageLayoutService.findAll(tenantId, objectId, layoutType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('default/:objectId')
|
@Get('default/:objectId')
|
||||||
findDefaultByObject(
|
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) {
|
||||||
@TenantId() tenantId: string,
|
return this.pageLayoutService.findDefaultByObject(tenantId, objectId);
|
||||||
@Param('objectId') objectId: string,
|
|
||||||
@Query('layoutType') layoutType?: PageLayoutType,
|
|
||||||
) {
|
|
||||||
return this.pageLayoutService.findDefaultByObject(tenantId, objectId, layoutType || 'detail');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -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, PageLayoutType } from './dto/page-layout.dto';
|
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageLayoutService {
|
export class PageLayoutService {
|
||||||
@@ -8,19 +8,17 @@ 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 and layout type
|
// If this layout is set as default, unset other defaults for the same object
|
||||||
if (createDto.isDefault) {
|
if (createDto.isDefault) {
|
||||||
await knex('page_layouts')
|
await knex('page_layouts')
|
||||||
.where({ object_id: createDto.objectId, layout_type: layoutType })
|
.where({ object_id: createDto.objectId })
|
||||||
.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,
|
||||||
@@ -31,7 +29,7 @@ export class PageLayoutService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) {
|
async findAll(tenantId: string, objectId?: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
let query = knex('page_layouts');
|
let query = knex('page_layouts');
|
||||||
@@ -40,10 +38,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -60,11 +54,11 @@ export class PageLayoutService {
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') {
|
async findDefaultByObject(tenantId: string, objectId: string) {
|
||||||
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, layout_type: layoutType })
|
.where({ object_id: objectId, is_default: true })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
return layout || null;
|
return layout || null;
|
||||||
@@ -74,12 +68,13 @@ 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
|
||||||
const layout = await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
// If setting as default, unset other defaults for the same object and layout type
|
// If setting as default, unset other defaults for the same object
|
||||||
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, layout_type: layout.layout_type })
|
.where({ object_id: layout.object_id })
|
||||||
.whereNot({ id })
|
.whereNot({ id })
|
||||||
.update({ is_default: false });
|
.update({ is_default: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ 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,
|
||||||
@@ -68,48 +66,6 @@ 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,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ 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()
|
||||||
|
|
||||||
@@ -193,17 +190,9 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-30 flex justify-center px-2">
|
||||||
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 transition-all duration-200"
|
class="pointer-events-auto w-full border border-border bg-background shadow-xl 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">
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -85,31 +85,9 @@ 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:
|
||||||
try {
|
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
|
||||||
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:
|
||||||
try {
|
return val instanceof Date ? val.toLocaleString() : new Date(val).toLocaleString()
|
||||||
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:
|
||||||
|
|||||||
@@ -78,9 +78,7 @@ const fetchRecords = async () => {
|
|||||||
try {
|
try {
|
||||||
const endpoint = `${props.baseUrl}/${relationObject.value}/records`
|
const endpoint = `${props.baseUrl}/${relationObject.value}/records`
|
||||||
const response = await api.get(endpoint)
|
const response = await api.get(endpoint)
|
||||||
records.value = Array.isArray(response)
|
records.value = response || []
|
||||||
? response
|
|
||||||
: response?.data || response?.records || []
|
|
||||||
|
|
||||||
// If we have a modelValue, find the selected record
|
// If we have a modelValue, find the selected record
|
||||||
if (props.modelValue) {
|
if (props.modelValue) {
|
||||||
|
|||||||
@@ -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',
|
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',
|
||||||
props.class)"
|
props.class)"
|
||||||
>
|
>
|
||||||
<CheckboxIndicator class="grid place-content-center text-current">
|
<CheckboxIndicator class="grid place-content-center text-current">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -12,7 +12,6 @@ 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,8 +22,6 @@ interface Props {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
totalCount?: number
|
|
||||||
searchSummary?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -32,7 +29,6 @@ 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<{
|
||||||
@@ -45,91 +41,41 @@ const emit = defineEmits<{
|
|||||||
'sort': [field: string, direction: 'asc' | 'desc']
|
'sort': [field: string, direction: 'asc' | 'desc']
|
||||||
'search': [query: string]
|
'search': [query: string]
|
||||||
'refresh': []
|
'refresh': []
|
||||||
'page-change': [page: number, pageSize: number]
|
|
||||||
'load-more': [page: number, pageSize: number]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const normalizeId = (id: any) => String(id)
|
const selectedRows = ref<Set<string>>(new Set())
|
||||||
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 bulkAction = ref('delete')
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const visibleFields = computed(() =>
|
const visibleFields = computed(() =>
|
||||||
props.config.fields.filter(f => f.showOnList !== false)
|
props.config.fields.filter(f => f.showOnList !== false)
|
||||||
)
|
)
|
||||||
|
|
||||||
const pageSize = computed(() => props.config.pageSize ?? 10)
|
|
||||||
const maxFrontendRecords = computed(() => props.config.maxFrontendRecords ?? 500)
|
|
||||||
const totalRecords = computed(() =>
|
|
||||||
(props.totalCount && props.totalCount > 0)
|
|
||||||
? props.totalCount
|
|
||||||
: props.data.length
|
|
||||||
)
|
|
||||||
const useHybridPagination = computed(() => totalRecords.value > maxFrontendRecords.value)
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalRecords.value / pageSize.value)))
|
|
||||||
const loadedPages = computed(() => Math.max(1, Math.ceil(props.data.length / pageSize.value)))
|
|
||||||
const availablePages = computed(() => {
|
|
||||||
if (useHybridPagination.value && props.totalCount && props.data.length < props.totalCount) {
|
|
||||||
return loadedPages.value
|
|
||||||
}
|
|
||||||
return totalPages.value
|
|
||||||
})
|
|
||||||
const startIndex = computed(() => (currentPage.value - 1) * pageSize.value)
|
|
||||||
const paginatedData = computed(() => {
|
|
||||||
const start = startIndex.value
|
|
||||||
const end = start + pageSize.value
|
|
||||||
return props.data.slice(start, end)
|
|
||||||
})
|
|
||||||
const pageStart = computed(() => (props.data.length === 0 ? 0 : startIndex.value + 1))
|
|
||||||
const pageEnd = computed(() => Math.min(startIndex.value + paginatedData.value.length, totalRecords.value))
|
|
||||||
const showPagination = computed(() => totalRecords.value > pageSize.value)
|
|
||||||
const canGoPrev = computed(() => currentPage.value > 1)
|
|
||||||
const canGoNext = computed(() => currentPage.value < availablePages.value)
|
|
||||||
const showLoadMore = computed(() => (
|
|
||||||
useHybridPagination.value &&
|
|
||||||
Boolean(props.totalCount) &&
|
|
||||||
props.data.length < totalRecords.value
|
|
||||||
))
|
|
||||||
|
|
||||||
const allSelected = computed({
|
const allSelected = computed({
|
||||||
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
|
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
|
||||||
set: (val: boolean) => {
|
set: (val: boolean) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
|
selectedRows.value = new Set(props.data.map(row => row.id))
|
||||||
} else {
|
} else {
|
||||||
selectedRowIds.value = []
|
selectedRows.value.clear()
|
||||||
}
|
}
|
||||||
emit('row-select', getSelectedRows())
|
emit('row-select', getSelectedRows())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const getSelectedRows = () => {
|
const getSelectedRows = () => {
|
||||||
const idSet = new Set(selectedRowIds.value)
|
return props.data.filter(row => selectedRows.value.has(row.id))
|
||||||
return props.data.filter(row => idSet.has(normalizeId(row.id)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleRowSelection = (rowId: string) => {
|
const toggleRowSelection = (rowId: string) => {
|
||||||
const normalizedId = normalizeId(rowId)
|
if (selectedRows.value.has(rowId)) {
|
||||||
const nextSelection = new Set(selectedRowIds.value)
|
selectedRows.value.delete(rowId)
|
||||||
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 {
|
||||||
nextSelection.delete(normalizedId)
|
selectedRows.value.add(rowId)
|
||||||
}
|
}
|
||||||
selectedRowIds.value = Array.from(nextSelection)
|
|
||||||
emit('row-select', getSelectedRows())
|
emit('row-select', getSelectedRows())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,49 +96,6 @@ const handleSearch = () => {
|
|||||||
const handleAction = (actionId: string) => {
|
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 nextPage = Math.min(Math.max(page, 1), availablePages.value)
|
|
||||||
if (nextPage !== currentPage.value) {
|
|
||||||
currentPage.value = nextPage
|
|
||||||
emit('page-change', nextPage, pageSize.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMore = () => {
|
|
||||||
const nextPage = Math.ceil(props.data.length / pageSize.value) + 1
|
|
||||||
emit('load-more', nextPage, pageSize.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.data.length, totalRecords.value, pageSize.value],
|
|
||||||
() => {
|
|
||||||
if (currentPage.value > availablePages.value) {
|
|
||||||
currentPage.value = availablePages.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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>
|
||||||
@@ -210,31 +113,18 @@ 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="selectedRowIds.length > 0">
|
<template v-if="selectedRows.size > 0">
|
||||||
<Badge variant="secondary" class="px-3 py-1">
|
<Badge variant="secondary" class="px-3 py-1">
|
||||||
{{ selectedRowIds.length }} selected
|
{{ selectedRows.size }} selected
|
||||||
</Badge>
|
</Badge>
|
||||||
<div class="flex items-center gap-2">
|
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
|
||||||
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
|
<Trash2 class="h-4 w-4 mr-2" />
|
||||||
<SelectTrigger class="h-8 w-[180px]">
|
Delete
|
||||||
<SelectValue placeholder="Select action" />
|
</Button>
|
||||||
</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 -->
|
||||||
@@ -268,10 +158,7 @@ watch(
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead v-if="selectable" class="w-12">
|
<TableHead v-if="selectable" class="w-12">
|
||||||
<Checkbox
|
<Checkbox v-model:checked="allSelected" />
|
||||||
:model-value="allSelected"
|
|
||||||
@update:model-value="(value: boolean) => (allSelected = value)"
|
|
||||||
/>
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
v-for="field in visibleFields"
|
v-for="field in visibleFields"
|
||||||
@@ -305,15 +192,15 @@ watch(
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow
|
<TableRow
|
||||||
v-else
|
v-else
|
||||||
v-for="row in paginatedData"
|
v-for="row in data"
|
||||||
:key="row.id"
|
:key="row.id"
|
||||||
class="cursor-pointer hover:bg-muted/50"
|
class="cursor-pointer hover:bg-muted/50"
|
||||||
@click="emit('row-click', row)"
|
@click="emit('row-click', row)"
|
||||||
>
|
>
|
||||||
<TableCell v-if="selectable" @click.stop>
|
<TableCell v-if="selectable" @click.stop>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="selectedRowIds.includes(normalizeId(row.id))"
|
:checked="selectedRows.has(row.id)"
|
||||||
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
|
@update:checked="toggleRowSelection(row.id)"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell v-for="field in visibleFields" :key="field.id">
|
<TableCell v-for="field in visibleFields" :key="field.id">
|
||||||
@@ -340,26 +227,7 @@ watch(
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
|
<!-- Pagination would go here -->
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>Showing {{ pageStart }}-{{ pageEnd }} of {{ totalRecords }} records</span>
|
|
||||||
<span v-if="showLoadMore">
|
|
||||||
(loaded {{ data.length }})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" :disabled="!canGoPrev" @click="goToPage(currentPage - 1)">
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span class="px-2">Page {{ currentPage }} of {{ totalPages }}</span>
|
|
||||||
<Button variant="outline" size="sm" :disabled="!canGoNext" @click="goToPage(currentPage + 1)">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
<Button v-if="showLoadMore" variant="secondary" size="sm" @click="loadMore">
|
|
||||||
Load more
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ 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,
|
||||||
@@ -40,7 +37,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 ?? !shouldHideOnList,
|
showOnList: fieldDef.showOnList ?? true,
|
||||||
showOnDetail: fieldDef.showOnDetail ?? true,
|
showOnDetail: fieldDef.showOnDetail ?? true,
|
||||||
showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
|
showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
|
||||||
sortable: fieldDef.sortable ?? true,
|
sortable: fieldDef.sortable ?? true,
|
||||||
@@ -70,43 +67,18 @@ 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 => {
|
||||||
let fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
|
const 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,
|
||||||
mode: 'list' as ViewMode,
|
mode: 'list' as ViewMode,
|
||||||
fields,
|
fields,
|
||||||
pageSize: 10,
|
pageSize: 25,
|
||||||
maxFrontendRecords: 500,
|
|
||||||
searchable: true,
|
searchable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
exportable: true,
|
exportable: true,
|
||||||
@@ -211,7 +183,6 @@ export const useViewState = <T extends { id?: string }>(
|
|||||||
apiEndpoint: string
|
apiEndpoint: string
|
||||||
) => {
|
) => {
|
||||||
const records = ref<T[]>([])
|
const records = ref<T[]>([])
|
||||||
const totalCount = ref(0)
|
|
||||||
const currentRecord = ref<T | null>(null)
|
const currentRecord = ref<T | null>(null)
|
||||||
const currentView = ref<'list' | 'detail' | 'edit'>('list')
|
const currentView = ref<'list' | 'detail' | 'edit'>('list')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -220,51 +191,13 @@ export const useViewState = <T extends { id?: string }>(
|
|||||||
|
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
|
|
||||||
const normalizeListResponse = (response: any) => {
|
const fetchRecords = async (params?: Record<string, any>) => {
|
||||||
const payload: { data: T[]; totalCount: number; page?: number; pageSize?: number } = {
|
|
||||||
data: [],
|
|
||||||
totalCount: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
payload.data = response
|
|
||||||
payload.totalCount = response.length
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response && typeof response === 'object') {
|
|
||||||
if (Array.isArray(response.data)) {
|
|
||||||
payload.data = response.data
|
|
||||||
} else if (Array.isArray((response as any).records)) {
|
|
||||||
payload.data = (response as any).records
|
|
||||||
} else if (Array.isArray((response as any).results)) {
|
|
||||||
payload.data = (response as any).results
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.totalCount =
|
|
||||||
response.totalCount ??
|
|
||||||
response.total ??
|
|
||||||
response.count ??
|
|
||||||
payload.data.length ??
|
|
||||||
0
|
|
||||||
payload.page = response.page
|
|
||||||
payload.pageSize = response.pageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRecords = async (params?: Record<string, any>, options?: { append?: boolean }) => {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await api.get(apiEndpoint, { params })
|
const response = await api.get(apiEndpoint, { params })
|
||||||
const normalized = normalizeListResponse(response)
|
// Handle response - data might be directly in response or in response.data
|
||||||
totalCount.value = normalized.totalCount ?? normalized.data.length ?? 0
|
records.value = response.data || response || []
|
||||||
records.value = options?.append
|
|
||||||
? [...records.value, ...normalized.data]
|
|
||||||
: normalized.data
|
|
||||||
return normalized
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
console.error('Failed to fetch records:', e)
|
console.error('Failed to fetch records:', e)
|
||||||
@@ -297,7 +230,6 @@ export const useViewState = <T extends { id?: string }>(
|
|||||||
// Handle response - it might be the data directly or wrapped in { data: ... }
|
// Handle response - it might be the data directly or wrapped in { data: ... }
|
||||||
const recordData = response.data || response
|
const recordData = response.data || response
|
||||||
records.value.push(recordData)
|
records.value.push(recordData)
|
||||||
totalCount.value += 1
|
|
||||||
currentRecord.value = recordData
|
currentRecord.value = recordData
|
||||||
return recordData
|
return recordData
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -340,7 +272,6 @@ export const useViewState = <T extends { id?: string }>(
|
|||||||
try {
|
try {
|
||||||
await api.delete(`${apiEndpoint}/${id}`)
|
await api.delete(`${apiEndpoint}/${id}`)
|
||||||
records.value = records.value.filter(r => r.id !== id)
|
records.value = records.value.filter(r => r.id !== id)
|
||||||
totalCount.value = Math.max(0, totalCount.value - 1)
|
|
||||||
if (currentRecord.value?.id === id) {
|
if (currentRecord.value?.id === id) {
|
||||||
currentRecord.value = null
|
currentRecord.value = null
|
||||||
}
|
}
|
||||||
@@ -357,25 +288,8 @@ 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)
|
|
||||||
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)
|
||||||
@@ -413,7 +327,6 @@ export const useViewState = <T extends { id?: string }>(
|
|||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
records,
|
records,
|
||||||
totalCount,
|
|
||||||
currentRecord,
|
currentRecord,
|
||||||
currentView,
|
currentView,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest, PageLayoutType } from '~/types/page-layout'
|
import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest } from '~/types/page-layout'
|
||||||
|
|
||||||
export const usePageLayouts = () => {
|
export const usePageLayouts = () => {
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
|
|
||||||
const getPageLayouts = async (objectId?: string, layoutType?: PageLayoutType) => {
|
const getPageLayouts = async (objectId?: string) => {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {}
|
const params = objectId ? { objectId } : {}
|
||||||
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) {
|
||||||
@@ -26,11 +24,9 @@ export const usePageLayouts = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultPageLayout = async (objectId: string, layoutType: PageLayoutType = 'detail') => {
|
const getDefaultPageLayout = async (objectId: string) => {
|
||||||
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)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { 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,9 +15,6 @@ 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
|
||||||
@@ -33,47 +30,12 @@ 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 ref="insetRef" class="relative flex flex-col">
|
<SidebarInset class="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"
|
||||||
>
|
>
|
||||||
@@ -111,8 +73,7 @@ onBeforeUnmount(() => {
|
|||||||
<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>
|
||||||
|
|||||||
831
frontend/package-lock.json
generated
831
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } 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()
|
||||||
@@ -42,14 +32,12 @@ 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)
|
||||||
|
|
||||||
// Use view state composable
|
// Use view state composable
|
||||||
const {
|
const {
|
||||||
records,
|
records,
|
||||||
totalCount,
|
|
||||||
currentRecord,
|
currentRecord,
|
||||||
loading: dataLoading,
|
loading: dataLoading,
|
||||||
saving,
|
saving,
|
||||||
@@ -69,7 +57,7 @@ const handleAiRecordCreated = (event: Event) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (view.value === 'list') {
|
if (view.value === 'list') {
|
||||||
initializeListRecords()
|
fetchRecords()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +125,11 @@ 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(() => {
|
||||||
@@ -156,20 +142,6 @@ const editConfig = computed(() => {
|
|||||||
return buildEditViewConfig(objectDefinition.value)
|
return buildEditViewConfig(objectDefinition.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
|
||||||
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 () => {
|
||||||
try {
|
try {
|
||||||
@@ -177,16 +149,6 @@ 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)
|
||||||
@@ -223,42 +185,16 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (rows: any[]) => {
|
const handleDelete = async (rows: any[]) => {
|
||||||
pendingDeleteRows.value = rows
|
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
|
||||||
deleteSummary.value = null
|
try {
|
||||||
deleteDialogOpen.value = true
|
const ids = rows.map(r => r.id)
|
||||||
}
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,88 +220,6 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadListRecords = async (
|
|
||||||
page = 1,
|
|
||||||
options?: { append?: boolean; pageSize?: number }
|
|
||||||
) => {
|
|
||||||
const pageSize = options?.pageSize ?? listPageSize.value
|
|
||||||
const result = await fetchRecords({ page, pageSize }, { append: options?.append })
|
|
||||||
const resolvedTotal = result?.totalCount ?? totalCount.value ?? records.value.length
|
|
||||||
totalCount.value = resolvedTotal
|
|
||||||
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 firstResult = await loadListRecords(1)
|
|
||||||
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
|
|
||||||
const shouldPrefetchAll =
|
|
||||||
resolvedTotal <= maxFrontendRecords.value && records.value.length < resolvedTotal
|
|
||||||
|
|
||||||
if (shouldPrefetchAll) {
|
|
||||||
await loadListRecords(1, { append: false, pageSize: maxFrontendRecords.value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
if (page > loadedPages && totalCount.value > records.value.length) {
|
|
||||||
await loadListRecords(page, { append: true, pageSize })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLoadMore = async (page: number, pageSize: number) => {
|
|
||||||
if (isSearchActive.value) {
|
|
||||||
await searchListRecords(page, { append: true, pageSize })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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'
|
||||||
@@ -380,7 +234,7 @@ watch(() => route.params, async (newParams, oldParams) => {
|
|||||||
|
|
||||||
// Fetch records if navigating back to list
|
// Fetch records if navigating back to list
|
||||||
if (!newParams.recordId && !newParams.view) {
|
if (!newParams.recordId && !newParams.view) {
|
||||||
await initializeListRecords()
|
await fetchRecords()
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
@@ -389,7 +243,7 @@ onMounted(async () => {
|
|||||||
await fetchObjectDefinition()
|
await fetchObjectDefinition()
|
||||||
|
|
||||||
if (view.value === 'list') {
|
if (view.value === 'list') {
|
||||||
await initializeListRecords()
|
await fetchRecords()
|
||||||
} else if (recordId.value && recordId.value !== 'new') {
|
} else if (recordId.value && recordId.value !== 'new') {
|
||||||
await fetchRecord(recordId.value)
|
await fetchRecord(recordId.value)
|
||||||
}
|
}
|
||||||
@@ -434,18 +288,13 @@ onMounted(async () => {
|
|||||||
v-else-if="view === 'list' && listConfig"
|
v-else-if="view === 'list' && listConfig"
|
||||||
:config="listConfig"
|
:config="listConfig"
|
||||||
:data="records"
|
:data="records"
|
||||||
:loading="dataLoading || searchLoading"
|
:loading="dataLoading"
|
||||||
: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"
|
|
||||||
@load-more="handleLoadMore"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Detail View -->
|
<!-- Detail View -->
|
||||||
@@ -477,46 +326,6 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -71,12 +71,7 @@ const fetchPage = async () => {
|
|||||||
|
|
||||||
if (page.value.objectApiName) {
|
if (page.value.objectApiName) {
|
||||||
loadingRecords.value = true
|
loadingRecords.value = true
|
||||||
const response = await api.get(
|
records.value = await api.get(`/runtime/objects/${page.value.objectApiName}/records`)
|
||||||
`/runtime/objects/${page.value.objectApiName}/records`
|
|
||||||
)
|
|
||||||
records.value = Array.isArray(response)
|
|
||||||
? response
|
|
||||||
: response?.data || response?.records || []
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } 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'
|
||||||
@@ -12,7 +11,6 @@ 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)
|
||||||
@@ -27,14 +25,12 @@ 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)
|
||||||
|
|
||||||
// Use view state composable
|
// Use view state composable
|
||||||
const {
|
const {
|
||||||
records,
|
records,
|
||||||
totalCount,
|
|
||||||
currentRecord,
|
currentRecord,
|
||||||
loading: dataLoading,
|
loading: dataLoading,
|
||||||
saving,
|
saving,
|
||||||
@@ -54,7 +50,7 @@ const handleAiRecordCreated = (event: Event) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (view.value === 'list') {
|
if (view.value === 'list') {
|
||||||
initializeListRecords()
|
fetchRecords()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +65,11 @@ 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(() => {
|
||||||
@@ -88,9 +82,6 @@ const editConfig = computed(() => {
|
|||||||
return buildEditViewConfig(objectDefinition.value)
|
return buildEditViewConfig(objectDefinition.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
|
||||||
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
|
|
||||||
|
|
||||||
// Fetch object definition
|
// Fetch object definition
|
||||||
const fetchObjectDefinition = async () => {
|
const fetchObjectDefinition = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -98,16 +89,6 @@ 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)
|
||||||
@@ -179,39 +160,6 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadListRecords = async (
|
|
||||||
page = 1,
|
|
||||||
options?: { append?: boolean; pageSize?: number }
|
|
||||||
) => {
|
|
||||||
const pageSize = options?.pageSize ?? listPageSize.value
|
|
||||||
const result = await fetchRecords({ page, pageSize }, { append: options?.append })
|
|
||||||
const resolvedTotal = result?.totalCount ?? totalCount.value ?? records.value.length
|
|
||||||
totalCount.value = resolvedTotal
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeListRecords = async () => {
|
|
||||||
const firstResult = await loadListRecords(1)
|
|
||||||
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
|
|
||||||
const shouldPrefetchAll =
|
|
||||||
resolvedTotal <= maxFrontendRecords.value && records.value.length < resolvedTotal
|
|
||||||
|
|
||||||
if (shouldPrefetchAll) {
|
|
||||||
await loadListRecords(1, { append: false, pageSize: maxFrontendRecords.value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePageChange = async (page: number, pageSize: number) => {
|
|
||||||
const loadedPages = Math.ceil(records.value.length / pageSize)
|
|
||||||
if (page > loadedPages && totalCount.value > records.value.length) {
|
|
||||||
await loadListRecords(page, { append: true, pageSize })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLoadMore = async (page: number, pageSize: number) => {
|
|
||||||
await loadListRecords(page, { append: true, pageSize })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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'
|
||||||
@@ -226,7 +174,7 @@ watch(() => route.params, async (newParams, oldParams) => {
|
|||||||
|
|
||||||
// Fetch records if navigating back to list
|
// Fetch records if navigating back to list
|
||||||
if (!newParams.recordId && !newParams.view) {
|
if (!newParams.recordId && !newParams.view) {
|
||||||
await initializeListRecords()
|
await fetchRecords()
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
@@ -235,7 +183,7 @@ onMounted(async () => {
|
|||||||
await fetchObjectDefinition()
|
await fetchObjectDefinition()
|
||||||
|
|
||||||
if (view.value === 'list') {
|
if (view.value === 'list') {
|
||||||
await initializeListRecords()
|
await fetchRecords()
|
||||||
} else if (recordId.value && recordId.value !== 'new') {
|
} else if (recordId.value && recordId.value !== 'new') {
|
||||||
await fetchRecord(recordId.value)
|
await fetchRecord(recordId.value)
|
||||||
}
|
}
|
||||||
@@ -277,14 +225,11 @@ onMounted(async () => {
|
|||||||
:config="listConfig"
|
:config="listConfig"
|
||||||
:data="records"
|
:data="records"
|
||||||
:loading="dataLoading"
|
:loading="dataLoading"
|
||||||
:total-count="totalCount"
|
|
||||||
selectable
|
selectable
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@create="handleCreate"
|
@create="handleCreate"
|
||||||
@edit="handleEdit"
|
@edit="handleEdit"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@page-change="handlePageChange"
|
|
||||||
@load-more="handleLoadMore"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Detail View -->
|
<!-- Detail View -->
|
||||||
|
|||||||
@@ -16,11 +16,10 @@
|
|||||||
<!-- 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-4 max-w-2xl">
|
<TabsList class="grid w-full grid-cols-3 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 -->
|
||||||
@@ -149,7 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
v-if="layout.isDefault || layout.is_default"
|
v-if="layout.isDefault"
|
||||||
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
|
||||||
@@ -186,84 +185,6 @@
|
|||||||
/>
|
/>
|
||||||
</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>
|
||||||
@@ -378,7 +299,6 @@ 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'
|
||||||
@@ -395,16 +315,11 @@ 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 (detail/edit layouts)
|
// Page layouts state
|
||||||
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')
|
||||||
@@ -505,8 +420,7 @@ const fetchLayouts = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
loadingLayouts.value = true
|
loadingLayouts.value = true
|
||||||
// Fetch only detail layouts (default type)
|
layouts.value = await getPageLayouts(object.value.id)
|
||||||
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')
|
||||||
@@ -515,20 +429,6 @@ 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
|
||||||
@@ -784,7 +684,6 @@ 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: [] },
|
||||||
})
|
})
|
||||||
@@ -837,73 +736,6 @@ 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
|
||||||
@@ -915,9 +747,6 @@ 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 () => {
|
||||||
@@ -926,8 +755,5 @@ onMounted(async () => {
|
|||||||
if (activeTab.value === 'layouts') {
|
if (activeTab.value === 'layouts') {
|
||||||
await fetchLayouts()
|
await fetchLayouts()
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'listLayouts') {
|
|
||||||
await fetchListLayouts()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ export interface ViewConfig {
|
|||||||
export interface ListViewConfig extends ViewConfig {
|
export interface ListViewConfig extends ViewConfig {
|
||||||
mode: ViewMode.LIST;
|
mode: ViewMode.LIST;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
maxFrontendRecords?: number;
|
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
exportable?: boolean;
|
exportable?: boolean;
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
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[];
|
||||||
@@ -19,23 +15,16 @@ 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user