From c24c20f911ab79d54d7e1a900c2debd7ff91db50 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Wed, 19 Aug 2026 10:55:14 +0300 Subject: [PATCH 1/2] chore(UI): github review skill [YTFRONT-5935] --- .agents/skills/code-review-checklist/SKILL.md | 135 ++++++++++++++++++ .agents/skills/github-pr-review/SKILL.md | 24 ++++ 2 files changed, 159 insertions(+) create mode 100644 .agents/skills/code-review-checklist/SKILL.md create mode 100644 .agents/skills/github-pr-review/SKILL.md diff --git a/.agents/skills/code-review-checklist/SKILL.md b/.agents/skills/code-review-checklist/SKILL.md new file mode 100644 index 0000000..d48078c --- /dev/null +++ b/.agents/skills/code-review-checklist/SKILL.md @@ -0,0 +1,135 @@ +--- +name: code-review-checklist +description: Applies the Code Review Checklist (logical bugs, edge cases, security, performance) when reviewing code, checking logic, or hunting bugs. Use on any request for code review, проверку логики, поиск багов, or bug hunting in code. +--- + +# **Code Review Checklist** + +## **Logical Bugs Checklist** + +### **Control Flow** + +- All branches are reachable and necessary +- No dead code paths +- Loop conditions terminate correctly +- Switch/case has default or is exhaustive +- Early returns don't skip necessary cleanup +- Conditional logic matches the intent (off-by-one, inverted conditions) + +### **Null & Undefined Handling** + +- Null checks before dereferencing +- Optional chaining used where appropriate +- Default values for missing fields +- No assumptions about object shape without validation + +### **Error Handling** + +- Errors caught at appropriate level +- No swallowed errors (empty catch blocks) +- Error propagation preserves context +- Graceful degradation on failure +- Resource cleanup in finally blocks + +### **Concurrency & Race Conditions** + +- Shared mutable state is protected +- No race conditions in async code +- Locks/mutexes used correctly where needed +- Callbacks don't cause interleaving issues +- Atomicity of compound operations guaranteed + +### **State Management** + +- State transitions are valid and complete +- No stale state after updates +- State not mutated directly (where immutable pattern expected) +- Derived state recomputed when dependencies change +- No state leaks between independent operations + +### **Data Flow** + +- Data transformations preserve invariants +- No data loss in type conversions +- Array/object mutations don't affect unexpected references +- Input validation at boundaries +- Output consistency with input constraints + +## **Edge Cases Checklist** + +### **Boundary Conditions** + +- Empty collections handled +- Zero / negative values handled +- Maximum values don't overflow +- String length edge cases (empty, very long, unicode) +- Date/time edge cases (timezones, leap years, midnight) + +### **Resource Management** + +- File handles closed after use +- Network connections properly terminated +- Database connections returned to pool +- Event listeners removed when no longer needed +- Temporary resources cleaned up + +### **Integration Points** + +- API contracts honored (request/response shapes) +- External service failures handled gracefully +- Backward compatibility maintained for public interfaces +- Breaking changes identified and documented +- Migration paths exist for schema changes + +## **Security Checklist** + +### **Input Validation** + +- All user inputs are validated +- Input sanitization applied where needed +- Type checking enforced +- Boundary conditions handled + +### **SQL Injection** + +- Parameterized queries used +- No string concatenation for SQL +- ORM methods used correctly + +### **XSS (Cross-Site Scripting)** + +- Output encoding applied +- No `dangerouslySetInnerHTML` without sanitization +- URL parameters validated + +### **Authentication & Authorization** + +- Proper authentication checks +- Authorization verified for each endpoint +- Session management secure + +### **Secrets & Credentials** + +- No hardcoded secrets +- Environment variables used for sensitive data +- No credentials in logs + +## **Performance Checklist** + +### **Database** + +- N+1 queries avoided +- Proper indexes exist +- Query optimization applied + +### **Memory** + +- No memory leaks +- Large objects handled efficiently +- Caching used where appropriate + +### **Algorithms** + +- Appropriate data structures used +- Time complexity acceptable +- No nested loops that could be optimized diff --git a/.agents/skills/github-pr-review/SKILL.md b/.agents/skills/github-pr-review/SKILL.md new file mode 100644 index 0000000..6153ba4 --- /dev/null +++ b/.agents/skills/github-pr-review/SKILL.md @@ -0,0 +1,24 @@ +--- +name: github-pr-review +description: Reviews GitHub Pull Requests, analyzes diffs, and validates existing review comments for relevance. Use when the user provides a GitHub PR URL, asks to review a PR, поревьювить PR, проверить PR, or check whether existing review comments are still relevant. +--- + +# GitHub PR Review & Validation Rule + +## Работа со ссылкой на PR +Когда предоставлена ссылка на GitHub PR: +1. **Анализ изменений**: Изучи diff и файлы, затронутые в PR. +2. **Применение чек-листа**: Используй критерии из skill `code-review-checklist` (Logical Bugs, Edge Cases, Security, Performance) для анализа входящего кода. Сначала прочитай `.agents/skills/code-review-checklist/SKILL.md`. + +## Проверка существующих замечаний (Comments Validation) +Если в PR уже есть комментарии/замечания от других ревьюеров: +1. **Релевантность**: Проверь, актуально ли ещё замечание. Если код уже исправлен в последних коммитах — отметь это. +2. **Объективность**: Сверь замечание с текущим чек-листом. Если замечание противоречит стандартам проекта или чек-листу, укажи на это. + +## Формат ответа +Для каждого замечания (нового или существующего из PR) используй формат: +- **Локация**: [Файл : Строка] +- **Статус**: (Новое / Подтверждено / Исправлено / Неактуально) +- **Критичность**: (High / Medium / Low) +- **Суть**: Краткое описание проблемы согласно чек-листу. +- **Рекомендация**: Конкретный пример исправленного кода. From 779573c4b6b831cdf6c7cc8f372c37072a0ed612 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Tue, 18 Aug 2026 17:18:32 +0300 Subject: [PATCH 2/2] feat(UI): navigation [YTFRONT-5935] --- .../gravity-ui/references/package-routing.md | 4 +- plans/history-header-search-with-buttons.md | 81 ---- plans/navigation-fields-selector-a428f43b.md | 158 +++++++ plans/navigation-lists-render-override.md | 178 ++++++++ plans/navigation-meta-tab.md | 243 +++++++++++ plans/navigation-preview-tab.md | 215 ++++++++++ plans/navigation-review-e107d4f6.md | 282 +++++++++++++ plans/navigation-schema-props-grouping.md | 145 +++++++ plans/navigation-schema-tab.md | 204 +++++++++ plans/tutorials-history-plan.md | 59 --- src/components/Breadcrumbs/Breadcrumbs.scss | 25 ++ src/components/Breadcrumbs/Breadcrumbs.tsx | 114 +++++ .../Breadcrumbs/helpers/parsePathSegments.ts | 16 + .../i18n/dicts.ts | 0 src/components/Breadcrumbs/i18n/en.json | 4 + .../i18n/index.ts | 2 +- src/components/Breadcrumbs/i18n/ru.json | 4 + src/components/Breadcrumbs/index.ts | 2 + src/components/DataTable/DataTable.scss | 35 ++ .../DataTable/DataTable.stories.tsx | 65 +++ src/components/DataTable/DataTable.tsx | 81 ++++ src/components/DataTable/index.ts | 1 + src/components/EmptyContent/EmptyContent.scss | 3 + src/components/EmptyContent/EmptyContent.tsx | 57 +++ src/components/EmptyContent/i18n/dicts.ts | 4 + .../i18n/en.json | 3 + src/components/EmptyContent/i18n/index.ts | 5 + .../i18n/ru.json | 3 + src/components/EmptyContent/index.ts | 1 + .../FieldsSearchToolbar.tsx | 48 +++ src/components/FieldsSearchToolbar/index.ts | 2 + src/components/FieldsSelector/index.ts | 1 + .../HistoryListEmpty/HistoryListEmpty.scss | 3 - .../HistoryListEmpty/HistoryListEmpty.tsx | 27 -- src/components/HistoryListEmpty/index.ts | 2 - src/components/LazyList/LazyList.scss | 13 + src/components/LazyList/LazyList.tsx | 97 +++++ src/components/LazyList/index.ts | 2 + src/components/ListSpinner/ListSpinner.scss | 3 + src/components/ListSpinner/ListSpinner.tsx | 18 + src/components/ListSpinner/index.ts | 2 + src/components/PathEditor/PathEditor.scss | 41 ++ .../PathEditor/PathEditor.stories.helpers.ts | 118 ++++++ .../PathEditor/PathEditor.stories.tsx | 57 +++ src/components/PathEditor/PathEditor.tsx | 380 +++++++++++++++++ .../PathEditor/helpers/suggestions.ts | 40 ++ src/components/PathEditor/i18n/dicts.ts | 4 + src/components/PathEditor/i18n/en.json | 4 + src/components/PathEditor/i18n/index.ts | 5 + src/components/PathEditor/i18n/ru.json | 4 + src/components/PathEditor/index.ts | 2 + src/components/index.ts | 17 +- src/constants/row.ts | 1 + src/helpers/getDefaultNavigationIcon.ts | 33 ++ src/helpers/getParentPath.ts | 10 + src/helpers/useLoadMoreSentinel.ts | 31 ++ src/helpers/useVisibleColumns.ts | 26 ++ src/index.ts | 2 + src/modules/ClustersList/ClustersList.scss | 11 + src/modules/ClustersList/ClustersList.tsx | 50 +++ src/modules/ClustersList/index.ts | 4 + .../ClustersList/internal/ClusterRow.tsx | 34 ++ .../NavigationDetail/NavigationDetail.scss | 13 + .../NavigationDetail/NavigationDetail.tsx | 118 ++++++ src/modules/NavigationDetail/index.ts | 2 + .../internal/NavigationDetailTabs.tsx | 32 ++ .../NavigationHeader.stories.tsx | 118 ++++++ .../NavigationHeader/NavigationHeader.tsx | 50 +++ src/modules/NavigationHeader/index.ts | 2 + .../NavigationItemsList.scss | 65 +++ .../NavigationItemsList.tsx | 93 ++++ src/modules/NavigationItemsList/index.ts | 4 + .../internal/NavigationItemRow.tsx | 28 ++ .../NavigationItemsListEmptyState.tsx | 45 ++ .../internal/NavigationItemsListHeader.scss | 6 + .../internal/NavigationItemsListHeader.tsx | 58 +++ .../internal/useParentRow.ts | 23 + .../NavigationMeta/NavigationMeta.scss | 25 ++ .../NavigationMeta/NavigationMeta.stories.tsx | 101 +++++ src/modules/NavigationMeta/NavigationMeta.tsx | 87 ++++ .../helpers/buildMetaGroups.tsx | 29 ++ src/modules/NavigationMeta/i18n/dicts.ts | 4 + src/modules/NavigationMeta/i18n/en.json | 4 + src/modules/NavigationMeta/i18n/index.ts | 5 + src/modules/NavigationMeta/i18n/ru.json | 4 + src/modules/NavigationMeta/index.ts | 3 + .../NavigationPreview/NavigationPreview.scss | 24 ++ .../NavigationPreview.stories.tsx | 105 +++++ .../NavigationPreview/NavigationPreview.tsx | 110 +++++ .../helpers/buildPreviewColumns.tsx | 19 + .../helpers/filterPreviewRows.ts | 29 ++ src/modules/NavigationPreview/i18n/dicts.ts | 4 + src/modules/NavigationPreview/i18n/en.json | 3 + src/modules/NavigationPreview/i18n/index.ts | 5 + src/modules/NavigationPreview/i18n/ru.json | 3 + src/modules/NavigationPreview/index.ts | 4 + .../NavigationSchema/NavigationSchema.scss | 24 ++ .../NavigationSchema.stories.tsx | 119 ++++++ .../NavigationSchema/NavigationSchema.tsx | 118 ++++++ .../helpers/buildSchemaColumns.tsx | 60 +++ .../NavigationSchema/helpers/filterSchema.ts | 18 + src/modules/NavigationSchema/i18n/dicts.ts | 4 + src/modules/NavigationSchema/i18n/en.json | 9 + src/modules/NavigationSchema/i18n/index.ts | 5 + src/modules/NavigationSchema/i18n/ru.json | 9 + src/modules/NavigationSchema/index.ts | 4 + src/modules/RowsList/RowsList.tsx | 16 +- src/modules/index.ts | 14 + src/types/navigation.ts | 177 ++++++++ src/types/pathEditor.ts | 29 ++ .../QueriesNavigation/QueriesNavigation.scss | 7 + .../QueriesNavigation.stories.tsx | 397 ++++++++++++++++++ .../QueriesNavigation/QueriesNavigation.tsx | 195 +++++++++ .../helpers/createEmptyDetailConfig.ts | 13 + .../helpers/createNavigationDetailResolver.ts | 28 ++ .../helpers/createTableDetailConfig.tsx | 90 ++++ src/widgets/QueriesNavigation/i18n/dicts.ts | 4 + src/widgets/QueriesNavigation/i18n/en.json | 10 + src/widgets/QueriesNavigation/i18n/index.ts | 5 + src/widgets/QueriesNavigation/i18n/ru.json | 10 + src/widgets/QueriesNavigation/index.ts | 10 + src/widgets/index.ts | 11 + 122 files changed, 5512 insertions(+), 185 deletions(-) delete mode 100644 plans/history-header-search-with-buttons.md create mode 100644 plans/navigation-fields-selector-a428f43b.md create mode 100644 plans/navigation-lists-render-override.md create mode 100644 plans/navigation-meta-tab.md create mode 100644 plans/navigation-preview-tab.md create mode 100644 plans/navigation-review-e107d4f6.md create mode 100644 plans/navigation-schema-props-grouping.md create mode 100644 plans/navigation-schema-tab.md delete mode 100644 plans/tutorials-history-plan.md create mode 100644 src/components/Breadcrumbs/Breadcrumbs.scss create mode 100644 src/components/Breadcrumbs/Breadcrumbs.tsx create mode 100644 src/components/Breadcrumbs/helpers/parsePathSegments.ts rename src/components/{HistoryListEmpty => Breadcrumbs}/i18n/dicts.ts (100%) create mode 100644 src/components/Breadcrumbs/i18n/en.json rename src/components/{HistoryListEmpty => Breadcrumbs}/i18n/index.ts (55%) create mode 100644 src/components/Breadcrumbs/i18n/ru.json create mode 100644 src/components/Breadcrumbs/index.ts create mode 100644 src/components/DataTable/DataTable.scss create mode 100644 src/components/DataTable/DataTable.stories.tsx create mode 100644 src/components/DataTable/DataTable.tsx create mode 100644 src/components/DataTable/index.ts create mode 100644 src/components/EmptyContent/EmptyContent.scss create mode 100644 src/components/EmptyContent/EmptyContent.tsx create mode 100644 src/components/EmptyContent/i18n/dicts.ts rename src/components/{HistoryListEmpty => EmptyContent}/i18n/en.json (50%) create mode 100644 src/components/EmptyContent/i18n/index.ts rename src/components/{HistoryListEmpty => EmptyContent}/i18n/ru.json (52%) create mode 100644 src/components/EmptyContent/index.ts create mode 100644 src/components/FieldsSearchToolbar/FieldsSearchToolbar.tsx create mode 100644 src/components/FieldsSearchToolbar/index.ts delete mode 100644 src/components/HistoryListEmpty/HistoryListEmpty.scss delete mode 100644 src/components/HistoryListEmpty/HistoryListEmpty.tsx delete mode 100644 src/components/HistoryListEmpty/index.ts create mode 100644 src/components/LazyList/LazyList.scss create mode 100644 src/components/LazyList/LazyList.tsx create mode 100644 src/components/LazyList/index.ts create mode 100644 src/components/ListSpinner/ListSpinner.scss create mode 100644 src/components/ListSpinner/ListSpinner.tsx create mode 100644 src/components/ListSpinner/index.ts create mode 100644 src/components/PathEditor/PathEditor.scss create mode 100644 src/components/PathEditor/PathEditor.stories.helpers.ts create mode 100644 src/components/PathEditor/PathEditor.stories.tsx create mode 100644 src/components/PathEditor/PathEditor.tsx create mode 100644 src/components/PathEditor/helpers/suggestions.ts create mode 100644 src/components/PathEditor/i18n/dicts.ts create mode 100644 src/components/PathEditor/i18n/en.json create mode 100644 src/components/PathEditor/i18n/index.ts create mode 100644 src/components/PathEditor/i18n/ru.json create mode 100644 src/components/PathEditor/index.ts create mode 100644 src/helpers/getDefaultNavigationIcon.ts create mode 100644 src/helpers/getParentPath.ts create mode 100644 src/helpers/useLoadMoreSentinel.ts create mode 100644 src/helpers/useVisibleColumns.ts create mode 100644 src/modules/ClustersList/ClustersList.scss create mode 100644 src/modules/ClustersList/ClustersList.tsx create mode 100644 src/modules/ClustersList/index.ts create mode 100644 src/modules/ClustersList/internal/ClusterRow.tsx create mode 100644 src/modules/NavigationDetail/NavigationDetail.scss create mode 100644 src/modules/NavigationDetail/NavigationDetail.tsx create mode 100644 src/modules/NavigationDetail/index.ts create mode 100644 src/modules/NavigationDetail/internal/NavigationDetailTabs.tsx create mode 100644 src/modules/NavigationHeader/NavigationHeader.stories.tsx create mode 100644 src/modules/NavigationHeader/NavigationHeader.tsx create mode 100644 src/modules/NavigationHeader/index.ts create mode 100644 src/modules/NavigationItemsList/NavigationItemsList.scss create mode 100644 src/modules/NavigationItemsList/NavigationItemsList.tsx create mode 100644 src/modules/NavigationItemsList/index.ts create mode 100644 src/modules/NavigationItemsList/internal/NavigationItemRow.tsx create mode 100644 src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx create mode 100644 src/modules/NavigationItemsList/internal/NavigationItemsListHeader.scss create mode 100644 src/modules/NavigationItemsList/internal/NavigationItemsListHeader.tsx create mode 100644 src/modules/NavigationItemsList/internal/useParentRow.ts create mode 100644 src/modules/NavigationMeta/NavigationMeta.scss create mode 100644 src/modules/NavigationMeta/NavigationMeta.stories.tsx create mode 100644 src/modules/NavigationMeta/NavigationMeta.tsx create mode 100644 src/modules/NavigationMeta/helpers/buildMetaGroups.tsx create mode 100644 src/modules/NavigationMeta/i18n/dicts.ts create mode 100644 src/modules/NavigationMeta/i18n/en.json create mode 100644 src/modules/NavigationMeta/i18n/index.ts create mode 100644 src/modules/NavigationMeta/i18n/ru.json create mode 100644 src/modules/NavigationMeta/index.ts create mode 100644 src/modules/NavigationPreview/NavigationPreview.scss create mode 100644 src/modules/NavigationPreview/NavigationPreview.stories.tsx create mode 100644 src/modules/NavigationPreview/NavigationPreview.tsx create mode 100644 src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx create mode 100644 src/modules/NavigationPreview/helpers/filterPreviewRows.ts create mode 100644 src/modules/NavigationPreview/i18n/dicts.ts create mode 100644 src/modules/NavigationPreview/i18n/en.json create mode 100644 src/modules/NavigationPreview/i18n/index.ts create mode 100644 src/modules/NavigationPreview/i18n/ru.json create mode 100644 src/modules/NavigationPreview/index.ts create mode 100644 src/modules/NavigationSchema/NavigationSchema.scss create mode 100644 src/modules/NavigationSchema/NavigationSchema.stories.tsx create mode 100644 src/modules/NavigationSchema/NavigationSchema.tsx create mode 100644 src/modules/NavigationSchema/helpers/buildSchemaColumns.tsx create mode 100644 src/modules/NavigationSchema/helpers/filterSchema.ts create mode 100644 src/modules/NavigationSchema/i18n/dicts.ts create mode 100644 src/modules/NavigationSchema/i18n/en.json create mode 100644 src/modules/NavigationSchema/i18n/index.ts create mode 100644 src/modules/NavigationSchema/i18n/ru.json create mode 100644 src/modules/NavigationSchema/index.ts create mode 100644 src/types/navigation.ts create mode 100644 src/types/pathEditor.ts create mode 100644 src/widgets/QueriesNavigation/QueriesNavigation.scss create mode 100644 src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx create mode 100644 src/widgets/QueriesNavigation/QueriesNavigation.tsx create mode 100644 src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts create mode 100644 src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts create mode 100644 src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx create mode 100644 src/widgets/QueriesNavigation/i18n/dicts.ts create mode 100644 src/widgets/QueriesNavigation/i18n/en.json create mode 100644 src/widgets/QueriesNavigation/i18n/index.ts create mode 100644 src/widgets/QueriesNavigation/i18n/ru.json create mode 100644 src/widgets/QueriesNavigation/index.ts diff --git a/.agents/skills/gravity-ui/references/package-routing.md b/.agents/skills/gravity-ui/references/package-routing.md index 994d5d0..6428a27 100644 --- a/.agents/skills/gravity-ui/references/package-routing.md +++ b/.agents/skills/gravity-ui/references/package-routing.md @@ -340,13 +340,13 @@ A library for rendering whole web pages or page sections from declarative JSON/Y - Data-driven pages: render a `content` config of typed blocks with `PageConstructor` wrapped in `PageConstructorProvider`. - Marketing, landing, and documentation pages assembled from prebuilt blocks (headers, media, cards, etc.). - Server-side YFM processing of block text via the `@gravity-ui/page-constructor/server` utilities (`contentTransformer`, `fullTransform`). -- Reusing just the responsive grid (`Grid`/`Row`/`Col`) or `Navigation` component standalone. +- Reusing just the responsive grid (`Grid`/`Row`/`Col`) or `QueriesNavigation` component standalone. #### When not to use - General application UI (buttons, forms, modals) — use [`@gravity-ui/uikit`](https://github.com/gravity-ui/uikit). - Editing Markdown/YFM content — use [`@gravity-ui/markdown-editor`](https://github.com/gravity-ui/markdown-editor). -- App navigation shells (aside header) — use [`@gravity-ui/navigation`](https://github.com/gravity-ui/navigation); this package's `Navigation` is a page-level top nav. +- App navigation shells (aside header) — use [`@gravity-ui/navigation`](https://github.com/gravity-ui/navigation); this package's `QueriesNavigation` is a page-level top nav. ## Page-constructor-builder — `@gravity-ui/page-constructor-builder` diff --git a/plans/history-header-search-with-buttons.md b/plans/history-header-search-with-buttons.md deleted file mode 100644 index de17025..0000000 --- a/plans/history-header-search-with-buttons.md +++ /dev/null @@ -1,81 +0,0 @@ -# Рефакторинг 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={[]}` и `endButtons={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 -``` diff --git a/plans/navigation-fields-selector-a428f43b.md b/plans/navigation-fields-selector-a428f43b.md new file mode 100644 index 0000000..5b169f6 --- /dev/null +++ b/plans/navigation-fields-selector-a428f43b.md @@ -0,0 +1,158 @@ +# План: правки вкладок NavigationDetail (meta без поиска + FieldsSelector в schema/preview) + +Правки по мотивам коммита `a428f43b`, который добавил вкладки `schema` / `preview` / `meta` / `view` +в [`createTableDetailConfig()`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:34) +и общий ряд поиска в [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:102). + +## Требования + +1. Во вкладке **meta** не показывать строку поиска — она там не нужна. +2. Во вкладках **schema** и **preview** добавить возможность выбирать видимые колонки данных + через кнопку `FieldsSelector`. + +## Договорённости (уточнено с заказчиком) + +- В **schema** `FieldsSelector` управляет видимостью **столбцов метаданных** таблицы + (`name` / `type` / `sortOrder` / `required`). +- В **preview** `FieldsSelector` управляет видимостью **колонок данных**. +- Кнопка `FieldsSelector` располагается в той же строке, что и поиск — в `endButtons` + компонента [`SearchWithButtons`](src/components/SearchWithButtons/SearchWithButtons.tsx:11). +- Состояние видимых колонок — **внутреннее в модулях** `NavigationSchema` / `NavigationPreview` + (uncontrolled по умолчанию) с возможностью **controlled‑переопределения через props**. + +## Текущее состояние (что уже есть) + +- [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:102) рендерит один общий + `SearchWithButtons` для всех вкладок, если `config.hasSearch === true`. Значит поиск сейчас виден + и на `meta`, и на `view` (где он бесполезен). +- [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:39) + задаёт `hasSearch: true` на уровне конфига (общий для всех вкладок). +- Поиск используется только в `schema` (передаётся в `renderContent({search})`); + `preview` и `meta` его игнорируют. +- [`FieldsSelector`](src/components/FieldsSelector/FieldsSelector.tsx:24) уже реализован и + экспортируется, имеет i18n‑ключ `action_configure-visible-fields`, API: + `fields` / `value` / `onChange` / `buttonLabel`. +- [`SearchWithButtons`](src/components/SearchWithButtons/SearchWithButtons.tsx:11) уже поддерживает + `endButtons` и `innerButtons`. + +## Ключевое архитектурное решение + +Чтобы одновременно выполнить «кнопка в `endButtons` у `SearchWithButtons`» и «состояние внутри +модулей», строку поиска/тулбара для табличных вкладок должен рендерить **сам модуль** +(`NavigationSchema` / `NavigationPreview`), а не общий ряд `NavigationDetail`. Это же автоматически +убирает поиск с вкладок `meta` и `view` (они свой тулбар не рендерят). + +Механика: + +- `createTableDetailConfig` перестаёт полагаться на общий `hasSearch` (ставит `hasSearch: false`) + и передаёт контекст поиска (`search`, `onSearchUpdate`, `searchPlaceholder`) вниз в + `renderContent` каждой табличной вкладки. +- `NavigationSchema` / `NavigationPreview` рендерят собственный `SearchWithButtons` + (поиск + `FieldsSelector` в `endButtons`) над `DataTable`. +- `NavigationMeta` и вкладка `view` тулбар не рендерят → поиска на них нет (требование 1). +- Общий механизм `config.hasSearch` в `NavigationDetail` **сохраняется** для обратной совместимости + кастомных конфигов (например, история `CustomDetailResolver` со своими вкладками). + +## Изменения по файлам + +### 1. Типы — [`src/types/navigation.ts`](src/types/navigation.ts:63) + +- Расширить `NavigationDetailTabRenderContext`: добавить необязательные + `onSearchUpdate?: (value: string) => void` и `searchPlaceholder?: string` + (сейчас там только `search`). Нужно, чтобы модуль во вкладке мог отрисовать + контролируемый `SearchWithButtons`. +- (Опционально) публичные типы для контролируемого выбора колонок вынести в + props модулей (см. ниже), а не в конфиг. + +### 2. [`NavigationDetail.tsx`](src/modules/NavigationDetail/NavigationDetail.tsx:83) + +- В `activeTabConfig.renderContent(...)` передавать расширенный контекст: + `{search, onSearchUpdate: handleSearchUpdate, searchPlaceholder: config.searchPlaceholder}`. +- Логику общего `config.hasSearch` оставить без изменений (для кастомных вкладок). + +### 3. [`createTableDetailConfig.tsx`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:39) + +- Заменить `hasSearch: true` на `hasSearch: false` (тулбар теперь внутри модулей). +- `schema.renderContent`: пробрасывать `search` / `onSearchUpdate` / `searchPlaceholder` + в `NavigationSchema` и включать выбор столбцов. +- `preview.renderContent`: пробрасывать `search` / `onSearchUpdate` / `searchPlaceholder` + в `NavigationPreview` и включать выбор колонок. +- `meta.renderContent`: без изменений по сути — тулбар/поиск не рендерится (требование 1). +- `view`: без изменений (`content: null`). +- (Опционально) прокинуть в опции фабрики контролируемые пропсы выбора колонок + (`schemaVisibleColumns` / `previewVisibleColumns` и колбэки), если консюмеру нужен controlled‑режим. + +### 4. [`NavigationSchema.tsx`](src/modules/NavigationSchema/NavigationSchema.tsx:28) + +- Новые props: `search?`, `onSearchUpdate?`, `searchPlaceholder?`, + `visibleColumns?: string[]`, `onVisibleColumnsChange?: (v: string[]) => void`, + `defaultVisibleColumns?: string[]`, и флаг включения селектора (по умолчанию включён, + например `hideFieldsSelector?: boolean`). +- Внутреннее (uncontrolled) состояние видимых столбцов метаданных; если задан `visibleColumns` — + работать в controlled‑режиме. +- Список опций селектора строить из отображаемых столбцов (`resolvedColumns`): + `{id: column.name, title: column.header}`. По умолчанию видимы все. +- Перед передачей в `DataTable` фильтровать `resolvedColumns` по набору видимых имён. +- Рендерить `SearchWithButtons` (значение `search`, `onUpdate={onSearchUpdate}`, + `placeholder={searchPlaceholder}`) с `endButtons={[]}` над таблицей. + +### 5. [`NavigationPreview.tsx`](src/modules/NavigationPreview/NavigationPreview.tsx:24) + +- Аналогичные новые props (см. п.4). +- Опции селектора — из `data.columns` (`{id: column, title: column}`). По умолчанию видимы все. +- Фильтровать отображаемые колонки данных по набору видимых. +- Рендерить `SearchWithButtons` + `FieldsSelector` в `endButtons` над таблицей. +- Решение по поиску в preview (на ревью): либо реализовать простую клиентскую фильтрацию строк по + подстроке (чтобы строка поиска была функциональной, как в schema), либо показывать в тулбаре + только `FieldsSelector` без инпута поиска. По умолчанию в плане — показать `SearchWithButtons` + с функциональной фильтрацией строк по подстроке для консистентности с schema. + +### 6. i18n + +- Отдельный ключ не нужен: у `FieldsSelector` уже есть + `action_configure-visible-fields` (кнопка). Плейсхолдеры поиска берём из существующих ключей + виджета (`field_detail-search-placeholder`). + +### 7. Барели / экспорт + +- Обновить экспорт типов props модулей (`NavigationSchemaProps`, `NavigationPreviewProps`) + в [`src/modules/index.ts`](src/modules/index.ts:1), если добавились новые публичные поля/типы. +- `FieldsSelector` уже экспортируется из [`src/components/index.ts`](src/components/index.ts:15). + +### 8. Storybook + +- В [`QueriesNavigation.stories.tsx`](src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx:203): + убедиться, что во вкладке meta поиска нет, а в schema/preview появилась кнопка выбора колонок. +- Обновить/добавить истории модулей + [`NavigationSchema.stories.tsx`](src/modules/NavigationSchema/NavigationSchema.stories.tsx) и + [`NavigationPreview.stories.tsx`](src/modules/NavigationPreview/NavigationPreview.stories.tsx): + default (селектор + поиск), controlled‑режим видимых колонок, пустой выбор. + +### 9. Проверка + +- `npm run build` и Storybook — убедиться, что нет регрессий и импорты идут строго + `widgets → modules → components` (правила [AGENTS.md](AGENTS.md)). + +## Диаграмма (после изменений) + +```mermaid +flowchart TD + Detail[NavigationDetail] -->|renderContent search onSearchUpdate| SchemaTab[schema tab] + Detail -->|renderContent search onSearchUpdate| PreviewTab[preview tab] + Detail -->|renderContent| MetaTab[meta tab] + SchemaTab --> Schema[NavigationSchema] + PreviewTab --> Preview[NavigationPreview] + MetaTab --> Meta[NavigationMeta no toolbar] + Schema --> SchemaToolbar[SearchWithButtons + FieldsSelector endButtons] + Preview --> PreviewToolbar[SearchWithButtons + FieldsSelector endButtons] + SchemaToolbar --> SchemaTable[DataTable filtered meta columns] + PreviewToolbar --> PreviewTable[DataTable filtered data columns] +``` + +## Границы / договорённости + +- Механизм `config.hasSearch` в `NavigationDetail` сохраняется для кастомных вкладок + (обратная совместимость). +- Состояние видимых колонок по умолчанию — внутри модулей; controlled‑режим через props. +- Импорты строго `widgets → modules → components`. +- Поведение вкладки `view` (`content: null`) не меняется. diff --git a/plans/navigation-lists-render-override.md b/plans/navigation-lists-render-override.md new file mode 100644 index 0000000..b027542 --- /dev/null +++ b/plans/navigation-lists-render-override.md @@ -0,0 +1,178 @@ +# План: переопределение строк в ClustersList и NavigationItemsList + +## Цель + +Дать потребителю библиотеки возможность переопределять рендер строк в двух +списках модуля навигации — [`ClustersList`](src/modules/ClustersList/ClustersList.tsx:22) +и [`NavigationItemsList`](src/modules/NavigationItemsList/NavigationItemsList.tsx:30) — +по аналогии с уже реализованным паттерном `renderRowItem` в +[`HistoryList`](src/modules/HistoryList/HistoryList.tsx:23) и его пробросом в виджет +[`QueriesHistory`](src/widgets/QueriesHistory/QueriesHistory.tsx:32). + +## Эталонный паттерн (как сделано в HistoryList) + +- Модуль принимает опциональный проп `renderRowItem?: (data) => React.ReactNode`. +- Если проп передан — используется он, иначе рендерится дефолтный контент строки + ([`HistoryRowContent`](src/modules/HistoryList/HistoryRowContent.tsx:7)). +- В `data` передаётся вся информация, нужная для рендера (item, index, isActive и т.д.), + см. `QueryHistoryRowRenderData` в [`src/types/history.ts`](src/types/history.ts:91). +- Виджет [`QueriesHistory`](src/widgets/QueriesHistory/QueriesHistory.tsx:90) просто + пробрасывает `renderRowItem` в модуль. + +## Ключевые особенности навигации + +1. `ClustersList` рендерит [`ClusterRow`](src/modules/ClustersList/internal/ClusterRow.tsx:12) + для каждого `NavigationCluster`. +2. `NavigationItemsList` рендерит [`NavigationItemRow`](src/modules/NavigationItemsList/internal/NavigationItemRow.tsx:13) + в двух местах: + - в основном `LazyList` (для каждого `NavigationItem`, включая синтетическую parent-row `..`); + - внутри [`NavigationItemsListEmptyState`](src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx:16) + для parent-row над empty-content. + + Значит, кастомный рендер должен применяться в обоих местах, и его нужно + пробросить в `NavigationItemsListEmptyState`. +3. `LazyList` уже отдаёт в `renderItem` сигнатуру `(item, isActive, index)` + (см. [`LazyList`](src/components/LazyList/LazyList.tsx:21)) — можно использовать её + как основу для render-данных. + +## Дженерики: возможность передать расширенный объект данных + +Сейчас [`NavigationItem`](src/types/navigation.ts:29) и +[`NavigationCluster`](src/types/navigation.ts:18) — фиксированные типы, а модули +жёстко типизированы `NavigationItem[]` / `NavigationCluster[]`. Если потребитель +передаст объект с доп. полями (`cluster.env`, `item.owner` и т.п.), внутри +`renderRowItem` эти поля будут не видны в типах. + +Эталон `HistoryList` решает это дженериком ``, протянутым +через `items` / `renderRowItem` / `onItemClick` / `getRowActions`. Применяем тот же +подход к навигации: делаем оба модуля и типы render-данных дженериками с дефолтом, +что сохраняет обратную совместимость (кто не использует доп. поля — ничего не меняет). + +## Дизайн API + +### Типы (в [`src/types/navigation.ts`](src/types/navigation.ts:1)) + +```ts +export type NavigationItemRowRenderData = { + item: T; + index: number; + isActive: boolean; + /** true, если это синтетическая parent-row ".." (не несёт доп. полей T) */ + isParentRow: boolean; +}; + +export type NavigationClusterRowRenderData = { + cluster: T; + index: number; + isActive: boolean; +}; + +export type RenderNavigationItem = + (data: NavigationItemRowRenderData) => React.ReactNode; +export type RenderNavigationCluster = + (data: NavigationClusterRowRenderData) => React.ReactNode; +``` + +Примечание: `isParentRow` полезен, т.к. parent-row `..` — синтетическая строка +(создаётся внутри в [`useParentRow`](src/modules/NavigationItemsList/internal/useParentRow.ts:10) +как базовый `NavigationItem` без доп. полей `T`), и потребитель может отрендерить её +иначе либо оставить дефолт. Финальный состав полей уточняется на ревью. + +### ClustersList + +- Сделать дженериком: `ClustersList`. +- `items: T[]`, `onItemClick?: (cluster: T) => void`. +- Добавить проп `renderRowItem?: RenderNavigationCluster`. +- В `renderItem` использовать `renderRowItem?.(data) ?? `. + +### NavigationItemsList + +- Сделать дженериком: `NavigationItemsList`. +- `items: T[]`, `onItemClick?: (item: T) => void`. +- Добавить проп `renderRowItem?: RenderNavigationItem`. +- Использовать его в основном `LazyList.renderItem`. +- Пробросить `renderRowItem` в `NavigationItemsListEmptyState`, чтобы parent-row в + empty-state рендерился тем же кастомным рендером (с `isParentRow: true`). + +### NavigationItemsListEmptyState + +- Принять `renderRowItem?: RenderNavigationItem` и применить к parent-row + вместо жёсткого `NavigationItemRow`. + +### Виджет QueriesNavigation + +- Сделать дженериком по типам item/cluster: + `QueriesNavigation`. +- Протянуть `TItem` / `TCluster` через `items` / `clusters` / `onItemClick` / + `onClusterClick`. +- Добавить пропы `renderNavigationItem?: RenderNavigationItem` и + `renderClusterItem?: RenderNavigationCluster`. +- Пробросить их в `NavigationItemsList` (как `renderRowItem`) и `ClustersList` + (как `renderRowItem`) соответственно. +- Примечание: `QueriesNavigation` сейчас объявлен как `FC` — + для дженерика придётся переписать сигнатуру на обычную дженерик-функцию + (как сделано в [`QueriesHistory`](src/widgets/QueriesHistory/QueriesHistory.tsx:39)), + т.к. `React.FC` не поддерживает дженерики. + +## Экспорт дефолтных строк + +Чтобы потребитель мог переиспользовать/обернуть дефолтную строку внутри своего +рендера (частый сценарий: «то же, что дефолт, но с доп. элементом»), сделать +`NavigationItemRow` и `ClusterRow` частью публичного API. + +Решение: **оставляем компоненты в `internal/`** и ре-экспортируем их через `index.ts` +модулей: + +- [`src/modules/ClustersList/index.ts`](src/modules/ClustersList/index.ts:1) — + добавить `export {ClusterRow}` и `export type {ClusterRowProps}`. +- [`src/modules/NavigationItemsList/index.ts`](src/modules/NavigationItemsList/index.ts:1) — + добавить `export {NavigationItemRow}` и `export type {NavigationItemRowProps}`. +- Далее эти реэкспорты подхватываются в [`src/modules/index.ts`](src/modules/index.ts:1) + и [`src/index.ts`](src/index.ts:1). + +## Диаграмма потока рендера + +```mermaid +flowchart TD + Widget[QueriesNavigation] -->|renderClusterItem| CL[ClustersList] + Widget -->|renderNavigationItem| NL[NavigationItemsList] + CL -->|renderRowItem or default| CRow[ClusterRow] + NL -->|renderRowItem or default| NRow[NavigationItemRow] + NL -->|renderRowItem| ES[NavigationItemsListEmptyState] + ES -->|parent-row: renderRowItem or default| NRow +``` + +## Шаги реализации + +1. Добавить типы render-данных и render-функций в [`src/types/navigation.ts`](src/types/navigation.ts:1). +2. Вынести/экспортировать дефолтные строки `NavigationItemRow` и `ClusterRow` в + публичный API согласно [`AGENTS.md`](AGENTS.md:1). +3. Добавить `renderRowItem` в [`ClustersList`](src/modules/ClustersList/ClustersList.tsx:22). +4. Добавить `renderRowItem` в [`NavigationItemsList`](src/modules/NavigationItemsList/NavigationItemsList.tsx:30) + (основной список). +5. Пробросить `renderRowItem` в [`NavigationItemsListEmptyState`](src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx:16) + для parent-row. +6. Добавить `renderNavigationItem`/`renderClusterItem` в + [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:42) и пробросить в модули. +7. Обновить barrel-экспорты: index.ts модулей, + [`src/modules/index.ts`](src/modules/index.ts:1), [`src/index.ts`](src/index.ts:1) + (новые типы + дефолтные компоненты строк). +8. Добавить/обновить stories с примером переопределения строк для `ClustersList`, + `NavigationItemsList` и `QueriesNavigation`. +9. Проверить сборку и Storybook. + +## Решения + +- Render-данные включают флаг `isParentRow` — потребитель отличает синтетическую + parent-row `..` и не ожидает от неё доп. полей `T`. (Подтверждено.) +- Модули и типы render-данных делаем дженериками с дефолтом + (`NavigationItem` / `NavigationCluster`) — обратная совместимость сохраняется. + +## Открытые вопросы для ревью + +- Именование пропа в модулях: `renderRowItem` (как в `HistoryList`) — оставить так + для консистентности. +- Именование пропов виджета: `renderNavigationItem` / `renderClusterItem`. +- Нужно ли выносить `NavigationItemRow` / `ClusterRow` в `src/components/` или + достаточно ре-экспорта из `internal/`. +- Делать ли дженериком сам виджет `QueriesNavigation` или оставить дефолтные типы. diff --git a/plans/navigation-meta-tab.md b/plans/navigation-meta-tab.md new file mode 100644 index 0000000..c0f9cd7 --- /dev/null +++ b/plans/navigation-meta-tab.md @@ -0,0 +1,243 @@ +# План: компонент просмотра метаданных во вкладке Meta (NavigationDetail) + +## Контекст + +Во вкладке **Meta** детали таблицы (`NavigationDetail`) сейчас пусто. Вкладка создаётся в +[`createTableDetailConfig()`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:48) +с `content: null`. Нужно наполнить её просмотрщиком метаданных таблицы по мотивам +`@ytsaurus/components` (`NavigationTable/NavigationMetaTab`), но адаптированным под +архитектуру querieskit. + +Задача поставлена по образцу уже реализованных вкладок **Schema** и **Preview** (см. +[`plans/navigation-schema-tab.md`](plans/navigation-schema-tab.md) и +[`plans/navigation-preview-tab.md`](plans/navigation-preview-tab.md)): те же принципы — +нормализованные данные на входе, минимальный набор + расширяемость, возможность полного +переопределения вида консюмером. + +### Договорённости с заказчиком + +- Просмотр метаданных встраиваем во вкладку **`meta`**. Вкладку **`view`** оставляем + пустой (`content: null`). +- На вход принимаем **уже преобразованные** данные (значения как строки/ReactNode), + библиотека **не** завязывается на сырые YT-структуры (YSON) и unipika. +- Требования аналогичны Schema/Preview: минимальный набор возможностей, но с расширением и + возможностью полностью переопределить содержимое вкладки `meta` своим рендером. + +## Анализ эталона ytsaurus (можно ли скопировать 1:1) + +Эталонный [`NavigationTable.tsx`](https://github.com/ytsaurus/ytsaurus-ui/blob/fe5de5c62a6e06e0adaa0a7746c5d61874ef0909/packages/components/src/modules/NavigationTable/NavigationTable.tsx) +рендерит `NavigationMetaTab` так: + +```text +renderMetaTab?: (props: {items: NavigationTableMeta[][]}) => React.ReactNode; +... +const metaContent = + activeTab === TableTab.Meta && + (renderMetaTab + ? renderMetaTab({items: table.meta}) + : ); +``` + +Ключевые наблюдения из эталона: + +- метаданные — это **массив групп** пар «ключ → значение» (`NavigationTableMeta[][]`); + каждая группа рендерится своим блоком, внутри группы — список key/value; +- есть хук **полного переопределения** таба через `renderMetaTab({items})`. + +**Вывод: скопировать `NavigationMetaTab` 1:1 нельзя.** В querieskit нет инфраструктуры +ytsaurus (Yson/unipika, внутренние компоненты meta-разметки), и по договорённости мы её +сознательно не тянем. Данные приходят **уже преобразованными**. + +Что **переносим по смыслу** (а не по коду): + +- модель данных «группы пар ключ→значение»; +- паттерн полного переопределения вида (`renderMeta` / `view.render`); +- состояния загрузки / пустоты / ошибки — как в + [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:28) и + [`NavigationPreview`](src/modules/NavigationPreview/NavigationPreview.tsx:24). + +Дефолтный рендер строим на компоненте `DefinitionList` из `@gravity-ui/components` +(готовое отображение пар ключ→значение), а не на `DataTable` — для метаданных это +семантически точнее, чем таблица. + +## Проектные решения + +### 1. Публичные типы Meta (`src/types/navigation.ts`) + +Минимальный расширяемый контракт. Единица метаданных — пара «имя → значение», уже +приведённое к строке/ReactNode. Группа — набор таких пар с необязательным заголовком. + +```text +NavigationMetaValue = ReactNode; // готовое к отображению значение + +NavigationMetaItem = { + name: string; // подпись поля + value: NavigationMetaValue; // готовое значение + // расширяемость для YT-кастомизации + [key: string]: unknown; +}; + +NavigationMetaGroup = { + title?: string; // необязательный заголовок группы + items: TItem[]; +}; + +NavigationMetaConfig = { + groups: NavigationMetaGroup[]; // группы пар (аналог NavigationTableMeta[][]) + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; +}; +``` + +Дженерик `TItem` проходит через типы/компонент, чтобы консюмер мог типобезопасно +передавать свою форму элемента и рендерить её в кастомном виде. + +### 2. Новый модуль `src/modules/NavigationMeta` + +По правилам [AGENTS.md](AGENTS.md) это **module** (сборка отображения метаданных в сценарий +одной вкладки; не самостоятельный атомарный компонент). Структура — зеркало +`NavigationPreview`: + +```text +modules/NavigationMeta/ + NavigationMeta.tsx + NavigationMeta.scss + NavigationMeta.stories.tsx + helpers/ + buildMetaGroups.tsx // дефолтный маппинг групп в элементы DefinitionList + i18n/ + en.json + ru.json + dicts.ts + index.ts + index.ts +``` + +Пропсы `NavigationMeta` (по образцу +[`NavigationPreviewProps`](src/modules/NavigationPreview/NavigationPreview.tsx:18)): + +```text +NavigationMetaViewConfig = { + // ПОЛНОЕ переопределение содержимого вкладки meta (замена дефолтного рендера) + render?: (data: NavigationMetaConfig) => ReactNode; + // ДОБАВЛЕНИЕ произвольного блока после дефолтных групп + extraContent?: ReactNode; +}; + +NavigationMetaProps = { + data: NavigationMetaConfig; + view?: NavigationMetaViewConfig; + className?: string; +}; +``` + +Поведение (по паттерну +[`NavigationPreview`](src/modules/NavigationPreview/NavigationPreview.tsx:24)): + +- `view.render` задан → используем его как есть (полная кастомизация под YT, минуя дефолт). +- Ошибка (`errorContent`) → показываем текст ошибки + (`` — паттерн из + [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:46)). +- `loading` → скелетон/лоадер; `loaded` при пустых `groups` → пустое состояние + (текст `context_empty` из i18n). +- Иначе рендерим дефолт: по группе на каждый `NavigationMetaGroup` — заголовок группы + (если есть) + `DefinitionList` с парами `name → value` (прочерк `value_empty` при + отсутствии значения), затем `view.extraContent`. + +> Примечание про search: вкладка meta по search **не фильтруется** (детальный поиск в +> NavigationDetail семантически про поля схемы). `renderContent` для вкладки meta +> игнорирует `search`, как и preview. + +`buildMetaGroups(groups, i18n)` — минимальный дефолт-хелпер, преобразующий группы в +структуру для отображения (пары с прочерком при пустом значении). Экспортируется из +модуля, чтобы консюмер мог взять дефолт за основу. + +### 3. i18n модуля + +Keyset `qp:navigation-meta`, ключи по правилам +[plans/i18n-rules.md](plans/i18n-rules.md): `value_empty` (прочерк) и `context_empty` +(текст пустого состояния). Регистрация через +[`addI18Keysets`](src/i18n/index.ts:11), `en` + `ru`, структура файлов идентична +[`NavigationPreview/i18n`](src/modules/NavigationPreview/i18n/index.ts:1). + +### 4. Интеграция во вкладку Meta + +В [`createTableDetailConfig()`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:25) +по образцу вкладок `schema` / `preview`: + +- расширить `CreateTableDetailConfigOptions` резолвером + `resolveMeta?: (item) => NavigationMetaConfig | undefined` + (аналогично `resolveSchema` / `resolvePreview`); +- дополнительно добавить хук **полного переопределения** таба + `renderMeta?: (data: NavigationMetaConfig) => ReactNode` (перенос идеи `renderMetaTab` + из ytsaurus) — если задан, `view.render` подставляется в `NavigationMeta`; +- для вкладки `meta` задать + `renderContent: () => ` + вместо `content: null`; +- вкладку `view` **оставить** как есть (`content: null`). + +Сигнатура `createTableDetailConfig` уже принимает объект опций +([`CreateTableDetailConfigOptions`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:20)), +поэтому добавление `resolveMeta` / `renderMeta` не ломает существующие вызовы; +[`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13) +не трогаем. + +### 5. Барели и экспорт + +- `src/modules/index.ts` → `NavigationMeta`, `buildMetaGroups`, типы + `NavigationMetaProps`, `NavigationMetaViewConfig`. +- `src/types/navigation.ts` → `NavigationMetaValue`, `NavigationMetaItem`, + `NavigationMetaGroup`, `NavigationMetaConfig` (ре-экспорт уже идёт через + [`src/index.ts`](src/index.ts:6)). + +### 6. Storybook + +- `NavigationMeta.stories.tsx` c mock-данными: состояния Default (несколько групп с + заголовками) / Loading / Empty / Error / Custom (`view.render` полного переопределения и + `view.extraContent`). +- Обновить историю в + [`QueriesNavigation.stories.tsx`](src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx:187), + чтобы во вкладке Meta отображались метаданные на mock-данных (передать `resolveMeta` в + `createTableDetailConfig`). + +## Диаграмма потока данных + +```mermaid +flowchart TD + Consumer[Consumer normalized meta groups] --> Config[NavigationMetaConfig] + Config --> Factory[createTableDetailConfig] + Factory --> Tab[NavigationDetailTab renderContent meta] + Tab --> Meta[NavigationMeta module] + Meta --> Custom{view.render set} + Custom -- yes --> Override[Consumer custom render] + Custom -- no --> Build[buildMetaGroups] + Build --> DefList[DefinitionList per group] + DefList --> UI[Meta tab UI] + Override --> UI +``` + +## Порядок реализации (шаги) + +1. Типы meta в `src/types/navigation.ts` (`NavigationMetaValue`, `NavigationMetaItem`, + `NavigationMetaGroup`, `NavigationMetaConfig`). +2. Модуль `NavigationMeta` (компонент + scss + index) — зеркало `NavigationPreview`, + дефолтный рендер на `DefinitionList`. +3. `buildMetaGroups(groups, i18n)` — дефолтное преобразование групп + экспорт. +4. Кастомизация: `view.render` (полный override), `view.extraContent`, состояния + loading / error / empty. +5. i18n модуля (`qp:navigation-meta`, en/ru/dicts/index): `value_empty`, `context_empty`. +6. Барели: `modules/index.ts`, экспорт типов (проверить `src/index.ts`). +7. Интеграция во вкладку `meta` через `createTableDetailConfig` (`resolveMeta`, + `renderMeta`), `view` оставить пустым. +8. Storybook: история модуля + mock, обновление истории виджета. +9. Проверка сборки (`npm run build`) и Storybook. + +## Границы / договорённости + +- Не завязываемся на сырые структуры YT / unipika / YSON — только нормализованные значения. +- `NavigationMetaTab` из ytsaurus **не копируется** дословно (несовместимые зависимости); + переносится только модель данных «группы пар» и публичный паттерн полного + переопределения вида (`renderMeta` / `view.render`). +- Вкладка `view` остаётся пустой в рамках этой задачи. +- Импорты строго `widgets → modules → components` (правила AGENTS.md). diff --git a/plans/navigation-preview-tab.md b/plans/navigation-preview-tab.md new file mode 100644 index 0000000..91dd793 --- /dev/null +++ b/plans/navigation-preview-tab.md @@ -0,0 +1,215 @@ +# План: просмотр данных таблицы во вкладке Preview (NavigationDetail) + +## Контекст + +Во вкладке **Preview** детали таблицы (`NavigationDetail`) сейчас пусто. Вкладка создаётся в +[`createTableDetailConfig()`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:28) +с `content: null`. Нужно наполнить её просмотрщиком данных таблицы (строки + значения) по +мотивам `@ytsaurus/components` (`NavigationTable/NavigationPreviewTab`), но адаптированным под +архитектуру querieskit. + +Задача поставлена по образцу уже реализованной вкладки **Schema** (см. +[`plans/navigation-schema-tab.md`](plans/navigation-schema-tab.md)): те же принципы — +нормализованные данные на входе, минимальный набор + расширяемость, возможность полного +переопределения вида консюмером. + +### Договорённости с заказчиком + +- Просмотр данных встраиваем во вкладку **`preview`**. Вкладку **`view`** оставляем пустой + (`content: null`). +- На вход принимаем **уже преобразованные** данные (строки/значения как строки/ReactNode), + библиотека **не** завязывается на сырые YT-структуры (YSON) и unipika. +- Требования аналогичны Schema: минимальный набор возможностей, но с расширением и + возможностью полностью переопределить `preview` своим рендером. + +## Анализ эталона ytsaurus (можно ли скопировать 1:1) + +Эталонный [`NavigationTable.tsx`](https://github.com/ytsaurus/ytsaurus-ui/blob/fe5de5c62a6e06e0adaa0a7746c5d61874ef0909/packages/components/src/modules/NavigationTable/NavigationTable.tsx) +рендерит `NavigationPreviewTab` так: + +```text +renderPreviewTab?: (props: { + table: NavigationTableData; + onEditorInsert?: () => void | Promise; + ysonSettings?: UnipikaSettings; + primitiveTypes?: SchemaDataTypeProps['primitiveTypes']; +}) => React.ReactNode; +``` + +и дефолтный `NavigationPreviewTab` получает дополнительно `logError` и +`ErrorBoundaryComponent`. + +**Вывод: скопировать `NavigationPreviewTab` 1:1 нельзя.** Он завязан на инфраструктуру, +которой у нас нет и которую мы сознательно не тянем: + +- `UnipikaSettings` / `ysonSettings` и внутренний `Yson/StructuredYson` — форматирование + сырого YSON. У нас данные **уже преобразованы**, unipika не нужен (в проекте есть + `@gravity-ui/unipika`, но по договорённости о нормализованных данных мы его не используем + в этом модуле). +- `primitiveTypes` / `SchemaDataTypeProps` — рендер типов YT. +- `ErrorBoundaryComponent` / `logError` — внутренняя инфраструктура ошибок ytsaurus. +- `onInsertTableSelect` / `onEditorInsert` — сценарий вставки `SELECT` в редактор YT. + +Что **переносим по смыслу** (а не по коду): идею вкладки-таблицы с данными строк, паттерн +`renderPreviewTab` (полное переопределение вида консюмером) и загрузку/пустое состояние. +Реализуем поверх уже существующего [`DataTable`](src/components/DataTable/DataTable.tsx:44) — +ровно так же, как это сделано для [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:28). + +## Проектные решения + +### 1. Публичные типы превью (`src/types/navigation.ts`) + +Минимальный расширяемый контракт данных превью. Строка — это запись «имя колонки → значение», +уже приведённое к строке/ReactNode консюмером. + +```text +NavigationPreviewCell = ReactNode; // готовое к отображению значение +NavigationPreviewRow = Record; + +NavigationPreviewConfig = { + columns: string[]; // порядок и состав колонок (имена полей) + rows: TRow[]; // данные (уже преобразованные значения) + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; +} +``` + +Дженерик `TRow` проходит через типы/компонент, чтобы консюмер мог типобезопасно передавать +свою форму строки и рендерить её в кастомных колонках. + +### 2. Новый модуль `src/modules/NavigationPreview` + +По правилам [AGENTS.md](AGENTS.md) это **module** (сборка `DataTable` + ячейки в сценарий +одной вкладки; не самостоятельный атомарный компонент). Структура — зеркало +`NavigationSchema`: + +```text +modules/NavigationPreview/ + NavigationPreview.tsx + NavigationPreview.scss + NavigationPreview.stories.tsx + helpers/ + buildPreviewColumns.tsx // дефолтный билдер колонок DataTable из columns[] + i18n/ + en.json + ru.json + dicts.ts + index.ts + index.ts +``` + +Пропсы `NavigationPreview` (по образцу +[`NavigationSchemaProps`](src/modules/NavigationSchema/NavigationSchema.tsx:20)): + +```text +{ + data: NavigationPreviewConfig; + view?: { + tableColumns?: Column[]; // ПОЛНОЕ переопределение колонок DataTable + extraColumns?: Column[]; // ДОБАВЛЕНИЕ колонок к дефолтным + }; + className?: string; +} +``` + +Поведение (полностью повторяет +[`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:28)): + +- `view.tableColumns` заданы → используем как есть (полная кастомизация под YT). +- Иначе строим дефолт `buildPreviewColumns(columns)` + `view.extraColumns`. +- Ошибка (`errorContent`) → показываем текст ошибки (паттерн из `NavigationSchema`). +- Иначе рендерим [`DataTable`](src/components/DataTable/DataTable.tsx:44) с + `loading` / `loaded` / `emptyVariant` (`no-data`). +- `settings={{displayIndices: false}}` — как в схеме (индексная колонка не нужна). + +`buildPreviewColumns(columns)` — минимальный дефолт: по одной колонке на каждое имя из +`config.columns`, `header` = имя колонки, `render` = значение из строки по ключу (с +прочерком `value_empty` при отсутствии). Экспортируется из модуля, чтобы консюмер мог взять +дефолт за основу. + +> Примечание про search: в отличие от Schema, для превью **фильтрация по search не +> предусмотрена** (детальный поиск в NavigationDetail семантически про поля схемы). Поэтому +> `renderContent` для вкладки preview просто игнорирует `search`. Если позже понадобится — +> добавим `filterPreview` отдельным шагом. + +### 3. i18n модуля + +Keyset `qp:navigation-preview`, ключи по правилам +[plans/i18n-rules.md](plans/i18n-rules.md): `value_empty` (прочерк) как минимум; при +необходимости — заголовки/подписи пустого состояния. Регистрация через +[`addI18Keysets`](src/i18n/index.ts:11), `en` + `ru`, структура файлов идентична +[`NavigationSchema/i18n`](src/modules/NavigationSchema/i18n/index.ts:1). + +### 4. Интеграция во вкладку Preview + +В [`createTableDetailConfig()`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:15) +по образцу вкладки `schema`: + +- расширить фабрику вторым резолвером `resolvePreview?: (item) => NavigationPreviewConfig` + (аналогично `resolveSchema`); +- для вкладки `preview` задать + `renderContent: () => ` + вместо `content: null`; +- вкладку `view` **оставить** как есть (`content: null`). + +Так как это меняет сигнатуру `createTableDetailConfig`, вариант — принимать объект опций +`createTableDetailConfig({resolveSchema, resolvePreview})` вместо позиционного +`resolveSchema`. Точную форму (сохранить обратную совместимость через перегрузку либо +явно поменять сигнатуру) уточняем на шаге реализации; резолвер +[`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13) +не ломаем. + +### 5. Барели и экспорт + +- `src/modules/index.ts` → `NavigationPreview`, `NavigationPreviewProps`, + `NavigationPreviewViewConfig`, `buildPreviewColumns`. +- `src/types/navigation.ts` → `NavigationPreviewRow`, `NavigationPreviewCell`, + `NavigationPreviewConfig` (ре-экспорт уже идёт через + [`src/index.ts`](src/index.ts:6)). + +### 6. Storybook + +- `NavigationPreview.stories.tsx` c mock-данными: состояния Default / Loading / Empty / + Error / CustomColumns (пример extra-колонки и полного переопределения `tableColumns`). +- Обновить историю в + [`QueriesNavigation.stories.tsx`](src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx:178), + чтобы во вкладке Preview отображалась таблица на mock-данных (передать `resolvePreview` в + `createTableDetailConfig`). + +## Диаграмма потока данных + +```mermaid +flowchart TD + Consumer[Consumer normalized rows] --> Config[NavigationPreviewConfig] + Config --> Factory[createTableDetailConfig] + Factory --> Tab[NavigationDetailTab renderContent preview] + Tab --> Preview[NavigationPreview module] + Preview --> Cols[buildPreviewColumns or custom] + Cols --> DataTable + Preview --> DataTable + DataTable --> UI[Preview table UI] +``` + +## Порядок реализации (шаги) + +1. Типы превью в `src/types/navigation.ts` (`NavigationPreviewCell`, `NavigationPreviewRow`, + `NavigationPreviewConfig`). +2. Модуль `NavigationPreview` (компонент + scss + index) на базе `DataTable`, зеркало + `NavigationSchema`. +3. `buildPreviewColumns(columns)` — дефолтные колонки из `config.columns` + экспорт. +4. Кастомизация: `view.tableColumns` / `view.extraColumns`, состояния loading/error/empty. +5. i18n модуля (`qp:navigation-preview`, en/ru/dicts/index). +6. Барели: `modules/index.ts`, экспорт типов (проверить `src/index.ts`). +7. Интеграция во вкладку `preview` через `createTableDetailConfig` (`resolvePreview`), + `view` оставить пустым. +8. Storybook: история модуля + mock, обновление истории виджета. +9. Проверка сборки (`npm run build`) и Storybook. + +## Границы / договорённости + +- Не завязываемся на сырые структуры YT / unipika / YSON — только нормализованные значения. +- `NavigationPreviewTab` из ytsaurus **не копируется** дословно (несовместимые зависимости); + переносится только идея и публичный паттерн `renderPreview`/полного переопределения вида. +- Вкладка `view` остаётся пустой в рамках этой задачи. +- Импорты строго `widgets → modules → components` (правила AGENTS.md). diff --git a/plans/navigation-review-e107d4f6.md b/plans/navigation-review-e107d4f6.md new file mode 100644 index 0000000..9cae51d --- /dev/null +++ b/plans/navigation-review-e107d4f6.md @@ -0,0 +1,282 @@ +# Ревью коммита e107d4f6 — виджет QueriesNavigation + +## Статус исправлений + +Согласованы и внесены правки **A**, **C**, **E** (сборка `npm run build` и `eslint` — зелёные): + +- **A (двойной канал actions).** В [`NavigationDetailPanelConfig`](src/types/navigation.ts:166) + добавлено поле `actions?: NavigationHeaderAction[]` — единый управляемый источник экшенов + detail-панели. Виджет + ([`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:88)) использует + `detail.actions ?? header.actions` (`resolvedDetailActions`) и прокидывает результат в + `NavigationDetail`. Экшены из resolver-конфига (`NavigationDetailConfig.actions`) + по-прежнему домёрживаются внутри `NavigationDetail`. +- **C (error + errorContent).** В [`NavigationListStateConfig`](src/types/navigation.ts:153) + два поля сведены к одному: `error?: boolean | ReactNode` (`true` — дефолтное сообщение, + `ReactNode` — кастомный контент, `false`/`undefined` — нет ошибки). Виджет упрощён до + единой ветки [`resolvedErrorContent`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:90). + Стори обновлена (`listState={{error: '...'}}`). +- **E (мертвый внутренний стейт).** [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:51) + теперь явно различает controlled/uncontrolled: внутренние `activeTabState`/`searchState` + обновляются только в неконтролируемом режиме (`isTabControlled`/`isSearchControlled`). + +Остальные находки (D, F, табы/контекст, kind-дефолты, G, H) остаются задокументированными +как договорённости — по ним правки не вносились. + +--- + +# (исходный анализ ниже) + +> Примечание: в окружении нет инструмента запуска `git`/`arc`, поэтому изолированный diff +> коммита `e107d4f6` получить не удалось. Анализ выполнен по текущему состоянию рабочего +> дерева навигационного стека (`QueriesNavigation` + модули `Navigation*` + типы + хелперы) +> и по проектным докам `plans/navigation-*.md`, которые описывают эти изменения. Перед +> внедрением правок стоит свериться с фактическим diff'ом. + +## Объём проанализированного + +- Виджет: [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:51), + [`QueriesNavigationProps`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:25). +- Хелперы: [`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13), + [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:34), + [`createEmptyDetailConfig`](src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts:5). +- Модули: [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:39), + [`NavigationDetailTabs`](src/modules/NavigationDetail/internal/NavigationDetailTabs.tsx:12), + [`NavigationHeader`](src/modules/NavigationHeader/NavigationHeader.tsx:15), + [`NavigationItemsList`](src/modules/NavigationItemsList/NavigationItemsList.tsx:31), + [`ClustersList`](src/modules/ClustersList/ClustersList.tsx:23), + [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:28), + [`NavigationPreview`](src/modules/NavigationPreview/NavigationPreview.tsx:24), + [`NavigationMeta`](src/modules/NavigationMeta/NavigationMeta.tsx:25). +- Типы: [`src/types/navigation.ts`](src/types/navigation.ts:1). + +--- + +## 1. Согласованность пропов и разрастание плоских пропов + +### Что сделано хорошо + +- Виджет уже сгруппировал большинство сквозных настроек в конфиг-объекты: + `header`, `search`, `sort`, `listState`, `detail` — см. + [`QueriesNavigationProps`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:25). Это + предотвращает разрастание плоского списка пропов на уровне виджета. +- `NavigationSchema` / `NavigationPreview` / `NavigationMeta` приведены к единой форме + `{ data, view?, className }` (+ `search?` только у схемы). Это консистентно между тремя + модулями и совпадает с планом + [`navigation-schema-props-grouping.md`](plans/navigation-schema-props-grouping.md). + +### Проблема A — двойной канал actions в detail-панели (несогласованность) + +`NavigationHeaderAction[]` в detail-панель попадает **двумя путями одновременно**: + +1. `header.actions` виджета прокидывается в `NavigationDetail` как проп `actions` + (см. [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:133)). +2. `NavigationDetailConfig.actions` приходит из фабрики конфига + (см. [`NavigationDetailConfig`](src/types/navigation.ts:132)) и внутри + [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:69) сливается с + пропом `actions` в `mergedActions`. + +При этом [`NavigationDetailPanelConfig`](src/types/navigation.ts:166) **не имеет** своего +поля `actions`. Итог: экшены хедера навигации всегда «протекают» и на detail-экран, а +отдельно задать экшены только для detail можно лишь через фабрику конфига. Канал управления +экшенами размазан по трём местам (`header.actions`, `config.actions`, проп `actions`), что +трудно предсказуемо. Стоит определить один источник правды. + +### Проблема B — `NavigationCluster.description` не типизирован, но используется + +[`ClusterRow`](src/modules/ClustersList/internal/ClusterRow.tsx:29) рендерит `description`, +и поле объявлено опциональным в [`NavigationCluster`](src/types/navigation.ts:25) — это ок. +Замечание мягкое: `color` / `backgroundColor` / `description` — это «презентационные» поля +модели данных; при кастомном `renderClusterItem` они дублируют возможности рендера. Не +критично, но это лёгкое разрастание модели данных презентационными полями. + +### Проблема C — `NavigationListStateConfig.error` vs `errorContent` + +В [`NavigationListStateConfig`](src/types/navigation.ts:153) есть и `error?: boolean`, и +`errorContent?: ReactNode`. В виджете они объединяются в один +[`resolvedErrorContent`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:85) через +`error || errorContent`. Два поля под одно состояние — потенциальная неоднозначность +(что если `error: false`, но `errorContent` задан?). Сейчас поведение «errorContent важнее» +работает, но контракт стоит задокументировать или свести к одному полю. + +--- + +## 2. Неиспользуемые / непроброшенные пропы + +### Находка D — `onItemClick` для parent-row в основном списке не срабатывает по клику строки + +В [`NavigationItemsList`](src/modules/NavigationItemsList/NavigationItemsList.tsx:85) +`LazyList.onItemClick` навешен на все строки, включая синтетическую parent-row `..`. +Но клик по parent-row должен вести «вверх», а обработку «вверх» делает +`QueriesNavigation.handleItemClick` через проверку `item.hasChildren`. У синтетической +parent-row (создаётся в `useParentRow`) полей `hasChildren`/`path` может не быть в ожидаемом +виде — нужно проверить, что клик по `..` в непустом списке реально навигирует наверх, а не +открывает detail. (В empty-state это обрабатывается отдельно в +[`NavigationItemsListEmptyState`](src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx:28).) +Требуется проверка [`useParentRow`](src/modules/NavigationItemsList/internal/useParentRow.ts:1). + +### Находка E — `NavigationDetail` игнорирует часть detail-config при контролируемом режиме + +[`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:51) хранит +`searchState`/`activeTabState` локально и одновременно принимает контролируемые +`search`/`activeTab`. Комбинация ок (controlled/uncontrolled), но: при контролируемом +`search` внутренний `searchState` продолжает обновляться и остаётся «мертвым» состоянием. +Не баг, но лишний стейт; стоит переключаться на один режим. + +### Находка F — вкладка `view` всегда пустая (`content: null`) + +В [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:70) +таб `view` создаётся с `content: null` и нет резолвера/рендера для него — это заложено +планами как временно пустая вкладка, но фактически это «мертвая» вкладка в публичном API: +пользователь видит переключатель, который ничего не показывает. Либо скрывать её +(`hidden`), либо дать `resolveView`/`renderView`. + +--- + +## 3. Может ли пользователь показывать кастомный контент и переопределять табы навигации + +### Кастомный контент строк списка — ДА + +- Кластеры: `renderClusterItem` → + [`ClustersList.renderRowItem`](src/modules/ClustersList/ClustersList.tsx:39). +- Элементы: `renderNavigationItem` → + [`NavigationItemsList.renderRowItem`](src/modules/NavigationItemsList/NavigationItemsList.tsx:68), + включая parent-row в empty-state + ([`NavigationItemsListEmptyState`](src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx:34)). +- Дефолтные строки `ClusterRow` / `NavigationItemRow` реэкспортированы публично + ([`src/modules/index.ts`](src/modules/index.ts:17)), что покрывает сценарий «дефолт + доп.». +- Дженерики `TItem`/`TCluster` протянуты сквозь виджет и модули — доп. поля видны в рендере + (стори `CustomRows` это демонстрирует). + +Вывод: сценарий кастомного контента строк реализован полноценно. + +### Переопределение табов навигации — ЧАСТИЧНО + +- Полностью кастомный набор табов задаётся через фабрику detail-config + ([`ResolveNavigationDetail`](src/types/navigation.ts:135) → + [`NavigationDetailConfig.tabs`](src/types/navigation.ts:128)); стори + `CustomDetailResolver` показывает произвольные табы для `kind: 'file'`. +- Каждый таб поддерживает и статический `content`, и `renderContent({search})` + ([`NavigationDetailTab`](src/types/navigation.ts:67)) — гибко. + +Ограничения / пробелы: + +- Нельзя «частично» переопределить один встроенный таб таблицы (например, только `meta`), + не пересобирая весь массив табов через `createTableDetailConfig`. Для `meta` есть + `view.render`, для `schema`/`preview` — только `view.tableColumns` (полная замена + колонок), но не замена всего таба целиком на уровне виджета. +- `renderContent` получает только `{ search }` + ([`NavigationDetailTabRenderContext`](src/types/navigation.ts:63)); нет `item`/`location` + в контексте — при рендере таба консюмер вынужден замыкать `item` в фабрике. Это работает, + но контекст беднее, чем мог бы быть. + +--- + +## 4. Кастомное отображение для файлов, отличных от таблиц + +### ДА — механизм есть, но дефолт покрывает только `table` + +- [`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13) + принимает `registry: Partial>` и `fallback`. + `NavigationItemKind` = `'folder' | 'file' | 'table' | 'link' | 'unknown'` + ([`src/types/navigation.ts`](src/types/navigation.ts:28)). +- Значит консюмер может задать свой конфиг для `file`, `link`, `unknown` и т.д. — стори + `CustomDetailResolver` делает это для `file`. +- Дефолтный реестр содержит **только** `table` + ([`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:9)), + для остальных kind без кастомного резолвера сработает + [`createEmptyDetailConfig`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:92) + (заглушка «no-files»). + +Замечания: + +- Открытие detail завязано на `!item.hasChildren` + ([`QueriesNavigation.handleItemClick`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:118)), + а не на `kind`. Файл без `hasChildren` откроет detail; папка — навигация внутрь. Ок, но + граничные случаи (`link` с детьми? `table` с `hasChildren`?) не специфицированы. +- Для не-table kind нет ни одного дефолтного шаблона (даже минимального «meta only»), поэтому + «из коробки» кастомное отображение файла = пустая заглушка, пока консюмер не напишет + фабрику. Это осознанно, но стоит зафиксировать в доке/типах. + +Вывод: кастомизация под любой `kind` возможна и типобезопасна; дефолтов, кроме `table`, нет. + +--- + +## 5. Дублирование и структура кода + +### Дублирование G — три модуля Schema/Preview/Meta повторяют один каркас + +[`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:28), +[`NavigationPreview`](src/modules/NavigationPreview/NavigationPreview.tsx:24) и +[`NavigationMeta`](src/modules/NavigationMeta/NavigationMeta.tsx:25) повторяют один и тот же +шаблон: + +- деструктуризация `{ data, view }`; +- ранний возврат `errorContent` → `` + (идентичный код в трёх местах); +- `resolvedColumns = view.tableColumns ?? [...buildX(), ...view.extraColumns]` + (идентично в Schema и Preview); +- одинаковая обёртка над `DataTable` c `settings={{displayIndices: false}}` (Schema/Preview). + +Возможности дедупликации: + +- Вынести общий `renderNavigationError(errorContent)` или тонкую обёртку + `NavigationDetailPanel` (error/loading/empty состояния) в общий internal-хелпер модуля + навигации. +- Общий тип `view` для табличных модулей (`NavigationTableViewConfig` с + `tableColumns`/`extraColumns`) — сейчас + [`NavigationSchemaViewConfig`](src/modules/NavigationSchema/NavigationSchema.tsx:13) и + [`NavigationPreviewViewConfig`](src/modules/NavigationPreview/NavigationPreview.tsx:12) + структурно идентичны. +- `value_empty` дублируется в трёх i18n-keyset'ах (`qp:navigation-schema`, + `qp:navigation-preview`, `qp:navigation-meta`) — приемлемо по правилам i18n, но + прочерк-хелпер логики `isEmptyValue` дублируется между + [`buildPreviewColumns`](src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx:12) и + [`buildMetaGroups`](src/modules/NavigationMeta/helpers/buildMetaGroups.tsx:15). + +### Дублирование H — NavigationHeader рендерится и в списке, и в detail + +`NavigationHeader` вызывается в двух ветках виджета +([`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:147) и внутри +[`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:83)) с почти +одинаковым набором пропов (`location`, `actions`, `onUpdate`, `onLoadSuggestions`). Логика +`handleNavigate` при этом живёт в виджете, а detail получает свой `onUpdate`. Дублирования +кода немного, но стоит проверить единообразие поведения хлебных крошек в обоих режимах. + +### Структура — соответствует AGENTS.md + +- Разделение `components / modules / widgets` соблюдено; direction импортов + `widgets → modules → components` не нарушен. +- Внутренние части (`internal/`) корректно спрятаны, дефолтные строки реэкспортированы для + переиспользования. +- Хелперы фабрик detail-config лежат в `widgets/QueriesNavigation/helpers` — уместно, т.к. + это виджет-специфичная сборка. + +Небольшая структурная заметка: `createTableDetailConfig.tsx` знает про конкретные модули +`NavigationSchema/Preview/Meta` и i18n виджета — это связывает виджет с тремя модулями +жёстко. Приемлемо, но при появлении новых видов детали фабрика будет разрастаться. + +--- + +## Итоговые наблюдения (сводка) + +| # | Тема | Оценка | Действие | +|---|------|--------|----------| +| A | Двойной/тройной канал `actions` (header/config/prop) | Несогласованность | Свести к одному источнику, задокументировать merge | +| C | `error` + `errorContent` в listState | Неоднозначность | Свести к одному полю или задокументировать приоритет | +| D | Клик по parent-row в непустом списке | Нужна проверка | Проверить useParentRow + ветку hasChildren | +| E | Мертвый внутренний стейт при controlled search/tab | Мелочь | Один режим controlled/uncontrolled | +| F | Пустая вкладка `view` в публичном API | UX-дыра | hidden или resolveView | +| Табы | Частичное переопределение одного встроенного таба | Пробел | Точечный override встроенных табов | +| Контекст | `renderContent` без item/location | Пробел | Расширить NavigationDetailTabRenderContext | +| kind | Дефолт только для `table` | Ожидаемо | Задокументировать; опц. дефолты для file/link | +| G | Дублирование каркаса Schema/Preview/Meta | Дублирование | Общий error/empty-хелпер + общий view-тип + isEmptyValue | + +## Открытые вопросы для согласования + +1. Нужен ли реальный diff коммита `e107d4f6` (подключить MCP-инструмент arc/arcanum или + выполнить `arc`/`git` вне агента), чтобы сузить ревью строго до изменённых строк? +2. Какие из находок (A, C, D, E, F, G) переводим в задачи на исправление, а какие оставляем + как задокументированные договорённости? +3. Приоритет: сначала согласованность API (A, C, F, табы/контекст) или сначала дедупликация + (G)? diff --git a/plans/navigation-schema-props-grouping.md b/plans/navigation-schema-props-grouping.md new file mode 100644 index 0000000..c397769 --- /dev/null +++ b/plans/navigation-schema-props-grouping.md @@ -0,0 +1,145 @@ +# План: группировка пропсов NavigationSchema + проверка поиска + +## Контекст + +Оценка текущей реализации визуализации схемы ([`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:25)) +выявила две области для правки: + +1. Плоская структура пропсов и дублирование с уже существующим типом + [`NavigationSchemaConfig`](src/types/navigation.ts:88). +2. Нужно подтвердить, что значение поиска влияет на отображение данных схемы (фильтр по имени). + +## Проверка поиска (выполнено) + +Сквозной путь подтверждён по коду, багов нет: + +- [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:95) рендерит + [`SearchWithButtons`](src/modules/NavigationDetail/NavigationDetail.tsx:96) при `hasSearch` + и прокидывает значение через + [`renderContent({search})`](src/modules/NavigationDetail/NavigationDetail.tsx:78). +- [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:23) + передаёт `search` в [`NavigationSchema`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:26). +- [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:42) вызывает + `filterSchema(columns, search)`. +- [`filterSchema`](src/modules/NavigationSchema/helpers/filterSchema.ts:12) матчит по `name` + (и `type`); пустой результат → `emptyVariant='nothing-found'`. + +Итог: фильтр по имени работает end-to-end и в контролируемом (`detail.search`), и в +неконтролируемом (внутренний `searchState`) режимах. + +Опционально в этом же плане закрепим поведение автотестом/стори-проверкой, а также рассмотрим +флаг «строго по имени» (см. раздел «Открытый вопрос»). + +## Проблема группировки пропсов + +Текущие [`NavigationSchemaProps`](src/modules/NavigationSchema/NavigationSchema.tsx:13) — 8 +плоских полей: + +```text +columns, search, loading, loaded, errorContent, tableColumns, extraColumns, className +``` + +Замечания: + +- `columns/loading/loaded/errorContent` дублируют уже существующий публичный тип + [`NavigationSchemaConfig`](src/types/navigation.ts:88), но компонент его не использует — + в [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:26) + поля конфига руками раскладываются по плоским пропсам (два источника правды). +- `tableColumns` / `extraColumns` — это отдельная смысловая группа «кастомизация вида», + логически не связанная с данными. +- Смысловая перегрузка слова column: `columns` — строки схемы (поля таблицы), а + `tableColumns`/`extraColumns` — колонки DataTable. + +## Проектное решение + +Сгруппировать пропсы в две смысловые группы, переиспользуя существующий +`NavigationSchemaConfig`, и оставить наверху только сквозные пропсы (`search`, `className`). + +### Новая форма пропсов + +```text +NavigationSchemaProps = { + // данные + состояние (переиспользуем существующий публичный тип) + data: NavigationSchemaConfig; // { columns, loading?, loaded?, errorContent? } + + // кастомизация вида колонок DataTable (опционально) + view?: { + tableColumns?: Array>; // ПОЛНАЯ замена набора колонок + extraColumns?: Array>; // ДОБАВЛЕНИЕ к дефолтным + }; + + // сквозные + search?: string; + className?: string; +} +``` + +Обоснование: + +- `data` = единый источник правды по данным/состоянию, совпадает с уже отдаваемым резолвером + `NavigationSchemaConfig` → в фабрике больше не нужно вручную раскладывать поля. +- `view` = изолированная группа кастомизации отображения, не смешивается с данными. +- `search`/`className` остаются наверху как инфраструктурные. + +### Изменения в компоненте + +В [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:25): + +- Деструктурировать `data` (`columns/loading/loaded/errorContent`) и `view` + (`tableColumns/extraColumns`). +- `resolvedColumns`: `view?.tableColumns ?? [...buildSchemaColumns(i18n), ...(view?.extraColumns ?? [])]`. +- `data = filterSchema(data.columns, search)` (без изменения логики). + +### Изменения в интеграции + +В [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:26) +передавать конфиг схемы одним объектом: + +```text + +``` + +Это устраняет ручную раскладку `columns/loading/loaded/errorContent`. + +### Обновление стори + +В [`NavigationSchema.stories.tsx`](src/modules/NavigationSchema/NavigationSchema.stories.tsx:36) +перевести все истории на новую форму: + +- `args.columns` → `args.data.columns` (+ `loading/loaded/errorContent` внутрь `data`). +- `extraColumns` в истории `CustomColumns` → `view.extraColumns`. + +## Открытый вопрос (нужно решение) + +`filterSchema` сейчас матчит и по `name`, и по `type`. Требование звучало как «фильтр по имени». +Варианты: + +- Оставить как есть (name + type) — обычно удобнее для пользователя. +- Сузить строго до `name`. +- Сделать поведение настраиваемым (поле в `view`, напр. `searchFields` или `filterPredicate`). + +По умолчанию в плане — оставить текущее поведение (name + type), сузить только по явному +запросу. + +## Порядок реализации + +1. Обновить тип [`NavigationSchemaProps`](src/modules/NavigationSchema/NavigationSchema.tsx:13): + ввести группы `data` и `view`, переиспользовать `NavigationSchemaConfig`. +2. Обновить тело [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:25) + под новые группы (без изменения логики фильтрации/рендера). +3. Обновить вызов в + [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:26) + на передачу `data`. +4. Обновить [`NavigationSchema.stories.tsx`](src/modules/NavigationSchema/NavigationSchema.stories.tsx:1) + на новую форму пропсов (все состояния: Default/Loading/Empty/NothingFound/Error/CustomColumns). +5. Проверить, что тип `NavigationSchemaConfig` экспортируется публично + ([`src/index.ts`](src/index.ts:6) уже реэкспортит `types/navigation`). +6. Сборка (`npm run build`) и Storybook — визуально подтвердить фильтрацию по поиску + (ввод в строку поиска → строки схемы фильтруются, пустой результат → nothing-found). + +## Границы / договорённости + +- Меняем только форму пропсов и интеграцию; логику фильтра и рендера колонок не трогаем. +- Обратная совместимость публичного API `NavigationSchema` намеренно ломается (группировка) — + это осознанный рефакторинг API до широкого использования в YT. +- Правки строго в рамках `widgets → modules → components` (правила [AGENTS.md](AGENTS.md)). diff --git a/plans/navigation-schema-tab.md b/plans/navigation-schema-tab.md new file mode 100644 index 0000000..0648419 --- /dev/null +++ b/plans/navigation-schema-tab.md @@ -0,0 +1,204 @@ +# План: компонент просмотра схемы во вкладке Schema (NavigationDetail) + +## Контекст + +Во вкладке **Schema** детали таблицы (`NavigationDetail`) сейчас пусто. Вкладка создаётся в +[`createTableDetailConfig()`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.ts:4) +с `content: null`. Нужно наполнить её просмотрщиком схемы по мотивам +`@ytsaurus/components` (`NavigationTable/NavigationSchemaTab`), но адаптированным под +архитектуру querieskit. + +### Ключевые находки из кодовой базы + +- [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:76) рендерит + `tab.content` как статический `ReactNode` и держит общий `search` для + [`SearchWithButtons`](src/components/SearchWithButtons/SearchWithButtons.tsx:1), но **не + прокидывает** `search` в контент вкладки. Значит для фильтрации схемы поиском контент + вкладки нужно сделать search-aware. +- Уже есть [`DataTable`](src/components/DataTable/DataTable.tsx:44) — обёртка над + `@gravity-ui/react-data-table` со скелетон-загрузкой и empty-состоянием. Это основа для + таблицы схемы (не тянем новую зависимость). +- Библиотека оперирует нормализованными типами (`NavigationItem`), а не сырыми структурами + YT. Схему консюмер тоже передаёт нормализованной. + +### Требования (уточнены с заказчиком) + +- Схема приходит **уже нормализованной** — не завязываемся на форматы YT. +- **Минимальный** набор колонок, но с **возможностью расширения**. +- В дальнейшем библиотека будет использоваться в YT → нужна **кастомизация вида** (полное + переопределение колонок и добавление своих). + +## Проектные решения + +### 1. Публичные типы схемы (`src/types/navigation.ts`) + +Минимальный расширяемый контракт одной колонки схемы: + +```text +NavigationSchemaSortOrder = 'ascending' | 'descending' + +NavigationSchemaColumn = { + name: string; // имя поля (обязательное) + type?: string; // тип в виде готовой строки + sortOrder?: NavigationSchemaSortOrder; // участие в ключе + направление + required?: boolean; // NOT NULL + // расширяемость: произвольные доп. поля для YT-кастомизации + [key: string]: unknown; +} +``` + +Дженерик по строке колонки `TColumn extends NavigationSchemaColumn = NavigationSchemaColumn` +проходит через все типы/компоненты, чтобы консюмер мог добавить свои поля (`lock`, `group`, +`expression`, `aggregate` и т.п.) и типобезопасно рендерить их в extra-колонках. + +Конфиг схемы для detail-вкладки: + +```text +NavigationSchemaConfig = { + columns: TColumn[]; // данные схемы (строки таблицы) + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; +} +``` + +### 2. Search-aware контент вкладки (`NavigationDetailTab`) + +Добавить в [`NavigationDetailTab`](src/types/navigation.ts:63) необязательное поле +`renderContent?: (ctx: {search: string}) => ReactNode` рядом с существующим `content`. +Правило: если задан `renderContent` — используется он (получает актуальный `search`), иначе +статический `content`. Обратная совместимость сохраняется полностью. + +В [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:76) вычисление +`activeContent` меняется: для активной вкладки при наличии `renderContent` вызвать её с +текущим `search`. + +### 3. Новый модуль `src/modules/NavigationSchema` + +По правилам [AGENTS.md](AGENTS.md) это **module** (сборка нескольких компонентов — +`DataTable`, ячейки — в сценарий одной вкладки; не самостоятельный атомарный компонент). + +```text +modules/NavigationSchema/ + NavigationSchema.tsx + NavigationSchema.scss + helpers/ + buildSchemaColumns.tsx // дефолтный билдер колонок DataTable + filterSchema.ts // фильтрация строк по search (по name/type) + i18n/ + en.json + ru.json + dicts.ts + index.ts + index.ts +``` + +Пропсы `NavigationSchema`: + +```text +{ + columns: TColumn[]; // строки схемы (данные) + search?: string; // фильтр по имени/типу + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; + tableColumns?: Column[]; // ПОЛНОЕ переопределение колонок DataTable + extraColumns?: Column[]; // ДОБАВЛЕНИЕ колонок к дефолтным + className?: string; +} +``` + +Поведение: + +- `tableColumns` заданы → используем их как есть (кейс полной кастомизации под YT). +- Иначе строим дефолт через `buildSchemaColumns(i18n)` и добавляем `extraColumns`. +- Данные фильтруются `filterSchema(columns, search)`. +- Ошибка (`errorContent`) → показываем текст ошибки (как в + [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:85)). +- Иначе рендерим [`DataTable`](src/components/DataTable/DataTable.tsx:44) с + `loading`/`loaded`/`emptyVariant`. + +Дефолтные колонки (`buildSchemaColumns`) — минимальный набор: + +| Колонка | Содержимое | +|------------|------------------------------------------------------------------------| +| Name | имя поля + индикатор ключа/сортировки (иконка asc/desc, если sortOrder) | +| Type | `type` как строка | +| Sort Order | `ascending` / `descending` / «—» | +| Required | галочка / «—» | + +`buildSchemaColumns` **экспортируется** из модуля, чтобы консюмер мог взять дефолт за основу +и дополнить/переопределить для YT-вида. + +### 4. i18n модуля + +Keyset `qp:navigation-schema`, ключи по правилам [plans/i18n-rules.md](plans/i18n-rules.md): +`title_column-name`, `title_column-type`, `title_column-sort-order`, +`title_column-required`, `value_sort-ascending`, `value_sort-descending`, +`value_empty` (прочерк). Регистрация через +[`addI18Keysets`](src/i18n/index.ts:11), en/ru. + +### 5. Интеграция во вкладку Schema + +- Расширить фабрику детали, чтобы принимать схему. Вариант: у `NavigationDetailPanelConfig` + (или через resolver) появляется способ передать `NavigationSchemaConfig`. Практично — + передавать schema-конфиг в фабрику детали, а + [`createTableDetailConfig()`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.ts:4) + для вкладки `schema` задаёт `renderContent: ({search}) => `. +- Так как `createTableDetailConfig` вызывается резолвером от `item`, добавим ему второй + аргумент/замыкание с данными схемы (например, фабрика `createTableDetailConfig(schema)` → + `(item) => config`). Точную форму передачи данных уточняем на этапе реализации, не ломая + сигнатуру [`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13). + +### 6. Барели и экспорт + +- `src/modules/index.ts` → `NavigationSchema`, `NavigationSchemaProps`, `buildSchemaColumns`. +- `src/types` → `NavigationSchemaColumn`, `NavigationSchemaSortOrder`, `NavigationSchemaConfig`. +- [`src/index.ts`](src/index.ts:1) → ре-экспорт публичной части. + +### 7. Storybook + +- `NavigationSchema.stories.tsx` c mock-схемой: состояния Default / Loading / Empty / + NothingFound (поиск) / Error / CustomColumns (пример extra-колонки под YT). +- Обновить историю `DetailView` в + [`QueriesNavigation.stories.tsx`](src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx:290), + чтобы во вкладке Schema отображалась таблица на mock-данных. + +## Диаграмма потока данных + +```mermaid +flowchart TD + Consumer[Consumer normalized schema] --> Config[NavigationSchemaConfig] + Config --> Factory[createTableDetailConfig] + Factory --> Tab[NavigationDetailTab renderContent] + Search[SearchWithButtons search] --> Detail[NavigationDetail] + Detail --> Tab + Tab --> Schema[NavigationSchema module] + Schema --> Filter[filterSchema] + Schema --> Cols[buildSchemaColumns or custom] + Filter --> DataTable + Cols --> DataTable + DataTable --> UI[Schema table UI] +``` + +## Порядок реализации (шаги) + +1. Типы схемы в `src/types/navigation.ts` (`NavigationSchemaColumn`, + `NavigationSchemaSortOrder`, `NavigationSchemaConfig`). +2. `NavigationDetailTab.renderContent` + прокидывание `search` в + `NavigationDetail`. +3. Модуль `NavigationSchema` (компонент + scss + index) на базе `DataTable`. +4. `buildSchemaColumns` (дефолтные колонки) + экспорт. +5. Кастомизация: `tableColumns` / `extraColumns`, `filterSchema`, loading/error/empty. +6. i18n модуля (`qp:navigation-schema`, en/ru/dicts/index). +7. Барели: `modules/index.ts`, `src/index.ts`, экспорт типов. +8. Интеграция во вкладку Schema через `createTableDetailConfig`. +9. Storybook: история модуля + mock, обновление `DetailView`. +10. Проверка сборки (`npm run build`) и Storybook. + +## Границы / договорённости + +- Не завязываемся на сырые структуры YT — только нормализованные значения. +- Минимальный набор колонок, но контракт и API рассчитаны на расширение и полную замену + вида под YT. +- Импорты строго `widgets → modules → components` (не нарушаем правила AGENTS.md). diff --git a/plans/tutorials-history-plan.md b/plans/tutorials-history-plan.md deleted file mode 100644 index 9a84ab2..0000000 --- a/plans/tutorials-history-plan.md +++ /dev/null @@ -1,59 +0,0 @@ -# План: виджет TutorialsHistory на базе инфраструктуры QueriesHistory - -## Контекст и развилка - -`TutorialsHistory` — частный случай [`QueriesHistory`](src/widgets/QueriesHistory/QueriesHistory.tsx:39): список туториалов без выбора видимых полей (`FieldsSelector` скрыт), с использованием [`TutorialRow`](src/modules/TutorialRow/TutorialRow.tsx:9) вместо [`HistoryRow`](src/modules/HistoryRow/HistoryRow.tsx:18). - -Проблема: [`QueryHistoryRow`](src/types/history.ts:13) требует обязательное поле `status`, которого нет у туториалов. `status` используется внутри [`QueryStatusIcon`](src/components/QueryStatusIcon/QueryStatusIcon.tsx:28), [`QueryDuration`](src/components/QueryDuration/QueryDuration.tsx:17)/[`useQueryDuration`](src/components/QueryDuration/useQueryDuration.ts), [`HistoryRow`](src/modules/HistoryRow/HistoryRow.tsx:18), [`HistorySearchRow`](src/modules/HistorySearchRow/HistorySearchRow.tsx:21). - -**Решение:** выделить базовый тип `BaseHistoryRow` (id/title/query?/href? + `HistoryRowRenderProps`), от которого наследуется `QueryHistoryRow` (добавляя обязательный `status` и query-специфичные поля). Общая generic-инфраструктура (`QueryHistoryItem`, `QueryHistoryRowRenderData` и связанные конфиги) параметризуется `BaseHistoryRow`, а status-специфичные компоненты (`HistoryRow`, `HistorySearchRow`, `QueryStatusIcon`, `QueryDuration`) продолжают требовать `QueryHistoryRow`. Обратная совместимость сохраняется — `QueryHistoryRow` всё ещё удовлетворяет `BaseHistoryRow`. - -## Согласованный набор возможностей TutorialsHistory - -Остаётся: `title`, `logo`, `search`, `filter`, `items`, `selectedRowId`, `onListItemClick`/`href`. -Убирается: `visibleFields`/`FieldsSelector`, `comparison`, `editing`, `getRowActions`. - -Полнотекстовый поиск (`fullSearch`) остаётся — для него будет отдельный `TutorialSearchRow` (аналог [`HistorySearchRow`](src/modules/HistorySearchRow/HistorySearchRow.tsx:21)) с Monaco-редактором, но в шапке только `id` и `title`, без `status`/`engine`/`mode`/`isPrivate`. - -## Диаграмма компонентов - -```mermaid -graph TD - BHR[BaseHistoryRow] --> QHR[QueryHistoryRow + status] - BHR --> THR[TutorialHistoryRow] - - QHR --> HistoryRow - QHR --> HistorySearchRow - THR --> TutorialRow - THR --> TutorialSearchRow - - HistoryRow --> HistoryRowContent - HistorySearchRow --> HistoryRowContent - TutorialRow --> TutorialRowContent - TutorialSearchRow --> TutorialRowContent - - HistoryRowContent --> HistoryList - HistoryList --> RowsList - TutorialRowContent --> TutorialsHistory - - RowsList --> QueriesHistory - RowsList --> TutorialsHistory - HistoryLayout --> QueriesHistory - HistoryLayout --> TutorialsHistory -``` - -Список один — [`RowsList`](../src/modules/RowsList/RowsList.tsx): он владеет виртуализацией, высотами строк и пустым состоянием, а разметку строки получает через `renderRow`. [`HistoryList`](../src/modules/HistoryList/HistoryList.tsx) — тонкая обёртка над ним с query-строками по умолчанию. Каркас виджета (logo/actions, title, header, footer) вынесен в [`HistoryLayout`](../src/modules/HistoryLayout/HistoryLayout.tsx). - -## Чек-лист реализации - -1. **Типы** — вынести `BaseHistoryRow` в [`src/types/history.ts`](src/types/history.ts:13), ослабить generic-constraint (`QueryHistoryRow` → `BaseHistoryRow`) у `QueryHistoryItem`, `QueryHistoryRowAction`, `QueryHistoryEditingConfig`, `QueryHistoryComparisonConfig`, `QueryHistoryVisibleFieldsConfig`, `RowFieldKey`/`QueryHistoryFieldKey`, `QueryHistoryEditingRenderData`, `QueryHistoryRowRenderData`. `QueryHistoryRow` = `BaseHistoryRow & {status: QueryStatus, engine?, mode?, isPrivate?, startTime?, endTime?}`. -2. **Новый тип** — создать [`src/types/tutorial.ts`](src/types/tutorial.ts) с `TutorialHistoryRow` (на основе `BaseHistoryRow`, с запасом на будущие поля), реэкспортировать через [`src/index.ts`](src/index.ts:1). -3. **Промоут общих компонентов** — вынести [`HistoryGroupHeader`](src/modules/HistoryList/HistoryGroupHeader.tsx:1) и [`HistoryListEmpty`](src/modules/HistoryList/HistoryListEmpty/HistoryListEmpty.tsx:10) (со scss/i18n) из `src/modules/HistoryList/*` в `src/components/HistoryGroupHeader/` и `src/components/HistoryListEmpty/`; обновить импорты в [`HistoryRowContent.tsx`](src/modules/HistoryList/HistoryRowContent.tsx:1)/[`HistoryList.tsx`](src/modules/HistoryList/HistoryList.tsx:1) и barrel-экспорты. -4. **Общий хелпер** — перенести [`prepareRowData`](src/modules/HistoryList/helpers/prepareRowData.ts:21) в `src/helpers/prepareRowData.ts`, ослабить constraint до `BaseHistoryRow`, обновить импорт в `HistoryList.tsx`. -5. **Общие Monaco-хелперы** — вынести [`fitQueryToVisibleLines`](src/modules/HistorySearchRow/helpers/fitQueryToVisibleLines.ts:3), [`resolveMonacoLanguage`](src/modules/HistorySearchRow/helpers/resolveMonacoLanguage.ts:3), [`MONACO_CONFIG`](src/modules/HistorySearchRow/monacoConfig.ts:5) из `src/modules/HistorySearchRow/*` в `src/helpers/`, обновить импорт в `HistorySearchRow.tsx`. -6. **TutorialRow** — доработать [`src/modules/TutorialRow/TutorialRow.tsx`](src/modules/TutorialRow/TutorialRow.tsx:9): принимать `item: TutorialHistoryRow`, поддержать `href`/`isActive`-стилизацию по аналогии с `HistoryRow` (без статус-иконки, меню, editing, comparison); добавить `TutorialRow.scss` и `.stories.tsx`. -7. **TutorialSearchRow** — создать `src/modules/TutorialSearchRow/` по аналогии с `HistorySearchRow`: в шапке только `id`+`title`, ниже Monaco-редактор с `query` (реюз общих Monaco-хелперов); добавить scss и `.stories.tsx`. -8. **RowsList вместо отдельного TutorialList** — не копировать `HistoryList`, а вынести generic-список в `src/modules/RowsList/` (`T extends BaseHistoryRow`, обязательный `renderRow`, `rowVariant` пробрасывается в `renderRow`); `HistoryList` переписать как обёртку над ним. Строки туториалов переключает `TutorialRowContent`, живущий внутри виджета. -9. **TutorialsHistory widget** — создать `src/widgets/TutorialsHistory/`: `HistoryLayout` + `RowsList` с `renderRow={TutorialRowContent}`; без `FieldsSelector`/`visibleFields`/`comparison`/`editing`/`getRowActions`; оставить `title`/`logo`/`search`/`filter`/`items`/`selectedRowId`/`onListItemClick`; generic по `T extends TutorialHistoryRow`; i18n-кейсет `qp:tutorials` с ключом `title_tutorials`; добавить `.stories.tsx`. -10. **Barrel-экспорты** — обновить `src/modules/index.ts`, `src/widgets/index.ts`, `src/components/index.ts`, `src/index.ts`. -11. **Проверка** — прогнать сборку и Storybook, исправить возможные TS-ошибки после ослабления generic-constraints. diff --git a/src/components/Breadcrumbs/Breadcrumbs.scss b/src/components/Breadcrumbs/Breadcrumbs.scss new file mode 100644 index 0000000..216b748 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.scss @@ -0,0 +1,25 @@ +.qp-breadcrumbs { + $self: &; + + min-width: 0; + + &__list { + flex: 1; + min-width: 0; + } + + &__path-editor { + flex: 1; + min-width: 0; + } + + &__edit-button { + display: none; + } + + &:hover { + #{$self}__edit-button { + display: block; + } + } +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000..592f0b0 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,114 @@ +import React, {FC, useState} from 'react'; +import {Button, Flex, Breadcrumbs as GravityBreadcrumbs, Icon, Text} from '@gravity-ui/uikit'; +import FolderTreeIcon from '@gravity-ui/icons/svgs/folder-tree.svg'; +import PencilIcon from '@gravity-ui/icons/svgs/pencil.svg'; +import cn from 'bem-cn-lite'; +import {parsePathSegments} from './helpers/parsePathSegments'; +import {NavigationLocation} from '../../types/navigation'; +import type {LoadPathSuggestions} from '../../types/pathEditor'; +import {PathEditor} from '../PathEditor'; +import i18n from './i18n'; +import './Breadcrumbs.scss'; + +export type BreadcrumbsProps = { + location: NavigationLocation; + hideResetButton?: boolean; + className?: string; + onUpdate: (location: NavigationLocation) => void; + onLoadSuggestions?: LoadPathSuggestions; +}; + +const block = cn('qp-breadcrumbs'); + +export const Breadcrumbs: FC = ({ + location, + hideResetButton, + onUpdate, + onLoadSuggestions, + className, +}) => { + const [edit, setEdit] = useState(false); + + const {cluster, path} = location; + const ROOT_PATH = undefined; + const items = cluster ? [{path: ROOT_PATH, title: cluster}, ...parsePathSegments(path)] : []; + + const handleReset = () => { + onUpdate({cluster: undefined, path: undefined}); + }; + + const handleCancelEdit = () => { + setEdit(false); + }; + + const handleOnSubmit = (nextPath: string) => { + const normalizedPath = nextPath.endsWith('/') ? nextPath.slice(0, -1) : nextPath; + onUpdate({cluster, path: normalizedPath || undefined}); + setEdit(false); + }; + + if (edit) { + return ( + + event.currentTarget.select()} + /> + + ); + } + + return ( + + {!hideResetButton && ( + + )} + {items.length > 0 ? ( + 1 ? undefined : 1} + > + / + + {items.map((item, index) => { + const isLast = index === items.length - 1; + + return ( + onUpdate({cluster, path: item.path}) + } + > + {item.title} + + ); + })} + + + + ) : null} + + ); +}; diff --git a/src/components/Breadcrumbs/helpers/parsePathSegments.ts b/src/components/Breadcrumbs/helpers/parsePathSegments.ts new file mode 100644 index 0000000..b1d432d --- /dev/null +++ b/src/components/Breadcrumbs/helpers/parsePathSegments.ts @@ -0,0 +1,16 @@ +export type BreadcrumbSegment = { + path: string; + title: string; +}; + +export function parsePathSegments(path: string | undefined): BreadcrumbSegment[] { + if (!path) return []; + + const parts = path.trim().split('/').filter(Boolean); + + let pathAcc = ''; + return parts.map((segment) => { + pathAcc += `/${segment}`; + return {path: pathAcc, title: segment}; + }); +} diff --git a/src/components/HistoryListEmpty/i18n/dicts.ts b/src/components/Breadcrumbs/i18n/dicts.ts similarity index 100% rename from src/components/HistoryListEmpty/i18n/dicts.ts rename to src/components/Breadcrumbs/i18n/dicts.ts diff --git a/src/components/Breadcrumbs/i18n/en.json b/src/components/Breadcrumbs/i18n/en.json new file mode 100644 index 0000000..257142f --- /dev/null +++ b/src/components/Breadcrumbs/i18n/en.json @@ -0,0 +1,4 @@ +{ + "action_reset": "Reset navigation", + "action_edit-path": "Edit path" +} diff --git a/src/components/HistoryListEmpty/i18n/index.ts b/src/components/Breadcrumbs/i18n/index.ts similarity index 55% rename from src/components/HistoryListEmpty/i18n/index.ts rename to src/components/Breadcrumbs/i18n/index.ts index aabee55..d666e9a 100644 --- a/src/components/HistoryListEmpty/i18n/index.ts +++ b/src/components/Breadcrumbs/i18n/index.ts @@ -2,4 +2,4 @@ import {addI18Keysets} from '../../../i18n'; import dicts from './dicts'; -export default addI18Keysets('qp:history-list-empty', dicts); +export default addI18Keysets('qp:breadcrumbs', dicts); diff --git a/src/components/Breadcrumbs/i18n/ru.json b/src/components/Breadcrumbs/i18n/ru.json new file mode 100644 index 0000000..c9bff50 --- /dev/null +++ b/src/components/Breadcrumbs/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "action_reset": "Сбросить навигацию", + "action_edit-path": "Редактировать путь" +} diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts new file mode 100644 index 0000000..f5e777e --- /dev/null +++ b/src/components/Breadcrumbs/index.ts @@ -0,0 +1,2 @@ +export {Breadcrumbs} from './Breadcrumbs'; +export type {BreadcrumbsProps} from './Breadcrumbs'; diff --git a/src/components/DataTable/DataTable.scss b/src/components/DataTable/DataTable.scss new file mode 100644 index 0000000..39b4da8 --- /dev/null +++ b/src/components/DataTable/DataTable.scss @@ -0,0 +1,35 @@ +.qp-data-table { + display: flex; + flex-direction: column; + height: 100%; + + &__empty { + flex: 1; + } + + &__tr_empty { + .qp-data-table__td_empty { + border-bottom: 1px solid var(--g-color-line-generic); + } + } + + &__content_empty { + display: flex; + + &.qp-data-table__content_align_right { + justify-content: flex-end; + } + + &.qp-data-table__content_align_center { + justify-content: center; + } + } + + &__no-data-placeholder { + width: 100%; + max-width: 120px; + height: var(--g-text-body-1-line-height); + border-radius: 4px; + background-color: var(--g-color-base-generic); + } +} diff --git a/src/components/DataTable/DataTable.stories.tsx b/src/components/DataTable/DataTable.stories.tsx new file mode 100644 index 0000000..e1cc67c --- /dev/null +++ b/src/components/DataTable/DataTable.stories.tsx @@ -0,0 +1,65 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {DataTable} from './DataTable'; +import type {Column} from './DataTable'; + +type Row = { + id: number; + name: string; + status: string; + size: number; +}; + +const columns: Array> = [ + {name: 'id', header: 'ID', width: 60}, + {name: 'name', header: 'Name'}, + {name: 'status', header: 'Status'}, + {name: 'size', header: 'Size', align: 'right'}, +]; + +const data: Row[] = [ + {id: 1, name: 'orders.csv', status: 'ready', size: 1024}, + {id: 2, name: 'users.json', status: 'ready', size: 2048}, + {id: 3, name: 'events.parquet', status: 'processing', size: 4096}, +]; + +const meta: Meta = { + title: 'Components/DataTable', + component: DataTable, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj>; + +export const Default: Story = { + args: { + columns, + data, + loaded: true, + }, +}; + +export const Loading: Story = { + args: { + columns, + data: [], + loading: true, + }, +}; + +export const Empty: Story = { + args: { + columns, + data: [], + loaded: true, + }, +}; + +export const EmptyNothingFound: Story = { + args: { + columns, + data: [], + loaded: true, + emptyVariant: 'nothing-found', + }, +}; diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx new file mode 100644 index 0000000..1052c6e --- /dev/null +++ b/src/components/DataTable/DataTable.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import BaseDataTable, { + DataTableProps as BaseDataTableProps, + Column, +} from '@gravity-ui/react-data-table'; +import cn from 'bem-cn-lite'; + +import {EmptyContent, EmptyContentVariant} from '../EmptyContent'; + +import './DataTable.scss'; + +export type {Column}; + +const block = cn('qp-data-table'); + +const SKELETON_ROWS_COUNT = 4; + +export type DataTableProps = { + loading?: boolean; + loaded?: boolean; + className?: string; + emptyVariant?: EmptyContentVariant; +} & Omit, 'theme'>; + +function renderEmptyCell(key: string, align?: Column['align']) { + return ( + +
+
+
+ + ); +} + +function renderLoadingSkeleton(columns: Array>, displayIndices: boolean) { + return Array.from({length: SKELETON_ROWS_COUNT}, (_, index) => ( + + {displayIndices && renderEmptyCell('__index')} + {columns.map((column) => renderEmptyCell(column.name, column.align))} + + )); +} + +export function DataTable(props: DataTableProps) { + const { + loading, + loaded, + className, + emptyVariant = 'no-data', + columns, + data, + settings, + ...rest + } = props; + + const isEmpty = loaded && data.length === 0; + const displayIndices = settings?.displayIndices !== false; + + const renderEmptyRow = () => { + if (loading && !loaded) { + return renderLoadingSkeleton(columns, displayIndices); + } + + return null; + }; + + return ( +
+ + {isEmpty && } +
+ ); +} diff --git a/src/components/DataTable/index.ts b/src/components/DataTable/index.ts new file mode 100644 index 0000000..46e5fda --- /dev/null +++ b/src/components/DataTable/index.ts @@ -0,0 +1 @@ +export {DataTable, type DataTableProps, type Column} from './DataTable'; diff --git a/src/components/EmptyContent/EmptyContent.scss b/src/components/EmptyContent/EmptyContent.scss new file mode 100644 index 0000000..b948aef --- /dev/null +++ b/src/components/EmptyContent/EmptyContent.scss @@ -0,0 +1,3 @@ +.qp-empty-content { + height: 100%; +} diff --git a/src/components/EmptyContent/EmptyContent.tsx b/src/components/EmptyContent/EmptyContent.tsx new file mode 100644 index 0000000..e3aef1d --- /dev/null +++ b/src/components/EmptyContent/EmptyContent.tsx @@ -0,0 +1,57 @@ +import React, {FC} from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import {Folder, NoSearchResults} from '@gravity-ui/illustrations'; +import cn from 'bem-cn-lite'; +import i18n from './i18n'; +import './EmptyContent.scss'; + +const block = cn('qp-empty-content'); + +export type EmptyContentVariant = 'no-files' | 'no-clusters' | 'nothing-found' | 'no-data'; + +export type EmptyContentProps = { + variant: EmptyContentVariant; + className?: string; +}; + +type EmptyContentConfig = { + icon: FC<{height?: number}>; + title: string; + description?: string; +}; + +const CONTENT_BY_VARIANT: Record = { + 'no-files': { + icon: Folder, + title: i18n('title_no-files'), + }, + 'no-clusters': { + icon: Folder, + title: i18n('title_no-clusters'), + }, + 'nothing-found': { + icon: NoSearchResults, + title: i18n('title_nothing-found'), + description: i18n('context_try-change-filters'), + }, + 'no-data': { + icon: Folder, + title: i18n('title_no-data'), + }, +}; + +export const EmptyContent: FC = ({variant, className}) => { + const {icon: Icon, title, description} = CONTENT_BY_VARIANT[variant]; + + return ( + + + + + {title} + {description && {description}} + + + + ); +}; diff --git a/src/components/EmptyContent/i18n/dicts.ts b/src/components/EmptyContent/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/components/EmptyContent/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/components/HistoryListEmpty/i18n/en.json b/src/components/EmptyContent/i18n/en.json similarity index 50% rename from src/components/HistoryListEmpty/i18n/en.json rename to src/components/EmptyContent/i18n/en.json index 1c1bf71..20fe7f5 100644 --- a/src/components/HistoryListEmpty/i18n/en.json +++ b/src/components/EmptyContent/i18n/en.json @@ -1,4 +1,7 @@ { "title_nothing-found": "Nothing found", + "title_no-files": "No files", + "title_no-clusters": "No clusters", + "title_no-data": "No data", "context_try-change-filters": "Try to change filters" } diff --git a/src/components/EmptyContent/i18n/index.ts b/src/components/EmptyContent/i18n/index.ts new file mode 100644 index 0000000..324156c --- /dev/null +++ b/src/components/EmptyContent/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:empty-content', dicts); diff --git a/src/components/HistoryListEmpty/i18n/ru.json b/src/components/EmptyContent/i18n/ru.json similarity index 52% rename from src/components/HistoryListEmpty/i18n/ru.json rename to src/components/EmptyContent/i18n/ru.json index 2bbe89f..2eb888e 100644 --- a/src/components/HistoryListEmpty/i18n/ru.json +++ b/src/components/EmptyContent/i18n/ru.json @@ -1,4 +1,7 @@ { "title_nothing-found": "Ничего не найдено", + "title_no-files": "Нет файлов", + "title_no-clusters": "Нет кластеров", + "title_no-data": "Нет данных", "context_try-change-filters": "Попробуйте изменить фильтры" } diff --git a/src/components/EmptyContent/index.ts b/src/components/EmptyContent/index.ts new file mode 100644 index 0000000..22d9f36 --- /dev/null +++ b/src/components/EmptyContent/index.ts @@ -0,0 +1 @@ +export {EmptyContent, type EmptyContentProps, type EmptyContentVariant} from './EmptyContent'; diff --git a/src/components/FieldsSearchToolbar/FieldsSearchToolbar.tsx b/src/components/FieldsSearchToolbar/FieldsSearchToolbar.tsx new file mode 100644 index 0000000..e478908 --- /dev/null +++ b/src/components/FieldsSearchToolbar/FieldsSearchToolbar.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {FieldsSelector, type FieldsSelectorOption} from '../FieldsSelector'; +import {SearchWithButtons} from '../SearchWithButtons'; + +export type FieldsSearchToolbarProps = { + search?: string; + onSearchUpdate?: (value: string) => void; + searchPlaceholder?: string; + fields: FieldsSelectorOption[]; + visibleFields: K[]; + onVisibleFieldsChange: (value: K[]) => void; + hideFieldsSelector?: boolean; + className?: string; +}; + +export function FieldsSearchToolbar({ + search, + onSearchUpdate, + searchPlaceholder, + fields, + visibleFields, + onVisibleFieldsChange, + hideFieldsSelector, + className, +}: FieldsSearchToolbarProps) { + const showFieldsSelector = !hideFieldsSelector && fields.length > 0; + + return ( + + key="fields-selector" + fields={fields} + value={visibleFields} + onChange={onVisibleFieldsChange} + />, + ] + : undefined + } + /> + ); +} diff --git a/src/components/FieldsSearchToolbar/index.ts b/src/components/FieldsSearchToolbar/index.ts new file mode 100644 index 0000000..2af72ce --- /dev/null +++ b/src/components/FieldsSearchToolbar/index.ts @@ -0,0 +1,2 @@ +export {FieldsSearchToolbar} from './FieldsSearchToolbar'; +export type {FieldsSearchToolbarProps} from './FieldsSearchToolbar'; diff --git a/src/components/FieldsSelector/index.ts b/src/components/FieldsSelector/index.ts index 9846aaa..7cbd607 100644 --- a/src/components/FieldsSelector/index.ts +++ b/src/components/FieldsSelector/index.ts @@ -1 +1,2 @@ export {FieldsSelector} from './FieldsSelector'; +export type {FieldsSelectorOption, FieldsSelectorProps} from './FieldsSelector'; diff --git a/src/components/HistoryListEmpty/HistoryListEmpty.scss b/src/components/HistoryListEmpty/HistoryListEmpty.scss deleted file mode 100644 index af5ea27..0000000 --- a/src/components/HistoryListEmpty/HistoryListEmpty.scss +++ /dev/null @@ -1,3 +0,0 @@ -.qp-history-list-empty { - height: 100%; -} diff --git a/src/components/HistoryListEmpty/HistoryListEmpty.tsx b/src/components/HistoryListEmpty/HistoryListEmpty.tsx deleted file mode 100644 index 67062df..0000000 --- a/src/components/HistoryListEmpty/HistoryListEmpty.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, {FC} from 'react'; -import {Flex, Text} from '@gravity-ui/uikit'; -import {NoSearchResults} from '@gravity-ui/illustrations'; -import cn from 'bem-cn-lite'; -import i18n from './i18n'; -import './HistoryListEmpty.scss'; - -const block = cn('qp-history-list-empty'); - -export type HistoryListEmptyProps = { - showFiltersHint?: boolean; - className?: string; -}; - -export const HistoryListEmpty: FC = ({showFiltersHint, className}) => { - return ( - - - - - {i18n('title_nothing-found')} - {showFiltersHint && {i18n('context_try-change-filters')}} - - - - ); -}; diff --git a/src/components/HistoryListEmpty/index.ts b/src/components/HistoryListEmpty/index.ts deleted file mode 100644 index 33d74d6..0000000 --- a/src/components/HistoryListEmpty/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {HistoryListEmpty} from './HistoryListEmpty'; -export type {HistoryListEmptyProps} from './HistoryListEmpty'; diff --git a/src/components/LazyList/LazyList.scss b/src/components/LazyList/LazyList.scss new file mode 100644 index 0000000..ec67460 --- /dev/null +++ b/src/components/LazyList/LazyList.scss @@ -0,0 +1,13 @@ +.qp-lazy-list { + .g-list__item_selected:hover { + background-color: var(--g-color-base-selection-hover); + } +} + +.qp-lazy-list__sentinel { + height: 1px; +} + +.qp-lazy-list__spinner { + height: 100%; +} diff --git a/src/components/LazyList/LazyList.tsx b/src/components/LazyList/LazyList.tsx new file mode 100644 index 0000000..2606f59 --- /dev/null +++ b/src/components/LazyList/LazyList.tsx @@ -0,0 +1,97 @@ +import React, {useMemo} from 'react'; +import {List} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {useLoadMoreSentinel} from '../../helpers/useLoadMoreSentinel'; +import {ListSpinner} from '../ListSpinner'; +import './LazyList.scss'; + +const block = cn('qp-lazy-list'); + +const SENTINEL_ROW_HEIGHT = 1; + +type SentinelRow = {__sentinel: true}; +type LazyListRow = T | SentinelRow; + +const isSentinelRow = (row: LazyListRow): row is SentinelRow => + Boolean(row) && typeof row === 'object' && '__sentinel' in (row as object); + +export type LazyListProps = { + items: T[]; + itemHeight: (item: T) => number; + renderItem: (item: T, isActive: boolean, index: number) => React.ReactNode; + hasMore?: boolean; + onLoadMore?: () => void; + onItemClick?: (item: T, index: number) => void; + selectedItemIndex?: T[keyof T] | number; + filterable?: boolean; + loading?: boolean; + error?: React.ReactNode; + emptyContent?: React.ReactNode; + isEmpty?: boolean; + className?: string; +}; + +export const LazyList = ({ + items, + itemHeight, + renderItem, + hasMore, + onLoadMore, + onItemClick, + selectedItemIndex, + filterable = false, + loading, + error, + emptyContent, + isEmpty, + className, +}: LazyListProps) => { + const sentinelRef = useLoadMoreSentinel(hasMore, onLoadMore); + + const rows: LazyListRow[] = useMemo( + () => (hasMore ? [...items, {__sentinel: true}] : items), + [items, hasMore], + ); + + const getRowHeight = (row: LazyListRow) => + isSentinelRow(row) ? SENTINEL_ROW_HEIGHT : itemHeight(row); + + if (error) { + return {error}; + } + + const empty = isEmpty ?? !items.length; + + if (loading && empty) { + return ; + } + + if (empty) { + return {emptyContent}; + } + + return ( + > + className={block(null, className)} + filterable={filterable} + items={rows} + itemHeight={getRowHeight} + itemsHeight={(listRows) => + listRows.reduce((total, row) => total + getRowHeight(row), 0) + } + renderItem={(row, isActive, index) => + isSentinelRow(row) ? ( +
+ ) : ( + renderItem(row, isActive, index) + ) + } + selectedItemIndex={selectedItemIndex as never} + onItemClick={(row, index) => { + if (!isSentinelRow(row)) { + onItemClick?.(row, index); + } + }} + /> + ); +}; diff --git a/src/components/LazyList/index.ts b/src/components/LazyList/index.ts new file mode 100644 index 0000000..10b00e4 --- /dev/null +++ b/src/components/LazyList/index.ts @@ -0,0 +1,2 @@ +export {LazyList} from './LazyList'; +export type {LazyListProps} from './LazyList'; diff --git a/src/components/ListSpinner/ListSpinner.scss b/src/components/ListSpinner/ListSpinner.scss new file mode 100644 index 0000000..150fe30 --- /dev/null +++ b/src/components/ListSpinner/ListSpinner.scss @@ -0,0 +1,3 @@ +.qp-list-spinner { + height: 100%; +} diff --git a/src/components/ListSpinner/ListSpinner.tsx b/src/components/ListSpinner/ListSpinner.tsx new file mode 100644 index 0000000..1bffbd0 --- /dev/null +++ b/src/components/ListSpinner/ListSpinner.tsx @@ -0,0 +1,18 @@ +import React, {FC} from 'react'; +import {Flex, Spin} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import './ListSpinner.scss'; + +const block = cn('qp-list-spinner'); + +export type ListSpinnerProps = { + className?: string; +}; + +export const ListSpinner: FC = ({className}) => { + return ( + + + + ); +}; diff --git a/src/components/ListSpinner/index.ts b/src/components/ListSpinner/index.ts new file mode 100644 index 0000000..3768c7b --- /dev/null +++ b/src/components/ListSpinner/index.ts @@ -0,0 +1,2 @@ +export {ListSpinner} from './ListSpinner'; +export type {ListSpinnerProps} from './ListSpinner'; diff --git a/src/components/PathEditor/PathEditor.scss b/src/components/PathEditor/PathEditor.scss new file mode 100644 index 0000000..a43d2df --- /dev/null +++ b/src/components/PathEditor/PathEditor.scss @@ -0,0 +1,41 @@ +.qp-path-editor { + position: relative; + display: block; + width: 100%; + + &__items { + max-height: 300px; + overflow: hidden auto; + background-color: var(--g-color-base-float); + } + + &__item { + display: flex; + align-items: center; + width: 100%; + padding: 0 16px; + overflow: hidden; + line-height: 32px; + color: var(--g-color-text-primary); + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + + &:hover, + &_selected { + background-color: var(--g-color-base-simple-hover); + } + + &_error { + color: var(--g-color-text-danger); + cursor: default; + } + } + + &__item-path { + margin-left: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/components/PathEditor/PathEditor.stories.helpers.ts b/src/components/PathEditor/PathEditor.stories.helpers.ts new file mode 100644 index 0000000..b307375 --- /dev/null +++ b/src/components/PathEditor/PathEditor.stories.helpers.ts @@ -0,0 +1,118 @@ +import type {LoadPathSuggestionsParams, PathEditorSuggestion} from '../../types/pathEditor'; +import type {NavigationItemKind} from '../../types/navigation'; + +type MockNode = { + name: string; + kind: NavigationItemKind; + children?: MockNode[]; +}; + +const TREE: MockNode = { + name: '', + kind: 'folder', + children: [ + { + name: 'home', + kind: 'folder', + children: [ + { + name: 'user', + kind: 'folder', + children: [ + { + name: 'projects', + kind: 'folder', + children: [ + {name: 'favorites', kind: 'folder'}, + {name: 'query.sql', kind: 'file'}, + ], + }, + {name: 'tmp', kind: 'folder'}, + {name: 'events', kind: 'table'}, + {name: 'events_dyn', kind: 'table'}, + ], + }, + { + name: 'my-projects', + kind: 'folder', + children: [ + {name: 'favorites', kind: 'folder'}, + { + name: 'very', + kind: 'folder', + children: [ + { + name: 'long', + kind: 'folder', + children: [ + { + name: 'nested', + kind: 'folder', + children: [ + { + name: 'directory', + kind: 'folder', + children: [{name: 'structure', kind: 'folder'}], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + {name: 'tmp', kind: 'folder'}, + {name: 'sys', kind: 'unknown'}, + ], +}; + +function getParentPath(path: string): string { + if (!path || path === '/') { + return '/'; + } + + const normalized = path.endsWith('/') ? path.slice(0, -1) : path; + const index = normalized.lastIndexOf('/'); + return index <= 0 ? '/' : normalized.slice(0, index); +} + +function findNode(path: string): MockNode | undefined { + if (path === '/' || path === '') { + return TREE; + } + + const parts = path.split('/').filter(Boolean); + let current: MockNode | undefined = TREE; + + for (const part of parts) { + current = current.children?.find((child) => child.name === part); + if (!current) { + return undefined; + } + } + + return current; +} + +export async function mockLoadPathSuggestions({ + path, +}: LoadPathSuggestionsParams): Promise { + await new Promise((resolve) => setTimeout(resolve, 150)); + + const parentPath = getParentPath(path); + const parent = findNode(parentPath); + const children = parent?.children ?? []; + const prefix = parentPath === '/' ? '' : parentPath; + + return children + .map((child) => ({ + parentPath, + childPath: `/${child.name}`, + path: `${prefix}/${child.name}`, + kind: child.kind, + })) + .sort((a, b) => a.childPath.localeCompare(b.childPath)); +} diff --git a/src/components/PathEditor/PathEditor.stories.tsx b/src/components/PathEditor/PathEditor.stories.tsx new file mode 100644 index 0000000..0b59f9e --- /dev/null +++ b/src/components/PathEditor/PathEditor.stories.tsx @@ -0,0 +1,57 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {action} from 'storybook/actions'; +import {PathEditor} from './PathEditor'; +import {mockLoadPathSuggestions} from './PathEditor.stories.helpers'; + +const meta: Meta = { + title: 'Components/PathEditor', + component: PathEditor, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultPath: '/home/user', + autoFocus: true, + onLoadSuggestions: mockLoadPathSuggestions, + onChange: action('onChange'), + onApply: action('onApply'), + onCancel: action('onCancel'), + onBlur: action('onBlur'), + }, +}; + +export const WithClear: Story = { + args: { + defaultPath: '/home/user/projects', + hasClear: true, + onLoadSuggestions: mockLoadPathSuggestions, + onApply: action('onApply'), + }, +}; + +export const Disabled: Story = { + args: { + defaultPath: '/home/user', + disabled: true, + onLoadSuggestions: mockLoadPathSuggestions, + }, +}; + +export const SuggestionsError: Story = { + args: { + defaultPath: '/home/user', + autoFocus: true, + suggestionsError: true, + errorMessage: 'Failed to load suggestions', + onLoadSuggestions: async () => { + throw new Error('Failed to load suggestions'); + }, + }, +}; diff --git a/src/components/PathEditor/PathEditor.tsx b/src/components/PathEditor/PathEditor.tsx new file mode 100644 index 0000000..c92a93d --- /dev/null +++ b/src/components/PathEditor/PathEditor.tsx @@ -0,0 +1,380 @@ +import React, { + FC, + type FocusEvent, + type KeyboardEvent, + type MouseEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {Icon, Popup, Text, TextInput} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type { + LoadPathSuggestions, + PathEditorEventPayload, + PathEditorSuggestion, + PathEditorSuggestionFilter, +} from '../../types/pathEditor'; +import {getDefaultNavigationIcon} from '../../helpers/getDefaultNavigationIcon'; +import { + filterByCurrentPath, + getCompletedPath, + getLastFragment, + getNextSelectedIndex, + getPrevSelectedIndex, +} from './helpers/suggestions'; +import i18n from './i18n'; +import './PathEditor.scss'; + +const DEBOUNCE_MS = 300; +const block = cn('qp-path-editor'); + +export type PathEditorProps = { + className?: string; + placeholder?: string; + defaultPath?: string; + disabled?: boolean; + autoFocus?: boolean; + hasClear?: boolean; + showErrors?: boolean; + customFilter?: PathEditorSuggestionFilter; + cluster?: string; + suggestions?: PathEditorSuggestion[]; + suggestionsError?: boolean; + errorMessage?: string; + onLoadSuggestions?: LoadPathSuggestions; + onChange?: (path: string) => void; + onFocus?: (event: FocusEvent, payload: PathEditorEventPayload) => void; + onBlur?: (path: string) => void; + onApply?: (path: string) => void; + onCancel?: () => void; +}; + +export const PathEditor: FC = ({ + className, + placeholder = i18n('field_placeholder'), + defaultPath = '', + disabled = false, + autoFocus = false, + hasClear = false, + showErrors = true, + customFilter, + cluster, + suggestions: suggestionsFromProps, + suggestionsError: suggestionsErrorFromProps, + errorMessage: errorMessageFromProps, + onLoadSuggestions, + onChange, + onFocus, + onBlur, + onApply, + onCancel, +}) => { + const inputRef = useRef(null); + const selectedItemRef = useRef(null); + const debounceTimerRef = useRef | undefined>(undefined); + const requestIdRef = useRef(0); + const wasDisabledRef = useRef(disabled); + const [rootElement, setRootElement] = useState(null); + + const onLoadSuggestionsRef = useRef(onLoadSuggestions); + onLoadSuggestionsRef.current = onLoadSuggestions; + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const customFilterRef = useRef(customFilter); + customFilterRef.current = customFilter; + + const [path, setPath] = useState(defaultPath); + const [loadedSuggestions, setLoadedSuggestions] = useState([]); + const [internalError, setInternalError] = useState(false); + const [internalErrorMessage, setInternalErrorMessage] = useState(); + const [inputFocus, setInputFocus] = useState(false); + const [inputChange, setInputChange] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [inputWidth, setInputWidth] = useState(0); + + const suggestions = suggestionsFromProps ?? loadedSuggestions; + const suggestionsError = suggestionsErrorFromProps ?? internalError; + const errorMessage = + errorMessageFromProps ?? internalErrorMessage ?? i18n('message_error-default'); + + const actualSuggestions = useMemo(() => { + if (!inputFocus || !inputChange || !suggestions.length) { + return []; + } + + return filterByCurrentPath(path, suggestions); + }, [inputFocus, inputChange, path, suggestions]); + + const loadSuggestions = useCallback( + (nextPath: string) => { + const load = onLoadSuggestionsRef.current; + if (!load) { + return; + } + + const requestId = ++requestIdRef.current; + + Promise.resolve( + load({ + path: nextPath, + customFilter: customFilterRef.current, + cluster, + }), + ) + .then((result) => { + if (requestId !== requestIdRef.current) { + return; + } + + if (result) { + setLoadedSuggestions(result); + } + setInternalError(false); + setInternalErrorMessage(undefined); + }) + .catch((error: unknown) => { + if (requestId !== requestIdRef.current) { + return; + } + + setLoadedSuggestions([]); + setInternalError(true); + setInternalErrorMessage(error instanceof Error ? error.message : undefined); + }); + }, + [cluster], + ); + + const debounceLoading = useCallback( + (nextPath: string) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + loadSuggestions(nextPath); + onChangeRef.current?.(nextPath); + }, DEBOUNCE_MS); + }, + [loadSuggestions], + ); + + const hideSuggestions = useCallback(() => { + setInputFocus(false); + setSelectedIndex(-1); + }, []); + + const handleInputChange = useCallback( + (nextPath: string) => { + setPath(nextPath); + setSelectedIndex(-1); + setInputChange(true); + setInputFocus(true); + debounceLoading(nextPath); + }, + [debounceLoading], + ); + + const handleInputFocus = useCallback( + (event: FocusEvent) => { + setInputFocus(true); + onFocus?.(event, {path}); + }, + [onFocus, path], + ); + + const handleInputBlur = useCallback(() => { + hideSuggestions(); + onBlur?.(path); + }, [hideSuggestions, onBlur, path]); + + const handleEnterClick = useCallback( + (event: KeyboardEvent) => { + event.preventDefault(); + + const inputPath = event.currentTarget.value; + + if (selectedIndex === -1) { + setPath(inputPath); + setSelectedIndex(-1); + onApply?.(inputPath); + return; + } + + const suggestion = actualSuggestions[selectedIndex]; + if (suggestion) { + handleInputChange(getCompletedPath(suggestion)); + } + }, + [actualSuggestions, handleInputChange, onApply, selectedIndex], + ); + + const handleEscClick = useCallback(() => { + inputRef.current?.blur(); + onCancel?.(); + }, [onCancel]); + + const handleTabClick = useCallback( + (event: KeyboardEvent) => { + event.preventDefault(); + + if (actualSuggestions.length === 1) { + handleInputChange(getCompletedPath(actualSuggestions[0])); + } else if (actualSuggestions.length > 1) { + setSelectedIndex((current) => getNextSelectedIndex(actualSuggestions, current)); + } + }, + [actualSuggestions, handleInputChange], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + if (!actualSuggestions.length) { + break; + } + event.preventDefault(); + setSelectedIndex((current) => getNextSelectedIndex(actualSuggestions, current)); + break; + case 'ArrowUp': + if (!actualSuggestions.length) { + break; + } + event.preventDefault(); + setSelectedIndex((current) => getPrevSelectedIndex(actualSuggestions, current)); + break; + case 'Enter': + handleEnterClick(event); + break; + case 'Escape': + handleEscClick(); + break; + case 'Tab': + if (!actualSuggestions.length) { + break; + } + handleTabClick(event); + break; + } + }, + [actualSuggestions, handleEnterClick, handleEscClick, handleTabClick], + ); + + useEffect(() => { + if (path) { + loadSuggestions(path); + } + + return () => { + requestIdRef.current += 1; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (wasDisabledRef.current && !disabled) { + inputRef.current?.focus(); + } + wasDisabledRef.current = disabled; + }, [disabled]); + + useLayoutEffect(() => { + if (inputFocus && rootElement) { + setInputWidth(rootElement.offsetWidth); + } + }, [inputFocus, path, rootElement]); + + useLayoutEffect(() => { + selectedItemRef.current?.scrollIntoView({block: 'nearest'}); + }, [selectedIndex]); + + const isPopupVisible = Boolean( + (actualSuggestions.length || (suggestionsError && showErrors)) && inputFocus, + ); + + return ( +
+ + { + if (!open) { + hideSuggestions(); + } + }} + anchorElement={rootElement} + open={isPopupVisible} + offset={{mainAxis: 0, crossAxis: 0}} + disableEscapeKeyDown + disableFocusOut + > +
+ {suggestionsError && showErrors ? ( + + {errorMessage} + + ) : ( + actualSuggestions.map((item, index) => { + const completedPath = getCompletedPath(item); + const isSelected = index === selectedIndex; + const lastFragment = getLastFragment(item.path); + + const handleMouseDown = (event: MouseEvent) => { + handleInputChange(completedPath); + event.preventDefault(); + }; + + return ( +
+ {item.icon ?? ( + + )} + + {lastFragment ? `\u2026/${lastFragment}` : item.path} + +
+ ); + }) + )} +
+
+
+ ); +}; diff --git a/src/components/PathEditor/helpers/suggestions.ts b/src/components/PathEditor/helpers/suggestions.ts new file mode 100644 index 0000000..57acec4 --- /dev/null +++ b/src/components/PathEditor/helpers/suggestions.ts @@ -0,0 +1,40 @@ +import type {PathEditorSuggestion} from '../../../types/pathEditor'; + +export function filterByCurrentPath( + currentPath: string, + suggestions: PathEditorSuggestion[], +): PathEditorSuggestion[] { + const path = currentPath.toLowerCase(); + + return suggestions.filter((child) => { + const hasPartOfPath = child.path.toLowerCase().startsWith(path); + const isShowCurrentChild = child.path.toLowerCase() !== path || child.kind === 'folder'; + + return hasPartOfPath && isShowCurrentChild; + }); +} + +export function getNextSelectedIndex(suggestions: PathEditorSuggestion[], selectedIndex: number) { + if (selectedIndex === -1 || selectedIndex === suggestions.length - 1) { + return 0; + } + + return selectedIndex + 1; +} + +export function getPrevSelectedIndex(suggestions: PathEditorSuggestion[], selectedIndex: number) { + if (selectedIndex === -1 || selectedIndex === 0) { + return suggestions.length - 1; + } + + return selectedIndex - 1; +} + +export function getCompletedPath(suggestion: PathEditorSuggestion) { + return suggestion.kind === 'folder' ? `${suggestion.path}/` : suggestion.path; +} + +export function getLastFragment(path: string): string | undefined { + const segments = path.split('/').filter(Boolean); + return segments[segments.length - 1]; +} diff --git a/src/components/PathEditor/i18n/dicts.ts b/src/components/PathEditor/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/components/PathEditor/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/components/PathEditor/i18n/en.json b/src/components/PathEditor/i18n/en.json new file mode 100644 index 0000000..eb4cf79 --- /dev/null +++ b/src/components/PathEditor/i18n/en.json @@ -0,0 +1,4 @@ +{ + "message_error-default": "Oops, something went wrong", + "field_placeholder": "Enter the path..." +} diff --git a/src/components/PathEditor/i18n/index.ts b/src/components/PathEditor/i18n/index.ts new file mode 100644 index 0000000..4be1080 --- /dev/null +++ b/src/components/PathEditor/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:path-editor', dicts); diff --git a/src/components/PathEditor/i18n/ru.json b/src/components/PathEditor/i18n/ru.json new file mode 100644 index 0000000..58fd46f --- /dev/null +++ b/src/components/PathEditor/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "message_error-default": "Что-то пошло не так", + "field_placeholder": "Введите путь..." +} diff --git a/src/components/PathEditor/index.ts b/src/components/PathEditor/index.ts new file mode 100644 index 0000000..c413666 --- /dev/null +++ b/src/components/PathEditor/index.ts @@ -0,0 +1,2 @@ +export {PathEditor} from './PathEditor'; +export type {PathEditorProps} from './PathEditor'; diff --git a/src/components/index.ts b/src/components/index.ts index da7698f..50ddc1d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,11 +1,24 @@ +export {DataTable} from './DataTable'; +export type {DataTableProps, Column} from './DataTable'; export {QueryStatusIcon} from './QueryStatusIcon'; export {QueryDuration} from './QueryDuration'; +export {LazyList} from './LazyList'; +export type {LazyListProps} from './LazyList'; +export {ListSpinner} from './ListSpinner'; +export type {ListSpinnerProps} from './ListSpinner'; +export {Breadcrumbs} from './Breadcrumbs'; +export type {BreadcrumbsProps} from './Breadcrumbs'; +export {PathEditor} from './PathEditor'; +export type {PathEditorProps} from './PathEditor'; export {HistoryFilter} from './HistoryFilter'; export {HistoryPrivateIcon} from './HistoryPrivateIcon'; export {FieldsSelector} from './FieldsSelector'; +export type {FieldsSelectorOption, FieldsSelectorProps} from './FieldsSelector'; +export {FieldsSearchToolbar} from './FieldsSearchToolbar'; +export type {FieldsSearchToolbarProps} from './FieldsSearchToolbar'; export {HistoryGroupHeader} from './HistoryGroupHeader'; -export {HistoryListEmpty} from './HistoryListEmpty'; -export type {HistoryListEmptyProps} from './HistoryListEmpty'; +export {EmptyContent} from './EmptyContent'; +export type {EmptyContentProps, EmptyContentVariant} from './EmptyContent'; export {RowLink} from './RowLink'; export type {RowLinkProps} from './RowLink'; export {SearchRowLayout} from './SearchRowLayout'; diff --git a/src/constants/row.ts b/src/constants/row.ts index 9cdc7b2..82e47ed 100644 --- a/src/constants/row.ts +++ b/src/constants/row.ts @@ -1 +1,2 @@ export const SEARCH_ROW_HEIGHT = 110; +export const NAVIGATION_ROW_HEIGHT = 32; diff --git a/src/helpers/getDefaultNavigationIcon.ts b/src/helpers/getDefaultNavigationIcon.ts new file mode 100644 index 0000000..cb2a28d --- /dev/null +++ b/src/helpers/getDefaultNavigationIcon.ts @@ -0,0 +1,33 @@ +import type {IconData} from '@gravity-ui/uikit'; +import BanIcon from '@gravity-ui/icons/svgs/ban.svg'; +import EyeSlashIcon from '@gravity-ui/icons/svgs/eye-slash.svg'; +import FileTextIcon from '@gravity-ui/icons/svgs/file-text.svg'; +import FolderIcon from '@gravity-ui/icons/svgs/folder.svg'; +import LayoutHeaderCellsLargeIcon from '@gravity-ui/icons/svgs/layout-header-cells-large.svg'; +import LinkIcon from '@gravity-ui/icons/svgs/link.svg'; +import LinkSlashIcon from '@gravity-ui/icons/svgs/link-slash.svg'; +import type {NavigationItemKind} from '../types/navigation'; + +export function getDefaultNavigationIcon( + kind: NavigationItemKind = 'unknown', + targetPathBroken?: boolean, +): IconData { + if (kind === 'link' && targetPathBroken) { + return LinkSlashIcon; + } + + switch (kind) { + case 'folder': + return FolderIcon; + case 'file': + return FileTextIcon; + case 'table': + return LayoutHeaderCellsLargeIcon; + case 'link': + return LinkIcon; + case 'unknown': + return EyeSlashIcon; + default: + return BanIcon; + } +} diff --git a/src/helpers/getParentPath.ts b/src/helpers/getParentPath.ts new file mode 100644 index 0000000..16e9879 --- /dev/null +++ b/src/helpers/getParentPath.ts @@ -0,0 +1,10 @@ +export function getParentPath(path: string): string | undefined { + const trimmed = path.replace(/\/+$/, ''); + const lastSlashIndex = trimmed.lastIndexOf('/'); + + if (lastSlashIndex <= 0) { + return trimmed.startsWith('/') ? '/' : undefined; + } + + return trimmed.slice(0, lastSlashIndex); +} diff --git a/src/helpers/useLoadMoreSentinel.ts b/src/helpers/useLoadMoreSentinel.ts new file mode 100644 index 0000000..3c78536 --- /dev/null +++ b/src/helpers/useLoadMoreSentinel.ts @@ -0,0 +1,31 @@ +import {useCallback, useEffect, useRef} from 'react'; + +export function useLoadMoreSentinel(hasMore: boolean | undefined, onLoadMore?: () => void) { + const observerRef = useRef(null); + const hasMoreRef = useRef(hasMore); + hasMoreRef.current = hasMore; + const onLoadMoreRef = useRef(onLoadMore); + onLoadMoreRef.current = onLoadMore; + + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + }; + }, []); + + return useCallback((node: HTMLElement | null) => { + observerRef.current?.disconnect(); + + if (!node) { + return; + } + + observerRef.current = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting) && hasMoreRef.current) { + onLoadMoreRef.current?.(); + } + }); + + observerRef.current.observe(node); + }, []); +} diff --git a/src/helpers/useVisibleColumns.ts b/src/helpers/useVisibleColumns.ts new file mode 100644 index 0000000..b3c656c --- /dev/null +++ b/src/helpers/useVisibleColumns.ts @@ -0,0 +1,26 @@ +import {useState} from 'react'; + +export type UseVisibleColumnsOptions = { + value?: string[]; + onChange?: (value: string[]) => void; + defaultValue?: string[]; +}; + +export function useVisibleColumns( + allColumns: string[], + {value, onChange, defaultValue}: UseVisibleColumnsOptions, +): [string[], (value: string[]) => void] { + const isControlled = value !== undefined; + const [state, setState] = useState(() => defaultValue ?? allColumns); + + const activeColumns = isControlled ? value : state; + + const handleChange = (next: string[]) => { + if (!isControlled) { + setState(next); + } + onChange?.(next); + }; + + return [activeColumns, handleChange]; +} diff --git a/src/index.ts b/src/index.ts index 031bda2..ca759ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,5 @@ export * from './modules'; export * from './widgets'; export * from './types/history'; export * from './types/tutorial'; +export * from './types/navigation'; +export * from './types/pathEditor'; diff --git a/src/modules/ClustersList/ClustersList.scss b/src/modules/ClustersList/ClustersList.scss new file mode 100644 index 0000000..b6082f6 --- /dev/null +++ b/src/modules/ClustersList/ClustersList.scss @@ -0,0 +1,11 @@ +.qp-cluster-row { + width: 100%; + padding: 0 var(--g-spacing-2); + box-sizing: border-box; + cursor: pointer; + + &__title { + flex-grow: 1; + min-width: 0; + } +} diff --git a/src/modules/ClustersList/ClustersList.tsx b/src/modules/ClustersList/ClustersList.tsx new file mode 100644 index 0000000..f250479 --- /dev/null +++ b/src/modules/ClustersList/ClustersList.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import cn from 'bem-cn-lite'; +import {NavigationCluster, RenderNavigationCluster} from '../../types/navigation'; +import {ClusterRow} from './internal/ClusterRow'; +import {LazyList} from '../../components'; +import {NAVIGATION_ROW_HEIGHT} from '../../constants/row'; +import './ClustersList.scss'; + +const block = cn('qp-clusters-list'); + +export type ClustersListProps = { + items: T[]; + loading?: boolean; + error?: React.ReactNode; + hasMore?: boolean; + onLoadMore?: () => void; + emptyContent?: React.ReactNode; + renderRowItem?: RenderNavigationCluster; + onItemClick?: (cluster: T) => void; + className?: string; +}; + +export const ClustersList = ({ + items, + loading, + error, + hasMore, + onLoadMore, + emptyContent, + renderRowItem, + onItemClick, + className, +}: ClustersListProps) => { + return ( + + className={block(null, className)} + items={items} + itemHeight={() => NAVIGATION_ROW_HEIGHT} + renderItem={(cluster, isActive, index) => + renderRowItem?.({cluster, index, isActive}) ?? + } + hasMore={hasMore} + onLoadMore={onLoadMore} + loading={loading} + error={error} + emptyContent={emptyContent} + onItemClick={onItemClick} + /> + ); +}; diff --git a/src/modules/ClustersList/index.ts b/src/modules/ClustersList/index.ts new file mode 100644 index 0000000..60239f1 --- /dev/null +++ b/src/modules/ClustersList/index.ts @@ -0,0 +1,4 @@ +export {ClustersList} from './ClustersList'; +export type {ClustersListProps} from './ClustersList'; +export {ClusterRow} from './internal/ClusterRow'; +export type {ClusterRowProps} from './internal/ClusterRow'; diff --git a/src/modules/ClustersList/internal/ClusterRow.tsx b/src/modules/ClustersList/internal/ClusterRow.tsx new file mode 100644 index 0000000..d810597 --- /dev/null +++ b/src/modules/ClustersList/internal/ClusterRow.tsx @@ -0,0 +1,34 @@ +import React, {FC} from 'react'; +import {Avatar, Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {NavigationCluster} from '../../../types/navigation'; + +const block = cn('qp-cluster-row'); + +export type ClusterRowProps = { + cluster: NavigationCluster; +}; + +export const ClusterRow: FC = ({ + cluster: {icon, title, color, backgroundColor, description}, +}) => { + return ( + + {icon ?? ( + + )} + + {title} + + + {description} + + + ); +}; diff --git a/src/modules/NavigationDetail/NavigationDetail.scss b/src/modules/NavigationDetail/NavigationDetail.scss new file mode 100644 index 0000000..1db8840 --- /dev/null +++ b/src/modules/NavigationDetail/NavigationDetail.scss @@ -0,0 +1,13 @@ +.qp-navigation-detail { + min-width: 0; + + &__tabs { + overflow-x: auto; + } + + &__content { + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + } +} diff --git a/src/modules/NavigationDetail/NavigationDetail.tsx b/src/modules/NavigationDetail/NavigationDetail.tsx new file mode 100644 index 0000000..69b22ec --- /dev/null +++ b/src/modules/NavigationDetail/NavigationDetail.tsx @@ -0,0 +1,118 @@ +import React, {useMemo, useState} from 'react'; +import cn from 'bem-cn-lite'; +import {Flex} from '@gravity-ui/uikit'; +import {NavigationHeader} from '../NavigationHeader'; +import {SearchWithButtons} from '../../components'; +import { + NavigationDetailConfig, + NavigationHeaderAction, + NavigationLocation, +} from '../../types/navigation'; +import type {LoadPathSuggestions} from '../../types/pathEditor'; +import {NavigationDetailTabs} from './internal/NavigationDetailTabs'; +import './NavigationDetail.scss'; + +const block = cn('qp-navigation-detail'); + +export type NavigationDetailProps = { + config: NavigationDetailConfig; + location: NavigationLocation; + onUpdate: (location: NavigationLocation) => void; + onLoadSuggestions?: LoadPathSuggestions; + actions?: NavigationHeaderAction[]; + activeTab?: string; + onTabUpdate?: (tab: string) => void; + search?: string; + onSearchUpdate?: (value: string) => void; + className?: string; +}; + +const getInitialTab = (config: NavigationDetailConfig): string => { + const visibleTabs = config.tabs.filter((tab) => !tab.hidden); + const defaultTab = config.defaultTab + ? visibleTabs.find((tab) => tab.id === config.defaultTab) + : undefined; + const firstEnabled = visibleTabs.find((tab) => !tab.disabled); + return (defaultTab ?? firstEnabled ?? visibleTabs[0])?.id ?? ''; +}; + +export const NavigationDetail: React.FC = ({ + config, + location, + onUpdate, + onLoadSuggestions, + actions, + activeTab: activeTabProp, + onTabUpdate, + search: searchProp, + onSearchUpdate, + className, +}) => { + const isTabControlled = activeTabProp !== undefined; + const isSearchControlled = searchProp !== undefined; + + const [activeTabState, setActiveTabState] = useState(() => getInitialTab(config)); + const [searchState, setSearchState] = useState(''); + + const activeTab = isTabControlled ? activeTabProp : activeTabState; + const search = isSearchControlled ? searchProp : searchState; + + const handleTabUpdate = (tab: string) => { + if (!isTabControlled) { + setActiveTabState(tab); + } + onTabUpdate?.(tab); + }; + + const handleSearchUpdate = (value: string) => { + if (!isSearchControlled) { + setSearchState(value); + } + onSearchUpdate?.(value); + }; + + const visibleTabs = useMemo(() => config.tabs.filter((tab) => !tab.hidden), [config.tabs]); + + const mergedActions = useMemo(() => { + if (!actions && !config.actions) { + return undefined; + } + return [...(actions ?? []), ...(config.actions ?? [])]; + }, [actions, config.actions]); + + const activeTabConfig = visibleTabs.find((tab) => tab.id === activeTab); + const activeContent = activeTabConfig + ? (activeTabConfig.renderContent?.({ + search, + onSearchUpdate: handleSearchUpdate, + searchPlaceholder: config.searchPlaceholder, + }) ?? + activeTabConfig.content ?? + null) + : null; + + return ( + + + + {config.hasSearch && ( + + )} +
{activeContent}
+
+ ); +}; diff --git a/src/modules/NavigationDetail/index.ts b/src/modules/NavigationDetail/index.ts new file mode 100644 index 0000000..9488a35 --- /dev/null +++ b/src/modules/NavigationDetail/index.ts @@ -0,0 +1,2 @@ +export {NavigationDetail} from './NavigationDetail'; +export type {NavigationDetailProps} from './NavigationDetail'; diff --git a/src/modules/NavigationDetail/internal/NavigationDetailTabs.tsx b/src/modules/NavigationDetail/internal/NavigationDetailTabs.tsx new file mode 100644 index 0000000..481fd6f --- /dev/null +++ b/src/modules/NavigationDetail/internal/NavigationDetailTabs.tsx @@ -0,0 +1,32 @@ +import React, {FC} from 'react'; +import {SegmentedRadioGroup} from '@gravity-ui/uikit'; +import {NavigationDetailTab} from '../../../types/navigation'; + +export type NavigationDetailTabsProps = { + tabs: NavigationDetailTab[]; + activeTab: string; + onUpdate: (tab: string) => void; + className?: string; +}; + +export const NavigationDetailTabs: FC = ({ + tabs, + activeTab, + onUpdate, + className, +}) => { + return ( + + {tabs.map((tab) => ( + + {tab.title} + + ))} + + ); +}; diff --git a/src/modules/NavigationHeader/NavigationHeader.stories.tsx b/src/modules/NavigationHeader/NavigationHeader.stories.tsx new file mode 100644 index 0000000..00e97ff --- /dev/null +++ b/src/modules/NavigationHeader/NavigationHeader.stories.tsx @@ -0,0 +1,118 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {action} from 'storybook/actions'; +import {Box, Icon} from '@gravity-ui/uikit'; +import FileArrowRightOutIcon from '@gravity-ui/icons/svgs/file-arrow-right-out.svg'; +import ArrowUpRightFromSquareIcon from '@gravity-ui/icons/svgs/arrow-up-right-from-square.svg'; +import {NavigationHeader} from './NavigationHeader'; +import {NavigationHeaderAction, NavigationLocation} from '../../types/navigation'; +import {mockLoadPathSuggestions} from '../../components/PathEditor/PathEditor.stories.helpers'; + +const meta: Meta = { + title: 'Modules/NavigationHeader', + component: NavigationHeader, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +const defaultLocation: NavigationLocation = { + cluster: 'test', + path: '/home/my-projects/favorites', +}; + +const longLocation: NavigationLocation = { + cluster: 'prod', + path: '/home/my-projects/favorites/very/long/nested/directory/structure', +}; + +const logAction = action('actionClick'); + +const defaultActions: NavigationHeaderAction[] = [ + { + id: 'paste', + title: 'Paste path', + content: , + onClick: (location) => logAction('Paste', location), + }, + { + id: 'open', + title: 'Open in new tab', + content: , + onClick: (location) => logAction('Open', location), + }, +]; + +const InteractiveStory = ({ + initialLocation = defaultLocation, + actions = defaultActions, +}: { + initialLocation?: NavigationLocation; + actions?: NavigationHeaderAction[]; +}) => { + const [location, setLocation] = useState(initialLocation); + + return ( + + + + ); +}; + +export const Default: Story = {render: () => }; + +export const WithoutActions: Story = { + args: { + location: defaultLocation, + onUpdate: action('onUpdate'), + onLoadSuggestions: mockLoadPathSuggestions, + }, + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +export const EmptyLocation: Story = { + args: { + location: {cluster: undefined, path: undefined}, + actions: defaultActions, + onUpdate: action('onUpdate'), + }, +}; + +export const LongPath: Story = { + render: () => , +}; + +export const DisabledAction: Story = { + render: () => ( + + headerAction.id === 'paste' ? {...headerAction, disabled: true} : headerAction, + )} + /> + ), +}; + +export const HiddenAction: Story = { + render: () => ( + + headerAction.id === 'open' ? {...headerAction, hidden: true} : headerAction, + )} + /> + ), +}; diff --git a/src/modules/NavigationHeader/NavigationHeader.tsx b/src/modules/NavigationHeader/NavigationHeader.tsx new file mode 100644 index 0000000..6292123 --- /dev/null +++ b/src/modules/NavigationHeader/NavigationHeader.tsx @@ -0,0 +1,50 @@ +import React, {FC} from 'react'; +import {Button, Flex} from '@gravity-ui/uikit'; +import {Breadcrumbs} from '../../components/Breadcrumbs'; +import {NavigationHeaderAction, NavigationLocation} from '../../types/navigation'; +import type {LoadPathSuggestions} from '../../types/pathEditor'; + +export type NavigationHeaderProps = { + location: NavigationLocation; + actions?: NavigationHeaderAction[]; + onUpdate: (location: NavigationLocation) => void; + onLoadSuggestions?: LoadPathSuggestions; + className?: string; +}; + +export const NavigationHeader: FC = ({ + location, + actions, + onUpdate, + onLoadSuggestions, + className, +}) => { + const visibleActions = actions?.filter((action) => !action.hidden) ?? []; + + return ( + + + {visibleActions.length > 0 && ( + + {visibleActions.map((action) => ( + + ))} + + )} + + ); +}; diff --git a/src/modules/NavigationHeader/index.ts b/src/modules/NavigationHeader/index.ts new file mode 100644 index 0000000..3806d2f --- /dev/null +++ b/src/modules/NavigationHeader/index.ts @@ -0,0 +1,2 @@ +export {NavigationHeader} from './NavigationHeader'; +export type {NavigationHeaderProps} from './NavigationHeader'; diff --git a/src/modules/NavigationItemsList/NavigationItemsList.scss b/src/modules/NavigationItemsList/NavigationItemsList.scss new file mode 100644 index 0000000..a4f2675 --- /dev/null +++ b/src/modules/NavigationItemsList/NavigationItemsList.scss @@ -0,0 +1,65 @@ +.qp-navigation-item-row { + width: 100%; + padding: 0 var(--g-spacing-2); + box-sizing: border-box; + cursor: pointer; + + &__title { + flex-grow: 1; + min-width: 0; + } + + &_disabled { + color: var(--g-color-text-secondary); + cursor: default; + } +} + +.qp-navigation-items-list-header { + padding: 0 var(--g-spacing-2); + + &__button { + padding-left: 0; + } +} + +.qp-navigation-items-list { + display: flex; + flex-direction: column; + height: 100%; + + &__header { + height: 28px; + } + + &__list { + flex-grow: 1; + min-height: 0; + + .g-list__item_selected:hover { + background-color: var(--g-color-base-selection-hover); + } + } + + &__empty { + display: flex; + flex-direction: column; + flex-grow: 1; + min-height: 0; + } + + &__parent-row { + flex-shrink: 0; + height: 32px; + cursor: pointer; + + &:hover { + background-color: var(--g-color-base-simple-hover); + } + } + + &__empty-content { + flex-grow: 1; + min-height: 0; + } +} diff --git a/src/modules/NavigationItemsList/NavigationItemsList.tsx b/src/modules/NavigationItemsList/NavigationItemsList.tsx new file mode 100644 index 0000000..818d7cb --- /dev/null +++ b/src/modules/NavigationItemsList/NavigationItemsList.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import cn from 'bem-cn-lite'; +import {NavigationItem, NavigationSortOrder, RenderNavigationItem} from '../../types/navigation'; +import {LazyList} from '../../components'; +import {NavigationItemRow} from './internal/NavigationItemRow'; +import {NavigationItemsListHeader} from './internal/NavigationItemsListHeader'; +import {NavigationItemsListEmptyState} from './internal/NavigationItemsListEmptyState'; +import {useParentRow} from './internal/useParentRow'; +import {NAVIGATION_ROW_HEIGHT} from '../../constants/row'; +import './NavigationItemsList.scss'; + +const block = cn('qp-navigation-items-list'); + +export type NavigationItemsListProps = { + items: T[]; + path?: string; + search?: string; + sort?: NavigationSortOrder; + onSortUpdate?: (sort: NavigationSortOrder) => void; + titleLabel: string; + loading?: boolean; + error?: React.ReactNode; + hasMore?: boolean; + onLoadMore?: () => void; + emptyContent?: React.ReactNode; + renderRowItem?: RenderNavigationItem; + onItemClick?: (item: T) => void; + className?: string; +}; + +export const NavigationItemsList = ({ + items, + path, + search, + sort, + onSortUpdate, + titleLabel, + loading, + error, + hasMore, + onLoadMore, + emptyContent, + renderRowItem, + onItemClick, + className, +}: NavigationItemsListProps) => { + const parentRow = useParentRow(path, search); + + const rows = (parentRow ? [parentRow as T, ...items] : items) as T[]; + + return ( +
+ + + className={block('list')} + items={rows} + isEmpty={!items.length} + itemHeight={() => NAVIGATION_ROW_HEIGHT} + renderItem={(item, isActive, index) => { + const isParentRow = Boolean(parentRow) && item === (parentRow as T); + + return ( + renderRowItem?.({item, index, isActive, isParentRow}) ?? ( + + ) + ); + }} + hasMore={hasMore} + onLoadMore={onLoadMore} + loading={loading} + error={error} + emptyContent={ + + } + onItemClick={(item) => { + if (!item.disabled) { + onItemClick?.(item); + } + }} + /> +
+ ); +}; diff --git a/src/modules/NavigationItemsList/index.ts b/src/modules/NavigationItemsList/index.ts new file mode 100644 index 0000000..fe8702c --- /dev/null +++ b/src/modules/NavigationItemsList/index.ts @@ -0,0 +1,4 @@ +export {NavigationItemsList} from './NavigationItemsList'; +export type {NavigationItemsListProps} from './NavigationItemsList'; +export {NavigationItemRow} from './internal/NavigationItemRow'; +export type {NavigationItemRowProps} from './internal/NavigationItemRow'; diff --git a/src/modules/NavigationItemsList/internal/NavigationItemRow.tsx b/src/modules/NavigationItemsList/internal/NavigationItemRow.tsx new file mode 100644 index 0000000..3771767 --- /dev/null +++ b/src/modules/NavigationItemsList/internal/NavigationItemRow.tsx @@ -0,0 +1,28 @@ +import React, {FC} from 'react'; +import {Flex, Icon, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {NavigationItem} from '../../../types/navigation'; +import {getDefaultNavigationIcon} from '../../../helpers/getDefaultNavigationIcon'; + +const block = cn('qp-navigation-item-row'); + +export type NavigationItemRowProps = { + item: NavigationItem; +}; + +export const NavigationItemRow: FC = ({item}) => { + return ( + + {item.icon ?? ( + + )} + + {item.title} + + + ); +}; diff --git a/src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx b/src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx new file mode 100644 index 0000000..4fdaddd --- /dev/null +++ b/src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import {Flex} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {NavigationItem, RenderNavigationItem} from '../../../types/navigation'; +import {NavigationItemRow} from './NavigationItemRow'; + +const block = cn('qp-navigation-items-list'); + +export type NavigationItemsListEmptyStateProps = { + parentRow?: T; + emptyContent?: React.ReactNode; + renderRowItem?: RenderNavigationItem; + onItemClick?: (item: T) => void; +}; + +export const NavigationItemsListEmptyState = ({ + parentRow, + emptyContent, + renderRowItem, + onItemClick, +}: NavigationItemsListEmptyStateProps) => { + return ( +
+ {parentRow && ( + { + if (!parentRow.disabled) { + onItemClick?.(parentRow); + } + }} + > + {renderRowItem?.({ + item: parentRow, + index: 0, + isActive: false, + isParentRow: true, + }) ?? } + + )} +
{emptyContent}
+
+ ); +}; diff --git a/src/modules/NavigationItemsList/internal/NavigationItemsListHeader.scss b/src/modules/NavigationItemsList/internal/NavigationItemsListHeader.scss new file mode 100644 index 0000000..e158b47 --- /dev/null +++ b/src/modules/NavigationItemsList/internal/NavigationItemsListHeader.scss @@ -0,0 +1,6 @@ +.qp-navigation-items-list-header { + &__sort { + cursor: pointer; + user-select: none; + } +} \ No newline at end of file diff --git a/src/modules/NavigationItemsList/internal/NavigationItemsListHeader.tsx b/src/modules/NavigationItemsList/internal/NavigationItemsListHeader.tsx new file mode 100644 index 0000000..2f34ed1 --- /dev/null +++ b/src/modules/NavigationItemsList/internal/NavigationItemsListHeader.tsx @@ -0,0 +1,58 @@ +import React, {FC} from 'react'; +import {Flex, Icon, Text} from '@gravity-ui/uikit'; +import ArrowUpIcon from '@gravity-ui/icons/svgs/arrow-up.svg'; +import ArrowDownIcon from '@gravity-ui/icons/svgs/arrow-down.svg'; +import ArrowUpArrowDownIcon from '@gravity-ui/icons/svgs/arrow-up-arrow-down.svg'; +import cn from 'bem-cn-lite'; +import {NavigationSortOrder} from '../../../types/navigation'; +import './NavigationItemsListHeader.scss'; + +const block = cn('qp-navigation-items-list-header'); + +const SORT_ICONS: Record = { + asc: ArrowUpIcon, + desc: ArrowDownIcon, +}; + +export type NavigationItemsListHeaderProps = { + titleLabel: string; + sort?: NavigationSortOrder; + onSortUpdate?: (sort: NavigationSortOrder) => void; + className?: string; +}; + +export const NavigationItemsListHeader: FC = ({ + titleLabel, + sort, + onSortUpdate, + className, +}) => { + if (!onSortUpdate) { + return ( + + {titleLabel} + + ); + } + + const handleClick = () => { + onSortUpdate(sort === 'asc' ? 'desc' : 'asc'); + }; + + const icon = sort ? SORT_ICONS[sort] : ArrowUpArrowDownIcon; + + return ( + + + {titleLabel} + + + + ); +}; diff --git a/src/modules/NavigationItemsList/internal/useParentRow.ts b/src/modules/NavigationItemsList/internal/useParentRow.ts new file mode 100644 index 0000000..3269779 --- /dev/null +++ b/src/modules/NavigationItemsList/internal/useParentRow.ts @@ -0,0 +1,23 @@ +import {useMemo} from 'react'; +import {NavigationItem} from '../../../types/navigation'; +import {getParentPath} from '../../../helpers/getParentPath'; + +export function useParentRow( + path: string | undefined, + search: string | undefined, +): NavigationItem | undefined { + const parentPath = path && !search ? getParentPath(path) : undefined; + + return useMemo(() => { + if (!parentPath) { + return undefined; + } + + return { + path: parentPath, + title: '\u2026', + kind: 'folder', + hasChildren: true, + }; + }, [parentPath]); +} diff --git a/src/modules/NavigationMeta/NavigationMeta.scss b/src/modules/NavigationMeta/NavigationMeta.scss new file mode 100644 index 0000000..691fd8c --- /dev/null +++ b/src/modules/NavigationMeta/NavigationMeta.scss @@ -0,0 +1,25 @@ +.qp-navigation-meta { + &__error { + display: block; + } + + &__group-title { + display: block; + margin-bottom: var(--g-spacing-2); + } + + &__group-body { + width: 100%; + display: grid; + grid-gap: 12px; + grid-template-columns: 128px 1fr; + } + + &__skeleton-row { + height: 24px; + } + + &__empty { + height: 100%; + } +} diff --git a/src/modules/NavigationMeta/NavigationMeta.stories.tsx b/src/modules/NavigationMeta/NavigationMeta.stories.tsx new file mode 100644 index 0000000..f8eb609 --- /dev/null +++ b/src/modules/NavigationMeta/NavigationMeta.stories.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Label, Text} from '@gravity-ui/uikit'; +import {NavigationMeta} from './NavigationMeta'; +import type {NavigationMetaConfig} from '../../types/navigation'; + +const GROUPS: NavigationMetaConfig['groups'] = [ + { + title: 'General', + items: [ + {name: 'Type', value: 'table'}, + {name: 'ID', value: '1-2-3-abcdef'}, + {name: 'Account', value: 'production'}, + {name: 'Owner', value: 'robot-yt'}, + ], + }, + { + title: 'Storage', + items: [ + {name: 'Compression', value: 'zstd_5'}, + {name: 'Erasure codec', value: 'none'}, + {name: 'Disk space', value: '1.2 TB'}, + {name: 'Chunk count', value: '42'}, + ], + }, +]; + +const meta: Meta = { + title: 'Modules/NavigationMeta', + component: NavigationMeta, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: {groups: GROUPS, loaded: true}, + }, +}; + +export const Loading: Story = { + args: { + data: {groups: [], loading: true}, + }, +}; + +export const Empty: Story = { + args: { + data: {groups: [], loaded: true}, + }, +}; + +export const Error: Story = { + args: { + data: {groups: [], errorContent: 'Failed to load table metadata'}, + }, +}; + +export const WithExtraContent: Story = { + args: { + data: {groups: GROUPS, loaded: true}, + view: { + extraContent: ( + + Additional info rendered below the default groups + + ), + }, + }, +}; + +export const CustomRender: Story = { + args: { + data: {groups: GROUPS, loaded: true}, + view: { + render: (data) => ( +
+ {data.groups + .flatMap((group) => group.items) + .map((item, index) => ( + + ))} +
+ ), + }, + }, +}; diff --git a/src/modules/NavigationMeta/NavigationMeta.tsx b/src/modules/NavigationMeta/NavigationMeta.tsx new file mode 100644 index 0000000..08ceeef --- /dev/null +++ b/src/modules/NavigationMeta/NavigationMeta.tsx @@ -0,0 +1,87 @@ +import React, {useMemo} from 'react'; +import {Flex, Skeleton, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {EmptyContent} from '../../components'; +import type {NavigationMetaConfig, NavigationMetaItem} from '../../types/navigation'; +import {buildMetaGroups} from './helpers/buildMetaGroups'; +import i18n from './i18n'; +import './NavigationMeta.scss'; + +const block = cn('qp-navigation-meta'); + +const SKELETON_ROWS_COUNT = 4; + +export type NavigationMetaViewConfig = { + render?: (data: NavigationMetaConfig) => React.ReactNode; + extraContent?: React.ReactNode; +}; + +export type NavigationMetaProps = { + data: NavigationMetaConfig; + view?: NavigationMetaViewConfig; + className?: string; +}; + +export function NavigationMeta({ + data, + view, + className, +}: NavigationMetaProps) { + const {groups, loading, loaded, errorContent} = data; + const {render, extraContent} = view ?? {}; + + const preparedGroups = useMemo(() => buildMetaGroups(groups, i18n), [groups]); + + if (render) { + return
{render(data)}
; + } + + if (errorContent) { + return ( + + {errorContent} + + ); + } + + if (loading && !loaded) { + return ( + + {Array.from({length: SKELETON_ROWS_COUNT}, (_, index) => ( + + ))} + + ); + } + + const isEmpty = preparedGroups.every((group) => group.items.length === 0); + + if (isEmpty && !extraContent) { + return ; + } + + return ( + + {preparedGroups.map((group, groupIndex) => + group.items.length === 0 ? null : ( +
+ {group.title ? ( + + {group.title} + + ) : null} +
+ {group.items.map(({name, value}) => ( + <> + {name} +
{value}
+ + ))} +
+
+ ), + )} + {extraContent} +
+ ); +} diff --git a/src/modules/NavigationMeta/helpers/buildMetaGroups.tsx b/src/modules/NavigationMeta/helpers/buildMetaGroups.tsx new file mode 100644 index 0000000..51a8b66 --- /dev/null +++ b/src/modules/NavigationMeta/helpers/buildMetaGroups.tsx @@ -0,0 +1,29 @@ +import type {ReactNode} from 'react'; +import type {NavigationMetaGroup, NavigationMetaItem} from '../../../types/navigation'; +import type metaI18n from '../i18n'; + +export type PreparedMetaItem = { + name: string; + value: ReactNode; +}; + +export type PreparedMetaGroup = { + title?: string; + items: PreparedMetaItem[]; +}; + +const isEmptyValue = (value: unknown): boolean => + value === undefined || value === null || value === ''; + +export function buildMetaGroups( + groups: Array>, + i18n: typeof metaI18n, +): PreparedMetaGroup[] { + return groups.map((group) => ({ + title: group.title, + items: group.items.map((item) => ({ + name: item.name, + value: isEmptyValue(item.value) ? i18n('value_empty') : item.value, + })), + })); +} diff --git a/src/modules/NavigationMeta/i18n/dicts.ts b/src/modules/NavigationMeta/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/modules/NavigationMeta/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/modules/NavigationMeta/i18n/en.json b/src/modules/NavigationMeta/i18n/en.json new file mode 100644 index 0000000..e06fabb --- /dev/null +++ b/src/modules/NavigationMeta/i18n/en.json @@ -0,0 +1,4 @@ +{ + "value_empty": "—", + "context_empty": "No metadata" +} diff --git a/src/modules/NavigationMeta/i18n/index.ts b/src/modules/NavigationMeta/i18n/index.ts new file mode 100644 index 0000000..94727a2 --- /dev/null +++ b/src/modules/NavigationMeta/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:navigation-meta', dicts); diff --git a/src/modules/NavigationMeta/i18n/ru.json b/src/modules/NavigationMeta/i18n/ru.json new file mode 100644 index 0000000..f102e70 --- /dev/null +++ b/src/modules/NavigationMeta/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "value_empty": "—", + "context_empty": "Нет метаданных" +} diff --git a/src/modules/NavigationMeta/index.ts b/src/modules/NavigationMeta/index.ts new file mode 100644 index 0000000..a6df668 --- /dev/null +++ b/src/modules/NavigationMeta/index.ts @@ -0,0 +1,3 @@ +export {NavigationMeta} from './NavigationMeta'; +export type {NavigationMetaProps, NavigationMetaViewConfig} from './NavigationMeta'; +export {buildMetaGroups} from './helpers/buildMetaGroups'; diff --git a/src/modules/NavigationPreview/NavigationPreview.scss b/src/modules/NavigationPreview/NavigationPreview.scss new file mode 100644 index 0000000..a5e4573 --- /dev/null +++ b/src/modules/NavigationPreview/NavigationPreview.scss @@ -0,0 +1,24 @@ +.qp-navigation-preview { + --data-table-border-color: var(--g-color-line-generic); + + min-width: 0; + + &__error { + display: block; + } + + &__table { + overflow-x: auto; + } + + .data-table__row, + .data-table__head { + height: 40px; + } + + .data-table__th, + .data-table__td { + border-width: 0 0 1px; + vertical-align: middle; + } +} diff --git a/src/modules/NavigationPreview/NavigationPreview.stories.tsx b/src/modules/NavigationPreview/NavigationPreview.stories.tsx new file mode 100644 index 0000000..4ff3260 --- /dev/null +++ b/src/modules/NavigationPreview/NavigationPreview.stories.tsx @@ -0,0 +1,105 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Icon, Label} from '@gravity-ui/uikit'; +import LockIcon from '@gravity-ui/icons/svgs/lock.svg'; +import {NavigationPreview} from './NavigationPreview'; +import type {NavigationPreviewRow} from '../../types/navigation'; + +const COLUMNS = ['id', 'created_at', 'title', 'status']; + +const ROWS: NavigationPreviewRow[] = [ + {id: '1', created_at: '2024-01-01T10:00:00Z', title: 'First row', status: 'active'}, + {id: '2', created_at: '2024-01-02T11:30:00Z', title: 'Second row', status: 'active'}, + {id: '3', created_at: '2024-01-03T09:15:00Z', title: 'Third row', status: 'archived'}, +]; + +const meta: Meta = { + title: 'Modules/NavigationPreview', + component: NavigationPreview, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: {columns: COLUMNS, rows: ROWS, loaded: true}, + }, +}; + +const ControlledStory = () => { + const [search, setSearch] = useState(''); + const [visibleColumns, setVisibleColumns] = useState(['id', 'title', 'status']); + + return ( + + ); +}; + +export const ControlledSearchAndColumns: Story = { + render: () => , +}; + +export const Loading: Story = { + args: { + data: {columns: [], rows: [], loading: true}, + }, +}; + +export const Empty: Story = { + args: { + data: {columns: COLUMNS, rows: [], loaded: true}, + }, +}; + +export const Error: Story = { + args: { + data: {columns: [], rows: [], errorContent: 'Failed to load table preview'}, + }, +}; + +type CustomRow = NavigationPreviewRow & {lock?: string}; + +const CUSTOM_ROWS: CustomRow[] = ROWS.map((row, index) => ({ + ...row, + lock: index % 2 === 0 ? 'shared' : undefined, +})); + +export const CustomColumns: Story = { + args: { + data: {columns: COLUMNS, rows: CUSTOM_ROWS, loaded: true}, + view: { + extraColumns: [ + { + name: 'lock', + header: 'Lock', + render: ({row}) => + (row as CustomRow).lock ? ( + + ) : ( + '—' + ), + }, + ], + }, + }, +}; diff --git a/src/modules/NavigationPreview/NavigationPreview.tsx b/src/modules/NavigationPreview/NavigationPreview.tsx new file mode 100644 index 0000000..00bafb7 --- /dev/null +++ b/src/modules/NavigationPreview/NavigationPreview.tsx @@ -0,0 +1,110 @@ +import React, {useMemo} from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {type Column, DataTable, FieldsSearchToolbar} from '../../components'; +import {useVisibleColumns} from '../../helpers/useVisibleColumns'; +import type {NavigationPreviewConfig, NavigationPreviewRow} from '../../types/navigation'; +import {buildPreviewColumns} from './helpers/buildPreviewColumns'; +import {filterPreviewRows} from './helpers/filterPreviewRows'; +import i18n from './i18n'; +import './NavigationPreview.scss'; + +const block = cn('qp-navigation-preview'); + +export type NavigationPreviewViewConfig = + { + tableColumns?: Array>; + extraColumns?: Array>; + }; + +export type NavigationPreviewProps = { + data: NavigationPreviewConfig; + view?: NavigationPreviewViewConfig; + search?: string; + onSearchUpdate?: (value: string) => void; + searchPlaceholder?: string; + visibleColumns?: string[]; + onVisibleColumnsChange?: (value: string[]) => void; + defaultVisibleColumns?: string[]; + hideToolbar?: boolean; + hideFieldsSelector?: boolean; + className?: string; +}; + +export function NavigationPreview({ + data, + view, + search, + onSearchUpdate, + searchPlaceholder, + visibleColumns, + onVisibleColumnsChange, + defaultVisibleColumns, + hideToolbar, + hideFieldsSelector, + className, +}: NavigationPreviewProps) { + const {columns, rows, loading, loaded, errorContent} = data; + const {tableColumns, extraColumns} = view ?? {}; + + const [activeVisibleColumns, handleVisibleColumnsChange] = useVisibleColumns(columns, { + value: visibleColumns, + onChange: onVisibleColumnsChange, + defaultValue: defaultVisibleColumns, + }); + + const displayedColumnNames = useMemo( + () => columns.filter((column) => activeVisibleColumns.includes(column)), + [columns, activeVisibleColumns], + ); + + const resolvedColumns = useMemo(() => { + if (tableColumns) { + return tableColumns; + } + return [...buildPreviewColumns(displayedColumnNames, i18n), ...(extraColumns ?? [])]; + }, [tableColumns, extraColumns, displayedColumnNames]); + + const fieldsOptions = useMemo( + () => columns.map((column) => ({id: column, title: column})), + [columns], + ); + + const filteredRows = useMemo( + () => filterPreviewRows(rows, displayedColumnNames, search), + [rows, displayedColumnNames, search], + ); + + if (errorContent) { + return ( + + {errorContent} + + ); + } + + return ( + + {!hideToolbar && ( + + )} + + columns={resolvedColumns} + data={filteredRows} + loading={loading} + loaded={loaded} + emptyVariant={search ? 'nothing-found' : 'no-data'} + settings={{displayIndices: false}} + className={block('table')} + /> + + ); +} diff --git a/src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx b/src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx new file mode 100644 index 0000000..2d7cef6 --- /dev/null +++ b/src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx @@ -0,0 +1,19 @@ +import type {Column} from '../../../components'; +import type {NavigationPreviewRow} from '../../../types/navigation'; +import type previewI18n from '../i18n'; + +export function buildPreviewColumns( + columns: string[], + i18n: typeof previewI18n, +): Array> { + return columns.map((column) => ({ + name: column, + header: column, + render: ({row}) => { + const value = row[column]; + return value === undefined || value === null || value === '' + ? i18n('value_empty') + : value; + }, + })); +} diff --git a/src/modules/NavigationPreview/helpers/filterPreviewRows.ts b/src/modules/NavigationPreview/helpers/filterPreviewRows.ts new file mode 100644 index 0000000..ffb5265 --- /dev/null +++ b/src/modules/NavigationPreview/helpers/filterPreviewRows.ts @@ -0,0 +1,29 @@ +import type {NavigationPreviewRow} from '../../../types/navigation'; + +const stringifyCell = (value: unknown): string => { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return ''; +}; + +export function filterPreviewRows( + rows: TRow[], + columns: string[], + search?: string, +): TRow[] { + const query = search?.trim().toLowerCase(); + if (!query) { + return rows; + } + + return rows.filter((row) => + columns.some((column) => stringifyCell(row[column]).toLowerCase().includes(query)), + ); +} diff --git a/src/modules/NavigationPreview/i18n/dicts.ts b/src/modules/NavigationPreview/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/modules/NavigationPreview/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/modules/NavigationPreview/i18n/en.json b/src/modules/NavigationPreview/i18n/en.json new file mode 100644 index 0000000..bb41253 --- /dev/null +++ b/src/modules/NavigationPreview/i18n/en.json @@ -0,0 +1,3 @@ +{ + "value_empty": "—" +} diff --git a/src/modules/NavigationPreview/i18n/index.ts b/src/modules/NavigationPreview/i18n/index.ts new file mode 100644 index 0000000..8c6592c --- /dev/null +++ b/src/modules/NavigationPreview/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:navigation-preview', dicts); diff --git a/src/modules/NavigationPreview/i18n/ru.json b/src/modules/NavigationPreview/i18n/ru.json new file mode 100644 index 0000000..bb41253 --- /dev/null +++ b/src/modules/NavigationPreview/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "value_empty": "—" +} diff --git a/src/modules/NavigationPreview/index.ts b/src/modules/NavigationPreview/index.ts new file mode 100644 index 0000000..5172f61 --- /dev/null +++ b/src/modules/NavigationPreview/index.ts @@ -0,0 +1,4 @@ +export {NavigationPreview} from './NavigationPreview'; +export type {NavigationPreviewProps, NavigationPreviewViewConfig} from './NavigationPreview'; +export {buildPreviewColumns} from './helpers/buildPreviewColumns'; +export {filterPreviewRows} from './helpers/filterPreviewRows'; diff --git a/src/modules/NavigationSchema/NavigationSchema.scss b/src/modules/NavigationSchema/NavigationSchema.scss new file mode 100644 index 0000000..77d7914 --- /dev/null +++ b/src/modules/NavigationSchema/NavigationSchema.scss @@ -0,0 +1,24 @@ +.qp-navigation-schema { + --data-table-border-color: var(--g-color-line-generic); + + min-width: 0; + + &__error { + display: block; + } + + &__table { + overflow-x: auto; + } + + .data-table__row, + .data-table__head { + height: 40px; + } + + .data-table__th, + .data-table__td { + border-width: 0 0 1px; + vertical-align: middle; + } +} diff --git a/src/modules/NavigationSchema/NavigationSchema.stories.tsx b/src/modules/NavigationSchema/NavigationSchema.stories.tsx new file mode 100644 index 0000000..4f07d33 --- /dev/null +++ b/src/modules/NavigationSchema/NavigationSchema.stories.tsx @@ -0,0 +1,119 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Icon, Label} from '@gravity-ui/uikit'; +import LockIcon from '@gravity-ui/icons/svgs/lock.svg'; +import {NavigationSchema} from './NavigationSchema'; +import type {NavigationSchemaColumn} from '../../types/navigation'; + +const COLUMNS: NavigationSchemaColumn[] = [ + {name: 'id', type: 'int64', sortOrder: 'ascending', required: true}, + {name: 'created_at', type: 'string', sortOrder: 'descending', required: true}, + {name: 'title', type: 'string', required: true}, + {name: 'status', type: 'string'}, + {name: 'payload', type: 'any'}, +]; + +const meta: Meta = { + title: 'Modules/NavigationSchema', + component: NavigationSchema, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: {columns: COLUMNS, loaded: true}, + }, +}; + +export const Loading: Story = { + args: { + data: {columns: [], loading: true}, + }, +}; + +export const Empty: Story = { + args: { + data: {columns: [], loaded: true}, + }, +}; + +const NothingFoundStory = () => { + const [search, setSearch] = useState('no-such-field'); + + return ( + + ); +}; + +export const NothingFound: Story = {render: () => }; + +const ControlledVisibleColumnsStory = () => { + const [search, setSearch] = useState(''); + const [visibleColumns, setVisibleColumns] = useState(['name', 'type']); + + return ( + + ); +}; + +export const ControlledVisibleColumns: Story = { + render: () => , +}; + +export const Error: Story = { + args: { + data: {columns: [], errorContent: 'Failed to load table schema'}, + }, +}; + +type CustomColumn = NavigationSchemaColumn & {lock?: string}; + +const CUSTOM_COLUMNS: CustomColumn[] = COLUMNS.map((column, index) => ({ + ...column, + lock: index % 2 === 0 ? 'shared' : undefined, +})); + +export const CustomColumns: Story = { + args: { + data: {columns: CUSTOM_COLUMNS, loaded: true}, + view: { + extraColumns: [ + { + name: 'lock', + header: 'Lock', + render: ({row}) => + (row as CustomColumn).lock ? ( + + ) : ( + '—' + ), + }, + ], + }, + }, +}; diff --git a/src/modules/NavigationSchema/NavigationSchema.tsx b/src/modules/NavigationSchema/NavigationSchema.tsx new file mode 100644 index 0000000..ef4e104 --- /dev/null +++ b/src/modules/NavigationSchema/NavigationSchema.tsx @@ -0,0 +1,118 @@ +import React, {useMemo} from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {type Column, DataTable, FieldsSearchToolbar} from '../../components'; +import {useVisibleColumns} from '../../helpers/useVisibleColumns'; +import type {NavigationSchemaColumn, NavigationSchemaConfig} from '../../types/navigation'; +import {buildSchemaColumns} from './helpers/buildSchemaColumns'; +import {filterSchema} from './helpers/filterSchema'; +import i18n from './i18n'; +import './NavigationSchema.scss'; + +const block = cn('qp-navigation-schema'); + +export type NavigationSchemaViewConfig< + TColumn extends NavigationSchemaColumn = NavigationSchemaColumn, +> = { + tableColumns?: Array>; + extraColumns?: Array>; +}; + +export type NavigationSchemaProps = + { + data: NavigationSchemaConfig; + view?: NavigationSchemaViewConfig; + search?: string; + onSearchUpdate?: (value: string) => void; + searchPlaceholder?: string; + visibleColumns?: string[]; + onVisibleColumnsChange?: (value: string[]) => void; + defaultVisibleColumns?: string[]; + hideToolbar?: boolean; + hideFieldsSelector?: boolean; + className?: string; + }; + +export function NavigationSchema({ + data, + view, + search, + onSearchUpdate, + searchPlaceholder, + visibleColumns, + onVisibleColumnsChange, + defaultVisibleColumns, + hideToolbar, + hideFieldsSelector, + className, +}: NavigationSchemaProps) { + const {columns, loading, loaded, errorContent} = data; + const {tableColumns, extraColumns} = view ?? {}; + + const resolvedColumns = useMemo(() => { + if (tableColumns) { + return tableColumns; + } + return [...buildSchemaColumns(i18n), ...(extraColumns ?? [])]; + }, [tableColumns, extraColumns]); + + const allColumnNames = useMemo( + () => resolvedColumns.map((column) => column.name), + [resolvedColumns], + ); + + const [activeVisibleColumns, handleVisibleColumnsChange] = useVisibleColumns(allColumnNames, { + value: visibleColumns, + onChange: onVisibleColumnsChange, + defaultValue: defaultVisibleColumns, + }); + + const displayedColumns = useMemo( + () => resolvedColumns.filter((column) => activeVisibleColumns.includes(column.name)), + [resolvedColumns, activeVisibleColumns], + ); + + const fieldsOptions = useMemo( + () => + resolvedColumns.map((column) => ({ + id: column.name, + title: column.header ?? column.name, + })), + [resolvedColumns], + ); + + const rows = useMemo(() => filterSchema(columns, search), [columns, search]); + + if (errorContent) { + return ( + + {errorContent} + + ); + } + + return ( + + {!hideToolbar && ( + + )} + + columns={displayedColumns} + data={rows} + loading={loading} + loaded={loaded} + emptyVariant={search ? 'nothing-found' : 'no-data'} + settings={{displayIndices: false}} + className={block('table')} + /> + + ); +} diff --git a/src/modules/NavigationSchema/helpers/buildSchemaColumns.tsx b/src/modules/NavigationSchema/helpers/buildSchemaColumns.tsx new file mode 100644 index 0000000..33f2072 --- /dev/null +++ b/src/modules/NavigationSchema/helpers/buildSchemaColumns.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import {Icon, Text} from '@gravity-ui/uikit'; +import ArrowUpIcon from '@gravity-ui/icons/svgs/arrow-up.svg'; +import ArrowDownIcon from '@gravity-ui/icons/svgs/arrow-down.svg'; +import CheckIcon from '@gravity-ui/icons/svgs/check.svg'; +import type {Column} from '../../../components'; +import type {NavigationSchemaColumn, NavigationSchemaSortOrder} from '../../../types/navigation'; +import type schemaI18n from '../i18n'; + +const SORT_ICONS: Record = { + ascending: ArrowUpIcon, + descending: ArrowDownIcon, +}; + +export function buildSchemaColumns( + i18n: typeof schemaI18n, +): Array> { + return [ + { + name: 'name', + header: i18n('title_column-name'), + render: ({row}) => ( + + {row.name} + {row.sortOrder && } + + ), + }, + { + name: 'type', + header: i18n('title_column-type'), + render: ({row}) => row.type ?? i18n('value_empty'), + }, + { + name: 'sortOrder', + header: i18n('title_column-sort-order'), + render: ({row}) => + row.sortOrder + ? i18n( + row.sortOrder === 'ascending' + ? 'value_sort-ascending' + : 'value_sort-descending', + ) + : i18n('value_empty'), + }, + { + name: 'required', + header: i18n('title_column-required'), + align: 'center', + render: ({row}) => + row.required ? : i18n('value_empty'), + }, + ]; +} diff --git a/src/modules/NavigationSchema/helpers/filterSchema.ts b/src/modules/NavigationSchema/helpers/filterSchema.ts new file mode 100644 index 0000000..979ed17 --- /dev/null +++ b/src/modules/NavigationSchema/helpers/filterSchema.ts @@ -0,0 +1,18 @@ +import type {NavigationSchemaColumn} from '../../../types/navigation'; + +export function filterSchema( + columns: TColumn[], + search?: string, +): TColumn[] { + const query = search?.trim().toLowerCase(); + if (!query) { + return columns; + } + + return columns.filter((column) => { + return ( + column.name.toLowerCase().includes(query) || + (column.type?.toLowerCase().includes(query) ?? false) + ); + }); +} diff --git a/src/modules/NavigationSchema/i18n/dicts.ts b/src/modules/NavigationSchema/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/modules/NavigationSchema/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/modules/NavigationSchema/i18n/en.json b/src/modules/NavigationSchema/i18n/en.json new file mode 100644 index 0000000..cdf6a04 --- /dev/null +++ b/src/modules/NavigationSchema/i18n/en.json @@ -0,0 +1,9 @@ +{ + "title_column-name": "Name", + "title_column-type": "Type", + "title_column-sort-order": "Sort order", + "title_column-required": "Required", + "value_sort-ascending": "Ascending", + "value_sort-descending": "Descending", + "value_empty": "—" +} diff --git a/src/modules/NavigationSchema/i18n/index.ts b/src/modules/NavigationSchema/i18n/index.ts new file mode 100644 index 0000000..c97c4fc --- /dev/null +++ b/src/modules/NavigationSchema/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:navigation-schema', dicts); diff --git a/src/modules/NavigationSchema/i18n/ru.json b/src/modules/NavigationSchema/i18n/ru.json new file mode 100644 index 0000000..c950222 --- /dev/null +++ b/src/modules/NavigationSchema/i18n/ru.json @@ -0,0 +1,9 @@ +{ + "title_column-name": "Имя", + "title_column-type": "Тип", + "title_column-sort-order": "Сортировка", + "title_column-required": "Обязательное", + "value_sort-ascending": "По возрастанию", + "value_sort-descending": "По убыванию", + "value_empty": "—" +} diff --git a/src/modules/NavigationSchema/index.ts b/src/modules/NavigationSchema/index.ts new file mode 100644 index 0000000..f90eed4 --- /dev/null +++ b/src/modules/NavigationSchema/index.ts @@ -0,0 +1,4 @@ +export {NavigationSchema} from './NavigationSchema'; +export type {NavigationSchemaProps, NavigationSchemaViewConfig} from './NavigationSchema'; +export {buildSchemaColumns} from './helpers/buildSchemaColumns'; +export {filterSchema} from './helpers/filterSchema'; diff --git a/src/modules/RowsList/RowsList.tsx b/src/modules/RowsList/RowsList.tsx index 605ebe0..482626e 100644 --- a/src/modules/RowsList/RowsList.tsx +++ b/src/modules/RowsList/RowsList.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import {List} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; import { BaseHistoryRow, @@ -11,7 +10,7 @@ import { QueryHistoryRowVariant, QueryHistoryVisibleFieldsConfig, } from '../../types/history'; -import {HistoryListEmpty} from '../../components'; +import {EmptyContent, LazyList} from '../../components'; import {prepareRowData} from './helpers/prepareRowData'; import {SEARCH_ROW_HEIGHT} from '../../constants/row'; import './RowsList.scss'; @@ -59,18 +58,19 @@ export const RowsList = ({ }; if (!items.length) { - return ; + return ( + + ); } return ( - > className={block(null, className)} - filterable={false} items={items} itemHeight={getItemHeight} - itemsHeight={(listItems) => - listItems.reduce((totalHeight, item) => totalHeight + getItemHeight(item), 0) - } renderItem={(item, isActive, index) => renderRow( prepareRowData({ diff --git a/src/modules/index.ts b/src/modules/index.ts index fe7e587..50acee3 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,5 +1,7 @@ export {HistoryHeader} from './HistoryHeader'; export {HistoryLayout} from './HistoryLayout'; +export {NavigationHeader} from './NavigationHeader'; +export type {NavigationHeaderProps} from './NavigationHeader'; export type {HistoryLayoutProps} from './HistoryLayout'; export {RowsList} from './RowsList'; export type {RowsListProps} from './RowsList'; @@ -12,3 +14,15 @@ export {TutorialRow} from './TutorialRow'; export type {TutorialRowProps} from './TutorialRow'; export {TutorialSearchRow} from './TutorialSearchRow'; export type {TutorialSearchRowProps} from './TutorialSearchRow'; +export {ClustersList, ClusterRow} from './ClustersList'; +export type {ClustersListProps, ClusterRowProps} from './ClustersList'; +export {NavigationItemsList, NavigationItemRow} from './NavigationItemsList'; +export type {NavigationItemsListProps, NavigationItemRowProps} from './NavigationItemsList'; +export {NavigationDetail} from './NavigationDetail'; +export type {NavigationDetailProps} from './NavigationDetail'; +export {NavigationSchema, buildSchemaColumns, filterSchema} from './NavigationSchema'; +export type {NavigationSchemaProps, NavigationSchemaViewConfig} from './NavigationSchema'; +export {NavigationPreview, buildPreviewColumns, filterPreviewRows} from './NavigationPreview'; +export type {NavigationPreviewProps, NavigationPreviewViewConfig} from './NavigationPreview'; +export {NavigationMeta, buildMetaGroups} from './NavigationMeta'; +export type {NavigationMetaProps, NavigationMetaViewConfig} from './NavigationMeta'; diff --git a/src/types/navigation.ts b/src/types/navigation.ts new file mode 100644 index 0000000..eb01dac --- /dev/null +++ b/src/types/navigation.ts @@ -0,0 +1,177 @@ +import {ReactNode} from 'react'; +import type {LoadPathSuggestions} from './pathEditor'; + +export type NavigationLocation = { + cluster: string | undefined; + path: string | undefined; +}; + +export type NavigationHeaderAction = { + id: string; + title: string; + content: ReactNode; + hidden?: boolean; + disabled?: boolean; + qa?: string; + onClick: (location: NavigationLocation) => void; +}; + +export type NavigationCluster = { + id: string; + title: string; + icon?: ReactNode; + color?: string; + backgroundColor?: string; + description?: string; +}; + +export type NavigationItemKind = 'folder' | 'file' | 'table' | 'link' | 'unknown'; + +export type NavigationItem = { + path: string; + title: string; + icon?: ReactNode; + kind?: NavigationItemKind; + targetPathBroken?: boolean; + hasChildren?: boolean; + disabled?: boolean; +}; + +export type NavigationSortOrder = 'asc' | 'desc'; + +export type NavigationItemRowRenderData = { + item: T; + index: number; + isActive: boolean; + isParentRow: boolean; +}; + +export type NavigationClusterRowRenderData = { + cluster: T; + index: number; + isActive: boolean; +}; + +export type RenderNavigationItem = ( + data: NavigationItemRowRenderData, +) => ReactNode; + +export type RenderNavigationCluster = ( + data: NavigationClusterRowRenderData, +) => ReactNode; + +export type NavigationDetailTabRenderContext = { + search: string; + onSearchUpdate?: (value: string) => void; + searchPlaceholder?: string; +}; + +export type NavigationDetailTab = { + id: string; + title: string; + content?: ReactNode; + renderContent?: (ctx: NavigationDetailTabRenderContext) => ReactNode; + hidden?: boolean; + disabled?: boolean; +}; + +export type NavigationSchemaSortOrder = 'ascending' | 'descending'; + +export type NavigationSchemaColumn = { + name: string; + type?: string; + sortOrder?: NavigationSchemaSortOrder; + required?: boolean; + [key: string]: unknown; +}; + +export type NavigationSchemaConfig< + TColumn extends NavigationSchemaColumn = NavigationSchemaColumn, +> = { + columns: TColumn[]; + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; +}; + +export type NavigationPreviewCell = ReactNode; + +export type NavigationPreviewRow = Record; + +export type NavigationPreviewConfig = { + columns: string[]; + rows: TRow[]; + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; +}; + +export type NavigationMetaValue = ReactNode; + +export type NavigationMetaItem = { + name: string; + value: NavigationMetaValue; + [key: string]: unknown; +}; + +export type NavigationMetaGroup = { + title?: string; + items: TItem[]; +}; + +export type NavigationMetaConfig = { + groups: Array>; + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; +}; + +export type NavigationDetailConfig = { + tabs: NavigationDetailTab[]; + defaultTab?: string; + hasSearch?: boolean; + searchPlaceholder?: string; + actions?: NavigationHeaderAction[]; +}; + +export type ResolveNavigationDetail = ( + item: T, +) => NavigationDetailConfig | undefined; + +export type NavigationDetailConfigFactory = ( + item: T, +) => NavigationDetailConfig; + +export type NavigationSearchConfig = { + value?: string; + onUpdate?: (value: string) => void; +}; + +export type NavigationSortConfig = { + value?: NavigationSortOrder; + onUpdate?: (sort: NavigationSortOrder) => void; +}; + +export type NavigationListStateConfig = { + loading?: boolean; + error?: boolean | ReactNode; + hasMore?: boolean; + onLoadMore?: () => void; +}; + +export type NavigationHeaderConfig = { + actions?: NavigationHeaderAction[]; + onLoadSuggestions?: LoadPathSuggestions; +}; + +export type NavigationDetailPanelConfig = { + openedItem?: TItem; + onItemOpen?: (item: TItem) => void; + onClose?: () => void; + resolve?: ResolveNavigationDetail; + search?: string; + onSearchUpdate?: (value: string) => void; + activeTab?: string; + onTabUpdate?: (tab: string) => void; + actions?: NavigationHeaderAction[]; +}; diff --git a/src/types/pathEditor.ts b/src/types/pathEditor.ts new file mode 100644 index 0000000..1e7940a --- /dev/null +++ b/src/types/pathEditor.ts @@ -0,0 +1,29 @@ +import {ReactNode} from 'react'; +import type {NavigationItemKind} from './navigation'; + +export type PathEditorSuggestion = { + parentPath: string; + childPath: string; + path: string; + icon?: ReactNode; + kind?: NavigationItemKind; + targetPathBroken?: boolean; +}; + +export type PathEditorSuggestionFilter = ( + suggestions: PathEditorSuggestion[], +) => PathEditorSuggestion[]; + +export type PathEditorEventPayload = { + path: string; +}; + +export type LoadPathSuggestionsParams = { + path: string; + customFilter: PathEditorSuggestionFilter | undefined; + cluster: string | undefined; +}; + +export type LoadPathSuggestions = ( + params: LoadPathSuggestionsParams, +) => void | Promise; diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.scss b/src/widgets/QueriesNavigation/QueriesNavigation.scss new file mode 100644 index 0000000..61624e0 --- /dev/null +++ b/src/widgets/QueriesNavigation/QueriesNavigation.scss @@ -0,0 +1,7 @@ +.qp-queries-navigation { + &__empty, + &__error { + padding: var(--g-spacing-2); + text-align: center; + } +} diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx b/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx new file mode 100644 index 0000000..4d5279e --- /dev/null +++ b/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx @@ -0,0 +1,397 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {QueriesNavigation} from './QueriesNavigation'; +import {createNavigationDetailResolver} from './helpers/createNavigationDetailResolver'; +import {createTableDetailConfig} from './helpers/createTableDetailConfig'; +import {action} from 'storybook/actions'; +import { + NavigationCluster, + NavigationHeaderAction, + NavigationItem, + NavigationLocation, + NavigationMetaConfig, + NavigationPreviewRow, + NavigationSchemaColumn, + NavigationSortOrder, +} from '../../types/navigation'; +import {mockLoadPathSuggestions} from '../../components/PathEditor/PathEditor.stories.helpers'; +import FileArrowRightOutIcon from '@gravity-ui/icons/svgs/file-arrow-right-out.svg'; +import ArrowUpRightFromSquareIcon from '@gravity-ui/icons/svgs/arrow-up-right-from-square.svg'; +import {Flex, Icon, Label, Text} from '@gravity-ui/uikit'; +import {ClusterRow, NavigationItemRow} from '../../modules'; + +const meta: Meta = { + title: 'Widgets/QueriesNavigation', + component: QueriesNavigation, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +}; + +const onBreadcrumbsUpdate = action('onUpdate'); +const logAction = action('actionClick'); + +const defaultActions: NavigationHeaderAction[] = [ + { + id: 'paste', + title: 'Paste path', + content: , + onClick: (location) => logAction('Paste', location), + }, + { + id: 'open', + title: 'Open in new tab', + content: , + onClick: (location) => logAction('Open', location), + }, +]; + +const CLUSTERS: NavigationCluster[] = [ + { + id: 'arnold', + title: 'Arnold', + color: 'white', + backgroundColor: 'rgba(218, 68, 83, 1)', + description: 'Production', + }, + { + id: 'freud', + title: 'Freud', + color: 'white', + backgroundColor: 'rgba(127, 130, 133, 1)', + description: 'Production', + }, + { + id: 'hahn', + title: 'Hahn', + color: 'white', + backgroundColor: 'rgba(215, 112, 173, 1)', + description: 'Production', + }, + { + id: 'yp-sas-test', + title: 'YP-Sas-Test', + color: 'white', + backgroundColor: 'rgba(150, 122, 220, 1)', + description: 'Testing', + }, + { + id: 'zeno', + title: 'Zeno', + color: 'white', + backgroundColor: 'rgba(233, 87, 63, 1)', + description: 'Production', + }, + { + id: 'ada', + title: 'Ada', + color: 'white', + backgroundColor: 'rgba(67, 68, 69, 1)', + description: 'Production', + }, + { + id: 'arnold-gnd', + title: 'Arnold-GND', + color: 'white', + backgroundColor: 'rgba(140, 193, 82, 1)', + description: 'tesdting', + }, + { + id: 'deimos', + title: 'Deimos', + color: 'white', + backgroundColor: 'rgba(55, 188, 155, 1)', + description: 'Production', + }, + { + id: 'freud-gnd', + title: 'Freud-GND', + color: 'white', + backgroundColor: 'rgba(140, 193, 82, 1)', + description: 'Prestable', + }, +]; + +const ITEM_NAMES: Array> = [ + {title: 'abcdapter', kind: 'folder', hasChildren: true}, + {title: 'access_control_object', kind: 'file'}, + {title: 'account_tree', kind: 'folder', hasChildren: true, disabled: true}, + {title: 'cell_balancers', kind: 'folder', hasChildren: true}, + {title: 'clusters', kind: 'folder', hasChildren: true}, + {title: 'doctors_table', kind: 'table'}, +]; + +const getPathDepth = (path: string | undefined): number => + (path ?? '').split('/').filter(Boolean).length; + +const getItemsForPath = (path: string | undefined): NavigationItem[] => { + if (getPathDepth(path) > 1) { + return []; + } + + return ITEM_NAMES.map(({title, kind, hasChildren, disabled}) => ({ + path: `${path ?? ''}/${title}`, + title, + kind, + hasChildren, + disabled, + })); +}; + +export default meta; +type Story = StoryObj; + +const TABLE_SCHEMA_COLUMNS: NavigationSchemaColumn[] = [ + {name: 'id', type: 'int64', sortOrder: 'ascending', required: true}, + {name: 'created_at', type: 'string', sortOrder: 'descending', required: true}, + {name: 'title', type: 'string', required: true}, + {name: 'status', type: 'string'}, +]; + +const TABLE_PREVIEW_COLUMNS = ['id', 'created_at', 'title', 'status']; + +const TABLE_PREVIEW_ROWS: NavigationPreviewRow[] = [ + {id: '1', created_at: '2024-01-01T10:00:00Z', title: 'First row', status: 'active'}, + {id: '2', created_at: '2024-01-02T11:30:00Z', title: 'Second row', status: 'active'}, + {id: '3', created_at: '2024-01-03T09:15:00Z', title: 'Third row', status: 'archived'}, +]; + +const TABLE_META_GROUPS: NavigationMetaConfig['groups'] = [ + { + title: 'General', + items: [ + {name: 'Type', value: 'table'}, + {name: 'ID', value: '1-2-3-abcdef'}, + {name: 'Account', value: 'production'}, + ], + }, + { + title: 'Storage', + items: [ + {name: 'Compression', value: 'zstd_5'}, + {name: 'Disk space', value: '1.2 TB'}, + {name: 'Chunk count', value: '42'}, + ], + }, +]; + +const useLocationState = (initial: NavigationLocation) => { + const [location, setLocation] = useState(initial); + const onUpdate = (next: NavigationLocation) => { + onBreadcrumbsUpdate(next); + setLocation(next); + }; + return {location, onUpdate}; +}; + +const useNavigationStoryState = (initial: NavigationLocation) => { + const {location, onUpdate} = useLocationState(initial); + const [sort, setSort] = useState('asc'); + const items = getItemsForPath(location.path); + + return {location, onUpdate, sort, setSort, items}; +}; + +const ClustersToItemsStory = () => { + const {location, onUpdate, sort, setSort, items} = useNavigationStoryState({ + cluster: undefined, + path: undefined, + }); + const [openedItem, setOpenedItem] = useState(undefined); + + const resolveDetail = createNavigationDetailResolver({ + table: createTableDetailConfig({ + resolveSchema: () => ({columns: TABLE_SCHEMA_COLUMNS, loaded: true}), + resolvePreview: () => ({ + columns: TABLE_PREVIEW_COLUMNS, + rows: TABLE_PREVIEW_ROWS, + loaded: true, + }), + resolveMeta: () => ({groups: TABLE_META_GROUPS, loaded: true}), + }), + }); + + return ( +
+ { + action('onItemOpen')(item); + setOpenedItem(item); + }, + onClose: () => setOpenedItem(undefined), + resolve: resolveDetail, + }} + onClusterClick={action('onClusterClick')} + onItemClick={action('onItemClick')} + /> +
+ ); +}; + +const LoadingStory = () => { + const {location, onUpdate} = useLocationState({cluster: undefined, path: undefined}); + + return ( +
+ +
+ ); +}; + +const EmptyStory = () => { + const {location, onUpdate} = useLocationState({cluster: 'arnold', path: '/home/empty'}); + + return ( +
+ +
+ ); +}; + +const EmptySearchStory = () => { + const {location, onUpdate} = useLocationState({cluster: 'arnold', path: '/home'}); + const [search, setSearch] = useState('no-such-item'); + + return ( +
+ +
+ ); +}; + +const ErrorStory = () => { + const {location, onUpdate} = useLocationState({cluster: undefined, path: undefined}); + + return ( +
+ +
+ ); +}; + +type CustomCluster = NavigationCluster & {env: string}; +type CustomItem = NavigationItem & {owner?: string}; + +const CUSTOM_CLUSTERS: CustomCluster[] = CLUSTERS.map((cluster) => ({ + ...cluster, + env: cluster.description ?? 'Unknown', +})); + +const getCustomItemsForPath = (path: string | undefined): CustomItem[] => + getItemsForPath(path).map((item, index) => ({ + ...item, + owner: index % 2 === 0 ? 'robot' : 'user', + })); + +const CustomRowsStory = () => { + const {location, onUpdate} = useLocationState({cluster: undefined, path: undefined}); + const [sort, setSort] = useState('asc'); + + return ( +
+ + location={location} + header={{actions: defaultActions, onLoadSuggestions: mockLoadPathSuggestions}} + onUpdate={onUpdate} + clusters={CUSTOM_CLUSTERS} + items={getCustomItemsForPath(location.path)} + sort={{value: sort, onUpdate: setSort}} + renderClusterItem={({cluster}) => ( + + + + + )} + renderNavigationItem={({item, isParentRow}) => + isParentRow ? ( + + ) : ( + + + {item.owner && ( + + {item.owner} + + )} + + ) + } + onClusterClick={action('onClusterClick')} + onItemClick={action('onItemClick')} + /> +
+ ); +}; + +const CustomDetailResolverStory = () => { + const {location, onUpdate} = useLocationState({cluster: 'arnold', path: '/home'}); + const [openedItem, setOpenedItem] = useState(undefined); + + const resolveDetail = createNavigationDetailResolver({ + file: (item) => ({ + tabs: [ + {id: 'content', title: 'Content', content: `Content of ${item.title}`}, + {id: 'meta', title: 'Meta', content: 'Meta placeholder'}, + ], + hasSearch: false, + }), + }); + + return ( +
+ setOpenedItem(undefined), + resolve: resolveDetail, + }} + onItemClick={action('onItemClick')} + /> +
+ ); +}; + +export const ClustersToItems: Story = {render: () => }; +export const Loading: Story = {render: () => }; +export const Empty: Story = {render: () => }; +export const EmptySearch: Story = {render: () => }; +export const Error: Story = {render: () => }; +export const CustomRows: Story = {render: () => }; +export const CustomDetailResolver: Story = {render: () => }; diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.tsx b/src/widgets/QueriesNavigation/QueriesNavigation.tsx new file mode 100644 index 0000000..eaf2689 --- /dev/null +++ b/src/widgets/QueriesNavigation/QueriesNavigation.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import {ClustersList, NavigationDetail, NavigationHeader, NavigationItemsList} from '../../modules'; +import {EmptyContent, SearchWithButtons} from '../../components'; +import { + NavigationCluster, + NavigationDetailConfig, + NavigationDetailPanelConfig, + NavigationHeaderConfig, + NavigationItem, + NavigationListStateConfig, + NavigationLocation, + NavigationSearchConfig, + NavigationSortConfig, + RenderNavigationCluster, + RenderNavigationItem, +} from '../../types/navigation'; +import {createEmptyDetailConfig} from './helpers/createEmptyDetailConfig'; +import i18n from './i18n'; +import cn from 'bem-cn-lite'; +import './QueriesNavigation.scss'; + +const block = cn('qp-queries-navigation'); + +export type QueriesNavigationProps< + TItem extends NavigationItem = NavigationItem, + TCluster extends NavigationCluster = NavigationCluster, +> = { + location: NavigationLocation; + onUpdate: (location: NavigationLocation) => void; + clusters?: TCluster[]; + items?: TItem[]; + header?: NavigationHeaderConfig; + search?: NavigationSearchConfig; + sort?: NavigationSortConfig; + listState?: NavigationListStateConfig; + detail?: NavigationDetailPanelConfig; + renderClusterItem?: RenderNavigationCluster; + renderNavigationItem?: RenderNavigationItem; + onClusterClick?: (cluster: TCluster) => void; + onItemClick?: (item: TItem) => void; + className?: string; +}; + +type NavigationBody = + | {type: 'loading'} + | {type: 'details'; item: TItem; config: NavigationDetailConfig} + | {type: 'clusters'} + | {type: 'items'}; + +export const QueriesNavigation = < + TItem extends NavigationItem = NavigationItem, + TCluster extends NavigationCluster = NavigationCluster, +>({ + location, + onUpdate, + clusters = [], + items = [], + header, + search, + sort, + listState, + detail, + renderClusterItem, + renderNavigationItem, + onClusterClick, + onItemClick, + className, +}: QueriesNavigationProps) => { + const {loading, error, hasMore, onLoadMore} = listState ?? {}; + const {actions, onLoadSuggestions} = header ?? {}; + const {value: searchValue, onUpdate: onSearchUpdate} = search ?? {}; + const {value: sortValue, onUpdate: onSortUpdate} = sort ?? {}; + const { + openedItem, + onItemOpen, + onClose: onDetailClose, + resolve: resolveDetail, + search: detailSearch, + onSearchUpdate: onDetailSearchUpdate, + activeTab: detailActiveTab, + onTabUpdate: onDetailTabUpdate, + actions: detailActions, + } = detail ?? {}; + + const resolvedDetailActions = detailActions ?? actions; + + const resolvedErrorContent = error ? ( + + {error === true ? i18n('alert_load-error') : error} + + ) : null; + + const openedConfig = openedItem + ? (resolveDetail?.(openedItem) ?? createEmptyDetailConfig(openedItem)) + : undefined; + + const body: NavigationBody = (() => { + if (loading) { + return {type: 'loading'}; + } + if (openedItem && openedConfig) { + return {type: 'details', item: openedItem, config: openedConfig}; + } + if (!location.cluster) { + return {type: 'clusters'}; + } + return {type: 'items'}; + })(); + + const handleNavigate = (next: NavigationLocation) => { + onDetailClose?.(); + onUpdate(next); + }; + + const handleClusterClick = (cluster: TCluster) => { + handleNavigate({cluster: cluster.id, path: undefined}); + onClusterClick?.(cluster); + }; + + const handleItemClick = (item: TItem) => { + if (item.hasChildren) { + handleNavigate({cluster: location.cluster, path: item.path}); + } else { + onItemOpen?.(item); + } + onItemClick?.(item); + }; + + if (body.type === 'details') { + return ( + + ); + } + + return ( + + + + {body.type === 'clusters' || (body.type === 'loading' && !location.cluster) ? ( + + items={clusters} + loading={loading} + error={resolvedErrorContent} + hasMore={hasMore} + onLoadMore={onLoadMore} + emptyContent={ + + } + renderRowItem={renderClusterItem} + onItemClick={handleClusterClick} + /> + ) : ( + + items={items} + path={location.path} + search={searchValue} + sort={sortValue} + onSortUpdate={onSortUpdate} + titleLabel={i18n('title_name')} + loading={loading} + error={resolvedErrorContent} + hasMore={hasMore} + onLoadMore={onLoadMore} + emptyContent={ + + } + renderRowItem={renderNavigationItem} + onItemClick={handleItemClick} + /> + )} + + ); +}; diff --git a/src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts b/src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts new file mode 100644 index 0000000..40f1719 --- /dev/null +++ b/src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts @@ -0,0 +1,13 @@ +import React from 'react'; +import type {NavigationDetailConfig, NavigationItem} from '../../../types/navigation'; +import {EmptyContent} from '../../../components'; + +export const createEmptyDetailConfig = (_item: NavigationItem): NavigationDetailConfig => ({ + tabs: [ + { + id: 'empty', + title: '', + content: React.createElement(EmptyContent, {variant: 'no-files'}), + }, + ], +}); diff --git a/src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts b/src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts new file mode 100644 index 0000000..6eb6af2 --- /dev/null +++ b/src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts @@ -0,0 +1,28 @@ +import type { + NavigationDetailConfigFactory, + NavigationItem, + NavigationItemKind, + ResolveNavigationDetail, +} from '../../../types/navigation'; +import {createTableDetailConfig} from './createTableDetailConfig'; + +const defaultDetailRegistry: Partial> = { + table: createTableDetailConfig(), +}; + +export const createNavigationDetailResolver = ( + registry?: Partial>>, + fallback?: NavigationDetailConfigFactory, +): ResolveNavigationDetail => { + const merged = { + ...(defaultDetailRegistry as Partial< + Record> + >), + ...registry, + }; + + return (item) => { + const factory = (item.kind && merged[item.kind]) ?? fallback; + return factory?.(item); + }; +}; diff --git a/src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx b/src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx new file mode 100644 index 0000000..f7f0eac --- /dev/null +++ b/src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import {NavigationMeta, NavigationPreview, NavigationSchema} from '../../../modules'; +import type { + NavigationDetailConfig, + NavigationDetailConfigFactory, + NavigationItem, + NavigationMetaConfig, + NavigationPreviewConfig, + NavigationSchemaConfig, +} from '../../../types/navigation'; +import i18n from '../i18n'; + +export type NavigationSchemaResolver = ( + item: T, +) => NavigationSchemaConfig | undefined; + +export type NavigationPreviewResolver = ( + item: T, +) => NavigationPreviewConfig | undefined; + +export type NavigationMetaResolver = ( + item: T, +) => NavigationMetaConfig | undefined; + +export type NavigationMetaRenderer = (data: NavigationMetaConfig) => React.ReactNode; + +export type CreateTableDetailConfigOptions = { + resolveSchema?: NavigationSchemaResolver; + resolvePreview?: NavigationPreviewResolver; + resolveMeta?: NavigationMetaResolver; + renderMeta?: NavigationMetaRenderer; +}; + +export const createTableDetailConfig = ( + options?: CreateTableDetailConfigOptions, +): NavigationDetailConfigFactory => { + const {resolveSchema, resolvePreview, resolveMeta, renderMeta} = options ?? {}; + + return (item): NavigationDetailConfig => ({ + tabs: [ + { + id: 'schema', + title: i18n('tab_schema'), + renderContent: ({search, onSearchUpdate, searchPlaceholder}) => { + const schema = resolveSchema?.(item); + return ( + + ); + }, + }, + { + id: 'preview', + title: i18n('tab_preview'), + renderContent: ({search, onSearchUpdate, searchPlaceholder}) => { + const preview = resolvePreview?.(item); + return ( + + ); + }, + }, + { + id: 'meta', + title: i18n('tab_meta'), + renderContent: () => { + const meta = resolveMeta?.(item); + return ( + + ); + }, + }, + {id: 'view', title: i18n('tab_view'), content: null}, + ], + defaultTab: 'schema', + hasSearch: false, + searchPlaceholder: i18n('field_detail-search-placeholder'), + }); +}; diff --git a/src/widgets/QueriesNavigation/i18n/dicts.ts b/src/widgets/QueriesNavigation/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/widgets/QueriesNavigation/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/widgets/QueriesNavigation/i18n/en.json b/src/widgets/QueriesNavigation/i18n/en.json new file mode 100644 index 0000000..ff16a0a --- /dev/null +++ b/src/widgets/QueriesNavigation/i18n/en.json @@ -0,0 +1,10 @@ +{ + "title_name": "Title", + "alert_load-error": "Failed to load data", + "field_search-placeholder": "Search", + "tab_schema": "Schema", + "tab_preview": "Preview", + "tab_meta": "Meta", + "tab_view": "View", + "field_detail-search-placeholder": "Search" +} diff --git a/src/widgets/QueriesNavigation/i18n/index.ts b/src/widgets/QueriesNavigation/i18n/index.ts new file mode 100644 index 0000000..723c993 --- /dev/null +++ b/src/widgets/QueriesNavigation/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:queries-navigation', dicts); diff --git a/src/widgets/QueriesNavigation/i18n/ru.json b/src/widgets/QueriesNavigation/i18n/ru.json new file mode 100644 index 0000000..d146c2f --- /dev/null +++ b/src/widgets/QueriesNavigation/i18n/ru.json @@ -0,0 +1,10 @@ +{ + "title_name": "Название", + "alert_load-error": "Не удалось загрузить данные", + "field_search-placeholder": "Поиск", + "tab_schema": "Схема", + "tab_preview": "Превью", + "tab_meta": "Мета", + "tab_view": "Просмотр", + "field_detail-search-placeholder": "Поиск" +} diff --git a/src/widgets/QueriesNavigation/index.ts b/src/widgets/QueriesNavigation/index.ts new file mode 100644 index 0000000..9052b26 --- /dev/null +++ b/src/widgets/QueriesNavigation/index.ts @@ -0,0 +1,10 @@ +export {QueriesNavigation} from './QueriesNavigation'; +export type {QueriesNavigationProps} from './QueriesNavigation'; +export {createTableDetailConfig} from './helpers/createTableDetailConfig'; +export type { + CreateTableDetailConfigOptions, + NavigationPreviewResolver, + NavigationSchemaResolver, +} from './helpers/createTableDetailConfig'; +export {createEmptyDetailConfig} from './helpers/createEmptyDetailConfig'; +export {createNavigationDetailResolver} from './helpers/createNavigationDetailResolver'; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index d0ab5cd..35d904e 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -3,3 +3,14 @@ export type {QueriesHistoryProps} from './QueriesHistory'; export {DashboardCharts} from './DashboardCharts'; export {TutorialsHistory} from './TutorialsHistory'; export type {TutorialsHistoryProps} from './TutorialsHistory'; +export { + QueriesNavigation, + createTableDetailConfig, + createNavigationDetailResolver, +} from './QueriesNavigation'; +export type { + QueriesNavigationProps, + CreateTableDetailConfigOptions, + NavigationPreviewResolver, + NavigationSchemaResolver, +} from './QueriesNavigation';