Add pagination to NavigationMenuGlobalSearch with b-pagination component:
- Replace previous offset-based navigation with paginated navigation. - Adjust layout and styles for improved usability and flexibility. - Refactor result handling to integrate pagination events and model updates.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { BButton, BCheckbox, BField, BInput, BModal, BSelect, BSidebar, BTag } from 'buefy';
|
||||
import { BButton, BCheckbox, BField, BInput, BModal, BPagination, BSelect, BSidebar, BTag } from 'buefy';
|
||||
import SessionUser from '@/components/session/token/SessionUser.vue';
|
||||
|
||||
type SearchMethod = 'POST' | 'GET';
|
||||
@@ -131,8 +131,7 @@ const meta = computed(() => payload.value?.meta ?? null);
|
||||
const total = computed(() => meta.value?.total ?? 0);
|
||||
const rangeStart = computed(() => (visibleResults.value.length ? clampOffset(offset.value) + 1 : 0));
|
||||
const rangeEnd = computed(() => (visibleResults.value.length ? clampOffset(offset.value) + visibleResults.value.length : 0));
|
||||
const canPrev = computed(() => clampOffset(offset.value) > 0 && !loading.value);
|
||||
const canNext = computed(() => clampOffset(offset.value) + clampLimit(limit.value) < total.value && !loading.value);
|
||||
const currentPage = computed(() => Math.floor(clampOffset(offset.value) / clampLimit(limit.value)) + 1);
|
||||
|
||||
watch(grouped, (next) => {
|
||||
if (selectedGroup.value === 'all') return;
|
||||
@@ -235,6 +234,14 @@ const runSearch = async (nextOffset?: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
if (loading.value) return;
|
||||
const safePage = Math.max(1, Math.floor(Number.isNaN(Number(page)) ? 1 : Number(page)));
|
||||
const nextOffset = (safePage - 1) * clampLimit(limit.value);
|
||||
if (nextOffset === clampOffset(offset.value)) return;
|
||||
runSearch(nextOffset);
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
response.value = null;
|
||||
error.value = '';
|
||||
@@ -452,84 +459,100 @@ const asJson = (v: unknown) => {
|
||||
|
||||
<p v-if="error" class="help is-danger mb-2">{{ error }}</p>
|
||||
|
||||
<div v-if="payload" class="box p-3 search-result-box">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
|
||||
<p v-if="selectedGroup === 'all'" class="is-size-7 has-text-weight-semibold mb-0">Results {{ rangeStart }}-{{ rangeEnd }} of {{ total }}</p>
|
||||
<p v-else class="is-size-7 has-text-weight-semibold mb-0">{{ selectedGroupLabel }}: {{ visibleResults.length }} of {{ results.length }} loaded</p>
|
||||
<b-button type="is-text" size="is-small" @click="showMeta = !showMeta">{{ showMeta ? 'Hide meta' : 'Show meta' }}</b-button>
|
||||
</div>
|
||||
<div v-if="showMeta && meta" class="meta-box mb-3">
|
||||
<p class="is-size-7 mb-1"><strong>Query:</strong> {{ meta.query }}</p>
|
||||
<p class="is-size-7 mb-1"><strong>Limit:</strong> {{ meta.limit }} | <strong>Offset:</strong> {{ meta.offset }}</p>
|
||||
<p class="is-size-7 mb-1"><strong>Cache hit:</strong> {{ meta.cache.hit ? 'yes' : 'no' }}</p>
|
||||
<div class="is-size-7 mb-1">
|
||||
<strong>Allowed types:</strong>
|
||||
<span v-if="meta.allowed_types.length === 0">none</span>
|
||||
<span v-else>
|
||||
<b-tag v-for="t in meta.allowed_types" :key="`allow-${t}`" type="is-light" rounded class="mr-1 mb-1">{{ humanize(t) }}</b-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="meta.intent_parser" class="intent-meta is-size-7 mt-2">
|
||||
<p class="has-text-weight-semibold mb-1">Intent parser diagnostics</p>
|
||||
<p class="mb-1">
|
||||
<strong>Invoked:</strong> {{ meta.intent_parser.invoked ? 'yes' : 'no' }} |
|
||||
<strong>Source:</strong> {{ meta.intent_parser.source }} |
|
||||
<strong>Status:</strong> {{ meta.intent_parser.status }} |
|
||||
<strong>Confidence:</strong> {{ meta.intent_parser.confidence }}
|
||||
</p>
|
||||
<p class="mb-1" v-if="meta.intent_parser.expanded_terms.length"><strong>Expanded terms:</strong> {{ meta.intent_parser.expanded_terms.join(', ') }}</p>
|
||||
<p class="mb-1" v-if="meta.intent_parser.entity_hints.length"><strong>Entity hints:</strong> {{ meta.intent_parser.entity_hints.map(humanize).join(', ') }}</p>
|
||||
<p class="mb-0" v-if="meta.intent_parser.fallback_reason"><strong>Fallback reason:</strong> {{ meta.intent_parser.fallback_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2" v-if="showGrouped && grouped.length">
|
||||
<details>
|
||||
<summary class="is-size-7 has-text-weight-semibold">Grouped results (by entity type)</summary>
|
||||
<div class="mt-2">
|
||||
<div v-for="[groupKey, groupRows] in grouped" :key="`group-${groupKey}`" class="mb-2">
|
||||
<p class="is-size-7 has-text-weight-semibold mb-1">{{ humanize(groupKey) }} ({{ groupRows.length }})</p>
|
||||
<div class="is-flex is-flex-wrap-wrap">
|
||||
<b-tag v-for="row in groupRows" :key="`grp-${groupKey}-${row.entity_id}-${row.score}`" type="is-light" class="mr-1 mb-1">{{ row.title }}</b-tag>
|
||||
</div>
|
||||
<div class="search-content-area">
|
||||
<div v-if="payload" class="results-layout">
|
||||
<div class="box p-3 search-result-box">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
|
||||
<p v-if="selectedGroup === 'all'" class="is-size-7 has-text-weight-semibold mb-0">Results {{ rangeStart }}-{{ rangeEnd }} of {{ total }}</p>
|
||||
<p v-else class="is-size-7 has-text-weight-semibold mb-0">{{ selectedGroupLabel }}: {{ visibleResults.length }} of {{ results.length }} loaded</p>
|
||||
<b-button type="is-text" size="is-small" @click="showMeta = !showMeta">{{ showMeta ? 'Hide meta' : 'Show meta' }}</b-button>
|
||||
</div>
|
||||
<div v-if="showMeta && meta" class="meta-box mb-3">
|
||||
<p class="is-size-7 mb-1"><strong>Query:</strong> {{ meta.query }}</p>
|
||||
<p class="is-size-7 mb-1"><strong>Limit:</strong> {{ meta.limit }} | <strong>Offset:</strong> {{ meta.offset }}</p>
|
||||
<p class="is-size-7 mb-1"><strong>Cache hit:</strong> {{ meta.cache.hit ? 'yes' : 'no' }}</p>
|
||||
<div class="is-size-7 mb-1">
|
||||
<strong>Allowed types:</strong>
|
||||
<span v-if="meta.allowed_types.length === 0">none</span>
|
||||
<span v-else>
|
||||
<b-tag v-for="t in meta.allowed_types" :key="`allow-${t}`" type="is-light" rounded class="mr-1 mb-1">{{ humanize(t) }}</b-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="meta.intent_parser" class="intent-meta is-size-7 mt-2">
|
||||
<p class="has-text-weight-semibold mb-1">Intent parser diagnostics</p>
|
||||
<p class="mb-1">
|
||||
<strong>Invoked:</strong> {{ meta.intent_parser.invoked ? 'yes' : 'no' }} |
|
||||
<strong>Source:</strong> {{ meta.intent_parser.source }} |
|
||||
<strong>Status:</strong> {{ meta.intent_parser.status }} |
|
||||
<strong>Confidence:</strong> {{ meta.intent_parser.confidence }}
|
||||
</p>
|
||||
<p class="mb-1" v-if="meta.intent_parser.expanded_terms.length"><strong>Expanded terms:</strong> {{ meta.intent_parser.expanded_terms.join(', ') }}</p>
|
||||
<p class="mb-1" v-if="meta.intent_parser.entity_hints.length"><strong>Entity hints:</strong> {{ meta.intent_parser.entity_hints.map(humanize).join(', ') }}</p>
|
||||
<p class="mb-0" v-if="meta.intent_parser.fallback_reason"><strong>Fallback reason:</strong> {{ meta.intent_parser.fallback_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<template v-if="visibleResults.length">
|
||||
<div v-for="row in visibleResults" :key="`${row.entity_type}-${row.entity_id}-${row.score}`" class="result-item mb-2">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-start mb-1">
|
||||
<p class="is-size-7 has-text-weight-semibold mb-0">{{ row.title }}</p>
|
||||
<b-tag type="is-info" size="is-small" rounded>{{ humanize(row.entity_type) }}</b-tag>
|
||||
</div>
|
||||
<p v-if="row.description" class="is-size-7 has-text-grey-dark mb-1">{{ row.description }}</p>
|
||||
<p class="is-size-7 mb-1">
|
||||
<strong>ID:</strong> {{ row.entity_id }} | <strong>Score:</strong> {{ row.score }}
|
||||
<template v-if="row.customer_number !== null && typeof row.customer_number !== 'undefined'"> | <strong>Customer #:</strong> {{ row.customer_number }}</template>
|
||||
<template v-if="row.department_id !== null && typeof row.department_id !== 'undefined'"> | <strong>Department #:</strong> {{ row.department_id }}</template>
|
||||
</p>
|
||||
<p v-if="row.association_reason" class="is-size-7 has-text-info mb-1"><strong>Association:</strong> {{ row.association_reason }}</p>
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between">
|
||||
<details class="is-size-7 payload-details">
|
||||
<summary>Payload</summary>
|
||||
<pre class="payload-pre">{{ asJson(row.payload) }}</pre>
|
||||
<div class="mb-2" v-if="showGrouped && grouped.length">
|
||||
<details>
|
||||
<summary class="is-size-7 has-text-weight-semibold">Grouped results (by entity type)</summary>
|
||||
<div class="mt-2">
|
||||
<div v-for="[groupKey, groupRows] in grouped" :key="`group-${groupKey}`" class="mb-2">
|
||||
<p class="is-size-7 has-text-weight-semibold mb-1">{{ humanize(groupKey) }} ({{ groupRows.length }})</p>
|
||||
<div class="is-flex is-flex-wrap-wrap">
|
||||
<b-tag v-for="row in groupRows" :key="`grp-${groupKey}-${row.entity_id}-${row.score}`" type="is-light" class="mr-1 mb-1">{{ row.title }}</b-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<b-button size="is-small" type="is-light" icon-left="arrow-right" icon-pack="fas" :disabled="!resolvePath(row)" @click="openResult(row)">Open</b-button>
|
||||
</div>
|
||||
|
||||
<template v-if="visibleResults.length">
|
||||
<div v-for="row in visibleResults" :key="`${row.entity_type}-${row.entity_id}-${row.score}`" class="result-item mb-2">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-start mb-1">
|
||||
<p class="is-size-7 has-text-weight-semibold mb-0">{{ row.title }}</p>
|
||||
<b-tag type="is-info" size="is-small" rounded>{{ humanize(row.entity_type) }}</b-tag>
|
||||
</div>
|
||||
<p v-if="row.description" class="is-size-7 has-text-grey-dark mb-1">{{ row.description }}</p>
|
||||
<p class="is-size-7 mb-1">
|
||||
<strong>ID:</strong> {{ row.entity_id }} | <strong>Score:</strong> {{ row.score }}
|
||||
<template v-if="row.customer_number !== null && typeof row.customer_number !== 'undefined'"> | <strong>Customer #:</strong> {{ row.customer_number }}</template>
|
||||
<template v-if="row.department_id !== null && typeof row.department_id !== 'undefined'"> | <strong>Department #:</strong> {{ row.department_id }}</template>
|
||||
</p>
|
||||
<p v-if="row.association_reason" class="is-size-7 has-text-info mb-1"><strong>Association:</strong> {{ row.association_reason }}</p>
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between">
|
||||
<details class="is-size-7 payload-details">
|
||||
<summary>Payload</summary>
|
||||
<pre class="payload-pre">{{ asJson(row.payload) }}</pre>
|
||||
</details>
|
||||
<b-button size="is-small" type="is-light" icon-left="arrow-right" icon-pack="fas" :disabled="!resolvePath(row)" @click="openResult(row)">Open</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="is-size-7 has-text-grey mb-2">No {{ selectedGroupLabel.toLowerCase() }} results for this search.</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="is-size-7 has-text-grey mb-2">No {{ selectedGroupLabel.toLowerCase() }} results for this search.</p>
|
||||
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center mt-3">
|
||||
<b-button size="is-small" type="is-light" :disabled="!canPrev" @click="runSearch(clampOffset(offset) - clampLimit(limit))">Previous</b-button>
|
||||
<span class="is-size-7 has-text-grey">Offset {{ clampOffset(offset) }} / Limit {{ clampLimit(limit) }}</span>
|
||||
<b-button size="is-small" type="is-light" :disabled="!canNext" @click="runSearch(clampOffset(offset) + clampLimit(limit))">Next</b-button>
|
||||
<div class="box p-3 search-pagination-box">
|
||||
<b-pagination
|
||||
:total="total"
|
||||
:per-page="clampLimit(limit)"
|
||||
:model-value="currentPage"
|
||||
size="is-medium"
|
||||
order="is-centered"
|
||||
rounded
|
||||
icon-pack="fas"
|
||||
icon-prev="chevron-left"
|
||||
icon-next="chevron-right"
|
||||
:range-before="2"
|
||||
:range-after="2"
|
||||
@change="onPageChange"
|
||||
@update:model-value="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="box has-text-centered p-5">
|
||||
<p class="has-text-grey is-size-7 mb-0">Run a search to load results.</p>
|
||||
<div v-else class="box has-text-centered p-5 search-empty-box">
|
||||
<p class="has-text-grey is-size-7 mb-0">Run a search to load results.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -632,7 +655,8 @@ const asJson = (v: unknown) => {
|
||||
.system-search-launcher { width: 100%; }
|
||||
.search-modal-shell {
|
||||
display: flex;
|
||||
min-height: 78vh;
|
||||
height: 82vh;
|
||||
min-height: 82vh;
|
||||
max-height: 82vh;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
@@ -688,10 +712,43 @@ const asJson = (v: unknown) => {
|
||||
.search-modal-main-pane {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.search-result-box { max-height: 52vh; overflow: auto; }
|
||||
.search-content-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.results-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.search-result-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.search-pagination-box {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.search-empty-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.meta-box { background: #f8f9fa; border-radius: 6px; padding: 10px; }
|
||||
.intent-meta { border-left: 2px solid #3273dc; padding-left: 8px; }
|
||||
.result-item { border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 8px; padding: 8px; }
|
||||
|
||||
Reference in New Issue
Block a user