Skip to content

Commit 2f6a6a8

Browse files
committed
Merge branch 'next' of github.com:devforth/adminforth into next
2 parents 401d2c6 + 0cdae8d commit 2f6a6a8

File tree

7 files changed

+117
-27
lines changed

7 files changed

+117
-27
lines changed

adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,43 @@ If you want to make table header or pagination, you can add `makeHeaderSticky`,
12371237
></Table>
12381238
```
12391239

1240+
### Don't block pagination on loading
1241+
1242+
Sometimes you might want to allow user switch between pages, even if old request wasn't finished. For these porpuses you can use `blockPaginationOnLoading` and `abortSignal` in data callback:
1243+
```ts
1244+
<Table
1245+
:columns="[
1246+
{ label: 'Name', fieldName: 'name' },
1247+
{ label: 'Age', fieldName: 'age' },
1248+
{ label: 'Country', fieldName: 'country' },
1249+
]"
1250+
:data="loadPageData"
1251+
//diff-add
1252+
:blockPaginationOnLoading="false"
1253+
:pageSize="3">
1254+
</Table>
1255+
1256+
1257+
...
1258+
1259+
async function loadPageData(data, abortSignal) {
1260+
const { offset, limit } = data;
1261+
// in real app do await callAdminForthApi or await fetch to get date, use offset and limit value to slice data
1262+
await new Promise(resolve => setTimeout(resolve, offset === 500)) // simulate network delay
1263+
if (abortSignal.abort) return; // since result won't be displayed, we stop computing
1264+
1265+
return {
1266+
data: [
1267+
{ name: 'John', age: offset, country: 'US' },
1268+
{ name: 'Rick', age: offset+1, country: 'CA' },
1269+
{ name: 'Alice', age: offset+2, country: 'BR' },
1270+
],
1271+
total: 30 // should return total amount of records in database
1272+
}
1273+
}
1274+
1275+
```
1276+
12401277
## ProgressBar
12411278

12421279
<div class="split-screen" >

adminforth/modules/restApi.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
155155
}
156156
}
157157

158+
checkAbortSignal(abortSignal: AbortSignal): boolean {
159+
if (abortSignal.aborted) {
160+
return true;
161+
}
162+
return false;
163+
}
164+
158165
registerEndpoints(server: IHttpServer) {
159166
server.endpoint({
160167
noAuth: true,
@@ -686,7 +693,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
686693
server.endpoint({
687694
method: 'POST',
688695
path: '/get_resource_data',
689-
handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
696+
handler: async ({ body, adminUser, headers, query, cookies, requestUrl, abortSignal }) => {
690697
const { resourceId, source } = body;
691698
if (['show', 'list', 'edit'].includes(source) === false) {
692699
return { error: 'Invalid source, should be list or show' };
@@ -728,7 +735,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
728735
if (!allowed) {
729736
return { error };
730737
}
731-
738+
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
732739
const hookSource = {
733740
'show': 'show',
734741
'list': 'list',
@@ -738,6 +745,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
738745
for (const hook of listify(resource.hooks?.[hookSource]?.beforeDatasourceRequest as BeforeDataSourceRequestFunction[])) {
739746
const filterTools = filtersTools.get(body);
740747
body.filtersTools = filterTools;
748+
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
741749
const resp = await (hook as BeforeDataSourceRequestFunction)({
742750
resource,
743751
query: body,
@@ -783,6 +791,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
783791
throw new Error(`Wrong filter object value: ${JSON.stringify(filters)}`);
784792
}
785793
}
794+
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
786795

787796
const data = await this.adminforth.connectors[resource.dataSource].getData({
788797
resource,
@@ -815,6 +824,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
815824
if (pksUnique.length === 0) {
816825
return;
817826
}
827+
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
818828
const targetData = await targetConnector.getData({
819829
resource: targetResource,
820830
limit: pksUnique.length,
@@ -859,6 +869,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
859869
return;
860870
}
861871
});
872+
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
862873

863874
const targetData = (await Promise.all(Object.keys(pksUniques).map((polymorphicOnValue) =>
864875
targetConnectors[polymorphicOnValue].getData({
@@ -939,6 +950,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
939950

940951
// only after adminforth made all post processing, give user ability to edit it
941952
for (const hook of listify(resource.hooks?.[hookSource]?.afterDatasourceResponse)) {
953+
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
942954
const resp = await hook({
943955
resource,
944956
query: body,

adminforth/servers/express.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,12 @@ class ExpressServer implements IExpressHttpServer {
303303
const fullPath = `${this.adminforth.config.baseUrl}/adminapi/v1${path}`;
304304

305305
const expressHandler = async (req, res) => {
306+
const abortController = new AbortController();
307+
res.on('close', () => {
308+
if(req.destroyed) {
309+
abortController.abort();
310+
}
311+
});
306312
// Enforce JSON-only for mutation HTTP methods
307313
// AdminForth API endpoints accept only application/json for POST, PUT, PATCH, DELETE
308314
// If you need other content types, use a custom server endpoint.
@@ -357,7 +363,7 @@ class ExpressServer implements IExpressHttpServer {
357363

358364
const acceptLang = headers['accept-language'];
359365
const tr = (msg: string, category: string, params: any, pluralizationNumber?: number): Promise<string> => this.adminforth.tr(msg, category, acceptLang, params, pluralizationNumber);
360-
const input = { body, query, headers, cookies, adminUser, response, requestUrl, _raw_express_req: req, _raw_express_res: res, tr};
366+
const input = { body, query, headers, cookies, adminUser, response, requestUrl, _raw_express_req: req, _raw_express_res: res, tr, abortSignal: abortController.signal};
361367

362368
let output;
363369
try {

adminforth/spa/src/afcl/Table.vue

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,12 @@
9191
<template #total><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ dataResult.total }}</span></template>
9292
</i18n-t>
9393
<div class="af-pagination-container flex flex-row items-center xs:flex-row xs:justify-between xs:items-center gap-3">
94-
<div class="inline-flex" :class="isLoading || props.isLoading ? 'pointer-events-none select-none opacity-50' : ''">
94+
<div class="inline-flex" :class="blockPagination ? 'pointer-events-none select-none opacity-50' : ''">
9595
<!-- Buttons -->
9696
<button
9797
class="flex items-center py-1 px-3 gap-1 text-sm font-medium text-lightActivePaginationButtonText bg-lightActivePaginationButtonBackground border-r-0 rounded-s hover:opacity-90 dark:bg-darkActivePaginationButtonBackground dark:text-darkActivePaginationButtonText disabled:opacity-50"
9898
@click="currentPage--; pageInput = currentPage.toString();"
99-
:disabled="currentPage <= 1 || isLoading || props.isLoading">
99+
:disabled="currentPage <= 1 || blockPagination">
100100
<svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
101101
viewBox="0 0 14 10">
102102
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -106,7 +106,7 @@
106106
<button
107107
class="flex items-center py-1 px-3 text-sm font-medium text-lightUnactivePaginationButtonText focus:outline-none bg-lightUnactivePaginationButtonBackground border-r-0 border border-lightUnactivePaginationButtonBorder hover:bg-lightUnactivePaginationButtonHoverBackground hover:text-lightUnactivePaginationButtonHoverText dark:bg-darkUnactivePaginationButtonBackground dark:text-darkUnactivePaginationButtonText dark:border-darkUnactivePaginationButtonBorder dark:hover:text-darkUnactivePaginationButtonHoverText dark:hover:bg-darkUnactivePaginationButtonHoverBackground disabled:opacity-50"
108108
@click="switchPage(1); pageInput = currentPage.toString();"
109-
:disabled="currentPage <= 1 || isLoading || props.isLoading">
109+
:disabled="currentPage <= 1 || blockPagination">
110110
<!-- <IconChevronDoubleLeftOutline class="w-4 h-4" /> -->
111111
1
112112
</button>
@@ -123,15 +123,15 @@
123123
<button
124124
class="flex items-center py-1 px-3 text-sm font-medium text-lightUnactivePaginationButtonText focus:outline-none bg-lightUnactivePaginationButtonBackground border-l-0 border border-lightUnactivePaginationButtonBorder hover:bg-lightUnactivePaginationButtonHoverBackground hover:text-lightUnactivePaginationButtonHoverText dark:bg-darkUnactivePaginationButtonBackground dark:text-darkUnactivePaginationButtonText dark:border-darkUnactivePaginationButtonBorder dark:hover:text-darkUnactivePaginationButtonHoverText dark:hover:bg-darkUnactivePaginationButtonHoverBackground disabled:opacity-50"
125125
@click="currentPage = totalPages; pageInput = currentPage.toString();"
126-
:disabled="currentPage >= totalPages || isLoading || props.isLoading"
126+
:disabled="currentPage >= totalPages || blockPagination"
127127
>
128128
{{ totalPages }}
129129

130130
</button>
131131
<button
132132
class="flex items-center py-1 px-3 gap-1 text-sm font-medium text-lightActivePaginationButtonText focus:outline-none bg-lightActivePaginationButtonBackground border-l-0 rounded-e hover:opacity-90 dark:bg-darkActivePaginationButtonBackground dark:text-darkActivePaginationButtonText disabled:opacity-50"
133133
@click="currentPage++; pageInput = currentPage.toString();"
134-
:disabled="currentPage >= totalPages || isLoading || props.isLoading"
134+
:disabled="currentPage >= totalPages || blockPagination"
135135
>
136136
<svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
137137
viewBox="0 0 14 10">
@@ -163,17 +163,19 @@
163163
}[],
164164
data: {
165165
[key: string]: any,
166-
}[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }) => Promise<{data: {[key: string]: any}[], total: number}>),
166+
}[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }, abortSignal?: AbortSignal) => Promise<{data: {[key: string]: any}[], total: number}>),
167167
evenHighlights?: boolean,
168168
pageSize?: number,
169169
isLoading?: boolean,
170170
defaultSortField?: string,
171171
defaultSortDirection?: 'asc' | 'desc',
172172
makeHeaderSticky?: boolean,
173173
makePaginationSticky?: boolean,
174+
blockPaginationOnLoading?: boolean,
174175
}>(), {
175176
evenHighlights: true,
176177
pageSize: 5,
178+
blockPaginationOnLoading: true,
177179
}
178180
);
179181
@@ -188,6 +190,9 @@
188190
const isAtLeastOneLoading = ref<boolean[]>([false]);
189191
const currentSortField = ref<string | undefined>(props.defaultSortField);
190192
const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc');
193+
const oldAbortController = ref<AbortController | null>(null);
194+
195+
const blockPagination = computed(() => (isLoading.value || props.isLoading) && props.blockPaginationOnLoading);
191196
192197
onMounted(() => {
193198
// If defaultSortField points to a non-sortable column, ignore it
@@ -277,16 +282,25 @@
277282
isLoading.value = true;
278283
const currentLoadingIndex = currentPage.value;
279284
isAtLeastOneLoading.value[currentLoadingIndex] = true;
280-
const result = await props.data({
281-
offset: (currentLoadingIndex - 1) * props.pageSize,
282-
limit: props.pageSize,
283-
sortField: currentSortField.value,
284-
...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),
285-
});
285+
const abortController = new AbortController();
286+
if (oldAbortController.value) {
287+
oldAbortController.value.abort();
288+
}
289+
oldAbortController.value = abortController;
290+
const result = await props.data(
291+
{
292+
offset: (currentLoadingIndex - 1) * props.pageSize,
293+
limit: props.pageSize,
294+
sortField: currentSortField.value,
295+
...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),
296+
},
297+
abortController.signal
298+
);
286299
isAtLeastOneLoading.value[currentLoadingIndex] = false;
287300
if (isAtLeastOneLoading.value.every(v => v === false)) {
288301
isLoading.value = false;
289302
}
303+
if(abortController.signal.aborted) return;
290304
dataResult.value = result;
291305
} else if (typeof props.data === 'object' && Array.isArray(props.data)) {
292306
const start = (currentPage.value - 1) * props.pageSize;

adminforth/spa/src/utils/listUtils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import { type AdminForthResourceCommon } from '../types/Common';
44
import { useAdminforth } from '@/adminforth';
55
import { showErrorTost } from '@/composables/useFrontendApi'
66

7-
7+
let getResourceDataLastAbortController: AbortController | null = null;
88
export async function getList(resource: AdminForthResourceCommon, isPageLoaded: boolean, page: number | null , pageSize: number, sort: any, checkboxes:{ value: any[] }, filters: any = [] ) {
99
let rows: any[] = [];
1010
let totalRows: number | null = null;
1111
if (!isPageLoaded) {
1212
return;
1313
}
14+
const abortController = new AbortController();
15+
if (getResourceDataLastAbortController) {
16+
getResourceDataLastAbortController.abort();
17+
}
18+
getResourceDataLastAbortController = abortController;
1419
const data = await callAdminForthApi({
1520
path: '/get_resource_data',
1621
method: 'POST',
@@ -21,7 +26,8 @@ export async function getList(resource: AdminForthResourceCommon, isPageLoaded:
2126
offset: ((page || 1) - 1) * pageSize,
2227
filters: filters,
2328
sort: sort,
24-
}
29+
},
30+
abortSignal: abortController.signal
2531
});
2632
if (data.error) {
2733
showErrorTost(data.error);

adminforth/spa/src/utils/utils.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ const LS_LANG_KEY = `afLanguage`;
1919
const MAX_CONSECUTIVE_EMPTY_RESULTS = 2;
2020
const ITEMS_PER_PAGE_LIMIT = 100;
2121

22-
export async function callApi({path, method, body, headers, silentError = false}: {
22+
export async function callApi({path, method, body, headers, silentError = false, abortSignal}: {
2323
path: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
2424
body?: any
2525
headers?: Record<string, string>
2626
silentError?: boolean
27+
abortSignal?: AbortSignal
2728
}): Promise<any> {
2829
const t = i18nInstance?.global.t || ((s: string) => s)
2930
const options = {
@@ -34,6 +35,7 @@ export async function callApi({path, method, body, headers, silentError = false}
3435
...headers
3536
},
3637
body: JSON.stringify(body),
38+
signal: abortSignal
3739
};
3840
const fullPath = `${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}${path}`;
3941
try {
@@ -68,22 +70,31 @@ export async function callApi({path, method, body, headers, silentError = false}
6870
return null;
6971
}
7072

71-
if (!silentError) {
73+
if (!silentError && !(e instanceof DOMException && e.name === 'AbortError')) {
7274
adminforth.alert({variant:'danger', message: t('Something went wrong, please try again later'),})
7375
}
7476
console.error(`error in callApi ${path}`, e);
7577
}
7678
}
7779

78-
export async function callAdminForthApi({ path, method, body=undefined, headers=undefined, silentError = false }: {
79-
path: string,
80-
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
81-
body?: any,
82-
headers?: Record<string, string>,
83-
silentError?: boolean
80+
export async function callAdminForthApi(
81+
{
82+
path,
83+
method,
84+
body=undefined,
85+
headers=undefined,
86+
silentError = false,
87+
abortSignal = undefined
88+
}: {
89+
path: string,
90+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
91+
body?: any,
92+
headers?: Record<string, string>,
93+
silentError?: boolean,
94+
abortSignal?: AbortSignal
8495
}): Promise<any> {
8596
try {
86-
return callApi({path: `/adminapi/v1${path}`, method, body, headers, silentError} );
97+
return callApi({path: `/adminapi/v1${path}`, method, body, headers, silentError, abortSignal} );
8798
} catch (e) {
8899
console.error('error', e);
89100
return { error: `Unexpected error: ${e}` };

adminforth/types/Back.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Express, Request } from 'express';
1+
import type { Express, Request, Response } from 'express';
22
import type { Writable } from 'stream';
33

44
import { ActionCheckSource, AdminForthFilterOperators, AdminForthSortDirections, AllowedActionsEnum, AdminForthResourcePages,
@@ -67,6 +67,10 @@ export interface IHttpServer {
6767
headers: {[key: string]: string},
6868
cookies: {[key: string]: string},
6969
response: IAdminForthHttpResponse,
70+
requestUrl: string,
71+
abortSignal: AbortSignal,
72+
_raw_express_req: Request,
73+
_raw_express_res: Response,
7074
) => void,
7175
}): void;
7276

0 commit comments

Comments
 (0)