diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index d4348aa3..5dcf6c3e 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -114,7 +114,23 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon const fieldObj = resource.dataSourceColumns.find((col) => col.name == (filters as IAdminForthSingleFilter).field); if (!fieldObj) { const similar = suggestIfTypo(resource.dataSourceColumns.map((col) => col.name), (filters as IAdminForthSingleFilter).field); - throw new Error(`Field '${(filters as IAdminForthSingleFilter).field}' not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}`); + + let isPolymorphicTarget = false; + if (global.adminforth?.config?.resources) { + isPolymorphicTarget = global.adminforth.config.resources.some(res => + res.dataSourceColumns.some(col => + col.foreignResource?.polymorphicResources?.some(pr => + pr.resourceId === resource.resourceId + ) + ) + ); + } + if (isPolymorphicTarget) { + process.env.HEAVY_DEBUG && console.log(`⚠️ Field '${(filters as IAdminForthSingleFilter).field}' not found in polymorphic target resource '${resource.resourceId}', allowing query to proceed.`); + return { ok: true, error: '' }; + } else { + throw new Error(`Field '${(filters as IAdminForthSingleFilter).field}' not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}`); + } } // value normalization if (filters.operator == AdminForthFilterOperators.IN || filters.operator == AdminForthFilterOperators.NIN) { diff --git a/adminforth/documentation/docs/tutorial/001-gettingStarted.md b/adminforth/documentation/docs/tutorial/001-gettingStarted.md index e8f7829c..cc95d78c 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 05197a96..2dee0aa5 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 9daf3356..7912e20e 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -659,6 +659,46 @@ 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) { + let hasFieldInAnyResource = false; + 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) { + hasFieldInAnyResource = true; + } + } + } + } + if (!hasFieldInAnyResource) { + errors.push(`Resource "${res.resourceId}" column "${col.name}" foreignResource.searchableFields contains field "${fieldName}" which does not exist in any of the polymorphic target resources`); + } + } + }); + } + 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`); @@ -667,6 +707,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 e309d109..078a1fdf 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -839,7 +839,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' }; } @@ -910,6 +910,46 @@ 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 availableSearchFields = searchableFields.filter((fieldName) => { + const fieldExists = targetResource.columns.some(col => col.name === fieldName); + if (!fieldExists) { + process.env.HEAVY_DEBUG && console.log(`⚠️ Field '${fieldName}' not found in polymorphic target resource '${targetResource.resourceId}', skipping in search filter.`); + } + return fieldExists; + }); + + if (availableSearchFields.length === 0) { + process.env.HEAVY_DEBUG && console.log(`⚠️ No searchable fields available in polymorphic target resource '${targetResource.resourceId}', skipping resource.`); + resolve({ items: [] }); + return; + } + const searchFilters = availableSearchFields.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 5c04c217..6c080a9a 100644 --- a/adminforth/spa/src/afcl/Select.vue +++ b/adminforth/spa/src/afcl/Select.vue @@ -6,7 +6,7 @@
+ class="fixed z-[5] w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700 + 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">
+ class="absolute z-10 mt-1 w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700 + 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(); @@ -247,7 +265,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/ColumnValueInput.vue b/adminforth/spa/src/components/ColumnValueInput.vue index d43ad16a..599e6a03 100644 --- a/adminforth/spa/src/components/ColumnValueInput.vue +++ b/adminforth/spa/src/components/ColumnValueInput.vue @@ -19,12 +19,26 @@ ref="input" class="w-full min-w-24" :options="columnOptions[column.name] || []" + :searchDisabled="!column.foreignResource.searchableFields" + @scroll-near-end="loadMoreOptions && loadMoreOptions(column.name)" + @search="(searchTerm) => { + if (column.foreignResource.searchableFields && onSearchInput && onSearchInput[column.name]) { + onSearchInput[column.name](searchTerm); + } + }" teleportToBody :placeholder = "columnOptions[column.name]?.length ?$t('Select...'): $t('There are no options available')" :modelValue="value" :readonly="(column.editReadonly && source === 'edit') || readonly" @update:modelValue="$emit('update:modelValue', $event)" - /> + > + +