Skip to content

Commit 12b1217

Browse files
committed
feat: add custom filter component for square meters and integrate into resource selection
1 parent 7ca0dea commit 12b1217

File tree

7 files changed

+278
-2
lines changed

7 files changed

+278
-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: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,4 +481,121 @@ 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+
:class="[
499+
baseBtnClass,
500+
selected === 'small' ? activeBtnClass : inactiveBtnClass
501+
]"
502+
@click="select('small')"
503+
type="button"
504+
>
505+
{{ $t('Small') }}
506+
</button>
507+
<button
508+
:class="[
509+
baseBtnClass,
510+
selected === 'medium' ? activeBtnClass : inactiveBtnClass
511+
]"
512+
@click="select('medium')"
513+
type="button"
514+
>
515+
{{ $t('Medium') }}
516+
</button>
517+
<button
518+
:class="[
519+
baseBtnClass,
520+
selected === 'large' ? activeBtnClass : inactiveBtnClass
521+
]"
522+
@click="select('large')"
523+
type="button"
524+
>
525+
{{ $t('Large') }}
526+
</button>
527+
</div>
528+
</div>
529+
</template>
530+
531+
<script setup lang="ts">
532+
import { ref, watch } from 'vue';
533+
534+
const emit = defineEmits(['update:modelValue']);
535+
536+
const props = defineProps<{
537+
modelValue: Array<{ operator: string; value: number }> | null;
538+
}>();
539+
540+
const selected = ref<string | null>(null);
541+
542+
const baseBtnClass =
543+
'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';
544+
const activeBtnClass =
545+
'text-white bg-blue-500 border-blue-500 hover:bg-blue-600 focus:ring-blue-200 dark:focus:ring-blue-800';
546+
const inactiveBtnClass =
547+
'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';
548+
549+
watch(
550+
() => props.modelValue,
551+
(val) => {
552+
if (!val || val.length === 0) {
553+
selected.value = null;
554+
return;
555+
}
556+
const ops = val.map((v) => `${v.operator}:${v.value}`);
557+
558+
if (ops.includes('lt:25')) selected.value = 'small';
559+
else if (ops.includes('gte:25') && ops.includes('lte:90')) selected.value = 'medium';
560+
else if (ops.includes('gt:90')) selected.value = 'large';
561+
else selected.value = null;
562+
},
563+
{ immediate: true }
564+
);
565+
566+
function select(size: string) {
567+
selected.value = size;
568+
569+
switch (size) {
570+
case 'small':
571+
emit('update:modelValue', [{ operator: 'lt', value: 25 }]);
572+
break;
573+
case 'medium':
574+
emit('update:modelValue', [
575+
{ operator: 'gte', value: 25 },
576+
{ operator: 'lte', value: 90 }
577+
]);
578+
break;
579+
case 'large':
580+
emit('update:modelValue', [{ operator: 'gt', value: 90 }]);
581+
break;
582+
}
583+
}
584+
</script>
585+
```
586+
587+
```ts title='./resources/apartments.ts'
588+
columns: [
589+
...
590+
{
591+
name: 'square_meter',
592+
label: 'Square',
593+
//diff-add
594+
components: {
595+
//diff-add
596+
filter: '@@/SquareMetersFilter.vue'
597+
//diff-add
598+
}
599+
},
600+
...
601+
]

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)