diff --git a/adminforth/documentation/docs/tutorial/001-gettingStarted.md b/adminforth/documentation/docs/tutorial/001-gettingStarted.md index 653e84fa..cce8f005 100644 --- a/adminforth/documentation/docs/tutorial/001-gettingStarted.md +++ b/adminforth/documentation/docs/tutorial/001-gettingStarted.md @@ -292,6 +292,7 @@ export default { name: 'realtor_id', foreignResource: { resourceId: 'adminuser', + searchableFields: ["id", "email"], // fields available for search in filter } } ], diff --git a/adminforth/documentation/docs/tutorial/03-Customization/13-standardPagesTuning.md b/adminforth/documentation/docs/tutorial/03-Customization/13-standardPagesTuning.md index d27c84bf..aa022fc4 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/13-standardPagesTuning.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/13-standardPagesTuning.md @@ -640,6 +640,31 @@ export default { ], ``` +### Searchable fields + +Enable search in filter dropdown by specifying which fields to search: + +```typescript title="./resources/apartments.ts" +export default { + name: 'apartments', + columns: [ + ... + { + name: "realtor_id", + foreignResource: { + resourceId: 'adminuser', +//diff-add + searchableFields: ["id", "email"], +//diff-add + searchIsCaseSensitive: true, // default false + }, + }, + ], + }, + ... + ], +``` + ### Polymorphic foreign resources Sometimes it is needed for one column to be a foreign key for multiple tables. For example, given the following schema: diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 684e0103..837ca248 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -658,6 +658,44 @@ export default class ConfigValidator implements IConfigValidator { } } + if (col.foreignResource.searchableFields) { + const searchableFields = Array.isArray(col.foreignResource.searchableFields) + ? col.foreignResource.searchableFields + : [col.foreignResource.searchableFields]; + + searchableFields.forEach((fieldName) => { + if (typeof fieldName !== 'string') { + errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields must contain only strings`); + return; + } + + if (col.foreignResource.resourceId) { + const targetResource = this.inputConfig.resources.find((r) => r.resourceId === col.foreignResource.resourceId || r.table === col.foreignResource.resourceId); + if (targetResource) { + const targetColumn = targetResource.columns.find((targetCol) => targetCol.name === fieldName); + if (!targetColumn) { + const similar = suggestIfTypo(targetResource.columns.map((c) => c.name), fieldName); + 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}"?` : ''}`); + } + } + } else if (col.foreignResource.polymorphicResources) { + // For polymorphic resources, check all possible target resources + for (const pr of col.foreignResource.polymorphicResources) { + if (pr.resourceId) { + const targetResource = this.inputConfig.resources.find((r) => r.resourceId === pr.resourceId || r.table === pr.resourceId); + if (targetResource) { + const hasField = targetResource.columns.some((targetCol) => targetCol.name === fieldName); + if (!hasField) { + const similar = suggestIfTypo(targetResource.columns.map((c) => c.name), fieldName); + 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}"?` : ''}`); + } + } + } + } + } + }); + } + if (col.foreignResource.unsetLabel) { if (typeof col.foreignResource.unsetLabel !== 'string') { 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 { // set default unset label col.foreignResource.unsetLabel = 'Unset'; } + + // Set default searchIsCaseSensitive + if (col.foreignResource.searchIsCaseSensitive === undefined) { + col.foreignResource.searchIsCaseSensitive = false; + } + const befHook = col.foreignResource.hooks?.dropdownList?.beforeDatasourceRequest; if (befHook) { if (!Array.isArray(befHook)) { diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 499ee606..541ef877 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -834,7 +834,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { method: 'POST', path: '/get_resource_foreign_data', handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => { - const { resourceId, column } = body; + const { resourceId, column, search } = body; if (!this.adminforth.statuses.dbDiscover) { return { error: 'Database discovery not started' }; } @@ -905,6 +905,34 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { throw new Error(`Wrong filter object value: ${JSON.stringify(filters)}`); } } + + if (search && search.trim() && columnConfig.foreignResource.searchableFields) { + const searchableFields = Array.isArray(columnConfig.foreignResource.searchableFields) + ? columnConfig.foreignResource.searchableFields + : [columnConfig.foreignResource.searchableFields]; + + const searchOperator = columnConfig.foreignResource.searchIsCaseSensitive + ? AdminForthFilterOperators.LIKE + : AdminForthFilterOperators.ILIKE; + + const searchFilters = searchableFields.map((fieldName) => { + const filter = { + field: fieldName, + operator: searchOperator, + value: `%${search.trim()}%`, + }; + return filter; + }); + + if (searchFilters.length > 1) { + normalizedFilters.subFilters.push({ + operator: AdminForthFilterOperators.OR, + subFilters: searchFilters, + }); + } else if (searchFilters.length === 1) { + normalizedFilters.subFilters.push(searchFilters[0]); + } + } const dbDataItems = await this.adminforth.connectors[targetResource.dataSource].getData({ resource: targetResource, limit, diff --git a/adminforth/spa/src/afcl/Select.vue b/adminforth/spa/src/afcl/Select.vue index 60a8d5fb..89225447 100644 --- a/adminforth/spa/src/afcl/Select.vue +++ b/adminforth/spa/src/afcl/Select.vue @@ -6,7 +6,7 @@
+ 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" + @scroll="handleDropdownScroll">
+ 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" + @scroll="handleDropdownScroll">
{ } }; +const handleDropdownScroll = (event: Event) => { + const target = event.target as HTMLElement; + const threshold = 10; // pixels from bottom + + if (target.scrollTop + target.clientHeight >= target.scrollHeight - threshold) { + emit('scroll-near-end'); + } +}; + onMounted(() => { updateFromProps(); @@ -241,7 +259,12 @@ onMounted(() => { }); const filteredItems = computed(() => { - return props.options.filter(item => + + if (!props.searchDisabled) { + return props.options || []; + } + + return (props.options || []).filter((item: any) => item.label.toLowerCase().includes(search.value.toLowerCase()) ); }); diff --git a/adminforth/spa/src/components/Filters.vue b/adminforth/spa/src/components/Filters.vue index a30c4e7f..c632cdee 100644 --- a/adminforth/spa/src/components/Filters.vue +++ b/adminforth/spa/src/components/Filters.vue @@ -27,9 +27,23 @@ :multiple="c.filterOptions.multiselect" class="w-full" :options="columnOptions[c.name] || []" + :searchDisabled="!c.foreignResource.searchableFields" + @scroll-near-end="loadMoreOptions(c.name)" + @search="(searchTerm) => { + if (c.foreignResource.searchableFields && onSearchInput[c.name]) { + onSearchInput[c.name](searchTerm); + } + }" @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event || undefined })" :modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value || (c.filterOptions.multiselect ? [] : '')" - /> + > + +