Files
neo/frontend/composables/useFieldViews.ts

438 lines
13 KiB
TypeScript

import { computed, ref } from 'vue'
import type { FieldConfig, ListViewConfig, DetailViewConfig, EditViewConfig, ViewMode } from '@/types/field-types'
/**
* Composable for working with dynamic fields and views
* Helps convert backend field definitions to frontend field configs
*/
export const useFields = () => {
/**
* Convert backend field definition to frontend FieldConfig
*/
const mapFieldDefinitionToConfig = (fieldDef: any): FieldConfig => {
// Convert isSystem to boolean (handle 0/1 from database)
const isSystemField = Boolean(fieldDef.isSystem)
// Define all system/auto-generated field names
const systemFieldNames = ['id', 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updatedBy', 'tenantId', 'ownerId']
const isAutoGeneratedField = systemFieldNames.includes(fieldDef.apiName)
// Hide system fields and auto-generated fields on edit
const shouldHideOnEdit = isSystemField || isAutoGeneratedField
// Hide 'id' field from list view by default (check both apiName and id field)
const shouldHideOnList = fieldDef.apiName === 'id' || fieldDef.label === 'Id' || fieldDef.label === 'ID'
return {
id: fieldDef.id,
apiName: fieldDef.apiName,
label: fieldDef.label,
type: fieldDef.type,
// Default values
placeholder: fieldDef.placeholder || fieldDef.description,
helpText: fieldDef.helpText || fieldDef.description,
defaultValue: fieldDef.defaultValue,
// Validation
isRequired: fieldDef.isRequired,
isReadOnly: isAutoGeneratedField || fieldDef.isReadOnly,
validationRules: fieldDef.validationRules || [],
// View options - only hide system and auto-generated fields by default
showOnList: fieldDef.showOnList ?? !shouldHideOnList,
showOnDetail: fieldDef.showOnDetail ?? true,
showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
sortable: fieldDef.sortable ?? true,
// Field type specific
options: fieldDef.options,
rows: fieldDef.rows,
min: fieldDef.min,
max: fieldDef.max,
step: fieldDef.step,
accept: fieldDef.accept,
relationObject: fieldDef.relationObject,
relationObjects: fieldDef.relationObjects,
relationDisplayField: fieldDef.relationDisplayField,
relationTypeField: fieldDef.relationTypeField,
// Formatting
format: fieldDef.format,
prefix: fieldDef.prefix,
suffix: fieldDef.suffix,
// Advanced
dependsOn: fieldDef.uiMetadata?.dependsOn,
computedValue: fieldDef.uiMetadata?.computedValue,
}
}
/**
* 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 = (
objectDef: any,
customConfig?: Partial<ListViewConfig>,
listLayoutConfig?: { fields: Array<{ fieldId: string; order?: number }> } | null
): ListViewConfig => {
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 {
objectApiName: objectDef.apiName,
mode: 'list' as ViewMode,
fields,
pageSize: 10,
maxFrontendRecords: 500,
searchable: true,
filterable: true,
exportable: true,
...customConfig,
}
}
/**
* Build a DetailView configuration from object definition
*/
const buildDetailViewConfig = (
objectDef: any,
customConfig?: Partial<DetailViewConfig>
): DetailViewConfig => {
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
return {
objectApiName: objectDef.apiName,
mode: 'detail' as ViewMode,
fields,
relatedLists: objectDef.relatedLists || [],
...customConfig,
}
}
/**
* Build an EditView configuration from object definition
*/
const buildEditViewConfig = (
objectDef: any,
customConfig?: Partial<EditViewConfig>
): EditViewConfig => {
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
return {
objectApiName: objectDef.apiName,
mode: 'edit' as ViewMode,
fields,
submitLabel: 'Save',
cancelLabel: 'Cancel',
...customConfig,
}
}
/**
* Auto-generate sections based on field types or custom logic
*/
const generateSections = (fields: FieldConfig[]) => {
// Group fields by some logic - this is a simple example
const basicFields = fields.filter(f =>
['text', 'email', 'password', 'number'].includes(f.type)
)
const relationFields = fields.filter(f =>
['belongsTo', 'hasMany', 'manyToMany'].includes(f.type)
)
const otherFields = fields.filter(f =>
!basicFields.includes(f) && !relationFields.includes(f)
)
const sections = []
if (basicFields.length > 0) {
sections.push({
title: 'Basic Information',
fields: basicFields.map(f => f.apiName),
})
}
if (relationFields.length > 0) {
sections.push({
title: 'Related Records',
fields: relationFields.map(f => f.apiName),
collapsible: true,
})
}
if (otherFields.length > 0) {
sections.push({
title: 'Additional Information',
fields: otherFields.map(f => f.apiName),
collapsible: true,
defaultCollapsed: true,
})
}
return sections
}
return {
mapFieldDefinitionToConfig,
buildListViewConfig,
buildDetailViewConfig,
buildEditViewConfig,
generateSections,
}
}
/**
* Composable for managing view state (CRUD operations)
*/
export const useViewState = <T extends { id?: string }>(
apiEndpoint: string
) => {
const records = ref<T[]>([])
const totalCount = ref(0)
const currentRecord = ref<T | null>(null)
const currentView = ref<'list' | 'detail' | 'edit'>('list')
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const { api } = useApi()
const normalizeListResponse = (response: any) => {
const payload: { data: T[]; totalCount: number; page?: number; pageSize?: number } = {
data: [],
totalCount: 0,
}
if (Array.isArray(response)) {
payload.data = response
payload.totalCount = response.length
return payload
}
if (response && typeof response === 'object') {
if (Array.isArray(response.data)) {
payload.data = response.data
} else if (Array.isArray((response as any).records)) {
payload.data = (response as any).records
} else if (Array.isArray((response as any).results)) {
payload.data = (response as any).results
}
payload.totalCount =
response.totalCount ??
response.total ??
response.count ??
payload.data.length ??
0
payload.page = response.page
payload.pageSize = response.pageSize
}
return payload
}
const fetchRecords = async (params?: Record<string, any>, options?: { append?: boolean }) => {
loading.value = true
error.value = null
try {
const response = await api.get(apiEndpoint, { params })
const normalized = normalizeListResponse(response)
totalCount.value = normalized.totalCount ?? normalized.data.length ?? 0
records.value = options?.append
? [...records.value, ...normalized.data]
: normalized.data
return normalized
} catch (e: any) {
error.value = e.message
console.error('Failed to fetch records:', e)
} finally {
loading.value = false
}
}
const fetchRecord = async (id: string) => {
loading.value = true
error.value = null
try {
const response = await api.get(`${apiEndpoint}/${id}`)
// Handle response - data might be directly in response or in response.data
currentRecord.value = response.data || response
} catch (e: any) {
error.value = e.message
console.error('Failed to fetch record:', e)
} finally {
loading.value = false
}
}
const createRecord = async (data: Partial<T>) => {
saving.value = true
error.value = null
try {
const response = await api.post(apiEndpoint, data)
// Handle response - it might be the data directly or wrapped in { data: ... }
const recordData = response.data || response
records.value.push(recordData)
totalCount.value += 1
currentRecord.value = recordData
return recordData
} catch (e: any) {
error.value = e.message
console.error('Failed to create record:', e)
throw e
} finally {
saving.value = false
}
}
const updateRecord = async (id: string, data: Partial<T>) => {
saving.value = true
error.value = null
try {
// Remove auto-generated fields that shouldn't be updated
const { id: _id, createdAt, created_at, updatedAt, updated_at, createdBy, updatedBy, ...updateData } = data as any
const response = await api.put(`${apiEndpoint}/${id}`, updateData)
// Handle response - data might be directly in response or in response.data
const recordData = response.data || response
const idx = records.value.findIndex(r => r.id === id)
if (idx !== -1) {
records.value[idx] = recordData
}
currentRecord.value = recordData
return recordData
} catch (e: any) {
error.value = e.message
console.error('Failed to update record:', e)
throw e
} finally {
saving.value = false
}
}
const deleteRecord = async (id: string) => {
loading.value = true
error.value = null
try {
await api.delete(`${apiEndpoint}/${id}`)
records.value = records.value.filter(r => r.id !== id)
totalCount.value = Math.max(0, totalCount.value - 1)
if (currentRecord.value?.id === id) {
currentRecord.value = null
}
} catch (e: any) {
error.value = e.message
console.error('Failed to delete record:', e)
throw e
} finally {
loading.value = false
}
}
const deleteRecords = async (ids: string[]) => {
loading.value = true
error.value = null
try {
const useBulkEndpoint = apiEndpoint.includes('/runtime/objects/')
if (useBulkEndpoint) {
const response = await api.post(`${apiEndpoint}/bulk-delete`, { ids })
const deletedIds = Array.isArray(response?.deletedIds) ? response.deletedIds : ids
records.value = records.value.filter(r => !deletedIds.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - deletedIds.length)
return {
deletedIds,
deniedIds: Array.isArray(response?.deniedIds) ? response.deniedIds : [],
}
}
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
records.value = records.value.filter(r => !ids.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - ids.length)
return {
deletedIds: ids,
deniedIds: [],
}
} catch (e: any) {
error.value = e.message
console.error('Failed to delete records:', e)
throw e
} finally {
loading.value = false
}
}
const showList = () => {
currentView.value = 'list'
currentRecord.value = null
}
const showDetail = (record: T) => {
currentRecord.value = record
currentView.value = 'detail'
}
const showEdit = (record?: T) => {
currentRecord.value = record || ({} as T)
currentView.value = 'edit'
}
const handleSave = async (data: T) => {
let savedRecord
if (data.id) {
savedRecord = await updateRecord(data.id, data)
} else {
savedRecord = await createRecord(data)
}
return savedRecord
}
return {
// State
records,
totalCount,
currentRecord,
currentView,
loading,
saving,
error,
// Methods
fetchRecords,
fetchRecord,
createRecord,
updateRecord,
deleteRecord,
deleteRecords,
// Navigation
showList,
showDetail,
showEdit,
handleSave,
}
}