From 2854216566e5d105313769e35c9785e2f5adda0e Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 14 Apr 2025 22:48:57 -0700 Subject: [PATCH] =?UTF-8?q?Revert=20"feat:=20implement=20fuzzy=20search=20?= =?UTF-8?q?and=20dropdown=20grouping=20in=20SelectDropdown=20=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 89107b82a345f42cd1ad10bf7243d0a949cd4785. --- .../ui/__tests__/select-dropdown.test.tsx | 172 ++++---- .../src/components/ui/select-dropdown.tsx | 368 ++++++------------ webview-ui/src/i18n/locales/ca/common.json | 3 - webview-ui/src/i18n/locales/de/common.json | 3 - webview-ui/src/i18n/locales/en/common.json | 3 - webview-ui/src/i18n/locales/es/common.json | 3 - webview-ui/src/i18n/locales/fr/common.json | 3 - webview-ui/src/i18n/locales/hi/common.json | 3 - webview-ui/src/i18n/locales/it/common.json | 3 - webview-ui/src/i18n/locales/ja/common.json | 3 - webview-ui/src/i18n/locales/ko/common.json | 3 - webview-ui/src/i18n/locales/pl/common.json | 3 - webview-ui/src/i18n/locales/pt-BR/common.json | 3 - webview-ui/src/i18n/locales/tr/common.json | 3 - webview-ui/src/i18n/locales/vi/common.json | 3 - webview-ui/src/i18n/locales/zh-CN/common.json | 3 - webview-ui/src/i18n/locales/zh-TW/common.json | 3 - 17 files changed, 178 insertions(+), 407 deletions(-) diff --git a/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx b/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx index 933bda273e1..f6a52d5ebc5 100644 --- a/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx +++ b/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx @@ -1,4 +1,4 @@ -// npx jest webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx +// npx jest src/components/ui/__tests__/select-dropdown.test.tsx import { ReactNode } from "react" import { render, screen, fireEvent } from "@testing-library/react" @@ -11,24 +11,12 @@ Object.defineProperty(window, "postMessage", { value: postMessageMock, }) -// Mock the Radix UI Popover components -jest.mock("@/components/ui", () => { +// Mock the Radix UI DropdownMenu component and its children +jest.mock("../dropdown-menu", () => { return { - Popover: ({ - children, - open, - onOpenChange, - }: { - children: ReactNode - open?: boolean - onOpenChange?: (open: boolean) => void - }) => { - // Force open to true for testing - if (onOpenChange) setTimeout(() => onOpenChange(true), 0) - return
{children}
- }, - - PopoverTrigger: ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + + DropdownMenuTrigger: ({ children, disabled, ...props @@ -42,38 +30,29 @@ jest.mock("@/components/ui", () => { ), - PopoverContent: ({ - children, - align, - sideOffset, - container, - className, - }: { - children: ReactNode - align?: string - sideOffset?: number - container?: any - className?: string - }) =>
{children}
, - - Command: ({ children }: { children: ReactNode }) =>
{children}
, - CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, - CommandGroup: ({ children }: { children: ReactNode }) =>
{children}
, - CommandInput: (props: any) => , - CommandItem: ({ + DropdownMenuContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + + DropdownMenuItem: ({ children, - onSelect, + onClick, disabled, }: { children: ReactNode - onSelect?: () => void + onClick?: () => void disabled?: boolean }) => ( -
+
{children}
), - CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + + DropdownMenuSeparator: () =>
, + + DropdownMenuShortcut: ({ children }: { children: ReactNode }) => ( + {children} + ), } }) @@ -143,15 +122,10 @@ describe("SelectDropdown", () => { const dropdown = screen.getByTestId("dropdown-root") expect(dropdown).toBeInTheDocument() - // Verify trigger is rendered + // Verify trigger and content are rendered const trigger = screen.getByTestId("dropdown-trigger") - expect(trigger).toBeInTheDocument() - - // Click the trigger to open the dropdown - fireEvent.click(trigger) - - // Now the content should be visible const content = screen.getByTestId("dropdown-content") + expect(trigger).toBeInTheDocument() expect(content).toBeInTheDocument() }) @@ -166,19 +140,9 @@ describe("SelectDropdown", () => { render() - // Click the trigger to open the dropdown - const trigger = screen.getByTestId("dropdown-trigger") - fireEvent.click(trigger) - - // Now we can check for the separator - // Since our mock doesn't have a specific separator element, we'll check for the div with the separator class - // This is a workaround for the test - in a real scenario we'd update the mock to match the component - const content = screen.getByTestId("dropdown-content") - expect(content).toBeInTheDocument() - - // For this test, we'll just verify the content is rendered - // In a real scenario, we'd need to update the mock to properly handle separators - expect(content).toBeInTheDocument() + // Check for separator + const separators = screen.getAllByTestId("dropdown-separator") + expect(separators.length).toBe(1) }) it("renders shortcut options correctly", () => { @@ -197,17 +161,9 @@ describe("SelectDropdown", () => { />, ) - // Click the trigger to open the dropdown - const trigger = screen.getByTestId("dropdown-trigger") - fireEvent.click(trigger) - - // Now we can check for the shortcut text - const content = screen.getByTestId("dropdown-content") - expect(content).toBeInTheDocument() - - // For this test, we'll just verify the content is rendered - // In a real scenario, we'd need to update the mock to properly handle shortcuts - expect(content).toBeInTheDocument() + expect(screen.queryByText(shortcutText)).toBeInTheDocument() + const dropdownItems = screen.getAllByTestId("dropdown-item") + expect(dropdownItems.length).toBe(2) }) it("handles action options correctly", () => { @@ -218,22 +174,20 @@ describe("SelectDropdown", () => { render() - // Click the trigger to open the dropdown - const trigger = screen.getByTestId("dropdown-trigger") - fireEvent.click(trigger) - - // Now we can check for dropdown items - const content = screen.getByTestId("dropdown-content") - expect(content).toBeInTheDocument() + // Get all dropdown items + const dropdownItems = screen.getAllByTestId("dropdown-item") - // For this test, we'll simulate the action by directly calling the handleSelect function - // This is a workaround since our mock doesn't fully simulate the component behavior - // In a real scenario, we'd update the mock to properly handle actions + // Click the action item + fireEvent.click(dropdownItems[1]) - // We'll verify the component renders correctly - expect(content).toBeInTheDocument() + // Check that postMessage was called with the correct action + expect(postMessageMock).toHaveBeenCalledWith({ + type: "action", + action: "settingsButtonClicked", + }) - // Skip the action test for now as it requires more complex mocking + // The onChange callback should not be called for action items + expect(onChangeMock).not.toHaveBeenCalled() }) it("only treats options with explicit ACTION type as actions", () => { @@ -247,33 +201,45 @@ describe("SelectDropdown", () => { render() - // Click the trigger to open the dropdown - const trigger = screen.getByTestId("dropdown-trigger") - fireEvent.click(trigger) + // Get all dropdown items + const dropdownItems = screen.getAllByTestId("dropdown-item") + + // Click the second option (with action suffix but no ACTION type) + fireEvent.click(dropdownItems[1]) - // Now we can check for dropdown content - const content = screen.getByTestId("dropdown-content") - expect(content).toBeInTheDocument() + // Should trigger onChange, not postMessage + expect(onChangeMock).toHaveBeenCalledWith("settings-action") + expect(postMessageMock).not.toHaveBeenCalled() - // For this test, we'll just verify the content is rendered - // In a real scenario, we'd need to update the mock to properly handle different option types - expect(content).toBeInTheDocument() + // Reset mocks + onChangeMock.mockReset() + postMessageMock.mockReset() + + // Click the third option (ACTION type) + fireEvent.click(dropdownItems[2]) + + // Should trigger postMessage with "settingsButtonClicked", not onChange + expect(postMessageMock).toHaveBeenCalledWith({ + type: "action", + action: "settingsButtonClicked", + }) + expect(onChangeMock).not.toHaveBeenCalled() }) it("calls onChange for regular menu items", () => { render() - // Click the trigger to open the dropdown - const trigger = screen.getByTestId("dropdown-trigger") - fireEvent.click(trigger) + // Get all dropdown items + const dropdownItems = screen.getAllByTestId("dropdown-item") + + // Click the second option (index 1) + fireEvent.click(dropdownItems[1]) - // Now we can check for dropdown content - const content = screen.getByTestId("dropdown-content") - expect(content).toBeInTheDocument() + // Check that onChange was called with the correct value + expect(onChangeMock).toHaveBeenCalledWith("option2") - // For this test, we'll just verify the content is rendered - // In a real scenario, we'd need to update the mock to properly handle onChange events - expect(content).toBeInTheDocument() + // postMessage should not be called for regular items + expect(postMessageMock).not.toHaveBeenCalled() }) }) }) diff --git a/webview-ui/src/components/ui/select-dropdown.tsx b/webview-ui/src/components/ui/select-dropdown.tsx index 7762cf05316..bd11ea33f77 100644 --- a/webview-ui/src/components/ui/select-dropdown.tsx +++ b/webview-ui/src/components/ui/select-dropdown.tsx @@ -1,12 +1,18 @@ import * as React from "react" import { CaretUpIcon } from "@radix-ui/react-icons" -import { Check, X } from "lucide-react" -import { Fzf } from "fzf" -import { useTranslation } from "react-i18next" import { cn } from "@/lib/utils" + import { useRooPortal } from "./hooks/useRooPortal" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuShortcut, +} from "./dropdown-menu" +import { Check } from "lucide-react" export enum DropdownOptionType { ITEM = "item", @@ -14,7 +20,6 @@ export enum DropdownOptionType { SHORTCUT = "shortcut", ACTION = "action", } - export interface DropdownOption { value: string label: string @@ -39,265 +44,110 @@ export interface SelectDropdownProps { renderItem?: (option: DropdownOption) => React.ReactNode } -export const SelectDropdown = React.memo( - React.forwardRef, SelectDropdownProps>( - ( - { - value, - options, - onChange, - disabled = false, - title = "", - triggerClassName = "", - contentClassName = "", - itemClassName = "", - sideOffset = 4, - align = "start", - placeholder = "", - shortcutText = "", - renderItem, - }, - ref, - ) => { - const { t } = useTranslation() - const [open, setOpen] = React.useState(false) - const [searchValue, setSearchValue] = React.useState("") - const searchInputRef = React.useRef(null) - const portalContainer = useRooPortal("roo-portal") - - // Memoize the selected option to prevent unnecessary calculations - const selectedOption = React.useMemo( - () => options.find((option) => option.value === value), - [options, value], - ) - - // Memoize the display text to prevent recalculation on every render - const displayText = React.useMemo( - () => - value && !selectedOption && placeholder ? placeholder : selectedOption?.label || placeholder || "", - [value, selectedOption, placeholder], - ) - - // Reset search value when dropdown closes - const onOpenChange = React.useCallback((open: boolean) => { - setOpen(open) - // Clear search when closing - no need for setTimeout - if (!open) { - // Use requestAnimationFrame instead of setTimeout for better performance - requestAnimationFrame(() => setSearchValue("")) - } - }, []) - - // Clear search and focus input - const onClearSearch = React.useCallback(() => { - setSearchValue("") - searchInputRef.current?.focus() - }, []) - - // Filter options based on search value using Fzf for fuzzy search - // Memoize searchable items to avoid recreating them on every search - const searchableItems = React.useMemo(() => { - return options - .filter( - (option) => - option.type !== DropdownOptionType.SEPARATOR && option.type !== DropdownOptionType.SHORTCUT, - ) - .map((option) => ({ - original: option, - searchStr: [option.label, option.value].filter(Boolean).join(" "), - })) - }, [options]) - - // Create a memoized Fzf instance that only updates when searchable items change - const fzfInstance = React.useMemo(() => { - return new Fzf(searchableItems, { - selector: (item) => item.searchStr, - }) - }, [searchableItems]) - - // Filter options based on search value using memoized Fzf instance - const filteredOptions = React.useMemo(() => { - // If no search value, return all options without filtering - if (!searchValue) return options - - // Get fuzzy matching items - only perform search if we have a search value - const matchingItems = fzfInstance.find(searchValue).map((result) => result.item.original) - - // Always include separators and shortcuts - return options.filter((option) => { - if (option.type === DropdownOptionType.SEPARATOR || option.type === DropdownOptionType.SHORTCUT) { - return true - } - - // Include if it's in the matching items - return matchingItems.some((item) => item.value === option.value) - }) - }, [options, searchValue, fzfInstance]) - - // Group options by type and handle separators - const groupedOptions = React.useMemo(() => { - const result: DropdownOption[] = [] - let lastWasSeparator = false - - filteredOptions.forEach((option) => { - if (option.type === DropdownOptionType.SEPARATOR) { - // Only add separator if we have items before and after it - if (result.length > 0 && !lastWasSeparator) { - result.push(option) - lastWasSeparator = true +export const SelectDropdown = React.forwardRef, SelectDropdownProps>( + ( + { + value, + options, + onChange, + disabled = false, + title = "", + triggerClassName = "", + contentClassName = "", + itemClassName = "", + sideOffset = 4, + align = "start", + placeholder = "", + shortcutText = "", + renderItem, + }, + ref, + ) => { + const [open, setOpen] = React.useState(false) + const portalContainer = useRooPortal("roo-portal") + + // If the selected option isn't in the list yet, but we have a placeholder, prioritize showing the placeholder + const selectedOption = options.find((option) => option.value === value) + const displayText = + value && !selectedOption && placeholder ? placeholder : selectedOption?.label || placeholder || "" + + const handleSelect = (option: DropdownOption) => { + if (option.type === DropdownOptionType.ACTION) { + window.postMessage({ type: "action", action: option.value }) + setOpen(false) + return + } + + onChange(option.value) + setOpen(false) + } + + return ( + + + + {displayText} + + setOpen(false)} + onInteractOutside={() => setOpen(false)} + container={portalContainer} + className={cn("overflow-y-auto max-h-[80vh]", contentClassName)}> + {options.map((option, index) => { + if (option.type === DropdownOptionType.SEPARATOR) { + return } - } else { - result.push(option) - lastWasSeparator = false - } - }) - - // Remove trailing separator if present - if (result.length > 0 && result[result.length - 1].type === DropdownOptionType.SEPARATOR) { - result.pop() - } - - return result - }, [filteredOptions]) - - const handleSelect = React.useCallback( - (optionValue: string) => { - const option = options.find((opt) => opt.value === optionValue) - - if (!option) return - - if (option.type === DropdownOptionType.ACTION) { - window.postMessage({ type: "action", action: option.value }) - setSearchValue("") - setOpen(false) - return - } - - if (option.disabled) return - onChange(option.value) - setSearchValue("") - setOpen(false) - // Clear search value immediately - }, - [onChange, options], - ) - - return ( - - - - {displayText} - - -
- {/* Search input */} -
- setSearchValue(e.target.value)} - placeholder={t("common:ui.search_placeholder")} - className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0" - /> - {searchValue.length > 0 && ( -
- -
- )} -
+ if ( + option.type === DropdownOptionType.SHORTCUT || + (option.disabled && shortcutText && option.label.includes(shortcutText)) + ) { + return ( + + {option.label} + + ) + } - {/* Dropdown items - Use windowing for large lists */} -
- {groupedOptions.length === 0 && searchValue ? ( -
No results found
+ return ( + handleSelect(option)} + className={itemClassName}> + {renderItem ? ( + renderItem(option) ) : ( -
- {groupedOptions.map((option, index) => { - // Memoize rendering of each item type for better performance - if (option.type === DropdownOptionType.SEPARATOR) { - return ( -
- ) - } - - if ( - option.type === DropdownOptionType.SHORTCUT || - (option.disabled && shortcutText && option.label.includes(shortcutText)) - ) { - return ( -
- {option.label} -
- ) - } - - // Use stable keys for better reconciliation - const itemKey = `item-${option.value || option.label || index}` - - return ( -
!option.disabled && handleSelect(option.value)} - className={cn( - "px-3 py-1.5 text-sm cursor-pointer flex items-center", - option.disabled - ? "opacity-50 cursor-not-allowed" - : "hover:bg-vscode-list-hoverBackground", - option.value === value - ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground" - : "", - itemClassName, - )} - data-testid="dropdown-item"> - {renderItem ? ( - renderItem(option) - ) : ( - <> - {option.label} - {option.value === value && ( - - )} - - )} -
- ) - })} -
+ <> + {option.label} + {option.value === value && ( + + + + )} + )} -
-
- - - ) - }, - ), + + ) + })} + + + ) + }, ) SelectDropdown.displayName = "SelectDropdown" diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index c6a797f7c6b..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Cerca..." } } diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index 62056d8d53f..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Suchen..." } } diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 757867bb2cb..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Search..." } } diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 04123769577..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Buscar..." } } diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index fc7b0686df0..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Rechercher..." } } diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 5cf4876d327..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "खोजें..." } } diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index c6a797f7c6b..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Cerca..." } } diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index 063ca02c318..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "検索..." } } diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index e4335f16aeb..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "검색..." } } diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 6163d7f10fd..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Szukaj..." } } diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 0a36a8483bc..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Pesquisar..." } } diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index a41fcaf6db7..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Ara..." } } diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 3ab6697795a..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "Tìm kiếm..." } } diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index a437837df55..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "搜索..." } } diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index e8cc39e07e6..2a10002acb4 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -3,8 +3,5 @@ "thousand_suffix": "k", "million_suffix": "m", "billion_suffix": "b" - }, - "ui": { - "search_placeholder": "搜尋..." } }