3 Commits

Author SHA1 Message Date
Francisco Gaona
f094fee192 WIP - display drawer shadow when open 2026-01-13 23:06:27 +01:00
Francisco Gaona
1b5ad4a131 WIP - bottom drawer width fix 2026-01-13 19:22:58 +01:00
Francisco Gaona
414d73daef WIP - update records fixes 2026-01-13 19:22:41 +01:00
6 changed files with 122 additions and 29 deletions

View File

@@ -1,7 +1,38 @@
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection'; import { Model, ModelOptions, QueryContext } from 'objection';
export class BaseModel extends Model { export class BaseModel extends Model {
static columnNameMappers = snakeCaseMappers(); /**
* 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;
},
};
id: string; id: string;
createdAt: Date; createdAt: Date;

View File

@@ -179,7 +179,8 @@ export class DynamicModelFactory {
* Convert a field definition to JSON schema property * Convert a field definition to JSON schema property
*/ */
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> { private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
switch (field.type.toUpperCase()) { const baseSchema = () => {
switch (field.type.toUpperCase()) {
case 'TEXT': case 'TEXT':
case 'STRING': case 'STRING':
case 'EMAIL': case 'EMAIL':
@@ -187,45 +188,57 @@ export class DynamicModelFactory {
case 'PHONE': case 'PHONE':
case 'PICKLIST': case 'PICKLIST':
case 'MULTI_PICKLIST': case 'MULTI_PICKLIST':
return { return {
type: 'string', type: 'string',
...(field.isUnique && { uniqueItems: true }), ...(field.isUnique && { uniqueItems: true }),
}; };
case 'LONG_TEXT': case 'LONG_TEXT':
return { type: 'string' }; return { type: 'string' };
case 'NUMBER': case 'NUMBER':
case 'DECIMAL': case 'DECIMAL':
case 'CURRENCY': case 'CURRENCY':
case 'PERCENT': case 'PERCENT':
return { return {
type: 'number', type: 'number',
...(field.isUnique && { uniqueItems: true }), ...(field.isUnique && { uniqueItems: true }),
}; };
case 'INTEGER': case 'INTEGER':
return { return {
type: 'integer', type: 'integer',
...(field.isUnique && { uniqueItems: true }), ...(field.isUnique && { uniqueItems: true }),
}; };
case 'BOOLEAN': case 'BOOLEAN':
return { type: 'boolean', default: false }; return { type: 'boolean', default: false };
case 'DATE': case 'DATE':
return { type: 'string', format: 'date' }; return { type: 'string', format: 'date' };
case 'DATE_TIME': case 'DATE_TIME':
return { type: 'string', format: 'date-time' }; return { type: 'string', format: 'date-time' };
case 'LOOKUP': case 'LOOKUP':
case 'BELONGS_TO': case 'BELONGS_TO':
return { type: 'string' }; return { type: 'string' };
default: default:
return { type: 'string' }; 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 schema;
} }
/** /**

View File

@@ -1179,7 +1179,8 @@ export class ObjectService {
objectApiName, objectApiName,
editableData, editableData,
); );
await boundModel.query().where({ id: recordId }).update(normalizedEditableData); // Use patch to avoid validating or overwriting fields that aren't present in the edit view
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
const record = await boundModel.query().where({ id: recordId }).first(); const record = await boundModel.query().where({ id: recordId }).first();
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record); await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
return record; return record;

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import AppSidebar from '@/components/AppSidebar.vue' import AppSidebar from '@/components/AppSidebar.vue'
import BottomDrawer from '@/components/BottomDrawer.vue' import BottomDrawer from '@/components/BottomDrawer.vue'
import { import {
@@ -15,6 +15,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
const route = useRoute() const route = useRoute()
const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs() 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(() => { const breadcrumbs = computed(() => {
// If custom breadcrumbs are set by the page, use those // If custom breadcrumbs are set by the page, use those
@@ -30,12 +33,47 @@ const breadcrumbs = computed(() => {
isLast: index === paths.length - 1, 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> </script>
<template> <template>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset class="flex flex-col"> <SidebarInset ref="insetRef" class="relative flex flex-col">
<header <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" 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"
> >
@@ -73,7 +111,8 @@ const breadcrumbs = computed(() => {
<slot /> <slot />
</div> </div>
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
<BottomDrawer :bounds="drawerBounds" />
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
</template> </template>