Implemented search filters for random restaurant
parent
1ebbe3579e
commit
b05fb94547
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const columns = [
|
|||
data: "tags",
|
||||
title: "Tags",
|
||||
render: {
|
||||
_: "[, ]",
|
||||
_: "[, ].name",
|
||||
},
|
||||
},
|
||||
{ data: "price", title: "Price" },
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue