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:
+5
-2
@@ -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;
|
||||
};
|
||||
|
||||
+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,
|
||||
|
||||
+12
-7
@@ -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();
|
||||
|
||||
|
||||
+8
-6
@@ -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(
|
||||
() =>
|
||||
|
||||
+7
-4
@@ -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(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user