18 Commits

Author SHA1 Message Date
Francisco Gaona
730fddd181 WIP - add meilisearch for easier record find for AI assistant 2026-01-13 07:44:47 +01:00
Francisco Gaona
a62f68fc10 WIP - refresh list after AI record creation 2026-01-13 00:00:40 +01:00
Francisco Gaona
d2b3fce4eb WIP - initial AI assistant chat working creating records 2026-01-12 23:55:57 +01:00
Francisco Gaona
ca11c8cbe7 WIP - add contct and contact details 2026-01-12 21:08:47 +01:00
Francisco Gaona
f8a3cffb64 WIP - dislpay name field for look up fields in related lists 2026-01-09 08:00:05 +01:00
Francisco Gaona
852c4e28d2 WIP - display related lists 2026-01-09 07:49:30 +01:00
phyroslam
2075fec183 Merge pull request #2 from phyroslam/codex/add-dynamic-related-lists-to-detail-views
Add dynamic tenant-level related lists and page layout selection
2026-01-08 15:16:17 -08:00
phyroslam
9be98e4a09 Add dynamic related lists with page layout support 2026-01-08 15:15:28 -08:00
Francisco Gaona
43cae4289b WIP - add owner to contact 2026-01-08 23:56:48 +01:00
Francisco Gaona
4de9203fd5 Merge branch 'drawer' into codex/add-contact-and-contact-details-objects 2026-01-08 21:34:48 +01:00
Francisco Gaona
8b9fa81594 WIP - UI fixes for bottom bar 2026-01-08 21:19:48 +01:00
phyroslam
a75b41fd0b Add contact and contact detail system objects 2026-01-08 07:41:09 -08:00
Francisco Gaona
7ae36411db WIP - move AI suggestions 2026-01-08 00:28:45 +01:00
Francisco Gaona
c9a3e00a94 WIP - UI cahnges to bottom bar 2026-01-08 00:21:12 +01:00
Francisco Gaona
8ad3fac1b0 WIP - UI drawer initial 2026-01-07 22:11:36 +01:00
Francisco Gaona
b34da6956c WIP - Fix create field dialog placement and look up field creation 2026-01-07 21:00:06 +01:00
Francisco Gaona
6c73eb1658 WIP - Basic adding and deleting field 2026-01-06 10:01:02 +01:00
Francisco Gaona
8e4690c9c9 WIP - initial iteration on manage fields 2026-01-06 09:45:29 +01:00
19 changed files with 150 additions and 1418 deletions

