diff --git a/src/components/AttachmentList/AttachmentList.scss b/src/components/AttachmentList/AttachmentList.scss
new file mode 100644
index 0000000..1571bd4
--- /dev/null
+++ b/src/components/AttachmentList/AttachmentList.scss
@@ -0,0 +1,25 @@
+.qp-attachment-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/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..a7063f9
--- /dev/null
+++ b/src/components/AttachmentList/AttachmentList.tsx
@@ -0,0 +1,67 @@
+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;
+ onRevert?: (attachment: AttachmentItemProps['attachment']) => void;
+
+ renderEditForm?: (attachment: AttachmentItemProps['attachment']) => ReactNode;
+
+ wasAddedIds?: string[];
+ wasEditedIds?: string[];
+
+ editingIds?: string[];
+
+ isDeleted?: boolean;
+};
+
+export const AttachmentList = ({
+ attachments,
+ onEdit,
+ onDelete,
+ onRevert,
+ wasAddedIds,
+ wasEditedIds,
+ editingIds,
+ renderEditForm,
+ className,
+ isDeleted,
+ ...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/AttachmentList/helpers/getIconByAttachmentName.ts b/src/components/AttachmentList/helpers/getIconByAttachmentName.ts
new file mode 100644
index 0000000..ed45528
--- /dev/null
+++ b/src/components/AttachmentList/helpers/getIconByAttachmentName.ts
@@ -0,0 +1,36 @@
+import {AbbrQl, AbbrSql, FileCode, LayoutList, LogoNodejs, LogoPython} from '@gravity-ui/icons';
+import {LogoCPlusPlus} from '../../Icons';
+
+const iconMatcher = {
+ js: LogoNodejs,
+ ql: AbbrQl,
+ sql: AbbrSql,
+ yql: AbbrSql,
+ py: LogoPython,
+ csv: LayoutList,
+ xls: LayoutList,
+ xlsx: LayoutList,
+ cpp: LogoCPlusPlus,
+ unknown: FileCode,
+} as const;
+
+export const getAttachmentExtension = (attachmentName: string) => {
+ const name = attachmentName.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 getAttachmentIcon = (attachmentName: string) => {
+ const extension = getAttachmentExtension(attachmentName);
+ return iconMatcher[extension];
+};
diff --git a/src/components/AttachmentList/helpers/objectLinkValidator.ts b/src/components/AttachmentList/helpers/objectLinkValidator.ts
new file mode 100644
index 0000000..80a27ac
--- /dev/null
+++ b/src/components/AttachmentList/helpers/objectLinkValidator.ts
@@ -0,0 +1,40 @@
+import {stringRequiredValidator} from './stringRequiredValidator';
+
+export type EditLinkErrors = Partial<{
+ link: string;
+ token: string;
+ name: string;
+}>;
+
+export type ObjectLinkValidator = (
+ values: Record,
+ isTokenRequired?: boolean,
+) => EditLinkErrors | null;
+
+export const objectLinkValidator = (values: Record, isTokenRequired?: boolean) => {
+ const patchErrors: Partial = {};
+
+ const linkError = stringRequiredValidator(values.link);
+
+ if (linkError) {
+ patchErrors['link'] = linkError;
+ }
+
+ const tokenError = isTokenRequired ? stringRequiredValidator(values.token) : null;
+
+ if (tokenError) {
+ patchErrors['token'] = tokenError;
+ }
+
+ const nameError = stringRequiredValidator(values.name);
+
+ if (nameError) {
+ patchErrors['name'] = nameError;
+ }
+
+ if (Object.keys(patchErrors).length === 0) {
+ return null;
+ }
+
+ return patchErrors;
+};
diff --git a/src/components/AttachmentList/helpers/stringRequiredValidator.ts b/src/components/AttachmentList/helpers/stringRequiredValidator.ts
new file mode 100644
index 0000000..2314d17
--- /dev/null
+++ b/src/components/AttachmentList/helpers/stringRequiredValidator.ts
@@ -0,0 +1,24 @@
+import i18n from '../i18n';
+
+const DEFAULT_MAX_LENGTH = 256;
+
+export type Validator = (value: unknown) => string | null;
+
+export const stringRequiredValidator: Validator = (
+ value: unknown,
+ maxLength: number = DEFAULT_MAX_LENGTH,
+) => {
+ if (typeof value !== 'string') {
+ return i18n('alert_value-must-be-string');
+ }
+
+ if (!value.trim()) {
+ return i18n('alert_required-field');
+ }
+
+ if (value.length > maxLength) {
+ return i18n('alert_max-length', {maxLength});
+ }
+
+ return null;
+};
diff --git a/src/components/AttachmentList/hooks/useKeyDownFormControl.ts b/src/components/AttachmentList/hooks/useKeyDownFormControl.ts
new file mode 100644
index 0000000..0c027e6
--- /dev/null
+++ b/src/components/AttachmentList/hooks/useKeyDownFormControl.ts
@@ -0,0 +1,22 @@
+export const useKeyDownFormControl = (onAccept: () => void, onCancel: () => void) => {
+ const handleInputKeyDown = (
+ event: React.KeyboardEvent,
+ ) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ onAccept();
+ }
+ };
+
+ const handleEditorKeyDown = (event: React.KeyboardEvent<'div'>) => {
+ if (event.key !== 'Escape' || event.nativeEvent.isComposing) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ onCancel?.();
+ };
+
+ return {handleInputKeyDown, handleEditorKeyDown};
+};
diff --git a/src/components/AttachmentList/hooks/useLabelRef.ts b/src/components/AttachmentList/hooks/useLabelRef.ts
new file mode 100644
index 0000000..6539739
--- /dev/null
+++ b/src/components/AttachmentList/hooks/useLabelRef.ts
@@ -0,0 +1,25 @@
+import {useLayoutEffect, useRef, useState} from 'react';
+
+export const useLabelRef = (labelText: string) => {
+ const labelRef = useRef(null);
+ const [labelWidth, setLabelWidth] = useState(0);
+
+ useLayoutEffect(() => {
+ const label = labelRef.current;
+
+ if (!label) return undefined;
+
+ const updateLabelWidth = () => setLabelWidth(label.offsetWidth);
+
+ updateLabelWidth();
+
+ if (typeof ResizeObserver === 'undefined') return undefined;
+
+ const observer = new ResizeObserver(updateLabelWidth);
+ observer.observe(label);
+
+ return () => observer.disconnect();
+ }, [labelText]);
+
+ return {labelRef, labelWidth};
+};
diff --git a/src/components/AttachmentList/i18n/dicts.ts b/src/components/AttachmentList/i18n/dicts.ts
new file mode 100644
index 0000000..4fa3a86
--- /dev/null
+++ b/src/components/AttachmentList/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/AttachmentList/i18n/en.json b/src/components/AttachmentList/i18n/en.json
new file mode 100644
index 0000000..b7da976
--- /dev/null
+++ b/src/components/AttachmentList/i18n/en.json
@@ -0,0 +1,10 @@
+{
+ "field_link": "Link:",
+ "field_token": "Token:",
+ "field_name": "Name:",
+ "action_save": "Save",
+ "action_cancel": "Cancel",
+ "alert_value-must-be-string": "Value must be a string",
+ "alert_required-field": "Required field",
+ "alert_max-length": "Maximum length is {{maxLength}}"
+}
diff --git a/src/components/AttachmentList/i18n/index.ts b/src/components/AttachmentList/i18n/index.ts
new file mode 100644
index 0000000..5f57eef
--- /dev/null
+++ b/src/components/AttachmentList/i18n/index.ts
@@ -0,0 +1,5 @@
+import {addI18Keysets} from '../../../i18n';
+
+import dicts from './dicts';
+
+export default addI18Keysets('qp:attachment-list', dicts);
diff --git a/src/components/AttachmentList/i18n/ru.json b/src/components/AttachmentList/i18n/ru.json
new file mode 100644
index 0000000..55845d1
--- /dev/null
+++ b/src/components/AttachmentList/i18n/ru.json
@@ -0,0 +1,10 @@
+{
+ "field_link": "Ссылка:",
+ "field_token": "Токен:",
+ "field_name": "Имя:",
+ "action_save": "Сохранить",
+ "action_cancel": "Отменить",
+ "alert_value-must-be-string": "Значение должно быть строкой",
+ "alert_required-field": "Обязательное поле",
+ "alert_max-length": "Максимальная длина — {{maxLength}}"
+}
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..2dbf48e
--- /dev/null
+++ b/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.scss
@@ -0,0 +1,23 @@
+.qp-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..e9b80eb
--- /dev/null
+++ b/src/components/AttachmentList/internal/AttachmentItem/AttachmentItem.tsx
@@ -0,0 +1,75 @@
+import React, {useMemo, useState} from 'react';
+import {Button, Flex, Icon, Text} from '@gravity-ui/uikit';
+import {ArrowRotateLeft, 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;
+ onRevert?: (attachment: AttachmentItemProps['attachment']) => void;
+ isDeleted?: boolean;
+};
+
+const block = cn('qp-attachment-item');
+
+export const AttachmentItem = ({
+ attachment,
+ wasAdded,
+ wasEdited,
+ onEdit,
+ onDelete,
+ onRevert,
+ isDeleted,
+}: 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 && !isDeleted && (
+
+
+
+
+
+ )}
+
+ {isHovered && isDeleted && (
+
+
+
+ )}
+
+ );
+};
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.scss b/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.scss
new file mode 100644
index 0000000..266603a
--- /dev/null
+++ b/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.scss
@@ -0,0 +1,21 @@
+.edit-link-item {
+ &__link-field-container {
+ padding: 1px;
+ }
+
+ &__link-field-label {
+ position: absolute;
+ z-index: 1;
+ inset-block-start: calc(var(--g-spacing-1) + 1px);
+ inset-inline-start: var(--g-spacing-2);
+ font-weight: 600;
+ }
+
+ .g-select {
+ padding: 1px;
+ }
+
+ .g-text-input {
+ padding: 1px;
+ }
+}
\ No newline at end of file
diff --git a/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.tsx b/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.tsx
new file mode 100644
index 0000000..d74ca01
--- /dev/null
+++ b/src/components/AttachmentList/internal/EditAttachmentItem/EditLinkItem.tsx
@@ -0,0 +1,194 @@
+import React, {useId, useRef, useState} from 'react';
+import {Box, Button, Flex, Icon, Select, Text, TextArea, TextInput} from '@gravity-ui/uikit';
+import {Check, Xmark} from '@gravity-ui/icons';
+import {useLabelRef} from '../../hooks/useLabelRef';
+import {useKeyDownFormControl} from '../../hooks/useKeyDownFormControl';
+import {ObjectLinkValidator, objectLinkValidator} from '../../helpers/objectLinkValidator';
+import cn from 'bem-cn-lite';
+
+import i18n from '../../i18n';
+
+import './EditLinkItem.scss';
+
+export type EditLinkValues = {
+ link: string;
+ token: string;
+ name: string;
+};
+
+type EditLinkErrors = {
+ 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;
+ defaultValues?: EditLinkValues;
+ validator?: ObjectLinkValidator;
+};
+
+const block = cn('edit-link-item');
+
+export const EditLinkItem = ({
+ linkLabel: cutomerLinkLabel,
+ tokenLabel: customerTokenLabel,
+ nameLabel: customeNameLabel,
+ onAccept,
+ onCancel,
+ onChange,
+ tokens,
+ values: customerValues,
+ validator = objectLinkValidator,
+ defaultValues,
+}: EditLinkItemProps) => {
+ const [innerValues, setInnerValues] = useState({
+ link: '',
+ token: '',
+ name: '',
+ ...customerValues,
+ ...(defaultValues ?? {}),
+ });
+
+ const [errors, setErrors] = useState | null>(null);
+
+ const linkInputId = useId();
+
+ const linkLabel = cutomerLinkLabel ?? i18n('field_link');
+ const tokenLabel = customerTokenLabel ?? i18n('field_token');
+ const nameLabel = customeNameLabel ?? i18n('field_name');
+
+ const values = customerValues ?? innerValues;
+
+ const {labelRef, labelWidth} = useLabelRef(linkLabel);
+
+ const wasSubmittedRef = useRef(false);
+
+ const isTokenRequired = tokens && tokens?.length > 0;
+
+ const handleUpdate = (key: string, patcValue: string) => {
+ const newValues: EditLinkValues = {...values, [key]: patcValue};
+
+ if (errors || wasSubmittedRef.current) {
+ setErrors(validator(newValues, isTokenRequired));
+ }
+
+ setInnerValues(newValues);
+ onChange?.(newValues);
+ };
+
+ const handleAccept = () => {
+ const validResult = validator(values, isTokenRequired);
+
+ wasSubmittedRef.current = true;
+
+ if (validResult) {
+ setErrors(validResult);
+ return;
+ }
+
+ onAccept?.(values);
+ };
+
+ const handleCancel = () => {
+ onCancel?.();
+ };
+
+ const handleLinkScroll = (event: React.UIEvent) => {
+ if (labelRef.current) {
+ labelRef.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
+ }
+ };
+
+ const {handleEditorKeyDown, handleInputKeyDown} = useKeyDownFormControl(
+ handleAccept,
+ handleCancel,
+ );
+
+ return (
+
+
+
+ {linkLabel}
+
+
+
+
+ {tokens && (
+
+ )}
+
+ handleUpdate('name', v)}
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/AttachmentList/internal/EditAttachmentItem/FileEdit.tsx b/src/components/AttachmentList/internal/EditAttachmentItem/FileEdit.tsx
new file mode 100644
index 0000000..9be9cfd
--- /dev/null
+++ b/src/components/AttachmentList/internal/EditAttachmentItem/FileEdit.tsx
@@ -0,0 +1,92 @@
+import React, {useRef, useState} from 'react';
+import {Button, Flex, Icon, TextInput} from '@gravity-ui/uikit';
+import {Check, Xmark} from '@gravity-ui/icons';
+import {useKeyDownFormControl} from '../../hooks/useKeyDownFormControl';
+import {Validator, stringRequiredValidator} from '../../helpers/stringRequiredValidator';
+
+import i18n from '../../i18n';
+
+export type EditFileItemProps = {
+ fileName?: string;
+ fileLabel?: string;
+ defaultFileName?: string;
+ onChangeFileName?: (fileName: string) => void;
+ onAccept?: (newFileName: string) => void;
+ onCancel?: () => void;
+ validator?: Validator;
+};
+
+export const FileEdit = ({
+ fileLabel: customerFileLabel,
+ fileName: customerFileName,
+ defaultFileName,
+ onChangeFileName,
+ onAccept,
+ onCancel,
+ validator = stringRequiredValidator,
+}: EditFileItemProps) => {
+ const [innerFileName, setInnerFileName] = useState(
+ defaultFileName ?? customerFileName ?? '',
+ );
+
+ const [errorMsg, setErrorMsg] = useState(null);
+
+ const wasSubmittedRef = useRef(false);
+
+ const label = customerFileLabel ?? i18n('field_name');
+
+ const fileName = customerFileName ?? innerFileName;
+
+ const handleChangeFileName = (patchFileName: string) => {
+ if (errorMsg || wasSubmittedRef.current) {
+ setErrorMsg(validator(patchFileName));
+ }
+
+ setInnerFileName(patchFileName);
+ onChangeFileName?.(patchFileName);
+ };
+
+ const handleAccept = () => {
+ const error = validator(fileName);
+
+ wasSubmittedRef.current = true;
+
+ if (error) {
+ setErrorMsg(error);
+ return;
+ }
+
+ onAccept?.(fileName);
+ };
+
+ const handleCancel = () => {
+ onCancel?.();
+ };
+
+ const {handleEditorKeyDown, handleInputKeyDown} = useKeyDownFormControl(
+ handleAccept,
+ handleCancel,
+ );
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/AttachmentList/internal/EditAttachmentItem/index.ts b/src/components/AttachmentList/internal/EditAttachmentItem/index.ts
new file mode 100644
index 0000000..0b70d0e
--- /dev/null
+++ b/src/components/AttachmentList/internal/EditAttachmentItem/index.ts
@@ -0,0 +1,4 @@
+export {EditAttachmentItem} from './EditAttachmentItem';
+export type {EditAttachmentItemProps} from './EditAttachmentItem';
+export type {EditLinkItemProps, EditLinkValues} from './EditLinkItem';
+export type {EditFileItemProps} from './FileEdit';
diff --git a/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.scss b/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.scss
new file mode 100644
index 0000000..5eaa412
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.scss
@@ -0,0 +1,5 @@
+.qp-attachment-list-placeholder {
+ &__doc-link {
+ margin-top: var(--g-spacing-2);
+ }
+}
diff --git a/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.stories.tsx b/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.stories.tsx
new file mode 100644
index 0000000..3617523
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.stories.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import {AttachmentListPlaceholder} from './AttachmentListPlaceholder';
+import type {Meta, StoryObj} from '@storybook/react';
+
+const meta = {
+ title: 'Components/AttachmentListPlaceholder',
+ component: AttachmentListPlaceholder,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ 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/AttachmentListPlaceholder/AttachmentListPlaceholder.tsx b/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.tsx
new file mode 100644
index 0000000..7f6ae78
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/AttachmentListPlaceholder.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 './AttachmentListPlaceholder.scss';
+
+export type AttachmentListPlaceholderProps = {
+ title?: ReactNode;
+ description?: ReactNode;
+ linkForDoc?: string;
+ linkText?: string;
+ className?: string;
+ onAddFile?: () => void;
+ onAddLink?: () => void;
+ qa?: string;
+};
+
+const block = cn('qp-attachment-list-placeholder');
+
+export const AttachmentListPlaceholder = ({
+ title: customerTitle,
+ description: customerDescription,
+ linkForDoc,
+ linkText: customerLinkText,
+ className,
+ onAddFile,
+ onAddLink,
+ qa,
+}: AttachmentListPlaceholderProps) => {
+ const title = customerTitle ?? i18n('title_no-attachments');
+ const description = customerDescription ?? i18n('context_add-attachment-for-request');
+ const linkText = customerLinkText ?? i18n('action_attachments-help');
+
+ return (
+
+
+
+
+ {title}
+ {description}
+ {linkForDoc && (
+
+ {linkText}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/AttachmentListPlaceholder/assets/empty-attachments.svg b/src/components/AttachmentListPlaceholder/assets/empty-attachments.svg
new file mode 100644
index 0000000..185c03b
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/assets/empty-attachments.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/components/AttachmentListPlaceholder/i18n/dicts.ts b/src/components/AttachmentListPlaceholder/i18n/dicts.ts
new file mode 100644
index 0000000..4fa3a86
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/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/AttachmentListPlaceholder/i18n/en.json b/src/components/AttachmentListPlaceholder/i18n/en.json
new file mode 100644
index 0000000..a8f2b0a
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/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_attachments-help": "How to work with attachments",
+ "action_add-file": "Add file",
+ "action_add-link": "Add Link"
+}
diff --git a/src/components/AttachmentListPlaceholder/i18n/index.ts b/src/components/AttachmentListPlaceholder/i18n/index.ts
new file mode 100644
index 0000000..fc78007
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/i18n/index.ts
@@ -0,0 +1,5 @@
+import {addI18Keysets} from '../../../i18n';
+
+import dicts from './dicts';
+
+export default addI18Keysets('qp:attachment-list-placeholder', dicts);
diff --git a/src/components/AttachmentListPlaceholder/i18n/ru.json b/src/components/AttachmentListPlaceholder/i18n/ru.json
new file mode 100644
index 0000000..e05ca7b
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/i18n/ru.json
@@ -0,0 +1,7 @@
+{
+ "title_no-attachments": "Нет вложений",
+ "context_add-attachment-for-request": "Добавьте вложение, чтобы использовать его в запросе",
+ "action_attachments-help": "Как работать с вложениями",
+ "action_add-file": "Добавить файл",
+ "action_add-link": "Добавить ссылку"
+}
diff --git a/src/components/AttachmentListPlaceholder/index.ts b/src/components/AttachmentListPlaceholder/index.ts
new file mode 100644
index 0000000..74fcd5f
--- /dev/null
+++ b/src/components/AttachmentListPlaceholder/index.ts
@@ -0,0 +1,2 @@
+export {AttachmentListPlaceholder} from './AttachmentListPlaceholder';
+export type {AttachmentListPlaceholderProps} from './AttachmentListPlaceholder';
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/index.ts b/src/components/Icons/index.ts
new file mode 100644
index 0000000..a6c1593
--- /dev/null
+++ b/src/components/Icons/index.ts
@@ -0,0 +1 @@
+export {LogoCPlusPlus} from './LogoCPlusPlus';
diff --git a/src/components/index.ts b/src/components/index.ts
index da7698f..02541ed 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 * from './AttachmentList';
+export * from './AttachmentListPlaceholder';
diff --git a/src/helpers/createUuid.ts b/src/helpers/createUuid.ts
new file mode 100644
index 0000000..3d4b957
--- /dev/null
+++ b/src/helpers/createUuid.ts
@@ -0,0 +1,3 @@
+export const createUuid = () => {
+ return crypto.randomUUID().replace(/-/g, '');
+};
diff --git a/src/helpers/isObjectEqual.ts b/src/helpers/isObjectEqual.ts
new file mode 100644
index 0000000..d45dc9e
--- /dev/null
+++ b/src/helpers/isObjectEqual.ts
@@ -0,0 +1,15 @@
+export const isObjectEqual = (obj1: Record, obj2: Record) => {
+ const keys1 = Object.keys(obj1);
+ const keys2 = Object.keys(obj2);
+
+ const compareKeys = keys1.length > keys2.length ? keys1 : keys2;
+
+ for (const key of compareKeys) {
+ const v1 = obj1[key];
+ const v2 = obj2[key];
+
+ if (v1 !== v2) return false;
+ }
+
+ return true;
+};
diff --git a/src/modules/Attachments/Attachments.scss b/src/modules/Attachments/Attachments.scss
new file mode 100644
index 0000000..342d6a9
--- /dev/null
+++ b/src/modules/Attachments/Attachments.scss
@@ -0,0 +1,10 @@
+.qp-attachments {
+ &__panel {
+ flex: 1;
+ }
+
+ &__tab .g-tab__title {
+ display: flex;
+ gap: var(--g-spacing-2)
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Attachments/Attachments.stories.tsx b/src/modules/Attachments/Attachments.stories.tsx
new file mode 100644
index 0000000..71523ba
--- /dev/null
+++ b/src/modules/Attachments/Attachments.stories.tsx
@@ -0,0 +1,112 @@
+import React, {useState} from 'react';
+import type {Meta, StoryObj} from '@storybook/react';
+import {fn} from 'storybook/test';
+import {Attachments} from './Attachments';
+
+type AttachmentsProps = React.ComponentProps;
+type Attachment = NonNullable[number];
+
+const attachments: Attachment[] = [
+ {id: 'readme', name: 'README.md'},
+ {id: 'query', name: 'daily-report.sql'},
+ {
+ id: 'documentation',
+ name: 'Query documentation',
+ link: 'https://example.com/docs/query',
+ token: 'documentation-token',
+ },
+];
+
+const deletedAttachments: Attachment[] = [
+ {id: 'archive', name: 'archive.csv'},
+ {
+ id: 'old-dashboard',
+ name: 'Old dashboard',
+ link: 'https://example.com/dashboards/old',
+ token: 'dashboard-token',
+ },
+];
+
+const tokens: NonNullable = [
+ {value: 'documentation-token', title: 'Documentation token'},
+ {value: 'dashboard-token', title: 'Dashboard token'},
+];
+
+const meta = {
+ title: 'Modules/Attachments',
+ component: Attachments,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {
+ tokens,
+ onChange: fn(),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Empty state with content configured through `placeholderProps`. */
+export const Default: Story = {
+ args: {
+ placeholderProps: {
+ title: 'No attachments',
+ description: 'Add files or links that should be available to the query.',
+ linkForDoc: '#attachments-help',
+ linkText: 'About attachments',
+ },
+ },
+};
+
+/** Initial files and links supplied through the uncontrolled `attachments` API. */
+export const WithAttachments: Story = {
+ args: {
+ attachments,
+ },
+};
+
+/** Current and deleted collections. Open the Deleted tab to restore an attachment. */
+export const WithDeletedAttachments: Story = {
+ args: {
+ attachments,
+ deletedAttachments,
+ },
+};
+
+const ControlledAttachments = (props: AttachmentsProps) => {
+ const [currentAttachments, setCurrentAttachments] = useState(props.attachments ?? []);
+ const [currentDeletedAttachments, setCurrentDeletedAttachments] = useState(
+ props.deletedAttachments ?? [],
+ );
+
+ return (
+ {
+ setCurrentAttachments(payload.attachments);
+ setCurrentDeletedAttachments(payload.deletedAttachments);
+ props.onChange?.(payload);
+ }}
+ />
+ );
+};
+
+/** Controlled usage: every edit, delete, restore, or addition is applied from `onChange`. */
+export const Controlled: Story = {
+ args: {
+ attachments,
+ deletedAttachments,
+ },
+ render: (args) => ,
+};
diff --git a/src/modules/Attachments/Attachments.tsx b/src/modules/Attachments/Attachments.tsx
new file mode 100644
index 0000000..028ffbe
--- /dev/null
+++ b/src/modules/Attachments/Attachments.tsx
@@ -0,0 +1,336 @@
+import React, {useMemo, useRef, useState} from 'react';
+import {Button, Flex, Icon, Tab, TabList, TabPanel, TabProvider, Text} from '@gravity-ui/uikit';
+import {AttachmentList, AttachmentListPlaceholder, EditAttachmentItem} from '../../components';
+import {createUuid} from '../../helpers/createUuid';
+import {isObjectEqual} from '../../helpers/isObjectEqual';
+import {Plus} from '@gravity-ui/icons';
+import type {
+ AttachmentItemProps,
+ AttachmentListPlaceholderProps,
+ AttachmentListProps,
+ EditLinkValues,
+} from '../../components';
+import cn from 'bem-cn-lite';
+
+import i18n from './i18n';
+
+import './Attachments.scss';
+
+type TabVariants = 'Current' | 'Deleted';
+
+export type AttachItem = AttachmentItemProps['attachment'] & {token?: string; isNew?: boolean};
+
+export type AttachmentsProps = {
+ tokens?: {value: string; title: string}[];
+ placeholderProps?: Omit;
+ attachmentListProps?: Pick;
+ attachments?: AttachItem[];
+ deletedAttachments?: AttachItem[];
+ onChange?: (payload: {attachments: AttachItem[]; deletedAttachments: AttachItem[]}) => void;
+};
+
+const block = cn('qp-attachments');
+
+export const Attachments = ({
+ tokens,
+ placeholderProps,
+ attachmentListProps,
+ onChange,
+ attachments: customerAttachments,
+ deletedAttachments: customerDeletedAttachments,
+}: AttachmentsProps) => {
+ const [currentTab, setCurrentTab] = useState<'Current' | 'Deleted'>('Current');
+
+ const [innerAttachList, setInnerAttachList] = useState(customerAttachments ?? []);
+ const [innerDeletedAttachList, setInnerDeletedAttachList] = useState(
+ customerDeletedAttachments ?? [],
+ );
+
+ const [editingIds, setEditingIds] = useState([]);
+ const [wasAddedIds, setWasAddedIds] = useState([]);
+ const [wasEdited, setWasEditedIds] = useState([]);
+
+ const attachList = customerAttachments ?? innerAttachList;
+ const deletedAttachList = customerDeletedAttachments ?? innerDeletedAttachList;
+
+ const attachCount = useMemo(() => attachList.filter((a) => !a.isNew).length, [attachList]);
+
+ const defaultAttachList = useRef(attachList);
+
+ const getDefaultValuesForLink = (linkId: string) => {
+ const currentLink = attachList.find((a) => a.id === linkId);
+ return {
+ name: currentLink?.name ?? '',
+ link: currentLink?.link ?? '',
+ token: currentLink?.token ?? '',
+ };
+ };
+
+ const runAfterAttachAdded = (attachId: string, newAttach: AttachItem) => {
+ const editedAttach = defaultAttachList.current.find((a) => a.id === attachId);
+
+ if (!editedAttach) return;
+
+ if (isObjectEqual(editedAttach, newAttach)) {
+ setWasEditedIds(wasEdited.filter((id) => id !== attachId));
+ return;
+ }
+
+ setWasEditedIds([...wasEdited, attachId]);
+ };
+
+ const handleAddEmptyFile = () => {
+ const id = createUuid();
+
+ const patchAttachList = [
+ ...attachList.filter((a) => !a.isNew),
+ {id, name: '', isNew: true},
+ ];
+
+ setInnerAttachList(patchAttachList);
+ setWasAddedIds([...wasAddedIds, id]);
+ setEditingIds([...editingIds, id]);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: deletedAttachList,
+ });
+ };
+
+ const handleAddEmptyLink = () => {
+ const id = createUuid();
+
+ const patchAttachList = [
+ ...attachList.filter((a) => !a.isNew),
+ {id, name: '', token: '', link: '', isNew: true},
+ ];
+
+ setInnerAttachList(patchAttachList);
+ setWasAddedIds([...wasAddedIds, id]);
+ setEditingIds([...editingIds, id]);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: deletedAttachList,
+ });
+ };
+
+ const handleAcceptFile = (fileId: string, fileName: string) => {
+ const patchedFile = attachList.find((a) => a.id === fileId);
+ if (!patchedFile) return;
+
+ const patchEditingIds = editingIds.filter((id) => id !== fileId);
+ const patchAttachList = attachList.map((attach) => {
+ if (attach.id === fileId) return {id: fileId, name: fileName};
+ return attach;
+ });
+
+ setEditingIds(patchEditingIds);
+ setInnerAttachList(patchAttachList);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: deletedAttachList,
+ });
+
+ runAfterAttachAdded(fileId, {id: fileId, name: fileName});
+ };
+
+ const handleAcceptLink = (linkId: string, link: EditLinkValues) => {
+ const patchedLink = attachList.find((a) => a.id === linkId);
+ if (!patchedLink) return;
+
+ const patchEditingIds = editingIds.filter((id) => id !== linkId);
+ const patchAttachList = attachList.map((attach) => {
+ if (attach.id === linkId) return {id: linkId, ...link};
+ return attach;
+ });
+
+ setEditingIds(patchEditingIds);
+ setInnerAttachList(patchAttachList);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: deletedAttachList,
+ });
+
+ runAfterAttachAdded(linkId, {id: linkId, ...link});
+ };
+
+ const handleCancelEdit = (fileId: string) => {
+ const patchEditingIds = editingIds.filter((id) => id !== fileId);
+ const patchAttachList = attachList.filter((a) => !a.isNew);
+
+ const wasItNewAttach = attachList.some((a) => a.isNew);
+ if (wasItNewAttach) {
+ setWasAddedIds(wasAddedIds.filter((id) => id !== fileId));
+ }
+
+ setInnerAttachList(patchAttachList);
+ setEditingIds(patchEditingIds);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: deletedAttachList,
+ });
+ };
+
+ const handleEdit = (editAttachId: string) => {
+ const patchEditingIds = [...editingIds, editAttachId];
+
+ setEditingIds(patchEditingIds);
+ };
+
+ const handleDelete = (attachId: string) => {
+ const deletedAttachmen = attachList.find((a) => a.id === attachId);
+
+ if (!deletedAttachmen) return;
+
+ const patchAttachList = attachList.filter((a) => a.id !== attachId);
+ const patchDeletedAttachList = [...deletedAttachList, deletedAttachmen];
+
+ setInnerAttachList(patchAttachList);
+ setInnerDeletedAttachList(patchDeletedAttachList);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: patchDeletedAttachList,
+ });
+ };
+
+ const handleDeleteAll = () => {
+ const patchAttachList: never[] = [];
+ const patchDeletedAttachList = [
+ ...deletedAttachList,
+ ...attachList.filter((a) => !a.isNew),
+ ];
+
+ setInnerAttachList(patchAttachList);
+ setInnerDeletedAttachList(patchDeletedAttachList);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: patchDeletedAttachList,
+ });
+ };
+
+ const handleRevertDelete = (attachId: string) => {
+ const revertAttach = deletedAttachList.find((a) => a.id === attachId);
+
+ if (!revertAttach) return;
+
+ const patchAttachList = [...attachList, revertAttach];
+ const patchDeletedAttachList = deletedAttachList.filter((a) => a.id !== attachId);
+
+ setInnerAttachList(patchAttachList);
+ setInnerDeletedAttachList(patchDeletedAttachList);
+
+ onChange?.({
+ attachments: patchAttachList,
+ deletedAttachments: patchDeletedAttachList,
+ });
+ };
+
+ return (
+
+ setCurrentTab(tab as TabVariants)}>
+
+
+ Current
+
+ {attachCount}
+
+
+
+ Deleted
+
+ {deletedAttachList.length}
+
+
+
+
+
+
+ {attachList.length ? (
+ handleEdit(attach.id)}
+ onDelete={(attach) => handleDelete(attach.id)}
+ renderEditForm={(attach) => {
+ const isLink = typeof attach.link === 'string';
+
+ if (isLink) {
+ const defaultLinkValues = getDefaultValuesForLink(
+ attach.id,
+ );
+
+ return (
+
+ handleAcceptLink(attach.id, link)
+ }
+ defaultValues={defaultLinkValues}
+ tokens={tokens}
+ onCancel={() => handleCancelEdit(attach.id)}
+ />
+ );
+ }
+
+ return (
+
+ handleAcceptFile(attach.id, pathName)
+ }
+ onCancel={() => handleCancelEdit(attach.id)}
+ />
+ );
+ }}
+ />
+ ) : (
+
+ )}
+ {Boolean(attachList.length) && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ handleRevertDelete(attach.id)}
+ />
+
+
+
+
+ );
+};
diff --git a/src/modules/Attachments/i18n/dicts.ts b/src/modules/Attachments/i18n/dicts.ts
new file mode 100644
index 0000000..4fa3a86
--- /dev/null
+++ b/src/modules/Attachments/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/Attachments/i18n/en.json b/src/modules/Attachments/i18n/en.json
new file mode 100644
index 0000000..d8d0cc4
--- /dev/null
+++ b/src/modules/Attachments/i18n/en.json
@@ -0,0 +1,5 @@
+{
+ "action_add-file": "File",
+ "action_add-link": "Link",
+ "action_remove-all": "Remove all"
+}
diff --git a/src/modules/Attachments/i18n/index.ts b/src/modules/Attachments/i18n/index.ts
new file mode 100644
index 0000000..7cd7b38
--- /dev/null
+++ b/src/modules/Attachments/i18n/index.ts
@@ -0,0 +1,5 @@
+import {addI18Keysets} from '../../../i18n';
+
+import dicts from './dicts';
+
+export default addI18Keysets('qp:attachments', dicts);
diff --git a/src/modules/Attachments/i18n/ru.json b/src/modules/Attachments/i18n/ru.json
new file mode 100644
index 0000000..e03c725
--- /dev/null
+++ b/src/modules/Attachments/i18n/ru.json
@@ -0,0 +1,5 @@
+{
+ "action_add-file": "Файл",
+ "action_add-link": "Ссылка",
+ "action_remove-all": "Удалить все"
+}
diff --git a/src/modules/Attachments/index.ts b/src/modules/Attachments/index.ts
new file mode 100644
index 0000000..3e8d4e0
--- /dev/null
+++ b/src/modules/Attachments/index.ts
@@ -0,0 +1,2 @@
+export {Attachments} from './Attachments';
+export type {AttachmentsProps, AttachItem} from './Attachments';
diff --git a/src/modules/index.ts b/src/modules/index.ts
index fe7e587..336b272 100644
--- a/src/modules/index.ts
+++ b/src/modules/index.ts
@@ -8,6 +8,7 @@ export {HistoryList} from './HistoryList';
export type {HistoryListProps} from './HistoryList';
export {HistorySearchRow} from './HistorySearchRow';
export * from './ChartEditor';
+export * from './Attachments';
export {TutorialRow} from './TutorialRow';
export type {TutorialRowProps} from './TutorialRow';
export {TutorialSearchRow} from './TutorialSearchRow';