Skip to content

Commit fb5855a

Browse files
authored
Merge pull request #227 from devforth/custom-filter-component
feat: add custom filter component for square meters and integrate int…
2 parents 18b9918 + 554a351 commit fb5855a

File tree

7 files changed

+276
-2
lines changed

7 files changed

+276
-2
lines changed

adminforth/commands/createCustomComponent/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async function handleFieldComponentCreation(config, resources) {
5050
{ name: '📃 show', value: 'show' },
5151
{ name: '✏️ edit', value: 'edit' },
5252
{ name: '➕ create', value: 'create' },
53+
{ name: '🔍 filter', value: 'filter'},
5354
new Separator(),
5455
{ name: '🔙 BACK', value: '__BACK__' },
5556
]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<template>
2+
<input
3+
type="text"
4+
:value="localValue"
5+
@input="onInput"
6+
placeholder="Search"
7+
aria-describedby="helper-text-explanation"
8+
class="inline-flex bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-0 focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary focus:border-blue-500 block w-20 p-2.5 dark:bg-gray-700 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 dark:text-white translate-y-0 rounded-l-md rounded-r-md w-full"
9+
/>
10+
</template>
11+
12+
<script setup lang="ts">
13+
import { ref, watch } from 'vue';
14+
15+
const emit = defineEmits(['update:modelValue']);
16+
17+
const props = defineProps<{
18+
column: any;
19+
meta?: any;
20+
modelValue: Array<{ operator: string; value: string }> | null;
21+
}>();
22+
23+
const localValue = ref(props.modelValue?.[0]?.value || '');
24+
25+
watch(() => props.modelValue, (val) => {
26+
localValue.value = val?.[0]?.value || '';
27+
});
28+
29+
function onInput(event: Event) {
30+
const target = event.target as HTMLInputElement;
31+
localValue.value = target.value;
32+
emit('update:modelValue', [{ operator: 'ilike', value: target.value }]);
33+
}
34+
</script>

