Skip to content

Commit 2d5ea62

Browse files
authored
Merge pull request #638 from devforth/add-next-button
Add next button
2 parents 39de982 + dc24d5a commit 2d5ea62

8 files changed

Lines changed: 154 additions & 6 deletions

File tree

adminforth/documentation/docs/tutorial/03-Customization/13-standardPagesTuning.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,26 @@ And `edit` action will be available as quick action:
542542
543543

544544

545+
## Show
546+
547+
### Next record button
548+
549+
By default, when a user opens a record from the list view, a **Next** button appears on the show page. It allows navigating through records one by one, respecting the current filters and sorting applied in the list. When the user reaches the last record on the current page, AdminForth automatically fetches the next page and continues navigation seamlessly.
550+
551+
To disable the Next button for a resource, set `showNextButton` to `false`:
552+
553+
```typescript title="./resources/apartments.ts"
554+
export default {
555+
resourceId: 'aparts',
556+
options: {
557+
//diff-add
558+
showNextButton: false,
559+
}
560+
}
561+
```
562+
563+
> ☝️ The Next button is only shown when the user navigates to the show page from the list view. Opening a record directly via URL will not display the button.
564+
545565
## Creating
546566

547567
### Fill with default values

adminforth/modules/restApi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ const getResourceDataResponseSchema: AnySchemaObject = createErrorOrSuccessSchem
318318
items: genericObjectSchema,
319319
},
320320
total: { type: 'number' },
321+
recordIds: { type: 'array', items: {} },
321322
options: genericObjectSchema,
322323
},
323324
additionalProperties: true,
@@ -1599,6 +1600,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
15991600
}
16001601
}
16011602

1603+
if (source === 'list') {
1604+
const pkField = resource.columns.find((col) => col.primaryKey).name;
1605+
(data as any).recordIds = data.data.map((item) => item[pkField]);
1606+
}
1607+
16021608
return data;
16031609
},
16041610
});

