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} + + +