adminforth/documentation/docs/tutorial/03-Customization/02-customFieldRendering.md

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,4 +481,119 @@ list: '@/renderers/ZeroStylesRichText.vue',
481481
//diff-add
482482
```
483483
484-
`ZeroStyleRichText` fits well for tasks like email templates preview fields.
484+
`ZeroStyleRichText` fits well for tasks like email templates preview fields.
485+
486+
487+
### Custom filter component for square meters
488+
489+
490+
Sometimes standard filters are not enough, and you want to make a convenient UI for selecting a range of apartment areas. For example, buttons with options for “Small (<25 m²)”, “Medium (25–90 m²)” and “Large (>90 m²)”.
491+
492+
```ts title='./custom/SquareMetersFilter.vue'
493+
<template>
494+
<div class="flex flex-col gap-2">
495+
<p class="font-medium mb-1 dark:text-white">{{ $t('Square meters filter') }}</p>
496+
<div class="flex gap-2">
497+
<button
498+
v-for="option in options"
499+
:key="option.value"
500+
type="button"
501+
class="flex gap-1 items-center py-1 px-3 text-sm font-medium rounded-default border focus:outline-none focus:z-10 focus:ring-4"
502+
:class="{
503+
'text-white bg-blue-500 border-blue-500 hover:bg-blue-600 focus:ring-blue-200 dark:focus:ring-blue-800': selected === option.value,
504+
'text-gray-900 bg-white border-gray-300 hover:bg-gray-100 hover:text-blue-500 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700': selected !== option.value
505+
}"
506+
@click="select(option.value)"
507+
>
508+
{{ $t(option.label) }}
509+
</button>
510+
</div>
511+
</div>
512+
</template>
513+
514+
<script setup lang="ts">
515+
import { ref, watch } from 'vue';
516+
517+
const emit = defineEmits(['update:modelValue']);
518+
519+
const props = defineProps<{
520+
modelValue: Array<{ operator: string; value: number }> | null;
521+
}>();
522+
523+
const selected = ref<string | null>(null);
524+
525+
const options = [
526+
{ value: 'small', label: 'Small' },
527+
{ value: 'medium', label: 'Medium' },
528+
{ value: 'large', label: 'Large' }
529+
];
530+
531+
onMounted(() => {
532+
const val = props.modelValue;
533+
if (!val || val.length === 0) {
534+
selected.value = null;
535+
return;
536+
}
537+
538+
const ops = val.map((v) => `${v.operator}:${v.value}`);
539+
540+
if (ops.includes('lt:25')) selected.value = 'small';
541+
else if (ops.includes('gte:25') && ops.includes('lte:90')) selected.value = 'medium';
542+
else if (ops.includes('gt:90')) selected.value = 'large';
543+
else selected.value = null;
544+
});
545+
546+
watch(selected, (size) => {
547+
if (!size) {
548+
emit('update:modelValue', []);
549+
return;
550+
}
551+
552+
const filters = {
553+
small: [{ operator: 'lt', value: 25 }],
554+
medium: [
555+
{ operator: 'gte', value: 25 },
556+
{ operator: 'lte', value: 90 }
557+
],
558+
large: [{ operator: 'gt', value: 90 }]
559+
};
560+
561+
emit('update:modelValue', filters[size]);
562+
});
563+
564+
function select(size: string) {
565+
selected.value = size;
566+
567+
switch (size) {
568+
case 'small':
569+
emit('update:modelValue', [{ operator: 'lt', value: 25 }]);
570+
break;
571+
case 'medium':
572+
emit('update:modelValue', [
573+
{ operator: 'gte', value: 25 },
574+
{ operator: 'lte', value: 90 }
575+
]);
576+
break;
577+
case 'large':
578+
emit('update:modelValue', [{ operator: 'gt', value: 90 }]);
579+
break;
580+
}
581+
}
582+
</script>
583+
```
584+
585+
```ts title='./resources/apartments.ts'
586+
columns: [
587+
...
588+
{
589+
name: 'square_meter',
590+
label: 'Square',
591+
//diff-add
592+
components: {
593+
//diff-add
594+
filter: '@@/SquareMetersFilter.vue'
595+
//diff-add
596+
}
597+
},
598+
...
599+
]

adminforth/spa/src/components/Filters.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,25 @@
2121
<ul class="space-y-3 font-medium">
2222
<li v-for="c in columnsWithFilter" :key="c">
2323
<p class="dark:text-gray-400">{{ c.label }}</p>
24+
<component
25+
v-if="c.components?.filter"
26+
:is="getCustomComponent(c.components.filter)"
27+
:meta="c?.components?.list?.meta"
28+
:column="c"
29+
class="w-full"
30+
@update:modelValue="(filtersArray) => {
31+
filtersStore.filters = filtersStore.filters.filter(f => f.field !== c.name);
2432
33+
for (const f of filtersArray) {
34+
filtersStore.filters.push({ field: c.name, ...f });
35+
}
36+
console.log('filtersStore.filters', filtersStore.filters);
37+
emits('update:filters', [...filtersStore.filters]);
38+
}"
39+
:modelValue="filtersStore.filters.filter(f => f.field === c.name)"
40+
/>
2541
<Select
26-
v-if="c.foreignResource"
42+
v-else-if="c.foreignResource"
2743
:multiple="c.filterOptions.multiselect"
2844
class="w-full"
2945
:options="columnOptions[c.name] || []"
@@ -128,6 +144,7 @@ import { useRouter } from 'vue-router';
128144
import { computedAsync } from '@vueuse/core'
129145
import CustomRangePicker from "@/components/CustomRangePicker.vue";
130146
import { useFiltersStore } from '@/stores/filters';
147+
import { getCustomComponent } from '@/utils';
131148
import Input from '@/afcl/Input.vue';
132149
import Select from '@/afcl/Select.vue';
133150
import debounce from 'debounce';

