Skip to content

Commit 3f67dee

Browse files
committed
feat: enhance foreign resource handling with pagination and search capabilities
1 parent 186ae54 commit 3f67dee

File tree

10 files changed

+343
-120
lines changed

10 files changed

+343
-120
lines changed

adminforth/dataConnectors/baseConnector.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,23 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
114114
const fieldObj = resource.dataSourceColumns.find((col) => col.name == (filters as IAdminForthSingleFilter).field);
115115
if (!fieldObj) {
116116
const similar = suggestIfTypo(resource.dataSourceColumns.map((col) => col.name), (filters as IAdminForthSingleFilter).field);
117-
throw new Error(`Field '${(filters as IAdminForthSingleFilter).field}' not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
117+
118+
let isPolymorphicTarget = false;
119+
if (global.adminforth?.config?.resources) {
120+
isPolymorphicTarget = global.adminforth.config.resources.some(res =>
121+
res.dataSourceColumns.some(col =>
122+
col.foreignResource?.polymorphicResources?.some(pr =>
123+
pr.resourceId === resource.resourceId
124+
)
125+
)
126+
);
127+
}
128+
if (isPolymorphicTarget) {
129+
process.env.HEAVY_DEBUG && console.log(`⚠️ Field '${(filters as IAdminForthSingleFilter).field}' not found in polymorphic target resource '${resource.resourceId}', allowing query to proceed.`);
130+
return { ok: true, error: '' };
131+
} else {
132+
throw new Error(`Field '${(filters as IAdminForthSingleFilter).field}' not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
133+
}
118134
}
119135
// value normalization
120136
if (filters.operator == AdminForthFilterOperators.IN || filters.operator == AdminForthFilterOperators.NIN) {

adminforth/modules/configValidator.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -679,18 +679,20 @@ export default class ConfigValidator implements IConfigValidator {
679679
}
680680
}
681681
} else if (col.foreignResource.polymorphicResources) {
682-
// For polymorphic resources, check all possible target resources
682+
let hasFieldInAnyResource = false;
683683
for (const pr of col.foreignResource.polymorphicResources) {
684684
if (pr.resourceId) {
685685
const targetResource = this.inputConfig.resources.find((r) => r.resourceId === pr.resourceId || r.table === pr.resourceId);
686686
if (targetResource) {
687687
const hasField = targetResource.columns.some((targetCol) => targetCol.name === fieldName);
688-
if (!hasField) {
689-
const similar = suggestIfTypo(targetResource.columns.map((c) => c.name), fieldName);
690-
errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields contains field "${fieldName}" which does not exist in polymorphic target resource "${pr.resourceId}". ${similar ? `Did you mean "${similar}"?` : ''}`);
688+
if (hasField) {
689+
hasFieldInAnyResource = true;
691690
}
692691
}
693692
}
693+
}
694+
if (!hasFieldInAnyResource) {
695+
errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields contains field "${fieldName}" which does not exist in any of the polymorphic target resources`);
694696
}
695697
}
696698
});

adminforth/modules/restApi.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -914,12 +914,24 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
914914
const searchOperator = columnConfig.foreignResource.searchIsCaseSensitive
915915
? AdminForthFilterOperators.LIKE
916916
: AdminForthFilterOperators.ILIKE;
917+
const availableSearchFields = searchableFields.filter((fieldName) => {
918+
const fieldExists = targetResource.columns.some(col => col.name === fieldName);
919+
if (!fieldExists) {
920+
process.env.HEAVY_DEBUG && console.log(`⚠️ Field '${fieldName}' not found in polymorphic target resource '${targetResource.resourceId}', skipping in search filter.`);
921+
}
922+
return fieldExists;
923+
});
917924

