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:
+4
-2
@@ -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;
|
||||
|
||||
|
||||
+27
-21
@@ -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}>
|
||||
|
||||
+37
-34
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user