Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions plans/history-header-search-with-buttons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Рефакторинг HistoryHeader: универсальный SearchWithButtons

## Контекст

Сейчас [`HistoryHeader`](src/modules/HistoryHeader/HistoryHeader.tsx:16) — модуль, собирающий:

- [`HistorySearch`](src/modules/HistoryHeader/HistorySearch.tsx:16) — `TextInput` с жёстко зашитой кнопкой переключения full-text поиска в `endContent` (иконка `ChevronsExpandHorizontalIcon`, подсветка `view="action"` при активном режиме);
- опциональный [`HistoryFilter`](src/components/HistoryFilter/HistoryFilter.tsx:14) — кнопка-воронка с попапом фильтров справа от инпута (подсветка `view={isChanged ? 'action' : 'normal'}`).

В новом дизайне похожий блок выглядит иначе: нет кнопки внутри инпута, кнопка справа — с другой иконкой. Чтобы поддерживать оба варианта без дублирования разметки/логики позиционирования, выносим универсальную "коробку" в `src/components`, а `HistoryHeader` делаем тонкой обёрткой над ней.

Используется в двух виджетах: [`QueriesHistory`](src/widgets/QueriesHistory/QueriesHistory.tsx:64) и [`TutorialsHistory`](src/widgets/TutorialsHistory/TutorialsHistory.tsx:47).

## Решение по API (обсуждено с пользователем)

- Слоты кнопок принимают **готовые `ReactNode[]`** (а не декларативные дескрипторы `{icon, onClick, view, ...}`), т.к. вся логика подсветки/состояния кнопок (full-search toggle, фильтр `isChanged`) уже инкапсулирована в самих кнопках-компонентах — поднимать её в конфиг универсального компонента избыточно и ломает инкапсуляцию.
- Новый базовый компонент кладём в `src/components/SearchWithButtons` (уровень `components`, т.к. имеет стабильный контракт пропсов и может использоваться отдельно от `HistoryHeader`).
- `HistoryHeader` остаётся в `src/modules`, использует `SearchWithButtons` внутри, публичный API `HistoryHeader` (`search`, `fullSearch`, `hasClear`, `filter`, `onUpdate`, `className`) **не меняется**.
- В рамках этой задачи новый вариант дизайна (без кнопки внутри инпута, другая иконка справа) **не реализуется** — только рефакторинг текущего `HistoryHeader` на основе `SearchWithButtons`. Новый вариант — отдельная задача позже.

## План работ

1. Создать базовый компонент `src/components/SearchWithButtons/SearchWithButtons.tsx`:
- Пропсы: `value`, `onUpdate`, `hasClear`, `placeholder`, `className`, `innerButtons?: React.ReactNode[]`, `endButtons?: React.ReactNode[]`.
- `innerButtons` рендерятся внутри `TextInput` через `endContent` (обёрнутые в `Flex`, если их несколько).
- `endButtons` рендерятся в `Flex` справа от инпута (аналогично текущему месту `HistoryFilter` в `HistoryHeader`).
- Создать `SearchWithButtons.scss` (перенести отступы из [`HistorySearch.scss`](src/modules/HistoryHeader/HistorySearch.scss:1)) и `index.ts`.

2. Экспортировать `SearchWithButtons` из [`src/components/index.ts`](src/components/index.ts:1).

3. Написать `SearchWithButtons.stories.tsx` в `src/components/SearchWithButtons` — демонстрация с несколькими кнопками в обоих слотах и без кнопок вовсе.

4. Вынести логику full-search toggle-кнопки из [`HistorySearch.tsx`](src/modules/HistoryHeader/HistorySearch.tsx:16) в отдельный маленький компонент (например `internal/FullSearchToggleButton.tsx` внутри модуля `HistoryHeader`), сохранив текущую иконку и подсветку `view="action"`.

5. Переписать [`HistoryHeader.tsx`](src/modules/HistoryHeader/HistoryHeader.tsx:16):
- Перенести в него state `search`/`isFullSearch` (ранее жили в `HistorySearch`) и обработчики `handleOnUpdate`/`handleModeChange`.
- Рендерить `SearchWithButtons` с `innerButtons={[<FullSearchToggleButton .../>]}` и `endButtons={filter ? [<HistoryFilter {...filter} />] : []}`.
- Публичный API компонента (пропсы) не менять.

6. Удалить/упростить [`HistorySearch.tsx`](src/modules/HistoryHeader/HistorySearch.tsx:16) и его `.scss` — логика переехала в `HistoryHeader` + `FullSearchToggleButton`; убрать неиспользуемые файлы.

