From fc1bec4de7e3c3925a3e21c0dfde38051252c1a9 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Tue, 23 Dec 2025 23:59:04 +0100 Subject: [PATCH] WIP - related lists and look up field --- RELATED_LISTS_IMPLEMENTATION.md | 219 ++++++++++++++++++ .../src/tenant/central-admin.controller.ts | 17 +- frontend/components/RelatedList.vue | 186 +++++++++++++++ frontend/components/fields/FieldRenderer.vue | 104 ++++++++- frontend/components/fields/LookupField.vue | 170 ++++++++++++++ frontend/components/views/DetailView.vue | 26 ++- .../components/views/DetailViewEnhanced.vue | 20 +- frontend/components/views/ListView.vue | 1 + frontend/composables/useCentralEntities.ts | 17 +- .../central/tenants/[[recordId]]/[[view]].vue | 16 ++ frontend/types/field-types.ts | 10 + 11 files changed, 774 insertions(+), 12 deletions(-) create mode 100644 RELATED_LISTS_IMPLEMENTATION.md create mode 100644 frontend/components/RelatedList.vue create mode 100644 frontend/components/fields/LookupField.vue diff --git a/RELATED_LISTS_IMPLEMENTATION.md b/RELATED_LISTS_IMPLEMENTATION.md new file mode 100644 index 0000000..f33b841 --- /dev/null +++ b/RELATED_LISTS_IMPLEMENTATION.md @@ -0,0 +1,219 @@ +# Related Lists and Lookup Fields Implementation + +This document describes the implementation of related lists and improved relationship field handling in the application. + +## Features Implemented + +### 1. Related Lists Component (`/frontend/components/RelatedList.vue`) + +A reusable component that displays related records for a parent entity in a table format. + +**Features:** +- Displays related records in a formatted table +- Shows configurable fields for each related record +- Supports navigation to related record detail pages +- Allows creating new related records +- Handles loading and error states +- Empty state with call-to-action button +- Automatically fetches related records or uses provided data + +**Usage Example:** +```vue + +``` + +### 2. Lookup Field Component (`/frontend/components/fields/LookupField.vue`) + +A searchable dropdown component for selecting related records (belongs-to relationships). + +**Features:** +- Searchable combobox for finding records +- Fetches available records from API +- Displays meaningful field names instead of UUIDs +- Clear button to remove selection +- Configurable relation object and display field +- Loading states + +**Usage:** +```vue + +``` + +### 3. Enhanced Field Renderer (`/frontend/components/fields/FieldRenderer.vue`) + +Updated to handle relationship fields intelligently. + +**New Features:** +- Detects BELONGS_TO field type +- Fetches related record for display in detail/list views +- Shows meaningful name instead of UUID +- Uses LookupField component for editing +- Automatic loading of related record data + +**Behavior:** +- **Detail/List View:** Fetches and displays related record name +- **Edit View:** Renders LookupField for selection +- Falls back to UUID if related record can't be fetched + +### 4. Enhanced Detail View (`/frontend/components/views/DetailView.vue`) + +Added support for displaying related lists below the main record details. + +**New Features:** +- `relatedLists` configuration support +- Emits `navigate` and `createRelated` events +- Passes related records data to RelatedList components +- Automatically displays all configured related lists + +### 5. Type Definitions (`/frontend/types/field-types.ts`) + +Added new types for related list configuration: + +```typescript +export interface RelatedListConfig { + title: string; + relationName: string; // Property name on parent object + objectApiName: string; // API endpoint name + fields: FieldConfig[]; // Fields to display in list + canCreate?: boolean; + createRoute?: string; +} + +export interface DetailViewConfig extends ViewConfig { + mode: ViewMode.DETAIL; + sections?: FieldSection[]; + actions?: ViewAction[]; + relatedLists?: RelatedListConfig[]; // NEW +} +``` + +### 6. Backend Support (`/backend/src/tenant/central-admin.controller.ts`) + +Added filtering support for fetching related records. + +**Enhancement:** +```typescript +@Get('domains') +async getDomains( + @Req() req: any, + @Query('parentId') parentId?: string, + @Query('tenantId') tenantId?: string, +) { + // ... + if (parentId || tenantId) { + query = query.where('tenantId', parentId || tenantId); + } + return query; +} +``` + +### 7. Central Entities Configuration (`/frontend/composables/useCentralEntities.ts`) + +Added related list configurations to tenant detail view: + +```typescript +export const tenantDetailConfig: DetailViewConfig = { + // ... existing config + relatedLists: [ + { + title: 'Domains', + relationName: 'domains', + objectApiName: 'domains', + fields: [ + { id: 'domain', apiName: 'domain', label: 'Domain', type: FieldType.TEXT }, + { id: 'isPrimary', apiName: 'isPrimary', label: 'Primary', type: FieldType.BOOLEAN }, + { id: 'createdAt', apiName: 'createdAt', label: 'Created', type: FieldType.DATETIME }, + ], + canCreate: true, + }, + ], +} +``` + +Updated domain field configuration to use lookup: + +```typescript +{ + id: 'tenantId', + apiName: 'tenantId', + label: 'Tenant', + type: FieldType.BELONGS_TO, // Changed from TEXT + relationObject: 'tenants', + relationDisplayField: 'name', + // ... +} +``` + +## User Experience Improvements + +### Before: +- **Relationship Fields:** Displayed raw UUIDs everywhere +- **Editing Relationships:** Had to manually enter or paste UUIDs +- **Related Records:** No way to see child records from parent detail page +- **Navigation:** Had to manually navigate to related record lists + +### After: +- **Relationship Fields:** Show meaningful names (e.g., "Acme Corp" instead of "abc-123-def") +- **Editing Relationships:** Searchable dropdown with all available options +- **Related Records:** Automatically displayed in related lists on detail pages +- **Navigation:** One-click navigation to related records; create button with parent context pre-filled + +## Example: Tenant Detail View + +When viewing a tenant, users now see: + +1. **Main tenant information** (name, slug, status, database config) +2. **Related Lists section** below main details: + - **Domains list** showing all domains for this tenant + - Each domain row displays: domain name, isPrimary flag, created date + - "New" button to create domain with tenantId pre-filled + - Click any domain to navigate to its detail page + +## Example: Creating a Domain + +When creating/editing a domain: + +1. **Tenant field** shows a searchable dropdown instead of text input +2. Type to search available tenants by name +3. Select from list - shows "Acme Corp" not "uuid-123" +4. Selected tenant's name is displayed +5. Can clear selection with X button + +## Technical Notes + +- All API calls use the centralized `$api` helper from `useNuxtApp()` +- Type casting via `unknown` to handle NuxtApp type issues +- Filter functions use TypeScript type predicates for proper type narrowing +- Related records can be passed in (if already fetched with parent) or fetched separately +- Backend supports both `parentId` and specific relationship field names (e.g., `tenantId`) + +## Future Enhancements + +Potential additions: +- Inline editing within related lists +- Pagination for large related lists +- Sorting and filtering within related lists +- Bulk operations on related records +- Many-to-many relationship support +- Has-many relationship support with junction tables diff --git a/backend/src/tenant/central-admin.controller.ts b/backend/src/tenant/central-admin.controller.ts index 6ea1c9b..2ad9a67 100644 --- a/backend/src/tenant/central-admin.controller.ts +++ b/backend/src/tenant/central-admin.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + Query, UseGuards, UnauthorizedException, Req, @@ -112,9 +113,21 @@ export class CentralAdminController { // ==================== DOMAINS ==================== @Get('domains') - async getDomains(@Req() req: any) { + async getDomains( + @Req() req: any, + @Query('parentId') parentId?: string, + @Query('tenantId') tenantId?: string, + ) { this.checkCentralAdmin(req); - return CentralDomain.query().withGraphFetched('tenant'); + + let query = CentralDomain.query().withGraphFetched('tenant'); + + // Filter by parent/tenant ID if provided (for related lists) + if (parentId || tenantId) { + query = query.where('tenantId', parentId || tenantId); + } + + return query; } @Get('domains/:id') diff --git a/frontend/components/RelatedList.vue b/frontend/components/RelatedList.vue new file mode 100644 index 0000000..4d06140 --- /dev/null +++ b/frontend/components/RelatedList.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend/components/fields/FieldRenderer.vue b/frontend/components/fields/FieldRenderer.vue index e0ff191..d10685e 100644 --- a/frontend/components/fields/FieldRenderer.vue +++ b/frontend/components/fields/FieldRenderer.vue @@ -1,5 +1,5 @@ + + + + diff --git a/frontend/components/views/DetailView.vue b/frontend/components/views/DetailView.vue index 4d697db..ef17100 100644 --- a/frontend/components/views/DetailView.vue +++ b/frontend/components/views/DetailView.vue @@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import FieldRenderer from '@/components/fields/FieldRenderer.vue' -import { DetailViewConfig, ViewMode, FieldSection } from '@/types/field-types' +import RelatedList from '@/components/RelatedList.vue' +import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types' import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next' import { Collapsible, @@ -13,7 +14,7 @@ import { } from '@/components/ui/collapsible' interface Props { - config: DetailViewConfig + config: DetailViewConfig & { relatedLists?: RelatedListConfig[] } data: any loading?: boolean } @@ -27,6 +28,8 @@ const emit = defineEmits<{ 'delete': [] 'back': [] 'action': [actionId: string] + 'navigate': [objectApiName: string, recordId: string] + 'createRelated': [objectApiName: string, parentId: string] }>() // Organize fields into sections @@ -47,7 +50,7 @@ const sections = computed(() => { const getFieldsBySection = (section: FieldSection) => { return section.fields .map(apiName => props.config.fields.find(f => f.apiName === apiName)) - .filter(Boolean) + .filter((field): field is FieldConfig => field !== undefined) } @@ -121,6 +124,7 @@ const getFieldsBySection = (section: FieldSection) => { :key="field.id" :field="field" :model-value="data[field.apiName]" + :record-data="data" :mode="ViewMode.DETAIL" /> @@ -139,9 +143,10 @@ const getFieldsBySection = (section: FieldSection) => {
@@ -149,6 +154,19 @@ const getFieldsBySection = (section: FieldSection) => { + + +
+ +
diff --git a/frontend/components/views/DetailViewEnhanced.vue b/frontend/components/views/DetailViewEnhanced.vue index ae0c1ea..96c8b2a 100644 --- a/frontend/components/views/DetailViewEnhanced.vue +++ b/frontend/components/views/DetailViewEnhanced.vue @@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Button } from '@/components/ui/button' import FieldRenderer from '@/components/fields/FieldRenderer.vue' import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue' -import { DetailViewConfig, ViewMode, FieldSection, FieldConfig } from '@/types/field-types' +import RelatedList from '@/components/RelatedList.vue' +import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types' import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next' import { Collapsible, @@ -29,6 +30,8 @@ const emit = defineEmits<{ 'delete': [] 'back': [] 'action': [actionId: string] + 'navigate': [objectApiName: string, recordId: string] + 'createRelated': [objectApiName: string, parentId: string] }>() const { getDefaultPageLayout } = usePageLayouts() @@ -165,6 +168,7 @@ const usePageLayout = computed(() => { :key="field.id" :field="field" :model-value="data[field.apiName]" + :record-data="data" :mode="ViewMode.DETAIL" /> @@ -186,6 +190,7 @@ const usePageLayout = computed(() => { :key="field?.id" :field="field" :model-value="data[field.apiName]" + :record-data="data" :mode="ViewMode.DETAIL" /> @@ -193,6 +198,19 @@ const usePageLayout = computed(() => { + + +
+ +
diff --git a/frontend/components/views/ListView.vue b/frontend/components/views/ListView.vue index cff698e..c47373f 100644 --- a/frontend/components/views/ListView.vue +++ b/frontend/components/views/ListView.vue @@ -205,6 +205,7 @@ const handleAction = (actionId: string) => { diff --git a/frontend/composables/useCentralEntities.ts b/frontend/composables/useCentralEntities.ts index e1f944a..768ca7c 100644 --- a/frontend/composables/useCentralEntities.ts +++ b/frontend/composables/useCentralEntities.ts @@ -4,7 +4,7 @@ */ import { FieldType, ViewMode } from '@/types/field-types' -import type { FieldConfig, ListViewConfig, DetailViewConfig, EditViewConfig } from '@/types/field-types' +import type { FieldConfig, ListViewConfig, DetailViewConfig, EditViewConfig, RelatedListConfig } from '@/types/field-types' // ==================== TENANTS ==================== @@ -155,6 +155,19 @@ export const tenantDetailConfig: DetailViewConfig = { collapsible: true, }, ], + relatedLists: [ + { + title: 'Domains', + relationName: 'domains', + objectApiName: 'domains', + fields: [ + { id: 'domain', apiName: 'domain', label: 'Domain', type: FieldType.TEXT }, + { id: 'isPrimary', apiName: 'isPrimary', label: 'Primary', type: FieldType.BOOLEAN }, + { id: 'createdAt', apiName: 'createdAt', label: 'Created', type: FieldType.DATETIME }, + ], + canCreate: true, + }, + ], } export const tenantEditConfig: EditViewConfig = { @@ -200,7 +213,7 @@ export const domainFields: FieldConfig[] = [ showOnList: true, showOnDetail: true, showOnEdit: true, - relationObject: 'Tenant', + relationObject: 'tenants', relationDisplayField: 'name', }, { diff --git a/frontend/pages/central/tenants/[[recordId]]/[[view]].vue b/frontend/pages/central/tenants/[[recordId]]/[[view]].vue index 3510217..e6c0627 100644 --- a/frontend/pages/central/tenants/[[recordId]]/[[view]].vue +++ b/frontend/pages/central/tenants/[[recordId]]/[[view]].vue @@ -81,6 +81,20 @@ const handleDelete = async (rows: any[]) => { } } +// Handle navigation to related records +const handleNavigate = (objectApiName: string, recordId: string) => { + router.push(`/central/${objectApiName}/${recordId}/detail`) +} + +// Handle creating related records +const handleCreateRelated = (objectApiName: string, parentId: string) => { + // Navigate to create page with parent context + router.push({ + path: `/central/${objectApiName}/new`, + query: { tenantId: parentId } + }) +} + const handleSaveRecord = async (data: any) => { try { const savedRecord = await handleSave(data) @@ -137,6 +151,8 @@ onMounted(async () => { @edit="handleEdit" @delete="() => handleDelete([currentRecord])" @back="handleBack" + @navigate="handleNavigate" + @create-related="handleCreateRelated" /> diff --git a/frontend/types/field-types.ts b/frontend/types/field-types.ts index 1969865..bc83183 100644 --- a/frontend/types/field-types.ts +++ b/frontend/types/field-types.ts @@ -118,10 +118,20 @@ export interface ListViewConfig extends ViewConfig { actions?: ViewAction[]; } +export interface RelatedListConfig { + title: string; + relationName: string; + objectApiName: string; + fields: FieldConfig[]; + canCreate?: boolean; + createRoute?: string; +} + export interface DetailViewConfig extends ViewConfig { mode: ViewMode.DETAIL; sections?: FieldSection[]; actions?: ViewAction[]; + relatedLists?: RelatedListConfig[]; } export interface EditViewConfig extends ViewConfig {