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

Open
wants to merge 2 commits into
base: next
Choose a base branch
from
Open
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
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
44 changes: 44 additions & 0 deletions adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
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) {
// 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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

so if at least one resource will not have column it will crash. This should be changed to - if there are no resources which have this column then crash.
Probably a little bit implicit but i suggest next behaviour:

if poly has 2 resources:

  • if res A has column C and res be has no col C - then when searching use column C in where of res A query, but dont use in query of res B (in query for res b use columns which exist)
  • if both res A and B has col C - use it in both queries (to both resources)
  • if no of resources has column C - drop this error here and say explicitly - we can't find this column neither in A nor in B.

So when applying poly search (later in code) - extract subset of this searchable fields which exist in resource and use only them in search with disjunction way

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`);
Expand All @@ -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)) {
Expand Down
30 changes: 29 additions & 1 deletion adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
Expand Down Expand Up @@ -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) {
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 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,
Expand Down
35 changes: 29 additions & 6 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 @@ -37,7 +37,8 @@
<teleport to="body" v-if="teleportToBody && showDropdown">
<div ref="dropdownEl" :style="getDropdownPosition" :class="{'shadow-none': isTop}"
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">
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 @@ -60,7 +61,8 @@

<div v-if="!teleportToBody && showDropdown" ref="dropdownEl" :style="dropdownStyle" :class="{'shadow-none': isTop}"
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">
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 Down Expand Up @@ -132,13 +134,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 @@ -159,6 +165,9 @@ function inputInput() {
selectedItems.value = [];
emit('update:modelValue', null);
}
if (!props.searchDisabled) {
emit('search', search.value);
}
}

function updateFromProps() {
Expand All @@ -177,7 +186,7 @@ function updateFromProps() {
}

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 @@ -221,6 +230,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 @@ -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())
);
});
Expand Down
Loading