21 Commits

Author SHA1 Message Date
Francisco Gaona
d00e26ad27 WIP - allow search in system fields 2026-01-13 10:55:11 +01:00
Francisco Gaona
9dcedcdf69 WIP - search with AI 2026-01-13 10:44:38 +01:00
Francisco Gaona
47fa72451d WIP - Added pagination for list view 2026-01-13 09:03:11 +01:00
Francisco Gaona
730fddd181 WIP - add meilisearch for easier record find for AI assistant 2026-01-13 07:44:47 +01:00
Francisco Gaona
a62f68fc10 WIP - refresh list after AI record creation 2026-01-13 00:00:40 +01:00
Francisco Gaona
d2b3fce4eb WIP - initial AI assistant chat working creating records 2026-01-12 23:55:57 +01:00
Francisco Gaona
ca11c8cbe7 WIP - add contct and contact details 2026-01-12 21:08:47 +01:00
Francisco Gaona
f8a3cffb64 WIP - dislpay name field for look up fields in related lists 2026-01-09 08:00:05 +01:00
Francisco Gaona
852c4e28d2 WIP - display related lists 2026-01-09 07:49:30 +01:00
phyroslam
2075fec183 Merge pull request #2 from phyroslam/codex/add-dynamic-related-lists-to-detail-views
Add dynamic tenant-level related lists and page layout selection
2026-01-08 15:16:17 -08:00
phyroslam
9be98e4a09 Add dynamic related lists with page layout support 2026-01-08 15:15:28 -08:00
Francisco Gaona
43cae4289b WIP - add owner to contact 2026-01-08 23:56:48 +01:00
Francisco Gaona
4de9203fd5 Merge branch 'drawer' into codex/add-contact-and-contact-details-objects 2026-01-08 21:34:48 +01:00
Francisco Gaona
8b9fa81594 WIP - UI fixes for bottom bar 2026-01-08 21:19:48 +01:00
phyroslam
a75b41fd0b Add contact and contact detail system objects 2026-01-08 07:41:09 -08:00
Francisco Gaona
7ae36411db WIP - move AI suggestions 2026-01-08 00:28:45 +01:00
Francisco Gaona
c9a3e00a94 WIP - UI cahnges to bottom bar 2026-01-08 00:21:12 +01:00
Francisco Gaona
8ad3fac1b0 WIP - UI drawer initial 2026-01-07 22:11:36 +01:00
Francisco Gaona
b34da6956c WIP - Fix create field dialog placement and look up field creation 2026-01-07 21:00:06 +01:00
Francisco Gaona
6c73eb1658 WIP - Basic adding and deleting field 2026-01-06 10:01:02 +01:00
Francisco Gaona
8e4690c9c9 WIP - initial iteration on manage fields 2026-01-06 09:45:29 +01:00
11 changed files with 61 additions and 319 deletions

View File

@@ -694,6 +694,8 @@ export class AiAssistantService {
type: field.type,
}));
console.log('fields:',fields);
const formatInstructions = parser.getFormatInstructions();
const today = new Date().toISOString();

View File

