WIP - saving list views
This commit is contained in:
@@ -4,9 +4,12 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useFields, useViewState } from '@/composables/useFieldViews'
|
||||
import { usePageLayouts } from '@/composables/usePageLayouts'
|
||||
import { useSavedViews } from '@/composables/useSavedViews'
|
||||
import type { SavedView } from '@/composables/useSavedViews'
|
||||
import ListView from '@/components/views/ListView.vue'
|
||||
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||
import SavedViewPanel from '@/components/SavedViewPanel.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,12 +18,23 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { api } = useApi()
|
||||
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
||||
const { getDefaultPageLayout } = usePageLayouts()
|
||||
const {
|
||||
savedViews,
|
||||
fetchSavedViews,
|
||||
createSavedView,
|
||||
updateSavedView,
|
||||
deleteSavedView,
|
||||
suggestViewName,
|
||||
} = useSavedViews()
|
||||
|
||||
// Use breadcrumbs composable
|
||||
const { setBreadcrumbs } = useBreadcrumbs()
|
||||
@@ -177,6 +191,16 @@ const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
|
||||
const deniedDeleteCount = computed(() => deleteSummary.value?.deniedIds.length ?? 0)
|
||||
|
||||
// ── Saved views state ──────────────────────────────────────────────────────
|
||||
const activeViewId = ref<string | null>(null)
|
||||
const currentSearchPlan = ref<{
|
||||
strategy: string; filters: any[]; sort: any; explanation: string
|
||||
} | null>(null)
|
||||
const viewPanelOpen = ref(false)
|
||||
const saveViewDialogOpen = ref(false)
|
||||
const saveViewName = ref('')
|
||||
const savingView = ref(false)
|
||||
|
||||
// Fetch object definition
|
||||
const fetchObjectDefinition = async () => {
|
||||
try {
|
||||
@@ -323,6 +347,19 @@ const searchListRecords = async (
|
||||
records.value = options?.append ? [...records.value, ...data] : data
|
||||
totalCount.value = total
|
||||
searchSummary.value = response?.explanation || ''
|
||||
|
||||
// Capture the plan for the "Save view" feature (only when strategy is query)
|
||||
if (response?.strategy === 'query') {
|
||||
currentSearchPlan.value = {
|
||||
strategy: response.strategy,
|
||||
filters: response.filters || [],
|
||||
sort: response.sort || null,
|
||||
explanation: response.explanation || '',
|
||||
}
|
||||
} else {
|
||||
currentSearchPlan.value = null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to search records'
|
||||
@@ -383,6 +420,8 @@ const handleSearch = async (query: string) => {
|
||||
searchQuery.value = trimmed
|
||||
if (!trimmed) {
|
||||
searchSummary.value = ''
|
||||
currentSearchPlan.value = null
|
||||
activeViewId.value = null
|
||||
await initializeListRecords()
|
||||
return
|
||||
}
|
||||
@@ -499,6 +538,103 @@ const handleDiscardDrafts = () => {
|
||||
cellErrors.value = {}
|
||||
}
|
||||
|
||||
// ── Saved view handlers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a saved view: execute its stored filters directly without re-running AI.
|
||||
*/
|
||||
const handleApplyView = async (view: SavedView) => {
|
||||
activeViewId.value = view.id
|
||||
searchQuery.value = ''
|
||||
searchSummary.value = view.description || ''
|
||||
currentSearchPlan.value = {
|
||||
strategy: view.strategy,
|
||||
filters: view.filters,
|
||||
sort: view.sort,
|
||||
explanation: view.description || '',
|
||||
}
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const response = await api.post(
|
||||
`/runtime/objects/${objectApiName.value}/records/search`,
|
||||
{
|
||||
filters: view.filters,
|
||||
sort: view.sort,
|
||||
page: 1,
|
||||
pageSize: listPageSize.value,
|
||||
},
|
||||
)
|
||||
const data = response?.data ?? []
|
||||
records.value = data
|
||||
totalCount.value = response?.totalCount ?? data.length
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to apply saved view'
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "Save view" dialog, pre-filling the name via AI suggestion.
|
||||
*/
|
||||
const handleSaveView = async () => {
|
||||
if (!currentSearchPlan.value) return
|
||||
saveViewName.value = ''
|
||||
saveViewDialogOpen.value = true
|
||||
// Async-suggest a name while dialog is open
|
||||
const objectLabel = objectDefinition.value?.label || objectApiName.value
|
||||
const suggested = await suggestViewName(
|
||||
objectLabel,
|
||||
currentSearchPlan.value.filters,
|
||||
currentSearchPlan.value.explanation,
|
||||
)
|
||||
// Only auto-fill if user hasn't typed anything yet
|
||||
if (!saveViewName.value) {
|
||||
saveViewName.value = suggested
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the save: persist the view and close the dialog.
|
||||
*/
|
||||
const confirmSaveView = async () => {
|
||||
const name = saveViewName.value.trim()
|
||||
if (!name || !currentSearchPlan.value) return
|
||||
savingView.value = true
|
||||
try {
|
||||
await createSavedView({
|
||||
name,
|
||||
objectApiName: objectApiName.value,
|
||||
filters: currentSearchPlan.value.filters,
|
||||
sort: currentSearchPlan.value.sort,
|
||||
description: currentSearchPlan.value.explanation,
|
||||
})
|
||||
saveViewDialogOpen.value = false
|
||||
saveViewName.value = ''
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to save view'
|
||||
} finally {
|
||||
savingView.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenViewManager = () => {
|
||||
viewPanelOpen.value = true
|
||||
}
|
||||
|
||||
const handleUpdateView = async (id: string, payload: any) => {
|
||||
await updateSavedView(id, payload)
|
||||
}
|
||||
|
||||
const handleDeleteView = async (view: SavedView) => {
|
||||
await deleteSavedView(view.id)
|
||||
if (activeViewId.value === view.id) {
|
||||
activeViewId.value = null
|
||||
currentSearchPlan.value = null
|
||||
await initializeListRecords()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => records.value.map(record => normalizeRecordId(record.id)),
|
||||
(ids) => {
|
||||
@@ -547,6 +683,8 @@ onMounted(async () => {
|
||||
|
||||
if (view.value === 'list') {
|
||||
await initializeListRecords()
|
||||
// Load saved views for this object
|
||||
fetchSavedViews(objectApiName.value).catch(() => {})
|
||||
} else if (recordId.value && recordId.value !== 'new') {
|
||||
await fetchRecord(recordId.value)
|
||||
}
|
||||
@@ -598,6 +736,10 @@ onMounted(async () => {
|
||||
:draft-edits="draftEdits"
|
||||
:cell-errors="cellErrors"
|
||||
:saving-drafts="savingDrafts"
|
||||
:saved-views="savedViews"
|
||||
:active-view-id="activeViewId"
|
||||
:current-search-plan="currentSearchPlan"
|
||||
:saving-view="savingView"
|
||||
selectable
|
||||
@row-click="handleRowClick"
|
||||
@create="handleCreate"
|
||||
@@ -610,6 +752,9 @@ onMounted(async () => {
|
||||
@cell-edit="handleCellEdit"
|
||||
@save-drafts="handleSaveDrafts"
|
||||
@discard-drafts="handleDiscardDrafts"
|
||||
@apply-view="handleApplyView"
|
||||
@save-view="handleSaveView"
|
||||
@open-view-manager="handleOpenViewManager"
|
||||
/>
|
||||
|
||||
<!-- Detail View -->
|
||||
@@ -681,6 +826,46 @@ onMounted(async () => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Save view dialog -->
|
||||
<Dialog v-model:open="saveViewDialogOpen">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save view</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give this search a name so you can reuse it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-2 py-2">
|
||||
<Label for="view-name">View name</Label>
|
||||
<Input
|
||||
id="view-name"
|
||||
v-model="saveViewName"
|
||||
placeholder="e.g. Cocker Spaniels"
|
||||
@keyup.enter="confirmSaveView"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="saveViewDialogOpen = false" :disabled="savingView">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="confirmSaveView" :disabled="savingView || !saveViewName.trim()">
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Saved views slide-over panel -->
|
||||
<SavedViewPanel
|
||||
v-model:open="viewPanelOpen"
|
||||
:views="savedViews"
|
||||
:object-label="objectDefinition?.pluralLabel || objectDefinition?.label || objectApiName"
|
||||
:active-view-id="activeViewId"
|
||||
@apply-view="handleApplyView"
|
||||
@update-view="handleUpdateView"
|
||||
@delete-view="handleDeleteView"
|
||||
/>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user