From 12304d5890c7d329c406b2c57eaf315871af1d47 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Fri, 10 Apr 2026 10:37:11 +0200 Subject: [PATCH] WIP - saving list views --- .../20260410000001_create_saved_list_views.js | 55 +++++ .../ai-assistant/ai-assistant.controller.ts | 8 + .../src/ai-assistant/ai-assistant.service.ts | 48 ++++ backend/src/app.module.ts | 2 + .../src/object/runtime-object.controller.ts | 29 +++ .../dto/saved-list-view.dto.ts | 57 +++++ .../saved-list-view.controller.ts | 58 +++++ .../saved-list-view/saved-list-view.module.ts | 12 + .../saved-list-view.service.ts | 107 +++++++++ frontend/components/SavedViewPanel.vue | 206 ++++++++++++++++++ .../dropdown-menu/DropdownMenuSeparator.vue | 16 ++ frontend/components/ui/dropdown-menu/index.ts | 1 + frontend/components/views/ListView.vue | 83 ++++++- frontend/composables/useSavedViews.ts | 108 +++++++++ .../[objectName]/[[recordId]]/[[view]].vue | 185 ++++++++++++++++ 15 files changed, 974 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/tenant/20260410000001_create_saved_list_views.js create mode 100644 backend/src/saved-list-view/dto/saved-list-view.dto.ts create mode 100644 backend/src/saved-list-view/saved-list-view.controller.ts create mode 100644 backend/src/saved-list-view/saved-list-view.module.ts create mode 100644 backend/src/saved-list-view/saved-list-view.service.ts create mode 100644 frontend/components/SavedViewPanel.vue create mode 100644 frontend/components/ui/dropdown-menu/DropdownMenuSeparator.vue create mode 100644 frontend/composables/useSavedViews.ts diff --git a/backend/migrations/tenant/20260410000001_create_saved_list_views.js b/backend/migrations/tenant/20260410000001_create_saved_list_views.js new file mode 100644 index 0000000..e02da6a --- /dev/null +++ b/backend/migrations/tenant/20260410000001_create_saved_list_views.js @@ -0,0 +1,55 @@ +/** + * Creates the saved_list_views table. + * Each row stores a named, reusable search/filter configuration for a specific + * CRM object type. Views can be private to the owning user or shared with the + * whole tenant. + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('saved_list_views', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('(UUID())')); + + // Human-readable name given by the user (or AI-suggested) + table.string('name').notNullable(); + + // The object this view belongs to (e.g. "Dog", "Contact") + table.string('object_api_name').notNullable(); + + // The user who created/owns this view + table.uuid('user_id').notNullable(); + + // When true the view is visible to all users in the tenant + table.boolean('is_shared').notNullable().defaultTo(false); + + // Strategy is always "query" for saved views (keyword views are not saved) + table.string('strategy').notNullable().defaultTo('query'); + + // Resolved filters as JSON array of AiSearchFilter objects + table.json('filters').notNullable(); + + // Optional sort: { field: string, direction: "asc" | "desc" } + table.json('sort').nullable(); + + // AI-generated plain-language explanation of what this view shows + table.text('description').nullable(); + + table.timestamps(true, true); + + // Foreign key to users + table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE'); + + // Primary lookup: all views for an object visible to a user + table.index(['object_api_name', 'user_id']); + table.index(['object_api_name', 'is_shared']); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('saved_list_views'); +}; diff --git a/backend/src/ai-assistant/ai-assistant.controller.ts b/backend/src/ai-assistant/ai-assistant.controller.ts index 8fdb6ef..83f47fb 100644 --- a/backend/src/ai-assistant/ai-assistant.controller.ts +++ b/backend/src/ai-assistant/ai-assistant.controller.ts @@ -38,4 +38,12 @@ export class AiAssistantController { payload, ); } + + @Post('suggest-view-name') + async suggestViewName( + @TenantId() tenantId: string, + @Body() payload: { objectLabel: string; filters: any[]; explanation?: string }, + ) { + return this.aiAssistantService.suggestViewName(tenantId, payload); + } } diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index f5e418f..c3edb7d 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -688,6 +688,8 @@ export class AiAssistantService { totalCount: meiliResults.total ?? records.totalCount ?? 0, strategy: plan.strategy, explanation: plan.explanation, + filters: [], + sort: null, }; } @@ -702,6 +704,8 @@ export class AiAssistantService { ...fallback, strategy: plan.strategy, explanation: plan.explanation, + filters: [], + sort: null, }; } @@ -730,9 +734,53 @@ export class AiAssistantService { ...filtered, strategy: plan.strategy, explanation: plan.explanation, + filters: resolvedFilters, + sort: plan.sort || null, }; } + /** + * Asks the LLM to suggest a short, human-friendly name for a saved list view + * based on the resolved filters / explanation. + */ + async suggestViewName( + tenantId: string, + payload: { objectLabel: string; filters: AiSearchFilter[]; explanation?: string }, + ): Promise<{ suggestedName: string }> { + const openAiConfig = await this.getOpenAiConfig(tenantId); + if (!openAiConfig) { + return { suggestedName: `${payload.objectLabel} View` }; + } + + const model = new ChatOpenAI({ + apiKey: openAiConfig.apiKey, + model: this.normalizeChatModel(openAiConfig.model), + temperature: 0.4, + }); + + const filterSummary = payload.explanation?.trim() + || payload.filters.map(f => `${f.field} ${f.operator} ${f.value ?? ''}`).join(', ') + || 'no filters'; + + try { + const response = await model.invoke([ + new SystemMessage( + 'You are a CRM assistant. Suggest a very short (2–5 words), descriptive, and human-friendly name for a saved list view. ' + + 'Reply with ONLY the name, no quotes or punctuation.', + ), + new HumanMessage( + `Object: ${payload.objectLabel}.\nFilter summary: ${filterSummary}`, + ), + ]); + + const raw = typeof response.content === 'string' ? response.content.trim() : ''; + const suggestedName = raw || `${payload.objectLabel} View`; + return { suggestedName }; + } catch { + return { suggestedName: `${payload.objectLabel} View` }; + } + } + // ============================================ // Planning-Based LangGraph Workflow // ============================================ diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 00dcef8..f2fe194 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { AppBuilderModule } from './app-builder/app-builder.module'; import { PageLayoutModule } from './page-layout/page-layout.module'; import { VoiceModule } from './voice/voice.module'; import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; +import { SavedListViewModule } from './saved-list-view/saved-list-view.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; PageLayoutModule, VoiceModule, AiAssistantModule, + SavedListViewModule, ], }) export class AppModule {} diff --git a/backend/src/object/runtime-object.controller.ts b/backend/src/object/runtime-object.controller.ts index 139c759..998d746 100644 --- a/backend/src/object/runtime-object.controller.ts +++ b/backend/src/object/runtime-object.controller.ts @@ -111,4 +111,33 @@ export class RuntimeObjectController { user.userId, ); } + + /** + * Direct filter-based search — used when applying a saved list view. + * Bypasses the AI planning step; accepts pre-resolved structured filters. + */ + @Post(':objectApiName/records/search') + async searchRecords( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @CurrentUser() user: any, + @Body() body: { + filters?: Array<{ field: string; operator: string; value?: any; values?: any[]; from?: string; to?: string }>; + sort?: { field: string; direction: 'asc' | 'desc' } | null; + page?: number; + pageSize?: number; + }, + ) { + const page = Number.isFinite(Number(body?.page)) ? Number(body.page) : 1; + const pageSize = Number.isFinite(Number(body?.pageSize)) ? Number(body.pageSize) : 25; + + return this.objectService.searchRecordsWithFilters( + tenantId, + objectApiName, + user.userId, + body?.filters || [], + { page, pageSize }, + body?.sort || undefined, + ); + } } diff --git a/backend/src/saved-list-view/dto/saved-list-view.dto.ts b/backend/src/saved-list-view/dto/saved-list-view.dto.ts new file mode 100644 index 0000000..d96fdad --- /dev/null +++ b/backend/src/saved-list-view/dto/saved-list-view.dto.ts @@ -0,0 +1,57 @@ +import { IsString, IsNotEmpty, IsArray, IsOptional, IsBoolean } from 'class-validator'; + +export class CreateSavedViewDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + objectApiName: string; + + @IsArray() + filters: Array<{ + field: string; + operator: string; + value?: any; + values?: any[]; + from?: string; + to?: string; + }>; + + @IsOptional() + sort?: { field: string; direction: 'asc' | 'desc' } | null; + + @IsOptional() + @IsString() + description?: string; +} + +export class UpdateSavedViewDto { + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @IsOptional() + @IsBoolean() + isShared?: boolean; + + @IsOptional() + @IsArray() + filters?: Array<{ + field: string; + operator: string; + value?: any; + values?: any[]; + from?: string; + to?: string; + }>; + + @IsOptional() + sort?: { field: string; direction: 'asc' | 'desc' } | null; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/backend/src/saved-list-view/saved-list-view.controller.ts b/backend/src/saved-list-view/saved-list-view.controller.ts new file mode 100644 index 0000000..eed4bc3 --- /dev/null +++ b/backend/src/saved-list-view/saved-list-view.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { TenantId } from '../tenant/tenant.decorator'; +import { SavedListViewService } from './saved-list-view.service'; +import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto'; + +@Controller('saved-views') +@UseGuards(JwtAuthGuard) +export class SavedListViewController { + constructor(private readonly savedListViewService: SavedListViewService) {} + + @Get(':objectApiName') + findByObject( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('objectApiName') objectApiName: string, + ) { + return this.savedListViewService.findByObject(tenantId, user.userId, objectApiName); + } + + @Post() + create( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Body() dto: CreateSavedViewDto, + ) { + return this.savedListViewService.create(tenantId, user.userId, dto); + } + + @Patch(':id') + update( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('id') id: string, + @Body() dto: UpdateSavedViewDto, + ) { + return this.savedListViewService.update(tenantId, user.userId, id, dto); + } + + @Delete(':id') + remove( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('id') id: string, + ) { + return this.savedListViewService.remove(tenantId, user.userId, id); + } +} diff --git a/backend/src/saved-list-view/saved-list-view.module.ts b/backend/src/saved-list-view/saved-list-view.module.ts new file mode 100644 index 0000000..a0c5aa3 --- /dev/null +++ b/backend/src/saved-list-view/saved-list-view.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SavedListViewService } from './saved-list-view.service'; +import { SavedListViewController } from './saved-list-view.controller'; +import { TenantModule } from '../tenant/tenant.module'; + +@Module({ + imports: [TenantModule], + controllers: [SavedListViewController], + providers: [SavedListViewService], + exports: [SavedListViewService], +}) +export class SavedListViewModule {} diff --git a/backend/src/saved-list-view/saved-list-view.service.ts b/backend/src/saved-list-view/saved-list-view.service.ts new file mode 100644 index 0000000..141ac10 --- /dev/null +++ b/backend/src/saved-list-view/saved-list-view.service.ts @@ -0,0 +1,107 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { TenantDatabaseService } from '../tenant/tenant-database.service'; +import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto'; + +@Injectable() +export class SavedListViewService { + constructor(private readonly tenantDbService: TenantDatabaseService) {} + + /** + * Returns all saved views visible to the user for a given object: + * - Views owned by the user (private or shared) + * - Views owned by other users that are shared with the tenant + */ + async findByObject(tenantId: string, userId: string, objectApiName: string) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + const rows = await knex('saved_list_views') + .where({ object_api_name: objectApiName }) + .andWhere(function () { + this.where({ user_id: userId }).orWhere({ is_shared: true }); + }) + .orderBy('created_at', 'asc'); + + return rows.map((r: any) => this.deserialize(r, userId)); + } + + async create(tenantId: string, userId: string, dto: CreateSavedViewDto) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // MySQL Knex returns [0] for UUID PKs (not auto-increment), so generate the + // UUID in application code and use it directly for the subsequent fetch. + const id = require('crypto').randomUUID(); + + await knex('saved_list_views').insert({ + id, + name: dto.name, + object_api_name: dto.objectApiName, + user_id: userId, + is_shared: false, + strategy: 'query', + filters: JSON.stringify(dto.filters || []), + sort: dto.sort ? JSON.stringify(dto.sort) : null, + description: dto.description || null, + }); + + const row = await knex('saved_list_views').where({ id }).first(); + return this.deserialize(row, userId); + } + + async update(tenantId: string, userId: string, id: string, dto: UpdateSavedViewDto) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + const existing = await knex('saved_list_views').where({ id }).first(); + if (!existing) throw new NotFoundException(`Saved view ${id} not found`); + if (existing.user_id !== userId) { + throw new ForbiddenException('You can only modify views you own'); + } + + const updates: Record = { updated_at: knex.fn.now() }; + if (dto.name !== undefined) updates.name = dto.name; + if (dto.isShared !== undefined) updates.is_shared = dto.isShared; + if (dto.filters !== undefined) updates.filters = JSON.stringify(dto.filters); + if (dto.sort !== undefined) updates.sort = dto.sort ? JSON.stringify(dto.sort) : null; + if (dto.description !== undefined) updates.description = dto.description; + + await knex('saved_list_views').where({ id }).update(updates); + + const row = await knex('saved_list_views').where({ id }).first(); + return this.deserialize(row, userId); + } + + async remove(tenantId: string, userId: string, id: string) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + const existing = await knex('saved_list_views').where({ id }).first(); + if (!existing) throw new NotFoundException(`Saved view ${id} not found`); + if (existing.user_id !== userId) { + throw new ForbiddenException('You can only delete views you own'); + } + + await knex('saved_list_views').where({ id }).delete(); + return { deleted: true }; + } + + private deserialize(row: any, currentUserId: string) { + return { + id: row.id, + name: row.name, + objectApiName: row.object_api_name, + userId: row.user_id, + isOwner: row.user_id === currentUserId, + isShared: Boolean(row.is_shared), + strategy: row.strategy, + filters: typeof row.filters === 'string' ? JSON.parse(row.filters) : (row.filters ?? []), + sort: row.sort + ? (typeof row.sort === 'string' ? JSON.parse(row.sort) : row.sort) + : null, + description: row.description, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/frontend/components/SavedViewPanel.vue b/frontend/components/SavedViewPanel.vue new file mode 100644 index 0000000..b045e49 --- /dev/null +++ b/frontend/components/SavedViewPanel.vue @@ -0,0 +1,206 @@ + + + diff --git a/frontend/components/ui/dropdown-menu/DropdownMenuSeparator.vue b/frontend/components/ui/dropdown-menu/DropdownMenuSeparator.vue new file mode 100644 index 0000000..fcc130a --- /dev/null +++ b/frontend/components/ui/dropdown-menu/DropdownMenuSeparator.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/components/ui/dropdown-menu/index.ts b/frontend/components/ui/dropdown-menu/index.ts index affbfff..6709255 100644 --- a/frontend/components/ui/dropdown-menu/index.ts +++ b/frontend/components/ui/dropdown-menu/index.ts @@ -2,3 +2,4 @@ export { default as DropdownMenu } from './DropdownMenu.vue' export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue' export { default as DropdownMenuContent } from './DropdownMenuContent.vue' export { default as DropdownMenuItem } from './DropdownMenuItem.vue' +export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue' diff --git a/frontend/components/views/ListView.vue b/frontend/components/views/ListView.vue index f07b59b..fdd991f 100644 --- a/frontend/components/views/ListView.vue +++ b/frontend/components/views/ListView.vue @@ -17,9 +17,17 @@ 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import FieldRenderer from '@/components/fields/FieldRenderer.vue' import { ListViewConfig, ViewMode, FieldType, FieldConfig } from '@/types/field-types' -import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next' +import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit, Bookmark, BookmarkPlus, Settings2 } from 'lucide-vue-next' +import type { SavedView } from '@/composables/useSavedViews' interface Props { config: ListViewConfig @@ -32,6 +40,11 @@ interface Props { draftEdits?: Record> cellErrors?: Record> savingDrafts?: boolean + // Saved views + savedViews?: SavedView[] + activeViewId?: string | null + currentSearchPlan?: { strategy: string; filters: any[]; sort: any; explanation: string } | null + savingView?: boolean } const props = withDefaults(defineProps(), { @@ -43,6 +56,10 @@ const props = withDefaults(defineProps(), { draftEdits: () => ({}), cellErrors: () => ({}), savingDrafts: false, + savedViews: () => [], + activeViewId: null, + currentSearchPlan: null, + savingView: false, }) const emit = defineEmits<{ @@ -61,6 +78,10 @@ const emit = defineEmits<{ 'cell-edit': [payload: { row: any; field: FieldConfig; newValue: any; oldValue: any }] 'save-drafts': [] 'discard-drafts': [] + // Saved views + 'apply-view': [view: SavedView] + 'save-view': [] + 'open-view-manager': [] }>() // State @@ -399,6 +420,66 @@ watch(
+ +
+ + + + + + + No saved views yet + + + {{ view.name }} + Shared + + + + Manage views… + + + + + +
+ + + + +
+ + + + + + + + +