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
@@ -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;
};
@@ -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,
@@ -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)
);
});
};
@@ -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(
@@ -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(
() =>
@@ -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<WorkspaceMember>({
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 = () => {
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableRows>
{filteredWorkspaceMembers.length > 0 ? (
filteredWorkspaceMembers.map((workspaceMember) => (
{optimizedWorkspaceMembers.length > 0 ? (
optimizedWorkspaceMembers.map((workspaceMember) => (
<TableRow
gridAutoColumns="150px 1fr 1fr"
mobileGridAutoColumns="100px 1fr 1fr"
@@ -13,6 +13,7 @@ import { getSettingsPath } from 'twenty-shared/utils';
import { IconChevronRight, IconSearch } from 'twenty-ui/display';
import { type Agent } from '~/generated-metadata/graphql';
import { SETTINGS_AI_AGENT_TABLE_METADATA } from '~/pages/settings/ai/constants/SettingsAiAgentTableMetadata';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import {
SettingsAIAgentTableRow,
@@ -39,11 +40,13 @@ export const SettingsAIAgentsTable = ({ agents }: { agents: Agent[] }) => {
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 (
<>
@@ -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) {
@@ -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<SettingsObjectDetailTableItem> =
{
@@ -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 (
<>
@@ -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],
);
@@ -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],
);
@@ -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);
});
});
});
@@ -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();
};