mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
feat: CB-655 table data editing
This commit is contained in:
@@ -10,6 +10,7 @@ import { observer } from 'mobx-react-lite';
|
||||
import {
|
||||
useEffect, useRef, useCallback, ChangeEvent
|
||||
} from 'react';
|
||||
import { useImperativeHandle } from 'react';
|
||||
import styled, { use } from 'reshadow';
|
||||
|
||||
import { Icon } from '@cloudbeaver/core-blocks';
|
||||
@@ -35,6 +36,7 @@ export interface InlineEditorProps {
|
||||
edited?: boolean;
|
||||
disabled?: boolean;
|
||||
autofocus?: boolean;
|
||||
active?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onReject?: () => void;
|
||||
@@ -42,7 +44,7 @@ export interface InlineEditorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const InlineEditor = observer(function InlineEditor({
|
||||
export const InlineEditor = observer<InlineEditorProps, HTMLInputElement | null>(function InlineEditor({
|
||||
name,
|
||||
value,
|
||||
type = 'text',
|
||||
@@ -55,12 +57,13 @@ export const InlineEditor = observer(function InlineEditor({
|
||||
edited = false,
|
||||
disabled,
|
||||
autofocus,
|
||||
active,
|
||||
onChange,
|
||||
onSave,
|
||||
onUndo,
|
||||
onReject,
|
||||
className,
|
||||
}: InlineEditorProps) {
|
||||
}, ref) {
|
||||
const commonDialogService = useService(CommonDialogService);
|
||||
|
||||
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>| string) => {
|
||||
@@ -79,23 +82,28 @@ export const InlineEditor = observer(function InlineEditor({
|
||||
}, [value, commonDialogService, onSave, onReject, handleChange]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSave();
|
||||
}
|
||||
if (event.key === 'Esc' && onReject) {
|
||||
onReject();
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
onSave();
|
||||
break;
|
||||
case 'Escape':
|
||||
onReject?.();
|
||||
break;
|
||||
}
|
||||
}, [onSave, onReject]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autofocus) {
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => inputRef.current!);
|
||||
|
||||
return styled(useStyles(InlineEditorStyles))(
|
||||
<editor as="div" className={className}>
|
||||
<editor as="div" className={className} {...use({ active })}>
|
||||
<editor-container as="div">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -118,4 +126,4 @@ export const InlineEditor = observer(function InlineEditor({
|
||||
</editor-actions>
|
||||
</editor>
|
||||
);
|
||||
});
|
||||
}, { forwardRef: true });
|
||||
|
||||
@@ -38,7 +38,7 @@ export const InlineEditorStyles = composes(
|
||||
border: solid 1px;
|
||||
}
|
||||
|
||||
editor:focus-within {
|
||||
editor:focus-within, editor[|active] {
|
||||
border-color: #01cca3;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export const InlineEditorStyles = composes(
|
||||
border: solid 1px;
|
||||
}
|
||||
|
||||
editor:focus-within {
|
||||
editor:focus-within, editor[|active] {
|
||||
& editor-actions {
|
||||
border-top-color: #01cca3;
|
||||
border-right-color: #01cca3;
|
||||
@@ -95,7 +95,7 @@ export const InlineEditorStyles = composes(
|
||||
left: auto;
|
||||
}
|
||||
|
||||
editor:focus-within {
|
||||
editor:focus-within, editor[|active] {
|
||||
& editor-actions[|position=bottom],
|
||||
& editor-actions[|position=top] {
|
||||
border-left-color: #01cca3;
|
||||
|
||||
+50
-31
@@ -7,13 +7,13 @@
|
||||
*/
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useContext, useLayoutEffect, useState } from 'react';
|
||||
import { useContext, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { EditorProps } from 'react-data-grid';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePopper } from 'react-popper';
|
||||
import styled, { css } from 'reshadow';
|
||||
|
||||
import { InlineEditor, InlineEditorControls } from '@cloudbeaver/core-app';
|
||||
import { InlineEditor } from '@cloudbeaver/core-app';
|
||||
|
||||
import { DataGridContext } from '../DataGridContext';
|
||||
|
||||
@@ -21,75 +21,94 @@ const styles = css`
|
||||
editor {
|
||||
composes: theme-typography--body2 from global;
|
||||
}
|
||||
box {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
InlineEditor {
|
||||
font-size: 12px;
|
||||
left: -1px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CellEditor: React.FC<EditorProps<any, any>> = observer(function CellEditor({
|
||||
export interface IEditorRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export const CellEditor = observer<Pick<EditorProps<any, any>, 'rowIdx' | 'row' | 'column' | 'onClose'>, IEditorRef>(function CellEditor({
|
||||
rowIdx,
|
||||
row,
|
||||
column,
|
||||
editorPortalTarget,
|
||||
onClose,
|
||||
}) {
|
||||
}, ref) {
|
||||
const context = useContext(DataGridContext);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
|
||||
const [popperRef, setPopperRef] = useState<HTMLDivElement | null>(null);
|
||||
const popper = usePopper(elementRef, popperRef, {
|
||||
placement: 'right',
|
||||
});
|
||||
const context = useContext(DataGridContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (elementRef && popperRef) {
|
||||
const size = elementRef.parentElement?.getBoundingClientRect();
|
||||
if (size) {
|
||||
popperRef.style.width = (size.width + 2) + 'px';
|
||||
popperRef.style.height = (size.height + 2) + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!context) {
|
||||
throw new Error('DataGridContext should be provided');
|
||||
}
|
||||
const modelResultData = context.model?.getResult(context.resultIndex);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (elementRef && popperRef) {
|
||||
const size = elementRef.parentElement?.parentElement?.getBoundingClientRect();
|
||||
|
||||
if (size) {
|
||||
popperRef.style.width = (size.width + 1) + 'px';
|
||||
popperRef.style.height = (size.height + 1) + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const value = row[column.key];
|
||||
|
||||
const isLastColumn = column.isLastFrozenColumn; // TODO: do not work
|
||||
const isLastRow = rowIdx > 0 && ((modelResultData?.data.rows.length || 0) - 1 === rowIdx);
|
||||
let controlsPosition: InlineEditorControls = 'right';
|
||||
|
||||
if (isLastColumn) {
|
||||
controlsPosition = isLastRow ? 'top' : 'bottom';
|
||||
}
|
||||
|
||||
const handleSave = () => onClose(false);
|
||||
const handleReject = () => onClose(false);
|
||||
const handleChange = () => {};
|
||||
const handleUndo = () => onClose(false);
|
||||
const handleReject = () => {
|
||||
context.model.source.getEditor(context.resultIndex)
|
||||
.revertCell(rowIdx, Number(column.key));
|
||||
onClose(false);
|
||||
};
|
||||
const handleChange = (value: string) => {
|
||||
context.model.source.getEditor(context.resultIndex)
|
||||
.setCell(rowIdx, Number(column.key), value);
|
||||
};
|
||||
const handleUndo = () => {
|
||||
context.model.source.getEditor(context.resultIndex)
|
||||
.revertCell(rowIdx, Number(column.key));
|
||||
onClose(false);
|
||||
};
|
||||
|
||||
return styled(styles)(
|
||||
<box ref={setElementRef} as='div'>
|
||||
{createPortal((
|
||||
<editor ref={setPopperRef} as="div" style={popper.styles.popper} {...popper.attributes.popper}>
|
||||
<InlineEditor
|
||||
ref={inputRef}
|
||||
type={typeof value === 'number' ? 'number' : 'text'}
|
||||
value={value}
|
||||
controlsPosition={controlsPosition}
|
||||
edited
|
||||
hideSave
|
||||
hideCancel
|
||||
autofocus
|
||||
active
|
||||
onSave={handleSave}
|
||||
onReject={handleReject}
|
||||
onChange={handleChange}
|
||||
onUndo={handleUndo}
|
||||
/>
|
||||
</editor>
|
||||
), context.getEditorPortal() || editorPortalTarget)}
|
||||
), context.getEditorPortal()!)}
|
||||
</box>
|
||||
);
|
||||
});
|
||||
}, { forwardRef: true });
|
||||
|
||||
+33
-12
@@ -11,27 +11,48 @@ import { useCallback, useContext } from 'react';
|
||||
import type { CellRendererProps } from 'react-data-grid';
|
||||
import { Cell } from 'react-data-grid';
|
||||
|
||||
import { EditingContext } from '../../Editing/EditingContext';
|
||||
import { DataGridContext } from '../DataGridContext';
|
||||
import { DataGridSelectionContext } from '../DataGridSelection/DataGridSelectionContext';
|
||||
|
||||
export const CellRenderer: React.FC<CellRendererProps<any>> = observer(function CellRenderer(props) {
|
||||
const gridSelectionContext = useContext(DataGridSelectionContext);
|
||||
if (!gridSelectionContext) {
|
||||
throw new Error('Grid selection context must be provided');
|
||||
}
|
||||
const context = useContext(DataGridContext);
|
||||
const selectionContext = useContext(DataGridSelectionContext);
|
||||
const editingContext = useContext(EditingContext);
|
||||
const editor = context?.model.source.getEditor(context.resultIndex);
|
||||
|
||||
let classes = '';
|
||||
const classes: string[] = [];
|
||||
const { rowIdx, column } = props;
|
||||
const { isSelected, select } = gridSelectionContext;
|
||||
|
||||
if (isSelected(column.key, rowIdx)) {
|
||||
classes += 'rdg-cell-custom-selected';
|
||||
if (selectionContext?.isSelected(column.key, rowIdx)) {
|
||||
classes.push('rdg-cell-custom-selected');
|
||||
}
|
||||
|
||||
const onClickHandler = useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
select(column.key, rowIdx, event.ctrlKey, event.shiftKey);
|
||||
}, [column, rowIdx, select]);
|
||||
if (editingContext?.isEditing({ idx: column.idx, rowIdx })) {
|
||||
classes.push('rdg-cell-custom-editing');
|
||||
}
|
||||
|
||||
if (editor?.isCellEdited(rowIdx, Number(column.key))) {
|
||||
classes.push('rdg-cell-custom-edited');
|
||||
}
|
||||
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
selectionContext?.select(column.key, rowIdx, event.ctrlKey, event.shiftKey);
|
||||
}, [column, rowIdx, selectionContext]);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
editingContext?.edit({ idx: column.idx, rowIdx });
|
||||
}, [column, rowIdx]);
|
||||
|
||||
const row = editor?.get(rowIdx) || props.row;
|
||||
|
||||
return (
|
||||
<Cell className={classes} onClick={onClickHandler} {...props} />
|
||||
<Cell
|
||||
className={classes.join(' ')}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
{...props}
|
||||
row={row}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { observer } from 'mobx-react-lite';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import DataGrid from 'react-data-grid';
|
||||
import type { Column } from 'react-data-grid';
|
||||
import type { Position } from 'react-data-grid/lib/types';
|
||||
import styled from 'reshadow';
|
||||
|
||||
import type { SqlResultSet } from '@cloudbeaver/core-sdk';
|
||||
@@ -18,10 +19,11 @@ import { useStyles } from '@cloudbeaver/core-theming';
|
||||
import { TextTools } from '@cloudbeaver/core-utils';
|
||||
import type { IDatabaseDataModel } from '@cloudbeaver/plugin-data-viewer';
|
||||
|
||||
import { EditingContext } from '../Editing/EditingContext';
|
||||
import { useEditing } from '../Editing/useEditing';
|
||||
import { ResultSetTools } from '../ResultSetTools';
|
||||
import baseStyles from '../styles/base.scss';
|
||||
import { reactGridStyles } from '../styles/styles';
|
||||
import { CellEditor } from './CellEditor/CellEditor';
|
||||
import { DataGridContext, IDataGridContext } from './DataGridContext';
|
||||
import { DataGridSelectionContext } from './DataGridSelection/DataGridSelectionContext';
|
||||
import { useGridSelectionContext } from './DataGridSelection/useGridSelectionContext';
|
||||
@@ -62,6 +64,37 @@ export const DataGridTable: React.FC<Props> = observer(function DataGridTable({
|
||||
|
||||
const gridSortingContext = useGridSortingContext(model);
|
||||
const gridSelectionContext = useGridSelectionContext(modelResultData, { indexColumnKey: indexColumn.key });
|
||||
const editingContext = useEditing({
|
||||
onEdit: (position, key) => {
|
||||
const editor = model.source.getEditor(resultIndex);
|
||||
|
||||
// TODO: not works yet
|
||||
switch (key) {
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
editor.setCell(position.rowIdx, position.idx, '');
|
||||
break;
|
||||
default:
|
||||
if (key) {
|
||||
editor.setCell(position.rowIdx, position.idx, key);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (model.isLoading()) {
|
||||
editingContext.close();
|
||||
}
|
||||
}, [model.isLoading()]);
|
||||
|
||||
const handleFocusChange = (position: Position) => {
|
||||
if (!editingContext.isEditing(position)) {
|
||||
editingContext.close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = useCallback(
|
||||
async (event: React.UIEvent<HTMLDivElement>) => {
|
||||
@@ -110,28 +143,11 @@ export const DataGridTable: React.FC<Props> = observer(function DataGridTable({
|
||||
// TODO: we need some result type specified formatter to common actions with data
|
||||
const rows = (modelResultData.data as SqlResultSet).rows || [];
|
||||
|
||||
const columns: Array<Column<any[], any>> = (modelResultData.data as SqlResultSet).columns!.map((col, i) => ({
|
||||
key: i + '',
|
||||
const columns = (modelResultData.data as SqlResultSet).columns!.map<Column<any[], any>>((col, columnIndex) => ({
|
||||
key: columnIndex + '',
|
||||
name: col.label!,
|
||||
width: Math.min(300, measuredCells[i]),
|
||||
minWidth: 40,
|
||||
resizable: true,
|
||||
editable: true,
|
||||
width: Math.min(300, measuredCells[columnIndex]),
|
||||
headerRenderer: TableColumnHeader,
|
||||
formatter: CellFormatter,
|
||||
editor: CellEditor,
|
||||
editorOptions: {
|
||||
onCellKeyDown: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
onNavigation: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}));
|
||||
columns.unshift(indexColumn);
|
||||
|
||||
@@ -148,18 +164,26 @@ export const DataGridTable: React.FC<Props> = observer(function DataGridTable({
|
||||
<DataGridContext.Provider value={gridContext}>
|
||||
<DataGridSortingContext.Provider value={gridSortingContext}>
|
||||
<DataGridSelectionContext.Provider value={gridSelectionContext}>
|
||||
<DataGridTableContainer modelResultData={modelResultData}>
|
||||
<DataGrid
|
||||
className={`cb-react-grid-theme ${className}`}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
headerRowHeight={28}
|
||||
rowHeight={24}
|
||||
rowRenderer={RowRenderer}
|
||||
onScroll={handleScroll}
|
||||
/>
|
||||
<div ref={editorRef} />
|
||||
</DataGridTableContainer>
|
||||
<EditingContext.Provider value={editingContext}>
|
||||
<DataGridTableContainer modelResultData={modelResultData}>
|
||||
<DataGrid
|
||||
className={`cb-react-grid-theme ${className}`}
|
||||
columns={columns}
|
||||
defaultColumnOptions={{
|
||||
minWidth: 40,
|
||||
resizable: true,
|
||||
formatter: CellFormatter,
|
||||
}}
|
||||
rows={rows}
|
||||
headerRowHeight={28}
|
||||
rowHeight={24}
|
||||
rowRenderer={RowRenderer}
|
||||
onSelectedCellChange={handleFocusChange}
|
||||
onScroll={handleScroll}
|
||||
/>
|
||||
<div ref={editorRef} />
|
||||
</DataGridTableContainer>
|
||||
</EditingContext.Provider>
|
||||
</DataGridSelectionContext.Provider>
|
||||
</DataGridSortingContext.Provider>
|
||||
</DataGridContext.Provider>
|
||||
|
||||
+35
-2
@@ -6,8 +6,12 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useContext, useEffect, useRef } from 'react';
|
||||
import type { FormatterProps } from 'react-data-grid';
|
||||
|
||||
import { EditingContext } from '../../Editing/EditingContext';
|
||||
import { CellEditor, IEditorRef } from '../CellEditor/CellEditor';
|
||||
|
||||
function valueGetter(rawValue: any) {
|
||||
if (rawValue !== null && typeof rawValue === 'object') {
|
||||
return JSON.stringify(rawValue);
|
||||
@@ -38,13 +42,42 @@ function getClasses(rawValue: any) {
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
export const CellFormatter: React.FC<FormatterProps> = function CellFormatter({ row, column }) {
|
||||
export const CellFormatter: React.FC<FormatterProps> = function CellFormatter({ rowIdx, row, column, isCellSelected }) {
|
||||
const editorRef = useRef<IEditorRef>(null);
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const editingContext = useContext(EditingContext);
|
||||
const rawValue = row[column.key];
|
||||
const classes = getClasses(rawValue);
|
||||
const value = formatValue(rawValue);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
editingContext?.closeEditor({ idx: column.idx, rowIdx });
|
||||
}, [column, rowIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCellSelected) {
|
||||
if (editingContext?.isEditing({ idx: column.idx, rowIdx })) {
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (editingContext?.isEditing({ idx: column.idx, rowIdx })) {
|
||||
return (
|
||||
<div className={`cell-formatter ${classes}`}>
|
||||
<CellEditor
|
||||
ref={editorRef}
|
||||
rowIdx={rowIdx}
|
||||
row={row}
|
||||
column={column}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<cell-formatter as='div' className={`cell-formatter ${classes}`}>
|
||||
<cell-formatter ref={cellRef} as='div' className={`cell-formatter ${classes}`}>
|
||||
{value}
|
||||
</cell-formatter>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* cloudbeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
import type { Position } from 'react-data-grid/lib/types';
|
||||
|
||||
export interface IEditingContext {
|
||||
edit: (position: Position, key?: string) => void;
|
||||
closeEditor: (position: Position) => void;
|
||||
close: () => void;
|
||||
isEditing: (position: Position) => boolean;
|
||||
}
|
||||
|
||||
export const EditingContext = createContext<IEditingContext | null>(null);
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* cloudbeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import { observable } from 'mobx';
|
||||
import { useState } from 'react';
|
||||
import type { Position } from 'react-data-grid/lib/types';
|
||||
|
||||
import { useObjectRef } from '@cloudbeaver/core-blocks';
|
||||
|
||||
import type { IEditingContext } from './EditingContext';
|
||||
|
||||
function findPosition(position: Position): (position: Position) => boolean {
|
||||
return p => p.idx === position.idx && p.rowIdx === position.rowIdx;
|
||||
}
|
||||
|
||||
interface IEditingOptions {
|
||||
onEdit: (position: Position, key?: string) => boolean;
|
||||
}
|
||||
|
||||
export function useEditing(options: IEditingOptions): IEditingContext {
|
||||
const optionsRef = useObjectRef<IEditingOptions>(options);
|
||||
const [editingCells] = useState(() => observable<Position>([]));
|
||||
|
||||
const [context] = useState<IEditingContext>({
|
||||
edit(position: Position, key?: string) {
|
||||
// TODO: not works yet
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
editingCells.splice(editingCells.findIndex(findPosition(position)), 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!optionsRef.current.onEdit(position, key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
editingCells.clear();
|
||||
editingCells.push(position);
|
||||
},
|
||||
closeEditor(position: Position) {
|
||||
editingCells.splice(editingCells.findIndex(findPosition(position)), 1);
|
||||
},
|
||||
close() {
|
||||
editingCells.clear();
|
||||
},
|
||||
isEditing(position: Position) {
|
||||
return editingCells.some(findPosition(position));
|
||||
},
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
.rdg-cell {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
.rdg-cell-editing {
|
||||
overflow: visible;
|
||||
height: 24px;
|
||||
|
||||
+12
-3
@@ -39,13 +39,22 @@
|
||||
@include mdc-theme-prop(background-color, surface, false);
|
||||
}
|
||||
|
||||
.rdg-cell-custom-selected {
|
||||
background-color: rgba(0, 145, 234, 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rdg-cell-selected {
|
||||
box-shadow: inset 0 0 0 1px #0091ea;
|
||||
}
|
||||
|
||||
.rdg-cell-custom-selected {
|
||||
background-color: rgba(0, 145, 234, 0.3);
|
||||
box-shadow: inset 0 0 0 1px #0091ea;
|
||||
.rdg-cell-custom-editing {
|
||||
box-shadow: none;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.rdg-cell-custom-edited {
|
||||
background-color: #ff99006b;
|
||||
}
|
||||
|
||||
.cell-formatter {
|
||||
|
||||
@@ -19,7 +19,7 @@ export const Spreadsheet: React.FC<IDataPresentationProps> = observer(function S
|
||||
}) {
|
||||
const deprecated = (model as DataModelWrapper).getOldModel(resultIndex);
|
||||
|
||||
if (!deprecated || (model.results.length === 0 && model.isLoading())) {
|
||||
if (!deprecated || (model.source.results.length === 0 && model.isLoading())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
import { observable, makeObservable } from 'mobx';
|
||||
|
||||
import type { NotificationService } from '@cloudbeaver/core-events';
|
||||
import type { GraphQLService, SqlDataFilterConstraint } from '@cloudbeaver/core-sdk';
|
||||
import type { GraphQLService, SqlDataFilterConstraint, SqlResultSet } from '@cloudbeaver/core-sdk';
|
||||
import { EDeferredState } from '@cloudbeaver/core-utils';
|
||||
|
||||
import { DatabaseDataEditor } from './DatabaseDataModel/DatabaseDataEditor';
|
||||
import { DatabaseDataSource } from './DatabaseDataModel/DatabaseDataSource';
|
||||
import type { IDatabaseDataResult } from './DatabaseDataModel/IDatabaseDataResult';
|
||||
import type { DataUpdate } from './DatabaseDataModel/IDatabaseDataSource';
|
||||
import { FetchTableDataAsyncProcess } from './FetchTableDataAsyncProcess';
|
||||
import type { IExecutionContext } from './IExecutionContext';
|
||||
import type { RowDiff } from './TableViewer/TableDataModel/EditedRow';
|
||||
@@ -29,7 +29,7 @@ export interface IDataContainerOptions {
|
||||
}
|
||||
|
||||
export interface IDataContainerResult extends IDatabaseDataResult {
|
||||
|
||||
data: SqlResultSet | undefined;
|
||||
}
|
||||
|
||||
export class ContainerDataSource extends DatabaseDataSource<IDataContainerOptions, IDataContainerResult> {
|
||||
@@ -51,6 +51,7 @@ export class ContainerDataSource extends DatabaseDataSource<IDataContainerOption
|
||||
|
||||
this.currentFetchTableProcess = null;
|
||||
this.executionContext = null;
|
||||
this.editor = new DatabaseDataEditor();
|
||||
}
|
||||
|
||||
cancel(): boolean {
|
||||
@@ -108,24 +109,52 @@ export class ContainerDataSource extends DatabaseDataSource<IDataContainerOption
|
||||
}
|
||||
|
||||
async save(
|
||||
prevResults: IDataContainerResult[],
|
||||
data: DataUpdate
|
||||
prevResults: IDataContainerResult[]
|
||||
): Promise<IDataContainerResult[]> {
|
||||
const executionContext = await this.ensureContextCreated();
|
||||
|
||||
const response = await this.graphQLService.sdk.updateResultsDataBatch({
|
||||
connectionId: executionContext.connectionId,
|
||||
contextId: executionContext.contextId,
|
||||
resultsId: data.data.id,
|
||||
// updatedRows: this.getRowsDiff(data),
|
||||
});
|
||||
const changes = this.editor?.getChanges();
|
||||
|
||||
this.requestInfo = {
|
||||
requestDuration: response.result?.duration || 0,
|
||||
requestMessage: 'Saved successfully',
|
||||
};
|
||||
if (!changes) {
|
||||
return prevResults;
|
||||
}
|
||||
|
||||
throw new Error('Not implemented');
|
||||
for (const update of changes) {
|
||||
const response = await this.graphQLService.sdk.updateResultsDataBatch({
|
||||
connectionId: executionContext.connectionId,
|
||||
contextId: executionContext.contextId,
|
||||
resultsId: update.resultId,
|
||||
updatedRows: Array.from(update.diff.values()).map(diff => ({
|
||||
data: diff.source,
|
||||
updateValues: diff.update.reduce((obj, value, index) => {
|
||||
if (value !== diff.source[index]) {
|
||||
obj[index] = value;
|
||||
}
|
||||
return obj;
|
||||
}, {}),
|
||||
})),
|
||||
});
|
||||
|
||||
this.requestInfo = {
|
||||
requestDuration: response.result?.duration || 0,
|
||||
requestMessage: 'Saved successfully',
|
||||
};
|
||||
|
||||
const result = prevResults.find(result => result.id === update.resultId)!;
|
||||
const responseResult = response.result?.results.find(result => result.resultSet?.id === update.resultId);
|
||||
|
||||
if (responseResult?.resultSet?.rows && result.data?.rows) {
|
||||
let i = 0;
|
||||
for (const row of update.diff.keys()) {
|
||||
result.data.rows[row] = responseResult.resultSet.rows[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
this.editor?.cancelResultChanges(result);
|
||||
}
|
||||
|
||||
return prevResults;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,7 +109,7 @@ export class DataViewerTabService {
|
||||
|
||||
// TODO: used for initial data fetch, but can repeat request each time data tab is selected,
|
||||
// so probably should be refactored and managed by presentation
|
||||
if (model.results.length === 0) {
|
||||
if (model.source.results.length === 0) {
|
||||
await model.requestData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { NotificationService } from '@cloudbeaver/core-events';
|
||||
import { GraphQLService } from '@cloudbeaver/core-sdk';
|
||||
|
||||
import { ContainerDataSource } from './ContainerDataSource';
|
||||
import { DatabaseDataAccessMode } from './DatabaseDataModel/IDatabaseDataModel';
|
||||
import { DatabaseDataAccessMode } from './DatabaseDataModel/IDatabaseDataSource';
|
||||
import type { DataModelWrapper } from './TableViewer/DataModelWrapper';
|
||||
import { TableViewerStorageService } from './TableViewer/TableViewerStorageService';
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* cloudbeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import { makeObservable, observable } from 'mobx';
|
||||
|
||||
import { DataUpdateType, IDatabaseDataEditor, IDatabaseDataResultEditor, IDataUpdate, IResultEditingDiff } from './IDatabaseDataEditor';
|
||||
import type { IDatabaseDataResult } from './IDatabaseDataResult';
|
||||
|
||||
export class DatabaseDataEditor<TResult extends IDatabaseDataResult> implements IDatabaseDataEditor<TResult> {
|
||||
readonly editedResults: Map<string, IDataUpdate>;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
editedResults: observable,
|
||||
});
|
||||
|
||||
this.editedResults = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
isRowEdited(result: TResult, row: number): boolean {
|
||||
const update = this.editedResults.get(result.id);
|
||||
const diff = update?.diff.get(row);
|
||||
|
||||
return !!diff?.source.some((value, index) => value !== diff.update[index]);
|
||||
}
|
||||
|
||||
isResultEdited(result: TResult): boolean {
|
||||
return this.editedResults.has(result.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
isCellEdited(result: TResult, row: number, column: number): boolean {
|
||||
const update = this.editedResults.get(result.id);
|
||||
const diff = update?.diff.get(row);
|
||||
|
||||
if (!update || !diff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return diff.source[column] !== diff.update[column];
|
||||
}
|
||||
|
||||
getResultEditor(result: TResult): IDatabaseDataResultEditor {
|
||||
return {
|
||||
set: this.set.bind(this, result),
|
||||
setCell: this.setCell.bind(this, result),
|
||||
get: this.get.bind(this, result),
|
||||
getCell: this.get.bind(this, result),
|
||||
isCellEdited: this.isCellEdited.bind(this, result),
|
||||
isEdited: this.isResultEdited.bind(this, result),
|
||||
isRowEdited: this.isRowEdited.bind(this, result),
|
||||
revert: this.revert.bind(this, result),
|
||||
revertCell: this.revertCell.bind(this, result),
|
||||
cancelChanges: this.cancelChanges.bind(this, result),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
get(result: TResult, row: number): any {
|
||||
const update = this.editedResults.get(result.id);
|
||||
const diff = update?.diff.get(row);
|
||||
|
||||
if (!update || !diff) {
|
||||
return result.data.rows[row];
|
||||
}
|
||||
|
||||
return diff?.update;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
getCell(result: TResult, row: number, column: number): any {
|
||||
const update = this.editedResults.get(result.id);
|
||||
const diff = update?.diff.get(row);
|
||||
|
||||
if (!update || !diff) {
|
||||
return result.data.rows[row][column];
|
||||
}
|
||||
|
||||
return diff?.update[column];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
set(result: TResult, row: number, value: any): void {
|
||||
const diff = this.getOrCreateDiff(result, row);
|
||||
|
||||
diff.update = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
setCell(result: TResult, row: number, column: number, value: any): void {
|
||||
const diff = this.getOrCreateDiff(result, row);
|
||||
|
||||
diff.update[column] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
revert(result: TResult, row: number): void {
|
||||
const update = this.editedResults.get(result.id);
|
||||
|
||||
if (update?.diff.has(row)) {
|
||||
update.diff.delete(row);
|
||||
}
|
||||
|
||||
if (update?.diff.size === 0) {
|
||||
this.editedResults.delete(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated more universal way should be implemented
|
||||
*/
|
||||
revertCell(result: TResult, row: number, column: number): void {
|
||||
const update = this.editedResults.get(result.id);
|
||||
const diff = update?.diff.get(row);
|
||||
|
||||
if (diff) {
|
||||
diff.update[column] = diff?.source[column];
|
||||
}
|
||||
|
||||
if (!this.isRowEdited(result, row)) {
|
||||
this.editedResults.delete(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
getChanges(): IDataUpdate[] {
|
||||
return Array.from(this.editedResults.values());
|
||||
}
|
||||
|
||||
cancelResultChanges(result: TResult): void {
|
||||
this.editedResults.delete(result.id);
|
||||
}
|
||||
|
||||
cancelChanges(): void {
|
||||
this.editedResults.clear();
|
||||
}
|
||||
|
||||
private getOrCreateUpdate(resultId: string): IDataUpdate {
|
||||
if (!this.editedResults.has(resultId)) {
|
||||
this.editedResults.set(resultId, { diff: new Map(), resultId });
|
||||
}
|
||||
|
||||
return this.editedResults.get(resultId)!;
|
||||
}
|
||||
|
||||
private getOrCreateDiff(result: TResult, row: number): IResultEditingDiff {
|
||||
const update = this.getOrCreateUpdate(result.id);
|
||||
|
||||
if (!update.diff.has(row)) {
|
||||
const source = result.data.rows[row];
|
||||
update.diff.set(row, {
|
||||
type: DataUpdateType.update,
|
||||
source,
|
||||
update: [...source],
|
||||
});
|
||||
}
|
||||
|
||||
return update.diff.get(row)!;
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,14 @@ import { observable, makeObservable } from 'mobx';
|
||||
import type { ResultDataFormat } from '@cloudbeaver/core-sdk';
|
||||
import { uuid } from '@cloudbeaver/core-utils';
|
||||
|
||||
import { DatabaseDataAccessMode, IDatabaseDataModel } from './IDatabaseDataModel';
|
||||
import type { IDatabaseDataModel } from './IDatabaseDataModel';
|
||||
import type { IDatabaseDataResult } from './IDatabaseDataResult';
|
||||
import type { IDatabaseDataSource, IRequestInfo } from './IDatabaseDataSource';
|
||||
import type { DatabaseDataAccessMode, IDatabaseDataSource, IRequestInfo } from './IDatabaseDataSource';
|
||||
|
||||
export class DatabaseDataModel<TOptions, TResult extends IDatabaseDataResult = IDatabaseDataResult>
|
||||
implements IDatabaseDataModel<TOptions, TResult> {
|
||||
id: string;
|
||||
results: TResult[];
|
||||
source: IDatabaseDataSource<TOptions, TResult>;
|
||||
access: DatabaseDataAccessMode;
|
||||
countGain: number;
|
||||
|
||||
get requestInfo(): IRequestInfo {
|
||||
@@ -33,16 +31,12 @@ implements IDatabaseDataModel<TOptions, TResult> {
|
||||
|
||||
constructor(source: IDatabaseDataSource<TOptions, TResult>) {
|
||||
makeObservable(this, {
|
||||
results: observable,
|
||||
access: observable,
|
||||
countGain: observable,
|
||||
});
|
||||
|
||||
this.id = uuid();
|
||||
this.source = source;
|
||||
this.countGain = 0;
|
||||
this.results = [];
|
||||
this.access = DatabaseDataAccessMode.Default;
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
@@ -53,17 +47,17 @@ implements IDatabaseDataModel<TOptions, TResult> {
|
||||
return this.source.offset <= offset && this.source.count >= count;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.requestData();
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
this.setSlice(0, this.countGain);
|
||||
await this.requestData();
|
||||
getResult(index: number): TResult | null {
|
||||
return this.source.getResult(index);
|
||||
}
|
||||
|
||||
setResults(results: TResult[]): this {
|
||||
this.results = results;
|
||||
this.source.setResults(results);
|
||||
return this;
|
||||
}
|
||||
|
||||
setAccess(access: DatabaseDataAccessMode): this {
|
||||
this.source.setAccess(access);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -72,19 +66,6 @@ implements IDatabaseDataModel<TOptions, TResult> {
|
||||
return this;
|
||||
}
|
||||
|
||||
getResult(index: number): TResult | null {
|
||||
if (this.results.length > index) {
|
||||
return this.results[index];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
setAccess(access: DatabaseDataAccessMode): this {
|
||||
this.access = access;
|
||||
return this;
|
||||
}
|
||||
|
||||
setSlice(offset: number, count = this.countGain): this {
|
||||
this.source.setSlice(offset, count);
|
||||
return this;
|
||||
@@ -105,14 +86,23 @@ implements IDatabaseDataModel<TOptions, TResult> {
|
||||
return this;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.requestData();
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
this.setSlice(0, this.countGain);
|
||||
await this.requestData();
|
||||
}
|
||||
|
||||
async requestDataPortion(offset: number, count: number): Promise<void> {
|
||||
if (!this.isDataAvailable(offset, count)) {
|
||||
this.source.setSlice(offset, count);
|
||||
this.results = await this.source.requestData(this.results);
|
||||
await this.source.requestData();
|
||||
}
|
||||
}
|
||||
|
||||
async requestData(): Promise<void> {
|
||||
this.results = await this.source.requestData(this.results);
|
||||
await this.source.requestData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,22 @@ import { ResultDataFormat } from '@cloudbeaver/core-sdk';
|
||||
import type { IExecutionContext } from '../IExecutionContext';
|
||||
import type { RowDiff } from '../TableViewer/TableDataModel/EditedRow';
|
||||
import type { IRequestDataResult } from '../TableViewer/TableViewerModel';
|
||||
import type { IDatabaseDataEditor, IDatabaseDataResultEditor } from './IDatabaseDataEditor';
|
||||
import type { IDatabaseDataResult } from './IDatabaseDataResult';
|
||||
import type { DataUpdate, IDatabaseDataSource, IRequestInfo } from './IDatabaseDataSource';
|
||||
import { DatabaseDataAccessMode, IDatabaseDataSource, IRequestInfo } from './IDatabaseDataSource';
|
||||
|
||||
export abstract class DatabaseDataSource<TOptions, TResult extends IDatabaseDataResult>
|
||||
implements IDatabaseDataSource<TOptions, TResult> {
|
||||
access: DatabaseDataAccessMode;
|
||||
dataFormat: ResultDataFormat;
|
||||
supportedDataFormats: ResultDataFormat[];
|
||||
editor: IDatabaseDataEditor<TResult> | null;
|
||||
results: TResult[];
|
||||
offset: number;
|
||||
count: number;
|
||||
dataFormat: ResultDataFormat;
|
||||
options: TOptions | null;
|
||||
requestInfo: IRequestInfo;
|
||||
executionContext: IExecutionContext | null;
|
||||
supportedDataFormats: ResultDataFormat[];
|
||||
abstract get canCancel(): boolean;
|
||||
|
||||
private activeRequest: Promise<TResult[]> | null;
|
||||
@@ -32,17 +36,23 @@ implements IDatabaseDataSource<TOptions, TResult> {
|
||||
|
||||
constructor() {
|
||||
makeObservable<DatabaseDataSource<TOptions, TResult>, 'activeRequest' | 'activeSave'>(this, {
|
||||
access: observable,
|
||||
dataFormat: observable,
|
||||
supportedDataFormats: observable,
|
||||
editor: observable,
|
||||
results: observable,
|
||||
offset: observable,
|
||||
count: observable,
|
||||
dataFormat: observable,
|
||||
options: observable,
|
||||
requestInfo: observable,
|
||||
executionContext: observable,
|
||||
supportedDataFormats: observable,
|
||||
activeRequest: observable,
|
||||
activeSave: observable,
|
||||
});
|
||||
|
||||
this.access = DatabaseDataAccessMode.Default;
|
||||
this.results = [];
|
||||
this.editor = null;
|
||||
this.offset = 0;
|
||||
this.count = 0;
|
||||
this.options = null;
|
||||
@@ -59,10 +69,42 @@ implements IDatabaseDataSource<TOptions, TResult> {
|
||||
|
||||
abstract cancel(): Promise<boolean> | boolean;
|
||||
|
||||
getResult(index: number): TResult | null {
|
||||
if (this.results.length > index) {
|
||||
return this.results[index];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getEditor(resultIndex: number): IDatabaseDataResultEditor {
|
||||
if (!this.editor) {
|
||||
throw new Error('Editor was not provided');
|
||||
}
|
||||
|
||||
return this.editor.getResultEditor(this.results[resultIndex]);
|
||||
}
|
||||
|
||||
setResults(results: TResult[]): this {
|
||||
this.editor?.cancelChanges();
|
||||
this.results = results;
|
||||
return this;
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
return !!this.activeRequest || !!this.activeSave;
|
||||
}
|
||||
|
||||
setEditor(editor: IDatabaseDataEditor<TResult>): this {
|
||||
this.editor = editor;
|
||||
return this;
|
||||
}
|
||||
|
||||
setAccess(access: DatabaseDataAccessMode): this {
|
||||
this.access = access;
|
||||
return this;
|
||||
}
|
||||
|
||||
setSlice(offset: number, count: number): this {
|
||||
this.offset = offset;
|
||||
this.count = count;
|
||||
@@ -90,7 +132,7 @@ implements IDatabaseDataSource<TOptions, TResult> {
|
||||
return this;
|
||||
}
|
||||
|
||||
async requestData(prevResults: TResult[]): Promise<TResult[]> {
|
||||
async requestData(): Promise<void> {
|
||||
if (this.activeSave) {
|
||||
try {
|
||||
await this.activeSave;
|
||||
@@ -98,25 +140,24 @@ implements IDatabaseDataSource<TOptions, TResult> {
|
||||
}
|
||||
|
||||
if (this.activeRequest) {
|
||||
return this.activeRequest;
|
||||
await this.activeRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const promise = this.request(prevResults);
|
||||
const promise = this.request(this.results);
|
||||
|
||||
if (promise instanceof Promise) {
|
||||
this.activeRequest = promise;
|
||||
}
|
||||
return await promise;
|
||||
this.editor?.cancelChanges();
|
||||
this.results = await promise;
|
||||
} finally {
|
||||
this.activeRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveData(
|
||||
prevResults: TResult[],
|
||||
data: DataUpdate
|
||||
): Promise<TResult[]> {
|
||||
async saveData(): Promise<void> {
|
||||
if (this.activeRequest) {
|
||||
try {
|
||||
await this.activeRequest;
|
||||
@@ -124,16 +165,17 @@ implements IDatabaseDataSource<TOptions, TResult> {
|
||||
}
|
||||
|
||||
if (this.activeSave) {
|
||||
return this.activeSave;
|
||||
await this.activeSave;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const promise = this.save(prevResults, data);
|
||||
const promise = this.save(this.results);
|
||||
|
||||
if (promise instanceof Promise) {
|
||||
this.activeSave = promise;
|
||||
}
|
||||
return await promise;
|
||||
this.results = await promise;
|
||||
} finally {
|
||||
this.activeSave = null;
|
||||
}
|
||||
@@ -163,7 +205,7 @@ implements IDatabaseDataSource<TOptions, TResult> {
|
||||
}
|
||||
|
||||
abstract request(prevResults: TResult[]): TResult[] | Promise<TResult[]>;
|
||||
abstract save(prevResults: TResult[], data: DataUpdate): Promise<TResult[]> | TResult[];
|
||||
abstract save(prevResults: TResult[]): Promise<TResult[]> | TResult[];
|
||||
abstract saveDeprecated(resultId: string, rows: RowDiff[]): Promise<IRequestDataResult>;
|
||||
|
||||
abstract dispose(): Promise<void>;
|
||||
|
||||
@@ -6,9 +6,52 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
export interface IDatabaseDataEditor {
|
||||
import type { IDatabaseDataResult } from './IDatabaseDataResult';
|
||||
|
||||
export enum DataUpdateType {
|
||||
delete,
|
||||
update,
|
||||
add
|
||||
}
|
||||
|
||||
export interface IResultEditingDiff {
|
||||
source: any[];
|
||||
update: any[];
|
||||
type: DataUpdateType;
|
||||
}
|
||||
|
||||
export interface IDataUpdate {
|
||||
resultId: string;
|
||||
diff: Map<number, IResultEditingDiff>;
|
||||
}
|
||||
|
||||
export interface IDatabaseDataEditor<TResult extends IDatabaseDataResult> {
|
||||
isResultEdited: (result: TResult) => boolean;
|
||||
isRowEdited: (result: TResult, row: number) => boolean;
|
||||
isCellEdited: (result: TResult, row: number, column: number) => boolean;
|
||||
get: (result: TResult, row: number) => void;
|
||||
set: (result: TResult, row: number, value: any) => void;
|
||||
getCell: (result: TResult, row: number, column: number) => void;
|
||||
setCell: (result: TResult, row: number, column: number, value: any) => void;
|
||||
revert: (result: TResult, row: number) => void;
|
||||
revertCell: (result: TResult, row: number, column: number) => void;
|
||||
|
||||
getResultEditor: (result: TResult) => IDatabaseDataResultEditor;
|
||||
|
||||
getChanges: () => IDataUpdate[];
|
||||
cancelChanges: () => void;
|
||||
cancelResultChanges: (result: TResult) => void;
|
||||
}
|
||||
|
||||
export interface IDatabaseDataResultEditor {
|
||||
isEdited: () => boolean;
|
||||
getChanges: () => void;
|
||||
applyChanges: () => void;
|
||||
cancelChanges: (skipUpdate?: boolean) => void;
|
||||
isRowEdited: (row: number) => boolean;
|
||||
isCellEdited: (row: number, column: number) => boolean;
|
||||
get: (row: number) => void;
|
||||
set: (row: number, value: any) => void;
|
||||
getCell: (row: number, column: number) => void;
|
||||
setCell: (row: number, column: number, value: any) => void;
|
||||
revert: (row: number) => void;
|
||||
revertCell: (row: number, column: number) => void;
|
||||
cancelChanges: () => void;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,11 @@
|
||||
import type { ResultDataFormat } from '@cloudbeaver/core-sdk';
|
||||
|
||||
import type { IDatabaseDataResult } from './IDatabaseDataResult';
|
||||
import type { IDatabaseDataSource, IRequestInfo } from './IDatabaseDataSource';
|
||||
|
||||
export enum DatabaseDataAccessMode {
|
||||
Default,
|
||||
Readonly
|
||||
}
|
||||
import type { DatabaseDataAccessMode, IDatabaseDataSource, IRequestInfo } from './IDatabaseDataSource';
|
||||
|
||||
export interface IDatabaseDataModel<TOptions, TResult extends IDatabaseDataResult = IDatabaseDataResult> {
|
||||
readonly id: string;
|
||||
readonly results: TResult[];
|
||||
readonly source: IDatabaseDataSource<TOptions, TResult>;
|
||||
readonly access: DatabaseDataAccessMode;
|
||||
readonly requestInfo: IRequestInfo;
|
||||
readonly supportedDataFormats: ResultDataFormat[];
|
||||
readonly countGain: number;
|
||||
@@ -30,8 +23,8 @@ export interface IDatabaseDataModel<TOptions, TResult extends IDatabaseDataResul
|
||||
|
||||
getResult: (index: number) => TResult | null;
|
||||
|
||||
setCountGain: (count: number) => this;
|
||||
setAccess: (access: DatabaseDataAccessMode) => this;
|
||||
setCountGain: (count: number) => this;
|
||||
setSlice: (offset: number, count?: number) => this;
|
||||
setOptions: (options: TOptions) => this;
|
||||
setDataFormat: (dataFormat: ResultDataFormat) => this;
|
||||
|
||||
@@ -6,53 +6,53 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import type { ResultDataFormat, SqlResultSet } from '@cloudbeaver/core-sdk';
|
||||
import type { ResultDataFormat } from '@cloudbeaver/core-sdk';
|
||||
|
||||
import type { IExecutionContext } from '../IExecutionContext';
|
||||
import type { RowDiff } from '../TableViewer/TableDataModel/EditedRow';
|
||||
import type { IRequestDataResult } from '../TableViewer/TableViewerModel';
|
||||
import type { IDatabaseDataEditor, IDatabaseDataResultEditor } from './IDatabaseDataEditor';
|
||||
import type { IDatabaseDataResult } from './IDatabaseDataResult';
|
||||
|
||||
export enum DataUpdateType {
|
||||
delete,
|
||||
update,
|
||||
add
|
||||
}
|
||||
|
||||
export interface IRequestInfo {
|
||||
readonly requestDuration: number;
|
||||
readonly requestMessage: string;
|
||||
}
|
||||
|
||||
export interface DataUpdate<T = any> {
|
||||
data: SqlResultSet;
|
||||
dataUpdate: SqlResultSet;
|
||||
type: DataUpdateType;
|
||||
export enum DatabaseDataAccessMode {
|
||||
Default,
|
||||
Readonly
|
||||
}
|
||||
|
||||
export interface IDatabaseDataSource<TOptions, TResult extends IDatabaseDataResult = IDatabaseDataResult> {
|
||||
readonly access: DatabaseDataAccessMode;
|
||||
readonly dataFormat: ResultDataFormat;
|
||||
readonly supportedDataFormats: ResultDataFormat[];
|
||||
readonly editor: IDatabaseDataEditor<TResult> | null;
|
||||
readonly results: TResult[];
|
||||
readonly offset: number;
|
||||
readonly count: number;
|
||||
readonly options: TOptions | null;
|
||||
readonly requestInfo: IRequestInfo;
|
||||
readonly dataFormat: ResultDataFormat;
|
||||
readonly executionContext: IExecutionContext | null;
|
||||
readonly supportedDataFormats: ResultDataFormat[];
|
||||
readonly canCancel: boolean;
|
||||
|
||||
isLoading: () => boolean;
|
||||
|
||||
getEditor: (resultIndex: number) => IDatabaseDataResultEditor;
|
||||
getResult: (index: number) => TResult | null;
|
||||
|
||||
setResults: (results: TResult[]) => this;
|
||||
setEditor: (editor: IDatabaseDataEditor<TResult>) => this;
|
||||
setAccess: (access: DatabaseDataAccessMode) => this;
|
||||
setSlice: (offset: number, count: number) => this;
|
||||
setOptions: (options: TOptions) => this;
|
||||
setDataFormat: (dataFormat: ResultDataFormat) => this;
|
||||
setSupportedDataFormats: (dataFormats: ResultDataFormat[]) => this;
|
||||
setExecutionContext: (context: IExecutionContext | null) => this;
|
||||
requestData: (
|
||||
prevResults: TResult[]
|
||||
) => Promise<TResult[]> | TResult[];
|
||||
saveData: (
|
||||
prevResults: TResult[],
|
||||
data: DataUpdate
|
||||
) => Promise<TResult[]> | TResult[];
|
||||
|
||||
requestData: () => Promise<void> | void;
|
||||
saveData: () => Promise<void> | void;
|
||||
/**
|
||||
* @deprecated will be refactored
|
||||
*/
|
||||
|
||||
@@ -14,9 +14,8 @@ import { DetailsError } from '@cloudbeaver/core-sdk';
|
||||
|
||||
import type { IDataContainerOptions } from '../ContainerDataSource';
|
||||
import { DatabaseDataModel } from '../DatabaseDataModel/DatabaseDataModel';
|
||||
import { DatabaseDataAccessMode } from '../DatabaseDataModel/IDatabaseDataModel';
|
||||
import type { IDatabaseDataResult } from '../DatabaseDataModel/IDatabaseDataResult';
|
||||
import type { IDatabaseDataSource } from '../DatabaseDataModel/IDatabaseDataSource';
|
||||
import { DatabaseDataAccessMode, IDatabaseDataSource } from '../DatabaseDataModel/IDatabaseDataSource';
|
||||
import type { RowDiff } from './TableDataModel/EditedRow';
|
||||
import { IRequestDataResult, TableViewerModel } from './TableViewerModel';
|
||||
|
||||
@@ -101,7 +100,7 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
|
||||
}
|
||||
|
||||
setAccess(access: DatabaseDataAccessMode): this {
|
||||
this.access = access;
|
||||
this.source.setAccess(access);
|
||||
|
||||
for (const model of this.deprecatedModels) {
|
||||
model.access = access;
|
||||
@@ -117,7 +116,7 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
|
||||
async requestData(): Promise<void> {
|
||||
this.clearErrors();
|
||||
try {
|
||||
this.results = await this.source.requestData(this.results);
|
||||
await this.source.requestData();
|
||||
await this.setDeprecatedModelData();
|
||||
} catch (exception) {
|
||||
this.showError(exception);
|
||||
@@ -130,7 +129,7 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
|
||||
this.source.setSlice(offset, count);
|
||||
this.clearErrors();
|
||||
try {
|
||||
this.results = await this.source.requestData(this.results);
|
||||
await this.source.requestData();
|
||||
await this.setDeprecatedModelData();
|
||||
} catch (exception) {
|
||||
this.showError(exception);
|
||||
@@ -164,12 +163,12 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
|
||||
* @deprecated will be removed
|
||||
*/
|
||||
private async setDeprecatedModelData() {
|
||||
if (this.deprecatedModels.length !== this.results.length) {
|
||||
this.deprecatedModels = this.deprecatedModels.slice(0, this.results.length);
|
||||
if (this.deprecatedModels.length !== this.source.results.length) {
|
||||
this.deprecatedModels = this.deprecatedModels.slice(0, this.source.results.length);
|
||||
|
||||
for (let i = this.deprecatedModels.length; i < this.results.length; i++) {
|
||||
for (let i = this.deprecatedModels.length; i < this.source.results.length; i++) {
|
||||
this.deprecatedModels.push(new TableViewerModel({
|
||||
access: this.access,
|
||||
access: this.source.access,
|
||||
requestDataAsync: async (
|
||||
model: TableViewerModel,
|
||||
offset: number,
|
||||
@@ -209,16 +208,16 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.results.length; i++) {
|
||||
for (let i = 0; i < this.source.results.length; i++) {
|
||||
const model = this.getOldModel(i)!;
|
||||
const result = this.getResult(i)!;
|
||||
|
||||
model.refresh();
|
||||
model.setColumns(result.data.columns);
|
||||
model.insertRows(0, result.data.rows, !result.loadedFully);
|
||||
model.access = this.results.length > 1
|
||||
model.access = this.source.results.length > 1
|
||||
? DatabaseDataAccessMode.Readonly
|
||||
: this.access;
|
||||
: this.source.access;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-4
@@ -35,12 +35,26 @@ export class TableFooterMenuService {
|
||||
if (context.data.model.deprecatedModels.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return context.data.model.isLoading() || !context.data.model.getOldModel(context.data.resultIndex)?.isEdited();
|
||||
const editor = context.data.model.source.getEditor(context.data.resultIndex);
|
||||
|
||||
return context.data.model.isLoading()
|
||||
|| (!context.data.model.getOldModel(context.data.resultIndex)?.isEdited()
|
||||
&& !editor.isEdited());
|
||||
},
|
||||
order: 1,
|
||||
title: 'ui_processing_save',
|
||||
icon: 'table-save',
|
||||
onClick: context => context.data.model.getOldModel(context.data.resultIndex)?.saveChanges(),
|
||||
onClick: context => {
|
||||
const editor = context.data.model.source.getEditor(context.data.resultIndex);
|
||||
|
||||
if (context.data.model.getOldModel(context.data.resultIndex)?.isEdited()) {
|
||||
context.data.model.getOldModel(context.data.resultIndex)?.saveChanges();
|
||||
}
|
||||
|
||||
if (editor.isEdited()) {
|
||||
context.data.model.source.saveData();
|
||||
}
|
||||
},
|
||||
});
|
||||
this.registerMenuItem({
|
||||
id: 'cancel ',
|
||||
@@ -51,12 +65,21 @@ export class TableFooterMenuService {
|
||||
if (context.data.model.deprecatedModels.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return !context.data.model.getOldModel(context.data.resultIndex)?.isEdited();
|
||||
|
||||
const editor = context.data.model.source.getEditor(context.data.resultIndex);
|
||||
|
||||
return !context.data.model.getOldModel(context.data.resultIndex)?.isEdited()
|
||||
&& !editor.isEdited();
|
||||
},
|
||||
order: 2,
|
||||
title: 'ui_processing_cancel',
|
||||
icon: 'table-cancel',
|
||||
onClick: context => context.data.model.getOldModel(context.data.resultIndex)?.cancelChanges(),
|
||||
onClick: context => {
|
||||
context.data.model.getOldModel(context.data.resultIndex)?.cancelChanges();
|
||||
|
||||
const editor = context.data.model.source.getEditor(context.data.resultIndex);
|
||||
editor.cancelChanges();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export const TableWhereFilter = observer(function TableWhereFilter({
|
||||
placeholder={translate('table_header_sql_expression')}
|
||||
controlsPosition='inside'
|
||||
edited={!!filterValue}
|
||||
disabled={context.isLoading() || context.results.length > 1}
|
||||
disabled={context.isLoading() || context.source.results.length > 1}
|
||||
simple
|
||||
onSave={handleApply}
|
||||
onUndo={resetFilter}
|
||||
|
||||
@@ -151,7 +151,7 @@ export const TableViewer = observer(function TableViewer({
|
||||
<Loader
|
||||
loading={dataModel.isLoading()}
|
||||
cancelDisabled={!dataModel.source.canCancel}
|
||||
overlay={dataModel.results.length > 0 && presentation.dataFormat === dataFormat}
|
||||
overlay={dataModel.source.results.length > 0 && presentation.dataFormat === dataFormat}
|
||||
onCancel={() => dataModel.source.cancel()}
|
||||
/>
|
||||
</table-box>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
|
||||
import { DetailsError, SqlDataFilterConstraint } from '@cloudbeaver/core-sdk';
|
||||
import { MetadataMap } from '@cloudbeaver/core-utils';
|
||||
|
||||
import { DatabaseDataAccessMode } from '../DatabaseDataModel/IDatabaseDataModel';
|
||||
import { DatabaseDataAccessMode } from '../DatabaseDataModel/IDatabaseDataSource';
|
||||
import { ErrorDialog } from './ErrorDialog';
|
||||
import type { RowDiff } from './TableDataModel/EditedRow';
|
||||
import type { TableColumn } from './TableDataModel/TableColumn';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { manifest } from './manifest';
|
||||
|
||||
export default manifest;
|
||||
|
||||
export * from './DatabaseDataModel/DatabaseDataEditor';
|
||||
export * from './DatabaseDataModel/DatabaseDataFormat';
|
||||
export * from './DatabaseDataModel/DatabaseDataModel';
|
||||
export * from './DatabaseDataModel/DatabaseDataSource';
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
import { observable, makeObservable } from 'mobx';
|
||||
|
||||
import type { NotificationService } from '@cloudbeaver/core-events';
|
||||
import type { GraphQLService, SqlDataFilterConstraint, SqlExecuteInfo } from '@cloudbeaver/core-sdk';
|
||||
import type { GraphQLService, SqlDataFilterConstraint, SqlExecuteInfo, SqlResultSet } from '@cloudbeaver/core-sdk';
|
||||
import { EDeferredState } from '@cloudbeaver/core-utils';
|
||||
import {
|
||||
DatabaseDataSource, DataUpdate, IDatabaseDataResult, IRequestDataResult, RowDiff
|
||||
DatabaseDataEditor,
|
||||
DatabaseDataSource, IDatabaseDataResult, IRequestDataResult, RowDiff
|
||||
} from '@cloudbeaver/plugin-data-viewer';
|
||||
|
||||
import type { IQueryTabGroup } from './ISqlEditorTabState';
|
||||
@@ -29,7 +30,7 @@ export interface IDataContainerOptions {
|
||||
}
|
||||
|
||||
export interface IDataContainerResult extends IDatabaseDataResult {
|
||||
|
||||
data: SqlResultSet | undefined;
|
||||
}
|
||||
|
||||
export class QueryDataSource extends DatabaseDataSource<IDataContainerOptions, IDataContainerResult> {
|
||||
@@ -51,6 +52,7 @@ export class QueryDataSource extends DatabaseDataSource<IDataContainerOptions, I
|
||||
});
|
||||
|
||||
this.queryExecutionProcess = null;
|
||||
this.editor = new DatabaseDataEditor();
|
||||
}
|
||||
|
||||
cancel(): boolean {
|
||||
@@ -60,11 +62,56 @@ export class QueryDataSource extends DatabaseDataSource<IDataContainerOptions, I
|
||||
return false;
|
||||
}
|
||||
|
||||
save(
|
||||
prevResults: IDataContainerResult[],
|
||||
data: DataUpdate<any>
|
||||
): IDataContainerResult[] | Promise<IDataContainerResult[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
async save(
|
||||
prevResults: IDataContainerResult[]
|
||||
): Promise<IDataContainerResult[]> {
|
||||
const params = this.options?.group.sqlQueryParams;
|
||||
if (!params) {
|
||||
throw new Error('sqlQueryParams must be provided');
|
||||
}
|
||||
|
||||
const changes = this.editor?.getChanges();
|
||||
|
||||
if (!changes) {
|
||||
return prevResults;
|
||||
}
|
||||
|
||||
for (const update of changes) {
|
||||
const response = await this.graphQLService.sdk.updateResultsDataBatch({
|
||||
connectionId: params.connectionId,
|
||||
contextId: params.contextId,
|
||||
resultsId: update.resultId,
|
||||
updatedRows: Array.from(update.diff.values()).map(diff => ({
|
||||
data: diff.source,
|
||||
updateValues: diff.update.reduce((obj, value, index) => {
|
||||
if (value !== diff.source[index]) {
|
||||
obj[index] = value;
|
||||
}
|
||||
return obj;
|
||||
}, {}),
|
||||
})),
|
||||
});
|
||||
|
||||
this.requestInfo = {
|
||||
requestDuration: response.result?.duration || 0,
|
||||
requestMessage: 'Saved successfully',
|
||||
};
|
||||
|
||||
const result = prevResults.find(result => result.id === update.resultId)!;
|
||||
const responseResult = response.result?.results.find(result => result.resultSet?.id === update.resultId);
|
||||
|
||||
if (responseResult?.resultSet?.rows && result.data?.rows) {
|
||||
let i = 0;
|
||||
for (const row of update.diff.keys()) {
|
||||
result.data.rows[row] = responseResult.resultSet.rows[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
this.editor?.cancelResultChanges(result);
|
||||
}
|
||||
|
||||
return prevResults;
|
||||
}
|
||||
|
||||
setOptions(options: IDataContainerOptions): this {
|
||||
|
||||
@@ -131,7 +131,7 @@ export class SqlResultTabsService {
|
||||
.setSlice(0, fetchingSettings.fetchDefault)
|
||||
.requestData();
|
||||
|
||||
length = model.results.length;
|
||||
length = model.source.results.length;
|
||||
|
||||
if (length === 1) {
|
||||
resultTab.name = this.getTabNameForOrder(tabGroup.order);
|
||||
|
||||
Reference in New Issue
Block a user