Implemented search filters for random restaurant

main
Eloi Zalczer 2024-06-21 15:58:03 +02:00
parent 1ebbe3579e
commit b05fb94547
12 changed files with 244 additions and 73 deletions

View File

@ -3,8 +3,10 @@ import { RouterView } from "vue-router";
import "./style.css";
import NavigationBar from "./components/NavigationBar.vue";
import { useRestaurantsStore } from "./stores/restaurants";
import { usePreferencesStore } from "./stores/preferences";
useRestaurantsStore().init();
usePreferencesStore().init();
</script>
<template>

View File

@ -9,8 +9,9 @@ import { storeToRefs } from "pinia";
import { useRestaurantsStore } from "../stores/restaurants";
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/yup";
import Restaurant from "../models/restaurant";
import { filterValues } from "../utils/filters";
import { makeFilters } from "../utils/filters";
import { SearchPreferences } from "../models/search";
import { usePreferencesStore } from "../stores/preferences";
const priceTicks = {
1: "€",
@ -20,6 +21,7 @@ const priceTicks = {
};
const { restaurants } = storeToRefs(useRestaurantsStore());
const { searchPreferences } = storeToRefs(usePreferencesStore());
const restaurantOptions = computed(() => {
return restaurants.value.map((restaurant) => restaurant.id);
@ -35,11 +37,35 @@ const restaurantLabels = computed(() => {
const availableTags = ref<Tag[]>([]);
const tagsNames = computed(() => {
return availableTags.value.map((tag) => tag.name);
const tagsOptions = computed(() => {
return availableTags.value.map((tag) => tag.id);
});
const { values, defineField } = useForm({
const tagsLabels = computed(() => {
const labels: Map<string, string> = new Map();
availableTags.value.forEach((tag) => {
labels.set(tag.id, tag.name);
});
return labels;
});
function mapValuesToModel(values: Object): SearchPreferences {
return {
priceRangeEnabled: values["priceRangeEnabled"],
includeTagsEnabled: values["includeTagsEnabled"],
excludeTagsEnabled: values["excludeTagsEnabled"],
includeRestaurantsEnabled: values["includeRestaurantsEnabled"],
excludeRestaurantsEnabled: values["excludeRestaurantsEnabled"],
lowerPriceBound: values["priceRange"] ? priceTicks[values["priceRange"][0]!] : undefined,
upperPriceBound: values["priceRange"] ? priceTicks[values["priceRange"][1]!] : undefined,
includedTags: values["includedTags"],
excludedTags: values["excludedTags"],
includedRestaurants: values["includedRestaurants"],
excludedRestaurants: values["excludedRestaurants"],
};
}
const { values, handleSubmit, defineField } = useForm({
validationSchema: toTypedSchema(
yup.object({
priceRangeEnabled: yup.boolean(),
@ -54,19 +80,33 @@ const { values, defineField } = useForm({
excludedRestaurants: yup.array().of(yup.string()),
})
),
initialValues: {
priceRangeEnabled: searchPreferences.value?.priceRangeEnabled,
includeTagsEnabled: searchPreferences.value?.includeTagsEnabled,
excludeTagsEnabled: searchPreferences.value?.excludeTagsEnabled,
includeRestaurantsEnabled: searchPreferences.value?.includeRestaurantsEnabled,
excludeRestaurantsEnabled: searchPreferences.value?.excludeRestaurantsEnabled,
priceRange: [
Number(Object.keys(priceTicks).find((key) => priceTicks[key] == searchPreferences.value?.lowerPriceBound)),
Number(Object.keys(priceTicks).find((key) => priceTicks[key] == searchPreferences.value?.upperPriceBound)),
],
includedTags: searchPreferences.value?.includedTags,
excludedTags: searchPreferences.value?.excludedTags,
includedRestaurants: searchPreferences.value?.includedRestaurants,
excludedRestaurants: searchPreferences.value?.excludedRestaurants,
},
});
const filteredRestaurants = ref<number>(restaurants.value.length);
watch(values, async (newval, oldval) => {
const filters: ((_: Restaurant) => boolean)[] = [];
if (values.excludeRestaurantsEnabled && values.excludedRestaurants) {
filters.push(filterValues.bind(undefined, "id", values.excludedRestaurants, true));
}
watch(
values,
async (newval, oldval) => {
const filters = makeFilters(mapValuesToModel(newval));
filteredRestaurants.value = useRestaurantsStore().getFilteredRestaurants(filters).length;
});
},
{ immediate: true }
);
const [priceRangeEnabled, priceRangeEnabledAttrs] = defineField("priceRangeEnabled");
const [includeTagsEnabled, includeTagsEnabledAttrs] = defineField("includeTagsEnabled");
@ -91,9 +131,17 @@ const emit = defineEmits({
preferencesSaved: () => true,
});
function onSavePreferences() {
const onSubmit = handleSubmit(async (values) => {
try {
const data = mapValuesToModel(values);
await pb.collection("search_preferences").update(searchPreferences.value!.id!, data);
} catch (err) {
console.log(err);
}
emit("preferencesSaved");
}
});
</script>
<template>
@ -123,7 +171,13 @@ function onSavePreferences() {
<input type="checkbox" v-model="includeTagsEnabled" class="checkbox border-solid" />
Include tags
</label>
<TagsField v-model="includedTags" :create="false" :options="tagsNames" :disabled="!includeTagsEnabled" />
<TagsField
v-model="includedTags"
:create="false"
:options="tagsOptions"
:labels="tagsLabels"
:disabled="!includeTagsEnabled"
/>
</div>
</div>
<div class="flex flex-row flex-nowrap w-full">
@ -132,7 +186,13 @@ function onSavePreferences() {
<input type="checkbox" v-model="excludeTagsEnabled" class="checkbox border-solid" />
Exclude tags
</label>
<TagsField v-model="excludedTags" :create="false" :options="tagsNames" :disabled="!excludeTagsEnabled" />
<TagsField
v-model="excludedTags"
:create="false"
:options="tagsOptions"
:labels="tagsLabels"
:disabled="!excludeTagsEnabled"
/>
</div>
</div>
<div class="flex flex-row flex-nowrap w-full">
@ -167,6 +227,6 @@ function onSavePreferences() {
<div class="flex-1"></div>
<div>
{{ filteredRestaurants }} filtered restaurants
<button @click="onSavePreferences" class="btn btn-outline w-full">Save preferences</button>
<button @click="onSubmit" class="btn btn-outline w-full">Save preferences</button>
</div>
</template>

View File

@ -20,7 +20,7 @@ const columns = [
data: "tags",
title: "Tags",
render: {
_: "[, ]",
_: "[, ].name",
},
},
{ data: "price", title: "Price" },

View File

@ -10,10 +10,10 @@ import { createPinia } from "pinia";
import { library } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faBars, faCheck, faUser, faXmark, faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { faBars, faCheck, faUser, faXmark, faExclamationCircle, faGear } from "@fortawesome/free-solid-svg-icons";
import { faGitAlt } from "@fortawesome/free-brands-svg-icons";
library.add(faBars, faUser, faXmark, faCheck, faGitAlt, faExclamationCircle);
library.add(faBars, faUser, faXmark, faCheck, faGitAlt, faExclamationCircle, faGear);
import App from "./App.vue";
import router from "./router";

View File

@ -1,7 +1,9 @@
import Tag from "./tag";
export default interface Restaurant {
id: string;
name: string;
tags: string[];
tags: Tag[];
price: string;
averageRating: number;
latitude: number;

View File

@ -0,0 +1,14 @@
export interface SearchPreferences {
id?: string;
priceRangeEnabled?: boolean;
includeTagsEnabled?: boolean;
excludeTagsEnabled?: boolean;
includeRestaurantsEnabled?: boolean;
excludeRestaurantsEnabled?: boolean;
lowerPriceBound?: string;
upperPriceBound?: string;
includedTags?: string[];
excludedTags?: string[];
includedRestaurants?: string[];
excludedRestaurants?: string[];
}

View File

@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@ -0,0 +1,37 @@
import { defineStore } from "pinia";
import { SearchPreferences } from "../models/search";
import { pb } from "../pocketbase";
export const usePreferencesStore = defineStore("preferences", {
state: () => ({
searchPreferences: undefined as SearchPreferences | undefined,
}),
actions: {
async load() {
if (!pb.authStore.model) {
return;
}
this.searchPreferences = await pb
.collection("search_preferences")
.getFirstListItem(pb.filter("user ~ {:id}", { id: pb.authStore.model.id }));
},
async reset() {
pb.collection("search_preferences").unsubscribe();
await this.load();
pb.collection("search_preferences").subscribe(this.searchPreferences.id, async (e) => {
await this.load();
});
},
async init() {
pb.authStore.onChange(async () => {
await this.reset();
}, true);
},
},
});

View File

@ -12,7 +12,10 @@ export const useRestaurantsStore = defineStore("restaurants", {
}),
getters: {
getRandomRestaurant(state) {
return (filters: ((_: Restaurant) => boolean)[]) => {
return (filters?: ((_: Restaurant) => boolean)[]) => {
if (filters === undefined) {
return _.sample(state.restaurants);
}
const filtered = state.restaurants.filter((restaurant) => {
for (const filter of filters) {
if (!filter(restaurant)) {
@ -25,9 +28,11 @@ export const useRestaurantsStore = defineStore("restaurants", {
};
},
getFilteredRestaurants(state) {
return (filters: ((_: Restaurant) => boolean)[]) => {
return (filters?: ((_: Restaurant) => boolean)[]) => {
if (filters === undefined) {
return state.restaurants;
}
const filtered = state.restaurants.filter((restaurant) => {
console.log(restaurant);
for (const filter of filters) {
if (!filter(restaurant)) {
return false;
@ -56,7 +61,7 @@ export const useRestaurantsStore = defineStore("restaurants", {
}
if (restaurant.expand?.tags !== undefined) {
restaurant.tags = restaurant.expand.tags.map((tag) => tag.name);
restaurant.tags = restaurant.expand.tags;
} else {
restaurant.tags = [];
}

View File

@ -1,8 +1,70 @@
export function filterValues(field: string, values: any[], negative: boolean = false, obj: Object) {
console.log("fielf", field, "values", values, "negative", negative, "obj", obj);
import Restaurant from "../models/restaurant";
import { SearchPreferences } from "../models/search";
function _filterValue(options: any[], value: any, negative: boolean = false) {
if (negative) {
return !values.includes(obj[field]);
return !options.includes(value);
} else {
return values.includes(obj[field]);
return options.includes(value);
}
}
export function filterValues(
field: string,
options: any[],
negative: boolean = false,
subfield: string | undefined = undefined,
obj: Object
) {
if (Array.isArray(obj[field])) {
for (let element of obj[field]) {
if (subfield) {
element = element[subfield];
}
if (negative) {
if (!_filterValue(options, element, negative)) {
return false;
}
} else {
if (_filterValue(options, element, negative)) {
return true;
}
}
}
return negative;
} else {
return _filterValue(options, obj[field], negative);
}
}
export function makeFilters(config: SearchPreferences) {
const filters: ((_: Restaurant) => boolean)[] = [];
if (config.excludeRestaurantsEnabled && config.excludedRestaurants) {
filters.push(filterValues.bind(undefined, "id", config.excludedRestaurants, true, undefined));
}
if (config.includeRestaurantsEnabled && config.includedRestaurants) {
filters.push(filterValues.bind(undefined, "id", config.includedRestaurants, false, undefined));
}
if (config.excludeTagsEnabled && config.excludedTags) {
filters.push(filterValues.bind(undefined, "tags", config.excludedTags, true, "id"));
}
if (config.includeTagsEnabled && config.includedTags) {
filters.push(filterValues.bind(undefined, "tags", config.includedTags, false, "id"));
}
if (config.priceRangeEnabled) {
const prices = ["€", "€€", "€€€", "€€€€"];
const lowerIndex = prices.findIndex((element) => element == config.lowerPriceBound);
const upperIndex = prices.findIndex((element) => element == config.upperPriceBound);
const values = prices.slice(lowerIndex, upperIndex);
filters.push(filterValues.bind(undefined, "price", values, false, undefined));
}
return filters;
}

View File

@ -1,20 +1,39 @@
<script setup lang="ts">
import { ref } from "vue";
import { onMounted, ref, watch } from "vue";
import "@/assets/form.css";
import RandomConfigurationDrawer from "../components/RandomConfigurationDrawer.vue";
import { useRestaurantsStore } from "../stores/restaurants.ts";
import Restaurant from "../models/restaurant.ts";
import { makeFilters } from "../utils/filters.ts";
import { storeToRefs } from "pinia";
import { usePreferencesStore } from "../stores/preferences.ts";
const restaurant = ref<Restaurant>();
const store = useRestaurantsStore();
const { searchPreferences } = storeToRefs(usePreferencesStore());
function getRandomRestaurant() {
restaurant.value = store.getRandomRestaurant();
restaurant.value = store.getRandomRestaurant(filters.value);
console.log(restaurant.value);
}
const filters = ref<((_: Restaurant) => boolean)[]>();
watch(
() => searchPreferences.value,
(newval, oldval) => {
if (newval) {
filters.value = makeFilters(newval);
} else {
filters.value = undefined;
}
},
{ immediate: true }
);
const drawer = ref<typeof RandomConfigurationDrawer>();
function onConfigureClicked() {
@ -27,10 +46,12 @@ function onConfigureClicked() {
<h2 class="mb-10" v-if="restaurant !== undefined">How about going to...</h2>
<p class="">{{ restaurant?.name }}</p>
<button class="btn btn-outline btn-primary mt-10" @click="getRandomRestaurant">Get random restaurant</button>
<button class="btn btn-ghost mt-5" @click="onConfigureClicked">
<font-awesome-icon icon="gear" />
Configure
</button>
</div>
<button class="btn" @click="onConfigureClicked">Configure</button>
<RandomConfigurationDrawer ref="drawer" />
</template>

View File

@ -34,9 +34,8 @@ const onSubmit = handleSubmit(async (values) => {
};
await pb.collection("users").create(data);
await pb
.collection("users")
.authWithPassword(values["username"], values["password"]);
await pb.collection("users").authWithPassword(values["username"], values["password"]);
await pb.collection("search_preferences").create({ user: currentUser.value.id });
} catch (err) {
error.value = err;
}
@ -49,44 +48,25 @@ const [passwordConfirm, passwordConfirmAttrs] = defineField("passwordConfirm");
<template>
<p v-if="currentUser">Signed in as {{ currentUser.username }}</p>
<form
v-else
class="flex flex-col place-content-center items-center h-full"
@submit.prevent="onSubmit"
>
<form v-else class="flex flex-col place-content-center items-center h-full" @submit.prevent="onSubmit">
<div class="flex flex-row flex-nowrap">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Username </label>
<input
class="form-field"
type="text"
v-model="username"
v-bind="usernameAttrs"
/>
<input class="form-field" type="text" v-model="username" v-bind="usernameAttrs" />
<label class="text-red-500">{{ errors.username }}</label>
</div>
</div>
<div class="flex flex-row flex-nowrap">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Password </label>
<input
class="form-field"
type="password"
v-model="password"
v-bind="passwordAttrs"
/>
<input class="form-field" type="password" v-model="password" v-bind="passwordAttrs" />
<label class="text-red-500">{{ errors.password }}</label>
</div>
</div>
<div class="flex flex-row flex-nowrap">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Confirm password </label>
<input
class="form-field"
type="password"
v-model="passwordConfirm"
v-bind="passwordConfirmAttrs"
/>
<input class="form-field" type="password" v-model="passwordConfirm" v-bind="passwordConfirmAttrs" />
<label class="text-red-500">{{ errors.passwordConfirm }}</label>
</div>
</div>