diff --git a/packages/ui/src/ui/components/ButtonPopup/ButtonPopup.scss b/packages/ui/src/ui/components/ButtonPopup/ButtonPopup.scss new file mode 100644 index 000000000..867f99dc4 --- /dev/null +++ b/packages/ui/src/ui/components/ButtonPopup/ButtonPopup.scss @@ -0,0 +1,9 @@ +.button-popup { + min-width: 620px; + padding: 16px 20px; + + &__top-row { + display: flex; + justify-content: space-between; + } +} diff --git a/packages/ui/src/ui/components/ButtonPopup/index.tsx b/packages/ui/src/ui/components/ButtonPopup/index.tsx new file mode 100644 index 000000000..fbe7c8b1c --- /dev/null +++ b/packages/ui/src/ui/components/ButtonPopup/index.tsx @@ -0,0 +1,45 @@ +import React, {useRef, useState} from 'react'; +import {Button, Icon, IconData, Popup, Text} from '@gravity-ui/uikit'; +import closeIcon from '@gravity-ui/icons/svgs/xmark.svg'; + +import cn from 'bem-cn-lite'; +import './ButtonPopup.scss'; + +const b = cn('button-popup'); + +type ButtonPopupProps = { + icon: IconData; + header: React.ReactNode; + children: React.ReactNode; + counter?: number; +}; + +export const ButtonWithPopup = ({icon, header, children, counter}: ButtonPopupProps) => { + const btnRef = useRef(null); + const [open, setOpen] = useState(false); + const toggle = () => setOpen(!open); + return ( + <> + + + {counter ? {counter} : undefined} + + + + + {header} + + + + + {children} + + + > + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx b/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx index fcd35bece..1f823020c 100644 --- a/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx +++ b/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx @@ -14,7 +14,7 @@ import { isQueryExecuted, isQueryLoading, } from '../module/query/selectors'; -import {SET_QUERY_PATCH, runQuery} from '../module/query/actions'; +import {runQuery, updateQueryDraft} from '../module/query/actions'; import FlexSplitPane from '../../../components/FlexSplitPane/FlexSplitPane'; import {QueryResults} from '../QueryResults'; import SquareIcon from '@gravity-ui/icons/svgs/square.svg'; @@ -106,7 +106,7 @@ const QueryEditorView = React.memo(function QueryEditorView({ const dispatch = useDispatch(); const upadteQueryText = useCallback( function (text: string) { - dispatch({type: SET_QUERY_PATCH, data: {query: text, error: undefined}}); + dispatch(updateQueryDraft({query: text, error: undefined})); }, [dispatch], ); diff --git a/packages/ui/src/ui/pages/query-tracker/QueryFilesButton/QueryFilesButton.scss b/packages/ui/src/ui/pages/query-tracker/QueryFilesButton/QueryFilesButton.scss new file mode 100644 index 000000000..63a6a31a9 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/QueryFilesButton/QueryFilesButton.scss @@ -0,0 +1,49 @@ +.query-files-popup { + width: 640px; + &__header { + display: flex; + align-items: center; + gap: 5px; + } + &__footer { + display: flex; + gap: 10px; + } + + &__file-list { + display: flex; + flex-direction: column; + gap: 10px; + padding-bottom: 10px; + } + + &__file-item { + padding: 10px 0; + width: 100%; + height: 48px; + display: grid; + grid-template-columns: 1fr min-content; + align-items: center; + gap: 10px; + &-icon { + min-width: fit-content; + } + &-body,&-controls { + min-width: 0; + height: 100%; + display: flex; + align-items: center; + gap: 10px; + } + &-body_edit { + align-items: flex-start; + } + &-controls_edit { + align-items: flex-start; + padding-top: 5px; + } + &-body_deleted>:last-child { + flex-shrink: 0; + } + } +} diff --git a/packages/ui/src/ui/pages/query-tracker/QueryFilesButton/index.tsx b/packages/ui/src/ui/pages/query-tracker/QueryFilesButton/index.tsx new file mode 100644 index 000000000..f1fee9be6 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/QueryFilesButton/index.tsx @@ -0,0 +1,433 @@ +import React, { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import cn from 'bem-cn-lite'; +import {QueryFile} from '../module/api'; +import { + Button, + Icon, + IconData, + Link, + List, + Tabs, + TabsItemProps, + Text, + TextInput, +} from '@gravity-ui/uikit'; +import linkIcon from '@gravity-ui/icons/svgs/link.svg'; +import fileIcon from '@gravity-ui/icons/svgs/file.svg'; +import pencilIcon from '@gravity-ui/icons/svgs/pencil.svg'; +import trashIcon from '@gravity-ui/icons/svgs/trash-bin.svg'; +import clipIcon from '@gravity-ui/icons/svgs/paperclip.svg'; +import plusIcon from '@gravity-ui/icons/svgs/plus.svg'; +import checkIcon from '@gravity-ui/icons/svgs/check.svg'; +import closeIcon from '@gravity-ui/icons/svgs/xmark.svg'; +import restoreIcon from '@gravity-ui/icons/svgs/arrow-rotate-left.svg'; +import uploadIcon from '@gravity-ui/icons/svgs/arrow-up-from-square.svg'; +import {ButtonWithPopup} from '../../../components/ButtonPopup'; + +import './QueryFilesButton.scss'; + +const b = cn('query-files-popup'); + +type QueryFilesState = ReturnType; + +const fileIsNew = (file: QueryFile) => [!file.name, !file.content].some(Boolean); + +const useQueryFiles = ( + files: QueryFile[], + queryId: string, + updateFiles: (files: QueryFile[]) => void, +) => { + const [deletedFiles, setDeletedFiles] = useState([]); + useEffect(() => setDeletedFiles([]), [queryId]); + const [filesMap, setFilesMap] = useState(new Map()); + const hasNewFiles = useMemo(() => files.some(fileIsNew), [files]); + useEffect(() => setFilesMap(new Map(files.map((f) => [f.name, f]))), [files]); + const upsertFile = useCallback( + (file: QueryFile, updating?: QueryFile) => { + const existing = filesMap.get(file.name); + if (existing && existing !== updating) { + return 'This name is already in use'; + } + const getFilesToUpdate = () => { + if (!updating) { + return [...files, file]; + } + + const index = files.indexOf(updating); + return [...files.slice(0, index), file, ...files.slice(index + 1)]; + }; + updateFiles(getFilesToUpdate()); + return; + }, + [files, filesMap, updateFiles], + ); + const createFile = useCallback( + (fileType: QueryFile['type']) => { + if (hasNewFiles) { + return; + } + upsertFile({type: fileType, name: '', content: ''}); + }, + [upsertFile, hasNewFiles], + ); + const discardFile = useCallback( + (file: QueryFile) => updateFiles(files.filter((f) => f !== file)), + [files], + ); + const removeFile = useCallback( + (file: QueryFile) => { + setDeletedFiles([...deletedFiles, file]); + discardFile(file); + }, + [discardFile, deletedFiles], + ); + const restoreFile = useCallback( + (file: QueryFile) => { + const result = upsertFile(file); + if (!result) { + setDeletedFiles([...deletedFiles.filter((f) => f !== file)]); + } + return result; + }, + [upsertFile, deletedFiles], + ); + return { + files, + deletedFiles, + createFile, + upsertFile, + removeFile, + restoreFile, + discardFile, + }; +}; + +const QueryFilesContext = createContext({} as QueryFilesState); + +type FileItemWrapperProps = { + body: React.ReactNode; + controls: React.ReactNode; + customModifier?: string; + link?: string; +}; + +const FileItemWrapper = ({body, controls, customModifier, link}: FileItemWrapperProps) => { + const bodyElement = ( + + {body} + + ); + return ( + + {link ? ( + + {bodyElement} + + ) : ( + bodyElement + )} + + {controls} + + + ); +}; + +const ControlButton = ({ + icon, + disabled, + action, +}: { + icon: IconData; + disabled?: boolean; + action: () => void; +}) => ( + + + +); + +const FileTypeIcon = ({file}: {file: QueryFile}) => ( + +); + +const DeletedFileItem = ({file}: {file: QueryFile}) => { + const [error, setError] = useState(''); + const textColor = useMemo(() => (error ? 'danger' : undefined), [error]); + const {restoreFile} = useContext(QueryFilesContext); + const tryRestore = useCallback(() => { + setError(restoreFile(file) ?? ''); + }, [file, restoreFile]); + return ( + + + + {file.name} + + {error} + > + } + controls={} + link={file.type === 'url' ? file.content : undefined} + customModifier="deleted" + /> + ); +}; + +const ViewFileItem = ({file, toggleEdit}: {file: QueryFile; toggleEdit: () => void}) => { + const {removeFile} = useContext(QueryFilesContext); + const remove = useCallback(() => removeFile(file), [file, removeFile]); + return ( + + + {file.name} + > + } + controls={ + <> + + + > + } + link={file.type === 'url' ? file.content : undefined} + /> + ); +}; + +const UploadButton = ({file}: {file: QueryFile}) => { + const {upsertFile} = useContext(QueryFilesContext); + const inputRef = useRef(null); + const readFileAsync = async (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); + const onFile = async ({target}: React.ChangeEvent) => { + const uploaded = target.files && target.files[0]; + if (!uploaded) { + return; + } + + try { + const content = await readFileAsync(uploaded); + upsertFile({...file, content}, file); + } catch (e) { + console.error(`Error reading file: ${e}`); + } + }; + return ( + <> + inputRef.current?.click()} /> + + > + ); +}; + +const EditFileItem = ({file, toggleEdit}: {file: QueryFile; toggleEdit: () => void}) => { + const {upsertFile, discardFile} = useContext(QueryFilesContext); + const [error, setError] = useState(''); + const validationState = useMemo(() => (error ? 'invalid' : undefined), [error]); + const [name, setName] = useState(file.name); + const [content, setContent] = useState(file.content); + useEffect(() => setContent(file.content), [file]); + const invalid = useMemo( + () => [validationState, !name, !content].some(Boolean), + [validationState, name, content], + ); + const close = () => { + if (fileIsNew(file)) { + discardFile(file); + } + toggleEdit(); + }; + useEffect(() => setError(''), [name]); + const trySave = () => { + const error = upsertFile({name, content, type: file.type}, file); + setError(error ?? ''); + if (!error) { + toggleEdit(); + } + }; + const isUrl = useMemo(() => file.type === 'url', [file]); + return ( + + + + > + } + controls={ + <> + {isUrl ? undefined : } + + + > + } + /> + ); +}; + +const FileItem = ({file}: {file: QueryFile}) => { + const newFile = useMemo(() => fileIsNew(file), [file]); + const [editMode, setEditMode] = useState(newFile); + useEffect(() => setEditMode(editMode || newFile), [newFile]); + const toggleEdit = useCallback(() => { + setEditMode(!editMode); + }, [editMode]); + return editMode ? ( + + ) : ( + + ); +}; + +const QueryFileList = ({ + items, + template, +}: { + items: QueryFile[]; + template: (file: QueryFile) => ReactNode; +}): React.JSX.Element => ( + +); + +enum FileTabs { + Current = 'current', + Deleted = 'deleted', +} + +export const QueryFilesButton = ({ + files, + queryId, + onChange, +}: { + files: QueryFile[]; + queryId: string; + onChange: (files: QueryFile[]) => void; +}) => { + const context = useQueryFiles(files, queryId, onChange); + const {deletedFiles, createFile} = context; + const [activeTab, setActiveTab] = useState(FileTabs.Current); + const createNewFile = useCallback( + (fileType: QueryFile['type']) => { + setActiveTab(FileTabs.Current); + createFile(fileType); + }, + [createFile], + ); + const tabs = useMemo( + () => [ + { + id: FileTabs.Current, + title: 'Current', + counter: files.length, + disabled: !files.length, + }, + { + id: FileTabs.Deleted, + title: 'Deleted', + counter: deletedFiles.length, + disabled: !deletedFiles.length, + }, + ], + [files, deletedFiles, activeTab], + ); + useEffect(() => { + if (activeTab === FileTabs.Current && !files.length && deletedFiles.length) { + setActiveTab(FileTabs.Deleted); + } + if (activeTab === FileTabs.Deleted && !deletedFiles.length) { + setActiveTab(FileTabs.Current); + } + }, [files, deletedFiles]); + return ( + + Attachments + + {files.length} + + + } + icon={clipIcon} + counter={files.length} + > + + + setActiveTab(tabId)} + /> + {activeTab === FileTabs.Current ? ( + } + /> + ) : ( + } + /> + )} + + + createNewFile('raw_inline_data')}> + + Add file + + createNewFile('url')}> + + Add URL + + + + + ); +}; diff --git a/packages/ui/src/ui/pages/query-tracker/QuerySettingsButton/index.tsx b/packages/ui/src/ui/pages/query-tracker/QuerySettingsButton/index.tsx index ac8904a11..cd1b53f37 100644 --- a/packages/ui/src/ui/pages/query-tracker/QuerySettingsButton/index.tsx +++ b/packages/ui/src/ui/pages/query-tracker/QuerySettingsButton/index.tsx @@ -3,11 +3,11 @@ import cn from 'bem-cn-lite'; import React, {KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import GearIcon from '@gravity-ui/icons/svgs/gear.svg'; import GearDotIcon from '@gravity-ui/icons/svgs/gear-dot.svg'; -import closeIcon from '../../../../../img/svg/close-icon.svg'; -import checkIcon from '../../../../../img/svg/icons/check.svg'; -import pencilIcon from '../../../../../img/svg/icons/pencil.svg'; -import trashIcon from '../../../../../img/svg/icons/trash-alt.svg'; -import plusIcon from '../../../../../img/svg/icons/plus.svg'; +import closeIcon from '@gravity-ui/icons/svgs/xmark.svg'; +import checkIcon from '@gravity-ui/icons/svgs/check.svg'; +import pencilIcon from '@gravity-ui/icons/svgs/pencil.svg'; +import trashIcon from '@gravity-ui/icons/svgs/trash-bin.svg'; +import plusIcon from '@gravity-ui/icons/svgs/plus.svg'; import './index.scss'; type QuerySetting = {name: string; value: string}; diff --git a/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryEngineSelector/QueryEngineSelector.tsx b/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryEngineSelector/QueryEngineSelector.tsx index bbd0b3c6d..90b5d68d9 100644 --- a/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryEngineSelector/QueryEngineSelector.tsx +++ b/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryEngineSelector/QueryEngineSelector.tsx @@ -3,7 +3,7 @@ import {useDispatch, useSelector} from 'react-redux'; import {ModalWithoutHandledScrollBar as Modal} from '../../../../../components/Modal/Modal'; import {QueryEnginesNames} from '../../../utils/query'; import {Engines, QueryEngine} from '../../../module/api'; -import {SET_QUERY_PATCH, createQueryFromTablePath} from '../../../module/query/actions'; +import {createQueryFromTablePath, updateQueryDraft} from '../../../module/query/actions'; import { getQueryDraft, hasLoadedQueryItem, @@ -33,12 +33,7 @@ export function QueryEngineSelector({className, cluster, path}: Props) { const updateEngine = useCallback( (engine: QueryEngine) => { - dispatch({ - type: SET_QUERY_PATCH, - data: { - engine: engine, - }, - }); + dispatch(updateQueryDraft({engine: engine})); if (cluster && path) { dispatch(createQueryFromTablePath(engine, cluster, path)); } diff --git a/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryMetaForm.tsx b/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryMetaForm.tsx index 3b2e3d886..9cde9eaff 100644 --- a/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryMetaForm.tsx +++ b/packages/ui/src/ui/pages/query-tracker/QueryTrackerTopRow/QueryMetaForm/QueryMetaForm.tsx @@ -5,10 +5,12 @@ import {EditableAsText} from '../../../../components/EditableAsText/EditableAsTe import {useDispatch, useSelector} from 'react-redux'; import {getQuery, getQueryDraft} from '../../module/query/selectors'; import {QuerySettingsButton} from '../../QuerySettingsButton'; -import {SET_QUERY_PATCH} from '../../module/query/actions'; +import {QueryFilesButton} from '../../QueryFilesButton'; +import {updateQueryDraft} from '../../module/query/actions'; import {QueryEngineSelector} from './QueryEngineSelector/QueryEngineSelector'; import './QueryMetaForm.scss'; +import {QueryFile} from '../../module/api'; const block = cn('query-tracker-meta-form'); export function QueryMetaForm({ @@ -25,28 +27,19 @@ export function QueryMetaForm({ const originalQuery = useSelector(getQuery); const onNameChange = useCallback( - (name: string) => { - dispatch({ - type: SET_QUERY_PATCH, - data: { - annotations: { - title: name, - }, - }, - }); + (name?: string) => { + dispatch(updateQueryDraft({annotations: {title: name}})); }, [dispatch], ); const onSettingsChange = useCallback( - (settings: Record) => { - dispatch({ - type: SET_QUERY_PATCH, - data: { - settings, - }, - }); - }, + (settings: Record) => dispatch(updateQueryDraft({settings})), + [dispatch], + ); + + const onFilesChange = useCallback( + (files: QueryFile[]) => dispatch(updateQueryDraft({files})), [dispatch], ); @@ -76,6 +69,11 @@ export function QueryMetaForm({ settings={draft.settings} onChange={onSettingsChange} /> + ); } diff --git a/packages/ui/src/ui/pages/query-tracker/module/api.ts b/packages/ui/src/ui/pages/query-tracker/module/api.ts index 70d5df14b..50dd74b9d 100644 --- a/packages/ui/src/ui/pages/query-tracker/module/api.ts +++ b/packages/ui/src/ui/pages/query-tracker/module/api.ts @@ -63,9 +63,16 @@ export type YQLStatistic = {sum?: number; count?: number; avg?: number; max?: nu export type YQLSstatistics = Record; +export type QueryFile = { + name: string; + content: string; + type: 'url' | 'raw_inline_data'; +}; + export interface DraftQuery { id?: QueryItemId; engine: QueryEngine; + files: QueryFile[]; query: string; annotations?: { title?: string; @@ -172,6 +179,7 @@ export async function generateQueryFromTable( schemaExists: Boolean(schema.length), dynamic: node.dynamic, }), + files: [], annotations: {}, settings: generateQuerySettings(engine, cluster), }; @@ -227,11 +235,12 @@ export function startQuery( return async (_dispatch, getState) => { const state = getState(); const {stage, yqlAgentStage} = getQueryTrackerRequestOptions(state); - const {query, engine, settings, annotations} = queryInstance; + const {query, engine, settings, annotations, files} = queryInstance; return ytApiV4Id.startQuery(YTApiId.startQuery, { parameters: { stage, query, + files, engine, annotations, settings: { diff --git a/packages/ui/src/ui/pages/query-tracker/module/query/actions.ts b/packages/ui/src/ui/pages/query-tracker/module/query/actions.ts index 290e9ac61..a310b10c8 100644 --- a/packages/ui/src/ui/pages/query-tracker/module/query/actions.ts +++ b/packages/ui/src/ui/pages/query-tracker/module/query/actions.ts @@ -72,6 +72,10 @@ export function loadQuery( }; } +export function updateQueryDraft(data: Partial) { + return {type: SET_QUERY_PATCH, data}; +} + export function createQueryFromTablePath( engine: QueryEngine, cluster: string, diff --git a/packages/ui/src/ui/pages/query-tracker/module/query/reducer.ts b/packages/ui/src/ui/pages/query-tracker/module/query/reducer.ts index fb17180e4..84ac83dce 100644 --- a/packages/ui/src/ui/pages/query-tracker/module/query/reducer.ts +++ b/packages/ui/src/ui/pages/query-tracker/module/query/reducer.ts @@ -32,6 +32,7 @@ export interface QueryState { const initialQueryDraftState: QueryState['draft'] = { engine: QueryEngine.YQL, query: '', + files: [], settings: {}, };