refator: reuse search page component (#2240)

* wip: fix recipe card section

* refactor basic search to share composable

* fix dialog results

* use search for cat/tag/tool pages

* update organizer to support name edits

* fix composable typing
This commit is contained in:
Hayden 2023-03-12 12:59:28 -08:00 committed by GitHub
parent b06517fdf4
commit 9650ba9b00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 205 additions and 538 deletions

View File

@ -61,7 +61,7 @@
v-if="!$vuetify.breakpoint.xsOnly"
:items="[
{
title: $t('general.toggle-view'),
title: $tc('general.toggle-view'),
icon: $globals.icons.eye,
event: 'toggle-dense-view',
},
@ -81,7 +81,6 @@
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
@delete="$emit('delete', recipe.slug)"
/>
</v-lazy>
</v-col>
@ -105,7 +104,6 @@
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
@delete="$emit('delete', recipe.slug)"
/>
</v-lazy>
</v-col>

View File

@ -5,7 +5,7 @@
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs">
<v-text-field
id="arrow-search"
v-model="search"
v-model="search.query.value"
autofocus
solo
flat
@ -35,7 +35,7 @@
</v-card-actions>
<RecipeCardMobile
v-for="(recipe, index) in searchResults"
v-for="(recipe, index) in search.data.value"
:key="index"
:tabindex="index"
class="ma-1 arrow-nav"
@ -55,10 +55,10 @@
<script lang="ts">
import { defineComponent, toRefs, reactive, ref, watch, useRoute } from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/shared";
import RecipeCardMobile from "./RecipeCardMobile.vue";
import { RecipeSummary } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
const SELECTED_EVENT = "selected";
export default defineComponent({
components: {
@ -69,7 +69,6 @@ export default defineComponent({
const state = reactive({
loading: false,
selectedIndex: -1,
searchResults: [] as RecipeSummary[],
});
// ===========================================================================
@ -79,9 +78,9 @@ export default defineComponent({
// Reset or Grab Recipes on Change
watch(dialog, (val) => {
if (!val) {
search.value = "";
search.query.value = "";
state.selectedIndex = -1;
state.searchResults = [];
search.data.value = [];
}
});
@ -142,30 +141,8 @@ export default defineComponent({
// ===========================================================================
// Basic Search
const api = useUserApi();
const search = ref("");
const search = useRecipeSearch(api);
watchDebounced(
search,
async (val) => {
console.log(val);
if (val) {
state.loading = true;
const { data, error } = await api.recipes.search({ search: val, page: 1, perPage: 10 });
if (error || !data) {
console.error(error);
state.searchResults = [];
} else {
state.searchResults = data.items;
}
state.loading = false;
}
},
{ debounce: 500, maxWait: 1000 }
);
// ===========================================================================
// Select Handler
function handleSelect(recipe: RecipeSummary) {
@ -173,7 +150,14 @@ export default defineComponent({
context.emit(SELECTED_EVENT, recipe);
}
return { ...toRefs(state), dialog, open, close, handleSelect, search };
return {
...toRefs(state),
dialog,
open,
close,
handleSelect,
search,
};
},
});
</script>

View File

@ -1,10 +1,10 @@
<template>
<div v-if="items">
<RecipeOrganizerDialog v-model="dialog" :item-type="itemType" />
<RecipeOrganizerDialog v-model="dialogs.organizer" :item-type="itemType" />
<BaseDialog
v-if="deleteTarget"
v-model="deleteDialog"
v-model="dialogs.delete"
:title="$t('general.delete-with-name', { name: deleteTarget.name })"
color="error"
:icon="$globals.icons.alertCircle"
@ -13,38 +13,43 @@
<v-card-text> {{ $t("general.confirm-delete-generic-with-name", { name: deleteTarget.name }) }} </v-card-text>
</BaseDialog>
<BaseDialog v-if="updateTarget" v-model="dialogs.update" :title="$t('general.update')" @confirm="updateOne()">
<v-card-text>
<v-text-field v-model="updateTarget.name" label="Name"> </v-text-field>
</v-card-text>
</BaseDialog>
{{ dialogs.update }}
<v-row dense>
<v-col>
<v-text-field
v-model="searchString"
outlined
autofocus
color="primary accent-3"
:placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search"
clearable
>
</v-text-field>
</v-col>
</v-row>
<v-col>
<v-text-field
v-model="searchString"
outlined
autofocus
color="primary accent-3"
:placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search"
clearable
>
</v-text-field>
</v-col>
</v-row>
<v-app-bar color="transparent" flat class="mt-n1 rounded align-center">
<v-icon large left>
{{ icon }}
</v-icon>
<v-toolbar-title class="headline">
<slot name="title">
{{ headline }}
</slot>
<slot name="title"> </slot>
</v-toolbar-title>
<v-spacer></v-spacer>
<BaseButton create @click="dialog = true" />
<BaseButton create @click="dialogs.organizer = true" />
</v-app-bar>
<section v-for="(itms, key, idx) in showItems" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
<BaseCardSectionTitle v-if="key.length === 1" :title="key"> </BaseCardSectionTitle>
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
<BaseCardSectionTitle v-if="isTitle(key)" :title="key" />
<v-row>
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
<v-card class="left-border" hover :to="`/recipes/${itemType}/${item.slug}`">
<v-card v-if="item" class="left-border" hover :to="`/?${itemType}=${item.id}`">
<v-card-actions>
<v-icon>
{{ icon }}
@ -53,7 +58,11 @@
{{ item.name }}
</v-card-title>
<v-spacer></v-spacer>
<ContextMenu :items="[presets.delete]" @delete="confirmDelete(item)" />
<ContextMenu
:items="[presets.delete, presets.edit]"
@delete="confirmDelete(item)"
@edit="openUpdateDialog(item)"
/>
</v-card-actions>
</v-card>
</v-col>
@ -69,9 +78,10 @@ import { useContextPresets } from "~/composables/use-context-presents";
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
import { RecipeOrganizer } from "~/lib/api/types/non-generated";
import { useRouteQuery } from "~/composables/use-router";
import { deepCopy } from "~/composables/use-utils";
interface GenericItem {
id?: string;
id: string;
name: string;
slug: string;
}
@ -109,14 +119,75 @@ export default defineComponent({
keys: ["name"],
},
});
// =================================================================
// Context Menu
const dialogs = ref({
organizer: false,
update: false,
delete: false,
});
const presets = useContextPresets();
const deleteTarget = ref<GenericItem | null>(null);
const updateTarget = ref<GenericItem | null>(null);
function confirmDelete(item: GenericItem) {
deleteTarget.value = item;
dialogs.value.delete = true;
}
function deleteOne() {
if (!deleteTarget.value) {
return;
}
emit("delete", deleteTarget.value.id);
}
function openUpdateDialog(item: GenericItem) {
updateTarget.value = deepCopy(item);
dialogs.value.update = true;
}
function updateOne() {
if (!updateTarget.value) {
return;
}
emit("update", updateTarget.value);
}
// ================================================================
// Search Functions
const searchString = useRouteQuery("q", "");
const fuse = computed(() => {
return new Fuse(props.items, state.options);
});
const fuzzyItems = computed<GenericItem[]>(() => {
if (searchString.value.trim() === "") {
return props.items;
}
const result = fuse.value.search(searchString.value.trim() as string);
return result.map((x) => x.item);
});
// =================================================================
// Sorted Items
const itemsSorted = computed(() => {
const byLetter: { [key: string]: Array<GenericItem> } = {};
if (!props.items) return byLetter;
if (!fuzzyItems.value) {
return byLetter;
}
props.items
fuzzyItems.value
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((item) => {
const letter = item.name[0].toUpperCase();
@ -129,63 +200,22 @@ export default defineComponent({
return byLetter;
});
// =================================================================
// Context Menu
const presets = useContextPresets();
const deleteTarget = ref<GenericItem | null>(null);
const deleteDialog = ref(false);
function confirmDelete(item: GenericItem) {
deleteTarget.value = item;
deleteDialog.value = true;
function isTitle(str: number | string) {
return typeof str === "string" && str.length === 1;
}
function deleteOne() {
if (!deleteTarget.value) {
return;
}
emit("delete", deleteTarget.value.id);
}
const dialog = ref(false);
// ================================================================
// Search Functions
const searchString = useRouteQuery("q", "");
const fuse = computed(() => {
return new Fuse(props.items, state.options);
});
const fuzzyItems = computed(() => {
if (searchString.value.trim() === "") {
return props.items;
}
const result = fuse.value.search(searchString.value.trim() as string);
return result.map((x) => x.item);
});
const showItems = computed(() => {
if (searchString.value.trim() === "") {
return itemsSorted.value;
} else {
return fuzzyItems;
}
});
return {
dialog,
isTitle,
dialogs,
confirmDelete,
openUpdateDialog,
updateOne,
updateTarget,
deleteOne,
deleteDialog,
deleteTarget,
presets,
itemsSorted,
searchString,
showItems,
};
},
// Needed for useMeta

View File

@ -0,0 +1,64 @@
import { Ref, ref } from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/core";
import { UserApi } from "~/lib/api";
import { Recipe } from "~/lib/api/types/recipe";
export interface UseRecipeSearchReturn {
query: Ref<string>;
error: Ref<string>;
loading: Ref<boolean>;
data: Ref<Recipe[]>;
}
/**
* `useRecipeSearch` constructs a basic reactive search query
* that when `query` is changed, will search for recipes based
* on the query. Useful for searchable list views. For advanced
* search, use the `useRecipeQuery` composable.
*/
export function useRecipeSearch(api: UserApi): UseRecipeSearchReturn {
const query = ref("");
const error = ref("");
const loading = ref(false);
const recipes = ref<Recipe[]>([]);
async function searchRecipes(term: string) {
loading.value = true;
const { data, error } = await api.recipes.search({
search: term,
page: 1,
orderBy: "name",
orderDirection: "asc",
perPage: 20,
});
if (error) {
console.error(error);
loading.value = false;
recipes.value = [];
return;
}
if (data) {
recipes.value= data.items;
}
loading.value = false;
}
watchDebounced(
() => query.value,
async (term: string) => {
await searchRecipes(term);
},
{ debounce: 500 }
);
return {
query,
error,
loading,
data: recipes,
}
}

View File

@ -7,7 +7,13 @@ export interface ContextMenuItem {
color?: string;
}
export function useContextPresets(): { [key: string]: ContextMenuItem } {
export interface ContextMenuPresets {
delete: ContextMenuItem;
edit: ContextMenuItem;
save: ContextMenuItem;
}
export function useContextPresets(): ContextMenuPresets {
const { $globals, i18n } = useContext();
return {

View File

@ -50,9 +50,9 @@
v-if="!dialog.note"
v-model="newMeal.recipeId"
:label="$t('meal-plan.meal-recipe')"
:items="recipes.data"
:loading="recipes.loading"
:search-input.sync="recipes.search"
:items="search.data.value"
:loading="search.loading.value"
:search-input.sync="search.query.value"
cache-items
item-text="name"
item-value="id"
@ -218,6 +218,7 @@ import { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useUserApi } from "~/composables/api";
import { useGroupSelf } from "~/composables/use-groups";
import { RecipeSummary } from "~/lib/api/types/recipe";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
export default defineComponent({
components: {
@ -330,44 +331,7 @@ export default defineComponent({
// =====================================================
// Search
const recipes = ref({
search: "",
loading: false,
error: false,
data: [] as RecipeSummary[],
});
async function searchRecipes(term: string) {
recipes.value.loading = true;
const { data, error } = await api.recipes.search({
search: term,
page: 1,
orderBy: "name",
orderDirection: "asc",
perPage: 20,
});
if (error) {
console.error(error);
recipes.value.loading = false;
recipes.value.data = [];
return;
}
if (data) {
recipes.value.data = data.items;
}
recipes.value.loading = false;
}
watchDebounced(
() => recipes.value.search,
async (term: string) => {
await searchRecipes(term);
},
{ debounce: 500 }
);
const search = useRecipeSearch(api);
return {
state,
@ -382,7 +346,7 @@ export default defineComponent({
randomMeal,
// Search
recipes,
search,
firstDayOfWeek,
};
},

View File

@ -124,11 +124,9 @@
:title="$tc('search.results')"
:recipes="recipes"
:query="passedQuery"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
></RecipeCardSection>
/>
</v-container>
</v-container>
</template>

View File

@ -1,127 +0,0 @@
<template>
<v-container>
<RecipeCardSection
v-if="category"
:icon="$globals.icons.tags"
:title="category.name"
:recipes="recipes"
:query="{ categories: [category.slug] }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
>
<template #title>
<v-btn icon class="mr-1">
<v-icon dark large @click="reset">
{{ $globals.icons.tags }}
</v-icon>
</v-btn>
<template v-if="edit">
<v-text-field
v-model="category.name"
autofocus
single-line
dense
hide-details
class="headline"
@keyup.enter="updateCategory"
>
</v-text-field>
<v-btn icon @click="updateCategory">
<v-icon size="28">
{{ $globals.icons.save }}
</v-icon>
</v-btn>
<v-btn icon class="mr-1" @click="reset">
<v-icon size="28">
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</template>
<template v-else>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true">
{{ category.name }}
</v-toolbar-title>
</template>
<span> Click to Edit </span>
</v-tooltip>
</template>
</template>
</RecipeCardSection>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useUserApi } from "~/composables/api";
export default defineComponent({
components: { RecipeCardSection },
setup() {
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const state = reactive({
initialValue: "",
edit: false,
});
const category = useAsync(async () => {
const { data } = await api.categories.bySlug(slug);
if (data) {
state.initialValue = data.name;
}
return data;
}, slug);
function reset() {
state.edit = false;
if (category.value) {
category.value.name = state.initialValue;
}
}
async function updateCategory() {
state.edit = false;
if (!category.value) {
return;
}
const { data } = await api.categories.updateOne(category.value.id, category.value);
if (data) {
router.push("/recipes/categories/" + data.slug);
}
}
return {
category,
reset,
...toRefs(state),
updateCategory,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
};
},
head() {
return {
title: this.$t("category.categories") as string,
};
},
});
</script>

View File

@ -6,6 +6,7 @@
:icon="$globals.icons.tags"
item-type="categories"
@delete="actions.deleteOne"
@update="actions.updateOne"
>
<template #title> {{ $tc("category.categories") }} </template>
</RecipeOrganizerPage>

View File

@ -1,127 +0,0 @@
<template>
<v-container>
<RecipeCardSection
v-if="tag"
:icon="$globals.icons.tags"
:title="tag.name"
:recipes="recipes"
:query="{ tags: [tag.slug] }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
>
<template #title>
<v-btn icon class="mr-1">
<v-icon dark large @click="reset">
{{ $globals.icons.tags }}
</v-icon>
</v-btn>
<template v-if="edit">
<v-text-field
v-model="tag.name"
autofocus
single-line
dense
hide-details
class="headline"
@keyup.enter="updateTags"
>
</v-text-field>
<v-btn icon @click="updateTags">
<v-icon size="28">
{{ $globals.icons.save }}
</v-icon>
</v-btn>
<v-btn icon class="mr-1" @click="reset">
<v-icon size="28">
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</template>
<template v-else>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true">
{{ tag.name }}
</v-toolbar-title>
</template>
<span> Click to Edit </span>
</v-tooltip>
</template>
</template>
</RecipeCardSection>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useUserApi } from "~/composables/api";
export default defineComponent({
components: { RecipeCardSection },
setup() {
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const state = reactive({
initialValue: "",
edit: false,
});
const tag = useAsync(async () => {
const { data } = await api.tags.bySlug(slug);
if (data) {
state.initialValue = data.name;
}
return data;
}, slug);
function reset() {
state.edit = false;
if (tag.value) {
tag.value.name = state.initialValue;
}
}
async function updateTags() {
state.edit = false;
if (!tag.value) {
return;
}
const { data } = await api.tags.updateOne(tag.value.id, tag.value);
if (data) {
router.push("/recipes/tags/" + data.slug);
}
}
return {
tag,
reset,
...toRefs(state),
updateTags,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
};
},
head() {
return {
title: this.$t("tag.tags") as string,
};
},
});
</script>

View File

@ -6,8 +6,9 @@
:icon="$globals.icons.tags"
item-type="tags"
@delete="actions.deleteOne"
@update="actions.updateOne"
>
<template #title> {{ $t('tag.tags') }} </template>
<template #title> {{ $t("tag.tags") }} </template>
</RecipeOrganizerPage>
</v-container>
</template>
@ -32,7 +33,7 @@ export default defineComponent({
head() {
return {
title: this.$tc("tag.tags"),
}
};
},
});
</script>

View File

@ -1,127 +0,0 @@
<template>
<v-container>
<RecipeCardSection
v-if="tool"
:icon="$globals.icons.potSteam"
:title="tool.name"
:recipes="recipes"
:query="{ tools: [tool.slug] }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
>
<template #title>
<v-btn icon class="mr-1">
<v-icon dark large @click="reset">
{{ $globals.icons.potSteam }}
</v-icon>
</v-btn>
<template v-if="edit">
<v-text-field
v-model="tool.name"
autofocus
single-line
dense
hide-details
class="headline"
@keyup.enter="updateTools"
>
</v-text-field>
<v-btn icon @click="updateTools">
<v-icon size="28">
{{ $globals.icons.save }}
</v-icon>
</v-btn>
<v-btn icon class="mr-1" @click="reset">
<v-icon size="28">
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</template>
<template v-else>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true">
{{ tool.name }}
</v-toolbar-title>
</template>
<span> Click to Edit </span>
</v-tooltip>
</template>
</template>
</RecipeCardSection>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useUserApi } from "~/composables/api";
export default defineComponent({
components: { RecipeCardSection },
setup() {
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const state = reactive({
initialValue: "",
edit: false,
});
const tool = useAsync(async () => {
const { data } = await api.tools.bySlug(slug);
if (data) {
state.initialValue = data.name;
}
return data;
}, slug);
function reset() {
state.edit = false;
if (tool.value) {
tool.value.name = state.initialValue;
}
}
async function updateTools() {
state.edit = false;
if (!tool.value) {
return;
}
const { data } = await api.tools.updateOne(tool.value.id, tool.value);
if (data) {
router.push("/recipes/tools/" + data.slug);
}
}
return {
tool,
reset,
...toRefs(state),
updateTools,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
};
},
head() {
return {
title: this.$t("tool.tools") as string,
};
},
});
</script>

View File

@ -6,8 +6,9 @@
:items="tools"
item-type="tools"
@delete="actions.deleteOne"
@update="actions.updateOne"
>
<template #title> {{ $t('tool.tools') }} </template>
<template #title> {{ $t("tool.tools") }} </template>
</RecipeOrganizerPage>
</v-container>
</template>

View File

@ -342,6 +342,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
count_query = select(func.count()).select_from(query)
count = self.session.scalar(count_query)
if not count:
count = 0
# interpret -1 as "get_all"
if pagination.per_page == -1:
@ -349,7 +351,6 @@ class RepositoryGeneric(Generic[Schema, Model]):
try:
total_pages = ceil(count / pagination.per_page)
except ZeroDivisionError:
total_pages = 0