View File

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

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { ChatOpenAI } from '@langchain/openai';
@@ -11,30 +11,6 @@ import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
import { MeilisearchService } from '../search/meilisearch.service';
type AiSearchFilter = {
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
};
type AiSearchPlan = {
strategy: 'keyword' | 'query';
explanation: string;
keyword?: string | null;
filters?: AiSearchFilter[];
sort?: { field: string; direction: 'asc' | 'desc' } | null;
};
type AiSearchPayload = {
objectApiName: string;
query: string;
page?: number;
pageSize?: number;
};
@Injectable()
export class AiAssistantService {
private readonly logger = new Logger(AiAssistantService.name);
@@ -94,108 +70,6 @@ export class AiAssistantService {
};
}
async searchRecords(
tenantId: string,
userId: string,
payload: AiSearchPayload,
) {
const queryText = payload?.query?.trim();
if (!payload?.objectApiName || !queryText) {
throw new BadRequestException('objectApiName and query are required');
}
// Normalize tenant ID so Meilisearch index names align with indexed records
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const objectDefinition = await this.objectService.getObjectDefinition(
resolvedTenantId,
payload.objectApiName,
);
if (!objectDefinition) {
throw new BadRequestException(`Object ${payload.objectApiName} not found`);
}
const page = Number.isFinite(Number(payload.page)) ? Number(payload.page) : 1;
const pageSize = Number.isFinite(Number(payload.pageSize)) ? Number(payload.pageSize) : 20;
const plan = await this.buildSearchPlan(
resolvedTenantId,
queryText,
objectDefinition,
);
console.log('AI search plan:', plan);
if (plan.strategy === 'keyword') {
console.log('AI search plan (keyword):', plan);
const keyword = plan.keyword?.trim() || queryText;
if (this.meilisearchService.isEnabled()) {
const offset = (page - 1) * pageSize;
const meiliResults = await this.meilisearchService.searchRecords(
resolvedTenantId,
payload.objectApiName,
keyword,
{ limit: pageSize, offset },
);
console.log('Meilisearch results:', meiliResults);
const ids = meiliResults.hits
.map((hit: any) => hit?.id)
.filter(Boolean);
const records = ids.length
? await this.objectService.searchRecordsByIds(
resolvedTenantId,
payload.objectApiName,
userId,
ids,
{ page, pageSize },
)
: { data: [], totalCount: 0, page, pageSize };
return {
...records,
totalCount: meiliResults.total ?? records.totalCount ?? 0,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
const fallback = await this.objectService.searchRecordsByKeyword(
resolvedTenantId,
payload.objectApiName,
userId,
keyword,
{ page, pageSize },
);
return {
...fallback,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
console.log('AI search plan (query):', plan);
const filtered = await this.objectService.searchRecordsWithFilters(
resolvedTenantId,
payload.objectApiName,
userId,
plan.filters || [],
{ page, pageSize },
plan.sort || undefined,
);
return {
...filtered,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
private async runAssistantGraph(
tenantId: string,
userId: string,
@@ -649,110 +523,6 @@ export class AiAssistantService {
return extracted;
}
private async buildSearchPlan(
tenantId: string,
message: string,
objectDefinition: any,
): Promise<AiSearchPlan> {
const openAiConfig = await this.getOpenAiConfig(tenantId);
if (!openAiConfig) {
return this.buildSearchPlanFallback(message);
}
try {
return await this.buildSearchPlanWithAi(openAiConfig, message, objectDefinition);
} catch (error) {
this.logger.warn(`AI search planning failed: ${error.message}`);
return this.buildSearchPlanFallback(message);
}
}
private buildSearchPlanFallback(message: string): AiSearchPlan {
const trimmed = message.trim();
return {
strategy: 'keyword',
keyword: trimmed,
explanation: `Searched records that matches the word: "${trimmed}"`,
};
}
private async buildSearchPlanWithAi(
openAiConfig: OpenAIConfig,
message: string,
objectDefinition: any,
): Promise<AiSearchPlan> {
const model = new ChatOpenAI({
apiKey: openAiConfig.apiKey,
model: this.normalizeChatModel(openAiConfig.model),
temperature: 0.2,
});
const parser = new JsonOutputParser<AiSearchPlan>();
const fields = (objectDefinition.fields || []).map((field: any) => ({
apiName: field.apiName,
label: field.label,
type: field.type,
}));
const formatInstructions = parser.getFormatInstructions();
const today = new Date().toISOString();
const response = await model.invoke([
new SystemMessage(
`You are a CRM search assistant. Decide whether the user input is a keyword search or a structured query.` +
`\nReturn a JSON object with keys: strategy, explanation, keyword, filters, sort.` +
`\n- strategy must be "keyword" or "query".` +
`\n- explanation must be a short sentence explaining the approach.` +
`\n- keyword should be the search term when strategy is "keyword", otherwise null.` +
`\n- filters is an array of {field, operator, value, values, from, to}.` +
`\n- operators must be one of eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, notIn, isNull, notNull, between.` +
`\n- Use between with from/to when the user gives date ranges like "yesterday" or "last week".` +
`\n- sort should be {field, direction} when sorting is requested.` +
`\n- Only use field apiName values exactly as provided.` +
`\n${formatInstructions}`,
),
new HumanMessage(
`Object: ${objectDefinition.label || objectDefinition.apiName}.\n` +
`Fields: ${JSON.stringify(fields)}.\n` +
`Today is ${today}.\n` +
`User query: ${message}`,
),
]);
const content = typeof response.content === 'string' ? response.content : '{}';
const parsed = await parser.parse(content);
return this.normalizeSearchPlan(parsed, message);
}
private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan {
if (!plan || typeof plan !== 'object') {
return this.buildSearchPlanFallback(message);
}
const strategy = plan.strategy === 'query' ? 'query' : 'keyword';
const explanation = plan.explanation?.trim()
? plan.explanation.trim()
: strategy === 'keyword'
? `Searched records that matches the word: "${message.trim()}"`
: `Applied filters based on: "${message.trim()}"`;
if (strategy === 'keyword') {
return {
strategy,
keyword: plan.keyword?.trim() || message.trim(),
explanation,
};
}
return {
strategy,
explanation,
keyword: null,
filters: Array.isArray(plan.filters) ? plan.filters : [],
sort: plan.sort || null,
};
}
private sanitizeUserOwnerFields(
fields: Record<string, any>,
fieldDefinitions: any[],
@@ -1129,18 +899,12 @@ export class AiAssistantService {
const providedValue = typeof value === 'string' ? value.trim() : value;
if (providedValue && typeof providedValue === 'string') {
console.log('providedValue:', providedValue);
const meiliMatch = await this.meilisearchService.searchRecord(
resolvedTenantId,
targetDefinition?.apiName || targetApiName,
providedValue,
displayField,
);
console.log('MeiliSearch lookup for', meiliMatch);
if (meiliMatch?.id) {
resolvedFields[field.apiName] = meiliMatch.id;
continue;

View File

@@ -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;
}

View File

@@ -1,38 +1,7 @@
import { Model, ModelOptions, QueryContext } from 'objection';
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
export class BaseModel extends Model {
/**
* 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;
},
};
static columnNameMappers = snakeCaseMappers();
id: string;
createdAt: Date;

View File

@@ -179,8 +179,7 @@ export class DynamicModelFactory {
* Convert a field definition to JSON schema property
*/
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
const baseSchema = () => {
switch (field.type.toUpperCase()) {
switch (field.type.toUpperCase()) {
case 'TEXT':
case 'STRING':
case 'EMAIL':
@@ -188,57 +187,45 @@ export class DynamicModelFactory {
case 'PHONE':
case 'PICKLIST':
case 'MULTI_PICKLIST':
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
case 'LONG_TEXT':
return { type: 'string' };
return { type: 'string' };
case 'NUMBER':
case 'DECIMAL':
case 'CURRENCY':
case 'PERCENT':
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
case 'INTEGER':
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
case 'BOOLEAN':
return { type: 'boolean', default: false };
return { type: 'boolean', default: false };
case 'DATE':
return { type: 'string', format: 'date' };
return { type: 'string', format: 'date' };
case 'DATE_TIME':
return { type: 'string', format: 'date-time' };
return { type: 'string', format: 'date-time' };
case 'LOOKUP':
case 'BELONGS_TO':
return { type: 'string' };
return { type: 'string' };
default:
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 { type: 'string' };
}
return schema;
}
/**

View File

@@ -10,25 +10,6 @@ import { User } from '../models/user.model';
import { ObjectMetadata } from './models/dynamic-model.factory';
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()
export class ObjectService {
private readonly logger = new Logger(ObjectService.name);
@@ -81,10 +62,8 @@ export class ObjectService {
.where({ objectDefinitionId: obj.id })
.orderBy('label', 'asc');
// Normalize all fields to ensure system fields are properly marked and add any missing system fields
const normalizedFields = this.addMissingSystemFields(
fields.map((field: any) => this.normalizeField(field)),
);
// Normalize all fields to ensure system fields are properly marked
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
// Get app information if object belongs to an app
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
async getRecords(
tenantId: string,
@@ -751,131 +516,81 @@ export class ObjectService {
userId: string,
filters?: any,
) {
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
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 })
.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
const {
page,
pageSize,
sortField,
sortDirection,
...rawFilters
} = filters || {};
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
const reservedFilterKeys = new Set(['page', 'pageSize', 'sortField', 'sortDirection']);
const filterEntries = Object.entries(rawFilters || {}).filter(
([key, value]) =>
!reservedFilterKeys.has(key) &&
value !== undefined &&
value !== null &&
value !== '',
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query();
// Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery(
query,
objectDefModel,
user,
'read',
knex,
);
if (filterEntries.length > 0) {
query = query.where(builder => {
for (const [key, value] of filterEntries) {
builder.where(key, value as any);
}
});
// Build graph expression for lookup fields
const lookupFields = objectDefModel.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
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) {
const direction = sortDirection === 'desc' ? 'desc' : 'asc';
query = query.orderBy(sortField, direction);
// Apply additional filters
if (filters) {
query = query.where(filters);
}
return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
}
async searchRecordsByIds(
tenantId: string,
objectApiName: string,
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,
const records = await query.select('*');
// Filter fields based on field-level permissions
const filteredRecords = await Promise.all(
records.map(record =>
this.authService.filterReadableFields(record, objectDefModel.fields, user)
)
);
query.whereIn('id', recordIds);
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);
return filteredRecords;
}
async getRecord(
@@ -1179,8 +894,7 @@ export class ObjectService {
objectApiName,
editableData,
);
// Use patch to avoid validating or overwriting fields that aren't present in the edit view
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
await boundModel.query().where({ id: recordId }).update(normalizedEditableData);
const record = await boundModel.query().where({ id: recordId }).first();
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
return record;
@@ -1238,95 +952,6 @@ export class ObjectService {
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(
tenantId: string,
objectApiName: string,
@@ -1339,32 +964,12 @@ export class ObjectService {
.map((field: any) => field.apiName)
.filter((apiName) => apiName && !this.isSystemField(apiName));
console.log('Indexing record', {
tenantId,
objectApiName,
recordId: record.id,
fieldsToIndex,
});
await this.meilisearchService.upsertRecord(
tenantId,
objectApiName,
record,
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(
@@ -1377,48 +982,15 @@ export class ObjectService {
}
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 [
'STRING',
'TEXT',
'EMAIL',
'PHONE',
'URL',
'RICH_TEXT',
'TEXTAREA',
].includes(normalized);
'id',
'ownerId',
'created_at',
'updated_at',
'createdAt',
'updatedAt',
'tenantId',
].includes(apiName);
}
private async normalizePolymorphicRelatedObject(

View File

@@ -95,20 +95,4 @@ export class RuntimeObjectController {
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,
);
}
}

View File

@@ -28,8 +28,6 @@ export class MeilisearchService {
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
console.log('querying Meilisearch index:', { indexName, query, displayField });
try {
const response = await this.requestJson('POST', url, {
q: query,
@@ -68,48 +66,6 @@ export class MeilisearchService {
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(
tenantId: string,
objectApiName: string,

View File

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

View File

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

View File

@@ -78,9 +78,7 @@ const fetchRecords = async () => {
try {
const endpoint = `${props.baseUrl}/${relationObject.value}/records`
const response = await api.get(endpoint)
records.value = Array.isArray(response)
? response
: response?.data || response?.records || []
records.value = response || []
// If we have a modelValue, find the selected record
if (props.modelValue) {

View File

@@ -18,7 +18,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CheckboxRoot
v-bind="forwarded"
: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)"
>
<CheckboxIndicator class="grid place-content-center text-current">

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, onMounted } from 'vue'
import {
Table,
TableBody,
@@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
@@ -23,8 +22,6 @@ interface Props {
loading?: boolean
selectable?: boolean
baseUrl?: string
totalCount?: number
searchSummary?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -32,7 +29,6 @@ const props = withDefaults(defineProps<Props>(), {
loading: false,
selectable: false,
baseUrl: '/runtime/objects',
searchSummary: '',
})
const emit = defineEmits<{
@@ -45,91 +41,41 @@ const emit = defineEmits<{
'sort': [field: string, direction: 'asc' | 'desc']
'search': [query: string]
'refresh': []
'page-change': [page: number, pageSize: number]
'load-more': [page: number, pageSize: number]
}>()
// State
const normalizeId = (id: any) => String(id)
const selectedRowIds = ref<string[]>([])
const selectedRows = ref<Set<string>>(new Set())
const searchQuery = ref('')
const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const bulkAction = ref('delete')
// Computed
const visibleFields = computed(() =>
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({
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) => {
if (val) {
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
selectedRows.value = new Set(props.data.map(row => row.id))
} else {
selectedRowIds.value = []
selectedRows.value.clear()
}
emit('row-select', getSelectedRows())
},
})
const getSelectedRows = () => {
const idSet = new Set(selectedRowIds.value)
return props.data.filter(row => idSet.has(normalizeId(row.id)))
return props.data.filter(row => selectedRows.value.has(row.id))
}
const toggleRowSelection = (rowId: string) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
const setRowSelection = (rowId: string, checked: boolean) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
if (checked) {
nextSelection.add(normalizedId)
if (selectedRows.value.has(rowId)) {
selectedRows.value.delete(rowId)
} else {
nextSelection.delete(normalizedId)
selectedRows.value.add(rowId)
}
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
@@ -150,49 +96,6 @@ const handleSearch = () => {
const handleAction = (actionId: string) => {
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>
<template>
@@ -210,31 +113,18 @@ watch(
@keyup.enter="handleSearch"
/>
</div>
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
{{ searchSummary }}
</p>
</div>
<div class="flex items-center gap-2">
<!-- Bulk Actions -->
<template v-if="selectedRowIds.length > 0">
<template v-if="selectedRows.size > 0">
<Badge variant="secondary" class="px-3 py-1">
{{ selectedRowIds.length }} selected
{{ selectedRows.size }} selected
</Badge>
<div class="flex items-center gap-2">
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
<SelectTrigger class="h-8 w-[180px]">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete selected</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" @click="handleBulkAction">
<Trash2 class="h-4 w-4 mr-2" />
Run
</Button>
</div>
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
<Trash2 class="h-4 w-4 mr-2" />
Delete
</Button>
</template>
<!-- Custom Actions -->
@@ -268,10 +158,7 @@ watch(
<TableHeader>
<TableRow>
<TableHead v-if="selectable" class="w-12">
<Checkbox
:model-value="allSelected"
@update:model-value="(value: boolean) => (allSelected = value)"
/>
<Checkbox v-model:checked="allSelected" />
</TableHead>
<TableHead
v-for="field in visibleFields"
@@ -305,15 +192,15 @@ watch(
</TableRow>
<TableRow
v-else
v-for="row in paginatedData"
v-for="row in data"
:key="row.id"
class="cursor-pointer hover:bg-muted/50"
@click="emit('row-click', row)"
>
<TableCell v-if="selectable" @click.stop>
<Checkbox
:model-value="selectedRowIds.includes(normalizeId(row.id))"
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
:checked="selectedRows.has(row.id)"
@update:checked="toggleRowSelection(row.id)"
/>
</TableCell>
<TableCell v-for="field in visibleFields" :key="field.id">
@@ -340,26 +227,7 @@ watch(
</Table>
</div>
<div v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
<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>
<!-- Pagination would go here -->
</div>
</template>

View File

@@ -78,8 +78,7 @@ export const useFields = () => {
objectApiName: objectDef.apiName,
mode: 'list' as ViewMode,
fields,
pageSize: 10,
maxFrontendRecords: 500,
pageSize: 25,
searchable: true,
filterable: true,
exportable: true,
@@ -184,7 +183,6 @@ export const useViewState = <T extends { id?: string }>(
apiEndpoint: string
) => {
const records = ref<T[]>([])
const totalCount = ref(0)
const currentRecord = ref<T | null>(null)
const currentView = ref<'list' | 'detail' | 'edit'>('list')
const loading = ref(false)
@@ -193,51 +191,13 @@ export const useViewState = <T extends { id?: string }>(
const { api } = useApi()
const normalizeListResponse = (response: 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 }) => {
const fetchRecords = async (params?: Record<string, any>) => {
loading.value = true
error.value = null
try {
const response = await api.get(apiEndpoint, { params })
const normalized = normalizeListResponse(response)
totalCount.value = normalized.totalCount ?? normalized.data.length ?? 0
records.value = options?.append
? [...records.value, ...normalized.data]
: normalized.data
return normalized
// Handle response - data might be directly in response or in response.data
records.value = response.data || response || []
} catch (e: any) {
error.value = e.message
console.error('Failed to fetch records:', e)
@@ -270,7 +230,6 @@ export const useViewState = <T extends { id?: string }>(
// Handle response - it might be the data directly or wrapped in { data: ... }
const recordData = response.data || response
records.value.push(recordData)
totalCount.value += 1
currentRecord.value = recordData
return recordData
} catch (e: any) {
@@ -313,7 +272,6 @@ export const useViewState = <T extends { id?: string }>(
try {
await api.delete(`${apiEndpoint}/${id}`)
records.value = records.value.filter(r => r.id !== id)
totalCount.value = Math.max(0, totalCount.value - 1)
if (currentRecord.value?.id === id) {
currentRecord.value = null
}
@@ -330,25 +288,8 @@ export const useViewState = <T extends { id?: string }>(
loading.value = true
error.value = null
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}`)))
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) {
error.value = e.message
console.error('Failed to delete records:', e)
@@ -386,7 +327,6 @@ export const useViewState = <T extends { id?: string }>(
return {
// State
records,
totalCount,
currentRecord,
currentView,
loading,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { ref } from 'vue'
import AppSidebar from '@/components/AppSidebar.vue'
import BottomDrawer from '@/components/BottomDrawer.vue'
import {
@@ -15,9 +15,6 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
const route = useRoute()
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(() => {
// If custom breadcrumbs are set by the page, use those
@@ -33,47 +30,12 @@ const breadcrumbs = computed(() => {
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>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset ref="insetRef" class="relative flex flex-col">
<SidebarInset class="flex flex-col">
<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"
>
@@ -111,8 +73,7 @@ onBeforeUnmount(() => {
<slot />
</div>
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
<BottomDrawer :bounds="drawerBounds" />
</SidebarInset>
</SidebarProvider>
</template>

View File

@@ -1,19 +1,11 @@
<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 { useApi } from '@/composables/useApi'
import { useFields, useViewState } from '@/composables/useFieldViews'
import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const route = useRoute()
const router = useRouter()
@@ -46,7 +38,6 @@ const error = ref<string | null>(null)
// Use view state composable
const {
records,
totalCount,
currentRecord,
loading: dataLoading,
saving,
@@ -66,7 +57,7 @@ const handleAiRecordCreated = (event: Event) => {
return
}
if (view.value === 'list') {
initializeListRecords()
fetchRecords()
}
}
@@ -151,20 +142,6 @@ const editConfig = computed(() => {
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
const fetchObjectDefinition = async () => {
try {
@@ -208,42 +185,16 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
}
const handleDelete = async (rows: any[]) => {
pendingDeleteRows.value = rows
deleteSummary.value = null
deleteDialogOpen.value = true
}
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 (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
try {
const ids = rows.map(r => r.id)
await deleteRecords(ids)
if (view.value !== 'list') {
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
}
}
@@ -269,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(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new'
@@ -365,7 +234,7 @@ watch(() => route.params, async (newParams, oldParams) => {
// Fetch records if navigating back to list
if (!newParams.recordId && !newParams.view) {
await initializeListRecords()
await fetchRecords()
}
}, { deep: true })
@@ -374,7 +243,7 @@ onMounted(async () => {
await fetchObjectDefinition()
if (view.value === 'list') {
await initializeListRecords()
await fetchRecords()
} else if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value)
}
@@ -419,18 +288,13 @@ onMounted(async () => {
v-else-if="view === 'list' && listConfig"
:config="listConfig"
:data="records"
:loading="dataLoading || searchLoading"
:total-count="totalCount"
:search-summary="searchSummary"
:loading="dataLoading"
:base-url="`/runtime/objects`"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
@search="handleSearch"
@page-change="handlePageChange"
@load-more="handleLoadMore"
/>
<!-- Detail View -->
@@ -462,46 +326,6 @@ onMounted(async () => {
@back="handleBack"
/>
</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>
</template>

View File

@@ -71,12 +71,7 @@ const fetchPage = async () => {
if (page.value.objectApiName) {
loadingRecords.value = true
const response = await api.get(
`/runtime/objects/${page.value.objectApiName}/records`
)
records.value = Array.isArray(response)
? response
: response?.data || response?.records || []
records.value = await api.get(`/runtime/objects/${page.value.objectApiName}/records`)
}
} catch (e: any) {
error.value = e.message

View File

@@ -1,5 +1,5 @@
<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 { useApi } from '@/composables/useApi'
import { useFields, useViewState } from '@/composables/useFieldViews'
@@ -31,7 +31,6 @@ const error = ref<string | null>(null)
// Use view state composable
const {
records,
totalCount,
currentRecord,
loading: dataLoading,
saving,
@@ -51,7 +50,7 @@ const handleAiRecordCreated = (event: Event) => {
return
}
if (view.value === 'list') {
initializeListRecords()
fetchRecords()
}
}
@@ -83,9 +82,6 @@ const editConfig = computed(() => {
return buildEditViewConfig(objectDefinition.value)
})
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
// Fetch object definition
const fetchObjectDefinition = async () => {
try {
@@ -164,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(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new'
@@ -211,7 +174,7 @@ watch(() => route.params, async (newParams, oldParams) => {
// Fetch records if navigating back to list
if (!newParams.recordId && !newParams.view) {
await initializeListRecords()
await fetchRecords()
}
}, { deep: true })
@@ -220,7 +183,7 @@ onMounted(async () => {
await fetchObjectDefinition()
if (view.value === 'list') {
await initializeListRecords()
await fetchRecords()
} else if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value)
}
@@ -262,14 +225,11 @@ onMounted(async () => {
:config="listConfig"
:data="records"
:loading="dataLoading"
:total-count="totalCount"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
@page-change="handlePageChange"
@load-more="handleLoadMore"
/>
<!-- Detail View -->

View File

@@ -114,7 +114,6 @@ export interface ViewConfig {
export interface ListViewConfig extends ViewConfig {
mode: ViewMode.LIST;
pageSize?: number;
maxFrontendRecords?: number;
searchable?: boolean;
filterable?: boolean;
exportable?: boolean;