Skip to content

Commit d5f4bb4

Browse files
committed
Merge branch 'frontend/complex-search-filter' of https://github.com/MoSchmidt/inquiro into frontend/complex-search-filter
# Conflicts: # frontend/src/assets/base.css # frontend/src/components/atoms/PaperCard.vue # frontend/src/components/molecules/PaperList.vue # frontend/src/components/organisms/search/SearchInputSection.vue
2 parents d6fe543 + 5c585bd commit d5f4bb4

3 files changed

Lines changed: 341 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<script setup lang="ts">
2+
import { ref, watch } from 'vue';
3+
import { VBtn, VSelect, VTooltip, VDivider } from 'vuetify/components';
4+
import ConditionRow from './ConditionRow.vue';
5+
import type { ConditionGroup, TextCondition } from '@/types/search';
6+
import { Plus, PlusSquare, Trash2 } from 'lucide-vue-next';
7+
8+
defineOptions({ name: 'AdvancedSearchGroup' });
9+
10+
const props = defineProps<{
11+
modelValue: ConditionGroup;
12+
removable?: boolean;
13+
/** Root renders without border/operator and is locked to AND */
14+
isRoot?: boolean;
15+
}>();
16+
17+
const emit = defineEmits<{
18+
(e: 'update:modelValue', value: ConditionGroup): void;
19+
(e: 'remove'): void;
20+
}>();
21+
22+
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
23+
const localGroup = ref<ConditionGroup>(clone(props.modelValue));
24+
25+
watch(() => props.modelValue, (val) => {
26+
// keep local shadow in sync but avoid shared refs
27+
localGroup.value = clone(val);
28+
}, { deep: true });
29+
30+
watch(localGroup, (val) => {
31+
// always emit a fresh object (breaks shared refs between siblings/parents)
32+
emit('update:modelValue', clone(val));
33+
}, { deep: true });
34+
35+
const logicalItems = [
36+
{ title: 'All conditions (AND)', value: 'AND' },
37+
{ title: 'Any condition (OR)', value: 'OR' },
38+
];
39+
40+
function addCondition() {
41+
const next: TextCondition = {
42+
type: 'condition',
43+
field: 'title',
44+
operator: 'contains',
45+
value: '',
46+
};
47+
localGroup.value = { ...localGroup.value, children: [...localGroup.value.children, next] };
48+
}
49+
50+
function addGroup() {
51+
const next: ConditionGroup = {
52+
type: 'group',
53+
operator: 'AND',
54+
children: [],
55+
};
56+
localGroup.value = { ...localGroup.value, children: [...localGroup.value.children, next] };
57+
}
58+
59+
function removeChild(idx: number) {
60+
const children = localGroup.value.children.slice();
61+
children.splice(idx, 1);
62+
localGroup.value = { ...localGroup.value, children };
63+
}
64+
65+
function updateChild(idx: number, updated: ConditionGroup | TextCondition) {
66+
const children = localGroup.value.children.slice();
67+
children[idx] = clone(updated);
68+
localGroup.value = { ...localGroup.value, children };
69+
}
70+
</script>
71+
72+
<template>
73+
<!-- Root: frameless; Nested: subtle card -->
74+
<div
75+
:class="[
76+
isRoot
77+
? ''
78+
: 'rounded-lg border border-default pa-4 bg-surface',
79+
]"
80+
>
81+
<!-- Header row: hidden for root; shown for nested -->
82+
<div
83+
v-if="!isRoot"
84+
class="d-flex align-center mb-3"
85+
style="gap: 12px"
86+
>
87+
<span class="text-medium-emphasis">Combine with</span>
88+
89+
<v-select
90+
v-model="localGroup.operator"
91+
:items="logicalItems"
92+
item-title="title"
93+
item-value="value"
94+
variant="outlined"
95+
density="compact"
96+
hide-details
97+
style="max-width: 240px"
98+
/>
99+
100+
<v-tooltip text="Remove group">
101+
<template #activator="{ props: tprops }">
102+
<v-btn
103+
v-if="removable"
104+
v-bind="tprops"
105+
size="x-small"
106+
variant="text"
107+
:ripple="false"
108+
@click="$emit('remove')"
109+
>
110+
<v-icon :icon="Trash2" size="18" />
111+
</v-btn>
112+
</template>
113+
</v-tooltip>
114+
</div>
115+
116+
<!-- Children -->
117+
<div class="ms-1">
118+
<div
119+
v-for="(child, idx) in localGroup.children"
120+
:key="idx"
121+
class="mb-2"
122+
>
123+
<AdvancedSearchGroup
124+
v-if="child.type === 'group'"
125+
:model-value="child"
126+
:removable="true"
127+
:is-root="false"
128+
@update:model-value="(val) => updateChild(idx, val)"
129+
@remove="removeChild(idx)"
130+
/>
131+
<ConditionRow
132+
v-else
133+
:model-value="child"
134+
@update:model-value="(val) => updateChild(idx, val)"
135+
@remove="removeChild(idx)"
136+
/>
137+
<v-divider v-if="idx < localGroup.children.length - 1" class="my-2" />
138+
</div>
139+
</div>
140+
141+
<!-- Actions -->
142+
<div class="d-flex mt-3" style="gap: 8px">
143+
<v-btn size="small" variant="outlined" :ripple="false" @click="addCondition">
144+
<v-icon :icon="Plus" start /> Condition
145+
</v-btn>
146+
<v-btn size="small" variant="outlined" :ripple="false" @click="addGroup">
147+
<v-icon :icon="PlusSquare" start /> Group
148+
</v-btn>
149+
</div>
150+
</div>
151+
</template>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<script setup lang="ts">
2+
import { computed, ref, watch } from 'vue';
3+
import type { AdvancedSearchOptions, ConditionGroup } from '@/types/search';
4+
import AdvancedSearchGroup from '@/components/atoms/AdvancedSearchGroup.vue';
5+
import { X } from 'lucide-vue-next';
6+
import ExpansionChevron from '@/components/atoms/ExpansionChevron.vue';
7+
8+
const emit = defineEmits<{
9+
(e: 'update', value: AdvancedSearchOptions): void;
10+
}>();
11+
12+
const currentYear = new Date().getFullYear();
13+
const years = computed(() =>
14+
Array.from({ length: 100 }, (_, i) => currentYear - i)
15+
);
16+
17+
const yearFrom = ref<number | undefined>();
18+
const yearTo = ref<number | undefined>();
19+
20+
const root = ref<ConditionGroup>({
21+
type: 'group',
22+
operator: 'AND',
23+
children: [],
24+
});
25+
26+
const hasActiveFilters = computed(() => {
27+
return (
28+
yearFrom.value !== undefined ||
29+
yearTo.value !== undefined ||
30+
root.value.children.length > 0
31+
);
32+
});
33+
34+
const clearAll = () => {
35+
yearFrom.value = undefined;
36+
yearTo.value = undefined;
37+
38+
root.value = {
39+
type: 'group',
40+
operator: 'AND',
41+
children: [],
42+
};
43+
};
44+
45+
watch(
46+
[yearFrom, yearTo, root],
47+
() => {
48+
emit('update', {
49+
yearFrom: yearFrom.value ?? undefined,
50+
yearTo: yearTo.value ?? undefined,
51+
root: root.value,
52+
});
53+
},
54+
{ deep: true }
55+
);
56+
</script>
57+
58+
<template>
59+
<v-expansion-panels flat rounded="lg" :ripple="false">
60+
<v-expansion-panel class="custom-shadow-panel">
61+
<v-expansion-panel-title v-slot="{ expanded }">
62+
<div class="d-flex align-center justify-space-between w-100">
63+
<div class="d-flex align-center ga-2">
64+
<ExpansionChevron :expanded="expanded" />
65+
<span>Advanced search</span>
66+
</div>
67+
<v-btn
68+
variant="text"
69+
class="text-caption"
70+
:disabled="!hasActiveFilters"
71+
@click.stop="clearAll"
72+
>
73+
Clear all
74+
</v-btn>
75+
</div>
76+
</v-expansion-panel-title>
77+
78+
<v-expansion-panel-text>
79+
<!-- Year filters -->
80+
<v-row>
81+
<v-col cols="6">
82+
<v-autocomplete
83+
v-model="yearFrom"
84+
:items="years"
85+
variant="outlined"
86+
label="Published from"
87+
clearable
88+
:clear-icon="X"
89+
density="compact"
90+
hide-details
91+
/>
92+
</v-col>
93+
94+
<v-col cols="6">
95+
<v-autocomplete
96+
v-model="yearTo"
97+
:items="years"
98+
variant="outlined"
99+
label="Published to"
100+
clearable
101+
:clear-icon="X"
102+
density="compact"
103+
hide-details
104+
/>
105+
</v-col>
106+
</v-row>
107+
108+
<AdvancedSearchGroup class="mt-5" v-model="root" :is-root="true" />
109+
</v-expansion-panel-text>
110+
</v-expansion-panel>
111+
</v-expansion-panels>
112+
</template>
113+
<style scoped>
114+
.custom-shadow-panel {
115+
box-shadow: var(--shadow-small);
116+
border-radius: var(--radius-default);
117+
}
118+
</style>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script setup lang="ts">
2+
import { VBtn, VSelect, VTextField, VTooltip } from 'vuetify/components';
3+
import type { TextCondition } from '@/types/search';
4+
import { X } from 'lucide-vue-next';
5+
6+
const props = defineProps<{ modelValue: TextCondition }>();
7+
8+
const emit = defineEmits<{
9+
(e: 'update:modelValue', value: TextCondition): void;
10+
(e: 'remove'): void;
11+
}>();
12+
13+
const update = (patch: Partial<TextCondition>) => {
14+
emit('update:modelValue', { ...props.modelValue, ...patch });
15+
};
16+
17+
const fieldItems = [
18+
{ title: 'Title', value: 'title' },
19+
{ title: 'Abstract', value: 'abstract' },
20+
];
21+
22+
const operatorItems = [
23+
{ title: 'Contains', value: 'contains' },
24+
{ title: 'Does not contain', value: 'not_contains' },
25+
];
26+
</script>
27+
28+
<template>
29+
<div class="d-flex align-center" style="gap: 12px">
30+
<v-select
31+
:items="fieldItems"
32+
item-title="title"
33+
item-value="value"
34+
:model-value="modelValue.field"
35+
variant="outlined"
36+
density="compact"
37+
hide-details
38+
style="max-width: 180px"
39+
@update:model-value="(v) => update({ field: v as any })"
40+
/>
41+
42+
<v-select
43+
:items="operatorItems"
44+
item-title="title"
45+
item-value="value"
46+
:model-value="modelValue.operator"
47+
variant="outlined"
48+
density="compact"
49+
hide-details
50+
style="max-width: 220px"
51+
@update:model-value="(v) => update({ operator: v as any })"
52+
/>
53+
54+
<v-text-field
55+
:model-value="modelValue.value"
56+
placeholder="Search text"
57+
variant="outlined"
58+
density="compact"
59+
hide-details
60+
class="flex-grow-1"
61+
@update:model-value="(v) => update({ value: v })"
62+
/>
63+
64+
<v-tooltip text="Remove condition">
65+
<template #activator="{ props: tprops }">
66+
<v-btn v-bind="tprops" icon variant="text" :ripple="false" @click="emit('remove')">
67+
<v-icon :icon="X" />
68+
</v-btn>
69+
</template>
70+
</v-tooltip>
71+
</div>
72+
</template>

0 commit comments

Comments
 (0)