feat: Add user QueryFilter and improve UI on mobile (#6235)

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
This commit is contained in:
Arsène Reymond
2025-12-09 16:49:12 +01:00
committed by GitHub
parent 89aed15905
commit 6f7fba5ac1
7 changed files with 367 additions and 311 deletions

View File

@@ -83,6 +83,11 @@ const fieldDefs: FieldDefinition[] = [
label: i18n.t("household.households"), label: i18n.t("household.households"),
type: Organizer.Household, type: Organizer.Household,
}, },
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{ {
name: "created_at", name: "created_at",
label: i18n.t("general.date-created"), label: i18n.t("general.date-created"),

View File

@@ -106,6 +106,11 @@ const fieldDefs: FieldDefinition[] = [
label: i18n.t("household.households"), label: i18n.t("household.households"),
type: Organizer.Household, type: Organizer.Household,
}, },
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{ {
name: "last_made", name: "last_made",
label: i18n.t("general.last-made"), label: i18n.t("general.last-made"),

View File

@@ -1,7 +1,6 @@
<template> <template>
<v-card class="ma-0" style="overflow-x: auto;"> <v-card class="ma-0" flat fluid>
<v-card-text class="ma-0 pa-0"> <v-card-text class="ma-0 pa-0">
<v-container fluid class="ma-0 pa-0">
<VueDraggable <VueDraggable
v-model="fields" v-model="fields"
handle=".handle" handle=".handle"
@@ -18,28 +17,27 @@
<v-row <v-row
v-for="(field, index) in fields" v-for="(field, index) in fields"
:key="field.id" :key="field.id"
class="d-flex flex-nowrap" class="d-flex flex-row flex-wrap mx-auto pb-2"
:class="$vuetify.display.xs ? (Math.floor(index / 1) % 2 === 0 ? 'bg-dark' : 'bg-light') : ''"
style="max-width: 100%;" style="max-width: 100%;"
> >
<!-- drag handle --> <!-- drag handle -->
<v-col <v-col
:cols="config.items.icon.cols" :cols="config.items.icon.cols(index)"
:class="config.col.class" :sm="config.items.icon.sm(index)"
:style="config.items.icon.style" :class="$vuetify.display.smAndDown ? 'd-flex pa-0' : 'd-flex justify-end pr-6'"
>
<v-icon
class="handle"
:size="24"
style="cursor: move;margin: auto;"
> >
<v-icon class="handle my-auto" :size="28" style="cursor: move;">
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</v-col> </v-col>
<!-- and / or --> <!-- and / or -->
<v-col <v-col
:cols="config.items.logicalOperator.cols" v-if="index != 0 || $vuetify.display.smAndUp"
:cols="config.items.logicalOperator.cols(index)"
:sm="config.items.logicalOperator.sm(index)"
:class="config.col.class" :class="config.col.class"
:style="config.items.logicalOperator.style"
> >
<v-select <v-select
v-if="index" v-if="index"
@@ -57,12 +55,13 @@
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- left parenthesis --> <!-- left parenthesis -->
<v-col <v-col
v-if="showAdvanced" v-if="showAdvanced"
:cols="config.items.leftParens.cols" :cols="config.items.leftParens.cols(index)"
:sm="config.items.leftParens.sm(index)"
:class="config.col.class" :class="config.col.class"
:style="config.items.leftParens.style"
> >
<v-select <v-select
:model-value="field.leftParenthesis" :model-value="field.leftParenthesis"
@@ -77,11 +76,12 @@
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field name --> <!-- field name -->
<v-col <v-col
:cols="config.items.fieldName.cols" :cols="config.items.fieldName.cols(index)"
:sm="config.items.fieldName.sm(index)"
:class="config.col.class" :class="config.col.class"
:style="config.items.fieldName.style"
> >
<v-select <v-select
chips chips
@@ -98,11 +98,12 @@
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- relational operator --> <!-- relational operator -->
<v-col <v-col
:cols="config.items.relationalOperator.cols" :cols="config.items.relationalOperator.cols(index)"
:sm="config.items.relationalOperator.sm(index)"
:class="config.col.class" :class="config.col.class"
:style="config.items.relationalOperator.style"
> >
<v-select <v-select
v-if="field.type !== 'boolean'" v-if="field.type !== 'boolean'"
@@ -120,11 +121,12 @@
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field value --> <!-- field value -->
<v-col <v-col
:cols="config.items.fieldValue.cols" :cols="config.items.fieldValue.cols(index)"
:sm="config.items.fieldValue.sm(index)"
:class="config.col.class" :class="config.col.class"
:style="config.items.fieldValue.style"
> >
<v-select <v-select
v-if="field.fieldOptions" v-if="field.fieldOptions"
@@ -190,7 +192,7 @@
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)" @update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag" v-else-if="field.type === Organizer.Tag"
@@ -200,7 +202,7 @@
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)" @update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool" v-else-if="field.type === Organizer.Tool"
@@ -210,7 +212,7 @@
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)" @update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food" v-else-if="field.type === Organizer.Food"
@@ -220,7 +222,7 @@
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)" @update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household" v-else-if="field.type === Organizer.Household"
@@ -230,15 +232,26 @@
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)" @update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.User"
v-model="field.organizers"
:selector-type="Organizer.User"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/> />
</v-col> </v-col>
<!-- right parenthesis --> <!-- right parenthesis -->
<v-col <v-col
v-if="showAdvanced" v-if="showAdvanced"
:cols="config.items.rightParens.cols" :cols="config.items.rightParens.cols(index)"
:sm="config.items.rightParens.sm(index)"
:class="config.col.class" :class="config.col.class"
:style="config.items.rightParens.style"
> >
<v-select <v-select
:model-value="field.rightParenthesis" :model-value="field.rightParenthesis"
@@ -253,11 +266,13 @@
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field actions --> <!-- field actions -->
<v-col <v-col
:cols="config.items.fieldActions.cols" v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
:cols="config.items.fieldActions.cols(index)"
:sm="config.items.fieldActions.sm(index)"
:class="config.col.class" :class="config.col.class"
:style="config.items.fieldActions.style"
> >
<BaseButtonGroup <BaseButtonGroup
:buttons="[ :buttons="[
@@ -274,10 +289,9 @@
</v-col> </v-col>
</v-row> </v-row>
</VueDraggable> </VueDraggable>
</v-container>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-row fluid class="d-flex justify-end pa-0 mx-2"> <v-row fluid class="d-flex justify-end ma-2">
<v-spacer /> <v-spacer />
<v-checkbox <v-checkbox
v-model="showAdvanced" v-model="showAdvanced"
@@ -305,6 +319,7 @@ import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerS
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response"; import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store";
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder"; import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
const props = defineProps({ const props = defineProps({
@@ -344,6 +359,7 @@ const storeMap = {
[Organizer.Tool]: useToolStore(), [Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(), [Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(), [Organizer.Household]: useHouseholdStore(),
[Organizer.User]: useUserStore(),
}; };
function onDragEnd(event: any) { function onDragEnd(event: any) {
@@ -602,46 +618,56 @@ function buildQueryFilterJSON(): QueryFilterJSON {
} }
const config = computed(() => { const config = computed(() => {
const baseColMaxWidth = 55; const multiple = fields.value.length > 1;
const adv = state.showAdvanced;
return { return {
col: { col: {
class: "d-flex justify-center align-end field-col pa-1", class: "d-flex justify-center align-end py-0",
}, },
select: { select: {
textClass: "d-flex justify-center text-center", textClass: "d-flex justify-center text-center",
}, },
items: { items: {
icon: { icon: {
cols: 1, cols: (_index: number) => 2,
sm: (_index: number) => 1,
style: "width: fit-content;", style: "width: fit-content;",
}, },
leftParens: { leftParens: {
cols: state.showAdvanced ? 1 : 0, cols: (index: number) => (adv ? (index === 0 ? 2 : 0) : 0),
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`, sm: (_index: number) => (adv ? 1 : 0),
}, },
logicalOperator: { logicalOperator: {
cols: 1, cols: (_index: number) => 0,
style: `min-width: ${baseColMaxWidth}px;`, sm: (_index: number) => (multiple ? 1 : 0),
}, },
fieldName: { fieldName: {
cols: state.showAdvanced ? 2 : 3, cols: (index: number) => {
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`, if (adv) return index === 0 ? 8 : 12;
return index === 0 ? 10 : 12;
},
sm: (_index: number) => (adv ? 2 : 3),
}, },
relationalOperator: { relationalOperator: {
cols: 2, cols: (_index: number) => 12,
style: `min-width: ${baseColMaxWidth * 2}px;`, sm: (_index: number) => 2,
}, },
fieldValue: { fieldValue: {
cols: state.showAdvanced ? 3 : 4, cols: (index: number) => {
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`, const last = index === fields.value.length - 1;
if (adv) return last ? 8 : 10;
return last ? 10 : 12;
},
sm: (_index: number) => (adv ? 3 : 4),
}, },
rightParens: { rightParens: {
cols: state.showAdvanced ? 1 : 0, cols: (index: number) => (adv ? (index === fields.value.length - 1 ? 2 : 0) : 0),
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`, sm: (_index: number) => (adv ? 1 : 0),
}, },
fieldActions: { fieldActions: {
cols: 1, cols: (index: number) => (index === fields.value.length - 1 ? 2 : 0),
style: `min-width: ${baseColMaxWidth}px;`, sm: (_index: number) => 1,
}, },
}, },
}; };
@@ -651,5 +677,14 @@ const config = computed(() => {
<style scoped> <style scoped>
* { * {
font-size: 1em; font-size: 1em;
--bg-opactity: calc(var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
}
.bg-dark {
background-color: rgba(0, 0, 0, var(--bg-opactity));
}
.bg-light {
background-color: rgba(255, 255, 255, var(--bg-opactity));
} }
</style> </style>

View File

@@ -8,14 +8,15 @@
:label="label" :label="label"
chips chips
closable-chips closable-chips
item-title="name" :item-title="itemTitle"
item-value="name"
multiple multiple
:variant="variant" :variant="variant"
:prepend-inner-icon="icon" :prepend-inner-icon="icon"
:append-icon="showAdd ? $globals.icons.create : undefined" :append-icon="showAdd ? $globals.icons.create : undefined"
return-object return-object
auto-select-first auto-select-first
class="pa-0" class="pa-0 ma-0"
@update:model-value="resetSearchInput" @update:model-value="resetSearchInput"
@click:append="dialog = true" @click:append="dialog = true"
> >
@@ -33,7 +34,6 @@
{{ item.value }} {{ item.value }}
</v-chip> </v-chip>
</template> </template>
<template <template
v-if="showAdd" v-if="showAdd"
#append #append
@@ -53,12 +53,13 @@ import type { RecipeTool } from "~/lib/api/types/admin";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated"; import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store";
import { normalizeFilter } from "~/composables/use-utils"; import { normalizeFilter } from "~/composables/use-utils";
import type { UserSummary } from "~/lib/api/types/user";
interface Props { interface Props {
selectorType: RecipeOrganizer; selectorType: RecipeOrganizer;
inputAttrs?: Record<string, any>; inputAttrs?: Record<string, any>;
returnObject?: boolean;
showAdd?: boolean; showAdd?: boolean;
showLabel?: boolean; showLabel?: boolean;
showIcon?: boolean; showIcon?: boolean;
@@ -67,7 +68,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
inputAttrs: () => ({}), inputAttrs: () => ({}),
returnObject: true,
showAdd: true, showAdd: true,
showLabel: true, showLabel: true,
showIcon: true, showIcon: true,
@@ -80,7 +80,7 @@ const selected = defineModel<(
| RecipeCategory | RecipeCategory
| RecipeTool | RecipeTool
| IngredientFood | IngredientFood
| string | UserSummary
)[] | undefined>({ required: true }); )[] | undefined>({ required: true });
onMounted(() => { onMounted(() => {
@@ -108,6 +108,8 @@ const label = computed(() => {
return i18n.t("general.foods"); return i18n.t("general.foods");
case Organizer.Household: case Organizer.Household:
return i18n.t("household.households"); return i18n.t("household.households");
case Organizer.User:
return i18n.t("user.users");
default: default:
return i18n.t("general.organizer"); return i18n.t("general.organizer");
} }
@@ -129,11 +131,19 @@ const icon = computed(() => {
return $globals.icons.foods; return $globals.icons.foods;
case Organizer.Household: case Organizer.Household:
return $globals.icons.household; return $globals.icons.household;
case Organizer.User:
return $globals.icons.user;
default: default:
return $globals.icons.tags; return $globals.icons.tags;
} }
}); });
const itemTitle = computed(() =>
props.selectorType === Organizer.User
? (i: any) => i?.fullName ?? i?.name ?? ""
: "name",
);
// =========================================================================== // ===========================================================================
// Store & Items Setup // Store & Items Setup
@@ -143,28 +153,19 @@ const storeMap = {
[Organizer.Tool]: useToolStore(), [Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(), [Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(), [Organizer.Household]: useHouseholdStore(),
[Organizer.User]: useUserStore(),
}; };
const store = computed(() => { const activeStore = computed(() => {
const { store } = storeMap[props.selectorType]; const { store } = storeMap[props.selectorType];
return store.value; return store.value;
}); });
const items = computed(() => { const items = computed<any[]>(() => {
if (!props.returnObject) { const list = (activeStore.value as unknown as any[]) ?? [];
return store.value.map(item => item.name); return list;
}
return store.value;
}); });
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: any) { function appendCreated(item: any) {
if (selected.value === undefined) { if (selected.value === undefined) {
return; return;

View File

@@ -168,6 +168,7 @@ export function useQueryFilterBuilder() {
|| type === Organizer.Tool || type === Organizer.Tool
|| type === Organizer.Food || type === Organizer.Food
|| type === Organizer.Household || type === Organizer.Household
|| type === Organizer.User
); );
}; };

View File

@@ -29,7 +29,8 @@ export type RecipeOrganizer
| "tags" | "tags"
| "tools" | "tools"
| "foods" | "foods"
| "households"; | "households"
| "users";
export enum Organizer { export enum Organizer {
Category = "categories", Category = "categories",
@@ -37,4 +38,5 @@ export enum Organizer {
Tool = "tools", Tool = "tools",
Food = "foods", Food = "foods",
Household = "households", Household = "households",
User = "users",
} }

View File

@@ -76,6 +76,7 @@
can-confirm can-confirm
@confirm="saveQueryFilter" @confirm="saveQueryFilter"
> >
<v-card-text>
<QueryFilterBuilder <QueryFilterBuilder
:key="queryFilterMenuKey" :key="queryFilterMenuKey"
:initial-query-filter="queryFilterJSON" :initial-query-filter="queryFilterJSON"
@@ -83,6 +84,7 @@
@input="(value) => queryFilterEditorValue = value" @input="(value) => queryFilterEditorValue = value"
@input-j-s-o-n="(value) => queryFilterEditorValueJSON = value" @input-j-s-o-n="(value) => queryFilterEditorValueJSON = value"
/> />
</v-card-text>
<template #custom-card-action> <template #custom-card-action>
<BaseButton <BaseButton
color="error" color="error"
@@ -653,6 +655,11 @@ export default defineNuxtComponent({
label: i18n.t("household.households"), label: i18n.t("household.households"),
type: Organizer.Household, type: Organizer.Household,
}, },
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
]; ];
function clearQueryFilter() { function clearQueryFilter() {