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 14e3f16d041a5f612dcbd080bbc4d96d9bb92f45 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-architecture.md | 262 ++++++++++++ .../queries-navigation-architecture-review.md | 217 ++++++++++ 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/ClusterRow/ClusterRow.tsx | 34 ++ src/components/ClusterRow/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 + .../NavigationActionButtons.tsx | 46 ++ .../NavigationActionButtons/index.ts | 2 + .../NavigationItemRow/NavigationItemRow.tsx | 28 ++ src/components/NavigationItemRow/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/SkeletonRows/SkeletonRows.tsx | 24 ++ src/components/SkeletonRows/index.ts | 2 + src/components/index.ts | 25 +- src/constants/row.ts | 1 + src/helpers/buildColumnsFromKeys.ts | 17 + src/helpers/getDefaultNavigationIcon.ts | 33 ++ src/helpers/getParentPath.ts | 10 + src/helpers/isEmptyValue.ts | 2 + 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 | 49 +++ src/modules/ClustersList/index.ts | 2 + .../NavigationDetail/NavigationDetail.scss | 13 + .../NavigationDetail/NavigationDetail.tsx | 121 ++++++ .../NavigationDetail/helpers/getInitialTab.ts | 9 + .../helpers/getVisibleTabs.ts | 5 + src/modules/NavigationDetail/index.ts | 2 + .../internal/NavigationDetailTabs.tsx | 32 ++ .../NavigationHeader.stories.tsx | 118 ++++++ .../NavigationHeader/NavigationHeader.tsx | 33 ++ src/modules/NavigationHeader/index.ts | 2 + .../NavigationItemsList.scss | 65 +++ .../NavigationItemsList.tsx | 92 ++++ src/modules/NavigationItemsList/index.ts | 2 + .../NavigationItemsListEmptyState.tsx | 45 ++ .../internal/NavigationItemsListHeader.scss | 6 + .../internal/NavigationItemsListHeader.tsx | 58 +++ .../internal/useParentRow.ts | 23 + .../NavigationMeta/NavigationMeta.scss | 25 ++ src/modules/NavigationMeta/NavigationMeta.tsx | 81 ++++ .../helpers/buildMetaGroups.tsx | 27 ++ 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 + .../story/NavigationMeta.stories.tsx | 80 ++++ src/modules/NavigationMeta/story/mockData.ts | 22 + .../NavigationPreview/NavigationPreview.scss | 24 ++ .../NavigationPreview/NavigationPreview.tsx | 110 +++++ .../helpers/buildPreviewColumns.tsx | 11 + .../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 + .../story/NavigationPreview.stories.tsx | 98 +++++ .../NavigationPreview/story/mockData.ts | 9 + .../NavigationSchema/NavigationSchema.scss | 24 ++ .../NavigationSchema/NavigationSchema.tsx | 118 ++++++ .../helpers/buildSchemaColumns.tsx | 54 +++ .../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 + .../story/NavigationSchema.stories.tsx | 112 +++++ .../NavigationSchema/story/mockData.ts | 9 + .../NavigationView/NavigationView.scss | 44 ++ src/modules/NavigationView/NavigationView.tsx | 60 +++ .../helpers/buildViewColumns.tsx | 11 + src/modules/NavigationView/i18n/dicts.ts | 4 + src/modules/NavigationView/i18n/en.json | 3 + src/modules/NavigationView/i18n/index.ts | 5 + src/modules/NavigationView/i18n/ru.json | 3 + src/modules/NavigationView/index.ts | 3 + .../internal/NavigationViewSectionItem.tsx | 70 +++ .../story/NavigationView.stories.tsx | 110 +++++ src/modules/NavigationView/story/mockData.ts | 35 ++ src/modules/RowsList/RowsList.tsx | 16 +- src/modules/index.ts | 16 + src/types/navigation.ts | 199 +++++++++ src/types/pathEditor.ts | 29 ++ .../QueriesNavigation/QueriesNavigation.scss | 7 + .../QueriesNavigation.stories.tsx | 400 ++++++++++++++++++ .../QueriesNavigation/QueriesNavigation.tsx | 195 +++++++++ .../helpers/createEmptyDetailConfig.tsx | 8 + .../helpers/createNavigationDetailResolver.ts | 28 ++ .../helpers/createTableDetailConfig.tsx | 127 ++++++ 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 | 13 + src/widgets/index.ts | 11 + 141 files changed, 5058 insertions(+), 185 deletions(-) delete mode 100644 plans/history-header-search-with-buttons.md create mode 100644 plans/navigation-architecture.md create mode 100644 plans/queries-navigation-architecture-review.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/ClusterRow/ClusterRow.tsx create mode 100644 src/components/ClusterRow/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/NavigationActionButtons/NavigationActionButtons.tsx create mode 100644 src/components/NavigationActionButtons/index.ts create mode 100644 src/components/NavigationItemRow/NavigationItemRow.tsx create mode 100644 src/components/NavigationItemRow/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/components/SkeletonRows/SkeletonRows.tsx create mode 100644 src/components/SkeletonRows/index.ts create mode 100644 src/helpers/buildColumnsFromKeys.ts create mode 100644 src/helpers/getDefaultNavigationIcon.ts create mode 100644 src/helpers/getParentPath.ts create mode 100644 src/helpers/isEmptyValue.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/NavigationDetail/NavigationDetail.scss create mode 100644 src/modules/NavigationDetail/NavigationDetail.tsx create mode 100644 src/modules/NavigationDetail/helpers/getInitialTab.ts create mode 100644 src/modules/NavigationDetail/helpers/getVisibleTabs.ts 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/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.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/NavigationMeta/story/NavigationMeta.stories.tsx create mode 100644 src/modules/NavigationMeta/story/mockData.ts create mode 100644 src/modules/NavigationPreview/NavigationPreview.scss 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/NavigationPreview/story/NavigationPreview.stories.tsx create mode 100644 src/modules/NavigationPreview/story/mockData.ts create mode 100644 src/modules/NavigationSchema/NavigationSchema.scss 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/modules/NavigationSchema/story/NavigationSchema.stories.tsx create mode 100644 src/modules/NavigationSchema/story/mockData.ts create mode 100644 src/modules/NavigationView/NavigationView.scss create mode 100644 src/modules/NavigationView/NavigationView.tsx create mode 100644 src/modules/NavigationView/helpers/buildViewColumns.tsx create mode 100644 src/modules/NavigationView/i18n/dicts.ts create mode 100644 src/modules/NavigationView/i18n/en.json create mode 100644 src/modules/NavigationView/i18n/index.ts create mode 100644 src/modules/NavigationView/i18n/ru.json create mode 100644 src/modules/NavigationView/index.ts create mode 100644 src/modules/NavigationView/internal/NavigationViewSectionItem.tsx create mode 100644 src/modules/NavigationView/story/NavigationView.stories.tsx create mode 100644 src/modules/NavigationView/story/mockData.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.tsx 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-architecture.md b/plans/navigation-architecture.md new file mode 100644 index 0000000..6695f7c --- /dev/null +++ b/plans/navigation-architecture.md @@ -0,0 +1,262 @@ +# Архитектура навигации: концепты + +Обобщение серии планов по навигационному стеку (`QueriesNavigation` и модули `Navigation*`). +Документ описывает **принципы и контракты**, а не пошаговые задачи: конкретные шаги реализации +и разбор отдельных коммитов исчерпаны. Продуктовые/доменные привязки сознательно убраны — +библиотека остаётся нейтральной к системе-источнику данных. + +## Состав стека + +По правилам `AGENTS.md` навигация разложена на три уровня: + +```text +widgets/ + QueriesNavigation — готовый виджет: список кластеров/элементов + detail-панель +modules/ + NavigationHeader — хлебные крошки, редактор пути, действия + ClustersList — список кластеров + NavigationItemsList — список элементов текущего пути (+ синтетическая строка «..») + NavigationDetail — панель детали: заголовок, вкладки, общий поиск + NavigationSchema — вкладка со структурой полей (табличная) + NavigationPreview — вкладка с данными (табличная) + NavigationMeta — вкладка с метаданными (пары «ключ → значение») +components/ + DataTable, LazyList, PathEditor, Breadcrumbs, + SearchWithButtons, FieldsSelector, FieldsSearchToolbar, + EmptyContent, ListSpinner +helpers/ + useVisibleColumns, useLoadMoreSentinel, getParentPath, getDefaultNavigationIcon +types/ + navigation.ts, pathEditor.ts — публичные контракты +``` + +Импорты идут строго в одну сторону: `widgets → modules → components`. + +## Базовые принципы + +1. **Нормализованные данные на входе.** Библиотека не парсит и не форматирует доменные + структуры источника. Значения приходят уже приведёнными к строке или `ReactNode`; схема, + строки данных и метаданные — плоские нормализованные объекты. Никаких зависимостей от + форматов сериализации конкретной системы. +2. **Минимальный дефолт + расширяемость.** Каждый контракт содержит минимальный набор + обязательных полей, а расширение делается через индексную подпись (`[key: string]: unknown`) + и дженерик, протянутый до рендера. Консюмер добавляет свои поля и типобезопасно использует + их в кастомных колонках/строках. +3. **Два уровня кастомизации.** Везде, где есть дефолтный вид, доступны оба варианта: + *добавление* к дефолту (`view.extraColumns`, `view.extraContent`) и *полная замена* + (`view.tableColumns`, `view.render`, `renderRowItem`). Дефолтные билдеры и дефолтные строки + экспортируются публично, чтобы сценарий «то же, что дефолт, плюс своё» не требовал + копирования кода. +4. **Единая форма пропсов у модулей детали:** `{data, view?, ...сквозные}`, где `data` — + единственный источник правды по данным и состоянию (совпадает с публичным типом конфига, + который отдаёт резолвер), `view` — изолированная группа кастомизации отображения, а наверху + остаются только инфраструктурные пропсы (`search`, `className` и пр.). Это не даёт разрастись + плоскому списку пропсов и убирает ручную раскладку полей конфига в местах интеграции. +5. **Controlled/uncontrolled для любого состояния.** Активная вкладка, значение поиска, набор + видимых колонок работают в обоих режимах: если проп задан — модуль полностью следует ему, + иначе держит внутреннее состояние. Внутренний стейт не обновляется в контролируемом режиме, + чтобы не заводить «мертвое» состояние. +6. **Дженерики с дефолтом** вместо фиксированных типов — обратная совместимость сохраняется + для тех, кто не расширяет модель. + +## Модель данных + +```text +NavigationLocation = {cluster?, path?} +NavigationCluster = {id, title, icon?, color?, backgroundColor?, description?} +NavigationItemKind = 'folder' | 'file' | 'table' | 'link' | 'unknown' +NavigationItem = {path, title, icon?, kind?, hasChildren?, targetPathBroken?, disabled?} +NavigationSortOrder = 'asc' | 'desc' +``` + +Навигация «внутрь» против открытия детали определяется по `hasChildren`, а не по `kind`: +элемент с детьми — переход по пути, без детей — открытие detail-панели. + +Виджет группирует сквозные настройки в конфиг-объекты (`header`, `search`, `sort`, `listState`, +`detail`) вместо плоского списка пропсов. + +## Detail-панель и вкладки + +Набор вкладок не зашит в виджет, а вычисляется резолвером по элементу: + +```text +ResolveNavigationDetail = (item: T) => NavigationDetailConfig | undefined + +NavigationDetailConfig = { + tabs: NavigationDetailTab[]; + defaultTab?: string; + hasSearch?: boolean; + searchPlaceholder?: string; + actions?: NavigationHeaderAction[]; +} + +NavigationDetailTab = { + id; title; + content?: ReactNode; // статический контент + renderContent?: (ctx) => ReactNode; // приоритетнее content + hidden?; disabled?; +} + +NavigationDetailTabRenderContext = {search, onSearchUpdate?, searchPlaceholder?} +``` + +Ключевые решения: + +- **Реестр по виду элемента.** Резолвер собирается из реестра `Partial>` плюс `fallback`. Это даёт кастомное отображение для любого вида элемента, а не только + для табличного. Дефолт из коробки есть только для табличного вида; для остальных без + собственной фабрики показывается заглушка. +- **Search-aware контент.** Статический `content` не может реагировать на поиск, поэтому у вкладки + есть `renderContent(ctx)`, получающий актуальное значение поиска, колбэк его обновления и + плейсхолдер. Правило: задан `renderContent` — используется он, иначе `content`. +- **Кто рендерит строку поиска.** Табличные вкладки рендерят собственный тулбар (поиск + выбор + колонок), поэтому фабрика встроенных вкладок ставит `hasSearch: false`, а общий ряд поиска в + `NavigationDetail` остаётся механизмом для кастомных наборов вкладок. Побочный эффект — + на вкладках без тулбара (метаданные и т.п.) поиска нет, что и требовалось: поиск семантически + относится к полям, а не к метаданным. + +```mermaid +flowchart TD + Item[NavigationItem] --> Resolver[resolve detail by kind] + Resolver --> Config[NavigationDetailConfig tabs] + Config --> Detail[NavigationDetail] + Detail -->|renderContent ctx| Tab[active tab] + Tab --> Schema[NavigationSchema] + Tab --> Preview[NavigationPreview] + Tab --> Meta[NavigationMeta] +``` + +## Табличные вкладки: структура полей и данные + +Обе вкладки построены на общем компоненте `DataTable` (скелетон-загрузка, пустые состояния) и +имеют одинаковый каркас. + +Контракты: + +```text +NavigationSchemaColumn = { + name: string; // имя поля + type?: string; // тип готовой строкой + sortOrder?: 'ascending' | 'descending'; // участие в ключе + направление + required?: boolean; + [key: string]: unknown; // расширение +} +NavigationSchemaConfig = {columns: TColumn[]; loading?; loaded?; errorContent?} + +NavigationPreviewCell = ReactNode // готовое к отображению значение +NavigationPreviewRow = Record +NavigationPreviewConfig = {columns: string[]; rows: TRow[]; loading?; loaded?; errorContent?} +``` + +Терминологическая тонкость: в схеме `columns` — это **строки** таблицы (поля описываемой +сущности), а `view.tableColumns` — **колонки** отображающей таблицы. В превью `columns` задают +состав и порядок колонок данных. + +Поведение модулей: + +- `view.tableColumns` заданы → используются как есть (полная замена вида). +- Иначе строится дефолт билдером (`buildSchemaColumns` / `buildPreviewColumns`) и к нему + добавляются `view.extraColumns`. Билдеры экспортируются публично. +- Данные фильтруются по значению поиска: в схеме — по имени и типу поля, в превью — по подстроке + в видимых колонках. Пустой результат поиска даёт состояние «ничего не найдено», пустые + исходные данные — «нет данных». +- Индексная колонка отключена (`displayIndices: false`). + +### Тулбар: поиск + выбор видимых колонок + +Над таблицей рендерится общий тулбар (`FieldsSearchToolbar`): инпут поиска плюс кнопка выбора +видимых полей в `endButtons`. Состав опций селектора берётся из отображаемых колонок; по умолчанию +видимы все. Набор видимых колонок живёт в модуле (uncontrolled) через общий хук +`useVisibleColumns`, но переопределяется извне парой `visibleColumns` / `onVisibleColumnsChange` +плюс `defaultVisibleColumns` для стартового набора. Тулбар и сам селектор можно скрыть +(`hideToolbar`, `hideFieldsSelector`). + +## Вкладка метаданных + +Модель — **массив групп пар «ключ → значение»**, а не таблица: для метаданных это семантически +точнее. + +```text +NavigationMetaItem = {name: string; value: ReactNode; [key: string]: unknown} +NavigationMetaGroup = {title?: string; items: NavigationMetaItem[]} +NavigationMetaConfig = {groups: NavigationMetaGroup[]; loading?; loaded?; errorContent?} + +NavigationMetaViewConfig = { + render?: (data) => ReactNode; // полная замена содержимого вкладки + extraContent?: ReactNode; // блок после дефолтных групп +} +``` + +Дефолт рендерит по блоку на группу: необязательный заголовок группы и список пар с прочерком +на месте пустого значения. Полная замена вида (`view.render`) имеет приоритет над всем, включая +обработку ошибок, — это осознанный «сырой» хук для консюмера. Тулбар и поиск на этой вкладке +не рендерятся. + +## Переопределение строк списков + +Паттерн повторяет уже принятый в списке истории: модуль принимает необязательный +`renderRowItem?: (data) => ReactNode`, и если он задан — используется вместо дефолтной строки. + +```text +NavigationItemRowRenderData = {item: T; index; isActive; isParentRow} +NavigationClusterRowRenderData = {cluster: T; index; isActive} +``` + +Особенности: + +- Флаг `isParentRow` отличает синтетическую строку «..» (создаётся внутри модуля и не несёт + дополнительных полей `T`), чтобы консюмер мог отрендерить её иначе или оставить дефолт. +- Строка «..» рендерится в двух местах — в основном списке и над пустым состоянием, — поэтому + кастомный рендер прокидывается и в пустое состояние. +- Дженерики `TItem` / `TCluster` протянуты через виджет и модули (`items`, `onItemClick`, + `renderRowItem`), поэтому дополнительные поля видны в типах внутри рендера. Виджет из-за этого + объявляется обычной дженерик-функцией, а не через `React.FC`. +- Дефолтные строки остаются в `internal/`, но реэкспортируются через `index.ts` модулей и + попадают в публичное API — для сценария «дефолт плюс своё». +- На уровне виджета пропсы называются `renderNavigationItem` / `renderClusterItem`, на уровне + модулей — `renderRowItem` (консистентно с остальными списками библиотеки). + +## Состояния + +Единая схема для всех модулей детали: + +- `errorContent` — ранний возврат с текстом ошибки; в конфиге состояния списка ошибка сведена к + одному полю `error?: boolean | ReactNode` (`true` — дефолтное сообщение, `ReactNode` — свой + контент), чтобы не было двух полей под одно состояние. +- `loading` без `loaded` — скелетон. +- `loaded` при пустых данных — пустое состояние (`EmptyContent` с вариантом `no-data`). +- Непустой поиск без совпадений — вариант `nothing-found`. + +## i18n + +Каждый модуль/виджет с собственным сценарием держит локализацию в `i18n/` (`en.json`, `ru.json`, +`dicts.ts`, `index.ts`) и регистрирует keyset вида `qp:navigation-schema`, `qp:navigation-preview`, +`qp:navigation-meta`. Ключи — по формату `<контекст>_<содержимое>` (`title_column-name`, +`value_sort-ascending`, `value_empty`, `context_empty`, `field_detail-search-placeholder`, +`action_configure-visible-fields`). Дублирование мелких ключей вроде `value_empty` между +keyset'ами допустимо; общая логика вроде «прочерк вместо пустого значения» лучше жила бы в одном +хелпере. + +## Публичный экспорт + +- `index.ts` каждого уровня явно реэкспортирует публичные единицы уровня (включая дефолтные + строки списков и дефолтные билдеры колонок). +- Публичные типы контрактов лежат в `src/types/navigation.ts` и реэкспортируются из корневого + `index.ts`, чтобы консюмер мог типизировать свои данные. + +## Известные пробелы и договорённости + +Зафиксировано по итогам ревью навигационного стека; часть закрыта, часть осознанно оставлена. + +| Тема | Состояние | +|------|-----------| +| Источник действий detail-панели | Сведён к одному управляемому полю в конфиге панели; действия из резолвера домёрживаются внутри `NavigationDetail` | +| Ошибка в состоянии списка | Сведена к одному полю `error?: boolean \| ReactNode` | +| Внутренний стейт при controlled-режиме | Явно различаются controlled/uncontrolled для вкладки и поиска | +| Пустая вкладка «просмотр» | Остаётся заглушкой в публичном API: либо скрывать, либо дать резолвер/рендер | +| Точечное переопределение одной встроенной вкладки | Нельзя заменить одну вкладку, не пересобрав весь массив; есть только полная замена вида внутри модуля | +| Контекст рендера вкладки | Содержит только поиск; элемент и локацию консюмер вынужден замыкать в фабрике | +| Дефолты по видам элементов | Дефолтная фабрика есть только для табличного вида, остальные — заглушка до своей фабрики | +| Дублирование каркаса трёх модулей детали | Повторяются рендер ошибки, сборка колонок и обёртка над таблицей; кандидаты на общий хелпер, общий тип `view` для табличных модулей и общий `isEmptyValue` | +| Клик по строке «..» в непустом списке | Обработка «вверх» опирается на признак наличия детей у синтетической строки — поведение требует проверки | +| Презентационные поля модели кластера | `color` / `backgroundColor` / `description` в данных дублируют возможности кастомного рендера строки | diff --git a/plans/queries-navigation-architecture-review.md b/plans/queries-navigation-architecture-review.md new file mode 100644 index 0000000..d1ddb7b --- /dev/null +++ b/plans/queries-navigation-architecture-review.md @@ -0,0 +1,217 @@ +# Архитектурный анализ `QueriesNavigation` и дочерних компонент + +Область анализа: состояние кода после коммита `bcaa6e4c` (анализировалось текущее +состояние рабочей директории — прямого доступа к git-diff в среде нет). + +Затронутые единицы: + +- Виджет [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:51) + хелперы + [`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13), + [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:45), + [`createEmptyDetailConfig`](src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts:5). +- Модули: [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:39), + [`NavigationHeader`](src/modules/NavigationHeader/NavigationHeader.tsx:15), + [`ClustersList`](src/modules/ClustersList/ClustersList.tsx:23), + [`NavigationItemsList`](src/modules/NavigationItemsList/NavigationItemsList.tsx:31), + [`NavigationSchema`](src/modules/NavigationSchema/NavigationSchema.tsx:36), + [`NavigationPreview`](src/modules/NavigationPreview/NavigationPreview.tsx:34), + [`NavigationMeta`](src/modules/NavigationMeta/NavigationMeta.tsx:25), + [`NavigationView`](src/modules/NavigationView/NavigationView.tsx:24). +- Типы: [`src/types/navigation.ts`](src/types/navigation.ts:1). + +Общая оценка: архитектура зрелая и последовательная. Уровни `components → modules → widgets` +выдержаны, generic-типизация сквозная, controlled/uncontrolled паттерн реализован. Ниже — +конкретные замечания, сгруппированные по 7 пунктам задачи. + +--- + +## 1. Соответствие стилю проекта и Gravity UI + +Что хорошо: + +- Везде используется `bem-cn-lite` с префиксом `qp-*`, компоновка через `Flex`, spacing через + `gap`, что совпадает с остальным проектом. +- Detail-табы построены на `SegmentedRadioGroup`, meta-скелетоны на `Skeleton`, секции на + `Disclosure` — всё нативные примитивы Gravity UI, без самописных аналогов. + +Замечания: + +- **Инлайн-стили вместо SCSS/токенов в продакшн-коде.** В [`buildSchemaColumns`](src/modules/NavigationSchema/helpers/buildSchemaColumns.tsx:22) + используется `style={{display:'inline-flex', alignItems:'center', gap:4}}`. По стилю проекта + раскладка/отступы задаются через `Flex gap` или `--g-spacing-*` в `.scss`. Инлайн-`gap:4` стоит + заменить на `Flex gap` или CSS-класс с токеном. (Инлайн-стили и текстовые заглушки в сторибуках + оставляем как есть — для демо это допустимо.) +- **Пустой `title: ''` в конфиге таба.** В [`createEmptyDetailConfig`](src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts:9) + создаётся таб с пустым заголовком — формально валидация i18n не срабатывает (строка не идёт + через `t`), но семантически это «фейковый» таб только ради контейнера. См. п.4. + +## 2. Размещение новых компонент по уровням + +В целом соответствует правилам из [`AGENTS.md`](AGENTS.md:1): + +- Списковые/скомпонованные блоки (`ClustersList`, `NavigationItemsList`, `NavigationDetail`, + `NavigationSchema/Preview/Meta/View`) корректно лежат в `modules`. +- Внутренние части (`ClusterRow`, `NavigationItemRow`, `NavigationDetailTabs`, + `NavigationViewSectionItem`) — в `internal/`, что верно. +- Хелперы конфигов (`createTableDetailConfig` и пр.) — на уровне виджета, т.к. содержат + widget-specific i18n. Это оправданно. + +Замечания: + +- **`ClusterRow` и `NavigationItemRow` реэкспортируются как публичные из + [`src/modules/index.ts`](src/modules/index.ts:17), но физически лежат в `internal/`.** + Противоречие: либо это публичный API (тогда вынести из `internal/` на уровень модуля или в + `components`, т.к. они атомарны и переиспользуются в сторибуке `CustomRows`), либо это внутренняя + деталь (тогда убрать из barrel). Судя по использованию в кастомном рендере — это публичный + контракт, и их правильнее поднять до `components` (у них стабильный props-контракт, отдельное + использование есть). +- **`NavigationDetailTabs` в `internal/` — ок**, но у него нет `index.ts` и он импортируется по + прямому пути. По правилу «каждая единица — папка с `index.ts`» это допустимо для internal, но + стоит свериться на консистентность с другими internal-частями. +- **`NavigationDetail` не экспортирует `NavigationDetailConfig`-фабрики** из своего `index.ts`, + тогда как связанные типы живут в `types/navigation`. Это нормально, но стоит проверить, что + потребитель может собрать конфиг детали без импортов из `widgets`. + +## 3. Кандидаты на вынос в хелперы + +- **`visibleActions`-рендер дублируется.** Логика «отфильтровать по `hidden` → отрисовать список + `Button view=flat size=s` с `title/aria-label/disabled/qa`» повторяется в + [`NavigationHeader`](src/modules/NavigationHeader/NavigationHeader.tsx:22) и + [`NavigationViewSectionItem`](src/modules/NavigationView/internal/NavigationViewSectionItem.tsx:29). + Кандидат на общий компонент `ActionButtons`/`NavigationActions` (в `components`) с generic-типом + экшена, чтобы `NavigationHeaderAction` и `NavigationViewSectionAction` покрывались одним рендером. +- **Единый тип «action».** См. п.6 — `NavigationHeaderAction` и `NavigationViewSectionAction` + отличаются только сигнатурой `onClick`. Можно ввести generic `NavigationAction`. +- **`getInitialTab` / фильтр видимых табов.** В [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:30) + `config.tabs.filter(t => !t.hidden)` считается дважды (в `getInitialTab` и в `useMemo`). Можно + вынести хелпер `getVisibleTabs`/`resolveInitialTab` в `NavigationDetail/helpers/`. +- **SKELETON-заглушка.** Блок «N `Skeleton` в колонку» полностью повторяется в + [`NavigationMeta`](src/modules/NavigationMeta/NavigationMeta.tsx:47) и + [`NavigationView`](src/modules/NavigationView/NavigationView.tsx:40) (одна и та же + `SKELETON_ROWS_COUNT = 4`). Кандидат на общий `ListSkeleton`/`SkeletonRows` в `components`. + +## 4. Удобство использования, гибкость переопределения и типизация + +Сильные стороны: + +- Сквозные дженерики ``, ``, `` дают типобезопасное расширение + строк/колонок. +- Паттерн `renderRowItem` + fallback на дефолтный ряд (`ClustersList`, `NavigationItemsList`) — + гибкий и предсказуемый. +- Controlled/uncontrolled в [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:51) + реализован аккуратно (search и activeTab независимо). +- `view`-проп у detail-модулей (`tableColumns`/`extraColumns`/`render`) — хороший механизм + переопределения таблиц. + +Замечания: + +- **Дженерик теряется в detail-резолверах.** В [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:19) + резолверы типизированы как `NavigationSchemaResolver`, но возвращают `NavigationSchemaConfig` + без ``. Из-за этого `extraColumns`-типизация кастомных колонок в конфиге таблицы теряется. + Стоит пробросить `TColumn`/`TRow` до `NavigationSchema data`. +- **`createEmptyDetailConfig` через `React.createElement`.** Файл `.ts`, поэтому используется + `createElement` вместо JSX. Логичнее переименовать в `.tsx` и вернуть JSX, а «пустое состояние» + моделировать не фейковым табом с `title:''`, а отдельной веткой в `NavigationDetail` + (например, `config.empty?: ReactNode` или рендер `EmptyContent`, когда `tabs` пуст). +- **`[key: string]: unknown` в типах.** `NavigationSchemaColumn` и `NavigationMetaItem` имеют + индексную сигнатуру `[key: string]: unknown`. Это ослабляет типизацию расширений (лучше решать + через дженерик `T extends ...`, который и так есть). Возможная избыточность — см. п.6. +- **`resolvedDetailActions = detailActions ?? actions`** в + [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:86): fallback header-экшенов + на detail неочевиден. Стоит задокументировать поведение в типе `NavigationDetailPanelConfig`. + +## 5. Дублирование кода + +- **`buildPreviewColumns` и `buildViewColumns` идентичны.** + [`buildPreviewColumns`](src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx:5) и + [`buildViewColumns`](src/modules/NavigationView/helpers/buildViewColumns.tsx:5) — побайтово + одинаковая функция (маппинг `columns → Column` с заглушкой `value_empty`). Разнесены только по + разным i18n. Кандидат на общий хелпер (например, `buildStringColumns` в `src/helpers/` или + `components`), принимающий функцию перевода/`emptyText`. +- **Логика «пустого значения» повторяется трижды.** `value === undefined || null || ''` есть в + `buildPreviewColumns`, `buildViewColumns`, [`buildMetaGroups`](src/modules/NavigationMeta/helpers/buildMetaGroups.tsx:15) + (`isEmptyValue`). Вынести единый `isEmptyValue` в `src/helpers/`. +- **Скелетоны и action-рендер** — дублирование описано в п.3. +- **Блок `errorContent → `** повторяется в Schema/Preview/Meta/View. Можно + ввести общий `DetailError`/использовать существующий паттерн вывода ошибки. + +## 6. Оценка новых типов (избыточность/дублирование) + +Файл [`src/types/navigation.ts`](src/types/navigation.ts:1) большой и в целом хорошо +структурирован, но есть избыточность: + +- **`NavigationViewRow = NavigationPreviewRow`** ([`строка 109`](src/types/navigation.ts:109)) — псевдоним. + А `NavigationViewConfig`/`NavigationPreviewConfig` очень близки (secions vs rows). Стоит оценить, + не свести ли preview к частному случаю view, либо явно задокументировать, что это осознанно разные + сущности. +- **`NavigationHeaderAction` vs `NavigationViewSectionAction`** — отличаются только типом аргумента + `onClick` (`NavigationLocation` против `NavigationViewSection`). Кандидат на generic + `NavigationAction` c общими полями `id/title/content/hidden/disabled/qa`. +- **Четыре почти одинаковых `*Config`** (`Schema/Preview/Meta/View`) с общими полями + `loading/loaded/errorContent`. Можно ввести базовый `NavigationAsyncConfig` и расширять его + (`& {columns}`, `& {rows}`, `& {groups}`, `& {sections}`). +- **`ResolveNavigationDetail` vs `NavigationDetailConfigFactory`** — обе `(item) => Config`, + различие только в `| undefined`. Возможно, достаточно одного типа с опциональностью на месте + использования. +- **`NavigationPreviewCell = ReactNode` и `NavigationMetaValue = ReactNode`** — псевдонимы одного + и того же. Либо один общий `NavigationCellValue`, либо убрать псевдонимы. +- **Индексные сигнатуры `[key: string]: unknown`** в `NavigationSchemaColumn`/`NavigationMetaItem` + дублируют возможности дженериков — стоит выбрать один механизм расширения. + +## 7. Оценка сторибуков (избыточность, объединение) + +Наблюдения: + +- **Повторяющиеся `Loading/Empty/Error`-стори у 4 detail-модулей.** У Schema, Preview, Meta, View + практически идентичные state-стори. Их можно оставить (они полезны для autodocs), но mock-данные + дублируются между сторибуками и [`QueriesNavigation.stories`](src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx:148) + (`TABLE_SCHEMA_COLUMNS`, `TABLE_PREVIEW_ROWS`, `TABLE_META_GROUPS`, `TABLE_VIEW_SECTIONS` + повторяют данные модульных сторибуков). Кандидат на общий `story/mockData.ts` + (как уже сделано в `DashboardCharts/story/mockData.ts` и `ChartEditor/story/mockData.ts`). +- **`CustomColumns`-стори у Schema и Preview идентичны** (тот же `lock`-паттерн с `Label`+`LockIcon`). + Можно объединить логически или вынести общий пример. +- **Кандидат на объединение в одно демо.** Отдельные `Loading`/`Empty`/`Error` можно объединить в + одну «States»-стори через controls/args-матрицу, оставив `Default` и `Custom*` отдельными. Это + сократит число стори без потери покрытия. +- **Несогласованность с гайдом структуры сторибуков.** Часть модулей (`ChartEditor`, + `DashboardCharts`) держат стори в подпапке `story/` с `mockData.ts`, а Navigation-модули — рядом + с компонентом. Стоит привести к единому подходу (вероятно `story/` + вынесенные моки). +- **Мелочь:** опечатки в mock-данных `QueriesNavigation.stories` (`tesdting`, `Prestable`) — + косметика, но заметна в autodocs. + +--- + +## Приоритизация правок (для последующей реализации в Code mode) + +Высокий приоритет (дублирование/типобезопасность): + +1. Объединить [`buildPreviewColumns`](src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx:5) + и [`buildViewColumns`](src/modules/NavigationView/helpers/buildViewColumns.tsx:5) в один хелпер. +2. Вынести общий `isEmptyValue` в `src/helpers/`. +3. Пробросить дженерики `TColumn`/`TRow` в резолверах + [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:45). +4. Решить статус `ClusterRow`/`NavigationItemRow`: поднять в `components` либо убрать из + публичного barrel. + +Средний приоритет (переиспользование/типы): + +5. Ввести generic `NavigationAction` и общий рендер экшенов + (`NavigationHeader` + `NavigationViewSectionItem`). +6. Ввести базовый `NavigationAsyncConfig` (`loading/loaded/errorContent`) и общий `SkeletonRows`. +7. Убрать псевдонимы-дубликаты типов (`NavigationPreviewCell`/`NavigationMetaValue`, + `NavigationViewRow`) и пересмотреть индексные сигнатуры. + +Низкий приоритет (стиль/сторибуки): + +8. Вынести mock-данные detail-модулей в общий `story/mockData.ts`, объединить state-стори, + унифицировать расположение сторибуков. +9. Заменить инлайн-стиль `gap:4` в продакшн-хелпере [`buildSchemaColumns`](src/modules/NavigationSchema/helpers/buildSchemaColumns.tsx:22) + на `Flex gap` / CSS-класс с токеном (сторибуков не касается). +10. Переосмыслить `createEmptyDetailConfig` (JSX + отдельная ветка пустого состояния вместо + таба с `title:''`). + +--- + +Примечание по процессу: анализ выполнен по текущему состоянию файлов. Если нужно строго сверить +именно diff коммита `bcaa6e4c` (какие строки добавлены/удалены), это удобнее сделать в среде с +доступом к `git`/`arc` — тогда часть замечаний можно будет привязать к конкретным ханкам. 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/ClusterRow/ClusterRow.tsx b/src/components/ClusterRow/ClusterRow.tsx new file mode 100644 index 0000000..7d6df73 --- /dev/null +++ b/src/components/ClusterRow/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/components/ClusterRow/index.ts b/src/components/ClusterRow/index.ts new file mode 100644 index 0000000..f57e39c --- /dev/null +++ b/src/components/ClusterRow/index.ts @@ -0,0 +1,2 @@ +export {ClusterRow} from './ClusterRow'; +export type {ClusterRowProps} from './ClusterRow'; 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/NavigationActionButtons/NavigationActionButtons.tsx b/src/components/NavigationActionButtons/NavigationActionButtons.tsx new file mode 100644 index 0000000..a8a744d --- /dev/null +++ b/src/components/NavigationActionButtons/NavigationActionButtons.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {Button, Flex} from '@gravity-ui/uikit'; +import type {NavigationAction} from '../../types/navigation'; + +export type NavigationActionButtonsProps = { + actions?: Array>; + arg: TArg; + className?: string; + buttonClassName?: string; +}; + +export function NavigationActionButtons({ + actions, + arg, + className, + buttonClassName, +}: NavigationActionButtonsProps) { + const visibleActions = actions?.filter((action) => !action.hidden) ?? []; + + if (visibleActions.length === 0) { + return null; + } + + return ( + + {visibleActions.map((action) => ( + + ))} + + ); +} diff --git a/src/components/NavigationActionButtons/index.ts b/src/components/NavigationActionButtons/index.ts new file mode 100644 index 0000000..124c86a --- /dev/null +++ b/src/components/NavigationActionButtons/index.ts @@ -0,0 +1,2 @@ +export {NavigationActionButtons} from './NavigationActionButtons'; +export type {NavigationActionButtonsProps} from './NavigationActionButtons'; diff --git a/src/components/NavigationItemRow/NavigationItemRow.tsx b/src/components/NavigationItemRow/NavigationItemRow.tsx new file mode 100644 index 0000000..346e703 --- /dev/null +++ b/src/components/NavigationItemRow/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/components/NavigationItemRow/index.ts b/src/components/NavigationItemRow/index.ts new file mode 100644 index 0000000..7b65da7 --- /dev/null +++ b/src/components/NavigationItemRow/index.ts @@ -0,0 +1,2 @@ +export {NavigationItemRow} from './NavigationItemRow'; +export type {NavigationItemRowProps} from './NavigationItemRow'; 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/SkeletonRows/SkeletonRows.tsx b/src/components/SkeletonRows/SkeletonRows.tsx new file mode 100644 index 0000000..15f8445 --- /dev/null +++ b/src/components/SkeletonRows/SkeletonRows.tsx @@ -0,0 +1,24 @@ +import React, {FC} from 'react'; +import {Flex, Skeleton} from '@gravity-ui/uikit'; + +export type SkeletonRowsProps = { + count?: number; + className?: string; + rowClassName?: string; +}; + +const DEFAULT_ROWS_COUNT = 4; + +export const SkeletonRows: FC = ({ + count = DEFAULT_ROWS_COUNT, + className, + rowClassName, +}) => { + return ( + + {Array.from({length: count}, (_, index) => ( + + ))} + + ); +}; diff --git a/src/components/SkeletonRows/index.ts b/src/components/SkeletonRows/index.ts new file mode 100644 index 0000000..83353a4 --- /dev/null +++ b/src/components/SkeletonRows/index.ts @@ -0,0 +1,2 @@ +export {SkeletonRows} from './SkeletonRows'; +export type {SkeletonRowsProps} from './SkeletonRows'; diff --git a/src/components/index.ts b/src/components/index.ts index da7698f..8ed9429 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,11 +1,32 @@ +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 {ClusterRow} from './ClusterRow'; +export type {ClusterRowProps} from './ClusterRow'; +export {NavigationItemRow} from './NavigationItemRow'; +export type {NavigationItemRowProps} from './NavigationItemRow'; +export {NavigationActionButtons} from './NavigationActionButtons'; +export type {NavigationActionButtonsProps} from './NavigationActionButtons'; +export {SkeletonRows} from './SkeletonRows'; +export type {SkeletonRowsProps} from './SkeletonRows'; 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/buildColumnsFromKeys.ts b/src/helpers/buildColumnsFromKeys.ts new file mode 100644 index 0000000..dd102fc --- /dev/null +++ b/src/helpers/buildColumnsFromKeys.ts @@ -0,0 +1,17 @@ +import type {ReactNode} from 'react'; +import type {Column} from '../components'; +import {isEmptyValue} from './isEmptyValue'; + +export function buildColumnsFromKeys>( + columns: string[], + emptyText: string, +): Array> { + return columns.map((column) => ({ + name: column, + header: column, + render: ({row}) => { + const value = row[column]; + return isEmptyValue(value) ? emptyText : (value as ReactNode); + }, + })); +} 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/isEmptyValue.ts b/src/helpers/isEmptyValue.ts new file mode 100644 index 0000000..907273e --- /dev/null +++ b/src/helpers/isEmptyValue.ts @@ -0,0 +1,2 @@ +export const isEmptyValue = (value: unknown): boolean => + value === undefined || value === null || value === ''; 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..2f8e2ef --- /dev/null +++ b/src/modules/ClustersList/ClustersList.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import cn from 'bem-cn-lite'; +import {NavigationCluster, RenderNavigationCluster} from '../../types/navigation'; +import {ClusterRow, 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..41edd26 --- /dev/null +++ b/src/modules/ClustersList/index.ts @@ -0,0 +1,2 @@ +export {ClustersList} from './ClustersList'; +export type {ClustersListProps} from './ClustersList'; 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..047e822 --- /dev/null +++ b/src/modules/NavigationDetail/NavigationDetail.tsx @@ -0,0 +1,121 @@ +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 {getInitialTab} from './helpers/getInitialTab'; +import {getVisibleTabs} from './helpers/getVisibleTabs'; +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; +}; + +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 visibleTabs = useMemo(() => getVisibleTabs(config.tabs), [config.tabs]); + + const [activeTabState, setActiveTabState] = useState(() => + getInitialTab(visibleTabs, config.defaultTab), + ); + 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 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; + + const hasTabs = visibleTabs.length > 0; + + return ( + + + {hasTabs ? ( + <> + + {config.hasSearch && ( + + )} +
{activeContent}
+ + ) : ( + config.emptyContent + )} +
+ ); +}; diff --git a/src/modules/NavigationDetail/helpers/getInitialTab.ts b/src/modules/NavigationDetail/helpers/getInitialTab.ts new file mode 100644 index 0000000..19730b9 --- /dev/null +++ b/src/modules/NavigationDetail/helpers/getInitialTab.ts @@ -0,0 +1,9 @@ +import type {NavigationDetailTab} from '../../../types/navigation'; + +export function getInitialTab(visibleTabs: NavigationDetailTab[], defaultTab?: string): string { + const defaultTabConfig = defaultTab + ? visibleTabs.find((tab) => tab.id === defaultTab) + : undefined; + const firstEnabled = visibleTabs.find((tab) => !tab.disabled); + return (defaultTabConfig ?? firstEnabled ?? visibleTabs[0])?.id ?? ''; +} diff --git a/src/modules/NavigationDetail/helpers/getVisibleTabs.ts b/src/modules/NavigationDetail/helpers/getVisibleTabs.ts new file mode 100644 index 0000000..bd1f112 --- /dev/null +++ b/src/modules/NavigationDetail/helpers/getVisibleTabs.ts @@ -0,0 +1,5 @@ +import type {NavigationDetailTab} from '../../../types/navigation'; + +export function getVisibleTabs(tabs: NavigationDetailTab[]): NavigationDetailTab[] { + return tabs.filter((tab) => !tab.hidden); +} 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..b534494 --- /dev/null +++ b/src/modules/NavigationHeader/NavigationHeader.tsx @@ -0,0 +1,33 @@ +import React, {FC} from 'react'; +import {Flex} from '@gravity-ui/uikit'; +import {Breadcrumbs} from '../../components/Breadcrumbs'; +import {NavigationActionButtons} from '../../components'; +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, +}) => { + return ( + + + + + ); +}; 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..6e3c8d7 --- /dev/null +++ b/src/modules/NavigationItemsList/NavigationItemsList.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import cn from 'bem-cn-lite'; +import {NavigationItem, NavigationSortOrder, RenderNavigationItem} from '../../types/navigation'; +import {LazyList, NavigationItemRow} from '../../components'; +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..8628d86 --- /dev/null +++ b/src/modules/NavigationItemsList/index.ts @@ -0,0 +1,2 @@ +export {NavigationItemsList} from './NavigationItemsList'; +export type {NavigationItemsListProps} from './NavigationItemsList'; diff --git a/src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx b/src/modules/NavigationItemsList/internal/NavigationItemsListEmptyState.tsx new file mode 100644 index 0000000..9da2ea2 --- /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 '../../../components'; + +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.tsx b/src/modules/NavigationMeta/NavigationMeta.tsx new file mode 100644 index 0000000..08336ef --- /dev/null +++ b/src/modules/NavigationMeta/NavigationMeta.tsx @@ -0,0 +1,81 @@ +import React, {useMemo} from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {EmptyContent, SkeletonRows} 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'); + +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 ( + + ); + } + + 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..7c4a67e --- /dev/null +++ b/src/modules/NavigationMeta/helpers/buildMetaGroups.tsx @@ -0,0 +1,27 @@ +import type {ReactNode} from 'react'; +import {isEmptyValue} from '../../../helpers/isEmptyValue'; +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[]; +}; + +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/NavigationMeta/story/NavigationMeta.stories.tsx b/src/modules/NavigationMeta/story/NavigationMeta.stories.tsx new file mode 100644 index 0000000..7244fad --- /dev/null +++ b/src/modules/NavigationMeta/story/NavigationMeta.stories.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Label, Text} from '@gravity-ui/uikit'; +import {NavigationMeta} from '..'; +import {META_GROUPS} from './mockData'; + +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: META_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: META_GROUPS, loaded: true}, + view: { + extraContent: ( + + Additional info rendered below the default groups + + ), + }, + }, +}; + +export const CustomRender: Story = { + args: { + data: {groups: META_GROUPS, loaded: true}, + view: { + render: (data) => ( +
+ {data.groups + .flatMap((group) => group.items) + .map((item, index) => ( + + ))} +
+ ), + }, + }, +}; diff --git a/src/modules/NavigationMeta/story/mockData.ts b/src/modules/NavigationMeta/story/mockData.ts new file mode 100644 index 0000000..4f96cef --- /dev/null +++ b/src/modules/NavigationMeta/story/mockData.ts @@ -0,0 +1,22 @@ +import type {NavigationMetaConfig} from '../../../types/navigation'; + +export const META_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'}, + ], + }, +]; 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.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..0b36437 --- /dev/null +++ b/src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx @@ -0,0 +1,11 @@ +import type {Column} from '../../../components'; +import {buildColumnsFromKeys} from '../../../helpers/buildColumnsFromKeys'; +import type {NavigationPreviewRow} from '../../../types/navigation'; +import type previewI18n from '../i18n'; + +export function buildPreviewColumns( + columns: string[], + i18n: typeof previewI18n, +): Array> { + return buildColumnsFromKeys(columns, i18n('value_empty')); +} 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/NavigationPreview/story/NavigationPreview.stories.tsx b/src/modules/NavigationPreview/story/NavigationPreview.stories.tsx new file mode 100644 index 0000000..8df7ba8 --- /dev/null +++ b/src/modules/NavigationPreview/story/NavigationPreview.stories.tsx @@ -0,0 +1,98 @@ +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 '..'; +import type {NavigationPreviewRow} from '../../../types/navigation'; +import {PREVIEW_COLUMNS, PREVIEW_ROWS} from './mockData'; + +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: PREVIEW_COLUMNS, rows: PREVIEW_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: PREVIEW_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[] = PREVIEW_ROWS.map((row, index) => ({ + ...row, + lock: index % 2 === 0 ? 'shared' : undefined, +})); + +export const CustomColumns: Story = { + args: { + data: {columns: PREVIEW_COLUMNS, rows: CUSTOM_ROWS, loaded: true}, + view: { + extraColumns: [ + { + name: 'lock', + header: 'Lock', + render: ({row}) => + (row as CustomRow).lock ? ( + + ) : ( + '—' + ), + }, + ], + }, + }, +}; diff --git a/src/modules/NavigationPreview/story/mockData.ts b/src/modules/NavigationPreview/story/mockData.ts new file mode 100644 index 0000000..857e3e7 --- /dev/null +++ b/src/modules/NavigationPreview/story/mockData.ts @@ -0,0 +1,9 @@ +import type {NavigationPreviewRow} from '../../../types/navigation'; + +export const PREVIEW_COLUMNS = ['id', 'created_at', 'title', 'status']; + +export const 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'}, +]; 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.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..bd4ce6f --- /dev/null +++ b/src/modules/NavigationSchema/helpers/buildSchemaColumns.tsx @@ -0,0 +1,54 @@ +import React 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 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/NavigationSchema/story/NavigationSchema.stories.tsx b/src/modules/NavigationSchema/story/NavigationSchema.stories.tsx new file mode 100644 index 0000000..f2ef263 --- /dev/null +++ b/src/modules/NavigationSchema/story/NavigationSchema.stories.tsx @@ -0,0 +1,112 @@ +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 '..'; +import type {NavigationSchemaColumn} from '../../../types/navigation'; +import {SCHEMA_COLUMNS} from './mockData'; + +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: SCHEMA_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[] = SCHEMA_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/story/mockData.ts b/src/modules/NavigationSchema/story/mockData.ts new file mode 100644 index 0000000..437cf97 --- /dev/null +++ b/src/modules/NavigationSchema/story/mockData.ts @@ -0,0 +1,9 @@ +import type {NavigationSchemaColumn} from '../../../types/navigation'; + +export const 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'}, + {name: 'payload', type: 'any'}, +]; diff --git a/src/modules/NavigationView/NavigationView.scss b/src/modules/NavigationView/NavigationView.scss new file mode 100644 index 0000000..2186472 --- /dev/null +++ b/src/modules/NavigationView/NavigationView.scss @@ -0,0 +1,44 @@ +.qp-navigation-view { + --data-table-border-color: var(--g-color-line-generic); + + min-width: 0; + + &__error { + display: block; + } + + &__skeleton-row { + height: 24px; + } + + &__empty { + height: 100%; + } + + &__section { + width: 100%; + } + + &__action { + color: var(--g-color-text-secondary); + } + + &__summary { + width: 100%; + } + + &__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/NavigationView/NavigationView.tsx b/src/modules/NavigationView/NavigationView.tsx new file mode 100644 index 0000000..8eb7df4 --- /dev/null +++ b/src/modules/NavigationView/NavigationView.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {type Column, EmptyContent, SkeletonRows} from '../../components'; +import type {NavigationViewConfig, NavigationViewRow} from '../../types/navigation'; +import {NavigationViewSectionItem} from './internal/NavigationViewSectionItem'; +import './NavigationView.scss'; + +const block = cn('qp-navigation-view'); + +export type NavigationViewViewConfig = { + tableColumns?: Array>; + extraColumns?: Array>; +}; + +export type NavigationViewProps = { + data: NavigationViewConfig; + view?: NavigationViewViewConfig; + className?: string; +}; + +export function NavigationView({ + data, + view, + className, +}: NavigationViewProps) { + const {sections, loading, loaded, errorContent} = data; + const {tableColumns, extraColumns} = view ?? {}; + + if (errorContent) { + return ( + + {errorContent} + + ); + } + + if (loading && !loaded) { + return ( + + ); + } + + if (sections.length === 0) { + return ; + } + + return ( + + {sections.map((section) => ( + + key={section.id} + section={section} + tableColumns={tableColumns} + extraColumns={extraColumns} + /> + ))} + + ); +} diff --git a/src/modules/NavigationView/helpers/buildViewColumns.tsx b/src/modules/NavigationView/helpers/buildViewColumns.tsx new file mode 100644 index 0000000..9ba9529 --- /dev/null +++ b/src/modules/NavigationView/helpers/buildViewColumns.tsx @@ -0,0 +1,11 @@ +import type {Column} from '../../../components'; +import {buildColumnsFromKeys} from '../../../helpers/buildColumnsFromKeys'; +import type {NavigationViewRow} from '../../../types/navigation'; +import type viewI18n from '../i18n'; + +export function buildViewColumns( + columns: string[], + i18n: typeof viewI18n, +): Array> { + return buildColumnsFromKeys(columns, i18n('value_empty')); +} diff --git a/src/modules/NavigationView/i18n/dicts.ts b/src/modules/NavigationView/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/modules/NavigationView/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/NavigationView/i18n/en.json b/src/modules/NavigationView/i18n/en.json new file mode 100644 index 0000000..bb41253 --- /dev/null +++ b/src/modules/NavigationView/i18n/en.json @@ -0,0 +1,3 @@ +{ + "value_empty": "—" +} diff --git a/src/modules/NavigationView/i18n/index.ts b/src/modules/NavigationView/i18n/index.ts new file mode 100644 index 0000000..89116ab --- /dev/null +++ b/src/modules/NavigationView/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:navigation-view', dicts); diff --git a/src/modules/NavigationView/i18n/ru.json b/src/modules/NavigationView/i18n/ru.json new file mode 100644 index 0000000..bb41253 --- /dev/null +++ b/src/modules/NavigationView/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "value_empty": "—" +} diff --git a/src/modules/NavigationView/index.ts b/src/modules/NavigationView/index.ts new file mode 100644 index 0000000..464cc5a --- /dev/null +++ b/src/modules/NavigationView/index.ts @@ -0,0 +1,3 @@ +export {NavigationView} from './NavigationView'; +export type {NavigationViewProps, NavigationViewViewConfig} from './NavigationView'; +export {buildViewColumns} from './helpers/buildViewColumns'; diff --git a/src/modules/NavigationView/internal/NavigationViewSectionItem.tsx b/src/modules/NavigationView/internal/NavigationViewSectionItem.tsx new file mode 100644 index 0000000..9808309 --- /dev/null +++ b/src/modules/NavigationView/internal/NavigationViewSectionItem.tsx @@ -0,0 +1,70 @@ +import React, {useMemo} from 'react'; +import {Disclosure, Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import {type Column, DataTable, NavigationActionButtons} from '../../../components'; +import type {NavigationViewRow, NavigationViewSection} from '../../../types/navigation'; +import {buildViewColumns} from '../helpers/buildViewColumns'; +import i18n from '../i18n'; + +const block = cn('qp-navigation-view'); + +export type NavigationViewSectionItemProps = { + section: NavigationViewSection; + tableColumns?: Array>; + extraColumns?: Array>; +}; + +export function NavigationViewSectionItem({ + section, + tableColumns, + extraColumns, +}: NavigationViewSectionItemProps) { + const resolvedColumns = useMemo(() => { + if (tableColumns) { + return tableColumns; + } + return [...buildViewColumns(section.columns, i18n), ...(extraColumns ?? [])]; + }, [tableColumns, extraColumns, section.columns]); + + return ( + + + {(_props, defaultButton) => ( + + {defaultButton} + + + )} + + + {section.errorContent ? ( + {section.errorContent} + ) : ( + + columns={resolvedColumns} + data={section.rows} + loading={section.loading} + loaded={section.loaded} + settings={{displayIndices: false}} + className={block('table')} + /> + )} + + + ); +} diff --git a/src/modules/NavigationView/story/NavigationView.stories.tsx b/src/modules/NavigationView/story/NavigationView.stories.tsx new file mode 100644 index 0000000..dd38c68 --- /dev/null +++ b/src/modules/NavigationView/story/NavigationView.stories.tsx @@ -0,0 +1,110 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Icon} from '@gravity-ui/uikit'; +import ArrowUpRightFromSquareIcon from '@gravity-ui/icons/svgs/arrow-up-right-from-square.svg'; +import CodeIcon from '@gravity-ui/icons/svgs/code.svg'; +import GearIcon from '@gravity-ui/icons/svgs/gear.svg'; +import PlayIcon from '@gravity-ui/icons/svgs/play.svg'; +import {action} from 'storybook/actions'; +import {NavigationView} from '..'; +import type {NavigationViewSection} from '../../../types/navigation'; +import {VIEW_SECTIONS} from './mockData'; + +const meta: Meta = { + title: 'Modules/NavigationView', + component: NavigationView, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: {sections: VIEW_SECTIONS, loaded: true}, + }, +}; + +export const Loading: Story = { + args: { + data: {sections: [], loading: true}, + }, +}; + +export const Empty: Story = { + args: { + data: {sections: [], loaded: true}, + }, +}; + +export const Error: Story = { + args: { + data: {sections: [], errorContent: 'Failed to load view'}, + }, +}; + +const SECTIONS_WITH_ACTIONS: NavigationViewSection[] = VIEW_SECTIONS.map((section) => ({ + ...section, + actions: [ + { + id: 'export', + title: 'Export', + content: , + onClick: (s) => action('exportClick')(s.id), + }, + { + id: 'code', + title: 'Show code', + content: , + onClick: (s) => action('codeClick')(s.id), + }, + { + id: 'run', + title: 'Run', + content: , + onClick: (s) => action('runClick')(s.id), + }, + { + id: 'settings', + title: 'Settings', + content: , + onClick: (s) => action('settingsClick')(s.id), + }, + ], +})); + +export const WithActions: Story = { + args: { + data: {sections: SECTIONS_WITH_ACTIONS, loaded: true}, + }, +}; + +const ControlledExpandStory = () => { + const [expandedId, setExpandedId] = useState('general'); + + const sections: NavigationViewSection[] = VIEW_SECTIONS.map((section) => ({ + ...section, + expanded: section.id === expandedId, + defaultExpanded: undefined, + onExpandedChange: (expanded) => { + action('onExpandedChange')(section.id, expanded); + setExpandedId(expanded ? section.id : ''); + }, + })); + + return ; +}; + +export const ControlledExpand: Story = { + render: () => , +}; diff --git a/src/modules/NavigationView/story/mockData.ts b/src/modules/NavigationView/story/mockData.ts new file mode 100644 index 0000000..76ba213 --- /dev/null +++ b/src/modules/NavigationView/story/mockData.ts @@ -0,0 +1,35 @@ +import type {NavigationViewSection} from '../../../types/navigation'; + +export const VIEW_COLUMNS = ['Name and Title', 'Type', 'Datacatalog description']; + +export const makeViewRows = (count: number) => + Array.from({length: count}, (_, index) => ({ + 'Name and Title': `field_${index + 1}`, + Type: index % 2 === 0 ? 'string' : 'int64', + 'Datacatalog description': index % 3 === 0 ? 'Primary identifier' : '', + })); + +export const VIEW_SECTIONS: NavigationViewSection[] = [ + { + id: 'general', + title: 'General', + columns: VIEW_COLUMNS, + rows: makeViewRows(4), + loaded: true, + defaultExpanded: true, + }, + { + id: 'attributes', + title: 'Attributes', + columns: VIEW_COLUMNS, + rows: makeViewRows(3), + loaded: true, + }, + { + id: 'computed', + title: 'Computed fields', + columns: VIEW_COLUMNS, + rows: [], + loaded: true, + }, +]; 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..5826a6a 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,17 @@ export {TutorialRow} from './TutorialRow'; export type {TutorialRowProps} from './TutorialRow'; export {TutorialSearchRow} from './TutorialSearchRow'; export type {TutorialSearchRowProps} from './TutorialSearchRow'; +export {ClustersList} from './ClustersList'; +export type {ClustersListProps} from './ClustersList'; +export {NavigationItemsList} from './NavigationItemsList'; +export type {NavigationItemsListProps} 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'; +export {NavigationView, buildViewColumns} from './NavigationView'; +export type {NavigationViewProps, NavigationViewViewConfig} from './NavigationView'; diff --git a/src/types/navigation.ts b/src/types/navigation.ts new file mode 100644 index 0000000..d52dde9 --- /dev/null +++ b/src/types/navigation.ts @@ -0,0 +1,199 @@ +import {ReactNode} from 'react'; +import type {LoadPathSuggestions} from './pathEditor'; + +export type NavigationLocation = { + cluster: string | undefined; + path: string | undefined; +}; + +export type NavigationAction = { + id: string; + title: string; + content: ReactNode; + hidden?: boolean; + disabled?: boolean; + qa?: string; + onClick: (arg: TArg) => void; +}; + +export type NavigationHeaderAction = NavigationAction; + +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 NavigationAsyncConfig = { + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; +}; + +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, +> = NavigationAsyncConfig & { + columns: TColumn[]; +}; + +export type NavigationCellValue = ReactNode; + +export type NavigationPreviewRow = Record; + +export type NavigationPreviewConfig = + NavigationAsyncConfig & { + columns: string[]; + rows: TRow[]; + }; + +export type NavigationViewRow = NavigationPreviewRow; + +export type NavigationViewSectionAction = + NavigationAction>; + +export type NavigationViewSection = + NavigationAsyncConfig & { + id: string; + title: ReactNode; + columns: string[]; + rows: TRow[]; + expanded?: boolean; + defaultExpanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + actions?: Array>; + }; + +export type NavigationViewConfig = + NavigationAsyncConfig & { + sections: Array>; + }; + +export type NavigationMetaItem = { + name: string; + value: NavigationCellValue; + [key: string]: unknown; +}; + +export type NavigationMetaGroup = { + title?: string; + items: TItem[]; +}; + +export type NavigationMetaConfig = + NavigationAsyncConfig & { + groups: Array>; + }; + +export type NavigationDetailConfig = { + tabs: NavigationDetailTab[]; + defaultTab?: string; + hasSearch?: boolean; + searchPlaceholder?: string; + actions?: NavigationHeaderAction[]; + emptyContent?: ReactNode; +}; + +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..a8c7fc4 --- /dev/null +++ b/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx @@ -0,0 +1,400 @@ +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, + NavigationSortOrder, + NavigationViewSection, +} 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 CodeIcon from '@gravity-ui/icons/svgs/code.svg'; +import GearIcon from '@gravity-ui/icons/svgs/gear.svg'; +import {Flex, Icon, Label, Text} from '@gravity-ui/uikit'; +import {ClusterRow, NavigationItemRow} from '../../components'; +import {SCHEMA_COLUMNS} from '../../modules/NavigationSchema/story/mockData'; +import {PREVIEW_COLUMNS, PREVIEW_ROWS} from '../../modules/NavigationPreview/story/mockData'; +import {META_GROUPS} from '../../modules/NavigationMeta/story/mockData'; +import {VIEW_COLUMNS, makeViewRows} from '../../modules/NavigationView/story/mockData'; + +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_VIEW_SECTIONS_WITH_ACTIONS: NavigationViewSection[] = [ + { + id: 'general', + title: 'General', + columns: VIEW_COLUMNS, + rows: makeViewRows(2), + loaded: true, + defaultExpanded: true, + actions: [ + { + id: 'code', + title: 'Show code', + content: , + onClick: (section) => logAction('ViewShowCode', section.id), + }, + { + id: 'settings', + title: 'Settings', + content: , + onClick: (section) => logAction('ViewSettings', section.id), + }, + ], + }, + { + id: 'attributes', + title: 'Attributes', + columns: VIEW_COLUMNS, + rows: makeViewRows(2), + loaded: true, + }, +]; + +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: SCHEMA_COLUMNS, loaded: true}), + resolvePreview: () => ({ + columns: PREVIEW_COLUMNS, + rows: PREVIEW_ROWS, + loaded: true, + }), + resolveMeta: () => ({groups: META_GROUPS, loaded: true}), + resolveView: () => ({sections: TABLE_VIEW_SECTIONS_WITH_ACTIONS, 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.tsx b/src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.tsx new file mode 100644 index 0000000..f8e3831 --- /dev/null +++ b/src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import type {NavigationDetailConfig, NavigationItem} from '../../../types/navigation'; +import {EmptyContent} from '../../../components'; + +export const createEmptyDetailConfig = (_item: NavigationItem): NavigationDetailConfig => ({ + tabs: [], + emptyContent: , +}); 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..7cdc345 --- /dev/null +++ b/src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { + NavigationMeta, + NavigationPreview, + NavigationSchema, + NavigationView, +} from '../../../modules'; +import type { + NavigationDetailConfig, + NavigationDetailConfigFactory, + NavigationItem, + NavigationMetaConfig, + NavigationMetaItem, + NavigationPreviewConfig, + NavigationPreviewRow, + NavigationSchemaColumn, + NavigationSchemaConfig, + NavigationViewConfig, +} from '../../../types/navigation'; +import i18n from '../i18n'; + +export type NavigationSchemaResolver< + TItem extends NavigationItem = NavigationItem, + TColumn extends NavigationSchemaColumn = NavigationSchemaColumn, +> = (item: TItem) => NavigationSchemaConfig | undefined; + +export type NavigationPreviewResolver< + TItem extends NavigationItem = NavigationItem, + TRow extends NavigationPreviewRow = NavigationPreviewRow, +> = (item: TItem) => NavigationPreviewConfig | undefined; + +export type NavigationMetaResolver< + TItem extends NavigationItem = NavigationItem, + TMetaItem extends NavigationMetaItem = NavigationMetaItem, +> = (item: TItem) => NavigationMetaConfig | undefined; + +export type NavigationMetaRenderer = ( + data: NavigationMetaConfig, +) => React.ReactNode; + +export type NavigationViewResolver< + TItem extends NavigationItem = NavigationItem, + TRow extends NavigationPreviewRow = NavigationPreviewRow, +> = (item: TItem) => NavigationViewConfig | undefined; + +export type CreateTableDetailConfigOptions< + TItem extends NavigationItem = NavigationItem, + TColumn extends NavigationSchemaColumn = NavigationSchemaColumn, + TRow extends NavigationPreviewRow = NavigationPreviewRow, + TMetaItem extends NavigationMetaItem = NavigationMetaItem, +> = { + resolveSchema?: NavigationSchemaResolver; + resolvePreview?: NavigationPreviewResolver; + resolveMeta?: NavigationMetaResolver; + renderMeta?: NavigationMetaRenderer; + resolveView?: NavigationViewResolver; +}; + +export const createTableDetailConfig = < + TItem extends NavigationItem = NavigationItem, + TColumn extends NavigationSchemaColumn = NavigationSchemaColumn, + TRow extends NavigationPreviewRow = NavigationPreviewRow, + TMetaItem extends NavigationMetaItem = NavigationMetaItem, +>( + options?: CreateTableDetailConfigOptions, +): NavigationDetailConfigFactory => { + const {resolveSchema, resolvePreview, resolveMeta, renderMeta, resolveView} = options ?? {}; + + return (item): NavigationDetailConfig => ({ + tabs: [ + { + id: 'schema', + title: i18n('tab_schema'), + renderContent: ({search, onSearchUpdate, searchPlaceholder}) => { + const schema = resolveSchema?.(item); + return ( + + data={schema ?? {columns: []}} + search={search} + onSearchUpdate={onSearchUpdate} + searchPlaceholder={searchPlaceholder} + /> + ); + }, + }, + { + id: 'preview', + title: i18n('tab_preview'), + renderContent: ({search, onSearchUpdate, searchPlaceholder}) => { + const preview = resolvePreview?.(item); + return ( + + data={preview ?? {columns: [], rows: []}} + search={search} + onSearchUpdate={onSearchUpdate} + searchPlaceholder={searchPlaceholder} + /> + ); + }, + }, + { + id: 'meta', + title: i18n('tab_meta'), + renderContent: () => { + const meta = resolveMeta?.(item); + return ( + + data={meta ?? {groups: []}} + view={renderMeta ? {render: renderMeta} : undefined} + /> + ); + }, + }, + { + id: 'view', + title: i18n('tab_view'), + renderContent: () => { + const view = resolveView?.(item); + return data={view ?? {sections: []}} />; + }, + }, + ], + 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..3652663 --- /dev/null +++ b/src/widgets/QueriesNavigation/index.ts @@ -0,0 +1,13 @@ +export {QueriesNavigation} from './QueriesNavigation'; +export type {QueriesNavigationProps} from './QueriesNavigation'; +export {createTableDetailConfig} from './helpers/createTableDetailConfig'; +export type { + CreateTableDetailConfigOptions, + NavigationMetaRenderer, + NavigationMetaResolver, + NavigationPreviewResolver, + NavigationSchemaResolver, + NavigationViewResolver, +} 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';