adminforth/types/Common.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ export interface AdminForthFieldComponents {
187187
* emptiness emit is optional and required for complex cases. For example for virtual columns where initial value is not set.
188188
*/
189189
list?: AdminForthComponentDeclaration,
190+
191+
/**
192+
* Filter component is used to redefine input field in filter view.
193+
* Component accepts next properties: [record, column, resource, adminUser].
194+
*/
195+
filter?: AdminForthComponentDeclaration,
190196
}
191197

192198

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<template>
2+
<div class="flex flex-col gap-2">
3+
<!-- Label for the filter section -->
4+
<p class="font-medium mb-1 dark:text-white">{{ $t('Square meters filter') }}</p>
5+
6+
<!-- Button group for filter options -->
7+
<div class="flex gap-2">
8+
<button
9+
:class="[
10+
baseBtnClass,
11+
selected === 'small' ? activeBtnClass : inactiveBtnClass
12+
]"
13+
@click="select('small')"
14+
type="button"
15+
>
16+
{{ $t('Small (<25)') }}
17+
</button>
18+
<button
19+
:class="[
20+
baseBtnClass,
21+
selected === 'medium' ? activeBtnClass : inactiveBtnClass
22+
]"
23+
@click="select('medium')"
24+
type="button"
25+
>
26+
{{ $t('Medium (25–90)') }}
27+
</button>
28+
<button
29+
:class="[
30+
baseBtnClass,
31+
selected === 'large' ? activeBtnClass : inactiveBtnClass
32+
]"
33+
@click="select('large')"
34+
type="button"
35+
>
36+
{{ $t('Large (>90)') }}
37+
</button>
38+
</div>
39+
</div>
40+
</template>
41+
42+
<script setup lang="ts">
43+
import { ref, watch, computed } from 'vue';
44+
45+
const emit = defineEmits(['update:modelValue']);
46+
47+
const props = defineProps<{
48+
modelValue: Array<{ operator: string; value: number }> | null;
49+
}>();
50+
51+
// Track selected filter option
52+
const selected = ref<string | null>(null);
53+
54+
// Button classes
55+
const baseBtnClass =
56+
'flex gap-1 items-center py-1 px-3 text-sm font-medium rounded-default border focus:outline-none focus:z-10 focus:ring-4';
57+
const activeBtnClass =
58+
'text-white bg-blue-500 border-blue-500 hover:bg-blue-600 focus:ring-blue-200 dark:focus:ring-blue-800';
59+
const inactiveBtnClass =
60+
'text-gray-900 bg-white border-gray-300 hover:bg-gray-100 hover:text-blue-500 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700';
61+
62+
// Watch for external changes to the modelValue prop and update selected button accordingly
63+
watch(
64+
() => props.modelValue,
65+
(val) => {
66+
if (!val || val.length === 0) {
67+
selected.value = null;
68+
return;
69+
}
70+
71+
const ops = val.map((v) => `${v.operator}:${v.value}`);
72+
73+
if (ops.includes('lt:25')) selected.value = 'small';
74+
else if (ops.includes('gte:25') && ops.includes('lte:90')) selected.value = 'medium';
75+
else if (ops.includes('gt:90')) selected.value = 'large';
76+
else selected.value = null;
77+
},
78+
{ immediate: true }
79+
);
80+
81+
// Emit corresponding value array depending on selected size
82+
function select(size: string) {
83+
selected.value = size;
84+
85+
switch (size) {
86+
case 'small':
87+
emit('update:modelValue', [{ operator: 'lt', value: 25 }]);
88+
break;
89+
case 'medium':
90+
emit('update:modelValue', [
91+
{ operator: 'gte', value: 25 },
92+
{ operator: 'lte', value: 90 }
93+
]);
94+
break;
95+
case 'large':
96+
emit('update:modelValue', [{ operator: 'gt', value: 90 }]);
97+
break;
98+
}
99+
}
100+
</script>

dev-demo/resources/apartments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export default {
254254
showCountryName: true,
255255
},
256256
},
257+
filter: "@@/CustomSqueareMetersFilter.vue",
257258
},
258259
},
259260
{

0 commit comments

Comments
 (0)