feat: frontend autocomplete is diacritics/ligatures insensitive (#6169)

Co-authored-by: Pierre <pierre@debian.zabi.ovh>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
Noneangel
2025-12-05 19:44:37 +01:00
committed by GitHub
parent 6695314588
commit 71732d4766
10 changed files with 97 additions and 0 deletions

View File

@@ -56,6 +56,7 @@
variant="solo" variant="solo"
return-object return-object
:items="units || []" :items="units || []"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
class="mx-1" class="mx-1"
:placeholder="$t('recipe.choose-unit')" :placeholder="$t('recipe.choose-unit')"
@@ -114,6 +115,7 @@
variant="solo" variant="solo"
return-object return-object
:items="foods || []" :items="foods || []"
:custom-filter="normalizeFilter"
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')"
@@ -171,6 +173,7 @@
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')"
@@ -225,6 +228,7 @@ import { ref, computed, reactive, toRefs } 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 { 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";

View File

@@ -4,6 +4,7 @@
v-bind="inputAttrs" v-bind="inputAttrs"
v-model:search="searchInput" v-model:search="searchInput"
:items="items" :items="items"
:custom-filter="normalizeFilter"
:label="label" :label="label"
chips chips
closable-chips closable-chips
@@ -52,6 +53,7 @@ import type { RecipeTool } from "~/lib/api/types/admin";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated"; import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { normalizeFilter } from "~/composables/use-utils";
interface Props { interface Props {
selectorType: RecipeOrganizer; selectorType: RecipeOrganizer;

View File

@@ -7,6 +7,7 @@
item-title="name" item-title="name"
return-object return-object
:items="items" :items="items"
:custom-filter="normalizeFilter"
:prepend-icon="icon || $globals.icons.tags" :prepend-icon="icon || $globals.icons.tags"
auto-select-first auto-select-first
clearable clearable
@@ -52,6 +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";
export default defineNuxtComponent({ export default defineNuxtComponent({
props: { props: {
@@ -122,6 +124,7 @@ export default defineNuxtComponent({
itemIdVal, itemIdVal,
searchInput, searchInput,
emitCreate, emitCreate,
normalizeFilter,
}; };
}, },
}); });

View File

@@ -9,6 +9,7 @@
<v-autocomplete <v-autocomplete
v-model="selectedLocale" v-model="selectedLocale"
:items="locales" :items="locales"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
item-value="value" item-value="value"
class="my-3" class="my-3"
@@ -44,6 +45,7 @@
<script lang="ts"> <script lang="ts">
import { useLocales } from "~/composables/use-locales"; import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
export default defineNuxtComponent({ export default defineNuxtComponent({
props: { props: {
@@ -83,6 +85,7 @@ export default defineNuxtComponent({
locale, locale,
selectedLocale, selectedLocale,
onLocaleSelect, onLocaleSelect,
normalizeFilter,
}; };
}, },
}); });

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { normalize, normalizeFilter } from "./use-utils";
describe("test normalize", () => {
test("base case", () => {
expect(normalize("banana")).not.toEqual(normalize("Potatoes"));
});
test("diacritics", () => {
expect(normalize("Rátàtôuile")).toEqual("ratatouile");
});
test("ligatures", () => {
expect(normalize("IJ")).toEqual("ij");
expect(normalize("æ")).toEqual("ae");
expect(normalize("œ")).toEqual("oe");
expect(normalize("ff")).toEqual("ff");
expect(normalize("fi")).toEqual("fi");
expect(normalize("st")).toEqual("st");
});
});
describe("test normalize filter", () => {
test("base case", () => {
const patternA = "Escargots persillés";
const patternB = "persillés";
expect(normalizeFilter(patternA, patternB)).toBeTruthy();
expect(normalizeFilter(patternB, patternA)).toBeFalsy();
});
test("normalize", () => {
const value = "Cœur de bœuf";
const query = "coeur";
expect(normalizeFilter(value, query)).toBeTruthy();
});
});

View File

@@ -1,4 +1,5 @@
import { useDark, useToggle } from "@vueuse/core"; import { useDark, useToggle } from "@vueuse/core";
import type { FilterFunction } from "vuetify";
export const useToggleDarkMode = () => { export const useToggleDarkMode = () => {
const isDark = useDark(); const isDark = useDark();
@@ -18,6 +19,38 @@ export const titleCase = function (str: string) {
.join(" "); .join(" ");
}; };
const replaceAllBuilder = (map: Map<string, string>): ((str: string) => string) => {
const re = new RegExp(Array.from(map.keys()).join("|"), "gi");
return str => str.replace(re, matched => map.get(matched)!);
};
const normalizeLigatures = replaceAllBuilder(new Map([
["œ", "oe"],
["æ", "ae"],
["ij", "ij"],
["ff", "ff"],
["fi", "fi"],
["fl", "fl"],
["st", "st"],
]));
export const normalize = (str: string) => {
if (!str) {
return "";
}
let normalized = str.normalize("NFKD").toLowerCase();
normalized = normalized.replace(/\p{Diacritic}/gu, "");
normalized = normalizeLigatures(normalized);
return normalized;
};
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
const normalizedValue = normalize(value);
const normalizeQuery = normalize(query);
return normalizedValue.includes(normalizeQuery);
};
export function uuid4() { export function uuid4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
(parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16), (parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16),

View File

@@ -16,6 +16,7 @@
v-model="fromFood" v-model="fromFood"
return-object return-object
:items="foods" :items="foods"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
:label="$t('data-pages.foods.source-food')" :label="$t('data-pages.foods.source-food')"
/> />
@@ -23,6 +24,7 @@
v-model="toFood" v-model="toFood"
return-object return-object
:items="foods" :items="foods"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
:label="$t('data-pages.foods.target-food')" :label="$t('data-pages.foods.target-food')"
/> />
@@ -51,6 +53,7 @@
v-model="locale" v-model="locale"
:items="locales" :items="locales"
item-title="name" item-title="name"
:custom-filter="normalizeFilter"
:label="$t('data-pages.select-language')" :label="$t('data-pages.select-language')"
class="my-3" class="my-3"
hide-details hide-details
@@ -108,6 +111,7 @@
v-model="createTarget.labelId" v-model="createTarget.labelId"
clearable clearable
:items="allLabels" :items="allLabels"
:custom-filter="normalizeFilter"
item-value="id" item-value="id"
item-title="name" item-title="name"
:label="$t('data-pages.foods.food-label')" :label="$t('data-pages.foods.food-label')"
@@ -164,6 +168,7 @@
v-model="editTarget.labelId" v-model="editTarget.labelId"
clearable clearable
:items="allLabels" :items="allLabels"
:custom-filter="normalizeFilter"
item-value="id" item-value="id"
item-title="name" item-title="name"
:label="$t('data-pages.foods.food-label')" :label="$t('data-pages.foods.food-label')"
@@ -256,6 +261,7 @@
v-model="bulkAssignLabelId" v-model="bulkAssignLabelId"
clearable clearable
:items="allLabels" :items="allLabels"
:custom-filter="normalizeFilter"
item-value="id" item-value="id"
item-title="name" item-title="name"
:label="$t('data-pages.foods.food-label')" :label="$t('data-pages.foods.food-label')"
@@ -346,6 +352,7 @@ import { useUserApi } from "~/composables/api";
import type { CreateIngredientFood, IngredientFood, IngredientFoodAlias } from "~/lib/api/types/recipe"; import type { CreateIngredientFood, IngredientFood, IngredientFoodAlias } from "~/lib/api/types/recipe";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue"; import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import { useLocales } from "~/composables/use-locales"; import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useFoodStore, useLabelStore } from "~/composables/store"; import { useFoodStore, useLabelStore } from "~/composables/store";
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import type { VForm } from "~/types/auto-forms"; import type { VForm } from "~/types/auto-forms";
@@ -625,6 +632,7 @@ export default defineNuxtComponent({
foods, foods,
allLabels, allLabels,
validators, validators,
normalizeFilter,
// Create // Create
createDialog, createDialog,
domNewFoodForm, domNewFoodForm,

View File

@@ -109,6 +109,7 @@
<v-autocomplete <v-autocomplete
v-model="locale" v-model="locale"
:items="locales" :items="locales"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
:label="$t('data-pages.select-language')" :label="$t('data-pages.select-language')"
class="my-3" class="my-3"
@@ -186,6 +187,7 @@ import { useUserApi } from "~/composables/api";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue"; import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels"; import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import { useLocales } from "~/composables/use-locales"; import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useLabelData, useLabelStore } from "~/composables/store"; import { useLabelData, useLabelStore } from "~/composables/store";
export default defineNuxtComponent({ export default defineNuxtComponent({
@@ -316,6 +318,7 @@ export default defineNuxtComponent({
tableHeaders, tableHeaders,
labels: labelStore.store, labels: labelStore.store,
validators, validators,
normalizeFilter,
// create // create
createLabel, createLabel,

View File

@@ -19,6 +19,7 @@
v-model="fromUnit" v-model="fromUnit"
return-object return-object
:items="store" :items="store"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
:label="$t('data-pages.units.source-unit')" :label="$t('data-pages.units.source-unit')"
/> />
@@ -26,6 +27,7 @@
v-model="toUnit" v-model="toUnit"
return-object return-object
:items="store" :items="store"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
:label="$t('data-pages.units.target-unit')" :label="$t('data-pages.units.target-unit')"
/> />
@@ -313,6 +315,7 @@ import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe"; import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe";
import { useLocales } from "~/composables/use-locales"; import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useUnitStore } from "~/composables/store"; import { useUnitStore } from "~/composables/store";
import type { VForm } from "~/types/auto-forms"; import type { VForm } from "~/types/auto-forms";
@@ -536,6 +539,7 @@ export default defineNuxtComponent({
tableHeaders, tableHeaders,
store, store,
validators, validators,
normalizeFilter,
// Create // Create
createDialog, createDialog,
domNewUnitForm, domNewUnitForm,

View File

@@ -47,6 +47,7 @@
v-model:search="search.query.value" v-model:search="search.query.value"
:label="$t('meal-plan.meal-recipe')" :label="$t('meal-plan.meal-recipe')"
:items="search.data.value" :items="search.data.value"
:custom-filter="normalizeFilter"
:loading="search.loading.value" :loading="search.loading.value"
cache-items cache-items
item-title="name" item-title="name"
@@ -242,6 +243,7 @@ import RecipeCardImage from "~/components/Domain/Recipe/RecipeCardImage.vue";
import type { PlanEntryType, UpdatePlanEntry } from "~/lib/api/types/meal-plan"; import type { PlanEntryType, UpdatePlanEntry } from "~/lib/api/types/meal-plan";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { normalizeFilter } from "~/composables/use-utils";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search"; import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
export default defineNuxtComponent({ export default defineNuxtComponent({
@@ -416,6 +418,7 @@ export default defineNuxtComponent({
getEntryTypeText, getEntryTypeText,
requiredRule, requiredRule,
isCreateDisabled, isCreateDisabled,
normalizeFilter,
// Dialog // Dialog
dialog, dialog,