feat(front): add search to the language picker (#22095)
## What Adds a search bar to the **Settings → Experience → Language** picker, and makes languages searchable across languages. Each option is matched against: - its displayed label (the name in the current UI language) - its name **in English** — typing `chinese` finds "Chinois — Simplifié" - its **native name** — typing `中文` finds the same option Matching is also accent-insensitive (`francais` finds "Français"). ## How - The shared `Select` already supports search via `withSearchInput` (used by the currency/country pickers) — the picker just opts in. - Cross-language matching uses the platform `Intl.DisplayNames` API to derive each language's English and native names — no hardcoded translation tables, no extra requests. - A generic optional `searchKeywords` field on `SelectOption` lets the `Select` filter match synonyms on top of the label; the filter now runs through the existing `normalizeSearchText`, hence the accent-insensitivity. Behavior is unchanged for every existing `Select` (strict superset for ASCII labels). ## Test - `nx typecheck` / `nx lint` pass for `twenty-front` and `twenty-ui`. - Open the Language dropdown and try `chinese`, `中文`, or `francais`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22095?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -23,6 +23,7 @@ import { type IconComponent } from 'twenty-ui/icon';
|
|||||||
import { type SelectOption } from 'twenty-ui/input';
|
import { type SelectOption } from 'twenty-ui/input';
|
||||||
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
|
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
|
||||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
|
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||||
|
|
||||||
export type SelectSizeVariant = 'small' | 'default';
|
export type SelectSizeVariant = 'small' | 'default';
|
||||||
|
|
||||||
@@ -124,15 +125,20 @@ export const Select = <Value extends SelectValue>({
|
|||||||
return null;
|
return null;
|
||||||
}, [emptyOption, options, pinnedOption, value]);
|
}, [emptyOption, options, pinnedOption, value]);
|
||||||
|
|
||||||
const filteredOptions = useMemo(
|
const filteredOptions = useMemo(() => {
|
||||||
() =>
|
if (!isNonEmptyString(searchInputValue)) {
|
||||||
searchInputValue
|
return options;
|
||||||
? options.filter(({ label }) =>
|
}
|
||||||
label.toLowerCase().includes(searchInputValue.toLowerCase()),
|
|
||||||
)
|
const normalizedSearch = normalizeSearchText(searchInputValue);
|
||||||
: options,
|
|
||||||
[options, searchInputValue],
|
return options.filter(
|
||||||
);
|
({ label, searchKeywords }) =>
|
||||||
|
normalizeSearchText(label).includes(normalizedSearch) ||
|
||||||
|
(isDefined(searchKeywords) &&
|
||||||
|
normalizeSearchText(searchKeywords).includes(normalizedSearch)),
|
||||||
|
);
|
||||||
|
}, [options, searchInputValue]);
|
||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
disabledFromProps ||
|
disabledFromProps ||
|
||||||
|
|||||||
+33
-3
@@ -10,6 +10,7 @@ import { useInvalidateMetadataStore } from '@/metadata-store/hooks/useInvalidate
|
|||||||
import { useStore } from 'jotai';
|
import { useStore } from 'jotai';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { enUS } from 'date-fns/locale';
|
import { enUS } from 'date-fns/locale';
|
||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||||
@@ -17,6 +18,31 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
|
|||||||
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||||
import { logError } from '~/utils/logError';
|
import { logError } from '~/utils/logError';
|
||||||
|
|
||||||
|
// Language name in English and in its own language, so it is searchable
|
||||||
|
// regardless of the UI language — e.g. typing "chinese" or "中文".
|
||||||
|
const getLocaleSearchKeywords = (
|
||||||
|
locale: (typeof APP_LOCALES)[keyof typeof APP_LOCALES],
|
||||||
|
): string => {
|
||||||
|
const displayNames = [locale, APP_LOCALES.en].map((displayLocale) => {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames([displayLocale], { type: 'language' }).of(
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...new Set(displayNames.filter(isNonEmptyString))].join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCALE_SEARCH_KEYWORDS: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.values(APP_LOCALES).map((locale) => [
|
||||||
|
locale,
|
||||||
|
getLocaleSearchKeywords(locale),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -207,9 +233,12 @@ export const LocalePicker = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const localeOptions = [...unsortedLocaleOptions].sort((a, b) =>
|
const localeOptions = [...unsortedLocaleOptions]
|
||||||
a.label.localeCompare(b.label),
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
);
|
.map((option) => ({
|
||||||
|
...option,
|
||||||
|
searchKeywords: LOCALE_SEARCH_KEYWORDS[option.value],
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
@@ -217,6 +246,7 @@ export const LocalePicker = () => {
|
|||||||
dropdownId="preferred-locale"
|
dropdownId="preferred-locale"
|
||||||
dropdownWidthAuto
|
dropdownWidthAuto
|
||||||
fullWidth
|
fullWidth
|
||||||
|
withSearchInput
|
||||||
value={currentWorkspaceMember.locale}
|
value={currentWorkspaceMember.locale}
|
||||||
options={localeOptions}
|
options={localeOptions}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export type SelectOption<
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
color?: ThemeColor | 'transparent';
|
color?: ThemeColor | 'transparent';
|
||||||
contextualText?: string;
|
contextualText?: string;
|
||||||
|
searchKeywords?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user