Skip to content

Commit 39d7037

Browse files
authored
Merge pull request #231 from devforth/dynamic-filter
Dynamic filter
2 parents 397d35e + cc6ae1b commit 39d7037

File tree

14 files changed

+530
-68
lines changed

14 files changed

+530
-68
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/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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,46 @@ export default class ConfigValidator implements IConfigValidator {
659659
}
660660
}
661661

662+
if (col.foreignResource.searchableFields) {
663+
const searchableFields = Array.isArray(col.foreignResource.searchableFields)
664+
? col.foreignResource.searchableFields
665+
: [col.foreignResource.searchableFields];
666+
667+
searchableFields.forEach((fieldName) => {
668+
if (typeof fieldName !== 'string') {
669+
errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields must contain only strings`);
670+
return;
671+
}
672+
673+
if (col.foreignResource.resourceId) {
674+
const targetResource = this.inputConfig.resources.find((r) => r.resourceId === col.foreignResource.resourceId || r.table === col.foreignResource.resourceId);
675+
if (targetResource) {
676+
const targetColumn = targetResource.columns.find((targetCol) => targetCol.name === fieldName);
677+
if (!targetColumn) {
678+
const similar = suggestIfTypo(targetResource.columns.map((c) => c.name), fieldName);
679+
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}"?` : ''}`);
680+
}
681+
}
682+
} else if (col.foreignResource.polymorphicResources) {
683+
let hasFieldInAnyResource = false;
684+
for (const pr of col.foreignResource.polymorphicResources) {
685+
if (pr.resourceId) {
686+
const targetResource = this.inputConfig.resources.find((r) => r.resourceId === pr.resourceId || r.table === pr.resourceId);
687+
if (targetResource) {
688+
const hasField = targetResource.columns.some((targetCol) => targetCol.name === fieldName);
689+
if (hasField) {
690+
hasFieldInAnyResource = true;
691+
}
692+
}
693+
}
694+
}
695+
if (!hasFieldInAnyResource) {
696+
errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields contains field "${fieldName}" which does not exist in any of the polymorphic target resources`);
697+
}
698+
}
699+
});
700+
}
701+
662702
if (col.foreignResource.unsetLabel) {
663703
if (typeof col.foreignResource.unsetLabel !== 'string') {
664704
errors.push(`Resource "${res.resourceId}" column "${col.name}" has foreignResource unsetLabel which is not a string`);
@@ -667,6 +707,12 @@ export default class ConfigValidator implements IConfigValidator {
667707
// set default unset label
668708
col.foreignResource.unsetLabel = 'Unset';
669709
}
710+
711+
// Set default searchIsCaseSensitive
712+
if (col.foreignResource.searchIsCaseSensitive === undefined) {
713+
col.foreignResource.searchIsCaseSensitive = false;
714+
}
715+
670716
const befHook = col.foreignResource.hooks?.dropdownList?.beforeDatasourceRequest;
671717
if (befHook) {
672718
if (!Array.isArray(befHook)) {

adminforth/modules/restApi.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
839839
method: 'POST',
840840
path: '/get_resource_foreign_data',
841841
handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
842-
const { resourceId, column } = body;
842+
const { resourceId, column, search } = body;
843843
if (!this.adminforth.statuses.dbDiscover) {
844844
return { error: 'Database discovery not started' };
845845
}
@@ -910,6 +910,46 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
910910
throw new Error(`Wrong filter object value: ${JSON.stringify(filters)}`);
911911
}
912912
}
913+
914+
if (search && search.trim() && columnConfig.foreignResource.searchableFields) {
915+
const searchableFields = Array.isArray(columnConfig.foreignResource.searchableFields)
916+
? columnConfig.foreignResource.searchableFields
917+
: [columnConfig.foreignResource.searchableFields];
918+
919+
const searchOperator = columnConfig.foreignResource.searchIsCaseSensitive
920+
? AdminForthFilterOperators.LIKE
921+
: AdminForthFilterOperators.ILIKE;
922+
const availableSearchFields = searchableFields.filter((fieldName) => {
923+
const fieldExists = targetResource.columns.some(col => col.name === fieldName);
924+
if (!fieldExists) {
925+
process.env.HEAVY_DEBUG && console.log(`⚠️ Field '${fieldName}' not found in polymorphic target resource '${targetResource.resourceId}', skipping in search filter.`);
926+
}
927+
return fieldExists;
928+
});
929+
930+
if (availableSearchFields.length === 0) {
931+
process.env.HEAVY_DEBUG && console.log(`⚠️ No searchable fields available in polymorphic target resource '${targetResource.resourceId}', skipping resource.`);
932+
resolve({ items: [] });
933+
return;
934+
}
935+
const searchFilters = availableSearchFields.map((fieldName) => {
936+
const filter = {
937+
field: fieldName,
938+
operator: searchOperator,
939+
value: search.trim(),
940+
};
941+
return filter;
942+
});
943+
944+
if (searchFilters.length > 1) {
945+
normalizedFilters.subFilters.push({
946+
operator: AdminForthFilterOperators.OR,
947+
subFilters: searchFilters,
948+
});
949+
} else if (searchFilters.length === 1) {
950+
normalizedFilters.subFilters.push(searchFilters[0]);
951+
}
952+
}
913953
const dbDataItems = await this.adminforth.connectors[targetResource.dataSource].getData({
914954
resource: targetResource,
915955
limit,

adminforth/spa/src/afcl/Select.vue

Lines changed: 31 additions & 8 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"
@@ -38,8 +38,9 @@
3838
</div>
3939
<teleport to="body" v-if="teleportToBody && showDropdown">
4040
<div ref="dropdownEl" :style="getDropdownPosition" :class="{'shadow-none': isTop}"
41-
class="fixed z-[5] w-full bg-lightDropdownOptionsBackground shadow-lg dark:shadow-black dark:bg-darkDropdownOptionsBackground
42-
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+
class="fixed z-[5] w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
42+
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"
43+
@scroll="handleDropdownScroll">
4344
<div
4445
v-for="item in filteredItems"
4546
:key="item.value"
@@ -61,8 +62,9 @@
6162
</teleport>
6263

6364
<div v-if="!teleportToBody && showDropdown" ref="dropdownEl" :style="dropdownStyle" :class="{'shadow-none': isTop}"
64-
class="absolute z-10 mt-1 w-full bg-lightDropdownOptionsBackground shadow-lg text-lightDropdownOptionsText dark:shadow-black dark:bg-darkDropdownOptionsBackground
65-
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+
class="absolute z-10 mt-1 w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
66+
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"
67+
@scroll="handleDropdownScroll">
6668
<div
6769
v-for="item in filteredItems"
6870
:key="item.value"
@@ -133,13 +135,17 @@ const props = defineProps({
133135
type: Boolean,
134136
default: false,
135137
},
138+
searchDisabled: {
139+
type: Boolean,
140+
default: false,
141+
},
136142
teleportToBody: {
137143
type: Boolean,
138144
default: false,
139145
},
140146
});
141147
142-
const emit = defineEmits(['update:modelValue']);
148+
const emit = defineEmits(['update:modelValue', 'scroll-near-end', 'search']);
143149
144150
const search = ref('');
145151
const showDropdown = ref(false);
@@ -160,6 +166,9 @@ function inputInput() {
160166
selectedItems.value = [];
161167
emit('update:modelValue', null);
162168
}
169+
if (!props.searchDisabled) {
170+
emit('search', search.value);
171+
}
163172
}
164173
165174
function updateFromProps() {
@@ -178,7 +187,7 @@ function updateFromProps() {
178187
}
179188
180189
async function inputClick() {
181-
if (props.readonly) return;
190+
if (props.readonly || props.searchDisabled) return;
182191
// Toggle local dropdown
183192
showDropdown.value = !showDropdown.value;
184193
// If the dropdown is about to close, reset the search
@@ -227,6 +236,15 @@ const handleScroll = () => {
227236
}
228237
};
229238
239+
const handleDropdownScroll = (event: Event) => {
240+
const target = event.target as HTMLElement;
241+
const threshold = 10; // pixels from bottom
242+
243+
if (target.scrollTop + target.clientHeight >= target.scrollHeight - threshold) {
244+
emit('scroll-near-end');
245+
}
246+
};
247+
230248
onMounted(() => {
231249
updateFromProps();
232250
@@ -247,7 +265,12 @@ onMounted(() => {
247265
});
248266
249267
const filteredItems = computed(() => {
250-
return props.options.filter(item =>
268+
269+
if (props.searchDisabled) {
270+
return props.options || [];
271+
}
272+
273+
return (props.options || []).filter((item: any) =>
251274
item.label.toLowerCase().includes(search.value.toLowerCase())
252275
);
253276
});

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)"

0 commit comments

Comments
 (0)