Lists and preferences
Use useListService for a list backed by a paged endpoint. The page owns controls, columns, selection and CRUD; the composable coordinates query state, cancellation and saved preferences.
Prepare the backend and page
Follow the backend examples to expose /api/app/documentation-catalog. This teaching endpoint supports filter, category, minPrice, sorting, skipCount and maxResultCount, and returns { items, totalCount }. Sign in with a user granted AbpIdentity.Users.
Create src/pages/CataloguePage.vue with the following code. Its endpoint is the supplied sample, not an assumed part of every ABP application. Keep the Core and Basic Theme providers from the generated application; its layout already renders confirmation and notification hosts.
<template>
<AbpPage title="BookStore::Books">
<form class="row g-3 mb-3" @submit.prevent="list.get()">
<AbpFormField v-slot="{ id }" class="col-md-5" label="Search">
<AbpInput :id="id" v-model="list.filter.value" placeholder="Book name" />
</AbpFormField>
<AbpFormField v-slot="{ id }" class="col-md-3" label="Category">
<AbpSelect
:id="id"
v-model="category"
:options="categories"
placeholder="All categories"
clearable
/>
</AbpFormField>
<AbpFormField v-slot="{ id }" class="col-md-2" label="Minimum price">
<AbpInput :id="id" v-model="minPrice" type="number" :min="0" :max="1000" />
</AbpFormField>
<div class="col-md-2 d-flex align-items-end">
<AbpButton variant="secondary" @click="resetFilters">Reset filters</AbpButton>
</div>
</form>
<div class="d-flex flex-wrap gap-2 mb-3">
<AbpButton @click="createBook">New book</AbpButton>
<AbpButton variant="secondary" :disabled="loading" @click="list.getWithoutPageReset()"
>Refresh current page</AbpButton
>
<span role="status">Selected: {{ selected.length }}</span>
</div>
<p v-if="error" role="alert">
The query failed. Previous rows are kept until a successful retry.
<AbpButton
size="sm"
variant="secondary"
:disabled="loading"
@click="list.getWithoutPageReset()"
>Retry</AbpButton
>
</p>
<AbpDataTable
v-model:sort-key="list.sortKey.value"
v-model:sort-order="list.sortOrder.value"
v-model:selected="selected"
:columns="columns"
:data="items"
record-key="id"
selectable
:loading="loading"
caption="Filtered book catalogue"
>
<template #cell-price="{ value }">{{ Number(value).toFixed(2) }}</template>
<template #cell-actions="{ row }">
<AbpGridActions :record="row" :actions="rowActions" :disabled="saving || deleting" />
</template>
<template #empty>No books match these filters. Create one or reset the filters.</template>
</AbpDataTable>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mt-3">
<span>{{ localization.t('AbpUi::PagerInfo{0}{1}{2}', first, last, totalCount) }}</span>
<AbpPagination
v-model:page="list.page.value"
v-model:page-size="list.maxResultCount.value"
:total="totalCount"
show-size-selector
/>
</div>
<AbpModal v-model:visible="visible" title="Book" :dirty="form.dirty" :busy="saving">
<form id="book-editor" @submit.prevent="save">
<AbpFormField v-slot="{ id }" label="Name" :errors="fieldErrors('name')" required>
<AbpInput :id="id" v-model="form.controls.name.value" :disabled="saving" />
</AbpFormField>
<AbpFormField v-slot="{ id }" label="Category" :errors="fieldErrors('category')" required>
<AbpSelect
:id="id"
v-model="form.controls.category.value"
:options="categories"
:disabled="saving"
/>
</AbpFormField>
<AbpFormField v-slot="{ id }" label="Price" :errors="fieldErrors('price')" required>
<AbpInput
:id="id"
v-model="form.controls.price.value"
type="number"
:min="0"
:max="1000"
:disabled="saving"
/>
</AbpFormField>
<p v-for="message in form.unmatchedServerErrors" :key="message" role="alert">
{{ message }}
</p>
</form>
<template #footer="{ close }">
<AbpButton variant="secondary" :disabled="saving" @click="close">Cancel</AbpButton>
<AbpButton type="submit" form="book-editor" :loading="saving">Save</AbpButton>
</template>
</AbpModal>
</AbpPage>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import {
AbpDataTable,
AbpGridActions,
AbpPage,
type AbpTableColumn,
type RowAction,
} from '@lsw-abpvue/components';
import {
inject,
RestService,
useListService,
useLocalization,
type PagedResultDto,
} from '@lsw-abpvue/core';
import {
AbpButton,
AbpFormField,
AbpInput,
AbpModal,
AbpPagination,
AbpSelect,
ConfirmationStatus,
useAbpForm,
useConfirmation,
useServerValidation,
useValidationMessages,
Validators,
} from '@lsw-abpvue/theme-shared';
interface Book {
id: string;
name: string;
category: string;
price: number;
}
type BookInput = { name: string; category: string; price: number | null };
const endpoint = '/api/app/documentation-catalog';
const rest = inject(RestService);
const localization = useLocalization();
const confirmation = useConfirmation();
const messages = useValidationMessages();
const list = useListService({
persistKey: 'Documentation.Catalogue',
sortKey: 'name',
sortOrder: 'asc',
});
const category = ref<string | null>(null);
const minPrice = ref<number | null>(null);
const selected = ref<string[]>([]);
const categories = [
{ value: 'fiction', label: 'Fiction' },
{ value: 'reference', label: 'Reference' },
];
watch([list.sortKey, list.sortOrder, list.maxResultCount], () => {
list.page.value = 0;
});
watch([category, minPrice], () => list.get());
watch([list.filter, category, minPrice], () => {
selected.value = [];
});
const { items, totalCount, error } = list.hookToQuery((query, signal) =>
rest.request<never, PagedResultDto<Book>>(
{
method: 'GET',
url: endpoint,
params: {
...query,
category: category.value ?? undefined,
minPrice: minPrice.value ?? undefined,
},
},
{ signal },
),
);
const loading = computed(() => list.requestStatus.value === 'loading');
const columns: AbpTableColumn<Book>[] = [
{ id: 'actions', header: 'Actions' },
{ id: 'name', header: 'Name', sortable: true },
{ id: 'category', header: 'Category' },
{ id: 'price', header: 'Price', sortable: true },
];
const first = computed(() =>
totalCount.value ? list.page.value * list.maxResultCount.value + 1 : 0,
);
const last = computed(() =>
Math.min((list.page.value + 1) * list.maxResultCount.value, totalCount.value),
);
function resetFilters(): void {
list.filter.value = '';
category.value = null;
minPrice.value = null;
list.get();
}
const form = useAbpForm<BookInput>({
name: { value: '', validators: [Validators.required(), Validators.maxLength(128)] },
category: { value: 'fiction', validators: [Validators.required()] },
price: { value: 0, validators: [Validators.required(), Validators.range(0, 1000)] },
});
useServerValidation(form);
const fieldErrors = (key: keyof BookInput) =>
form.controls[key].touched ? messages(form.controls[key].errors) : [];
const visible = ref(false);
const editingId = ref<string | null>(null);
const saving = ref(false);
const deleting = ref(false);
const loadingDetail = ref(false);
const operation = new AbortController();
onBeforeUnmount(() => operation.abort());
const rowActions = computed<RowAction<Book>[]>(() => [
{ text: 'Edit', action: editBook, disabled: loadingDetail.value },
{ text: 'Delete', action: deleteBook, btnClass: 'text-danger' },
]);
function createBook(): void {
editingId.value = null;
form.reset({ name: '', category: 'fiction', price: 0 });
visible.value = true;
}
async function editBook(book: Book): Promise<void> {
if (loadingDetail.value || saving.value) return;
loadingDetail.value = true;
try {
const detail = await rest.request<never, Book>(
{ method: 'GET', url: `${endpoint}/${book.id}` },
{ signal: operation.signal },
);
if (operation.signal.aborted) return;
editingId.value = detail.id;
form.reset(detail);
visible.value = true;
} catch {
/* Framework handlers report failure without opening an incomplete editor. */
} finally {
loadingDetail.value = false;
}
}
async function save(): Promise<void> {
if (saving.value || !form.validate()) return;
saving.value = true;
const id = editingId.value;
try {
await rest.request<BookInput, Book>(
{ method: id ? 'PUT' : 'POST', url: id ? `${endpoint}/${id}` : endpoint, body: form.value },
{ signal: operation.signal },
);
if (operation.signal.aborted) return;
visible.value = false;
if (id) list.getWithoutPageReset();
else list.get();
} catch {
/* Keep the draft and server validation errors for correction. */
} finally {
saving.value = false;
}
}
async function deleteBook(book: Book): Promise<void> {
if (deleting.value) return;
deleting.value = true;
try {
if (
(await confirmation.warn(
{ key: 'BookStore::DeleteBook', defaultValue: `Delete ${book.name}?` },
'AbpUi::AreYouSure',
)) !== ConfirmationStatus.confirm
)
return;
await rest.request<never, void>(
{ method: 'DELETE', url: `${endpoint}/${book.id}` },
{ signal: operation.signal },
);
if (operation.signal.aborted) return;
selected.value = selected.value.filter(id => id !== book.id);
const lastPage = Math.max(0, Math.ceil((totalCount.value - 1) / list.maxResultCount.value) - 1);
if (list.page.value > lastPage) list.page.value = lastPage;
else list.getWithoutPageReset();
} catch {
/* A failed deletion keeps the rows and selection. */
} finally {
deleting.value = false;
}
}
</script>Register /catalogue as shown in the backend tutorial. Add BookStore::Books to your localization resource. The sample's other labels are English literals to keep the query flow visible; localize them with computed labels in your application.
Search, combine and reset filters
Typing into Search changes list.filter and starts a debounced request. Category and Minimum price are additional refs: the fetcher adds them to the request, and their watcher calls get() to reset the page. All three predicates apply on the server before counting and paging. Filtering only the downloaded page would produce incorrect totals.
Try creating Atlas with category Reference and price 20, and Novel with category Fiction and price 5. Search for Atlas, choose Reference and set Minimum price to 10: only Atlas should appear. Reset filters clears all controls and returns to page zero. Clearing the minimum means no price predicate; zero is an actual value.
hookToQuery starts the initial request. Do not also fetch from onMounted merely to initialize the same list. Vue batches synchronous state changes; the query watcher coordinates the final state. The fetcher receives an AbortSignal, which is forwarded to RestService.
Sort and page on the server
The table emits sorting changes. It does not reorder server rows. The example watches sortKey, sortOrder and page size before registering the query, and resets the page to zero. This ensures a new sort is not requested with the old page offset.
Pages are zero based: page 1 with size 10 sends skipCount: 10. Bind the pager to the server's totalCount, not items.length. Only mark a column sortable when the endpoint accepts its id. The sample allows name and price and uses an id tie-breaker for stable paging.
The UI's third sort click clears the sort direction. With an existing key and no direction, the query contains the field name alone; the sample backend interprets it as ascending. Define your own backend's default consistently rather than assuming the control sorts records.
Refresh, retry and empty results
get() returns to page zero; getWithoutPageReset() repeats the current query. Refresh and Retry use the latter. A failed query retains previous rows and exposes the returned error; successful empty results show the empty slot instead.
Stop the sample backend and choose Refresh to see the failed-query state. Restart it and Retry. The generic HTTP handler may also report the failure; this page's message explains the state of the list rather than displaying the same exception twice. Do not represent an error as an empty successful response.
Create, edit and delete
New book resets an owned form. Edit reads full detail before opening it. Validation runs before a save, a busy guard prevents repeated submissions, and a failed save leaves the draft and field errors available. Cancel calls the modal's guarded footer close.
After creating, get() returns to the first page. After updating, getWithoutPageReset() keeps the current page. Current filters still apply: a successfully created or edited book may not match them.
Delete asks for confirmation, waits for the server and only then removes its id from selection. If the new total no longer contains the current page, the page moves to the last valid index and reloads. A failed deletion retains both rows and selection. Try size 5 with six matching books, move to page 2 and delete its last row: the list returns to page 1.
Selection and saved preferences
record-key="id" gives stable identity across sorting. The table stores selected ids separately from query state; this sample clears them when filters change and retains selection across pages. Only visible page ids are changed by the header checkbox. A bulk endpoint must authorize every id again.
persistKey: 'Documentation.Catalogue' saves page size and sorting per user. It does not save filter values, page number or selection. Reload the page after changing size and sort to check restoration; logout clears only the current user's preferences. Use a new stable key or migrate saved values when the page schema changes. Never use a translated label as the key.
AbpExtensibleTable can share a list key to save hidden columns. An ordinary application page uses its own columns and does not need an extension container.
Diagnose the query
Inspect the actual URL and response when a page looks wrong. Verify that all filters are accepted, sorting happens before skip/take, and totalCount describes the filtered set. Check that the page has only one query registration and that neither the fetcher nor its computed parameters call get() recursively.
See DataTable, pagination, forms and request cancellation.