Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,43 @@ If you want to make table header or pagination, you can add `makeHeaderSticky`,
></Table>
```

### Don't block pagination on loading

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:
```ts
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="loadPageData"
//diff-add
:blockPaginationOnLoading="false"
:pageSize="3">
</Table>


...

async function loadPageData(data, abortSignal) {
const { offset, limit } = data;
// in real app do await callAdminForthApi or await fetch to get date, use offset and limit value to slice data
await new Promise(resolve => setTimeout(resolve, offset === 500)) // simulate network delay
if (abortSignal.abort) return; // since result won't be displayed, we stop computing

return {
data: [
{ name: 'John', age: offset, country: 'US' },
{ name: 'Rick', age: offset+1, country: 'CA' },
{ name: 'Alice', age: offset+2, country: 'BR' },
],
total: 30 // should return total amount of records in database
}
}

```

## ProgressBar

<div class="split-screen" >
Expand Down
16 changes: 14 additions & 2 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
}
}

checkAbortSignal(abortSignal: AbortSignal): boolean {
if (abortSignal.aborted) {
return true;
}
return false;
}

registerEndpoints(server: IHttpServer) {
server.endpoint({
noAuth: true,
Expand Down Expand Up @@ -686,7 +693,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
server.endpoint({
method: 'POST',
path: '/get_resource_data',
handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
handler: async ({ body, adminUser, headers, query, cookies, requestUrl, abortSignal }) => {
const { resourceId, source } = body;
if (['show', 'list', 'edit'].includes(source) === false) {
return { error: 'Invalid source, should be list or show' };
Expand Down Expand Up @@ -728,7 +735,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
if (!allowed) {
return { error };
}

if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
const hookSource = {
'show': 'show',
'list': 'list',
Expand All @@ -738,6 +745,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
for (const hook of listify(resource.hooks?.[hookSource]?.beforeDatasourceRequest as BeforeDataSourceRequestFunction[])) {
const filterTools = filtersTools.get(body);
body.filtersTools = filterTools;
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
const resp = await (hook as BeforeDataSourceRequestFunction)({
resource,
query: body,
Expand Down Expand Up @@ -783,6 +791,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
throw new Error(`Wrong filter object value: ${JSON.stringify(filters)}`);
}
}
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }

const data = await this.adminforth.connectors[resource.dataSource].getData({
resource,
Expand Down Expand Up @@ -815,6 +824,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
if (pksUnique.length === 0) {
return;
}
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
const targetData = await targetConnector.getData({
resource: targetResource,
limit: pksUnique.length,
Expand Down Expand Up @@ -859,6 +869,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
return;
}
});
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }

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

// only after adminforth made all post processing, give user ability to edit it
for (const hook of listify(resource.hooks?.[hookSource]?.afterDatasourceResponse)) {
if (this.checkAbortSignal(abortSignal)) { return { error: 'Request aborted' }; }
const resp = await hook({
resource,
query: body,
Expand Down
8 changes: 7 additions & 1 deletion adminforth/servers/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,12 @@ class ExpressServer implements IExpressHttpServer {
const fullPath = `${this.adminforth.config.baseUrl}/adminapi/v1${path}`;

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

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

let output;
try {
Expand Down
38 changes: 26 additions & 12 deletions adminforth/spa/src/afcl/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,12 @@
<template #total><span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ dataResult.total }}</span></template>
</i18n-t>
<div class="af-pagination-container flex flex-row items-center xs:flex-row xs:justify-between xs:items-center gap-3">
<div class="inline-flex" :class="isLoading || props.isLoading ? 'pointer-events-none select-none opacity-50' : ''">
<div class="inline-flex" :class="blockPagination ? 'pointer-events-none select-none opacity-50' : ''">
<!-- Buttons -->
<button
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"
@click="currentPage--; pageInput = currentPage.toString();"
:disabled="currentPage <= 1 || isLoading || props.isLoading">
:disabled="currentPage <= 1 || blockPagination">
<svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 14 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
Expand All @@ -106,7 +106,7 @@
<button
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"
@click="switchPage(1); pageInput = currentPage.toString();"
:disabled="currentPage <= 1 || isLoading || props.isLoading">
:disabled="currentPage <= 1 || blockPagination">
<!-- <IconChevronDoubleLeftOutline class="w-4 h-4" /> -->
1
</button>
Expand All @@ -123,15 +123,15 @@
<button
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"
@click="currentPage = totalPages; pageInput = currentPage.toString();"
:disabled="currentPage >= totalPages || isLoading || props.isLoading"
:disabled="currentPage >= totalPages || blockPagination"
>
{{ totalPages }}

</button>
<button
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"
@click="currentPage++; pageInput = currentPage.toString();"
:disabled="currentPage >= totalPages || isLoading || props.isLoading"
:disabled="currentPage >= totalPages || blockPagination"
>
<svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 14 10">
Expand Down Expand Up @@ -163,17 +163,19 @@
}[],
data: {
[key: string]: any,
}[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }) => Promise<{data: {[key: string]: any}[], total: number}>),
}[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }, abortSignal?: AbortSignal) => Promise<{data: {[key: string]: any}[], total: number}>),
evenHighlights?: boolean,
pageSize?: number,
isLoading?: boolean,
defaultSortField?: string,
defaultSortDirection?: 'asc' | 'desc',
makeHeaderSticky?: boolean,
makePaginationSticky?: boolean,
blockPaginationOnLoading?: boolean,
}>(), {
evenHighlights: true,
pageSize: 5,
blockPaginationOnLoading: true,
}
);

Expand All @@ -188,6 +190,9 @@
const isAtLeastOneLoading = ref<boolean[]>([false]);
const currentSortField = ref<string | undefined>(props.defaultSortField);
const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc');
const oldAbortController = ref<AbortController | null>(null);

const blockPagination = computed(() => (isLoading.value || props.isLoading) && props.blockPaginationOnLoading);

onMounted(() => {
// If defaultSortField points to a non-sortable column, ignore it
Expand Down Expand Up @@ -277,16 +282,25 @@
isLoading.value = true;
const currentLoadingIndex = currentPage.value;
isAtLeastOneLoading.value[currentLoadingIndex] = true;
const result = await props.data({
offset: (currentLoadingIndex - 1) * props.pageSize,
limit: props.pageSize,
sortField: currentSortField.value,
...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),
});
const abortController = new AbortController();
if (oldAbortController.value) {
oldAbortController.value.abort();
}
oldAbortController.value = abortController;
const result = await props.data(
{
offset: (currentLoadingIndex - 1) * props.pageSize,
limit: props.pageSize,
sortField: currentSortField.value,
...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),
},
abortController.signal
);
isAtLeastOneLoading.value[currentLoadingIndex] = false;
if (isAtLeastOneLoading.value.every(v => v === false)) {
isLoading.value = false;
}
if(abortController.signal.aborted) return;
dataResult.value = result;
} else if (typeof props.data === 'object' && Array.isArray(props.data)) {
const start = (currentPage.value - 1) * props.pageSize;
Expand Down
10 changes: 8 additions & 2 deletions adminforth/spa/src/utils/listUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { type AdminForthResourceCommon } from '../types/Common';
import { useAdminforth } from '@/adminforth';
import { showErrorTost } from '@/composables/useFrontendApi'


let getResourceDataLastAbortController: AbortController | null = null;
export async function getList(resource: AdminForthResourceCommon, isPageLoaded: boolean, page: number | null , pageSize: number, sort: any, checkboxes:{ value: any[] }, filters: any = [] ) {
let rows: any[] = [];
let totalRows: number | null = null;
if (!isPageLoaded) {
return;
}
const abortController = new AbortController();
if (getResourceDataLastAbortController) {
getResourceDataLastAbortController.abort();
}
getResourceDataLastAbortController = abortController;
const data = await callAdminForthApi({
path: '/get_resource_data',
method: 'POST',
Expand All @@ -21,7 +26,8 @@ export async function getList(resource: AdminForthResourceCommon, isPageLoaded:
offset: ((page || 1) - 1) * pageSize,
filters: filters,
sort: sort,
}
},
abortSignal: abortController.signal
});
if (data.error) {
showErrorTost(data.error);
Expand Down
29 changes: 20 additions & 9 deletions adminforth/spa/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ const LS_LANG_KEY = `afLanguage`;
const MAX_CONSECUTIVE_EMPTY_RESULTS = 2;
const ITEMS_PER_PAGE_LIMIT = 100;

export async function callApi({path, method, body, headers, silentError = false}: {
export async function callApi({path, method, body, headers, silentError = false, abortSignal}: {
path: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
body?: any
headers?: Record<string, string>
silentError?: boolean
abortSignal?: AbortSignal
}): Promise<any> {
const t = i18nInstance?.global.t || ((s: string) => s)
const options = {
Expand All @@ -34,6 +35,7 @@ export async function callApi({path, method, body, headers, silentError = false}
...headers
},
body: JSON.stringify(body),
signal: abortSignal
};
const fullPath = `${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}${path}`;
try {
Expand Down Expand Up @@ -68,22 +70,31 @@ export async function callApi({path, method, body, headers, silentError = false}
return null;
}

if (!silentError) {
if (!silentError && !(e instanceof DOMException && e.name === 'AbortError')) {
adminforth.alert({variant:'danger', message: t('Something went wrong, please try again later'),})
}
console.error(`error in callApi ${path}`, e);
}
}

export async function callAdminForthApi({ path, method, body=undefined, headers=undefined, silentError = false }: {
path: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
body?: any,
headers?: Record<string, string>,
silentError?: boolean
export async function callAdminForthApi(
{
path,
method,
body=undefined,
headers=undefined,
silentError = false,
abortSignal = undefined
}: {
path: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
body?: any,
headers?: Record<string, string>,
silentError?: boolean,
abortSignal?: AbortSignal
}): Promise<any> {
try {
return callApi({path: `/adminapi/v1${path}`, method, body, headers, silentError} );
return callApi({path: `/adminapi/v1${path}`, method, body, headers, silentError, abortSignal} );
} catch (e) {
console.error('error', e);
return { error: `Unexpected error: ${e}` };
Expand Down
6 changes: 5 additions & 1 deletion adminforth/types/Back.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Express, Request } from 'express';
import type { Express, Request, Response } from 'express';
import type { Writable } from 'stream';

import { ActionCheckSource, AdminForthFilterOperators, AdminForthSortDirections, AllowedActionsEnum, AdminForthResourcePages,
Expand Down Expand Up @@ -67,6 +67,10 @@ export interface IHttpServer {
headers: {[key: string]: string},
cookies: {[key: string]: string},
response: IAdminForthHttpResponse,
requestUrl: string,
abortSignal: AbortSignal,
_raw_express_req: Request,
_raw_express_res: Response,
) => void,
}): void;

Expand Down