WIP - configure list views
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.alterTable('page_layouts', (table) => {
|
||||||
|
// Add layout_type column to distinguish between detail/edit layouts and list view layouts
|
||||||
|
// Default to 'detail' for existing layouts
|
||||||
|
table.enum('layout_type', ['detail', 'list']).notNullable().defaultTo('detail').after('name');
|
||||||
|
|
||||||
|
// Update the unique index to include layout_type so we can have both a default detail and default list layout
|
||||||
|
table.dropIndex(['object_id', 'is_default']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.alterTable('page_layouts', (table) => {
|
||||||
|
table.dropColumn('layout_type');
|
||||||
|
table.index(['object_id', 'is_default']);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator';
|
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject, IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
export type PageLayoutType = 'detail' | 'list';
|
||||||
|
|
||||||
export class CreatePageLayoutDto {
|
export class CreatePageLayoutDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -7,18 +9,25 @@ export class CreatePageLayoutDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
objectId: string;
|
objectId: string;
|
||||||
|
|
||||||
|
@IsIn(['detail', 'list'])
|
||||||
|
@IsOptional()
|
||||||
|
layoutType?: PageLayoutType = 'detail';
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
layoutConfig: {
|
layoutConfig: {
|
||||||
|
// For detail layouts: grid-based field positions
|
||||||
fields: Array<{
|
fields: Array<{
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
x: number;
|
x?: number;
|
||||||
y: number;
|
y?: number;
|
||||||
w: number;
|
w?: number;
|
||||||
h: number;
|
h?: number;
|
||||||
|
// For list layouts: field order (optional, defaults to array index)
|
||||||
|
order?: number;
|
||||||
}>;
|
}>;
|
||||||
relatedLists?: string[];
|
relatedLists?: string[];
|
||||||
};
|
};
|
||||||
@@ -42,10 +51,11 @@ export class UpdatePageLayoutDto {
|
|||||||
layoutConfig?: {
|
layoutConfig?: {
|
||||||
fields: Array<{
|
fields: Array<{
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
x: number;
|
x?: number;
|
||||||
y: number;
|
y?: number;
|
||||||
w: number;
|
w?: number;
|
||||||
h: number;
|
h?: number;
|
||||||
|
order?: number;
|
||||||
}>;
|
}>;
|
||||||
relatedLists?: string[];
|
relatedLists?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PageLayoutService } from './page-layout.service';
|
import { PageLayoutService } from './page-layout.service';
|
||||||
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
|
import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
@@ -25,13 +25,21 @@ export class PageLayoutController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) {
|
findAll(
|
||||||
return this.pageLayoutService.findAll(tenantId, objectId);
|
@TenantId() tenantId: string,
|
||||||
|
@Query('objectId') objectId?: string,
|
||||||
|
@Query('layoutType') layoutType?: PageLayoutType,
|
||||||
|
) {
|
||||||
|
return this.pageLayoutService.findAll(tenantId, objectId, layoutType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('default/:objectId')
|
@Get('default/:objectId')
|
||||||
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) {
|
findDefaultByObject(
|
||||||
return this.pageLayoutService.findDefaultByObject(tenantId, objectId);
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectId') objectId: string,
|
||||||
|
@Query('layoutType') layoutType?: PageLayoutType,
|
||||||
|
) {
|
||||||
|
return this.pageLayoutService.findDefaultByObject(tenantId, objectId, layoutType || 'detail');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
|
import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageLayoutService {
|
export class PageLayoutService {
|
||||||
@@ -8,17 +8,19 @@ export class PageLayoutService {
|
|||||||
|
|
||||||
async create(tenantId: string, createDto: CreatePageLayoutDto) {
|
async create(tenantId: string, createDto: CreatePageLayoutDto) {
|
||||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
const layoutType = createDto.layoutType || 'detail';
|
||||||
|
|
||||||
// If this layout is set as default, unset other defaults for the same object
|
// If this layout is set as default, unset other defaults for the same object and layout type
|
||||||
if (createDto.isDefault) {
|
if (createDto.isDefault) {
|
||||||
await knex('page_layouts')
|
await knex('page_layouts')
|
||||||
.where({ object_id: createDto.objectId })
|
.where({ object_id: createDto.objectId, layout_type: layoutType })
|
||||||
.update({ is_default: false });
|
.update({ is_default: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [id] = await knex('page_layouts').insert({
|
const [id] = await knex('page_layouts').insert({
|
||||||
name: createDto.name,
|
name: createDto.name,
|
||||||
object_id: createDto.objectId,
|
object_id: createDto.objectId,
|
||||||
|
layout_type: layoutType,
|
||||||
is_default: createDto.isDefault || false,
|
is_default: createDto.isDefault || false,
|
||||||
layout_config: JSON.stringify(createDto.layoutConfig),
|
layout_config: JSON.stringify(createDto.layoutConfig),
|
||||||
description: createDto.description || null,
|
description: createDto.description || null,
|
||||||
@@ -29,7 +31,7 @@ export class PageLayoutService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(tenantId: string, objectId?: string) {
|
async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) {
|
||||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
let query = knex('page_layouts');
|
let query = knex('page_layouts');
|
||||||
@@ -38,6 +40,10 @@ export class PageLayoutService {
|
|||||||
query = query.where({ object_id: objectId });
|
query = query.where({ object_id: objectId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (layoutType) {
|
||||||
|
query = query.where({ layout_type: layoutType });
|
||||||
|
}
|
||||||
|
|
||||||
const layouts = await query.orderByRaw('is_default DESC, name ASC');
|
const layouts = await query.orderByRaw('is_default DESC, name ASC');
|
||||||
return layouts;
|
return layouts;
|
||||||
}
|
}
|
||||||
@@ -54,11 +60,11 @@ export class PageLayoutService {
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findDefaultByObject(tenantId: string, objectId: string) {
|
async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') {
|
||||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const layout = await knex('page_layouts')
|
const layout = await knex('page_layouts')
|
||||||
.where({ object_id: objectId, is_default: true })
|
.where({ object_id: objectId, is_default: true, layout_type: layoutType })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
return layout || null;
|
return layout || null;
|
||||||
@@ -68,13 +74,12 @@ export class PageLayoutService {
|
|||||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
// Check if layout exists
|
// Check if layout exists
|
||||||
await this.findOne(tenantId, id);
|
const layout = await this.findOne(tenantId, id);
|
||||||
|
|
||||||
// If setting as default, unset other defaults for the same object
|
// If setting as default, unset other defaults for the same object and layout type
|
||||||
if (updateDto.isDefault) {
|
if (updateDto.isDefault) {
|
||||||
const layout = await this.findOne(tenantId, id);
|
|
||||||
await knex('page_layouts')
|
await knex('page_layouts')
|
||||||
.where({ object_id: layout.object_id })
|
.where({ object_id: layout.object_id, layout_type: layout.layout_type })
|
||||||
.whereNot({ id })
|
.whereNot({ id })
|
||||||
.update({ is_default: false });
|
.update({ is_default: false });
|
||||||
}
|
}
|
||||||
|
|||||||
264
frontend/components/ListViewLayoutEditor.vue
Normal file
264
frontend/components/ListViewLayoutEditor.vue
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<template>
|
||||||
|
<div class="list-view-layout-editor">
|
||||||
|
<div class="flex h-full">
|
||||||
|
<!-- Selected Fields Area -->
|
||||||
|
<div class="flex-1 p-4 overflow-auto">
|
||||||
|
<div class="mb-4 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold">{{ layoutName || 'List View Layout' }}</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" @click="handleClear">
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" @click="handleSave">
|
||||||
|
Save Layout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg bg-slate-50 dark:bg-slate-900 p-4 min-h-[400px]">
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
Drag fields to reorder them. Fields will appear in the list view in this order.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="selectedFields.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
<p>No fields selected.</p>
|
||||||
|
<p class="text-sm">Click or drag fields from the right panel to add them.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
ref="sortableContainer"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(field, index) in selectedFields"
|
||||||
|
:key="field.id"
|
||||||
|
class="p-3 border rounded cursor-move bg-white dark:bg-slate-800 hover:border-primary transition-colors flex items-center justify-between"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart($event, index)"
|
||||||
|
@dragover.prevent="handleDragOver($event, index)"
|
||||||
|
@drop="handleDrop($event, index)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-muted-foreground cursor-grab">
|
||||||
|
<GripVertical class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-sm">{{ field.label }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ field.apiName }} • {{ formatFieldType(field.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
@click="removeField(field.id)"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Fields Sidebar -->
|
||||||
|
<div class="w-80 border-l bg-white dark:bg-slate-950 p-4 overflow-auto">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Available Fields</h3>
|
||||||
|
<p class="text-xs text-muted-foreground mb-4">Click or drag to add field to list</p>
|
||||||
|
|
||||||
|
<div v-if="availableFields.length === 0" class="text-center py-4 text-muted-foreground text-sm">
|
||||||
|
All fields have been added to the layout.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="field in availableFields"
|
||||||
|
:key="field.id"
|
||||||
|
class="p-3 border rounded cursor-pointer bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleAvailableFieldDragStart($event, field)"
|
||||||
|
@click="addField(field)"
|
||||||
|
>
|
||||||
|
<div class="font-medium text-sm">{{ field.label }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ field.apiName }} • {{ formatFieldType(field.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { GripVertical, X } from 'lucide-vue-next'
|
||||||
|
import type { FieldLayoutItem } from '~/types/page-layout'
|
||||||
|
import type { FieldConfig } from '~/types/field-types'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fields: FieldConfig[]
|
||||||
|
initialLayout?: FieldLayoutItem[]
|
||||||
|
layoutName?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [layout: { fields: FieldLayoutItem[] }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Selected fields in order
|
||||||
|
const selectedFieldIds = ref<string[]>([])
|
||||||
|
const draggedIndex = ref<number | null>(null)
|
||||||
|
const draggedAvailableField = ref<FieldConfig | null>(null)
|
||||||
|
|
||||||
|
// Initialize with initial layout
|
||||||
|
watch(() => props.initialLayout, (layout) => {
|
||||||
|
if (layout && layout.length > 0) {
|
||||||
|
// Sort by order if available, otherwise use array order
|
||||||
|
const sorted = [...layout].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
selectedFieldIds.value = sorted.map(item => item.fieldId)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Computed selected fields in order
|
||||||
|
const selectedFields = computed(() => {
|
||||||
|
return selectedFieldIds.value
|
||||||
|
.map(id => props.fields.find(f => f.id === id))
|
||||||
|
.filter((f): f is FieldConfig => f !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Available fields (not selected)
|
||||||
|
const availableFields = computed(() => {
|
||||||
|
const selectedSet = new Set(selectedFieldIds.value)
|
||||||
|
return props.fields.filter(field => !selectedSet.has(field.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatFieldType = (type: string): string => {
|
||||||
|
const typeNames: Record<string, string> = {
|
||||||
|
'TEXT': 'Text',
|
||||||
|
'LONG_TEXT': 'Textarea',
|
||||||
|
'EMAIL': 'Email',
|
||||||
|
'PHONE': 'Phone',
|
||||||
|
'NUMBER': 'Number',
|
||||||
|
'CURRENCY': 'Currency',
|
||||||
|
'PERCENT': 'Percent',
|
||||||
|
'PICKLIST': 'Picklist',
|
||||||
|
'MULTI_PICKLIST': 'Multi-select',
|
||||||
|
'BOOLEAN': 'Checkbox',
|
||||||
|
'DATE': 'Date',
|
||||||
|
'DATE_TIME': 'DateTime',
|
||||||
|
'TIME': 'Time',
|
||||||
|
'URL': 'URL',
|
||||||
|
'LOOKUP': 'Lookup',
|
||||||
|
'FILE': 'File',
|
||||||
|
'IMAGE': 'Image',
|
||||||
|
'JSON': 'JSON',
|
||||||
|
'text': 'Text',
|
||||||
|
'textarea': 'Textarea',
|
||||||
|
'email': 'Email',
|
||||||
|
'number': 'Number',
|
||||||
|
'currency': 'Currency',
|
||||||
|
'select': 'Picklist',
|
||||||
|
'multiSelect': 'Multi-select',
|
||||||
|
'boolean': 'Checkbox',
|
||||||
|
'date': 'Date',
|
||||||
|
'datetime': 'DateTime',
|
||||||
|
'url': 'URL',
|
||||||
|
'lookup': 'Lookup',
|
||||||
|
'belongsTo': 'Lookup',
|
||||||
|
}
|
||||||
|
return typeNames[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const addField = (field: FieldConfig) => {
|
||||||
|
if (!selectedFieldIds.value.includes(field.id)) {
|
||||||
|
selectedFieldIds.value.push(field.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeField = (fieldId: string) => {
|
||||||
|
selectedFieldIds.value = selectedFieldIds.value.filter(id => id !== fieldId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and drop for reordering
|
||||||
|
const handleDragStart = (event: DragEvent, index: number) => {
|
||||||
|
draggedIndex.value = index
|
||||||
|
draggedAvailableField.value = null
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent, index: number) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent, targetIndex: number) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
// Handle drop from available fields
|
||||||
|
if (draggedAvailableField.value) {
|
||||||
|
addField(draggedAvailableField.value)
|
||||||
|
// Move the newly added field to the target position
|
||||||
|
const newFieldId = draggedAvailableField.value.id
|
||||||
|
const currentIndex = selectedFieldIds.value.indexOf(newFieldId)
|
||||||
|
if (currentIndex !== -1 && currentIndex !== targetIndex) {
|
||||||
|
const ids = [...selectedFieldIds.value]
|
||||||
|
ids.splice(currentIndex, 1)
|
||||||
|
ids.splice(targetIndex, 0, newFieldId)
|
||||||
|
selectedFieldIds.value = ids
|
||||||
|
}
|
||||||
|
draggedAvailableField.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reordering within selected fields
|
||||||
|
if (draggedIndex.value === null || draggedIndex.value === targetIndex) {
|
||||||
|
draggedIndex.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...selectedFieldIds.value]
|
||||||
|
const [removed] = ids.splice(draggedIndex.value, 1)
|
||||||
|
ids.splice(targetIndex, 0, removed)
|
||||||
|
selectedFieldIds.value = ids
|
||||||
|
draggedIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag from available fields
|
||||||
|
const handleAvailableFieldDragStart = (event: DragEvent, field: FieldConfig) => {
|
||||||
|
draggedAvailableField.value = field
|
||||||
|
draggedIndex.value = null
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'copy'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
if (confirm('Are you sure you want to clear all fields from the layout?')) {
|
||||||
|
selectedFieldIds.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const layout: FieldLayoutItem[] = selectedFieldIds.value.map((fieldId, index) => ({
|
||||||
|
fieldId,
|
||||||
|
order: index,
|
||||||
|
}))
|
||||||
|
|
||||||
|
emit('save', { fields: layout })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-view-layout-editor {
|
||||||
|
height: calc(100vh - 300px);
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -70,12 +70,36 @@ export const useFields = () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a ListView configuration from object definition
|
* Build a ListView configuration from object definition
|
||||||
|
* @param objectDef - The object definition containing fields
|
||||||
|
* @param customConfig - Optional custom configuration
|
||||||
|
* @param listLayoutConfig - Optional list view layout configuration from page_layouts
|
||||||
*/
|
*/
|
||||||
const buildListViewConfig = (
|
const buildListViewConfig = (
|
||||||
objectDef: any,
|
objectDef: any,
|
||||||
customConfig?: Partial<ListViewConfig>
|
customConfig?: Partial<ListViewConfig>,
|
||||||
|
listLayoutConfig?: { fields: Array<{ fieldId: string; order?: number }> } | null
|
||||||
): ListViewConfig => {
|
): ListViewConfig => {
|
||||||
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
|
let fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
|
||||||
|
|
||||||
|
// If a list layout is provided, filter and order fields according to it
|
||||||
|
if (listLayoutConfig && listLayoutConfig.fields && listLayoutConfig.fields.length > 0) {
|
||||||
|
// Sort layout fields by order
|
||||||
|
const sortedLayoutFields = [...listLayoutConfig.fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
|
||||||
|
// Map layout fields to actual field configs, preserving order
|
||||||
|
const orderedFields: FieldConfig[] = []
|
||||||
|
for (const layoutField of sortedLayoutFields) {
|
||||||
|
const fieldConfig = fields.find((f: FieldConfig) => f.id === layoutField.fieldId)
|
||||||
|
if (fieldConfig) {
|
||||||
|
orderedFields.push(fieldConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ordered fields if we found any, otherwise fall back to all fields
|
||||||
|
if (orderedFields.length > 0) {
|
||||||
|
fields = orderedFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
objectApiName: objectDef.apiName,
|
objectApiName: objectDef.apiName,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest } from '~/types/page-layout'
|
import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest, PageLayoutType } from '~/types/page-layout'
|
||||||
|
|
||||||
export const usePageLayouts = () => {
|
export const usePageLayouts = () => {
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
|
|
||||||
const getPageLayouts = async (objectId?: string) => {
|
const getPageLayouts = async (objectId?: string, layoutType?: PageLayoutType) => {
|
||||||
try {
|
try {
|
||||||
const params = objectId ? { objectId } : {}
|
const params: Record<string, string> = {}
|
||||||
|
if (objectId) params.objectId = objectId
|
||||||
|
if (layoutType) params.layoutType = layoutType
|
||||||
const response = await api.get('/page-layouts', { params })
|
const response = await api.get('/page-layouts', { params })
|
||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -24,9 +26,11 @@ export const usePageLayouts = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultPageLayout = async (objectId: string) => {
|
const getDefaultPageLayout = async (objectId: string, layoutType: PageLayoutType = 'detail') => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/page-layouts/default/${objectId}`)
|
const response = await api.get(`/page-layouts/default/${objectId}`, {
|
||||||
|
params: { layoutType }
|
||||||
|
})
|
||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching default page layout:', error)
|
console.error('Error fetching default page layout:', error)
|
||||||
|
|||||||
831
frontend/package-lock.json
generated
831
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useFields, useViewState } from '@/composables/useFieldViews'
|
import { useFields, useViewState } from '@/composables/useFieldViews'
|
||||||
|
import { usePageLayouts } from '@/composables/usePageLayouts'
|
||||||
import ListView from '@/components/views/ListView.vue'
|
import ListView from '@/components/views/ListView.vue'
|
||||||
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||||
import EditView from '@/components/views/EditViewEnhanced.vue'
|
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||||
@@ -19,6 +20,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
||||||
|
const { getDefaultPageLayout } = usePageLayouts()
|
||||||
|
|
||||||
// Use breadcrumbs composable
|
// Use breadcrumbs composable
|
||||||
const { setBreadcrumbs } = useBreadcrumbs()
|
const { setBreadcrumbs } = useBreadcrumbs()
|
||||||
@@ -40,6 +42,7 @@ const view = computed(() => {
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
const objectDefinition = ref<any>(null)
|
const objectDefinition = ref<any>(null)
|
||||||
|
const listViewLayout = ref<any>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -134,11 +137,13 @@ watch([objectDefinition, currentRecord, recordId], () => {
|
|||||||
// View configs
|
// View configs
|
||||||
const listConfig = computed(() => {
|
const listConfig = computed(() => {
|
||||||
if (!objectDefinition.value) return null
|
if (!objectDefinition.value) return null
|
||||||
|
// Pass the list view layout config to buildListViewConfig if available
|
||||||
|
const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig
|
||||||
return buildListViewConfig(objectDefinition.value, {
|
return buildListViewConfig(objectDefinition.value, {
|
||||||
searchable: true,
|
searchable: true,
|
||||||
exportable: true,
|
exportable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
})
|
}, layoutConfig)
|
||||||
})
|
})
|
||||||
|
|
||||||
const detailConfig = computed(() => {
|
const detailConfig = computed(() => {
|
||||||
@@ -172,6 +177,16 @@ const fetchObjectDefinition = async () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
const response = await api.get(`/setup/objects/${objectApiName.value}`)
|
const response = await api.get(`/setup/objects/${objectApiName.value}`)
|
||||||
objectDefinition.value = response
|
objectDefinition.value = response
|
||||||
|
|
||||||
|
// Fetch the default list view layout for this object
|
||||||
|
if (response?.id) {
|
||||||
|
try {
|
||||||
|
listViewLayout.value = await getDefaultPageLayout(response.id, 'list')
|
||||||
|
} catch (e) {
|
||||||
|
// No list view layout configured, will use default behavior
|
||||||
|
listViewLayout.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message || 'Failed to load object definition'
|
error.value = e.message || 'Failed to load object definition'
|
||||||
console.error('Error fetching object definition:', e)
|
console.error('Error fetching object definition:', e)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useFields, useViewState } from '@/composables/useFieldViews'
|
import { useFields, useViewState } from '@/composables/useFieldViews'
|
||||||
|
import { usePageLayouts } from '@/composables/usePageLayouts'
|
||||||
import ListView from '@/components/views/ListView.vue'
|
import ListView from '@/components/views/ListView.vue'
|
||||||
import DetailView from '@/components/views/DetailView.vue'
|
import DetailView from '@/components/views/DetailView.vue'
|
||||||
import EditView from '@/components/views/EditView.vue'
|
import EditView from '@/components/views/EditView.vue'
|
||||||
@@ -11,6 +12,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
||||||
|
const { getDefaultPageLayout } = usePageLayouts()
|
||||||
|
|
||||||
// Get object API name from route
|
// Get object API name from route
|
||||||
const objectApiName = computed(() => route.params.objectName as string)
|
const objectApiName = computed(() => route.params.objectName as string)
|
||||||
@@ -25,6 +27,7 @@ const view = computed(() => {
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
const objectDefinition = ref<any>(null)
|
const objectDefinition = ref<any>(null)
|
||||||
|
const listViewLayout = ref<any>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -66,11 +69,13 @@ onBeforeUnmount(() => {
|
|||||||
// View configs
|
// View configs
|
||||||
const listConfig = computed(() => {
|
const listConfig = computed(() => {
|
||||||
if (!objectDefinition.value) return null
|
if (!objectDefinition.value) return null
|
||||||
|
// Pass the list view layout config to buildListViewConfig if available
|
||||||
|
const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig
|
||||||
return buildListViewConfig(objectDefinition.value, {
|
return buildListViewConfig(objectDefinition.value, {
|
||||||
searchable: true,
|
searchable: true,
|
||||||
exportable: true,
|
exportable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
})
|
}, layoutConfig)
|
||||||
})
|
})
|
||||||
|
|
||||||
const detailConfig = computed(() => {
|
const detailConfig = computed(() => {
|
||||||
@@ -93,6 +98,16 @@ const fetchObjectDefinition = async () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
const response = await api.get(`/setup/objects/${objectApiName.value}`)
|
const response = await api.get(`/setup/objects/${objectApiName.value}`)
|
||||||
objectDefinition.value = response
|
objectDefinition.value = response
|
||||||
|
|
||||||
|
// Fetch the default list view layout for this object
|
||||||
|
if (response?.id) {
|
||||||
|
try {
|
||||||
|
listViewLayout.value = await getDefaultPageLayout(response.id, 'list')
|
||||||
|
} catch (e) {
|
||||||
|
// No list view layout configured, will use default behavior
|
||||||
|
listViewLayout.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message || 'Failed to load object definition'
|
error.value = e.message || 'Failed to load object definition'
|
||||||
console.error('Error fetching object definition:', e)
|
console.error('Error fetching object definition:', e)
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<Tabs v-model="activeTab" default-value="fields" class="w-full">
|
<Tabs v-model="activeTab" default-value="fields" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-3 max-w-2xl">
|
<TabsList class="grid w-full grid-cols-4 max-w-2xl">
|
||||||
<TabsTrigger value="fields">Fields</TabsTrigger>
|
<TabsTrigger value="fields">Fields</TabsTrigger>
|
||||||
<TabsTrigger value="access">Access</TabsTrigger>
|
<TabsTrigger value="access">Access</TabsTrigger>
|
||||||
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
|
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
|
||||||
|
<TabsTrigger value="listLayouts">List View Layouts</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Fields Tab -->
|
<!-- Fields Tab -->
|
||||||
@@ -148,7 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
v-if="layout.isDefault"
|
v-if="layout.isDefault || layout.is_default"
|
||||||
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
|
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
|
||||||
>
|
>
|
||||||
Default
|
Default
|
||||||
@@ -185,6 +186,84 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- List View Layouts Tab -->
|
||||||
|
<TabsContent value="listLayouts" class="mt-6">
|
||||||
|
<div v-if="!selectedListLayout" class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">List View Layouts</h2>
|
||||||
|
<Button @click="handleCreateListLayout">
|
||||||
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
|
New List Layout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
Configure which fields appear in list views and their order.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loadingListLayouts" class="text-center py-8">
|
||||||
|
Loading list layouts...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="listLayouts.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
No list view layouts yet. Create one to customize your list views.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="layout in listLayouts"
|
||||||
|
:key="layout.id"
|
||||||
|
class="p-4 border rounded-lg bg-card hover:border-primary cursor-pointer transition-colors"
|
||||||
|
@click="handleSelectListLayout(layout)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold">{{ layout.name }}</h3>
|
||||||
|
<p v-if="layout.description" class="text-sm text-muted-foreground">
|
||||||
|
{{ layout.description }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
{{ getListLayoutFieldCount(layout) }} fields configured
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="layout.isDefault || layout.is_default"
|
||||||
|
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="handleDeleteListLayout(layout.id)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List Layout Editor -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="mb-4">
|
||||||
|
<Button variant="outline" @click="selectedListLayout = null">
|
||||||
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
|
Back to List Layouts
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListViewLayoutEditor
|
||||||
|
:fields="object.fields"
|
||||||
|
:initial-layout="(selectedListLayout.layoutConfig || selectedListLayout.layout_config)?.fields || []"
|
||||||
|
:layout-name="selectedListLayout.name"
|
||||||
|
@save="handleSaveListLayout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,6 +378,7 @@ import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
|
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
|
||||||
|
import ListViewLayoutEditor from '@/components/ListViewLayoutEditor.vue'
|
||||||
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
|
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
|
||||||
import FieldTypeSelector from '@/components/fields/FieldTypeSelector.vue'
|
import FieldTypeSelector from '@/components/fields/FieldTypeSelector.vue'
|
||||||
import FieldAttributesCommon from '@/components/fields/FieldAttributesCommon.vue'
|
import FieldAttributesCommon from '@/components/fields/FieldAttributesCommon.vue'
|
||||||
@@ -315,11 +395,16 @@ const loading = ref(true)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const activeTab = ref('fields')
|
const activeTab = ref('fields')
|
||||||
|
|
||||||
// Page layouts state
|
// Page layouts state (detail/edit layouts)
|
||||||
const layouts = ref<PageLayout[]>([])
|
const layouts = ref<PageLayout[]>([])
|
||||||
const loadingLayouts = ref(false)
|
const loadingLayouts = ref(false)
|
||||||
const selectedLayout = ref<PageLayout | null>(null)
|
const selectedLayout = ref<PageLayout | null>(null)
|
||||||
|
|
||||||
|
// List view layouts state
|
||||||
|
const listLayouts = ref<PageLayout[]>([])
|
||||||
|
const loadingListLayouts = ref(false)
|
||||||
|
const selectedListLayout = ref<PageLayout | null>(null)
|
||||||
|
|
||||||
// Field management state
|
// Field management state
|
||||||
const showFieldDialog = ref(false)
|
const showFieldDialog = ref(false)
|
||||||
const fieldDialogMode = ref<'create' | 'edit'>('create')
|
const fieldDialogMode = ref<'create' | 'edit'>('create')
|
||||||
@@ -420,7 +505,8 @@ const fetchLayouts = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
loadingLayouts.value = true
|
loadingLayouts.value = true
|
||||||
layouts.value = await getPageLayouts(object.value.id)
|
// Fetch only detail layouts (default type)
|
||||||
|
layouts.value = await getPageLayouts(object.value.id, 'detail')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Error fetching layouts:', e)
|
console.error('Error fetching layouts:', e)
|
||||||
toast.error('Failed to load page layouts')
|
toast.error('Failed to load page layouts')
|
||||||
@@ -429,6 +515,20 @@ const fetchLayouts = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchListLayouts = async () => {
|
||||||
|
if (!object.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingListLayouts.value = true
|
||||||
|
listLayouts.value = await getPageLayouts(object.value.id, 'list')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error fetching list layouts:', e)
|
||||||
|
toast.error('Failed to load list view layouts')
|
||||||
|
} finally {
|
||||||
|
loadingListLayouts.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openFieldDialog = async (mode: 'create' | 'edit', field?: any) => {
|
const openFieldDialog = async (mode: 'create' | 'edit', field?: any) => {
|
||||||
fieldDialogMode.value = mode
|
fieldDialogMode.value = mode
|
||||||
fieldDialogError.value = null
|
fieldDialogError.value = null
|
||||||
@@ -684,6 +784,7 @@ const handleCreateLayout = async () => {
|
|||||||
const newLayout = await createPageLayout({
|
const newLayout = await createPageLayout({
|
||||||
name,
|
name,
|
||||||
objectId: object.value.id,
|
objectId: object.value.id,
|
||||||
|
layoutType: 'detail',
|
||||||
isDefault: layouts.value.length === 0,
|
isDefault: layouts.value.length === 0,
|
||||||
layoutConfig: { fields: [], relatedLists: [] },
|
layoutConfig: { fields: [], relatedLists: [] },
|
||||||
})
|
})
|
||||||
@@ -736,6 +837,73 @@ const handleDeleteLayout = async (layoutId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List View Layout methods
|
||||||
|
const handleCreateListLayout = async () => {
|
||||||
|
const name = prompt('Enter a name for the new list view layout:')
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newLayout = await createPageLayout({
|
||||||
|
name,
|
||||||
|
objectId: object.value.id,
|
||||||
|
layoutType: 'list',
|
||||||
|
isDefault: listLayouts.value.length === 0,
|
||||||
|
layoutConfig: { fields: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
listLayouts.value.push(newLayout)
|
||||||
|
selectedListLayout.value = newLayout
|
||||||
|
toast.success('List view layout created successfully')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error creating list layout:', e)
|
||||||
|
toast.error('Failed to create list view layout')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectListLayout = (layout: PageLayout) => {
|
||||||
|
selectedListLayout.value = layout
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveListLayout = async (layoutConfig: { fields: FieldLayoutItem[] }) => {
|
||||||
|
if (!selectedListLayout.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updatePageLayout(selectedListLayout.value.id, {
|
||||||
|
layoutConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update the layout in the list
|
||||||
|
const index = listLayouts.value.findIndex(l => l.id === selectedListLayout.value!.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
listLayouts.value[index] = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedListLayout.value = updated
|
||||||
|
toast.success('List view layout saved successfully')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error saving list layout:', e)
|
||||||
|
toast.error('Failed to save list view layout')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteListLayout = async (layoutId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this list view layout?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePageLayout(layoutId)
|
||||||
|
listLayouts.value = listLayouts.value.filter(l => l.id !== layoutId)
|
||||||
|
toast.success('List view layout deleted successfully')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error deleting list layout:', e)
|
||||||
|
toast.error('Failed to delete list view layout')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getListLayoutFieldCount = (layout: PageLayout): number => {
|
||||||
|
const config = layout.layoutConfig || layout.layout_config
|
||||||
|
return config?.fields?.length || 0
|
||||||
|
}
|
||||||
|
|
||||||
const handleAccessUpdate = (orgWideDefault: string) => {
|
const handleAccessUpdate = (orgWideDefault: string) => {
|
||||||
if (object.value) {
|
if (object.value) {
|
||||||
object.value.orgWideDefault = orgWideDefault
|
object.value.orgWideDefault = orgWideDefault
|
||||||
@@ -747,6 +915,9 @@ watch(activeTab, (newTab) => {
|
|||||||
if (newTab === 'layouts' && layouts.value.length === 0 && !loadingLayouts.value) {
|
if (newTab === 'layouts' && layouts.value.length === 0 && !loadingLayouts.value) {
|
||||||
fetchLayouts()
|
fetchLayouts()
|
||||||
}
|
}
|
||||||
|
if (newTab === 'listLayouts' && listLayouts.value.length === 0 && !loadingListLayouts.value) {
|
||||||
|
fetchListLayouts()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -755,5 +926,8 @@ onMounted(async () => {
|
|||||||
if (activeTab.value === 'layouts') {
|
if (activeTab.value === 'layouts') {
|
||||||
await fetchLayouts()
|
await fetchLayouts()
|
||||||
}
|
}
|
||||||
|
if (activeTab.value === 'listLayouts') {
|
||||||
|
await fetchListLayouts()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
export interface FieldLayoutItem {
|
export interface FieldLayoutItem {
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
x: number;
|
x?: number;
|
||||||
y: number;
|
y?: number;
|
||||||
w: number;
|
w?: number;
|
||||||
h: number;
|
h?: number;
|
||||||
|
// For list layouts: field order (optional)
|
||||||
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PageLayoutType = 'detail' | 'list';
|
||||||
|
|
||||||
export interface PageLayoutConfig {
|
export interface PageLayoutConfig {
|
||||||
fields: FieldLayoutItem[];
|
fields: FieldLayoutItem[];
|
||||||
relatedLists?: string[];
|
relatedLists?: string[];
|
||||||
@@ -15,16 +19,23 @@ export interface PageLayout {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
objectId: string;
|
objectId: string;
|
||||||
|
layoutType: PageLayoutType;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
layoutConfig: PageLayoutConfig;
|
layoutConfig: PageLayoutConfig;
|
||||||
description?: string;
|
description?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
// Database column names (snake_case) - used when data comes directly from DB
|
||||||
|
layout_type?: PageLayoutType;
|
||||||
|
layout_config?: PageLayoutConfig;
|
||||||
|
object_id?: string;
|
||||||
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePageLayoutRequest {
|
export interface CreatePageLayoutRequest {
|
||||||
name: string;
|
name: string;
|
||||||
objectId: string;
|
objectId: string;
|
||||||
|
layoutType?: PageLayoutType;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
layoutConfig: PageLayoutConfig;
|
layoutConfig: PageLayoutConfig;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user