From cc513ceff11279fd944db192935d9f2468f7475c Mon Sep 17 00:00:00 2001 From: Victor Trumpel Date: Fri, 28 Aug 2026 10:42:12 +0300 Subject: [PATCH 01/16] feat: add FileList component --- src/components/FileList/FileList.scss | 25 +++ src/components/FileList/FileList.stories.tsx | 147 ++++++++++++++++++ src/components/FileList/FileList.tsx | 45 ++++++ .../FileList/helpers/getIconByFilePath.ts | 36 +++++ src/components/FileList/index.ts | 2 + .../FileList/internal/FileItem/FileItem.scss | 9 ++ .../FileList/internal/FileItem/FileItem.tsx | 52 +++++++ .../FileList/internal/FileItem/index.ts | 1 + src/components/Icons/LogoCPlusPlus.tsx | 34 ++++ src/components/Icons/TableIcon.tsx | 20 +++ src/components/Icons/index.ts | 2 + src/components/index.ts | 2 + src/index.ts | 1 + 13 files changed, 376 insertions(+) create mode 100644 src/components/FileList/FileList.scss create mode 100644 src/components/FileList/FileList.stories.tsx create mode 100644 src/components/FileList/FileList.tsx create mode 100644 src/components/FileList/helpers/getIconByFilePath.ts create mode 100644 src/components/FileList/index.ts create mode 100644 src/components/FileList/internal/FileItem/FileItem.scss create mode 100644 src/components/FileList/internal/FileItem/FileItem.tsx create mode 100644 src/components/FileList/internal/FileItem/index.ts create mode 100644 src/components/Icons/LogoCPlusPlus.tsx create mode 100644 src/components/Icons/TableIcon.tsx create mode 100644 src/components/Icons/index.ts diff --git a/src/components/FileList/FileList.scss b/src/components/FileList/FileList.scss new file mode 100644 index 0000000..98ae02e --- /dev/null +++ b/src/components/FileList/FileList.scss @@ -0,0 +1,25 @@ +.qp-file-list { + display: flex; + flex-direction: column; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; + + &__item { + min-width: 0; + } + + &__button { + --g-button-padding: var(--g-spacing-2); + + min-width: 0; + justify-content: flex-start; + text-align: start; + + &_active { + --g-button-background-color: var(--g-color-base-selection); + --g-button-background-color-hover: var(--g-color-base-selection-hover); + } + } +} diff --git a/src/components/FileList/FileList.stories.tsx b/src/components/FileList/FileList.stories.tsx new file mode 100644 index 0000000..bb1ee96 --- /dev/null +++ b/src/components/FileList/FileList.stories.tsx @@ -0,0 +1,147 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Text} from '@gravity-ui/uikit'; +import {fn} from 'storybook/test'; + +import {FileList, type FileListProps} from './FileList'; + +const files: FileListProps['files'] = [ + {id: 'readme', name: 'README'}, + {id: 'javascript', name: 'index.js'}, + {id: 'python', name: 'main.py'}, + {id: 'csv', name: 'sales.csv'}, + {id: 'excel', name: 'forecast.xlsx'}, + {id: 'cpp', name: 'processor.cpp'}, + {id: 'ql', name: 'analytics.ql'}, + {id: 'yql', name: 'events.yql'}, + {id: 'sql', name: 'report.sql'}, +]; + +const meta = { + title: 'Components/FileList', + component: FileList, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + files, + onDelete: fn(), + onEdit: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Files with all supported extensions use their corresponding icons. Row actions appear on hover. */ +export const Default: Story = {}; + +/** `addedFilesIds` and `editedFilesIds` highlight files with their current change state. */ +export const ChangeStates: Story = { + args: { + addedFilesIds: ['javascript', 'csv'], + editedFilesIds: ['python', 'sql'], + }, +}; + +/** The inherited List filtering API can be enabled when a consumer needs local file search. */ +export const Filterable: Story = { + args: { + filterable: true, + filterPlaceholder: 'Filter by file name', + filterItem: (filter) => (file) => + (file as FileListProps['files'][number]).name + .toLocaleLowerCase() + .includes(filter.toLocaleLowerCase()), + itemsHeight: 280, + }, +}; + +const InteractiveFileList = (props: FileListProps) => { + const [currentFiles, setCurrentFiles] = useState(props.files); + const [editedFilesIds, setEditedFilesIds] = useState([]); + + return ( + { + props.onDelete?.(file); + setCurrentFiles((current) => current.filter((item) => item.id !== file.id)); + setEditedFilesIds((current) => current.filter((id) => id !== file.id)); + }} + onEdit={(file) => { + props.onEdit?.(file); + setEditedFilesIds((current) => + current.includes(file.id) + ? current.filter((id) => id !== file.id) + : [...current, file.id], + ); + }} + /> + ); +}; + +/** Hover a row: edit toggles its edited state and delete removes it from the list. */ +export const InteractiveActions: Story = { + render: (args) => , +}; + +const SortableFileList = (props: FileListProps) => { + const [orderedFiles, setOrderedFiles] = useState(props.files); + + return ( + { + props.onSortEnd?.({oldIndex, newIndex}); + setOrderedFiles((current) => { + const next = [...current]; + const [movedFile] = next.splice(oldIndex, 1); + + if (movedFile) { + next.splice(newIndex, 0, movedFile); + } + + return next; + }); + }} + /> + ); +}; + +/** Drag files by the handle to reorder them using the inherited List sorting API. */ +export const Sortable: Story = { + args: { + sortable: true, + sortHandleAlign: 'left', + onSortEnd: fn(), + }, + render: (args) => , +}; + +/** Consumer-provided content is displayed when the file array is empty. */ +export const Empty: Story = { + args: { + files: [], + emptyPlaceholder: No files yet, + }, +}; + +/** The inherited loading state can be used while the file collection is being fetched. */ +export const Loading: Story = { + args: { + files: [], + loading: true, + }, +}; diff --git a/src/components/FileList/FileList.tsx b/src/components/FileList/FileList.tsx new file mode 100644 index 0000000..c3b4349 --- /dev/null +++ b/src/components/FileList/FileList.tsx @@ -0,0 +1,45 @@ +import React, {useMemo} from 'react'; +import {Flex, List, ListProps} from '@gravity-ui/uikit'; +import {FileItem, FileItemProps} from './internal/FileItem/FileItem'; +import './FileList.scss'; + +export type FileListProps = Omit & { + files: FileItemProps['file'][]; + className?: string; + onDelete?: (file: FileItemProps['file']) => void; + onEdit?: (file: FileItemProps['file']) => void; + addedFilesIds?: string[]; + editedFilesIds?: string[]; +}; + +export const FileList = ({ + files, + onEdit, + onDelete, + addedFilesIds, + editedFilesIds, + className, + ...listProps +}: FileListProps) => { + const addedIdsSet = useMemo(() => new Set(addedFilesIds), [addedFilesIds]); + const editedIdsSet = useMemo(() => new Set(editedFilesIds), [editedFilesIds]); + + return ( + + + filterable={false} + items={files} + renderItem={(file) => ( + + )} + {...listProps} + /> + + ); +}; diff --git a/src/components/FileList/helpers/getIconByFilePath.ts b/src/components/FileList/helpers/getIconByFilePath.ts new file mode 100644 index 0000000..b9f31d8 --- /dev/null +++ b/src/components/FileList/helpers/getIconByFilePath.ts @@ -0,0 +1,36 @@ +import {AbbrQl, AbbrSql, FileCode, LogoNodejs, LogoPython} from '@gravity-ui/icons'; +import {LogoCPlusPlus, TableIcon} from '../../Icons'; + +const iconMatcher = { + js: LogoNodejs, + ql: AbbrQl, + sql: AbbrSql, + yql: AbbrSql, + py: LogoPython, + csv: TableIcon, + xls: TableIcon, + xlsx: TableIcon, + cpp: LogoCPlusPlus, + unknown: FileCode, +} as const; + +export const getFileExtension = (filePath: string) => { + const name = filePath.split('/').pop(); + + if (name === undefined) return 'unknown'; + + const index = name?.lastIndexOf('.'); + + if (index === undefined) return 'unknown'; + + const extension = index > 0 ? name.slice(index + 1) : ''; + + if (extension in iconMatcher) return extension as keyof typeof iconMatcher; + + return 'unknown'; +}; + +export const getFileIcon = (filePath: string) => { + const extension = getFileExtension(filePath); + return iconMatcher[extension]; +}; diff --git a/src/components/FileList/index.ts b/src/components/FileList/index.ts new file mode 100644 index 0000000..754cd1c --- /dev/null +++ b/src/components/FileList/index.ts @@ -0,0 +1,2 @@ +export {FileList} from './FileList'; +export type {FileListProps} from './FileList'; diff --git a/src/components/FileList/internal/FileItem/FileItem.scss b/src/components/FileList/internal/FileItem/FileItem.scss new file mode 100644 index 0000000..a00d813 --- /dev/null +++ b/src/components/FileList/internal/FileItem/FileItem.scss @@ -0,0 +1,9 @@ +.file-item { + &_isAdded { + background-color: var(--g-color-base-positive-light); + } + + &_isEdit { + background-color: var(--g-color-base-info-light); + } +} \ No newline at end of file diff --git a/src/components/FileList/internal/FileItem/FileItem.tsx b/src/components/FileList/internal/FileItem/FileItem.tsx new file mode 100644 index 0000000..9de5d70 --- /dev/null +++ b/src/components/FileList/internal/FileItem/FileItem.tsx @@ -0,0 +1,52 @@ +import React, {useMemo, useState} from 'react'; +import {Button, Flex, Icon, Text} from '@gravity-ui/uikit'; +import {Pencil, TrashBin} from '@gravity-ui/icons'; +import {getFileIcon} from '../../helpers/getIconByFilePath'; +import cn from 'bem-cn-lite'; +import './FileItem.scss'; + +export type FileItemProps = { + file: {id: string; name: string}; + isEdit?: boolean; + isAdded?: boolean; + onEdit?: (file: FileItemProps['file']) => void; + onDelete?: (file: FileItemProps['file']) => void; +}; + +const block = cn('file-item'); + +export const FileItem = ({file, isAdded, isEdit, onEdit, onDelete}: FileItemProps) => { + const [isHovered, setIsHovered] = useState(false); + + const fileIcon = useMemo(() => getFileIcon(file.name), [file.name]); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + alignItems="center" + justifyContent="space-between" + spacing={{px: 4}} + className={block({isAdded, isEdit})} + > + + + {file.name} + + + {isHovered && ( + + + + + + )} + + ); +}; diff --git a/src/components/FileList/internal/FileItem/index.ts b/src/components/FileList/internal/FileItem/index.ts new file mode 100644 index 0000000..48faf98 --- /dev/null +++ b/src/components/FileList/internal/FileItem/index.ts @@ -0,0 +1 @@ +export {FileItem} from './FileItem'; diff --git a/src/components/Icons/LogoCPlusPlus.tsx b/src/components/Icons/LogoCPlusPlus.tsx new file mode 100644 index 0000000..795f3ba --- /dev/null +++ b/src/components/Icons/LogoCPlusPlus.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +export const LogoCPlusPlus = () => { + return ( + + + + + + + + ); +}; diff --git a/src/components/Icons/TableIcon.tsx b/src/components/Icons/TableIcon.tsx new file mode 100644 index 0000000..20be88f --- /dev/null +++ b/src/components/Icons/TableIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export const TableIcon = () => { + return ( + + + + ); +}; diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts new file mode 100644 index 0000000..9e751be --- /dev/null +++ b/src/components/Icons/index.ts @@ -0,0 +1,2 @@ +export {TableIcon} from './TableIcon'; +export {LogoCPlusPlus} from './LogoCPlusPlus'; diff --git a/src/components/index.ts b/src/components/index.ts index da7698f..8b8aae6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -39,3 +39,5 @@ export { MONACO_THEME_BY_UI, } from './MonacoEditor'; export type {MonacoThemeName} from './MonacoEditor'; +export {FileList} from './FileList'; +export type {FileListProps} from './FileList'; diff --git a/src/index.ts b/src/index.ts index 031bda2..8fef811 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,4 @@ export * from './modules'; export * from './widgets'; export * from './types/history'; export * from './types/tutorial'; +export type {FileListProps} from './types/fileList'; From ec0fe23b811f2921868879f7e9449bf82696d58c Mon Sep 17 00:00:00 2001 From: Victor Trumpel Date: Fri, 28 Aug 2026 13:00:42 +0300 Subject: [PATCH 02/16] feat: add FileListPlaceholder --- src/components/FileList/FileList.stories.tsx | 1 + .../FileList/internal/FileItem/FileItem.tsx | 9 ++- .../FileListPlaceholder.scss | 5 ++ .../FileListPlaceholder.stories.tsx | 30 ++++++++ .../FileListPlaceholder.tsx | 72 +++++++++++++++++++ .../assets/empty-attachments.svg | 14 ++++ .../FileListPlaceholder/i18n/dicts.ts | 4 ++ .../FileListPlaceholder/i18n/en.json | 7 ++ .../FileListPlaceholder/i18n/index.ts | 5 ++ .../FileListPlaceholder/i18n/ru.json | 7 ++ src/components/FileListPlaceholder/index.ts | 2 + src/components/index.ts | 2 + src/index.ts | 2 +- 13 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 src/components/FileListPlaceholder/FileListPlaceholder.scss create mode 100644 src/components/FileListPlaceholder/FileListPlaceholder.stories.tsx create mode 100644 src/components/FileListPlaceholder/FileListPlaceholder.tsx create mode 100644 src/components/FileListPlaceholder/assets/empty-attachments.svg create mode 100644 src/components/FileListPlaceholder/i18n/dicts.ts create mode 100644 src/components/FileListPlaceholder/i18n/en.json create mode 100644 src/components/FileListPlaceholder/i18n/index.ts create mode 100644 src/components/FileListPlaceholder/i18n/ru.json create mode 100644 src/components/FileListPlaceholder/index.ts diff --git a/src/components/FileList/FileList.stories.tsx b/src/components/FileList/FileList.stories.tsx index bb1ee96..5243e76 100644 --- a/src/components/FileList/FileList.stories.tsx +++ b/src/components/FileList/FileList.stories.tsx @@ -15,6 +15,7 @@ const files: FileListProps['files'] = [ {id: 'ql', name: 'analytics.ql'}, {id: 'yql', name: 'events.yql'}, {id: 'sql', name: 'report.sql'}, + {id: 'link', name: 'link/to/file.cpp', isLink: true}, ]; const meta = { diff --git a/src/components/FileList/internal/FileItem/FileItem.tsx b/src/components/FileList/internal/FileItem/FileItem.tsx index 9de5d70..9857691 100644 --- a/src/components/FileList/internal/FileItem/FileItem.tsx +++ b/src/components/FileList/internal/FileItem/FileItem.tsx @@ -1,12 +1,12 @@ import React, {useMemo, useState} from 'react'; import {Button, Flex, Icon, Text} from '@gravity-ui/uikit'; -import {Pencil, TrashBin} from '@gravity-ui/icons'; +import {Link, Pencil, TrashBin} from '@gravity-ui/icons'; import {getFileIcon} from '../../helpers/getIconByFilePath'; import cn from 'bem-cn-lite'; import './FileItem.scss'; export type FileItemProps = { - file: {id: string; name: string}; + file: {id: string; name: string; isLink?: boolean}; isEdit?: boolean; isAdded?: boolean; onEdit?: (file: FileItemProps['file']) => void; @@ -18,7 +18,10 @@ const block = cn('file-item'); export const FileItem = ({file, isAdded, isEdit, onEdit, onDelete}: FileItemProps) => { const [isHovered, setIsHovered] = useState(false); - const fileIcon = useMemo(() => getFileIcon(file.name), [file.name]); + const fileIcon = useMemo(() => { + if (file.isLink) return Link; + return getFileIcon(file.name); + }, [file.name, file.isLink]); return ( ( +
+ +
+ ), + ], + args: { + title: 'No attachments', + description: 'Add attachment for use in the request', + linkForDoc: '#attachments-help', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Empty state with documentation and two secondary actions, matching the Figma example. */ +export const Default: Story = {}; diff --git a/src/components/FileListPlaceholder/FileListPlaceholder.tsx b/src/components/FileListPlaceholder/FileListPlaceholder.tsx new file mode 100644 index 0000000..27f9eaf --- /dev/null +++ b/src/components/FileListPlaceholder/FileListPlaceholder.tsx @@ -0,0 +1,72 @@ +import React, {ReactNode} from 'react'; +import {Button, Flex, Icon, Link, Text} from '@gravity-ui/uikit'; +import {Plus} from '@gravity-ui/icons'; +import cn from 'bem-cn-lite'; +import image from './assets/empty-attachments.svg'; +import i18n from './i18n'; +import './FileListPlaceholder.scss'; + +export type FileListPlaceholderProps = { + title?: ReactNode; + description?: ReactNode; + linkForDoc?: string; + linkText?: string; + className?: string; + onAddFile?: () => void; + onAddLink?: () => void; + qa?: string; +}; + +const block = cn('qp-placeholder-container'); + +export const FileListPlaceholder = ({ + title: customerTitle, + description: customerDescription, + linkForDoc, + linkText: customerLinkText, + className, + onAddFile, + onAddLink, + qa, +}: FileListPlaceholderProps) => { + const title = customerTitle ?? i18n('title_no-attachments'); + const description = customerDescription ?? i18n('context_add-attachment-for-request'); + const linkText = customerLinkText ?? i18n('action_attached-files-help'); + + return ( + + empty-files-image + + + {title} + {description} + {linkForDoc && ( + + {linkText} + + )} + + + + + + + + + ); +}; diff --git a/src/components/FileListPlaceholder/assets/empty-attachments.svg b/src/components/FileListPlaceholder/assets/empty-attachments.svg new file mode 100644 index 0000000..185c03b --- /dev/null +++ b/src/components/FileListPlaceholder/assets/empty-attachments.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/components/FileListPlaceholder/i18n/dicts.ts b/src/components/FileListPlaceholder/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/components/FileListPlaceholder/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/FileListPlaceholder/i18n/en.json b/src/components/FileListPlaceholder/i18n/en.json new file mode 100644 index 0000000..4dc7e14 --- /dev/null +++ b/src/components/FileListPlaceholder/i18n/en.json @@ -0,0 +1,7 @@ +{ + "title_no-attachments": "No attachments", + "context_add-attachment-for-request": "Add attachment for use in the request", + "action_attached-files-help": "How to work with attached files", + "action_add-file": "Add file", + "action_add-link": "Add Link" +} diff --git a/src/components/FileListPlaceholder/i18n/index.ts b/src/components/FileListPlaceholder/i18n/index.ts new file mode 100644 index 0000000..ff7cac3 --- /dev/null +++ b/src/components/FileListPlaceholder/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; + +import dicts from './dicts'; + +export default addI18Keysets('qp:file-list-placeholder', dicts); diff --git a/src/components/FileListPlaceholder/i18n/ru.json b/src/components/FileListPlaceholder/i18n/ru.json new file mode 100644 index 0000000..4e4d24e --- /dev/null +++ b/src/components/FileListPlaceholder/i18n/ru.json @@ -0,0 +1,7 @@ +{ + "title_no-attachments": "Нет вложений", + "context_add-attachment-for-request": "Добавьте вложение, чтобы использовать его в запросе", + "action_attached-files-help": "Как работать с прикреплёнными файлами", + "action_add-file": "Добавить файл", + "action_add-link": "Добавить ссылку" +} diff --git a/src/components/FileListPlaceholder/index.ts b/src/components/FileListPlaceholder/index.ts new file mode 100644 index 0000000..f38cc79 --- /dev/null +++ b/src/components/FileListPlaceholder/index.ts @@ -0,0 +1,2 @@ +export {FileListPlaceholder} from './FileListPlaceholder'; +export type {FileListPlaceholderProps} from './FileListPlaceholder'; diff --git a/src/components/index.ts b/src/components/index.ts index 8b8aae6..482f309 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -41,3 +41,5 @@ export { export type {MonacoThemeName} from './MonacoEditor'; export {FileList} from './FileList'; export type {FileListProps} from './FileList'; +export {PlaceholderContainer} from './FileListPlaceholder'; +export type {PlaceholderContainerProps} from './FileListPlaceholder'; diff --git a/src/index.ts b/src/index.ts index 8fef811..b75b11c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,4 @@ export * from './modules'; export * from './widgets'; export * from './types/history'; export * from './types/tutorial'; -export type {FileListProps} from './types/fileList'; +export type {PlaceholderContainerProps} from './types/placeholderContainer'; From b04db06cfe16531a43944a52fa6512d35a5d5894 Mon Sep 17 00:00:00 2001 From: Victor Trumpel Date: Fri, 28 Aug 2026 21:23:00 +0300 Subject: [PATCH 03/16] feat: add possible edit file and links names --- .../AttachmentList.scss} | 2 +- .../AttachmentList/AttachmentList.stories.tsx | 243 ++++++++++++++++++ .../AttachmentList/AttachmentList.tsx | 60 +++++ .../helpers/getIconByAttachmentName.ts} | 18 +- src/components/AttachmentList/index.ts | 4 + .../AttachmentItem/AttachmentItem.scss | 23 ++ .../AttachmentItem/AttachmentItem.tsx | 63 +++++ .../internal/AttachmentItem/index.ts | 2 + .../EditAttachmentItem/EditAttachmentItem.tsx | 21 ++ .../EditAttachmentItem/EditLinkItem.tsx | 102 ++++++++ .../internal/EditAttachmentItem/FileEdit.tsx | 48 ++++ .../internal/EditAttachmentItem/index.ts | 4 + .../AttachmentListPlaceholder.scss} | 2 +- .../AttachmentListPlaceholder.stories.tsx} | 8 +- .../AttachmentListPlaceholder.tsx} | 14 +- .../assets/empty-attachments.svg | 0 .../i18n/dicts.ts | 0 .../i18n/en.json | 2 +- .../i18n/index.ts | 2 +- .../i18n/ru.json | 2 +- .../AttachmentListPlaceholder/index.ts | 2 + src/components/FileList/FileList.stories.tsx | 148 ----------- src/components/FileList/FileList.tsx | 45 ---- src/components/FileList/index.ts | 2 - .../FileList/internal/FileItem/FileItem.scss | 9 - .../FileList/internal/FileItem/FileItem.tsx | 55 ---- .../FileList/internal/FileItem/index.ts | 1 - src/components/FileListPlaceholder/index.ts | 2 - src/components/Icons/TableIcon.tsx | 20 -- src/components/Icons/index.ts | 1 - src/components/index.ts | 8 +- src/index.ts | 1 - 32 files changed, 601 insertions(+), 313 deletions(-) rename src/components/{FileList/FileList.scss => AttachmentList/AttachmentList.scss} (95%) create mode 100644 src/components/AttachmentList/AttachmentList.stories.tsx create mode 100644 src/components/AttachmentList/AttachmentList.tsx rename src/components/{FileList/helpers/getIconByFilePath.ts => AttachmentList/helpers/getIconByAttachmentName.ts} (53%) create mode 100644 src/components/AttachmentList/index.ts create mode 100644 src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.scss create mode 100644 src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.tsx create mode 100644 src/components/AttachmentList/internal/AttachmentItem/index.ts create mode 100644 src/components/AttachmentList/internal/EditAttachmentItem/EditAttachmentItem.tsx create mode 100644 src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.tsx create mode 100644 src/components/AttachmentList/internal/EditAttachmentItem/FileEdit.tsx create mode 100644 src/components/AttachmentList/internal/EditAttachmentItem/index.ts rename src/components/{FileListPlaceholder/FileListPlaceholder.scss => AttachmentListPlaceholder/AttachmentListPlaceholder.scss} (66%) rename src/components/{FileListPlaceholder/FileListPlaceholder.stories.tsx => AttachmentListPlaceholder/AttachmentListPlaceholder.stories.tsx} (75%) rename src/components/{FileListPlaceholder/FileListPlaceholder.tsx => AttachmentListPlaceholder/AttachmentListPlaceholder.tsx} (83%) rename src/components/{FileListPlaceholder => AttachmentListPlaceholder}/assets/empty-attachments.svg (100%) rename src/components/{FileListPlaceholder => AttachmentListPlaceholder}/i18n/dicts.ts (100%) rename src/components/{FileListPlaceholder => AttachmentListPlaceholder}/i18n/en.json (74%) rename src/components/{FileListPlaceholder => AttachmentListPlaceholder}/i18n/index.ts (51%) rename src/components/{FileListPlaceholder => AttachmentListPlaceholder}/i18n/ru.json (73%) create mode 100644 src/components/AttachmentListPlaceholder/index.ts delete mode 100644 src/components/FileList/FileList.stories.tsx delete mode 100644 src/components/FileList/FileList.tsx delete mode 100644 src/components/FileList/index.ts delete mode 100644 src/components/FileList/internal/FileItem/FileItem.scss delete mode 100644 src/components/FileList/internal/FileItem/FileItem.tsx delete mode 100644 src/components/FileList/internal/FileItem/index.ts delete mode 100644 src/components/FileListPlaceholder/index.ts delete mode 100644 src/components/Icons/TableIcon.tsx diff --git a/src/components/FileList/FileList.scss b/src/components/AttachmentList/AttachmentList.scss similarity index 95% rename from src/components/FileList/FileList.scss rename to src/components/AttachmentList/AttachmentList.scss index 98ae02e..1571bd4 100644 --- a/src/components/FileList/FileList.scss +++ b/src/components/AttachmentList/AttachmentList.scss @@ -1,4 +1,4 @@ -.qp-file-list { +.qp-attachment-list { display: flex; flex-direction: column; min-width: 0; diff --git a/src/components/AttachmentList/AttachmentList.stories.tsx b/src/components/AttachmentList/AttachmentList.stories.tsx new file mode 100644 index 0000000..0f5791f --- /dev/null +++ b/src/components/AttachmentList/AttachmentList.stories.tsx @@ -0,0 +1,243 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Text} from '@gravity-ui/uikit'; +import {EditAttachmentItem, EditLinkValues} from './internal/EditAttachmentItem'; +import {fn} from 'storybook/test'; + +import {AttachmentList, type AttachmentListProps} from './AttachmentList'; + +const attachments: AttachmentListProps['attachments'] = [ + {id: 'readme', name: 'README'}, + {id: 'javascript', name: 'index.js'}, + {id: 'python', name: 'main.py'}, + {id: 'csv', name: 'sales.csv'}, + {id: 'link-image', name: 'https://home/tutorial', link: 'https://home/tutorial'}, + {id: 'excel', name: 'forecast.xlsx'}, + {id: 'cpp', name: 'processor.cpp'}, + {id: 'ql', name: 'analytics.ql'}, + {id: 'yql', name: 'events.yql'}, + {id: 'sql', name: 'report.sql'}, + {id: 'link', name: 'link to file.cpp', link: 'link/to/file.cpp'}, +]; + +const meta = { + title: 'Components/AttachmentList', + component: AttachmentList, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + attachments, + onDelete: fn(), + onEdit: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Attachments with supported extensions use their corresponding icons. Row actions appear on hover. */ +export const Default: Story = {}; + +/** `wasAddedIds` and `wasEditedIds` highlight attachments with their current change state. */ +export const ChangeStates: Story = { + args: { + wasAddedIds: ['javascript', 'csv'], + wasEditedIds: ['python', 'sql'], + }, +}; + +/** The inherited List filtering API can be enabled when a consumer needs local attachment search. */ +export const Filterable: Story = { + args: { + filterable: true, + filterPlaceholder: 'Filter by attachment name', + filterItem: (filter) => (attachment) => + (attachment as AttachmentListProps['attachments'][number]).name + .toLocaleLowerCase() + .includes(filter.toLocaleLowerCase()), + }, +}; + +const InteractiveAttachmentList = (props: AttachmentListProps) => { + const [currentAttachments, setCurrentAttachments] = useState(props.attachments); + const [editedIds, setEditedIds] = useState([]); + + return ( + { + props.onDelete?.(attachment); + setCurrentAttachments((current) => + current.filter((item) => item.id !== attachment.id), + ); + setEditedIds((current) => current.filter((id) => id !== attachment.id)); + }} + onEdit={(attachment) => { + props.onEdit?.(attachment); + setEditedIds((current) => + current.includes(attachment.id) + ? current.filter((id) => id !== attachment.id) + : [...current, attachment.id], + ); + }} + /> + ); +}; + +/** Hover a row: edit toggles its edited state and delete removes it from the list. */ +export const InteractiveActions: Story = { + render: (args) => , +}; + +const EditableAttachmentList = (props: AttachmentListProps) => { + const [currentAttachments, setCurrentAttachments] = useState(props.attachments); + const [editingId, setEditingId] = useState(); + const [draftName, setDraftName] = useState(''); + const [draftLink, setDraftLink] = useState({ + link: '', + token: '', + name: '', + }); + + const handleAcceptDraft = () => { + setCurrentAttachments((prevState) => { + return prevState.map((attach) => { + if (attach.id === editingId && attach.link === undefined) { + return { + ...attach, + name: draftName, + }; + } + if (attach.id === editingId && typeof attach.link === 'string') { + return { + ...attach, + name: draftLink.name, + link: draftLink.link, + }; + } + return attach; + }); + }); + setDraftName(''); + setEditingId(''); + }; + + return ( + + setCurrentAttachments((list) => list.filter((l) => l.id !== attach.id)) + } + onEdit={(attachment) => { + props.onEdit?.(attachment); + setEditingId(attachment.id); + setDraftName(attachment.name); + if (typeof attachment.link === 'string') { + setDraftLink({...draftLink, name: attachment.name, link: attachment.link}); + } + }} + renderEditForm={(attachment) => { + const isLink = typeof attachment.link === 'string'; + + if (isLink) { + return ( + setEditingId('')} + onAccept={handleAcceptDraft} + values={draftLink} + onChange={setDraftLink} + tokens={[ + {value: 'default_database_logs', title: 'default_database_logs'}, + {value: 'default_yt', title: 'default_yt'}, + {value: 'default_yql', title: 'default_yql'}, + { + value: 'default_long_token_title', + title: 'default_long_token_title', + }, + ]} + type={'link'} + /> + ); + } + + return ( + setEditingId('')} + onAccept={handleAcceptDraft} + type={'file'} + /> + ); + }} + /> + ); +}; + +/** Hover a row and click edit to replace it with a consumer-provided rename form. */ +export const CustomEditForm: Story = { + render: (args) => , +}; + +const SortableAttachmentList = (props: AttachmentListProps) => { + const [orderedAttachments, setOrderedAttachments] = useState(props.attachments); + + return ( + { + props.onSortEnd?.({oldIndex, newIndex}); + setOrderedAttachments((current) => { + const next = [...current]; + const [movedAttachment] = next.splice(oldIndex, 1); + + if (movedAttachment) { + next.splice(newIndex, 0, movedAttachment); + } + + return next; + }); + }} + /> + ); +}; + +/** Drag attachments by the handle to reorder them using the inherited List sorting API. */ +export const Sortable: Story = { + args: { + sortable: true, + sortHandleAlign: 'left', + onSortEnd: fn(), + }, + render: (args) => , +}; + +/** Consumer-provided content is displayed when the attachment array is empty. */ +export const Empty: Story = { + args: { + attachments: [], + emptyPlaceholder: No attachments yet, + }, +}; + +/** The inherited loading state can be used while the attachment collection is being fetched. */ +export const Loading: Story = { + args: { + attachments: [], + loading: true, + }, +}; diff --git a/src/components/AttachmentList/AttachmentList.tsx b/src/components/AttachmentList/AttachmentList.tsx new file mode 100644 index 0000000..7609ce2 --- /dev/null +++ b/src/components/AttachmentList/AttachmentList.tsx @@ -0,0 +1,60 @@ +import React, {ReactNode, useMemo} from 'react'; +import {Flex, List, ListProps} from '@gravity-ui/uikit'; +import {AttachmentItem, AttachmentItemProps} from './internal/AttachmentItem/AttachmentItem'; +import './AttachmentList.scss'; + +export type AttachmentListProps = Omit & { + attachments: AttachmentItemProps['attachment'][]; + className?: string; + onDelete?: (attachment: AttachmentItemProps['attachment']) => void; + onEdit?: (attachment: AttachmentItemProps['attachment']) => void; + + renderEditForm?: (attachment: AttachmentItemProps['attachment']) => ReactNode; + + wasAddedIds?: string[]; + wasEditedIds?: string[]; + + editingIds?: string[]; +}; + +export const AttachmentList = ({ + attachments, + onEdit, + onDelete, + wasAddedIds, + wasEditedIds, + editingIds, + renderEditForm, + className, + ...listProps +}: AttachmentListProps) => { + const addedIdsSet = useMemo(() => new Set(wasAddedIds), [wasAddedIds]); + const editedIdsSet = useMemo(() => new Set(wasEditedIds), [wasEditedIds]); + const editingIdsSet = useMemo(() => new Set(editingIds), [editingIds]); + + return ( + + + filterable={false} + virtualized={false} + items={attachments} + renderItem={(attachment) => { + if (editingIdsSet.has(attachment.id)) { + return renderEditForm?.(attachment); + } + + return ( + + ); + }} + {...listProps} + /> + + ); +}; diff --git a/src/components/FileList/helpers/getIconByFilePath.ts b/src/components/AttachmentList/helpers/getIconByAttachmentName.ts similarity index 53% rename from src/components/FileList/helpers/getIconByFilePath.ts rename to src/components/AttachmentList/helpers/getIconByAttachmentName.ts index b9f31d8..ed45528 100644 --- a/src/components/FileList/helpers/getIconByFilePath.ts +++ b/src/components/AttachmentList/helpers/getIconByAttachmentName.ts @@ -1,5 +1,5 @@ -import {AbbrQl, AbbrSql, FileCode, LogoNodejs, LogoPython} from '@gravity-ui/icons'; -import {LogoCPlusPlus, TableIcon} from '../../Icons'; +import {AbbrQl, AbbrSql, FileCode, LayoutList, LogoNodejs, LogoPython} from '@gravity-ui/icons'; +import {LogoCPlusPlus} from '../../Icons'; const iconMatcher = { js: LogoNodejs, @@ -7,15 +7,15 @@ const iconMatcher = { sql: AbbrSql, yql: AbbrSql, py: LogoPython, - csv: TableIcon, - xls: TableIcon, - xlsx: TableIcon, + csv: LayoutList, + xls: LayoutList, + xlsx: LayoutList, cpp: LogoCPlusPlus, unknown: FileCode, } as const; -export const getFileExtension = (filePath: string) => { - const name = filePath.split('/').pop(); +export const getAttachmentExtension = (attachmentName: string) => { + const name = attachmentName.split('/').pop(); if (name === undefined) return 'unknown'; @@ -30,7 +30,7 @@ export const getFileExtension = (filePath: string) => { return 'unknown'; }; -export const getFileIcon = (filePath: string) => { - const extension = getFileExtension(filePath); +export const getAttachmentIcon = (attachmentName: string) => { + const extension = getAttachmentExtension(attachmentName); return iconMatcher[extension]; }; diff --git a/src/components/AttachmentList/index.ts b/src/components/AttachmentList/index.ts new file mode 100644 index 0000000..c700d6f --- /dev/null +++ b/src/components/AttachmentList/index.ts @@ -0,0 +1,4 @@ +export {AttachmentList} from './AttachmentList'; +export type {AttachmentListProps} from './AttachmentList'; +export * from './internal/EditAttachmentItem'; +export * from './internal/AttachmentItem'; diff --git a/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.scss b/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.scss new file mode 100644 index 0000000..8c88b1b --- /dev/null +++ b/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.scss @@ -0,0 +1,23 @@ +.attachment-item { + &__info { + flex: 1; + min-width: 0; + } + + &__actions { + flex-shrink: 0; + } + + &__attachment-name { + flex: 1; + min-width: 0; + } + + &_wasAdded { + background-color: var(--g-color-base-positive-light); + } + + &_wasEdited { + background-color: var(--g-color-base-info-light); + } +} diff --git a/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.tsx b/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.tsx new file mode 100644 index 0000000..1fbf228 --- /dev/null +++ b/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.tsx @@ -0,0 +1,63 @@ +import React, {useMemo, useState} from 'react'; +import {Button, Flex, Icon, Text} from '@gravity-ui/uikit'; +import {Link, Pencil, TrashBin} from '@gravity-ui/icons'; +import {getAttachmentIcon} from '../../helpers/getIconByAttachmentName'; +import cn from 'bem-cn-lite'; +import './AttachmentItem.scss'; + +export type AttachmentItemProps = { + attachment: {id: string; name: string; link?: string}; + wasEdited?: boolean; + wasAdded?: boolean; + onEdit?: (attachment: AttachmentItemProps['attachment']) => void; + onDelete?: (attachment: AttachmentItemProps['attachment']) => void; +}; + +const block = cn('attachment-item'); + +export const AttachmentItem = ({ + attachment, + wasAdded, + wasEdited, + onEdit, + onDelete, +}: AttachmentItemProps) => { + const [isHovered, setIsHovered] = useState(false); + + const attachmentIcon = useMemo(() => { + if (typeof attachment.link === 'string') return Link; + return getAttachmentIcon(attachment.name); + }, [attachment.name, attachment.link]); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + alignItems="center" + justifyContent="space-between" + spacing={{px: 4}} + className={block({wasEdited, wasAdded})} + > + + + + {attachment.name} + + + + {isHovered && ( + + + + + + )} + + ); +}; diff --git a/src/components/AttachmentList/internal/AttachmentItem/index.ts b/src/components/AttachmentList/internal/AttachmentItem/index.ts new file mode 100644 index 0000000..cdc133a --- /dev/null +++ b/src/components/AttachmentList/internal/AttachmentItem/index.ts @@ -0,0 +1,2 @@ +export {AttachmentItem} from './AttachmentItem'; +export type {AttachmentItemProps} from './AttachmentItem'; diff --git a/src/components/AttachmentList/internal/EditAttachmentItem/EditAttachmentItem.tsx b/src/components/AttachmentList/internal/EditAttachmentItem/EditAttachmentItem.tsx new file mode 100644 index 0000000..23fe0f8 --- /dev/null +++ b/src/components/AttachmentList/internal/EditAttachmentItem/EditAttachmentItem.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import {FileEdit} from './FileEdit'; +import {EditLinkItem} from './EditLinkItem'; +import type {EditFileItemProps} from './FileEdit'; +import type {EditLinkItemProps} from './EditLinkItem'; + +export type EditAttachmentItemProps = + | (EditFileItemProps & { + type: 'file'; + }) + | (EditLinkItemProps & { + type: 'link'; + }); + +export const EditAttachmentItem = ({...props}: EditAttachmentItemProps) => { + if (props.type === 'file') { + return ; + } + + return ; +}; diff --git a/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.tsx b/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.tsx new file mode 100644 index 0000000..9a42cf5 --- /dev/null +++ b/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.tsx @@ -0,0 +1,102 @@ +import React, {useState} from 'react'; +import {Button, Flex, Icon, Select, TextArea, TextInput} from '@gravity-ui/uikit'; +import {Check, Xmark} from '@gravity-ui/icons'; + +export type EditLinkValues = { + link: string; + token: string; + name: string; +}; + +export type EditLinkItemProps = { + linkLabel?: string; + tokenLabel?: string; + nameLabel?: string; + onAccept?: (values: EditLinkValues) => void; + onCancel?: () => void; + onChange?: (pathedValues: EditLinkValues) => void; + tokens?: {value: string; title: string}[]; + values?: EditLinkValues; +}; + +export const EditLinkItem = ({ + linkLabel: cutomerLinkLabel, + tokenLabel: customerTokenLabel, + nameLabel: customeNameLabel, + onAccept, + onCancel, + onChange, + tokens, + values: customerValues, +}: EditLinkItemProps) => { + const [innerValues, setInnerValues] = useState({ + link: '', + token: '', + name: '', + ...customerValues, + }); + + const linkLabel = cutomerLinkLabel ?? 'Link:'; + const tokenLabel = customerTokenLabel ?? 'Token:'; + const nameLabel = customeNameLabel ?? 'Name:'; + + const values = customerValues ?? innerValues; + + const handleUpdate = (key: string, patcValue: string) => { + const newValues: EditLinkValues = {...values, [key]: patcValue}; + + setInnerValues(newValues); + onChange?.(newValues); + }; + + const handleAccept = () => { + onAccept?.(values); + }; + + return ( + +