918-
const searchFilters = searchableFields.map((fieldName) => {
925+
if (availableSearchFields.length === 0) {
926+
process.env.HEAVY_DEBUG && console.log(`⚠️ No searchable fields available in polymorphic target resource '${targetResource.resourceId}', skipping resource.`);
927+
resolve({ items: [] });
928+
return;
929+
}
930+
const searchFilters = availableSearchFields.map((fieldName) => {
919931
const filter = {
920932
field: fieldName,
921933
operator: searchOperator,
922-
value: `%${search.trim()}%`,
934+
value: search.trim(),
923935
};
924936
return filter;
925937
});

adminforth/spa/src/components/ColumnValueInput.vue

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,26 @@
1919
ref="input"
2020
class="w-full min-w-24"
2121
:options="columnOptions[column.name] || []"
22+
:searchDisabled="!column.foreignResource.searchableFields"
23+
@scroll-near-end="loadMoreOptions && loadMoreOptions(column.name)"
24+
@search="(searchTerm) => {
25+
if (column.foreignResource.searchableFields && onSearchInput && onSearchInput[column.name]) {
26+
onSearchInput[column.name](searchTerm);
27+
}
28+
}"
2229
teleportToBody
2330
:placeholder = "columnOptions[column.name]?.length ?$t('Select...'): $t('There are no options available')"
2431
:modelValue="value"
2532
:readonly="(column.editReadonly && source === 'edit') || readonly"
2633
@update:modelValue="$emit('update:modelValue', $event)"
27-
/>
34+
>
35+
<template #extra-item v-if="columnLoadingState && columnLoadingState[column.name]?.loading">
36+
<div class="text-center text-gray-400 dark:text-gray-300 py-2 flex items-center justify-center gap-2">
37+
<Spinner class="w-4 h-4" />
38+
{{ $t('Loading...') }}
39+
</div>
40+
</template>
41+
</Select>
2842
<Select
2943
v-else-if="column.enum"
3044
ref="input"
@@ -142,7 +156,8 @@
142156
import CustomDatePicker from "@/components/CustomDatePicker.vue";
143157
import Select from '@/afcl/Select.vue';
144158
import Input from '@/afcl/Input.vue';
145-
import { ref } from 'vue';
159+
import Spinner from '@/afcl/Spinner.vue';
160+
import { ref, inject } from 'vue';
146161
import { getCustomComponent } from '@/utils';
147162
import { useI18n } from 'vue-i18n';
148163
import { useCoreStore } from '@/stores/core';
@@ -171,6 +186,10 @@
171186
}
172187
);
173188
189+
const columnLoadingState = inject('columnLoadingState', {} as any);
190+
const onSearchInput = inject('onSearchInput', {} as any);
191+
const loadMoreOptions = inject('loadMoreOptions', (() => {}) as any);
192+
174193
const input = ref(null);
175194
176195
const getBooleanOptions = (column: any) => {

adminforth/spa/src/components/ColumnValueInputWrapper.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
:currentValues="currentValues"
1414
:mode="mode"
1515
:columnOptions="columnOptions"
16+
:unmasked="unmasked"
1617
:deletable="!column.editReadonly"
1718
@update:modelValue="setCurrentValue(column.name, $event, arrayItemIndex)"
1819
@update:unmasked="$emit('update:unmasked', column.name)"

adminforth/spa/src/components/Filters.vue

Lines changed: 23 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137
import { watch, computed, ref, reactive } from 'vue';
138138
import { useI18n } from 'vue-i18n';
139139
import CustomDateRangePicker from '@/components/CustomDateRangePicker.vue';
140-
import { callAdminForthApi } from '@/utils';
140+
import { callAdminForthApi, loadMoreForeignOptions, searchForeignOptions } from '@/utils';
141141
import { useRouter } from 'vue-router';
142142
import CustomRangePicker from "@/components/CustomRangePicker.vue";
143143
import { useFiltersStore } from '@/stores/filters';
@@ -165,6 +165,7 @@ const columnsWithFilter = computed(
165165
const columnOptions = ref({});
166166
const columnLoadingState = reactive({});
167167
const columnOffsets = reactive({});
168+
const columnEmptyResultsCount = reactive({});
168169
169170
watch(() => props.columns, async (newColumns) => {
170171
if (!newColumns) return;
@@ -175,6 +176,7 @@ watch(() => props.columns, async (newColumns) => {
175176
columnOptions.value[column.name] = [];
176177
columnLoadingState[column.name] = { loading: false, hasMore: true };
177178
columnOffsets[column.name] = 0;
179+
columnEmptyResultsCount[column.name] = 0;
178180
179181
await loadMoreOptions(column.name);
180182
}
@@ -184,94 +186,32 @@ watch(() => props.columns, async (newColumns) => {
184186
185187
// Function to load more options for a specific column
186188
async function loadMoreOptions(columnName, searchTerm = '') {
187-
const column = props.columns?.find(c => c.name === columnName);
188-
if (!column || !column.foreignResource) return;
189-
190-
const state = columnLoadingState[columnName];
191-
if (state.loading || !state.hasMore) return;
192-
193-
state.loading = true;
194-
195-
try {
196-
const list = await callAdminForthApi({
197-
method: 'POST',
198-
path: `/get_resource_foreign_data`,
199-
body: {
200-
resourceId: router.currentRoute.value.params.resourceId,
201-
column: columnName,
202-
limit: 100,
203-
offset: columnOffsets[columnName],
204-
search: searchTerm,
205-
},
206-
});
207-
208-
if (!list || !Array.isArray(list.items)) {
209-
console.warn(`Unexpected API response for column ${columnName}:`, list);
210-
state.hasMore = false;
211-
return;
212-
}
213-
214-
if (!columnOptions.value[columnName]) {
215-
columnOptions.value[columnName] = [];
216-
}
217-
columnOptions.value[columnName].push(...list.items);
218-
219-
columnOffsets[columnName] += 100;
220-
221-
state.hasMore = list.items.length === 100;
222-
223-
} catch (error) {
224-
console.error('Error loading more options:', error);
225-
} finally {
226-
state.loading = false;
227-
}
189+
return loadMoreForeignOptions({
190+
columnName,
191+
searchTerm,
192+
columns: props.columns,
193+
resourceId: router.currentRoute.value.params.resourceId,
194+
columnOptions,
195+
columnLoadingState,
196+
columnOffsets,
197+
columnEmptyResultsCount
198+
});
228199
}
229200
230201
async function searchOptions(columnName, searchTerm) {
231-
const column = props.columns?.find(c => c.name === columnName);
232-
233-
if (!column || !column.foreignResource || !column.foreignResource.searchableFields) {
234-
return;
235-
}
236-
237-
const state = columnLoadingState[columnName];
238-
if (state.loading) return;
239-
240-
state.loading = true;
241-
242-
try {
243-
244-
const list = await callAdminForthApi({
245-
method: 'POST',
246-
path: `/get_resource_foreign_data`,
247-
body: {
248-
resourceId: router.currentRoute.value.params.resourceId,
249-
column: columnName,
250-
limit: 100,
251-
offset: 0,
252-
search: searchTerm,
253-
},
254-
});
255-
256-
if (!list || !Array.isArray(list.items)) {
257-
console.warn(`Unexpected API response for column ${columnName}:`, list);
258-
state.hasMore = false;
259-
return;
260-
}
261-
262-
columnOptions.value[columnName] = list.items;
263-
columnOffsets[columnName] = 100;
264-
state.hasMore = list.items.length === 100;
265-
266-
} catch (error) {
267-
console.error('Error searching options:', error);
268-
} finally {
269-
state.loading = false;
270-
}
202+
return searchForeignOptions({
203+
columnName,
204+
searchTerm,
205+
columns: props.columns,
206+
resourceId: router.currentRoute.value.params.resourceId,
207+
columnOptions,
208+
columnLoadingState,
209+
columnOffsets,
210+
columnEmptyResultsCount
211+
});
271212
}
272213
273214
274-
275215
// sync 'body' class 'overflow-hidden' with show prop show
276216
watch(() => props.show, (show) => {
277217
if (show) {

0 commit comments

Comments
 (0)