@@ -1,38 +1,7 @@
import { Model, ModelOptions, QueryContext } from 'objection';
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
export class BaseModel extends Model {
/**
* Use a minimal column mapper: keep property names as-is, but handle
* timestamp fields that are stored as created_at/updated_at in the DB.
*/
static columnNameMappers = {
parse(dbRow: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(dbRow || {})) {
if (key === 'created_at') {
mapped.createdAt = value;
} else if (key === 'updated_at') {
mapped.updatedAt = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
format(model: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(model || {})) {
if (key === 'createdAt') {
mapped.created_at = value;
} else if (key === 'updatedAt') {
mapped.updated_at = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
};
static columnNameMappers = snakeCaseMappers();
id: string;
createdAt: Date;

View File

@@ -179,8 +179,7 @@ export class DynamicModelFactory {
* Convert a field definition to JSON schema property
*/
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
const baseSchema = () => {
switch (field.type.toUpperCase()) {
switch (field.type.toUpperCase()) {
case 'TEXT':
case 'STRING':
case 'EMAIL':
@@ -188,57 +187,45 @@ export class DynamicModelFactory {
case 'PHONE':
case 'PICKLIST':
case 'MULTI_PICKLIST':
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
case 'LONG_TEXT':
return { type: 'string' };
return { type: 'string' };
case 'NUMBER':
case 'DECIMAL':
case 'CURRENCY':
case 'PERCENT':
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
case 'INTEGER':
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
case 'BOOLEAN':
return { type: 'boolean', default: false };
return { type: 'boolean', default: false };
case 'DATE':
return { type: 'string', format: 'date' };
return { type: 'string', format: 'date' };
case 'DATE_TIME':
return { type: 'string', format: 'date-time' };
return { type: 'string', format: 'date-time' };
case 'LOOKUP':
case 'BELONGS_TO':
return { type: 'string' };
return { type: 'string' };
default:
return { type: 'string' };
}
};
const schema = baseSchema();
// Allow null for non-required fields so optional strings/numbers don't fail validation
if (!field.isRequired) {
return {
anyOf: [schema, { type: 'null' }],
};
return { type: 'string' };
}
return schema;
}
/**

View File

@@ -1179,8 +1179,7 @@ export class ObjectService {
objectApiName,
editableData,
);
// Use patch to avoid validating or overwriting fields that aren't present in the edit view
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
await boundModel.query().where({ id: recordId }).update(normalizedEditableData);
const record = await boundModel.query().where({ id: recordId }).first();
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
return record;
@@ -1284,23 +1283,10 @@ export class ObjectService {
if (missingIds.length > 0) {
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
}
const deletableIds: string[] = [];
const deniedIds: string[] = [];
// Check if user can delete each record
for (const record of records) {
const canDelete = await this.authService.canPerformAction(
'delete',
objectDefModel,
record,
user,
knex,
);
if (canDelete) {
deletableIds.push(record.id);
} else {
deniedIds.push(record.id);
}
await this.authService.assertCanPerformAction('delete', objectDefModel, record, user, knex);
}
// Ensure model is registered
@@ -1308,23 +1294,14 @@ export class ObjectService {
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
if (deletableIds.length > 0) {
await boundModel.query().whereIn('id', deletableIds).delete();
}
await boundModel.query().whereIn('id', recordIds).delete();
// Remove from search index
await Promise.all(
deletableIds.map((id) =>
this.removeIndexedRecord(resolvedTenantId, objectApiName, id),
),
recordIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id)),
);
return {
success: true,
deleted: deletableIds.length,
deletedIds: deletableIds,
deniedIds,
};
return { success: true, deleted: recordIds.length };
}
private async indexRecord(

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { Toaster } from 'vue-sonner'
import BottomDrawer from '@/components/BottomDrawer.vue'
</script>
<template>
<div>
<Toaster position="top-right" :duration="4000" richColors />
<NuxtPage />
<BottomDrawer />
</div>
</template>

View File

@@ -11,9 +11,6 @@ import { useSoftphone } from '~/composables/useSoftphone'
const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false)
const activeTab = useState<string>('bottomDrawerTab', () => 'softphone')
const drawerHeight = useState<number>('bottomDrawerHeight', () => 240)
const props = defineProps<{
bounds?: { left: number; width: number }
}>()
const softphone = useSoftphone()
@@ -193,17 +190,9 @@ onBeforeUnmount(() => {
</script>
<template>
<div
class="pointer-events-none fixed bottom-0 z-30 flex justify-center px-2"
:style="{
left: props.bounds?.left ? `${props.bounds.left}px` : '0',
width: props.bounds?.width ? `${props.bounds.width}px` : '100vw',
right: props.bounds?.width ? 'auto' : '0',
}"
>
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-30 flex justify-center px-2">
<div
class="pointer-events-auto w-full border border-border bg-background transition-all duration-200"
:class="{ 'shadow-2xl': isDrawerOpen }"
class="pointer-events-auto w-full border border-border bg-background shadow-xl transition-all duration-200"
:style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }"
>
<div class="grid grid-cols-3 items-center justify-between border-border px-2 py-2">

View File

@@ -18,7 +18,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CheckboxRoot
v-bind="forwarded"
:class="
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class)"
>
<CheckboxIndicator class="grid place-content-center text-current">

View File

@@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button'
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 FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
@@ -50,13 +49,11 @@ const emit = defineEmits<{
}>()
// State
const normalizeId = (id: any) => String(id)
const selectedRowIds = ref<string[]>([])
const selectedRows = ref<Set<string>>(new Set())
const searchQuery = ref('')
const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const bulkAction = ref('delete')
// Computed
const visibleFields = computed(() =>
@@ -97,39 +94,27 @@ const showLoadMore = computed(() => (
))
const allSelected = computed({
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
set: (val: boolean) => {
if (val) {
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
selectedRows.value = new Set(props.data.map(row => row.id))
} else {
selectedRowIds.value = []
selectedRows.value.clear()
}
emit('row-select', getSelectedRows())
},
})
const getSelectedRows = () => {
const idSet = new Set(selectedRowIds.value)
return props.data.filter(row => idSet.has(normalizeId(row.id)))
return props.data.filter(row => selectedRows.value.has(row.id))
}
const toggleRowSelection = (rowId: string) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
const setRowSelection = (rowId: string, checked: boolean) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
if (checked) {
nextSelection.add(normalizedId)
if (selectedRows.value.has(rowId)) {
selectedRows.value.delete(rowId)
} else {
nextSelection.delete(normalizedId)
selectedRows.value.add(rowId)
}
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
@@ -151,14 +136,6 @@ const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows())
}
const handleBulkAction = () => {
if (bulkAction.value === 'delete') {
emit('delete', getSelectedRows())
return
}
emit('action', bulkAction.value, getSelectedRows())
}
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
if (nextPage !== currentPage.value) {
@@ -180,19 +157,6 @@ watch(
}
}
)
watch(
() => props.data,
(rows) => {
const rowIds = new Set(rows.map(row => normalizeId(row.id)))
const nextSelection = selectedRowIds.value.filter(id => rowIds.has(id))
if (nextSelection.length !== selectedRowIds.value.length) {
selectedRowIds.value = nextSelection
emit('row-select', getSelectedRows())
}
},
{ deep: true }
)
</script>
<template>
@@ -217,24 +181,14 @@ watch(
<div class="flex items-center gap-2">
<!-- Bulk Actions -->
<template v-if="selectedRowIds.length > 0">
<template v-if="selectedRows.size > 0">
<Badge variant="secondary" class="px-3 py-1">
{{ selectedRowIds.length }} selected
{{ selectedRows.size }} selected
</Badge>
<div class="flex items-center gap-2">
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
<SelectTrigger class="h-8 w-[180px]">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete selected</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" @click="handleBulkAction">
<Trash2 class="h-4 w-4 mr-2" />
Run
</Button>
</div>
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
<Trash2 class="h-4 w-4 mr-2" />
Delete
</Button>
</template>
<!-- Custom Actions -->
@@ -268,10 +222,7 @@ watch(
<TableHeader>
<TableRow>
<TableHead v-if="selectable" class="w-12">
<Checkbox
:model-value="allSelected"
@update:model-value="(value: boolean) => (allSelected = value)"
/>
<Checkbox v-model:checked="allSelected" />
</TableHead>
<TableHead
v-for="field in visibleFields"
@@ -312,8 +263,8 @@ watch(
>
<TableCell v-if="selectable" @click.stop>
<Checkbox
:model-value="selectedRowIds.includes(normalizeId(row.id))"
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
:checked="selectedRows.has(row.id)"
@update:checked="toggleRowSelection(row.id)"
/>
</TableCell>
<TableCell v-for="field in visibleFields" :key="field.id">

View File

@@ -330,25 +330,9 @@ export const useViewState = <T extends { id?: 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)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { ref } from 'vue'
import AppSidebar from '@/components/AppSidebar.vue'
import BottomDrawer from '@/components/BottomDrawer.vue'
import {
@@ -15,9 +15,6 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
const route = useRoute()
const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs()
const drawerBounds = useState('bottomDrawerBounds', () => ({ left: 0, width: 0 }))
const insetRef = ref<any>(null)
let resizeObserver: ResizeObserver | null = null
const breadcrumbs = computed(() => {
// If custom breadcrumbs are set by the page, use those
@@ -33,47 +30,12 @@ const breadcrumbs = computed(() => {
isLast: index === paths.length - 1,
}))
})
const resolveInsetEl = (): HTMLElement | null => {
const maybeComponent = insetRef.value as any
if (!maybeComponent) return null
return maybeComponent.$el ? maybeComponent.$el as HTMLElement : (maybeComponent as HTMLElement)
}
const updateBounds = () => {
const el = resolveInsetEl()
if (!el || typeof el.getBoundingClientRect !== 'function') return
const rect = el.getBoundingClientRect()
drawerBounds.value = {
left: rect.left,
width: rect.width,
}
}
onMounted(() => {
updateBounds()
const el = resolveInsetEl()
if (el && 'ResizeObserver' in window) {
resizeObserver = new ResizeObserver(updateBounds)
resizeObserver.observe(el)
}
window.addEventListener('resize', updateBounds)
})
onBeforeUnmount(() => {
const el = resolveInsetEl()
if (resizeObserver && el) {
resizeObserver.unobserve(el)
}
resizeObserver = null
window.removeEventListener('resize', updateBounds)
})
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset ref="insetRef" class="relative flex flex-col">
<SidebarInset class="flex flex-col">
<header
class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md"
>
@@ -111,8 +73,7 @@ onBeforeUnmount(() => {
<slot />
</div>
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
<BottomDrawer :bounds="drawerBounds" />
</SidebarInset>
</SidebarProvider>
</template>

View File

@@ -6,14 +6,6 @@ import { useFields, useViewState } from '@/composables/useFieldViews'
import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const route = useRoute()
const router = useRouter()
@@ -156,14 +148,8 @@ const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?
const searchQuery = ref('')
const searchSummary = ref('')
const searchLoading = ref(false)
const deleteDialogOpen = ref(false)
const deleteSubmitting = ref(false)
const pendingDeleteRows = ref<any[]>([])
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
const deniedDeleteCount = computed(() => deleteSummary.value?.deniedIds.length ?? 0)
// Fetch object definition
const fetchObjectDefinition = async () => {
@@ -208,42 +194,16 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
}
const handleDelete = async (rows: any[]) => {
pendingDeleteRows.value = rows
deleteSummary.value = null
deleteDialogOpen.value = true
}
const resetDeleteDialog = () => {
deleteDialogOpen.value = false
deleteSubmitting.value = false
pendingDeleteRows.value = []
deleteSummary.value = null
}
const confirmDelete = async () => {
if (pendingDeleteRows.value.length === 0) {
resetDeleteDialog()
return
}
deleteSubmitting.value = true
try {
const ids = pendingDeleteRows.value.map(r => r.id)
const result = await deleteRecords(ids)
const deletedIds = result?.deletedIds ?? []
const deniedIds = result?.deniedIds ?? []
deleteSummary.value = { deletedIds, deniedIds }
if (deniedIds.length === 0) {
resetDeleteDialog()
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
try {
const ids = rows.map(r => r.id)
await deleteRecords(ids)
if (view.value !== 'list') {
await router.push(`/${objectApiName.value.toLowerCase()}/`)
}
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
}
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
} finally {
deleteSubmitting.value = false
}
}
@@ -462,46 +422,6 @@ onMounted(async () => {
@back="handleBack"
/>
</div>
<Dialog v-model:open="deleteDialogOpen">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Delete records</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div class="space-y-2 text-sm">
<p>
You are about to delete {{ pendingDeleteCount }} record<span v-if="pendingDeleteCount !== 1">s</span>.
</p>
<p v-if="deleteSummary" class="text-muted-foreground">
Deleted {{ deleteSummary.deletedIds.length }} record<span v-if="deleteSummary.deletedIds.length !== 1">s</span>.
</p>
<p v-if="deniedDeleteCount > 0" class="text-destructive">
{{ deniedDeleteCount }} record<span v-if="deniedDeleteCount !== 1">s</span> could not be deleted due to missing permissions.
</p>
<p v-if="!deleteSummary" class="text-muted-foreground">
Records you do not have permission to delete will be skipped.
</p>
</div>
<DialogFooter>
<Button variant="outline" @click="resetDeleteDialog" :disabled="deleteSubmitting">
{{ deleteSummary ? 'Close' : 'Cancel' }}
</Button>
<Button
v-if="!deleteSummary"
variant="destructive"
@click="confirmDelete"
:disabled="deleteSubmitting"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</NuxtLayout>
</template>