adminforth/spa/src/components/ResourceListTable.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,8 @@ const props = withDefaults(defineProps<{
422422
bufferSize?: number,
423423
customActionIconsThreeDotsMenuItems?: AdminForthComponentDeclaration[]
424424
tableRowReplaceInjection?: AdminForthComponentDeclaration,
425-
isVirtualScrollEnabled: boolean
425+
isVirtualScrollEnabled: boolean,
426+
filters?: any[]
426427
}>(), {
427428
sort: () => []
428429
});
@@ -615,6 +616,12 @@ async function onClick(e: any, row: any) {
615616
// user asked to nothing on click
616617
return;
617618
}
619+
coreStore.listRecordIds = props.rows?.map(r => r._primaryKeyValue) ?? [];
620+
coreStore.listResourceId = props.resource?.resourceId ?? null;
621+
coreStore.listSort = props.sort;
622+
coreStore.listPage = page.value;
623+
coreStore.listPageSize = props.pageSize;
624+
coreStore.listFilters = props.filters ?? [];
618625
if (e.ctrlKey || e.metaKey || row._clickUrl?.includes('target=_blank')) {
619626
620627
if (row._clickUrl) {

adminforth/spa/src/stores/core.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export const useCoreStore = defineStore('core', () => {
2222
const isResourceFetching = ref(false);
2323
const isInternetError = ref(false);
2424
const screenWidth = ref(window.innerWidth);
25+
const listRecordIds: Ref<any[]> = ref([]);
26+
const listResourceId: Ref<string | null> = ref(null);
27+
const listFilters: Ref<any[]> = ref([]);
28+
const listSort: Ref<any[]> = ref([]);
29+
const listPage: Ref<number> = ref(0);
30+
const listPageSize: Ref<number> = ref(0);
2531

2632
onMounted(() => {
2733
window.addEventListener('resize', updateWidth);
@@ -290,5 +296,11 @@ export const useCoreStore = defineStore('core', () => {
290296
isIos,
291297
isInternetError,
292298
isMobile,
299+
listRecordIds,
300+
listResourceId,
301+
listFilters,
302+
listSort,
303+
listPage,
304+
listPageSize,
293305
}
294306
})

adminforth/spa/src/utils/listUtils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ export async function getList(resource: AdminForthResourceFrontend, isPageLoaded
4747
return row;
4848
});
4949
totalRows = data.total;
50-
50+
const recordIds = data.recordIds || [];
51+
5152
// if checkboxes have items which are not in current data, remove them
5253
checkboxes.value = checkboxes.value.filter((pk: any) => rows.some((r: any) => r._primaryKeyValue === pk));
5354
await nextTick();
54-
return { rows, totalRows };
55+
return { rows, totalRows, recordIds };
5556
}
5657

5758

adminforth/spa/src/views/ListView.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@
180180
@update:records="getListInner"
181181
@update:pageSize="(newSize) => { pageSize = newSize; page = 1; }"
182182
:sort="sort"
183+
:filters="filtersStore.filters"
183184
:pageSizeOptions="Array.isArray(coreStore.resource?.options?.listPageSizeOptions) ? coreStore.resource?.options?.listPageSizeOptions : []"
184185
:pageSize="pageSize"
185186
:totalRows="totalRows"

adminforth/spa/src/views/ShowView.vue

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@
1010
:adminUser="coreStore.adminUser"
1111
/>
1212
<BreadcrumbsWithButtons>
13+
<RouterLink
14+
v-if="hasListNavContext && coreStore.resourceOptions?.showNextButton !== false"
15+
:to="nextRecordRoute ?? $route"
16+
@click="handleNextClick()"
17+
:class="!nextRecordRoute ? 'opacity-50 pointer-events-none cursor-not-allowed' : ''"
18+
class="af-button-shadow h-[34px] inline-flex items-center gap-1 px-3 py-2 text-sm font-medium transition-all border outline-none bg-lightListViewButtonBackground text-lightListViewButtonText border-lightListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:border-darkListViewButtonBorder hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover rounded-default dark:hover:text-darkListViewButtonTextHover dark:hover:bg-darkListViewButtonBackgroundHover"
19+
>
20+
<Spinner v-if="isFetchingNextPage" class="w-4 h-4 text-gray-200 dark:text-gray-500 fill-gray-500 dark:fill-gray-300" />
21+
{{ $t('Next') }}
22+
</RouterLink>
23+
1324
<template v-if="coreStore.resource?.options?.actions">
1425

1526
<div class="flex gap-1" v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)" :key="action.id">
@@ -60,10 +71,11 @@
6071
{{ $t('Delete') }}
6172
</button>
6273

63-
<ThreeDotsMenu
74+
<ThreeDotsMenu
6475
:threeDotsDropdownItems="(coreStore.resourceOptions?.pageInjections?.show?.threeDotsDropdownItems as [])"
6576
:customActions="customActions"
6677
></ThreeDotsMenu>
78+
6779
</BreadcrumbsWithButtons>
6880

6981
<component
@@ -181,7 +193,7 @@ import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
181193
import { useCoreStore } from '@/stores/core';
182194
import { getCustomComponent, checkAcessByAllowedActions, initThreeDotsDropdown, formatComponent, executeCustomAction } from '@/utils';
183195
import { IconPenSolid, IconTrashBinSolid, IconPlusOutline } from '@iconify-prerendered/vue-flowbite';
184-
import { onMounted, onUnmounted, ref, computed } from 'vue';
196+
import { onMounted, onUnmounted, ref, computed, watch } from 'vue';
185197
import { useRoute,useRouter } from 'vue-router';
186198
import {callAdminForthApi} from '@/utils';
187199
import { showSuccesTost, showErrorTost } from '@/composables/useFrontendApi';
@@ -298,6 +310,87 @@ const allColumns = computed(() => {
298310
return coreStore.resource?.columns?.filter(col => col.showIn?.show);
299311
});
300312
313+
const isFetchingNextPage = ref(false);
314+
const nextPageRecords = ref<string[]>([]);
315+
316+
const hasListNavContext = computed(() =>
317+
coreStore.listResourceId === route.params.resourceId && coreStore.listRecordIds.length > 0
318+
);
319+
320+
const currentRecordIndex = computed(() => {
321+
const pk = String(route.params.primaryKey);
322+
return coreStore.listRecordIds.findIndex((id: any) => String(id) === pk);
323+
});
324+
325+
const isLastOnCurrentPage = computed(() =>
326+
currentRecordIndex.value === coreStore.listRecordIds.length - 1
327+
);
328+
329+
const hasNextRecord = computed(() => {
330+
if (currentRecordIndex.value < 0) return false;
331+
if (!isLastOnCurrentPage.value) return true;
332+
return coreStore.listRecordIds.length === coreStore.listPageSize;
333+
});
334+
335+
const nextRecordRoute = computed(() => {
336+
if (!hasNextRecord.value) return null;
337+
if (!isLastOnCurrentPage.value) {
338+
return {
339+
name: 'resource-show',
340+
params: { resourceId: route.params.resourceId, primaryKey: coreStore.listRecordIds[currentRecordIndex.value + 1] },
341+
};
342+
}
343+
if (nextPageRecords.value.length > 0) {
344+
return {
345+
name: 'resource-show',
346+
params: { resourceId: route.params.resourceId, primaryKey: nextPageRecords.value[0] },
347+
};
348+
}
349+
return null;
350+
});
351+
352+
async function prefetchNextPage() {
353+
if (!hasListNavContext.value || isFetchingNextPage.value) return;
354+
if (coreStore.listRecordIds.length !== coreStore.listPageSize) return;
355+
356+
isFetchingNextPage.value = true;
357+
try {
358+
const response = await callAdminForthApi({
359+
path: '/get_resource_data',
360+
method: 'POST',
361+
body: {
362+
source: 'list',
363+
resourceId: coreStore.listResourceId,
364+
limit: coreStore.listPageSize,
365+
offset: coreStore.listPage * coreStore.listPageSize,
366+
filters: coreStore.listFilters,
367+
sort: coreStore.listSort,
368+
},
369+
});
370+
if (response?.recordIds?.length) {
371+
nextPageRecords.value = response.recordIds;
372+
}
373+
} finally {
374+
isFetchingNextPage.value = false;
375+
}
376+
}
377+
378+
watch(isLastOnCurrentPage, (isLast) => {
379+
if (isLast) {
380+
prefetchNextPage();
381+
} else {
382+
nextPageRecords.value = [];
383+
}
384+
}, { immediate: true });
385+
386+
function handleNextClick() {
387+
if (isLastOnCurrentPage.value && nextPageRecords.value.length > 0) {
388+
coreStore.listRecordIds = nextPageRecords.value;
389+
coreStore.listPage += 1;
390+
nextPageRecords.value = [];
391+
}
392+
}
393+
301394
const otherColumns = computed(() => {
302395
const groupedColumnNames = new Set(
303396
groups.value.flatMap(group => group.columns?.map((col: AdminForthResourceColumnCommon) => col.name))

adminforth/types/Common.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ type AdminforthOptionsCommon = NonNullable<AdminForthResourceCommon['options']>;
344344
export interface AdminForthOptionsForFrontend extends Omit<AdminforthOptionsCommon, 'actions' | 'bulkActions'> {
345345
actions?: AdminForthActionFront[],
346346
bulkActions?: AdminForthBulkActionFront[],
347+
showNextButton?: boolean,
347348
}
348349

349350
export interface AdminForthResourceFrontend extends Omit<AdminForthResourceCommon, 'options' | 'table' | 'dataSource'> {
@@ -535,7 +536,14 @@ export interface AdminForthResourceInputCommon {
535536
/**
536537
* Whether to refresh existing list rows automatically every N seconds.
537538
*/
538-
listRowsAutoRefreshSeconds?: number,
539+
listRowsAutoRefreshSeconds?: number,
540+
541+
/**
542+
* Whether to show the "Next" navigation button on the show page.
543+
* Allows cycling through records respecting current list filters and sorting.
544+
* Defaults to `true`.
545+
*/
546+
showNextButton?: boolean,
539547

540548
/**
541549
* Custom components which can be injected into AdminForth CRUD pages.

0 commit comments

Comments
 (0)