From 8ba1dc1d94ca6b2ded8d8e0609b3b2ce586fc218 Mon Sep 17 00:00:00 2001 From: Saurav Jain Date: Fri, 19 Sep 2025 20:33:43 +0530 Subject: [PATCH] Added a util helper to remove accent and case to improve search logic (#14533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # [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 Co-authored-by: Félix Malfait --- .../useFilterActionsWithCommandMenuSearch.ts | 7 +- .../SettingsAdminConfigVariables.tsx | 6 +- ...ingsRoleAssignmentEntityPickerDropdown.tsx | 48 +++++++------ .../SettingsRoleAssignmentTable.tsx | 71 ++++++++++--------- ...ColumnSelectFieldSelectDropdownContent.tsx | 19 +++-- .../components/SubMatchingSelectInput.tsx | 14 ++-- .../spreadsheetImportGetSubFieldOptions.ts | 11 +-- .../input/components/MultiSelectInput.tsx | 12 ++-- .../ui/input/components/SelectInput.tsx | 14 ++-- .../settings/SettingsWorkspaceMembers.tsx | 68 +++++++++++++----- .../ai/components/SettingsAIAgentsTable.tsx | 13 ++-- .../components/SettingsAgentHandoffTable.tsx | 21 +++--- .../data-model/SettingsObjectFieldTable.tsx | 37 +++++----- .../data-model/SettingsObjectIndexTable.tsx | 13 ++-- .../settings/data-model/SettingsObjects.tsx | 25 ++++--- .../__tests__/normalizeSearchText.test.ts | 70 ++++++++++++++++++ .../src/utils/normalizeSearchText.ts | 17 +++++ 17 files changed, 315 insertions(+), 151 deletions(-) create mode 100644 packages/twenty-front/src/utils/__tests__/normalizeSearchText.test.ts create mode 100644 packages/twenty-front/src/utils/normalizeSearchText.ts diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useFilterActionsWithCommandMenuSearch.ts b/packages/twenty-front/src/modules/command-menu/hooks/useFilterActionsWithCommandMenuSearch.ts index 88d5a2193b..79b8872bb5 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useFilterActionsWithCommandMenuSearch.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useFilterActionsWithCommandMenuSearch.ts @@ -2,16 +2,19 @@ import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig'; import { getActionLabel } from '@/action-menu/utils/getActionLabel'; import { isNonEmptyString } from '@sniptt/guards'; import { useCallback } from 'react'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; const checkInShortcuts = (action: ActionConfig, search: string) => { const concatenatedString = action.hotKeys?.join('') ?? ''; - return concatenatedString.toLowerCase().includes(search.toLowerCase().trim()); + const searchNormalized = normalizeSearchText(search.trim()); + return normalizeSearchText(concatenatedString).includes(searchNormalized); }; const checkInLabels = (action: ActionConfig, search: string) => { const actionLabel = getActionLabel(action.label); if (isNonEmptyString(actionLabel)) { - return actionLabel.toLowerCase().includes(search.toLowerCase()); + const searchNormalized = normalizeSearchText(search); + return normalizeSearchText(actionLabel).includes(searchNormalized); } return false; }; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables.tsx b/packages/twenty-front/src/modules/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables.tsx index d1f5b84b1e..3948ad3152 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx index b6e7fa29c6..770fe93703 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx @@ -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 ( diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx index bb0760705a..25f2221d62 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx @@ -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 = ({ 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, diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx index f480d9c983..5d2a607c2f 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx @@ -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(); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectInput.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectInput.tsx index 1288aa56d6..9b2a896df2 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectInput.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelectInput.tsx @@ -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( () => diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportGetSubFieldOptions.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportGetSubFieldOptions.ts index 66979dcc14..1c5c862c28 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportGetSubFieldOptions.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportGetSubFieldOptions.ts @@ -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[], searchFilter: string, ): readonly Readonly[] => { - 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) + ); + }); }; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx index 4946c234b9..be64184b63 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/MultiSelectInput.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Key } from 'ts-key-enum'; import { type FieldMultiSelectValue } from '@/object-record/record-field/ui/types/FieldMetadata'; @@ -18,6 +18,7 @@ import { useLingui } from '@lingui/react/macro'; import { isDefined } from 'twenty-shared/utils'; import { type SelectOption } from 'twenty-ui/input'; import { MenuItem, MenuItemMultiSelectTag } from 'twenty-ui/navigation'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; type MultiSelectInputProps = { @@ -56,9 +57,12 @@ export const MultiSelectInput = ({ values?.includes(option.value), ); - const filteredOptionsInDropDown = options.filter((option) => - option.label.toLowerCase().includes(searchFilter.toLowerCase()), - ); + const filteredOptionsInDropDown = useMemo(() => { + const searchTerm = normalizeSearchText(searchFilter); + return options.filter((option) => { + return normalizeSearchText(option.label).includes(searchTerm); + }); + }, [options, searchFilter]); const formatNewSelectedOptions = (value: string) => { const selectedOptionsValues = selectedOptions.map( diff --git a/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx b/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx index 603f2830c2..1e25318ee8 100644 --- a/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx @@ -13,6 +13,7 @@ import { isDefined } from 'twenty-shared/utils'; 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 SelectInputProps { onOptionSelected: (selectedOption: SelectOption) => void; @@ -51,16 +52,17 @@ export const SelectInput = ({ 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( () => diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx index 30945eaa89..f72ca627ea 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx @@ -2,9 +2,10 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { Trans, useLingui } from '@lingui/react/macro'; import { isNonEmptyArray } from '@sniptt/guards'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useDebounce } from 'use-debounce'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -41,6 +42,8 @@ import { IconButton } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { useGetWorkspaceInvitationsQuery } from '~/generated-metadata/graphql'; +import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; import { TableCell } from '../../modules/ui/layout/table/components/TableCell'; import { TableRow } from '../../modules/ui/layout/table/components/TableRow'; import { useDeleteWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation'; @@ -102,6 +105,27 @@ export const SettingsWorkspaceMembers = () => { string | undefined >(); const [isFetchingMore, setIsFetchingMore] = useState(false); + const [searchFilter, setSearchFilter] = useState(''); + + const [debouncedSearchFilter] = useDebounce(searchFilter, 300); + + const searchServerFilter = useMemo(() => { + if (!debouncedSearchFilter?.trim()) return undefined; + + const normalizedSearchTerm = normalizeSearchText(debouncedSearchFilter); + const nameFilters = generateILikeFiltersForCompositeFields( + normalizedSearchTerm, + 'name', + ['firstName', 'lastName'], + ); + + return { + or: [ + ...nameFilters, + { userEmail: { ilike: `%${normalizedSearchTerm}%` } }, + ], + }; + }, [debouncedSearchFilter]); const { records: workspaceMembers, @@ -110,6 +134,7 @@ export const SettingsWorkspaceMembers = () => { loading, } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + filter: searchServerFilter, }); const { deleteOneRecord: deleteOneWorkspaceMember } = useDeleteOneRecord({ objectNameSingular: CoreObjectNameSingular.WorkspaceMember, @@ -128,8 +153,6 @@ export const SettingsWorkspaceMembers = () => { const workspaceInvitations = useRecoilValue(workspaceInvitationsState); const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState); - const [searchFilter, setSearchFilter] = useState(''); - const handleSearchChange = (text: string) => { setSearchFilter(text); }; @@ -189,20 +212,29 @@ export const SettingsWorkspaceMembers = () => { : formatDistanceToNow(new Date(expiresAt)); }; - const filteredWorkspaceMembers = !searchFilter - ? workspaceMembers - : workspaceMembers.filter((member) => { - const searchTerm = searchFilter.toLowerCase(); - const firstName = member.name.firstName?.toLowerCase() || ''; - const lastName = member.name.lastName?.toLowerCase() || ''; - const email = member.userEmail?.toLowerCase() || ''; + const optimizedWorkspaceMembers = useMemo(() => { + if (!searchFilter.trim()) { + return workspaceMembers; + } - return ( - firstName.includes(searchTerm) || - lastName.includes(searchTerm) || - email.includes(searchTerm) - ); - }); + const normalizedSearchTerm = normalizeSearchText(searchFilter); + const searchTerms = normalizedSearchTerm.split(/\s+/); + + return workspaceMembers.filter((member) => { + const firstName = normalizeSearchText(member.name.firstName); + const lastName = normalizeSearchText(member.name.lastName); + const email = normalizeSearchText(member.userEmail); + const fullName = `${firstName} ${lastName}`.trim(); + + return searchTerms.every( + (term) => + firstName.includes(term) || + lastName.includes(term) || + fullName.includes(term) || + email.includes(term), + ); + }); + }, [workspaceMembers, searchFilter]); const { openModal } = useModal(); @@ -334,8 +366,8 @@ export const SettingsWorkspaceMembers = () => { - {filteredWorkspaceMembers.length > 0 ? ( - filteredWorkspaceMembers.map((workspaceMember) => ( + {optimizedWorkspaceMembers.length > 0 ? ( + optimizedWorkspaceMembers.map((workspaceMember) => ( { const sortedAgents = useSortedArray(agents, SETTINGS_AI_AGENT_TABLE_METADATA); - const filteredAgents = sortedAgents.filter( - (agent) => - agent.name.toLowerCase().includes(searchTerm.toLowerCase()) || - agent.label.toLowerCase().includes(searchTerm.toLowerCase()), - ); + const filteredAgents = sortedAgents.filter((agent) => { + const searchNormalized = normalizeSearchText(searchTerm); + return ( + normalizeSearchText(agent.name).includes(searchNormalized) || + normalizeSearchText(agent.label).includes(searchNormalized) + ); + }); return ( <> diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentHandoffTable.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentHandoffTable.tsx index 86ee4940ee..7e0538ce1a 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentHandoffTable.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentHandoffTable.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; import { Trans, useLingui } from '@lingui/react/macro'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInput } from '@/ui/input/components/TextInput'; @@ -18,6 +18,7 @@ import { import { IconButton } from 'twenty-ui/input'; import { useRemoveAgentHandoffMutation } from '~/generated-metadata/graphql'; import { type AgentHandoffDto } from '~/generated/graphql'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; const AGENT_HANDOFF_DELETION_MODAL_ID = 'agent-handoff-deletion-modal'; @@ -67,15 +68,17 @@ export const SettingsAgentHandoffTable = ({ const [removeAgentHandoff] = useRemoveAgentHandoffMutation(); - const filteredHandoffTargets = !searchFilter - ? handoffTargets - : handoffTargets.filter((handoff) => { - const searchTerm = searchFilter.toLowerCase(); - const label = handoff.toAgent.label?.toLowerCase() || ''; - const description = handoff.description?.toLowerCase() || ''; + const filteredHandoffTargets = useMemo(() => { + if (!searchFilter) return handoffTargets; - return label.includes(searchTerm) || description.includes(searchTerm); - }); + const searchTerm = normalizeSearchText(searchFilter); + return handoffTargets.filter((handoff) => { + const label = normalizeSearchText(handoff.toAgent.label); + const description = normalizeSearchText(handoff.description); + + return label.includes(searchTerm) || description.includes(searchTerm); + }); + }, [handoffTargets, searchFilter]); const handleRemoveHandoff = async () => { if (!handoffToDelete) { diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx index 5f3e685384..f3b59d61f5 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx @@ -20,6 +20,7 @@ import { useRecoilState } from 'recoil'; import { IconSearch } from 'twenty-ui/display'; import { useMapFieldMetadataItemToSettingsObjectDetailTableItem } from '~/pages/settings/data-model/hooks/useMapFieldMetadataItemToSettingsObjectDetailTableItem'; import { type SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; const GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata = { @@ -155,25 +156,25 @@ export const SettingsObjectFieldTable = ({ tableMetadata, ); - const filteredActiveItems = useMemo( - () => - sortedActiveObjectSettingsDetailItems.filter( - (item) => - item.label.toLowerCase().includes(searchTerm.toLowerCase()) || - item.dataType.toLowerCase().includes(searchTerm.toLowerCase()), - ), - [sortedActiveObjectSettingsDetailItems, searchTerm], - ); + const filteredActiveItems = useMemo(() => { + const searchNormalized = normalizeSearchText(searchTerm); + return sortedActiveObjectSettingsDetailItems.filter((item) => { + return ( + normalizeSearchText(item.label).includes(searchNormalized) || + normalizeSearchText(item.dataType).includes(searchNormalized) + ); + }); + }, [sortedActiveObjectSettingsDetailItems, searchTerm]); - const filteredDisabledItems = useMemo( - () => - sortedDisabledObjectSettingsDetailItems.filter( - (item) => - item.label.toLowerCase().includes(searchTerm.toLowerCase()) || - item.dataType.toLowerCase().includes(searchTerm.toLowerCase()), - ), - [sortedDisabledObjectSettingsDetailItems, searchTerm], - ); + const filteredDisabledItems = useMemo(() => { + const searchNormalized = normalizeSearchText(searchTerm); + return sortedDisabledObjectSettingsDetailItems.filter((item) => { + return ( + normalizeSearchText(item.label).includes(searchNormalized) || + normalizeSearchText(item.dataType).includes(searchNormalized) + ); + }); + }, [sortedDisabledObjectSettingsDetailItems, searchTerm]); return ( <> diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectIndexTable.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectIndexTable.tsx index ff90539df0..ff335a68d5 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectIndexTable.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectIndexTable.tsx @@ -16,6 +16,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useRecoilState } from 'recoil'; import { IconSearch, IconSquareKey } from 'twenty-ui/display'; import { type SettingsObjectIndexesTableItem } from '~/pages/settings/data-model/types/SettingsObjectIndexesTableItem'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; export const StyledObjectIndexTableRow = styled(TableRow)` grid-template-columns: 350px 70px 80px; @@ -102,11 +103,13 @@ export const SettingsObjectIndexTable = ({ const filteredActiveItems = useMemo( () => - sortedActiveObjectSettingsDetailItems.filter( - (item) => - item.name.toLowerCase().includes(searchTerm.toLowerCase()) || - item.indexType.toLowerCase().includes(searchTerm.toLowerCase()), - ), + sortedActiveObjectSettingsDetailItems.filter((item) => { + const searchNormalized = normalizeSearchText(searchTerm); + return ( + normalizeSearchText(item.name).includes(searchNormalized) || + normalizeSearchText(item.indexType).includes(searchNormalized) + ); + }), [sortedActiveObjectSettingsDetailItems, searchTerm], ); diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx index c95b2ca80a..0375cf5473 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx @@ -35,6 +35,7 @@ import { Section } from 'twenty-ui/layout'; import { UndecoratedLink } from 'twenty-ui/navigation'; import { GET_SETTINGS_OBJECT_TABLE_METADATA } from '~/pages/settings/data-model/constants/SettingsObjectTableMetadata'; import { type SettingsObjectTableItem } from '~/pages/settings/data-model/types/SettingsObjectTableItem'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; const StyledIconChevronRight = styled(IconChevronRight)` color: ${({ theme }) => theme.font.color.tertiary}; @@ -124,21 +125,25 @@ export const SettingsObjects = () => { const filteredActiveObjectSettingsItems = useMemo( () => - sortedActiveObjectSettingsItems.filter( - (item) => - item.labelPlural.toLowerCase().includes(searchTerm.toLowerCase()) || - item.objectTypeLabel.toLowerCase().includes(searchTerm.toLowerCase()), - ), + sortedActiveObjectSettingsItems.filter((item) => { + const searchNormalized = normalizeSearchText(searchTerm); + return ( + normalizeSearchText(item.labelPlural).includes(searchNormalized) || + normalizeSearchText(item.objectTypeLabel).includes(searchNormalized) + ); + }), [sortedActiveObjectSettingsItems, searchTerm], ); const filteredInactiveObjectSettingsItems = useMemo( () => - sortedInactiveObjectSettingsItems.filter( - (item) => - item.labelPlural.toLowerCase().includes(searchTerm.toLowerCase()) || - item.objectTypeLabel.toLowerCase().includes(searchTerm.toLowerCase()), - ), + sortedInactiveObjectSettingsItems.filter((item) => { + const searchNormalized = normalizeSearchText(searchTerm); + return ( + normalizeSearchText(item.labelPlural).includes(searchNormalized) || + normalizeSearchText(item.objectTypeLabel).includes(searchNormalized) + ); + }), [sortedInactiveObjectSettingsItems, searchTerm], ); diff --git a/packages/twenty-front/src/utils/__tests__/normalizeSearchText.test.ts b/packages/twenty-front/src/utils/__tests__/normalizeSearchText.test.ts new file mode 100644 index 0000000000..a21feab393 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/normalizeSearchText.test.ts @@ -0,0 +1,70 @@ +import { normalizeSearchText } from '../normalizeSearchText'; + +describe('normalizeSearchText', () => { + it('should handle basic ASCII text', () => { + expect(normalizeSearchText('Hello World')).toBe('hello world'); + expect(normalizeSearchText('TEST')).toBe('test'); + expect(normalizeSearchText('MixedCase123')).toBe('mixedcase123'); + }); + + it('should remove accents from various languages', () => { + expect(normalizeSearchText('café')).toBe('cafe'); + expect(normalizeSearchText('naïve')).toBe('naive'); + expect(normalizeSearchText('résumé')).toBe('resume'); + expect(normalizeSearchText('Zürich')).toBe('zurich'); + expect(normalizeSearchText('Müller')).toBe('muller'); + expect(normalizeSearchText('niño')).toBe('nino'); + expect(normalizeSearchText('España')).toBe('espana'); + expect(normalizeSearchText('São Paulo')).toBe('sao paulo'); + expect(normalizeSearchText('João')).toBe('joao'); + expect(normalizeSearchText('Åse')).toBe('ase'); + expect(normalizeSearchText('Øyvind')).toBe('oyvind'); + }); + + it('should handle Nordic and Germanic special characters', () => { + expect(normalizeSearchText('Øyvind')).toBe('oyvind'); + expect(normalizeSearchText('Åse')).toBe('ase'); + expect(normalizeSearchText('Æther')).toBe('aether'); + expect(normalizeSearchText('Straße')).toBe('strasse'); + expect(normalizeSearchText('Łódź')).toBe('lodz'); + expect(normalizeSearchText('Œuvre')).toBe('oeuvre'); + }); + + it('should handle mixed case with accents', () => { + expect(normalizeSearchText('CAFÉ')).toBe('cafe'); + expect(normalizeSearchText('NaÏvE')).toBe('naive'); + expect(normalizeSearchText('ZÜRICH')).toBe('zurich'); + }); + + it('should handle null and undefined gracefully', () => { + expect(normalizeSearchText(null)).toBe(''); + expect(normalizeSearchText(undefined)).toBe(''); + }); + + it('should handle empty strings', () => { + expect(normalizeSearchText('')).toBe(''); + expect(normalizeSearchText(' ')).toBe(' '); + }); + + it('should handle special characters and numbers', () => { + expect(normalizeSearchText('user@example.com')).toBe('user@example.com'); + expect(normalizeSearchText('123-456-7890')).toBe('123-456-7890'); + expect(normalizeSearchText('Café #1')).toBe('cafe #1'); + }); + + it('should handle complex Unicode combinations', () => { + expect(normalizeSearchText('e\u0301')).toBe('e'); + expect(normalizeSearchText('a\u0300\u0301')).toBe('a'); + expect(normalizeSearchText('é')).toBe('e'); + expect(normalizeSearchText('e\u0301')).toBe('e'); + }); + + it('should be consistent for search matching', () => { + const searchTerm = 'cafe'; + const names = ['Café', 'CAFE', 'cafe', 'Cafe']; + + names.forEach((name) => { + expect(normalizeSearchText(name)).toContain(searchTerm); + }); + }); +}); diff --git a/packages/twenty-front/src/utils/normalizeSearchText.ts b/packages/twenty-front/src/utils/normalizeSearchText.ts new file mode 100644 index 0000000000..fa723f4119 --- /dev/null +++ b/packages/twenty-front/src/utils/normalizeSearchText.ts @@ -0,0 +1,17 @@ +export const normalizeSearchText = ( + text: string | null | undefined, +): string => { + if (!text) return ''; + return text + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/[øØ]/g, 'o') + .replace(/[åÅ]/g, 'a') + .replace(/[æÆ]/g, 'ae') + .replace(/[ßẞ]/g, 'ss') + .replace(/[ðÐ]/g, 'd') + .replace(/[þÞ]/g, 'th') + .replace(/[łŁ]/g, 'l') + .replace(/[œŒ]/g, 'oe') + .toLowerCase(); +};