Compare commits
2 Commits
47fa72451d
...
d00e26ad27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00e26ad27 | ||
|
|
9dcedcdf69 |
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
|||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
import { AiAssistantService } from './ai-assistant.service';
|
import { AiAssistantService } from './ai-assistant.service';
|
||||||
import { AiChatRequestDto } from './dto/ai-chat.dto';
|
import { AiChatRequestDto } from './dto/ai-chat.dto';
|
||||||
|
import { AiSearchRequestDto } from './dto/ai-search.dto';
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -24,4 +25,17 @@ export class AiAssistantController {
|
|||||||
payload.context,
|
payload.context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('search')
|
||||||
|
async search(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Body() payload: AiSearchRequestDto,
|
||||||
|
) {
|
||||||
|
return this.aiAssistantService.searchRecords(
|
||||||
|
tenantId,
|
||||||
|
user.userId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import { JsonOutputParser } from '@langchain/core/output_parsers';
|
import { JsonOutputParser } from '@langchain/core/output_parsers';
|
||||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||||
import { ChatOpenAI } from '@langchain/openai';
|
import { ChatOpenAI } from '@langchain/openai';
|
||||||
@@ -11,6 +11,30 @@ import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
|
|||||||
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
|
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
|
||||||
import { MeilisearchService } from '../search/meilisearch.service';
|
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()
|
@Injectable()
|
||||||
export class AiAssistantService {
|
export class AiAssistantService {
|
||||||
private readonly logger = new Logger(AiAssistantService.name);
|
private readonly logger = new Logger(AiAssistantService.name);
|
||||||
@@ -70,6 +94,108 @@ 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(
|
private async runAssistantGraph(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -523,6 +649,112 @@ export class AiAssistantService {
|
|||||||
return extracted;
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('fields:',fields);
|
||||||
|
|
||||||
|
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(
|
private sanitizeUserOwnerFields(
|
||||||
fields: Record<string, any>,
|
fields: Record<string, any>,
|
||||||
fieldDefinitions: any[],
|
fieldDefinitions: any[],
|
||||||
@@ -899,12 +1131,18 @@ export class AiAssistantService {
|
|||||||
|
|
||||||
const providedValue = typeof value === 'string' ? value.trim() : value;
|
const providedValue = typeof value === 'string' ? value.trim() : value;
|
||||||
if (providedValue && typeof providedValue === 'string') {
|
if (providedValue && typeof providedValue === 'string') {
|
||||||
|
|
||||||
|
console.log('providedValue:', providedValue);
|
||||||
|
|
||||||
const meiliMatch = await this.meilisearchService.searchRecord(
|
const meiliMatch = await this.meilisearchService.searchRecord(
|
||||||
resolvedTenantId,
|
resolvedTenantId,
|
||||||
targetDefinition?.apiName || targetApiName,
|
targetDefinition?.apiName || targetApiName,
|
||||||
providedValue,
|
providedValue,
|
||||||
displayField,
|
displayField,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('MeiliSearch lookup for', meiliMatch);
|
||||||
|
|
||||||
if (meiliMatch?.id) {
|
if (meiliMatch?.id) {
|
||||||
resolvedFields[field.apiName] = meiliMatch.id;
|
resolvedFields[field.apiName] = meiliMatch.id;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal file
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
|
||||||
|
|
||||||
|
export class AiSearchRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
objectApiName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
query: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
@@ -10,6 +10,25 @@ import { User } from '../models/user.model';
|
|||||||
import { ObjectMetadata } from './models/dynamic-model.factory';
|
import { ObjectMetadata } from './models/dynamic-model.factory';
|
||||||
import { MeilisearchService } from '../search/meilisearch.service';
|
import { MeilisearchService } from '../search/meilisearch.service';
|
||||||
|
|
||||||
|
type SearchFilter = {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value?: any;
|
||||||
|
values?: any[];
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchSort = {
|
||||||
|
field: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchPagination = {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ObjectService {
|
export class ObjectService {
|
||||||
private readonly logger = new Logger(ObjectService.name);
|
private readonly logger = new Logger(ObjectService.name);
|
||||||
@@ -62,8 +81,10 @@ export class ObjectService {
|
|||||||
.where({ objectDefinitionId: obj.id })
|
.where({ objectDefinitionId: obj.id })
|
||||||
.orderBy('label', 'asc');
|
.orderBy('label', 'asc');
|
||||||
|
|
||||||
// Normalize all fields to ensure system fields are properly marked
|
// Normalize all fields to ensure system fields are properly marked and add any missing system fields
|
||||||
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
|
const normalizedFields = this.addMissingSystemFields(
|
||||||
|
fields.map((field: any) => this.normalizeField(field)),
|
||||||
|
);
|
||||||
|
|
||||||
// Get app information if object belongs to an app
|
// Get app information if object belongs to an app
|
||||||
let app = null;
|
let app = null;
|
||||||
@@ -509,48 +530,41 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime endpoints - CRUD operations
|
private async buildAuthorizedQuery(
|
||||||
async getRecords(
|
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
filters?: any,
|
|
||||||
) {
|
) {
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Get user with roles and permissions
|
|
||||||
const user = await User.query(knex)
|
const user = await User.query(knex)
|
||||||
.findById(userId)
|
.findById(userId)
|
||||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get object definition with authorization settings
|
|
||||||
const objectDefModel = await ObjectDefinition.query(knex)
|
const objectDefModel = await ObjectDefinition.query(knex)
|
||||||
.findOne({ apiName: objectApiName })
|
.findOne({ apiName: objectApiName })
|
||||||
.withGraphFetched('fields');
|
.withGraphFetched('fields');
|
||||||
|
|
||||||
if (!objectDefModel) {
|
if (!objectDefModel) {
|
||||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableName = this.getTableName(
|
// Normalize and enrich fields to include system fields for downstream permissions/search
|
||||||
objectDefModel.apiName,
|
const normalizedFields = this.addMissingSystemFields(
|
||||||
objectDefModel.label,
|
(objectDefModel.fields || []).map((field: any) => this.normalizeField(field)),
|
||||||
objectDefModel.pluralLabel,
|
|
||||||
);
|
);
|
||||||
|
objectDefModel.fields = normalizedFields;
|
||||||
// Ensure model is registered
|
|
||||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
||||||
|
|
||||||
// Use Objection model
|
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
let query = boundModel.query();
|
let query = boundModel.query();
|
||||||
|
|
||||||
// Apply authorization scope (modifies query in place)
|
|
||||||
await this.authService.applyScopeToQuery(
|
await this.authService.applyScopeToQuery(
|
||||||
query,
|
query,
|
||||||
objectDefModel,
|
objectDefModel,
|
||||||
@@ -558,23 +572,190 @@ export class ObjectService {
|
|||||||
'read',
|
'read',
|
||||||
knex,
|
knex,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build graph expression for lookup fields
|
const lookupFields = objectDefModel.fields?.filter((field) =>
|
||||||
const lookupFields = objectDefModel.fields?.filter(f =>
|
field.type === 'LOOKUP' && field.referenceObject,
|
||||||
f.type === 'LOOKUP' && f.referenceObject
|
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
if (lookupFields.length > 0) {
|
if (lookupFields.length > 0) {
|
||||||
// Build relation expression - use singular lowercase for relation name
|
|
||||||
const relationExpression = lookupFields
|
const relationExpression = lookupFields
|
||||||
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
.map((field) => field.apiName.replace(/Id$/, '').toLowerCase())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
if (relationExpression) {
|
if (relationExpression) {
|
||||||
query = query.withGraphFetched(`[${relationExpression}]`);
|
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
objectDefModel,
|
||||||
|
user,
|
||||||
|
knex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySearchFilters(
|
||||||
|
query: any,
|
||||||
|
filters: SearchFilter[],
|
||||||
|
validFields: Set<string>,
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(filters) || filters.length === 0) {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filter of filters) {
|
||||||
|
const field = filter?.field;
|
||||||
|
if (!field || !validFields.has(field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operator = String(filter.operator || 'eq').toLowerCase();
|
||||||
|
const value = filter.value;
|
||||||
|
const values = Array.isArray(filter.values) ? filter.values : undefined;
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'eq':
|
||||||
|
query.where(field, value);
|
||||||
|
break;
|
||||||
|
case 'neq':
|
||||||
|
query.whereNot(field, value);
|
||||||
|
break;
|
||||||
|
case 'gt':
|
||||||
|
query.where(field, '>', value);
|
||||||
|
break;
|
||||||
|
case 'gte':
|
||||||
|
query.where(field, '>=', value);
|
||||||
|
break;
|
||||||
|
case 'lt':
|
||||||
|
query.where(field, '<', value);
|
||||||
|
break;
|
||||||
|
case 'lte':
|
||||||
|
query.where(field, '<=', value);
|
||||||
|
break;
|
||||||
|
case 'contains':
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}%`]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'startswith':
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
query.whereRaw('LOWER(??) like ?', [field, `${String(value).toLowerCase()}%`]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'endswith':
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}`]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'in':
|
||||||
|
if (values?.length) {
|
||||||
|
query.whereIn(field, values);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
query.whereIn(field, value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'notin':
|
||||||
|
if (values?.length) {
|
||||||
|
query.whereNotIn(field, values);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
query.whereNotIn(field, value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'isnull':
|
||||||
|
query.whereNull(field);
|
||||||
|
break;
|
||||||
|
case 'notnull':
|
||||||
|
query.whereNotNull(field);
|
||||||
|
break;
|
||||||
|
case 'between': {
|
||||||
|
const from = filter.from ?? (Array.isArray(value) ? value[0] : undefined);
|
||||||
|
const to = filter.to ?? (Array.isArray(value) ? value[1] : undefined);
|
||||||
|
if (from !== undefined && to !== undefined) {
|
||||||
|
query.whereBetween(field, [from, to]);
|
||||||
|
} else if (from !== undefined) {
|
||||||
|
query.where(field, '>=', from);
|
||||||
|
} else if (to !== undefined) {
|
||||||
|
query.where(field, '<=', to);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyKeywordFilter(
|
||||||
|
query: any,
|
||||||
|
keyword: string,
|
||||||
|
fields: string[],
|
||||||
|
) {
|
||||||
|
const trimmed = keyword?.trim();
|
||||||
|
if (!trimmed || fields.length === 0) {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
query.where((builder: any) => {
|
||||||
|
const lowered = trimmed.toLowerCase();
|
||||||
|
for (const field of fields) {
|
||||||
|
builder.orWhereRaw('LOWER(??) like ?', [field, `%${lowered}%`]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeRecordQuery(
|
||||||
|
query: any,
|
||||||
|
objectDefModel: any,
|
||||||
|
user: any,
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
) {
|
||||||
|
const parsedPage = Number.isFinite(Number(pagination?.page)) ? Number(pagination?.page) : 1;
|
||||||
|
const parsedPageSize = Number.isFinite(Number(pagination?.pageSize))
|
||||||
|
? Number(pagination?.pageSize)
|
||||||
|
: 0;
|
||||||
|
const safePage = parsedPage > 0 ? parsedPage : 1;
|
||||||
|
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
|
||||||
|
const shouldPaginate = safePageSize > 0;
|
||||||
|
|
||||||
|
const totalCount = await query.clone().resultSize();
|
||||||
|
|
||||||
|
if (shouldPaginate) {
|
||||||
|
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await query.select('*');
|
||||||
|
const filteredRecords = await Promise.all(
|
||||||
|
records.map((record: any) =>
|
||||||
|
this.authService.filterReadableFields(record, objectDefModel.fields, user),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: filteredRecords,
|
||||||
|
totalCount,
|
||||||
|
page: shouldPaginate ? safePage : 1,
|
||||||
|
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime endpoints - CRUD operations
|
||||||
|
async getRecords(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
userId: string,
|
||||||
|
filters?: any,
|
||||||
|
) {
|
||||||
|
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
// Extract pagination and sorting parameters from query string
|
// Extract pagination and sorting parameters from query string
|
||||||
const {
|
const {
|
||||||
@@ -603,36 +784,98 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortField) {
|
if (sortField) {
|
||||||
query = query.orderBy(sortField, sortDirection === 'desc' ? 'desc' : 'asc');
|
const direction = sortDirection === 'desc' ? 'desc' : 'asc';
|
||||||
|
query = query.orderBy(sortField, direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedPage = Number.isFinite(Number(page)) ? Number(page) : 1;
|
return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
|
||||||
const parsedPageSize = Number.isFinite(Number(pageSize)) ? Number(pageSize) : 0;
|
}
|
||||||
const safePage = parsedPage > 0 ? parsedPage : 1;
|
|
||||||
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
|
|
||||||
const shouldPaginate = safePageSize > 0;
|
|
||||||
|
|
||||||
const totalCount = await query.clone().resultSize();
|
async searchRecordsByIds(
|
||||||
|
tenantId: string,
|
||||||
if (shouldPaginate) {
|
objectApiName: string,
|
||||||
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
|
userId: string,
|
||||||
|
recordIds: string[],
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
totalCount: 0,
|
||||||
|
page: pagination?.page ?? 1,
|
||||||
|
pageSize: pagination?.pageSize ?? 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await query.select('*');
|
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
// Filter fields based on field-level permissions
|
objectApiName,
|
||||||
const filteredRecords = await Promise.all(
|
userId,
|
||||||
records.map(record =>
|
|
||||||
this.authService.filterReadableFields(record, objectDefModel.fields, user)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
query.whereIn('id', recordIds);
|
||||||
data: filteredRecords,
|
const orderBindings: any[] = ['id'];
|
||||||
totalCount,
|
const cases = recordIds
|
||||||
page: shouldPaginate ? safePage : 1,
|
.map((id, index) => {
|
||||||
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
|
orderBindings.push(id, index);
|
||||||
};
|
return 'when ? then ?';
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (cases) {
|
||||||
|
query.orderByRaw(`case ?? ${cases} end`, orderBindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchRecordsWithFilters(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
userId: string,
|
||||||
|
filters: SearchFilter[],
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
sort?: SearchSort,
|
||||||
|
) {
|
||||||
|
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const validFields = new Set([
|
||||||
|
...(objectDefModel.fields?.map((field: any) => field.apiName) || []),
|
||||||
|
...this.getSystemFieldNames(),
|
||||||
|
]);
|
||||||
|
this.applySearchFilters(query, filters, validFields);
|
||||||
|
|
||||||
|
if (sort?.field && validFields.has(sort.field)) {
|
||||||
|
query.orderBy(sort.field, sort.direction === 'desc' ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchRecordsByKeyword(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
userId: string,
|
||||||
|
keyword: string,
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
) {
|
||||||
|
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const keywordFields = (objectDefModel.fields || [])
|
||||||
|
.filter((field: any) => this.isKeywordField(field.type))
|
||||||
|
.map((field: any) => field.apiName);
|
||||||
|
|
||||||
|
this.applyKeywordFilter(query, keyword, keywordFields);
|
||||||
|
|
||||||
|
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecord(
|
async getRecord(
|
||||||
@@ -1073,12 +1316,32 @@ export class ObjectService {
|
|||||||
.map((field: any) => field.apiName)
|
.map((field: any) => field.apiName)
|
||||||
.filter((apiName) => apiName && !this.isSystemField(apiName));
|
.filter((apiName) => apiName && !this.isSystemField(apiName));
|
||||||
|
|
||||||
|
console.log('Indexing record', {
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
recordId: record.id,
|
||||||
|
fieldsToIndex,
|
||||||
|
});
|
||||||
|
|
||||||
await this.meilisearchService.upsertRecord(
|
await this.meilisearchService.upsertRecord(
|
||||||
tenantId,
|
tenantId,
|
||||||
objectApiName,
|
objectApiName,
|
||||||
record,
|
record,
|
||||||
fieldsToIndex,
|
fieldsToIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('Indexed record successfully');
|
||||||
|
|
||||||
|
|
||||||
|
const meiliResults = await this.meilisearchService.searchRecords(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
record.name,
|
||||||
|
{ limit: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Meilisearch results:', meiliResults);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeIndexedRecord(
|
private async removeIndexedRecord(
|
||||||
@@ -1091,15 +1354,48 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isSystemField(apiName: string): boolean {
|
private isSystemField(apiName: string): boolean {
|
||||||
|
return this.getSystemFieldNames().includes(apiName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSystemFieldNames(): string[] {
|
||||||
|
return ['id', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'tenantId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMissingSystemFields(fields: any[]): any[] {
|
||||||
|
const existing = new Map((fields || []).map((field) => [field.apiName, field]));
|
||||||
|
const systemDefaults = [
|
||||||
|
{ apiName: 'id', label: 'ID', type: 'STRING' },
|
||||||
|
{ apiName: 'created_at', label: 'Created At', type: 'DATE_TIME' },
|
||||||
|
{ apiName: 'updated_at', label: 'Updated At', type: 'DATE_TIME' },
|
||||||
|
{ apiName: 'ownerId', label: 'Owner', type: 'LOOKUP', referenceObject: 'User' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const merged = [...fields];
|
||||||
|
for (const sysField of systemDefaults) {
|
||||||
|
if (!existing.has(sysField.apiName)) {
|
||||||
|
merged.push({
|
||||||
|
...sysField,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
isRequired: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isKeywordField(type: string | undefined): boolean {
|
||||||
|
const normalized = String(type || '').toUpperCase();
|
||||||
return [
|
return [
|
||||||
'id',
|
'STRING',
|
||||||
'ownerId',
|
'TEXT',
|
||||||
'created_at',
|
'EMAIL',
|
||||||
'updated_at',
|
'PHONE',
|
||||||
'createdAt',
|
'URL',
|
||||||
'updatedAt',
|
'RICH_TEXT',
|
||||||
'tenantId',
|
'TEXTAREA',
|
||||||
].includes(apiName);
|
].includes(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async normalizePolymorphicRelatedObject(
|
private async normalizePolymorphicRelatedObject(
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export class MeilisearchService {
|
|||||||
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||||
|
|
||||||
|
console.log('querying Meilisearch index:', { indexName, query, displayField });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.requestJson('POST', url, {
|
const response = await this.requestJson('POST', url, {
|
||||||
q: query,
|
q: query,
|
||||||
@@ -66,6 +68,48 @@ export class MeilisearchService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchRecords(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
query: string,
|
||||||
|
options?: { limit?: number; offset?: number },
|
||||||
|
): Promise<{ hits: any[]; total: number }> {
|
||||||
|
const config = this.getConfig();
|
||||||
|
if (!config) return { hits: [], total: 0 };
|
||||||
|
|
||||||
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||||
|
const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20;
|
||||||
|
const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestJson('POST', url, {
|
||||||
|
q: query,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}, this.buildHeaders(config));
|
||||||
|
|
||||||
|
console.log('Meilisearch response body:', response.body);
|
||||||
|
|
||||||
|
if (!this.isSuccessStatus(response.status)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Meilisearch query failed for index ${indexName}: ${response.status}`,
|
||||||
|
);
|
||||||
|
return { hits: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
|
||||||
|
const total =
|
||||||
|
response.body?.estimatedTotalHits ??
|
||||||
|
response.body?.nbHits ??
|
||||||
|
hits.length;
|
||||||
|
return { hits, total };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Meilisearch query failed: ${error.message}`);
|
||||||
|
return { hits: [], total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async upsertRecord(
|
async upsertRecord(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface Props {
|
|||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
totalCount?: number
|
totalCount?: number
|
||||||
|
searchSummary?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -30,6 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
loading: false,
|
loading: false,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
baseUrl: '/runtime/objects',
|
baseUrl: '/runtime/objects',
|
||||||
|
searchSummary: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -172,6 +174,9 @@ 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">
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ const editConfig = computed(() => {
|
|||||||
|
|
||||||
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
||||||
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
|
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchSummary = ref('')
|
||||||
|
const searchLoading = ref(false)
|
||||||
|
|
||||||
|
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||||
|
|
||||||
// Fetch object definition
|
// Fetch object definition
|
||||||
const fetchObjectDefinition = async () => {
|
const fetchObjectDefinition = async () => {
|
||||||
@@ -235,6 +240,36 @@ const loadListRecords = async (
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchListRecords = async (
|
||||||
|
page = 1,
|
||||||
|
options?: { append?: boolean; pageSize?: number }
|
||||||
|
) => {
|
||||||
|
if (!isSearchActive.value) {
|
||||||
|
return initializeListRecords()
|
||||||
|
}
|
||||||
|
searchLoading.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = options?.pageSize ?? listPageSize.value
|
||||||
|
const response = await api.post('/ai/search', {
|
||||||
|
objectApiName: objectApiName.value,
|
||||||
|
query: searchQuery.value.trim(),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
const data = response?.data ?? []
|
||||||
|
const total = response?.totalCount ?? data.length
|
||||||
|
records.value = options?.append ? [...records.value, ...data] : data
|
||||||
|
totalCount.value = total
|
||||||
|
searchSummary.value = response?.explanation || ''
|
||||||
|
return response
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to search records'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
searchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initializeListRecords = async () => {
|
const initializeListRecords = async () => {
|
||||||
const firstResult = await loadListRecords(1)
|
const firstResult = await loadListRecords(1)
|
||||||
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
|
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
|
||||||
@@ -247,6 +282,10 @@ const initializeListRecords = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePageChange = async (page: number, pageSize: number) => {
|
const handlePageChange = async (page: number, pageSize: number) => {
|
||||||
|
if (isSearchActive.value) {
|
||||||
|
await searchListRecords(page, { append: page > 1, pageSize })
|
||||||
|
return
|
||||||
|
}
|
||||||
const loadedPages = Math.ceil(records.value.length / pageSize)
|
const loadedPages = Math.ceil(records.value.length / pageSize)
|
||||||
if (page > loadedPages && totalCount.value > records.value.length) {
|
if (page > loadedPages && totalCount.value > records.value.length) {
|
||||||
await loadListRecords(page, { append: true, pageSize })
|
await loadListRecords(page, { append: true, pageSize })
|
||||||
@@ -254,9 +293,24 @@ const handlePageChange = async (page: number, pageSize: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLoadMore = async (page: number, pageSize: number) => {
|
const handleLoadMore = async (page: number, pageSize: number) => {
|
||||||
|
if (isSearchActive.value) {
|
||||||
|
await searchListRecords(page, { append: true, pageSize })
|
||||||
|
return
|
||||||
|
}
|
||||||
await loadListRecords(page, { append: true, pageSize })
|
await loadListRecords(page, { append: true, pageSize })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (query: string) => {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
searchQuery.value = trimmed
|
||||||
|
if (!trimmed) {
|
||||||
|
searchSummary.value = ''
|
||||||
|
await initializeListRecords()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for route changes
|
// Watch for route changes
|
||||||
watch(() => route.params, async (newParams, oldParams) => {
|
watch(() => route.params, async (newParams, oldParams) => {
|
||||||
// Reset current record when navigating to 'new'
|
// Reset current record when navigating to 'new'
|
||||||
@@ -325,14 +379,16 @@ onMounted(async () => {
|
|||||||
v-else-if="view === 'list' && listConfig"
|
v-else-if="view === 'list' && listConfig"
|
||||||
:config="listConfig"
|
:config="listConfig"
|
||||||
:data="records"
|
:data="records"
|
||||||
:loading="dataLoading"
|
:loading="dataLoading || searchLoading"
|
||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
|
:search-summary="searchSummary"
|
||||||
:base-url="`/runtime/objects`"
|
:base-url="`/runtime/objects`"
|
||||||
selectable
|
selectable
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@create="handleCreate"
|
@create="handleCreate"
|
||||||
@edit="handleEdit"
|
@edit="handleEdit"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
|
@search="handleSearch"
|
||||||
@page-change="handlePageChange"
|
@page-change="handlePageChange"
|
||||||
@load-more="handleLoadMore"
|
@load-more="handleLoadMore"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user