mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-15 20:33:12 -05:00
feat: Further improve recipe filter search and shopping list and recipe ingredient editor (#7063)
This commit is contained in:
@@ -58,8 +58,8 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="units || []"
|
:items="filteredUnits"
|
||||||
:custom-filter="normalizeFilter"
|
:custom-filter="() => true"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
:placeholder="$t('recipe.choose-unit')"
|
:placeholder="$t('recipe.choose-unit')"
|
||||||
@@ -117,8 +117,8 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="foods || []"
|
:items="filteredFoods"
|
||||||
:custom-filter="normalizeFilter"
|
:custom-filter="() => true"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('recipe.choose-food')"
|
:placeholder="$t('recipe.choose-food')"
|
||||||
@@ -176,7 +176,6 @@
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="search.data.value || []"
|
:items="search.data.value || []"
|
||||||
:custom-filter="normalizeFilter"
|
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('search.type-to-search')"
|
:placeholder="$t('search.type-to-search')"
|
||||||
@@ -227,11 +226,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive, toRefs } from "vue";
|
import { ref, computed, reactive, toRefs, watch } from "vue";
|
||||||
import { useDisplay } from "vuetify";
|
import { useDisplay } from "vuetify";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
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 { useNuxtApp } from "#app";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||||
@@ -343,8 +342,8 @@ const btns = computed(() => {
|
|||||||
// Foods
|
// Foods
|
||||||
const foodStore = useFoodStore();
|
const foodStore = useFoodStore();
|
||||||
const foodData = useFoodData();
|
const foodData = useFoodData();
|
||||||
const foodSearch = ref("");
|
|
||||||
const foodAutocomplete = ref<HTMLInputElement>();
|
const foodAutocomplete = ref<HTMLInputElement>();
|
||||||
|
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
|
||||||
|
|
||||||
async function createAssignFood() {
|
async function createAssignFood() {
|
||||||
foodData.data.name = foodSearch.value;
|
foodData.data.name = foodSearch.value;
|
||||||
@@ -375,8 +374,8 @@ watch(loading, (val) => {
|
|||||||
// Units
|
// Units
|
||||||
const unitStore = useUnitStore();
|
const unitStore = useUnitStore();
|
||||||
const unitsData = useUnitData();
|
const unitsData = useUnitData();
|
||||||
const unitSearch = ref("");
|
|
||||||
const unitAutocomplete = ref<HTMLInputElement>();
|
const unitAutocomplete = ref<HTMLInputElement>();
|
||||||
|
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
|
||||||
|
|
||||||
async function createAssignUnit() {
|
async function createAssignUnit() {
|
||||||
unitsData.data.name = unitSearch.value;
|
unitsData.data.name = unitSearch.value;
|
||||||
@@ -430,9 +429,6 @@ function quantityFilter(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { showTitle } = toRefs(state);
|
const { showTitle } = toRefs(state);
|
||||||
|
|
||||||
const foods = foodStore.store;
|
|
||||||
const units = unitStore.store;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { useParsedIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ingredient?: RecipeIngredient;
|
ingredient?: RecipeIngredient;
|
||||||
@@ -20,6 +20,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||||
|
const { useParsedIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const baseText = computed(() => {
|
const baseText = computed(() => {
|
||||||
if (!ingredient) return "";
|
if (!ingredient) return "";
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||||
import { useParsedIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ingredient: RecipeIngredient;
|
ingredient: RecipeIngredient;
|
||||||
@@ -46,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const $auth = useMealieAuth();
|
const $auth = useMealieAuth();
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||||
|
const { useParsedIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const parsedIng = computed(() => {
|
const parsedIng = computed(() => {
|
||||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -66,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
isCookMode: false,
|
isCookMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
function validateTitle(title?: string | null) {
|
function validateTitle(title?: string | null) {
|
||||||
return !(title === undefined || title === "" || title === null);
|
return !(title === undefined || title === "" || title === null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(["click-instruction-field", "update:assets"]);
|
const emit = defineEmits(["click-instruction-field", "update:assets"]);
|
||||||
|
|
||||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||||
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
|
||||||
const dialog = ref(false);
|
const dialog = ref(false);
|
||||||
const disabledSteps = ref<number[]>([]);
|
const disabledSteps = ref<number[]>([]);
|
||||||
@@ -581,7 +582,7 @@ function setUsedIngredients() {
|
|||||||
watch(activeRefs, () => setUsedIngredients());
|
watch(activeRefs, () => setUsedIngredients());
|
||||||
|
|
||||||
function autoSetReferences() {
|
function autoSetReferences() {
|
||||||
useExtractIngredientReferences(
|
extractIngredientReferences(
|
||||||
props.recipe.recipeIngredient,
|
props.recipe.recipeIngredient,
|
||||||
activeRefs.value,
|
activeRefs.value,
|
||||||
activeText.value,
|
activeText.value,
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient
|
|||||||
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
@@ -208,6 +208,8 @@ const props = defineProps<{
|
|||||||
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:modelValue", value: boolean): void;
|
(e: "update:modelValue", value: boolean): void;
|
||||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ import { useStaticRoutes } from "~/composables/api";
|
|||||||
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
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 { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||||
|
|
||||||
@@ -362,6 +362,8 @@ const hasNotes = computed(() => {
|
|||||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
function parseText(ingredient: RecipeIngredient) {
|
function parseText(ingredient: RecipeIngredient) {
|
||||||
return parseIngredientText(ingredient, props.scale);
|
return parseIngredientText(ingredient, props.scale);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
<v-card width="400">
|
<v-card width="400">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="state.search"
|
v-model="searchInput"
|
||||||
v-memo="[state.search]"
|
v-memo="[searchInput]"
|
||||||
class="mb-2"
|
class="mb-2"
|
||||||
hide-details
|
hide-details
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
@@ -146,19 +146,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { watchDebounced } from "@vueuse/core";
|
import type { ISearchableItem } from "~/composables/use-search";
|
||||||
import type { IFuseOptions } from "fuse.js";
|
import { useSearch } from "~/composables/use-search";
|
||||||
import Fuse from "fuse.js";
|
|
||||||
|
|
||||||
export interface SelectableItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array as () => SelectableItem[],
|
type: Array as () => ISearchableItem[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -177,21 +171,11 @@ export default defineNuxtComponent({
|
|||||||
emits: ["update:requireAll", "update:modelValue"],
|
emits: ["update:requireAll", "update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
search: "",
|
|
||||||
menu: false,
|
menu: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use shallowRef for better performance with arrays
|
// Use the search composable
|
||||||
const debouncedSearch = shallowRef("");
|
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
|
||||||
|
|
||||||
const fuseOptions: IFuseOptions<SelectableItem> = {
|
|
||||||
keys: ["name"],
|
|
||||||
ignoreLocation: true,
|
|
||||||
shouldSort: true,
|
|
||||||
threshold: 0.3,
|
|
||||||
minMatchCharLength: 1,
|
|
||||||
findAllMatches: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const combinator = computed({
|
const combinator = computed({
|
||||||
get: () => (props.requireAll ? "hasAll" : "hasAny"),
|
get: () => (props.requireAll ? "hasAll" : "hasAny"),
|
||||||
@@ -202,7 +186,7 @@ export default defineNuxtComponent({
|
|||||||
|
|
||||||
// Use shallowRef to prevent deep reactivity on large arrays
|
// Use shallowRef to prevent deep reactivity on large arrays
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
get: () => props.modelValue as SelectableItem[],
|
get: () => props.modelValue as ISearchableItem[],
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
context.emit("update:modelValue", 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 selectedCount = computed(() => selected.value.length);
|
||||||
const selectedIds = computed(() => {
|
const selectedIds = computed(() => {
|
||||||
return new Set(selected.value.map(item => item.id));
|
return new Set(selected.value.map(item => item.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCheckboxClick = (item: SelectableItem) => {
|
const handleRadioClick = (item: ISearchableItem) => {
|
||||||
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) => {
|
|
||||||
if (selectedRadio.value === item) {
|
if (selectedRadio.value === item) {
|
||||||
selectedRadio.value = null;
|
selectedRadio.value = null;
|
||||||
}
|
}
|
||||||
@@ -270,18 +213,18 @@ export default defineNuxtComponent({
|
|||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selected.value = [];
|
selected.value = [];
|
||||||
selectedRadio.value = null;
|
selectedRadio.value = null;
|
||||||
state.search = "";
|
searchInput.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
combinator,
|
combinator,
|
||||||
state,
|
state,
|
||||||
|
searchInput,
|
||||||
selected,
|
selected,
|
||||||
selectedRadio,
|
selectedRadio,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
filtered,
|
filtered,
|
||||||
handleCheckboxClick,
|
|
||||||
handleRadioClick,
|
handleRadioClick,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
v-model:search="searchInput"
|
v-model:search="searchInput"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
return-object
|
return-object
|
||||||
:items="items"
|
:items="filteredItems"
|
||||||
:custom-filter="normalizeFilter"
|
|
||||||
:prepend-icon="icon || $globals.icons.tags"
|
:prepend-icon="icon || $globals.icons.tags"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
clearable
|
clearable
|
||||||
color="primary"
|
color="primary"
|
||||||
hide-details
|
hide-details
|
||||||
|
:custom-filter="() => true"
|
||||||
@keyup.enter="emitCreate"
|
@keyup.enter="emitCreate"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||||
import { normalizeFilter } from "~/composables/use-utils";
|
import { useSearch } from "~/composables/use-search";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
@@ -85,7 +85,10 @@ export default defineNuxtComponent({
|
|||||||
emits: ["update:modelValue", "update:item-id", "create"],
|
emits: ["update:modelValue", "update:item-id", "create"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const autocompleteRef = ref<HTMLInputElement>();
|
const autocompleteRef = ref<HTMLInputElement>();
|
||||||
const searchInput = ref("");
|
|
||||||
|
// Use the search composable
|
||||||
|
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
|
||||||
|
|
||||||
const itemIdVal = computed({
|
const itemIdVal = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.itemId || undefined;
|
return props.itemId || undefined;
|
||||||
@@ -123,8 +126,8 @@ export default defineNuxtComponent({
|
|||||||
itemVal,
|
itemVal,
|
||||||
itemIdVal,
|
itemIdVal,
|
||||||
searchInput,
|
searchInput,
|
||||||
|
filteredItems,
|
||||||
emitCreate,
|
emitCreate,
|
||||||
normalizeFilter,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,56 +16,67 @@ describe("test use extract ingredient references", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("when text empty return empty", () => {
|
test("when text empty return empty", () => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "");
|
||||||
expect(result).toStrictEqual(new Set());
|
expect(result).toStrictEqual(new Set());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => {
|
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion");
|
||||||
|
|
||||||
expect(result).toEqual(new Set(["123"]));
|
expect(result).toEqual(new Set(["123"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => {
|
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix);
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix);
|
||||||
|
|
||||||
expect(result).toEqual(new Set(["123"]));
|
expect(result).toEqual(new Set(["123"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => {
|
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion");
|
||||||
expect(result).toEqual(new Set(["123"]));
|
expect(result).toEqual(new Set(["123"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when ingredient is first on a multiline, return the referenceId", () => {
|
test("when ingredient is first on a multiline, return the referenceId", () => {
|
||||||
const multilineSting = "lksjdlk\nOnion";
|
const multilineSting = "lksjdlk\nOnion";
|
||||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting);
|
|
||||||
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting);
|
||||||
expect(result).toEqual(new Set(["123"]));
|
expect(result).toEqual(new Set(["123"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => {
|
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions");
|
||||||
expect(result).toEqual(new Set(["123"]));
|
expect(result).toEqual(new Set(["123"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => {
|
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions");
|
||||||
expect(result).toEqual(new Set(["123"]));
|
expect(result).toEqual(new Set(["123"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when no ingredients, return empty", () => {
|
test("when no ingredients, return empty", () => {
|
||||||
const result = useExtractIngredientReferences([], [], "A sentence containing oNions");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([], [], "A sentence containing oNions");
|
||||||
expect(result).toEqual(new Set());
|
expect(result).toEqual(new Set());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => {
|
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion");
|
||||||
|
|
||||||
expect(result).toEqual(new Set());
|
expect(result).toEqual(new Set());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when an word is 2 letter of shorter, it is ignored", () => {
|
test("when an word is 2 letter of shorter, it is ignored", () => {
|
||||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On");
|
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||||
|
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On");
|
||||||
|
|
||||||
expect(result).toEqual(new Set());
|
expect(result).toEqual(new Set());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
|
|
||||||
function normalize(word: string): string {
|
function normalize(word: string): string {
|
||||||
let normalizing = word;
|
let normalizing = word;
|
||||||
@@ -18,11 +18,6 @@ function removeStartingPunctuation(word: string): string {
|
|||||||
return word.replace(punctuationAtBeginning, "");
|
return word.replace(punctuationAtBeginning, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
|
|
||||||
const searchText = parseIngredientText(ingredient);
|
|
||||||
return searchText.toLowerCase().includes(word.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBlackListedWord(word: string) {
|
function isBlackListedWord(word: string) {
|
||||||
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
||||||
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
|
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
|
||||||
@@ -39,7 +34,15 @@ function isBlackListedWord(word: string) {
|
|||||||
return blackListedText.includes(word) || word.match(blackListedRegexMatch);
|
return blackListedText.includes(word) || word.match(blackListedRegexMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExtractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
|
export function useExtractIngredientReferences() {
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
|
function extractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
|
||||||
|
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
|
||||||
|
const searchText = parseIngredientText(ingredient);
|
||||||
|
return searchText.toLowerCase().includes(word.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
const availableIngredients = recipeIngredients
|
const availableIngredients = recipeIngredients
|
||||||
.filter(ingredient => ingredient.referenceId !== undefined)
|
.filter(ingredient => ingredient.referenceId !== undefined)
|
||||||
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
|
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
|
||||||
@@ -56,3 +59,8 @@ export function useExtractIngredientReferences(recipeIngredients: RecipeIngredie
|
|||||||
|
|
||||||
return new Set<string>(allMatchedIngredientIds);
|
return new Set<string>(allMatchedIngredientIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
extractIngredientReferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export { useFraction } from "./use-fraction";
|
export { useFraction } from "./use-fraction";
|
||||||
export { useRecipe } from "./use-recipe";
|
export { useRecipe } from "./use-recipe";
|
||||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
||||||
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
|
export { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||||
export { useNutritionLabels } from "./use-recipe-nutrition";
|
export { useNutritionLabels } from "./use-recipe-nutrition";
|
||||||
export { useTools } from "./use-recipe-tools";
|
export { useTools } from "./use-recipe-tools";
|
||||||
export { useRecipePermissions } from "./use-recipe-permissions";
|
export { useRecipePermissions } from "./use-recipe-permissions";
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
import { parseIngredientText } from "./use-recipe-ingredients";
|
import { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { useLocales } from "../use-locales";
|
import { useLocales } from "../use-locales";
|
||||||
|
|
||||||
vi.mock("../use-locales");
|
vi.mock("../use-locales");
|
||||||
|
|
||||||
describe(parseIngredientText.name, () => {
|
let parseIngredientText: (ingredient: RecipeIngredient, scale?: number, includeFormating?: boolean) => string;
|
||||||
|
|
||||||
|
describe("parseIngredientText", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(useLocales).mockReturnValue({
|
vi.mocked(useLocales).mockReturnValue({
|
||||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||||
} as any);
|
} as any);
|
||||||
|
({ parseIngredientText } = useIngredientTextParser());
|
||||||
});
|
});
|
||||||
|
|
||||||
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
|
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
|
||||||
@@ -145,6 +147,7 @@ describe(parseIngredientText.name, () => {
|
|||||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||||
} as any);
|
} as any);
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const ingredient = createRecipeIngredient({
|
const ingredient = createRecipeIngredient({
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
@@ -160,6 +163,7 @@ describe(parseIngredientText.name, () => {
|
|||||||
locales: [{ value: "en-US", pluralFoodHandling: "never" }],
|
locales: [{ value: "en-US", pluralFoodHandling: "never" }],
|
||||||
locale: { value: "en-US", pluralFoodHandling: "never" },
|
locale: { value: "en-US", pluralFoodHandling: "never" },
|
||||||
} as any);
|
} as any);
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const ingredient = createRecipeIngredient({
|
const ingredient = createRecipeIngredient({
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
@@ -175,6 +179,7 @@ describe(parseIngredientText.name, () => {
|
|||||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||||
} as any);
|
} as any);
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const ingredient = createRecipeIngredient({
|
const ingredient = createRecipeIngredient({
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
@@ -190,6 +195,7 @@ describe(parseIngredientText.name, () => {
|
|||||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||||
} as any);
|
} as any);
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const ingredient = createRecipeIngredient({
|
const ingredient = createRecipeIngredient({
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
|
|||||||
@@ -76,8 +76,10 @@ function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
export function useIngredientTextParser() {
|
||||||
const { locales, locale } = useLocales();
|
const { locales, locale } = useLocales();
|
||||||
|
|
||||||
|
function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
||||||
const filteredLocales = locales.filter(lc => lc.value === locale.value);
|
const filteredLocales = locales.filter(lc => lc.value === locale.value);
|
||||||
const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
|
const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
|
||||||
|
|
||||||
@@ -116,11 +118,17 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
|
|||||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
||||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
||||||
|
|
||||||
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||||
return sanitizeIngredientHTML(text);
|
return sanitizeIngredientHTML(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
useParsedIngredientText,
|
||||||
|
parseIngredientText,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
117
frontend/composables/use-search.ts
Normal file
117
frontend/composables/use-search.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { watchDebounced } from "@vueuse/core";
|
||||||
|
import type { IFuseOptions } from "fuse.js";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
|
export interface IAlias {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISearchableItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
aliases?: IAlias[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISearchItemInternal extends ISearchableItem {
|
||||||
|
aliasesText?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISearchOptions {
|
||||||
|
debounceMs?: number;
|
||||||
|
maxWaitMs?: number;
|
||||||
|
minSearchLength?: number;
|
||||||
|
fuseOptions?: Partial<IFuseOptions<ISearchItemInternal>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearch<T extends ISearchableItem>(
|
||||||
|
items: ComputedRef<T[]> | Ref<T[]> | T[],
|
||||||
|
options: ISearchOptions = {},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
debounceMs = 0,
|
||||||
|
maxWaitMs = 1500,
|
||||||
|
minSearchLength = 1,
|
||||||
|
fuseOptions: customFuseOptions = {},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// State
|
||||||
|
const search = ref("");
|
||||||
|
const debouncedSearch = shallowRef("");
|
||||||
|
|
||||||
|
// Flatten item aliases to include as searchable text
|
||||||
|
const searchItems = computed(() => {
|
||||||
|
const itemsArray = Array.isArray(items) ? items : items.value;
|
||||||
|
return itemsArray.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
aliasesText: item.aliases ? item.aliases.map(a => a.name).join(" ") : "",
|
||||||
|
} as ISearchItemInternal;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default Fuse options
|
||||||
|
const defaultFuseOptions: IFuseOptions<ISearchItemInternal> = {
|
||||||
|
keys: [
|
||||||
|
{ name: "name", weight: 3 },
|
||||||
|
{ name: "pluralName", weight: 3 },
|
||||||
|
{ name: "abbreviation", weight: 2 },
|
||||||
|
{ name: "pluralAbbreviation", weight: 2 },
|
||||||
|
{ name: "aliasesText", weight: 1 },
|
||||||
|
],
|
||||||
|
ignoreLocation: true,
|
||||||
|
shouldSort: true,
|
||||||
|
threshold: 0.3,
|
||||||
|
minMatchCharLength: 1,
|
||||||
|
findAllMatches: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge custom options with defaults
|
||||||
|
const fuseOptions = computed(() => ({
|
||||||
|
...defaultFuseOptions,
|
||||||
|
...customFuseOptions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Debounce search input
|
||||||
|
watchDebounced(
|
||||||
|
() => search.value,
|
||||||
|
(newSearch) => {
|
||||||
|
debouncedSearch.value = newSearch;
|
||||||
|
},
|
||||||
|
{ debounce: debounceMs, maxWait: maxWaitMs, immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize Fuse instance
|
||||||
|
const fuse = computed(() => {
|
||||||
|
return new Fuse(searchItems.value || [], fuseOptions.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute filtered results
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const itemsArray = Array.isArray(items) ? items : items.value;
|
||||||
|
const searchTerm = debouncedSearch.value.trim();
|
||||||
|
|
||||||
|
// If no search query or less than minSearchLength characters, return all items
|
||||||
|
if (!searchTerm || searchTerm.length < minSearchLength) {
|
||||||
|
return itemsArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemsArray || itemsArray.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = fuse.value.search(searchTerm);
|
||||||
|
return results.map(result => result.item as T);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
search.value = "";
|
||||||
|
debouncedSearch.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
debouncedSearch,
|
||||||
|
filtered,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ const normalizeLigatures = replaceAllBuilder(new Map([
|
|||||||
["st", "st"],
|
["st", "st"],
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated prefer fuse.js/use-search.ts
|
||||||
|
*/
|
||||||
export const normalize = (str: string) => {
|
export const normalize = (str: string) => {
|
||||||
if (!str) {
|
if (!str) {
|
||||||
return "";
|
return "";
|
||||||
@@ -45,6 +48,9 @@ export const normalize = (str: string) => {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated prefer fuse.js/use-search.ts
|
||||||
|
*/
|
||||||
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
|
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
|
||||||
const normalizedValue = normalize(value);
|
const normalizedValue = normalize(value);
|
||||||
const normalizeQuery = normalize(query);
|
const normalizeQuery = normalize(query);
|
||||||
|
|||||||
Reference in New Issue
Block a user