Skip to content

Dynamic filter #231

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion adminforth/dataConnectors/baseConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export default {
name: 'realtor_id',
foreignResource: {
resourceId: 'adminuser',
searchableFields: ["id", "email"], // fields available for search in filter
}
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SerVitasik or text at least

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`);
Expand All @@ -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)) {
Expand Down
42 changes: 41 additions & 1 deletion adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think poly case is not implemented here at all - it should do parallel queries

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or probably you can do it on frontend (later) - should be possible also, maybe even better

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,
Expand Down
39 changes: 31 additions & 8 deletions adminforth/spa/src/afcl/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<input
ref="inputEl"
type="text"
:readonly="readonly"
:readonly="readonly || searchDisabled"
v-model="search"
@click="inputClick"
@input="inputInput"
Expand Down Expand Up @@ -38,8 +38,9 @@
</div>
<teleport to="body" v-if="teleportToBody && showDropdown">
<div ref="dropdownEl" :style="getDropdownPosition" :class="{'shadow-none': isTop}"
class="fixed z-[5] w-full bg-lightDropdownOptionsBackground shadow-lg dark:shadow-black dark:bg-darkDropdownOptionsBackground
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">
class="fixed z-[5] w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
Copy link
Preview

Copilot AI Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded colors 'bg-white' and 'dark:bg-gray-700' replace the original theme-aware classes. This could break theming consistency. Consider preserving the original 'bg-lightDropdownOptionsBackground' and 'dark:bg-darkDropdownOptionsBackground' classes.

Suggested change
class="fixed z-[5] w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
class="fixed z-[5] w-full bg-lightDropdownOptionsBackground shadow-lg dark:shadow-black dark:bg-darkDropdownOptionsBackground

Copilot uses AI. Check for mistakes.

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">
<div
v-for="item in filteredItems"
:key="item.value"
Expand All @@ -61,8 +62,9 @@
</teleport>

<div v-if="!teleportToBody && showDropdown" ref="dropdownEl" :style="dropdownStyle" :class="{'shadow-none': isTop}"
class="absolute z-10 mt-1 w-full bg-lightDropdownOptionsBackground shadow-lg text-lightDropdownOptionsText dark:shadow-black dark:bg-darkDropdownOptionsBackground
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">
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"
Comment on lines +65 to +66
Copy link
Preview

Copilot AI Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same theming issue as the teleported dropdown. The hardcoded colors should be replaced with the original theme-aware classes for consistency.

Suggested change
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"
class="absolute z-10 mt-1 w-full bg-lightDropdownOptionsBackground shadow-lg dark:shadow-black dark:bg-darkDropdownOptionsBackground
dark:border-darkDropdownOptionsBorder rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-48"

Copilot uses AI. Check for mistakes.

@scroll="handleDropdownScroll">
<div
v-for="item in filteredItems"
:key="item.value"
Expand Down Expand Up @@ -133,13 +135,17 @@ const props = defineProps({
type: Boolean,
default: false,
},
searchDisabled: {
type: Boolean,
default: false,
},
teleportToBody: {
type: Boolean,
default: false,
},
});

const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'scroll-near-end', 'search']);

const search = ref('');
const showDropdown = ref(false);
Expand All @@ -160,6 +166,9 @@ function inputInput() {
selectedItems.value = [];
emit('update:modelValue', null);
}
if (!props.searchDisabled) {
emit('search', search.value);
}
}

function updateFromProps() {
Expand All @@ -178,7 +187,7 @@ function updateFromProps() {
}

async function inputClick() {
if (props.readonly) return;
if (props.readonly || props.searchDisabled) return;
// Toggle local dropdown
showDropdown.value = !showDropdown.value;
// If the dropdown is about to close, reset the search
Expand Down Expand Up @@ -227,6 +236,15 @@ const handleScroll = () => {
}
};

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();

Expand All @@ -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())
);
});
Expand Down
23 changes: 21 additions & 2 deletions adminforth/spa/src/components/ColumnValueInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
/>
>
<template #extra-item v-if="columnLoadingState && columnLoadingState[column.name]?.loading">
<div class="text-center text-gray-400 dark:text-gray-300 py-2 flex items-center justify-center gap-2">
<Spinner class="w-4 h-4" />
{{ $t('Loading...') }}
</div>
</template>
</Select>
<Select
v-else-if="column.enum"
ref="input"
Expand Down Expand Up @@ -142,7 +156,8 @@
import CustomDatePicker from "@/components/CustomDatePicker.vue";
import Select from '@/afcl/Select.vue';
import Input from '@/afcl/Input.vue';
import { ref } from 'vue';
import Spinner from '@/afcl/Spinner.vue';
import { ref, inject } from 'vue';
import { getCustomComponent } from '@/utils';
import { useI18n } from 'vue-i18n';
import { useCoreStore } from '@/stores/core';
Expand Down Expand Up @@ -171,6 +186,10 @@
}
);

const columnLoadingState = inject('columnLoadingState', {} as any);
const onSearchInput = inject('onSearchInput', {} as any);
const loadMoreOptions = inject('loadMoreOptions', (() => {}) as any);

const input = ref(null);

const getBooleanOptions = (column: any) => {
Expand Down
1 change: 1 addition & 0 deletions adminforth/spa/src/components/ColumnValueInputWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
:currentValues="currentValues"
:mode="mode"
:columnOptions="columnOptions"
:unmasked="unmasked"
:deletable="!column.editReadonly"
@update:modelValue="setCurrentValue(column.name, $event, arrayItemIndex)"
@update:unmasked="$emit('update:unmasked', column.name)"
Expand Down
Loading