Skip to content

Commit 82245c8

Browse files
committed
feat: add searchable fields and search functionality for foreign resources
1 parent 230da5a commit 82245c8

File tree

7 files changed

+259
-33
lines changed

7 files changed

+259
-33
lines changed

adminforth/documentation/docs/tutorial/001-gettingStarted.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export default {
292292
name: 'realtor_id',
293293
foreignResource: {
294294
resourceId: 'adminuser',
295+
searchableFields: ["id", "email"], // fields available for search in filter
295296
}
296297
}
297298
],

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,31 @@ export default {
640640
],
641641
```
642642

643+
### Searchable fields
644+
645+
Enable search in filter dropdown by specifying which fields to search:
646+
647+
```typescript title="./resources/apartments.ts"
648+
export default {
649+
name: 'apartments',
650+
columns: [
651+
...
652+
{
653+
name: "realtor_id",
654+
foreignResource: {
655+
resourceId: 'adminuser',
656+
//diff-add
657+
searchableFields: ["id", "email"],
658+
//diff-add
659+
searchIsCaseSensitive: true, // default false
660+
},
661+
},
662+
],
663+
},
664+
...
665+
],
666+
```
667+
643668
### Polymorphic foreign resources
644669

645670
Sometimes it is needed for one column to be a foreign key for multiple tables. For example, given the following schema:

adminforth/modules/configValidator.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,44 @@ export default class ConfigValidator implements IConfigValidator {
658658
}
659659
}
660660

661+
if (col.foreignResource.searchableFields) {
662+
const searchableFields = Array.isArray(col.foreignResource.searchableFields)
663+
? col.foreignResource.searchableFields
664+
: [col.foreignResource.searchableFields];
665+
666+
searchableFields.forEach((fieldName) => {
667+
if (typeof fieldName !== 'string') {
668+
errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields must contain only strings`);
669+
return;
670+
}
671+
672+
if (col.foreignResource.resourceId) {
673+
const targetResource = this.inputConfig.resources.find((r) => r.resourceId === col.foreignResource.resourceId || r.table === col.foreignResource.resourceId);
674+
if (targetResource) {
675+
const targetColumn = targetResource.columns.find((targetCol) => targetCol.name === fieldName);
676+
if (!targetColumn) {
677+
const similar = suggestIfTypo(targetResource.columns.map((c) => c.name), fieldName);
678+
errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields contains field "${fieldName}" which does not exist in target resource "${targetResource.resourceId || targetResource.table}". ${similar ? `Did you mean "${similar}"?` : ''}`);
679+
}
680+
}
681+
} else if (col.foreignResource.polymorphicResources) {
682+
// For polymorphic resources, check all possible target resources
683+
for (const pr of col.foreignResource.polymorphicResources) {
684+
if (pr.resourceId) {
685+
const targetResource = this.inputConfig.resources.find((r) => r.resourceId === pr.resourceId || r.table === pr.resourceId);
686+
if (targetResource) {
687+
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}"?` : ''}`);
691+
}
692+
}
693+
}
694+
}
695+
}
696+
});
697+
}
698+
661699
if (col.foreignResource.unsetLabel) {
662700
if (typeof col.foreignResource.unsetLabel !== 'string') {
663701
errors.push(`Resource "${res.resourceId}" column "${col.name}" has foreignResource unsetLabel which is not a string`);
@@ -666,6 +704,12 @@ export default class ConfigValidator implements IConfigValidator {
666704
// set default unset label
667705
col.foreignResource.unsetLabel = 'Unset';
668706
}
707+
708+
// Set default searchIsCaseSensitive
709+
if (col.foreignResource.searchIsCaseSensitive === undefined) {
710+
col.foreignResource.searchIsCaseSensitive = false;
711+
}
712+
669713
const befHook = col.foreignResource.hooks?.dropdownList?.beforeDatasourceRequest;
670714
if (befHook) {
671715
if (!Array.isArray(befHook)) {

adminforth/modules/restApi.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
834834
method: 'POST',
835835
path: '/get_resource_foreign_data',
836836
handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
837-
const { resourceId, column } = body;
837+
const { resourceId, column, search } = body;
838838
if (!this.adminforth.statuses.dbDiscover) {
839839
return { error: 'Database discovery not started' };
840840
}
@@ -905,6 +905,34 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
905905
throw new Error(`Wrong filter object value: ${JSON.stringify(filters)}`);
906906
}
907907
}
908+
909+
if (search && search.trim() && columnConfig.foreignResource.searchableFields) {
910+
const searchableFields = Array.isArray(columnConfig.foreignResource.searchableFields)
911+
? columnConfig.foreignResource.searchableFields
912+
: [columnConfig.foreignResource.searchableFields];
913+
914+
const searchOperator = columnConfig.foreignResource.searchIsCaseSensitive
915+
? AdminForthFilterOperators.LIKE
916+
: AdminForthFilterOperators.ILIKE;
917+
918+
const searchFilters = searchableFields.map((fieldName) => {
919+
const filter = {
920+
field: fieldName,
921+
operator: searchOperator,
922+
value: `%${search.trim()}%`,
923+
};
924+
return filter;
925+
});
926+
927+
if (searchFilters.length > 1) {
928+
normalizedFilters.subFilters.push({
929+
operator: AdminForthFilterOperators.OR,
930+
subFilters: searchFilters,
931+
});
932+
} else if (searchFilters.length === 1) {
933+
normalizedFilters.subFilters.push(searchFilters[0]);
934+
}
935+
}
908936
const dbDataItems = await this.adminforth.connectors[targetResource.dataSource].getData({
909937
resource: targetResource,
910938
limit,

adminforth/spa/src/afcl/Select.vue

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<input
77
ref="inputEl"
88
type="text"
9-
:readonly="readonly"
9+
:readonly="readonly || searchDisabled"
1010
v-model="search"
1111
@click="inputClick"
1212
@input="inputInput"
@@ -37,7 +37,8 @@
3737
<teleport to="body" v-if="teleportToBody && showDropdown">
3838
<div ref="dropdownEl" :style="getDropdownPosition" :class="{'shadow-none': isTop}"
3939
class="fixed z-[5] w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
40-
dark:border-gray-600 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-48">
40+
dark:border-gray-600 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-48"
41+
@scroll="handleDropdownScroll">
4142
<div
4243
v-for="item in filteredItems"
4344
:key="item.value"
@@ -60,7 +61,8 @@
6061

6162
<div v-if="!teleportToBody && showDropdown" ref="dropdownEl" :style="dropdownStyle" :class="{'shadow-none': isTop}"
6263
class="absolute z-10 mt-1 w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
63-
dark:border-gray-600 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-48">
64+
dark:border-gray-600 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-48"
65+
@scroll="handleDropdownScroll">
6466
<div
6567
v-for="item in filteredItems"
6668
:key="item.value"
@@ -132,13 +134,17 @@ const props = defineProps({
132134
type: Boolean,
133135
default: false,
134136
},
137+
searchDisabled: {
138+
type: Boolean,
139+
default: false,
140+
},
135141
teleportToBody: {
136142
type: Boolean,
137143
default: false,
138144
},
139145
});
140146
141-
const emit = defineEmits(['update:modelValue']);
147+
const emit = defineEmits(['update:modelValue', 'scroll-near-end', 'search']);
142148
143149
const search = ref('');
144150
const showDropdown = ref(false);
@@ -159,6 +165,9 @@ function inputInput() {
159165
selectedItems.value = [];
160166
emit('update:modelValue', null);
161167
}
168+
if (!props.searchDisabled) {
169+
emit('search', search.value);
170+
}
162171
}
163172
164173
function updateFromProps() {
@@ -177,7 +186,7 @@ function updateFromProps() {
177186
}
178187
179188
function inputClick() {
180-
if (props.readonly) return;
189+
if (props.readonly && !props.searchDisabled) return;
181190
// Toggle local dropdown
182191
showDropdown.value = !showDropdown.value;
183192
// If the dropdown is about to close, reset the search
@@ -221,6 +230,15 @@ const handleScroll = () => {
221230
}
222231
};
223232
233+
const handleDropdownScroll = (event: Event) => {
234+
const target = event.target as HTMLElement;
235+
const threshold = 10; // pixels from bottom
236+
237+
if (target.scrollTop + target.clientHeight >= target.scrollHeight - threshold) {
238+
emit('scroll-near-end');
239+
}
240+
};
241+
224242
onMounted(() => {
225243
updateFromProps();
226244
@@ -241,7 +259,12 @@ onMounted(() => {
241259
});
242260
243261
const filteredItems = computed(() => {
244-
return props.options.filter(item =>
262+
263+
if (!props.searchDisabled) {
264+
return props.options || [];
265+
}
266+
267+
return (props.options || []).filter((item: any) =>
245268
item.label.toLowerCase().includes(search.value.toLowerCase())
246269
);
247270
});

0 commit comments

Comments
 (0)