Compare commits
3 Commits
d00e26ad27
...
f094fee192
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f094fee192 | ||
|
|
1b5ad4a131 | ||
|
|
414d73daef |
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user