feat: Further improve recipe filter search and shopping list and recipe ingredient editor (#7063)

This commit is contained in:
Michael Genson
2026-02-14 00:34:17 -06:00
committed by GitHub
parent 8e225ee796
commit 73d86f6f6b
16 changed files with 267 additions and 160 deletions

View File

@@ -58,8 +58,8 @@
density="compact"
variant="solo"
return-object
:items="units || []"
:custom-filter="normalizeFilter"
:items="filteredUnits"
:custom-filter="() => true"
item-title="name"
class="mx-1"
:placeholder="$t('recipe.choose-unit')"
@@ -117,8 +117,8 @@
density="compact"
variant="solo"
return-object
:items="foods || []"
:custom-filter="normalizeFilter"
:items="filteredFoods"
:custom-filter="() => true"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')"
@@ -176,7 +176,6 @@
variant="solo"
return-object
:items="search.data.value || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('search.type-to-search')"
@@ -227,11 +226,11 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, toRefs } from "vue";
import { ref, computed, reactive, toRefs, watch } from "vue";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { normalizeFilter } from "~/composables/use-utils";
import { useSearch } from "~/composables/use-search";
import { useNuxtApp } from "#app";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
@@ -343,8 +342,8 @@ const btns = computed(() => {
// Foods
const foodStore = useFoodStore();
const foodData = useFoodData();
const foodSearch = ref("");
const foodAutocomplete = ref<HTMLInputElement>();
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
async function createAssignFood() {
foodData.data.name = foodSearch.value;
@@ -375,8 +374,8 @@ watch(loading, (val) => {
// Units
const unitStore = useUnitStore();
const unitsData = useUnitData();
const unitSearch = ref("");
const unitAutocomplete = ref<HTMLInputElement>();
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
async function createAssignUnit() {
unitsData.data.name = unitSearch.value;
@@ -430,9 +429,6 @@ function quantityFilter(e: KeyboardEvent) {
}
const { showTitle } = toRefs(state);
const foods = foodStore.store;
const units = unitStore.store;
</script>
<style>

View File

@@ -12,7 +12,7 @@
<script setup lang="ts">
import { computed } from "vue";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { useParsedIngredientText } from "~/composables/recipes";
import { useIngredientTextParser } from "~/composables/recipes";
interface Props {
ingredient?: RecipeIngredient;
@@ -20,6 +20,7 @@ interface Props {
}
const { ingredient, scale = 1 } = defineProps<Props>();
const { useParsedIngredientText } = useIngredientTextParser();
const baseText = computed(() => {
if (!ingredient) return "";

View File

@@ -34,7 +34,7 @@
<script setup lang="ts">
import type { RecipeIngredient } from "~/lib/api/types/household";
import { useParsedIngredientText } from "~/composables/recipes";
import { useIngredientTextParser } from "~/composables/recipes";
interface Props {
ingredient: RecipeIngredient;
@@ -46,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
const route = useRoute();
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const { useParsedIngredientText } = useIngredientTextParser();
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());

View File

@@ -52,7 +52,7 @@
<script setup lang="ts">
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { parseIngredientText } from "~/composables/recipes";
import { useIngredientTextParser } from "~/composables/recipes";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
interface Props {
@@ -66,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
isCookMode: false,
});
const { parseIngredientText } = useIngredientTextParser();
function validateTitle(title?: string | null) {
return !(title === undefined || title === "" || title === null);
}

View File

@@ -431,6 +431,7 @@ const props = defineProps({
const emit = defineEmits(["click-instruction-field", "update:assets"]);
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
const { extractIngredientReferences } = useExtractIngredientReferences();
const dialog = ref(false);
const disabledSteps = ref<number[]>([]);
@@ -581,7 +582,7 @@ function setUsedIngredients() {
watch(activeRefs, () => setUsedIngredients());
function autoSetReferences() {
useExtractIngredientReferences(
extractIngredientReferences(
props.recipe.recipeIngredient,
activeRefs.value,
activeText.value,

View File

@@ -197,7 +197,7 @@ import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient
import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useUserApi } from "~/composables/api";
import { parseIngredientText } from "~/composables/recipes";
import { useIngredientTextParser } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { alert } from "~/composables/use-toast";
@@ -208,6 +208,8 @@ const props = defineProps<{
ingredients: NoUndefinedField<RecipeIngredient[]>;
}>();
const { parseIngredientText } = useIngredientTextParser();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;

View File

@@ -192,7 +192,7 @@ import { useStaticRoutes } from "~/composables/api";
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
import { useIngredientTextParser, useNutritionLabels } from "~/composables/recipes";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
@@ -362,6 +362,8 @@ const hasNotes = computed(() => {
return props.recipe.notes && props.recipe.notes.length > 0;
});
const { parseIngredientText } = useIngredientTextParser();
function parseText(ingredient: RecipeIngredient) {
return parseIngredientText(ingredient, props.scale);
}

View File

@@ -28,8 +28,8 @@
<v-card width="400">
<v-card-text>
<v-text-field
v-model="state.search"
v-memo="[state.search]"
v-model="searchInput"
v-memo="[searchInput]"
class="mb-2"
hide-details
density="comfortable"
@@ -146,19 +146,13 @@
</template>
<script lang="ts">
import { watchDebounced } from "@vueuse/core";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
export interface SelectableItem {
id: string;
name: string;
}
import type { ISearchableItem } from "~/composables/use-search";
import { useSearch } from "~/composables/use-search";
export default defineNuxtComponent({
props: {
items: {
type: Array as () => SelectableItem[],
type: Array as () => ISearchableItem[],
required: true,
},
modelValue: {
@@ -177,21 +171,11 @@ export default defineNuxtComponent({
emits: ["update:requireAll", "update:modelValue"],
setup(props, context) {
const state = reactive({
search: "",
menu: false,
});
// Use shallowRef for better performance with arrays
const debouncedSearch = shallowRef("");
const fuseOptions: IFuseOptions<SelectableItem> = {
keys: ["name"],
ignoreLocation: true,
shouldSort: true,
threshold: 0.3,
minMatchCharLength: 1,
findAllMatches: false,
};
// Use the search composable
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
const combinator = computed({
get: () => (props.requireAll ? "hasAll" : "hasAny"),
@@ -202,7 +186,7 @@ export default defineNuxtComponent({
// Use shallowRef to prevent deep reactivity on large arrays
const selected = computed({
get: () => props.modelValue as SelectableItem[],
get: () => props.modelValue as ISearchableItem[],
set: (value) => {
context.emit("update:modelValue", value);
},
@@ -215,53 +199,12 @@ export default defineNuxtComponent({
},
});
watchDebounced(
() => state.search,
(newSearch) => {
debouncedSearch.value = newSearch;
},
{ debounce: 500, maxWait: 1500, immediate: false },
);
const fuse = computed(() => {
return new Fuse(props.items || [], fuseOptions);
});
const filtered = computed(() => {
const items = props.items;
const search = debouncedSearch.value.trim();
// If no search query or less than 2 characters, return all items
if (!search || search.length < 2) {
return items;
}
if (!items || items.length === 0) {
return [];
}
const results = fuse.value.search(search);
return results.map(result => result.item);
});
const selectedCount = computed(() => selected.value.length);
const selectedIds = computed(() => {
return new Set(selected.value.map(item => item.id));
});
const handleCheckboxClick = (item: SelectableItem) => {
const currentSelection = selected.value;
const isSelected = selectedIds.value.has(item.id);
if (isSelected) {
selected.value = currentSelection.filter(i => i.id !== item.id);
}
else {
selected.value = [...currentSelection, item];
}
};
const handleRadioClick = (item: SelectableItem) => {
const handleRadioClick = (item: ISearchableItem) => {
if (selectedRadio.value === item) {
selectedRadio.value = null;
}
@@ -270,18 +213,18 @@ export default defineNuxtComponent({
function clearSelection() {
selected.value = [];
selectedRadio.value = null;
state.search = "";
searchInput.value = "";
}
return {
combinator,
state,
searchInput,
selected,
selectedRadio,
selectedCount,
selectedIds,
filtered,
handleCheckboxClick,
handleRadioClick,
clearSelection,
};

View File

@@ -6,13 +6,13 @@
v-model:search="searchInput"
item-title="name"
return-object
:items="items"
:custom-filter="normalizeFilter"
:items="filteredItems"
:prepend-icon="icon || $globals.icons.tags"
auto-select-first
clearable
color="primary"
hide-details
:custom-filter="() => true"
@keyup.enter="emitCreate"
>
<template
@@ -53,7 +53,7 @@
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { normalizeFilter } from "~/composables/use-utils";
import { useSearch } from "~/composables/use-search";
export default defineNuxtComponent({
props: {
@@ -85,7 +85,10 @@ export default defineNuxtComponent({
emits: ["update:modelValue", "update:item-id", "create"],
setup(props, context) {
const autocompleteRef = ref<HTMLInputElement>();
const searchInput = ref("");
// Use the search composable
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
const itemIdVal = computed({
get: () => {
return props.itemId || undefined;
@@ -123,8 +126,8 @@ export default defineNuxtComponent({
itemVal,
itemIdVal,
searchInput,
filteredItems,
emitCreate,
normalizeFilter,
};
},
});