Added a util helper to remove accent and case to improve search logic (#14533)

# [WIP] Make Local Search Non-Accent-Sensitive

fixes(#14468)

---

**Description:**  
This PR adds accent- and case-insensitive search for the
**SettingsWorkspaceMembers** component’s local search. It ensures that
searching for names or emails works correctly even if the user types
letters without accents.

**Implementation:**  
- Added a helper util: `removeAccentsAndCase(text: string)`  
- This util:
  - Normalizes text to NFD form
  - Removes diacritics (accents)
  - Converts text to lowercase
- Replaced current `.includes(searchFilter)` logic with a normalized
comparison:


---

### Video ->

[Screencast from 2025-09-16
16-42-50.webm](https://github.com/user-attachments/assets/7035906c-9c5d-414d-81e5-74e53803628a)


---

Opening a draft pr for initial strategy review before extending it to
other components.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Saurav Jain
2025-09-19 20:33:43 +05:30
committed by GitHub
parent deed08ba25
commit 8ba1dc1d94
17 changed files with 315 additions and 151 deletions
@@ -1,3 +1,5 @@
import { useMemo, useState } from 'react';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFieldMetadataTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel';
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
@@ -15,11 +17,11 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { IconForbid, IconX, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type ReadonlyDeep } from 'type-fest';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledContainer = styled.div`
max-height: 360px;
@@ -54,12 +56,15 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({
const { availableFieldMetadataItems } = useSpreadsheetImportInternal();
const filteredAvailableFieldMetadataItems =
availableFieldMetadataItems.filter(
(field) =>
field.label.toLowerCase().includes(searchFilter.toLowerCase()) ||
field.name.toLowerCase().includes(searchFilter.toLowerCase()),
);
const filteredAvailableFieldMetadataItems = useMemo(() => {
const searchTerm = normalizeSearchText(searchFilter);
return availableFieldMetadataItems.filter((field) => {
return (
normalizeSearchText(field.label).includes(searchTerm) ||
normalizeSearchText(field.name).includes(searchTerm)
);
});
}, [availableFieldMetadataItems, searchFilter]);
const { getIcon } = useIcons();
@@ -6,6 +6,7 @@ import { useMemo, useRef, useState } from 'react';
import { type TagColor } from 'twenty-ui/components';
import { type SelectOption } from 'twenty-ui/input';
import { MenuItemSelectTag } from 'twenty-ui/navigation';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
interface SubMatchingSelectInputProps {
onOptionSelected: (selectedOption: SelectOption) => void;
@@ -25,16 +26,17 @@ export const SubMatchingSelectInput = ({
SelectOption | undefined
>(defaultOption);
const optionsToSelect = useMemo(
() =>
const optionsToSelect = useMemo(() => {
const searchTerm = normalizeSearchText(searchFilter);
return (
options.filter((option) => {
return (
option.value !== selectedOption?.value &&
option.label.toLowerCase().includes(searchFilter.toLowerCase())
normalizeSearchText(option.label).includes(searchTerm)
);
}) || [],
[options, searchFilter, selectedOption?.value],
);
}) || []
);
}, [options, searchFilter, selectedOption?.value]);
const optionsInDropDown = useMemo(
() =>
@@ -1,14 +1,17 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type SpreadsheetImportFieldOption } from '@/spreadsheet-import/types/SpreadsheetImportFieldOption';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
export const getSubFieldOptions = (
fieldMetadataItem: FieldMetadataItem,
options: readonly Readonly<SpreadsheetImportFieldOption>[],
searchFilter: string,
): readonly Readonly<SpreadsheetImportFieldOption>[] => {
return options.filter(
(option) =>
return options.filter((option) => {
const searchNormalized = normalizeSearchText(searchFilter);
return (
option.fieldMetadataItemId === fieldMetadataItem.id &&
option.label.toLowerCase().includes(searchFilter.toLowerCase()),
);
normalizeSearchText(option.label).includes(searchNormalized)
);
});
};