7. Обновить [`HistoryHeader.stories.tsx`](src/modules/HistoryHeader/HistoryHeader.stories.tsx:1) под новую реализацию (сценарии `Default` и `FullSearchActive` должны продолжать работать).

8. Проверить оба места использования — [`QueriesHistory.tsx`](src/widgets/QueriesHistory/QueriesHistory.tsx:64) и [`TutorialsHistory.tsx`](src/widgets/TutorialsHistory/TutorialsHistory.tsx:47) — без изменений кода в этих файлах, поведение должно остаться прежним.

9. Прогнать typecheck/build и Storybook, вручную проверить:
- переключение full-text поиска и его подсветка;
- открытие фильтра, подсветка при `isChanged`;
- `hasClear` работает как раньше;
- `className` на `HistoryHeader` по-прежнему применяется (см. использование `block('header')` в `QueriesHistory`).

## Структура файлов после рефакторинга

```text
src/
components/
SearchWithButtons/
SearchWithButtons.tsx
SearchWithButtons.scss
SearchWithButtons.stories.tsx
index.ts
modules/
HistoryHeader/
HistoryHeader.tsx
HistoryHeader.stories.tsx
internal/
FullSearchToggleButton.tsx
index.ts
```

## Диаграмма компоновки

```mermaid
graph TD
QH[QueriesHistory / TutorialsHistory widgets] --> HH[HistoryHeader module]
HH --> SWB[SearchWithButtons component]
HH --> FSB[FullSearchToggleButton internal]
HH --> HF[HistoryFilter component]
SWB -->|innerButtons| FSB
SWB -->|endButtons| HF
```
10 changes: 10 additions & 0 deletions src/components/SearchWithButtons/SearchWithButtons.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.qp-search-with-buttons {
&__input {
flex-grow: 1;
min-width: 0;
}

&__inner-buttons {
margin-right: 4px;
}
}
107 changes: 107 additions & 0 deletions src/components/SearchWithButtons/SearchWithButtons.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React, {useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import {Button, Icon} from '@gravity-ui/uikit';
import ChevronsExpandHorizontalIcon from '@gravity-ui/icons/svgs/chevrons-expand-horizontal.svg';
import FunnelIcon from '@gravity-ui/icons/svgs/funnel.svg';
import GearIcon from '@gravity-ui/icons/svgs/gear.svg';
import {SearchWithButtons} from './SearchWithButtons';

const meta: Meta<typeof SearchWithButtons> = {
title: 'Components/SearchWithButtons',
component: SearchWithButtons,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
};

export default meta;
type Story = StoryObj<typeof SearchWithButtons>;

const PlainStory = () => {
const [value, setValue] = useState('');

return (
<SearchWithButtons value={value} placeholder="Search" hasClear={true} onUpdate={setValue} />
);
};

const WithInnerButtonStory = () => {
const [value, setValue] = useState('');
const [active, setActive] = useState(false);

return (
<SearchWithButtons
value={value}
placeholder="Search"
hasClear={true}
onUpdate={setValue}
innerButtons={[
<Button
key="full-search"
size="xs"
view={active ? 'action' : undefined}
onClick={() => setActive(!active)}
>
<Icon data={ChevronsExpandHorizontalIcon} size={12} />
</Button>,
]}
/>
);
};

const WithEndButtonsStory = () => {
const [value, setValue] = useState('');

return (
<SearchWithButtons
value={value}
placeholder="Search"
hasClear={true}
onUpdate={setValue}
endButtons={[
<Button key="filter">
<Icon data={FunnelIcon} size={16} />
</Button>,
<Button key="settings">
<Icon data={GearIcon} size={16} />
</Button>,
]}
/>
);
};

const WithBothSlotsStory = () => {
const [value, setValue] = useState('');

return (
<SearchWithButtons
value={value}
placeholder="Search"
hasClear={true}
onUpdate={setValue}
innerButtons={[
<Button key="full-search" size="xs">
<Icon data={ChevronsExpandHorizontalIcon} size={12} />
</Button>,
]}
endButtons={[
<Button key="filter">
<Icon data={FunnelIcon} size={16} />
</Button>,
]}
/>
);
};

/** Just the search input, without any buttons */
export const Default: Story = {render: () => <PlainStory />};

/** A toggle button rendered inside the input */
export const WithInnerButton: Story = {render: () => <WithInnerButtonStory />};

/** Several buttons rendered after the input */
export const WithEndButtons: Story = {render: () => <WithEndButtonsStory />};

/** Both slots are filled at the same time */
export const WithBothSlots: Story = {render: () => <WithBothSlotsStory />};
56 changes: 56 additions & 0 deletions src/components/SearchWithButtons/SearchWithButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, {FC} from 'react';
import {Flex, TextInput} from '@gravity-ui/uikit';
import cn from 'bem-cn-lite';
import './SearchWithButtons.scss';

export type SearchWithButtonsProps = {
value?: string;
placeholder?: string;
hasClear?: boolean;
innerButtons?: React.ReactNode[];
endButtons?: React.ReactNode[];
onUpdate?: (value: string) => void;
className?: string;
};

const block = cn('qp-search-with-buttons');

const renderButtons = (buttons: React.ReactNode[]) =>
buttons.map((button, index) => <React.Fragment key={index}>{button}</React.Fragment>);

export const SearchWithButtons: FC<SearchWithButtonsProps> = ({
value,
placeholder,
hasClear,
innerButtons,
endButtons,
onUpdate,
className,
}) => {
const hasInnerButtons = Boolean(innerButtons?.length);
const hasEndButtons = Boolean(endButtons?.length);

return (
<Flex gap={1} className={block(null, className)}>
<TextInput
className={block('input')}
value={value}
placeholder={placeholder}
hasClear={hasClear}
onUpdate={onUpdate}
endContent={
hasInnerButtons ? (
<Flex gap={1} className={block('inner-buttons')}>
{renderButtons(innerButtons as React.ReactNode[])}
</Flex>
) : undefined
}
/>
{hasEndButtons && (
<Flex gap={1} className={block('end-buttons')}>
{renderButtons(endButtons as React.ReactNode[])}
</Flex>
)}
</Flex>
);
};
2 changes: 2 additions & 0 deletions src/components/SearchWithButtons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {SearchWithButtons} from './SearchWithButtons';
export type {SearchWithButtonsProps} from './SearchWithButtons';
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export {RowLink} from './RowLink';
export type {RowLinkProps} from './RowLink';
export {SearchRowLayout} from './SearchRowLayout';
export type {SearchRowLayoutProps} from './SearchRowLayout';
export {SearchWithButtons} from './SearchWithButtons';
export type {SearchWithButtonsProps} from './SearchWithButtons';
export {MonacoEditor} from './MonacoEditor';
export type {MonacoEditorConfig} from './MonacoEditor';
export {
Expand Down
59 changes: 45 additions & 14 deletions src/modules/HistoryHeader/HistoryHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, {FC} from 'react';
import {Flex} from '@gravity-ui/uikit';
import {HistorySearch} from './HistorySearch';
import {HistoryFilter} from '../../components/HistoryFilter';
import React, {FC, useEffect, useState} from 'react';
import {FullSearchToggleButton} from './internal/FullSearchToggleButton';
import {HistoryFilter, SearchWithButtons} from '../../components';
import {QueryHistoryFilterConfig} from '../../types/history';

type Props = {
Expand All @@ -10,18 +9,50 @@ type Props = {
hasClear?: boolean;
filter?: QueryHistoryFilterConfig;
onUpdate: (data: {value: string; fullSearch: boolean}) => void;
className?: string;
};

export const HistoryHeader: FC<Props> = ({search, fullSearch, hasClear, filter, onUpdate}) => {
export const HistoryHeader: FC<Props> = ({
search,
fullSearch,
hasClear,
filter,
onUpdate,
className,
}) => {
const [searchValue, setSearchValue] = useState(search || '');
const [isFullSearch, setFullSearch] = useState(fullSearch || false);

useEffect(() => {
setSearchValue(search || '');
setFullSearch(fullSearch || false);
}, [search, fullSearch]);

const handleOnUpdate = (newValue: string) => {
setSearchValue(newValue);
onUpdate({value: newValue, fullSearch: isFullSearch});
};

const handleModeChange = () => {
const newValue = !isFullSearch;
setFullSearch(newValue);
onUpdate({value: searchValue, fullSearch: newValue});
};

return (
<Flex gap={1}>
<HistorySearch
value={search}
fullSearch={fullSearch}
hasClear={hasClear}
onUpdate={onUpdate}
/>
{filter && <HistoryFilter {...filter} />}
</Flex>
<SearchWithButtons
className={className}
value={searchValue}
hasClear={hasClear}
onUpdate={handleOnUpdate}
innerButtons={[
<FullSearchToggleButton
key="full-search"
active={isFullSearch}
onClick={handleModeChange}
/>,
]}
endButtons={filter ? [<HistoryFilter key="filter" {...filter} />] : undefined}
/>
);
};
5 changes: 0 additions & 5 deletions src/modules/HistoryHeader/HistorySearch.scss

This file was deleted.

Loading
Loading