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
@@ -16,6 +16,7 @@ import {
ConfigSource,
useGetConfigVariablesGroupedQuery,
} from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { ConfigVariableSearchInput } from './ConfigVariableSearchInput';
const StyledControlsContainer = styled.div`
@@ -75,9 +76,10 @@ export const SettingsAdminConfigVariables = () => {
const hasSelectedSpecificGroup = configVariableGroupFilter !== 'all';
return allVariables.filter((v) => {
const searchTerm = normalizeSearchText(search);
const matchesSearch =
v.name.toLowerCase().includes(search.toLowerCase()) ||
(v.description?.toLowerCase() || '').includes(search.toLowerCase());
normalizeSearchText(v.name).includes(searchTerm) ||
normalizeSearchText(v.description).includes(searchTerm);
if (isSearching && !matchesSearch) return false;
@@ -5,13 +5,14 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
type Agent,
useFindManyAgentsQuery,
useGetApiKeysQuery,
} from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledLoadingContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
@@ -65,9 +66,11 @@ export const SettingsRoleAssignmentEntityPickerDropdown = ({
const loading = isAgent ? agentsLoading : apiKeysLoading;
const entities = ((isAgent
? agentsData?.findManyAgents.filter((agent) => agent.isCustom)
: apiKeysData?.apiKeys) || []) as EntityData[];
const entities = useMemo(() => {
return ((isAgent
? agentsData?.findManyAgents.filter((agent) => agent.isCustom)
: apiKeysData?.apiKeys) || []) as EntityData[];
}, [isAgent, agentsData?.findManyAgents, apiKeysData?.apiKeys]);
const placeholder = isAgent ? t`Search agents` : t`Search API keys`;
@@ -81,25 +84,28 @@ export const SettingsRoleAssignmentEntityPickerDropdown = ({
}
};
const filteredEntities = entities.filter((entity) => {
const isExcluded = excludedIds.includes(entity.id);
const filteredEntities = useMemo(() => {
const searchTerm = normalizeSearchText(searchFilter);
return entities.filter((entity) => {
const isExcluded = excludedIds.includes(entity.id);
if (isExcluded) {
return false;
}
if (isExcluded) {
return false;
}
if (isAgent) {
const agent = entity as Agent;
return (
agent.name.toLowerCase().includes(searchFilter.toLowerCase()) ||
agent.label.toLowerCase().includes(searchFilter.toLowerCase())
);
} else {
return (entity as ApiKeyForRole).name
.toLowerCase()
.includes(searchFilter.toLowerCase());
}
});
if (isAgent) {
const agent = entity as Agent;
return (
normalizeSearchText(agent.name).includes(searchTerm) ||
normalizeSearchText(agent.label).includes(searchTerm)
);
} else {
return normalizeSearchText((entity as ApiKeyForRole).name).includes(
searchTerm,
);
}
});
}, [entities, searchFilter, excludedIds, isAgent]);
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
@@ -9,12 +9,13 @@ import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { H2Title, IconSearch } from 'twenty-ui/display';
import { type Agent } from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { type PartialWorkspaceMember } from '../../types/RoleWithPartialMembers';
const StyledTable = styled.div`
@@ -86,40 +87,42 @@ export const SettingsRoleAssignmentTable = <T extends RoleTargetType>({
const roleTargets = tableConfig[roleTargetType].roleTargets;
const getSearchableFields = (
roleTarget: PartialWorkspaceMember | Agent | ApiKeyForRole,
): string[] => {
switch (roleTargetType) {
case 'member': {
const member = roleTarget as PartialWorkspaceMember;
return [
member.name.firstName?.toLowerCase() || '',
member.name.lastName?.toLowerCase() || '',
member.userEmail?.toLowerCase() || '',
];
}
case 'agent': {
const agent = roleTarget as Agent;
return [
agent.name?.toLowerCase() || '',
agent.label?.toLowerCase() || '',
agent.description?.toLowerCase() || '',
];
}
case 'apiKey': {
const apiKey = roleTarget as ApiKeyForRole;
return [apiKey.name?.toLowerCase() || ''];
}
}
};
const filteredRoleTargets = useMemo(() => {
if (!searchFilter) return roleTargets;
const filteredRoleTargets = !searchFilter
? roleTargets
: roleTargets.filter((roleTarget) => {
const searchTerm = searchFilter.toLowerCase();
const searchableFields = getSearchableFields(roleTarget);
return searchableFields.some((field) => field.includes(searchTerm));
});
const getSearchableFields = (
roleTarget: PartialWorkspaceMember | Agent | ApiKeyForRole,
): string[] => {
switch (roleTargetType) {
case 'member': {
const member = roleTarget as PartialWorkspaceMember;
return [
normalizeSearchText(member.name.firstName),
normalizeSearchText(member.name.lastName),
normalizeSearchText(member.userEmail),
];
}
case 'agent': {
const agent = roleTarget as Agent;
return [
normalizeSearchText(agent.name),
normalizeSearchText(agent.label),
normalizeSearchText(agent.description),
];
}
case 'apiKey': {
const apiKey = roleTarget as ApiKeyForRole;
return [normalizeSearchText(apiKey.name)];
}
}
};
const searchTerm = normalizeSearchText(searchFilter);
return roleTargets.filter((roleTarget) => {
const searchableFields = getSearchableFields(roleTarget);
return searchableFields.some((field) => field.includes(searchTerm));
});
}, [roleTargets, searchFilter, roleTargetType]);
const createRoleTarget = (
roleTarget: PartialWorkspaceMember | Agent | ApiKeyForRole,