Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438475a99c | ||
|
|
f967a20edf | ||
|
|
5bc1734cb6 | ||
|
|
aba27cf9d3 | ||
|
|
a098db62c3 | ||
|
|
c4886127a6 | ||
|
|
343d671575 | ||
|
|
06a0411f84 | ||
|
|
ffe3d45017 | ||
|
|
16005be3e6 | ||
|
|
005adb10ce | ||
|
|
0a7d9e6a25 | ||
|
|
0edfb29851 | ||
|
|
52f567fded | ||
|
|
116b1a3be8 | ||
|
|
84cc6fcf60 | ||
|
|
1f4266ec95 | ||
|
|
c85b3ccd24 | ||
|
|
7687943ecf | ||
|
|
587d25bb40 | ||
|
|
5561d37ce8 | ||
|
|
fffbea22af | ||
|
|
934d45617a | ||
|
|
ccb6f2208c | ||
|
|
d9e4383b22 | ||
|
|
cbd8458776 | ||
|
|
3352b66a26 | ||
|
|
9b5ceed501 | ||
|
|
3687a0807d | ||
|
|
bda9966330 | ||
|
|
f0be7b6af6 | ||
|
|
3b0f0ddf1e | ||
|
|
85526b45dd | ||
|
|
a6a7aeb97f | ||
|
|
edf54d3b72 | ||
|
|
7a48102df4 | ||
|
|
a16d03d5a6 | ||
|
|
8410df4fa9 |
@@ -25,6 +25,12 @@ You can see a live demo of CloudBeaver here: https://demo.cloudbeaver.io
|
||||
|
||||
## Changelog
|
||||
|
||||
### 25.0.5 2025-05-19
|
||||
- Added the ability to configure the default database or schema in the connection configuration;
|
||||
- A new setting to autosave scripts in the SQL editor appeared in global and users' preferences;
|
||||
- SQLite: added information about a table Strict mode to the table metadata section (thanks to @eusebe-cda);
|
||||
- Vietnamese localization has been added (thanks to @0xhanh).
|
||||
|
||||
### 25.0.4 2025-05-05
|
||||
- Important:
|
||||
- Connection templates were removed from the application. You can use the Connection Editor to create new connections.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dbeaver/react-dnd": "workspace:^",
|
||||
"@dbeaver/ui-kit": "workspace:^",
|
||||
"react": "^19",
|
||||
"react-data-grid": "file:./artifacts",
|
||||
|
||||
@@ -4,19 +4,24 @@ import { DataGridCellContext, type IDataGridCellRenderer } from './DataGridCellC
|
||||
import { createCellMouseEvent } from './eventUtils.js';
|
||||
import { DataGridCellInnerContext, type IDataGridCellInnerContext } from './DataGridCellInnerContext.js';
|
||||
import { useGridReactiveValue } from './useGridReactiveValue.js';
|
||||
import { HeaderDnDContext } from './useHeaderDnD.js';
|
||||
|
||||
export const BaseCell = memo(function BaseCell<TRow, TSummaryRow>(props: CellRendererProps<TRow, TSummaryRow>) {
|
||||
const cellContext = use(DataGridCellContext);
|
||||
const tooltip = useGridReactiveValue(cellContext?.cellTooltip, props.rowIdx, props.column.idx);
|
||||
const dndContext = use(HeaderDnDContext)!;
|
||||
const virtualColIdx = props.column.idx;
|
||||
const dataColIdx = dndContext.getDataColIdx(props.column.idx);
|
||||
const rowIdx = props.rowIdx;
|
||||
const tooltip = useGridReactiveValue(cellContext?.cellTooltip, rowIdx, dataColIdx);
|
||||
|
||||
function handleClick(event: React.MouseEvent<HTMLDivElement>) {
|
||||
props.onClick?.(
|
||||
{
|
||||
rowIdx: props.rowIdx,
|
||||
rowIdx,
|
||||
row: props.row,
|
||||
column: props.column,
|
||||
selectCell(enableEditor) {
|
||||
props.selectCell({ rowIdx: props.rowIdx, idx: props.column.idx }, enableEditor);
|
||||
props.selectCell({ rowIdx, idx: virtualColIdx }, enableEditor);
|
||||
},
|
||||
},
|
||||
createCellMouseEvent(event),
|
||||
@@ -26,11 +31,11 @@ export const BaseCell = memo(function BaseCell<TRow, TSummaryRow>(props: CellRen
|
||||
function handleDoubleClick(event: React.MouseEvent<HTMLDivElement>) {
|
||||
props.onDoubleClick?.(
|
||||
{
|
||||
rowIdx: props.rowIdx,
|
||||
rowIdx,
|
||||
row: props.row,
|
||||
column: props.column,
|
||||
selectCell(enableEditor) {
|
||||
props.selectCell({ rowIdx: props.rowIdx, idx: props.column.idx }, enableEditor);
|
||||
props.selectCell({ rowIdx, idx: virtualColIdx }, enableEditor);
|
||||
},
|
||||
},
|
||||
createCellMouseEvent(event),
|
||||
@@ -40,11 +45,11 @@ export const BaseCell = memo(function BaseCell<TRow, TSummaryRow>(props: CellRen
|
||||
function handleContextMenu(event: React.MouseEvent<HTMLDivElement>) {
|
||||
props.onContextMenu?.(
|
||||
{
|
||||
rowIdx: props.rowIdx,
|
||||
rowIdx,
|
||||
row: props.row,
|
||||
column: props.column,
|
||||
selectCell(enableEditor) {
|
||||
props.selectCell({ rowIdx: props.rowIdx, idx: props.column.idx }, enableEditor);
|
||||
props.selectCell({ rowIdx, idx: virtualColIdx }, enableEditor);
|
||||
},
|
||||
},
|
||||
createCellMouseEvent(event),
|
||||
@@ -74,7 +79,7 @@ export const BaseCell = memo(function BaseCell<TRow, TSummaryRow>(props: CellRen
|
||||
[...Object.values(props), tooltip],
|
||||
);
|
||||
|
||||
const cellElement = useGridReactiveValue(cellContext?.cellElement, props.rowIdx, props.column.idx, mappedProps, renderDefaultCell);
|
||||
const cellElement = useGridReactiveValue(cellContext?.cellElement, rowIdx, dataColIdx, mappedProps, renderDefaultCell);
|
||||
|
||||
const innerCellContext = useMemo<IDataGridCellInnerContext>(() => ({ isFocused: props.isCellSelected }), [props.isCellSelected]);
|
||||
return <DataGridCellInnerContext value={innerCellContext}>{cellElement ?? <Cell title={tooltip} {...props} />}</DataGridCellInnerContext>;
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
}
|
||||
|
||||
@layer components.rdg.Cell {
|
||||
.rdg {
|
||||
--dbv-kit-icon-btn-outline-width: 1px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rdg-cell-frozen {
|
||||
&:nth-last-child(1 of &) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { mapRenderHeaderCell } from './mapRenderHeaderCell.js';
|
||||
import { mapEditCellRenderer } from './mapEditCellRenderer.js';
|
||||
import { DataGridRowContext, type IDataGridRowContext } from './DataGridRowContext.js';
|
||||
import './DataGrid.css';
|
||||
import { HeaderDnDContext, useHeaderDnD } from './useHeaderDnD.js';
|
||||
|
||||
export interface ICellPosition {
|
||||
rowIdx: number;
|
||||
@@ -46,9 +47,13 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(function DataGrid
|
||||
headerElement,
|
||||
getHeaderWidth,
|
||||
headerText,
|
||||
getHeaderOrder,
|
||||
getHeaderResizable,
|
||||
columnSortable,
|
||||
getHeaderHeight,
|
||||
getHeaderPinned,
|
||||
columnSortingState,
|
||||
getHeaderDnD,
|
||||
cell,
|
||||
cellText,
|
||||
cellElement,
|
||||
@@ -59,10 +64,12 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(function DataGrid
|
||||
rowCount,
|
||||
getRowId,
|
||||
getRowHeight,
|
||||
onHeaderReorder,
|
||||
onScroll,
|
||||
onScrollToBottom,
|
||||
onFocus,
|
||||
onCellChange,
|
||||
onColumnSort,
|
||||
children,
|
||||
className,
|
||||
onCellKeyDown,
|
||||
@@ -87,21 +94,23 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(function DataGrid
|
||||
minWidth: 26,
|
||||
editable: row => getCellEditable?.(row.idx, i) ?? false,
|
||||
frozen: getHeaderPinned?.(i),
|
||||
renderHeaderCell: mapRenderHeaderCell,
|
||||
renderCell: mapCellContentRenderer,
|
||||
renderEditCell: mapEditCellRenderer,
|
||||
renderHeaderCell: mapRenderHeaderCell(i),
|
||||
renderCell: mapCellContentRenderer(i),
|
||||
renderEditCell: mapEditCellRenderer(i),
|
||||
};
|
||||
});
|
||||
|
||||
const dndHeaderContext = useHeaderDnD({ columns, onReorder: onHeaderReorder, getCanDrag: getHeaderDnD, getHeaderOrder });
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
selectCell: (position: ICellPosition) => {
|
||||
innerGridRef.current?.selectCell({ idx: position.colIdx, rowIdx: position.rowIdx });
|
||||
innerGridRef.current?.selectCell({ idx: dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx });
|
||||
},
|
||||
scrollToCell: (position: Partial<ICellPosition>) => {
|
||||
innerGridRef.current?.scrollToCell({ idx: position.colIdx, rowIdx: position.rowIdx });
|
||||
innerGridRef.current?.scrollToCell({ idx: position.colIdx && dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx });
|
||||
},
|
||||
openEditor: (position: ICellPosition) => {
|
||||
innerGridRef.current?.selectCell({ idx: position.colIdx, rowIdx: position.rowIdx }, true);
|
||||
innerGridRef.current?.selectCell({ idx: dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx }, true);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -124,34 +133,40 @@ export const DataGrid = forwardRef<DataGridRef, DataGridProps>(function DataGrid
|
||||
);
|
||||
|
||||
function handleCellFocus(args: CellSelectArgs<IInnerRow, unknown>) {
|
||||
onFocus?.({ colIdx: args.column.idx, rowIdx: args.rowIdx });
|
||||
onFocus?.({ colIdx: dndHeaderContext.getDataColIdx(args.column.idx), rowIdx: args.rowIdx });
|
||||
}
|
||||
|
||||
function handleCellKeyDown(args: CellSelectArgs<IInnerRow, unknown>, event: DataGridCellKeyboardEvent) {
|
||||
onCellKeyDown?.({ colIdx: dndHeaderContext.getDataColIdx(args.column.idx), rowIdx: args.rowIdx }, event);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridRowContext value={{ rowCount, onScrollToBottom }}>
|
||||
<DataGridCellContext value={{ cell, cellText, cellElement, cellTooltip, onCellChange }}>
|
||||
<DataGridCellHeaderContext value={{ headerElement, headerText }}>
|
||||
<DataGridBase
|
||||
key={gridKey.current}
|
||||
ref={innerGridRef}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
className={className}
|
||||
headerRowHeight={getHeaderHeight?.()}
|
||||
onScroll={onScroll}
|
||||
rowHeight={getRowHeight ? row => getRowHeight(row.idx) : undefined}
|
||||
rowKeyGetter={getRowId ? row => getRowId(row.idx) : undefined}
|
||||
onSelectedCellChange={handleCellFocus}
|
||||
onCellKeyDown={onCellKeyDown ? (args, event) => onCellKeyDown({ rowIdx: args.rowIdx, colIdx: args.column.idx }, event) : undefined}
|
||||
renderers={{
|
||||
renderRow: rowRenderer,
|
||||
renderCell: cellRenderer,
|
||||
noRowsFallback: children,
|
||||
}}
|
||||
minimumRowsToRender={100}
|
||||
/>
|
||||
</DataGridCellHeaderContext>
|
||||
</DataGridCellContext>
|
||||
</DataGridRowContext>
|
||||
<HeaderDnDContext value={dndHeaderContext}>
|
||||
<DataGridRowContext value={{ rowCount, onScrollToBottom }}>
|
||||
<DataGridCellContext value={{ cell, cellText, cellElement, cellTooltip, onCellChange }}>
|
||||
<DataGridCellHeaderContext value={{ headerElement, headerText, getHeaderDnD, columnSortable, onColumnSort, columnSortingState }}>
|
||||
<DataGridBase
|
||||
key={gridKey.current}
|
||||
ref={innerGridRef}
|
||||
columns={dndHeaderContext.columns}
|
||||
rows={rows}
|
||||
className={className}
|
||||
headerRowHeight={getHeaderHeight?.()}
|
||||
onScroll={onScroll}
|
||||
rowHeight={getRowHeight ? row => getRowHeight(row.idx) : undefined}
|
||||
rowKeyGetter={getRowId ? row => getRowId(row.idx) : undefined}
|
||||
onSelectedCellChange={handleCellFocus}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
renderers={{
|
||||
renderRow: rowRenderer,
|
||||
renderCell: cellRenderer,
|
||||
noRowsFallback: children,
|
||||
}}
|
||||
minimumRowsToRender={100}
|
||||
/>
|
||||
</DataGridCellHeaderContext>
|
||||
</DataGridCellContext>
|
||||
</DataGridRowContext>
|
||||
</HeaderDnDContext>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,10 +4,16 @@ import type { IGridReactiveValue } from './IGridReactiveValue.js';
|
||||
export interface IDataGridHeaderCellContext {
|
||||
headerElement?: IGridReactiveValue<React.ReactNode, [colIdx: number]>;
|
||||
headerText?: IGridReactiveValue<string, [colIdx: number]>;
|
||||
getHeaderOrder?: () => number[];
|
||||
getHeaderWidth?: (colIdx: number) => number | string | null;
|
||||
getHeaderResizable?: (colIdx: number) => boolean;
|
||||
getHeaderHeight?: () => number;
|
||||
getHeaderPinned?: (colIdx: number) => boolean;
|
||||
getHeaderDnD?: (colIdx: number) => boolean;
|
||||
onHeaderReorder?: (from: number, to: number) => void;
|
||||
columnSortingState?: IGridReactiveValue<'asc' | 'desc' | undefined | null, [colIdx: number]>;
|
||||
columnSortable?: IGridReactiveValue<boolean, [colIdx: number]>;
|
||||
onColumnSort?: (colIdx: number, order: 'asc' | 'desc' | null, isMultiple: boolean) => void;
|
||||
}
|
||||
|
||||
export const DataGridCellHeaderContext = createContext<IDataGridHeaderCellContext | null>(null);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function BooleanFormatter({ value, className, onClick, onKeyDown }: Props
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onClick}
|
||||
>
|
||||
{value === null ? <NullFormatter /> : <Checkbox className="tw:data-disabled:opacity-100" disabled size="small" checked={value} />}
|
||||
{value === null ? <NullFormatter /> : <Checkbox className="tw:data-disabled:opacity-100!" disabled size="small" checked={value} />}
|
||||
</Focusable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './DataGridCellInnerContext.js';
|
||||
export * from './IGridReactiveValue.js';
|
||||
export * from './useCreateGridReactiveValue.js';
|
||||
export * from './useGridReactiveValue.js';
|
||||
export * from './reorderArray.js';
|
||||
|
||||
export * from './formatters/NullFormatter.js';
|
||||
export * from './formatters/BooleanFormatter.js';
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { RenderCellProps } from 'react-data-grid';
|
||||
import type { IInnerRow } from './IInnerRow.js';
|
||||
import { CellContentRenderer } from './renderers/CellContentRenderer.js';
|
||||
|
||||
export function mapCellContentRenderer({ rowIdx, column }: RenderCellProps<IInnerRow, unknown>) {
|
||||
return <CellContentRenderer rowIdx={rowIdx} colIdx={column.idx} />;
|
||||
export function mapCellContentRenderer(colIdx: number) {
|
||||
return function RenderCell({ rowIdx }: RenderCellProps<IInnerRow, unknown>) {
|
||||
return <CellContentRenderer rowIdx={rowIdx} colIdx={colIdx} />;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { RenderEditCellProps } from 'react-data-grid';
|
||||
import type { IInnerRow } from './IInnerRow.js';
|
||||
import { TextEditor } from './editors/TextEditor.js';
|
||||
|
||||
export function mapEditCellRenderer({ rowIdx, column, onClose }: RenderEditCellProps<IInnerRow, unknown>) {
|
||||
return <TextEditor rowIdx={rowIdx} colIdx={column.idx} onClose={() => onClose()} />;
|
||||
export function mapEditCellRenderer(colIdx: number) {
|
||||
return function RenderEditCell({ rowIdx, onClose }: RenderEditCellProps<IInnerRow, unknown>) {
|
||||
return <TextEditor rowIdx={rowIdx} colIdx={colIdx} onClose={() => onClose()} />;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { RenderHeaderCellProps } from 'react-data-grid';
|
||||
import type { IInnerRow } from './IInnerRow.js';
|
||||
import { HeaderCellContentRenderer } from './renderers/HeaderCellContentRenderer.js';
|
||||
|
||||
export function mapRenderHeaderCell({ column }: RenderHeaderCellProps<IInnerRow, unknown>) {
|
||||
return <HeaderCellContentRenderer colIdx={column.idx} />;
|
||||
export function mapRenderHeaderCell(colIdx: number) {
|
||||
return function RenderHeaderCell({ tabIndex }: RenderHeaderCellProps<IInnerRow, unknown>) {
|
||||
return <HeaderCellContentRenderer colIdx={colIdx} tabIndex={tabIndex} />;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,80 @@
|
||||
import { use } from 'react';
|
||||
import { memo, use, useRef } from 'react';
|
||||
import { useDrag, useDrop, type DnDStoreProvider } from '@dbeaver/react-dnd';
|
||||
import { DataGridCellHeaderContext } from '../DataGridHeaderCellContext.js';
|
||||
import { useGridReactiveValue } from '../useGridReactiveValue.js';
|
||||
import { HeaderDnDContext } from '../useHeaderDnD.js';
|
||||
import { OrderButton } from './OrderButton.js';
|
||||
|
||||
interface Props {
|
||||
colIdx: number;
|
||||
tabIndex?: number;
|
||||
}
|
||||
export function HeaderCellContentRenderer({ colIdx }: Props) {
|
||||
export const HeaderCellContentRenderer = memo(function HeaderCellContentRenderer({ colIdx, tabIndex }: Props) {
|
||||
const dndHeaderContext = use(HeaderDnDContext);
|
||||
const cellHeaderContext = use(DataGridCellHeaderContext);
|
||||
const headerElement = useGridReactiveValue(cellHeaderContext?.headerElement, colIdx);
|
||||
const getHeaderText = useGridReactiveValue(headerElement ? undefined : cellHeaderContext?.headerText, colIdx);
|
||||
return headerElement ?? getHeaderText ?? '';
|
||||
}
|
||||
const isColumnSortable = useGridReactiveValue(cellHeaderContext?.columnSortable, colIdx);
|
||||
const onColumnSort = cellHeaderContext?.onColumnSort;
|
||||
const sortingState = useGridReactiveValue(cellHeaderContext?.columnSortingState, colIdx);
|
||||
|
||||
const orderButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const draggable = dndHeaderContext?.getCanDrag?.(colIdx) ?? false;
|
||||
const drag = useDrag({
|
||||
draggable,
|
||||
onDragStart(event, store) {
|
||||
if (dndHeaderContext?.id) {
|
||||
store.setData('dbeaver-react-data-grid-id', dndHeaderContext?.id);
|
||||
}
|
||||
store.setData('dbeaver-react-data-grid-col-idx', colIdx);
|
||||
},
|
||||
onDragEnd() {
|
||||
dndHeaderContext?.onDragEnd(colIdx);
|
||||
},
|
||||
});
|
||||
|
||||
function canDrop(event: React.DragEvent<HTMLElement>, store: DnDStoreProvider | null) {
|
||||
const gridId = store?.getData('dbeaver-react-data-grid-id');
|
||||
return gridId === dndHeaderContext?.id;
|
||||
}
|
||||
const drop = useDrop({
|
||||
canDrop,
|
||||
onDragOver(event, store) {
|
||||
if (!canDrop(event, store)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dragColIdx = store?.getData('dbeaver-react-data-grid-col-idx');
|
||||
const position = event.clientX - event.currentTarget.getBoundingClientRect().left;
|
||||
const width = event.currentTarget.offsetWidth;
|
||||
const isAfter = position > width / 2;
|
||||
|
||||
dndHeaderContext?.onDragOver(dragColIdx, colIdx, isAfter);
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Tab' && !e.shiftKey && isColumnSortable && onColumnSort && orderButtonRef.current !== document.activeElement) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
containerRef.current?.parentElement?.setAttribute('aria-selected', 'false');
|
||||
orderButtonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={tabIndex}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="tw:w-full tw:h-full tw:content-center tw:flex tw:items-center tw:justify-between tw:gap-1 tw:outline-none tw:group"
|
||||
{...drag.props}
|
||||
{...drop.props}
|
||||
>
|
||||
<span className="tw:overflow-hidden tw:text-ellipsis">{headerElement ?? getHeaderText ?? ''}</span>
|
||||
{isColumnSortable && onColumnSort && <OrderButton ref={orderButtonRef} colIdx={colIdx} sortState={sortingState} onSort={onColumnSort} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { clsx, IconButton } from '@dbeaver/ui-kit';
|
||||
|
||||
interface OrderButtonProps {
|
||||
colIdx: number;
|
||||
sortState?: 'asc' | 'desc' | null;
|
||||
onSort: (attributePosition: number, order: 'asc' | 'desc' | null, isMultiple: boolean) => void;
|
||||
tabIndex?: number;
|
||||
ref: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export function OrderButton({ colIdx, sortState, onSort, tabIndex, ref }: OrderButtonProps) {
|
||||
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
const nextSortState = sortState === 'asc' ? 'desc' : sortState === 'desc' ? null : 'asc';
|
||||
onSort(colIdx, nextSortState, e.ctrlKey || e.metaKey);
|
||||
}
|
||||
|
||||
const svgSortAsc = '#icon-sort-asc';
|
||||
const svgSortDesc = '#icon-sort-desc';
|
||||
const svgSortUnknown = '#icon-sort-unknown';
|
||||
|
||||
const iconSrc = sortState === 'asc' ? svgSortAsc : sortState === 'desc' ? svgSortDesc : svgSortUnknown;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
tabIndex={tabIndex}
|
||||
ref={ref}
|
||||
title="Sort by column"
|
||||
aria-label="Sort by column"
|
||||
className={clsx(
|
||||
'tw:opacity-0 tw:group-focus:opacity-100 tw:focus:opacity-100 tw:group-hover:opacity-100 tw:hover:opacity-100 tw:outline-offset-0',
|
||||
sortState && 'tw:opacity-100',
|
||||
)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<use href={iconSrc} />
|
||||
</svg>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function reorderArray<T>(array: T[], from: number, to: number): T[] {
|
||||
const newArray = [...array];
|
||||
if (from < to) {
|
||||
newArray.splice(to, 0, array[from]!);
|
||||
newArray.splice(from, 1);
|
||||
} else {
|
||||
newArray.splice(from, 1);
|
||||
newArray.splice(to, 0, array[from]!);
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ColumnOrColumnGroup } from 'react-data-grid';
|
||||
import type { IInnerRow } from './IInnerRow.js';
|
||||
import { createContext, useId, useMemo, useState } from 'react';
|
||||
import { reorderArray } from './reorderArray.js';
|
||||
|
||||
interface IHeaderDnDOptions {
|
||||
columns: Array<ColumnOrColumnGroup<IInnerRow, unknown>>;
|
||||
getCanDrag?: (colIdx: number) => boolean;
|
||||
onReorder?: (from: number, to: number) => void;
|
||||
getHeaderOrder?: () => number[];
|
||||
}
|
||||
|
||||
interface IHeaderDnD {
|
||||
id: string;
|
||||
columns: Array<ColumnOrColumnGroup<IInnerRow, unknown>>;
|
||||
getDataColIdx: (virtualColIdx: number) => number;
|
||||
getVirtualColIdx: (dataColIdx: number) => number;
|
||||
getCanDrag?: (colIdx: number) => boolean;
|
||||
onDragOver: (dragColIdx: number, dropColIdx: number, isAfter: boolean) => void;
|
||||
onDragEnd: (colIdx: number) => void;
|
||||
}
|
||||
|
||||
export const HeaderDnDContext = createContext<IHeaderDnD | null>(null);
|
||||
|
||||
export function useHeaderDnD({ columns, getCanDrag, onReorder, getHeaderOrder }: IHeaderDnDOptions): IHeaderDnD {
|
||||
const id = useId();
|
||||
const [activeDnDElement, setActiveDnDElement] = useState<number | null>(null);
|
||||
const [dragOverElement, setDragOverElement] = useState<[number, boolean] | null>(null);
|
||||
|
||||
function getDataColIdx(virtualColIdx: number) {
|
||||
// Given a visible (ordered) column index, return the original data column index
|
||||
const order = getHeaderOrder?.();
|
||||
if (order) {
|
||||
return order[virtualColIdx] ?? virtualColIdx;
|
||||
}
|
||||
return virtualColIdx;
|
||||
}
|
||||
|
||||
function getVirtualColIdx(dataColIdx: number) {
|
||||
// Given the original data column index, return its visible (ordered) index
|
||||
const order = getHeaderOrder?.();
|
||||
if (order) {
|
||||
return order.indexOf(dataColIdx);
|
||||
}
|
||||
return dataColIdx;
|
||||
}
|
||||
|
||||
function onDragEnd(dragColIdx: number) {
|
||||
setActiveDnDElement(null);
|
||||
setDragOverElement(null);
|
||||
|
||||
if (dragOverElement) {
|
||||
const isAfter = dragOverElement?.[1];
|
||||
let to = dragOverElement?.[0];
|
||||
|
||||
if (isAfter) {
|
||||
to = getDataColIdx(getVirtualColIdx(to) + 1);
|
||||
}
|
||||
|
||||
onReorder?.(dragColIdx, to);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(dragColIdx: number, dropColIdx: number, isAfter: boolean) {
|
||||
setActiveDnDElement(dragColIdx);
|
||||
if (dragColIdx !== dropColIdx) {
|
||||
setDragOverElement([dropColIdx, isAfter]);
|
||||
}
|
||||
}
|
||||
|
||||
columns = useMemo(() => columns.map((col, idx) => columns[getDataColIdx(idx)]!), [columns, getHeaderOrder]);
|
||||
|
||||
if (activeDnDElement !== null && dragOverElement !== null) {
|
||||
const from = getVirtualColIdx(activeDnDElement);
|
||||
let to = getVirtualColIdx(dragOverElement[0]);
|
||||
const isAfter = dragOverElement[1];
|
||||
|
||||
if (isAfter) {
|
||||
to++;
|
||||
}
|
||||
|
||||
columns = reorderArray(columns, from, to);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
columns,
|
||||
getDataColIdx,
|
||||
getVirtualColIdx,
|
||||
getCanDrag,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../react-dnd"
|
||||
},
|
||||
{
|
||||
"path": "../ui-kit"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# @dbeaver/react-dnd
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@dbeaver/react-dnd",
|
||||
"type": "module",
|
||||
"sideEffects": [],
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./*": "./lib/*.js"
|
||||
},
|
||||
"scripts": {
|
||||
"clear": "rimraf lib"
|
||||
},
|
||||
"files": [
|
||||
"package.json",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"lib",
|
||||
"!lib/**/*.d.ts.map",
|
||||
"!lib/**/*.test.js",
|
||||
"!lib/**/*.test.d.ts",
|
||||
"!lib/**/*.test.d.ts.map",
|
||||
"!lib/**/*.test.js.map",
|
||||
"!lib/tests",
|
||||
"!.tsbuildinfo"
|
||||
],
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"devDependencies": {
|
||||
"@dbeaver/tsconfig": "workspace:^",
|
||||
"@types/react": "^19",
|
||||
"rimraf": "^6",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useDrag.js';
|
||||
export * from './useDrop.js';
|
||||
export { type DnDStoreProvider } from './store.js';
|
||||
@@ -0,0 +1,46 @@
|
||||
export const DND_STORE_PREFIX = 'application/dbeaver-react-dnd-';
|
||||
|
||||
export interface DnDStoreProvider {
|
||||
getData: (key: string) => any;
|
||||
setData: (key: string, value: any) => void;
|
||||
removeData: () => void;
|
||||
}
|
||||
|
||||
class DnDStore {
|
||||
private data: Map<string, Record<string, any>> = new Map();
|
||||
setData(id: string, key: string, value: any) {
|
||||
if (!this.data.has(id)) {
|
||||
this.data.set(id, {});
|
||||
}
|
||||
this.data.get(id)![key] = value;
|
||||
}
|
||||
getData(id: string, key: string) {
|
||||
return this.data.get(id)?.[key];
|
||||
}
|
||||
removeData(id: string) {
|
||||
this.data.delete(id);
|
||||
}
|
||||
|
||||
getProvider(id: string) {
|
||||
return {
|
||||
getData: (key: string) => this.getData(id, key),
|
||||
setData: (key: string, value: any) => {
|
||||
this.setData(id, key, value);
|
||||
},
|
||||
removeData: () => {
|
||||
this.removeData(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoreProvider(event: React.DragEvent<HTMLElement>): DnDStoreProvider | null {
|
||||
const id = event.dataTransfer.types.find(type => type.startsWith(DND_STORE_PREFIX))?.slice(DND_STORE_PREFIX.length);
|
||||
|
||||
return id ? globalDnDStore.getProvider(id) : null;
|
||||
}
|
||||
|
||||
// TODO: do we want to use react context here?
|
||||
// the main idea was to use native DataTransfer, but it's available only for drag start and drop events
|
||||
// so we need to use a custom store
|
||||
export const globalDnDStore = new DnDStore();
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useId, useMemo, useRef, useState } from 'react';
|
||||
import { DND_STORE_PREFIX, globalDnDStore, type DnDStoreProvider } from './store.js';
|
||||
|
||||
interface IDragOptions {
|
||||
draggable?: boolean;
|
||||
onDrag?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider) => void;
|
||||
onDragStart?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider) => void;
|
||||
onDragEnd?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider) => void;
|
||||
}
|
||||
interface IDrag {
|
||||
isDragging: boolean;
|
||||
props: {
|
||||
draggable?: boolean;
|
||||
onDrag: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDragStart: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDragEnd: (event: React.DragEvent<HTMLElement>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useDrag({ draggable, onDragStart, onDrag, onDragEnd }: IDragOptions = {}): IDrag {
|
||||
const id = useId();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const optionsRef = useRef<IDragOptions>({ draggable, onDragStart, onDrag, onDragEnd });
|
||||
optionsRef.current = { draggable, onDragStart, onDrag, onDragEnd };
|
||||
|
||||
function handleDragStart(event: React.DragEvent<HTMLElement>) {
|
||||
if (!optionsRef.current.draggable) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(`${DND_STORE_PREFIX}${id}`, id);
|
||||
setDragging(true);
|
||||
optionsRef.current.onDragStart?.(event, globalDnDStore.getProvider(id));
|
||||
}
|
||||
|
||||
function handleDrag(event: React.DragEvent<HTMLElement>) {
|
||||
if (!optionsRef.current.draggable) {
|
||||
return;
|
||||
}
|
||||
optionsRef.current.onDrag?.(event, globalDnDStore.getProvider(id));
|
||||
}
|
||||
|
||||
function handleDragEnd(event: React.DragEvent<HTMLElement>) {
|
||||
if (!optionsRef.current.draggable) {
|
||||
return;
|
||||
}
|
||||
setDragging(false);
|
||||
optionsRef.current.onDragEnd?.(event, globalDnDStore.getProvider(id));
|
||||
globalDnDStore.removeData(id);
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging: dragging,
|
||||
props: {
|
||||
draggable,
|
||||
...useMemo(
|
||||
() => ({
|
||||
onDrag: handleDrag,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { getStoreProvider, type DnDStoreProvider } from './store.js';
|
||||
|
||||
interface IDropOptions {
|
||||
canDrop?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider | null) => boolean;
|
||||
onDrop?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider | null) => void;
|
||||
onDragOver?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider | null) => void;
|
||||
onDragEnter?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider | null) => void;
|
||||
onDragLeave?: (event: React.DragEvent<HTMLElement>, provider: DnDStoreProvider | null) => void;
|
||||
}
|
||||
|
||||
interface IDrop {
|
||||
props: {
|
||||
onDrop: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDragEnter: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDragLeave: (event: React.DragEvent<HTMLElement>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useDrop({ canDrop, onDrop, onDragOver, onDragEnter, onDragLeave }: IDropOptions = {}): IDrop {
|
||||
const optionsRef = useRef<IDropOptions>({ canDrop, onDrop, onDragOver, onDragEnter, onDragLeave });
|
||||
optionsRef.current = { canDrop, onDrop, onDragOver, onDragEnter, onDragLeave };
|
||||
|
||||
function handleOver(event: React.DragEvent<HTMLElement>) {
|
||||
const provider = getStoreProvider(event);
|
||||
const isDroppable = optionsRef.current.canDrop?.(event, provider);
|
||||
if (isDroppable) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
} else {
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
optionsRef.current.onDragOver?.(event, provider);
|
||||
}
|
||||
|
||||
function handleEnter(event: React.DragEvent<HTMLElement>) {
|
||||
const provider = getStoreProvider(event);
|
||||
optionsRef.current.onDragEnter?.(event, provider);
|
||||
}
|
||||
function handleLeave(event: React.DragEvent<HTMLElement>) {
|
||||
const provider = getStoreProvider(event);
|
||||
optionsRef.current.onDragLeave?.(event, provider);
|
||||
}
|
||||
function handleDrop(event: React.DragEvent<HTMLElement>) {
|
||||
const provider = getStoreProvider(event);
|
||||
optionsRef.current.onDrop?.(event, provider);
|
||||
}
|
||||
|
||||
return {
|
||||
props: useMemo(
|
||||
() => ({
|
||||
onDrop: handleDrop,
|
||||
onDragOver: handleOver,
|
||||
onDragEnter: handleEnter,
|
||||
onDragLeave: handleLeave,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@dbeaver/tsconfig/tsconfig.webview.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./lib/.tsbuildinfo",
|
||||
"rootDir": "src",
|
||||
"rootDirs": [
|
||||
"src"
|
||||
],
|
||||
"outDir": "lib",
|
||||
"incremental": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@import 'tailwindcss' prefix(tw);
|
||||
@import 'tailwindcss' layer(base) prefix(tw);
|
||||
|
||||
@layer theme {
|
||||
main {
|
||||
@@ -86,6 +86,7 @@
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--dbv-kit-color-foreground: var(--tw-color-gray-200);
|
||||
--dbv-kit-color-background: var(--tw-color-gray-900);
|
||||
--dbv-kit-btn-loader-base-color: var(--tw-color-gray-900);
|
||||
|
||||
/* Primary Button */
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="currentColor">
|
||||
<polygon points="2 10 12 10 7 4 2 10" />
|
||||
<rect x="6" y="6" width="2" height="22" />
|
||||
<rect x="14" y="6" width="16" height="2" />
|
||||
<rect x="14" y="15" width="12" height="2" />
|
||||
<rect x="14" y="24" width="8" height="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="2 8.14 9.14 8.14 5.57 3.86 2 8.14" />
|
||||
<rect x="4.86" y="5.29" width="1.43" height="15.71" />
|
||||
<rect x="10.57" y="5.29" width="11.43" height="1.43" />
|
||||
<rect x="10.57" y="11.71" width="8.57" height="1.43" />
|
||||
<rect x="10.57" y="18.14" width="5.71" height="1.43" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
||||
<polygon points="0 4 5 4 2.5 1 0 4" />
|
||||
<rect x="2" y="4" width="1" height="11" />
|
||||
<rect x="6" y="3" width="10" height="1" />
|
||||
<rect x="6" y="8" width="7" height="1" />
|
||||
<rect x="6" y="13" width="4" height="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="currentColor">
|
||||
<polygon points="12 22 2 22 7 28 12 22" />
|
||||
<rect x="6" y="4" width="2" height="22" />
|
||||
<rect x="14" y="6" width="16" height="2" />
|
||||
<rect x="14" y="15" width="12" height="2" />
|
||||
<rect x="14" y="24" width="8" height="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="9.14 16.71 2 16.71 5.57 21 9.14 16.71" />
|
||||
<rect x="4.86" y="3.86" width="1.43" height="15.71" />
|
||||
<rect x="10.57" y="5.29" width="11.43" height="1.43" />
|
||||
<rect x="10.57" y="11.71" width="8.57" height="1.43" />
|
||||
<rect x="10.57" y="18.14" width="5.71" height="1.43" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
||||
<polygon points="5 12 0 12 2.5 15 5 12" />
|
||||
<rect x="2" y="1" width="1" height="12" />
|
||||
<rect x="6" y="3" width="10" height="1" />
|
||||
<rect x="6" y="8" width="7" height="1" />
|
||||
<rect x="6" y="13" width="4" height="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="currentColor">
|
||||
<polygon points="4 10 14 10 9 4 4 10" />
|
||||
<rect x="8" y="6" width="2" height="22" />
|
||||
<polygon points="28 22 18 22 23 28 28 22" />
|
||||
<rect x="22" y="4" width="2" height="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="3.4 8.29 10.55 8.29 6.98 4 3.4 8.29" />
|
||||
<rect x="6.26" y="5.43" width="1.43" height="15.71" />
|
||||
<polygon points="20.55 16.86 13.4 16.86 16.98 21.14 20.55 16.86" />
|
||||
<rect x="16.26" y="4" width="1.43" height="15.71" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 367 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
||||
<polygon points="2 4 7 4 4.5 1 2 4" />
|
||||
<rect x="4" y="4" width="1" height="11" />
|
||||
<polygon points="14 12 9 12 11.5 15 14 12" />
|
||||
<rect x="11" y="1" width="1" height="11" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="icon-sort-unknown" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="3.4 8.29 10.55 8.29 6.98 4 3.4 8.29" />
|
||||
<rect x="6.26" y="5.43" width="1.43" height="15.71" />
|
||||
<polygon points="20.55 16.86 13.4 16.86 16.98 21.14 20.55 16.86" />
|
||||
<rect x="16.26" y="4" width="1.43" height="15.71" />
|
||||
</symbol>
|
||||
<symbol id="icon-sort-desc" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<polygon points="9.14 16.71 2 16.71 5.57 21 9.14 16.71" />
|
||||
<rect x="4.86" y="3.86" width="1.43" height="15.71" />
|
||||
<rect x="10.57" y="5.29" width="11.43" height="1.43" />
|
||||
<rect x="10.57" y="11.71" width="8.57" height="1.43" />
|
||||
<rect x="10.57" y="18.14" width="5.71" height="1.43" />
|
||||
</symbol>
|
||||
<symbol id="icon-sort-asc" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="2 8.14 9.14 8.14 5.57 3.86 2 8.14" />
|
||||
<rect x="4.86" y="5.29" width="1.43" height="15.71" />
|
||||
<rect x="10.57" y="5.29" width="11.43" height="1.43" />
|
||||
<rect x="10.57" y="11.71" width="8.57" height="1.43" />
|
||||
<rect x="10.57" y="18.14" width="5.71" height="1.43" />
|
||||
</symbol>
|
||||
<symbol id="icon-cross" viewBox="0 0 24 24">
|
||||
<path xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.31579 3.31579C2.89474 3.73684 2.89474 4.42105 3.31579 4.84211L10.4737 12L3.31579 19.1579C2.89474 19.5789 2.89474 20.2632 3.31579 20.6842C3.73684 21.1053 4.42105 21.1053 4.84211 20.6842L12 13.5263L19.1579 20.6842C19.5789 21.1053 20.2632 21.1053 20.6842 20.6842C21.1053 20.2632 21.1053 19.5789 20.6842 19.1579L13.5263 12L20.6842 4.84211C21.1053 4.42105 21.1053 3.73684 20.6842 3.31579C20.2632 2.89474 19.5789 2.89474 19.1579 3.31579L12 10.4737L4.84211 3.31579C4.42105 2.89474 3.73684 2.89474 3.31579 3.31579Z" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -7,7 +7,8 @@
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./*.css": "./src/*.css",
|
||||
"./*": "./lib/*.js"
|
||||
"./*": "./lib/*.js",
|
||||
"./assets/icons/*.svg": "./assets/icons/*.svg"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
@@ -18,13 +19,13 @@
|
||||
"devDependencies": {
|
||||
"@dbeaver/tsconfig": "workspace:^",
|
||||
"@ladle/react": "^5",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@tailwindcss/vite": "4.0.7",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@wroud/vite-plugin-asset-resolver": "^0",
|
||||
"@wroud/vite-plugin-tsc": "^0.11.6",
|
||||
"rimraf": "^6",
|
||||
"tailwindcss": "^4",
|
||||
"tailwindcss": "4.0.7",
|
||||
"typescript": "^5",
|
||||
"vite": "^6"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export default function template(files) {
|
||||
const exportLines = files.map((file) => {
|
||||
const { path } = file;
|
||||
|
||||
const fileName = path.split('/').pop();
|
||||
const componentNameWithExt = fileName.replace(/\.tsx$/, '.js');
|
||||
const componentName = fileName.replace(/\.tsx$/, '');
|
||||
|
||||
return `export { default as ${componentName} } from './${componentNameWithExt}';`;
|
||||
});
|
||||
|
||||
|
||||
const header = '/* This file is automatically generated by the build:icons script. Do not modify it manually.*/\n\n ';
|
||||
|
||||
return header + exportLines.join('\n');
|
||||
};
|
||||
@@ -4,19 +4,8 @@
|
||||
|
||||
@layer base {
|
||||
.dbv-kit-button {
|
||||
display: inline-flex;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
height: var(--dbv-kit-btn-height);
|
||||
padding-inline: var(--dbv-kit-btn-padding-inline);
|
||||
gap: var(--dbv-kit-btn-gap);
|
||||
|
||||
background-color: var(--dbv-kit-btn-background);
|
||||
color: var(--dbv-kit-btn-foreground);
|
||||
@@ -29,6 +18,8 @@
|
||||
font-size: var(--dbv-kit-btn-font-size);
|
||||
font-weight: var(--dbv-kit-btn-font-weight);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible,
|
||||
&[data-focus-visible] {
|
||||
outline: var(--dbv-kit-control-outline-width) solid var(--dbv-kit-btn-outline-color);
|
||||
@@ -37,18 +28,16 @@
|
||||
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--dbv-kit-btn-foreground-hover);
|
||||
background-color: var(--dbv-kit-btn-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:active,
|
||||
&[data-active] {
|
||||
color: var(--dbv-kit-btn-foreground-active);
|
||||
background-color: var(--dbv-kit-btn-background-active);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: var(--dbv-kit-control-disabled-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------- */
|
||||
@@ -94,55 +83,76 @@
|
||||
/* Variants */
|
||||
|
||||
.dbv-kit-button--primary {
|
||||
--dbv-kit-btn-background: var(--dbv-kit-btn-primary-background);
|
||||
--dbv-kit-btn-foreground: var(--dbv-kit-btn-primary-foreground);
|
||||
--dbv-kit-btn-foreground-hover: var(--dbv-kit-btn-primary-foreground-hover);
|
||||
--dbv-kit-btn-foreground-active: var(--dbv-kit-btn-primary-foreground-active);
|
||||
|
||||
--dbv-kit-btn-background: var(--dbv-kit-btn-primary-background);
|
||||
--dbv-kit-btn-background-hover: var(--dbv-kit-btn-primary-background-hover);
|
||||
--dbv-kit-btn-background-active: var(--dbv-kit-btn-primary-background-active);
|
||||
|
||||
--dbv-kit-btn-loader-color: var(--dbv-kit-btn-background-hover);
|
||||
--dbv-kit-btn-loader-color: var(--dbv-kit-btn-primary-foreground);
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--dbv-kit-control-disabled-background);
|
||||
color: var(--dbv-kit-control-disabled-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-button--secondary {
|
||||
--dbv-kit-btn-background: var(--dbv-kit-btn-secondary-background);
|
||||
--dbv-kit-btn-foreground: var(--dbv-kit-btn-secondary-foreground);
|
||||
--dbv-kit-btn-foreground-hover: var(--dbv-kit-btn-secondary-foreground-hover);
|
||||
--dbv-kit-btn-foreground-active: var(--dbv-kit-btn-secondary-foreground-active);
|
||||
|
||||
--dbv-kit-btn-background: var(--dbv-kit-btn-secondary-background);
|
||||
--dbv-kit-btn-background-hover: var(--dbv-kit-btn-secondary-background-hover);
|
||||
--dbv-kit-btn-background-active: var(--dbv-kit-btn-secondary-background-active);
|
||||
--dbv-kit-btn-border-color: var(--dbv-kit-btn-secondary-border-color);
|
||||
|
||||
--dbv-kit-btn-loader-color: var(--dbv-kit-btn-secondary-foreground);
|
||||
|
||||
&:disabled {
|
||||
color: var(--dbv-kit-control-disabled-foreground);
|
||||
border-color: var(--dbv-kit-control-disabled-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-button--danger {
|
||||
--dbv-kit-btn-background: var(--dbv-kit-btn-danger-background);
|
||||
--dbv-kit-btn-foreground: var(--dbv-kit-btn-danger-foreground);
|
||||
--dbv-kit-btn-foreground-hover: var(--dbv-kit-btn-danger-foreground);
|
||||
--dbv-kit-btn-foreground-active: var(--dbv-kit-btn-danger-foreground-active);
|
||||
|
||||
--dbv-kit-btn-background: var(--dbv-kit-btn-danger-background);
|
||||
--dbv-kit-btn-background-hover: var(--dbv-kit-btn-danger-background-hover);
|
||||
--dbv-kit-btn-background-active: var(--dbv-kit-btn-danger-background-active);
|
||||
--dbv-kit-btn-border-color: var(--dbv-kit-btn-danger-border-color);
|
||||
|
||||
--dbv-kit-btn-loader-color: var(--dbv-kit-btn-danger-background);
|
||||
--dbv-kit-btn-loader-color: var(--dbv-kit-btn-danger-foreground);
|
||||
--dbv-kit-btn-outline-color: var(--dbv-kit-btn-danger-color-outline);
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--dbv-kit-control-disabled-background);
|
||||
color: var(--dbv-kit-control-disabled-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------- */
|
||||
/* Loader */
|
||||
|
||||
.dbv-kit-button[data-loading='true'] {
|
||||
opacity: 0.75;
|
||||
color: color-mix(in oklch, var(--dbv-kit-btn-foreground) 25%, var(--dbv-kit-btn-loader-base-color));
|
||||
background-color: color-mix(in oklch, var(--dbv-kit-btn-loader-base-color) 75%, var(--dbv-kit-btn-background));
|
||||
background-color: var(--dbv-kit-btn-background-hover);
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&[data-active] {
|
||||
background-color: color-mix(in oklch, var(--dbv-kit-btn-loader-base-color) 85%, var(--dbv-kit-btn-background));
|
||||
background-color: var(--dbv-kit-btn-background-active);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-button__loader {
|
||||
.dbv-kit-button .dbv-kit-spinner {
|
||||
--dbv-kit-spinner-stroke-color: var(--dbv-kit-btn-loader-color);
|
||||
--dbv-kit-spinner-height: calc(var(--dbv-kit-btn-height) * 0.6);
|
||||
--dbv-kit-spinner-stroke-width: 3px;
|
||||
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ---------------------------- */
|
||||
|
||||
@@ -5,34 +5,21 @@
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
import { Button as AriaButton, type ButtonProps as AriaKitButtonProps } from '@ariakit/react';
|
||||
import clsx from 'clsx';
|
||||
import './Button.css';
|
||||
import type { ControlSize } from '../types/controls.js';
|
||||
import { componentProviderWrapper } from '../componentProviderWrapper.js';
|
||||
import { Spinner } from '../Spinner/Spinner.js';
|
||||
import { ButtonRoot, type ButtonRootProps } from './ButtonRoot.js';
|
||||
import './Button.css';
|
||||
|
||||
export interface ButtonProps extends Omit<AriaKitButtonProps, 'clickOnEnter' | 'clickOnSpace'> {
|
||||
export interface ButtonProps extends ButtonRootProps {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: ControlSize;
|
||||
loading?: boolean;
|
||||
loader?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ButtonBase({ className, variant = 'primary', size = 'medium', loading, loader, children, onClick, ...props }: ButtonProps) {
|
||||
export function ButtonBase({ className, variant = 'primary', size = 'medium', ...props }: ButtonProps) {
|
||||
const classToApply = clsx('dbv-kit-button', `dbv-kit-button--${variant}`, `dbv-kit-button--${size}`, className);
|
||||
|
||||
if (loading) {
|
||||
props['aria-busy'] = true;
|
||||
props['data-loading'] = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<AriaButton onClick={loading ? () => null : onClick} className={classToApply} {...props}>
|
||||
{loading && (loader ? loader : <Spinner className="dbv-kit-button__loader" />)}
|
||||
{children}
|
||||
</AriaButton>
|
||||
);
|
||||
return <ButtonRoot className={classToApply} {...props} />;
|
||||
}
|
||||
export interface ButtonIconProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
placement?: 'start' | 'end';
|
||||
@@ -43,9 +30,10 @@ export interface ButtonIconProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
*
|
||||
* @param props.placement This property is needed to adjust icon placement inside a button. The icon with placement="start" will cut the inline-start padding. placement="end" will affect the padding-inline-end accordingly. This property supports RTL and LTR, so you don't need to think about it.
|
||||
*/
|
||||
ButtonBase.Icon = function ButtonBaseIcon({ className, children, placement }: ButtonIconProps) {
|
||||
export function ButtonBaseIcon({ className, children, placement }: ButtonIconProps) {
|
||||
const classToApply = clsx('dbv-kit-button__icon', placement && `dbv-kit-button__icon--${placement}`, className);
|
||||
return <span className={classToApply}>{children}</span>;
|
||||
};
|
||||
}
|
||||
|
||||
export const Button = componentProviderWrapper('Button', ButtonBase);
|
||||
export const ButtonIcon = componentProviderWrapper('ButtonIcon', ButtonBaseIcon);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
@layer base {
|
||||
.dbv-kit-button__loader {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dbv-kit-button__content {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
gap: var(--dbv-kit-btn-gap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button as AriaButton, type ButtonProps as AriaKitButtonProps } from '@ariakit/react';
|
||||
import { Spinner } from '../Spinner/Spinner.js';
|
||||
import { clsx } from '../utils/clsx.js';
|
||||
import './ButtonRoot.css';
|
||||
|
||||
export interface ButtonRootProps extends Omit<AriaKitButtonProps, 'clickOnEnter' | 'clickOnSpace'> {
|
||||
loading?: boolean;
|
||||
loader?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ButtonRoot({ loading, loader, children, onClick, className, ...props }: ButtonRootProps) {
|
||||
if (loading) {
|
||||
props['aria-busy'] = true;
|
||||
props['data-loading'] = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<AriaButton onClick={loading ? () => null : onClick} className={clsx(className, 'tw:relative')} {...props}>
|
||||
<div className={clsx('dbv-kit-button__content', loading && 'tw:opacity-0')}>{children}</div>
|
||||
{loading && <div className="dbv-kit-button__loader">{loader ? loader : <Spinner />}</div>}
|
||||
</AriaButton>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,18 @@
|
||||
:root {
|
||||
--dbv-kit-btn-height: var(--dbv-kit-control-height-medium);
|
||||
--dbv-kit-btn-padding-inline: calc(var(--tw-spacing) * 2.5);
|
||||
--dbv-kit-btn-gap: calc(var(--dbv-kit-btn-padding-inline) / 4);
|
||||
--dbv-kit-btn-gap: calc(var(--dbv-kit-btn-padding-inline) / 2);
|
||||
|
||||
--dbv-kit-btn-outline-offset: var(--dbv-kit-control-outline-offset);
|
||||
--dbv-kit-btn-outline-color: var(--dbv-kit-control-outline-color);
|
||||
|
||||
--dbv-kit-btn-foreground: var(--tw-color-white);
|
||||
--dbv-kit-btn-foreground-hover: var(--tw-color-white);
|
||||
--dbv-kit-btn-foreground-active: var(--tw-color-white);
|
||||
|
||||
--dbv-kit-btn-background: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-btn-background-hover: var(--dbv-kit-color-primary-700);
|
||||
--dbv-kit-btn-background-active: var(--dbv-kit-color-primary-800);
|
||||
--dbv-kit-btn-outline-color: var(--dbv-kit-control-outline-color);
|
||||
|
||||
--dbv-kit-btn-border-width: var(--dbv-kit-control-border-width);
|
||||
--dbv-kit-btn-border-color: transparent;
|
||||
|
||||
@@ -2,22 +2,30 @@
|
||||
:root {
|
||||
/* Primary Button */
|
||||
--dbv-kit-btn-primary-foreground: var(--tw-color-white);
|
||||
--dbv-kit-btn-primary-foreground-hover: var(--tw-color-white);
|
||||
--dbv-kit-btn-primary-foreground-active: var(--tw-color-white);
|
||||
--dbv-kit-btn-primary-background: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-btn-primary-background-hover: var(--dbv-kit-color-primary-700);
|
||||
--dbv-kit-btn-primary-background-active: var(--dbv-kit-color-primary-800);
|
||||
|
||||
/* Secondary Button */
|
||||
--dbv-kit-btn-secondary-foreground: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-btn-secondary-foreground-hover: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-btn-secondary-foreground-active: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-btn-secondary-background: transparent;
|
||||
--dbv-kit-btn-secondary-background-hover: var(--dbv-kit-color-primary-50);
|
||||
--dbv-kit-btn-secondary-background-active: var(--dbv-kit-color-primary-100);
|
||||
--dbv-kit-btn-secondary-border-color: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-btn-secondary-border-color-hover: var(--dbv-kit-color-primary-600);
|
||||
|
||||
/* Danger Button */
|
||||
--dbv-kit-btn-danger-foreground: var(--tw-color-white);
|
||||
--dbv-kit-btn-danger-foreground-hover: var(--tw-color-white);
|
||||
--dbv-kit-btn-danger-foreground-active: var(--tw-color-white);
|
||||
--dbv-kit-btn-danger-background: var(--tw-color-red-600);
|
||||
--dbv-kit-btn-danger-background-hover: var(--tw-color-red-700);
|
||||
--dbv-kit-btn-danger-background-active: var(--tw-color-red-800);
|
||||
--dbv-kit-btn-danger-border-color: transparent;
|
||||
--dbv-kit-btn-danger-color-outline: var(--tw-color-red-300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ export function CheckboxBase({ children, className, icon, indeterminate, indeter
|
||||
>
|
||||
{checked &&
|
||||
(icon ?? (
|
||||
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 16 16">
|
||||
<polyline points="3,7 7,11 13,4" />
|
||||
<svg stroke="currentColor" strokeWidth="2" viewBox="0 0 16 16">
|
||||
<polyline fill="none" points="3,7 7,11 13,4" />
|
||||
</svg>
|
||||
))}
|
||||
{indeterminate &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext } from 'react';
|
||||
import type { ButtonBase } from './Button/Button.js';
|
||||
import type { ButtonBase, ButtonBaseIcon } from './Button/Button.js';
|
||||
import type { IconButtonBase } from './IconButton/IconButton.js';
|
||||
import type { CheckboxBase } from './Checkbox/Checkbox.js';
|
||||
import type { Radio } from './Radio/Radio.js';
|
||||
import type { InputBase } from './Input/Input.js';
|
||||
@@ -9,6 +10,8 @@ import type { SelectField } from './Select/SelectField.js';
|
||||
|
||||
export interface IComponentProvider {
|
||||
Button?: typeof ButtonBase;
|
||||
ButtonIcon?: typeof ButtonBaseIcon;
|
||||
IconButton?: typeof IconButtonBase;
|
||||
Input?: typeof InputBase;
|
||||
Checkbox?: typeof CheckboxBase;
|
||||
Radio?: typeof Radio;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
@import './_base.css';
|
||||
@import './_sizes.css';
|
||||
@import './_variants.css';
|
||||
|
||||
@layer base {
|
||||
.dbv-kit-icon-button {
|
||||
min-width: var(--dbv-kit-icon-btn-size);
|
||||
width: var(--dbv-kit-icon-btn-size);
|
||||
height: var(--dbv-kit-icon-btn-size);
|
||||
font-size: calc(var(--dbv-kit-icon-btn-size) * 0.75);
|
||||
|
||||
border-radius: var(--dbv-kit-icon-btn-border-radius);
|
||||
border-color: var(--dbv-kit-icon-btn-border-color);
|
||||
border-width: var(--dbv-kit-icon-btn-border-width);
|
||||
border-style: var(--dbv-kit-icon-btn-border-style);
|
||||
|
||||
color: var(--dbv-kit-icon-btn-foreground);
|
||||
background-color: var(--dbv-kit-icon-btn-background);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible,
|
||||
&[data-focus-visible] {
|
||||
outline: var(--dbv-kit-icon-btn-outline-width) solid var(--dbv-kit-icon-btn-outline-color);
|
||||
outline-offset: var(--dbv-kit-icon-btn-outline-offset);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--dbv-kit-icon-btn-foreground-hover);
|
||||
background-color: var(--dbv-kit-icon-btn-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:active,
|
||||
&[data-active] {
|
||||
color: var(--dbv-kit-icon-btn-foreground-active);
|
||||
background-color: var(--dbv-kit-icon-btn-background-active);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: var(--dbv-kit-control-disabled-opacity);
|
||||
}
|
||||
|
||||
&[data-loading='true'] {
|
||||
background-color: var(--dbv-kit-icon-btn-background);
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&[data-active] {
|
||||
background-color: var(--dbv-kit-icon-btn-background-active);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-spinner {
|
||||
--dbv-kit-spinner-stroke-color: var(--dbv-kit-icon-btn-loader-color);
|
||||
--dbv-kit-spinner-height: calc(var(--dbv-kit-icon-btn-size) * 0.8);
|
||||
--dbv-kit-spinner-stroke-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.dbv-kit-icon-button--small {
|
||||
--dbv-kit-icon-btn-size: var(--dbv-kit-icon-btn-small-size);
|
||||
}
|
||||
|
||||
.dbv-kit-icon-button--medium {
|
||||
--dbv-kit-icon-btn-size: var(--dbv-kit-icon-btn-medium-size);
|
||||
}
|
||||
|
||||
.dbv-kit-icon-button--large {
|
||||
--dbv-kit-icon-btn-size: var(--dbv-kit-icon-btn-large-size);
|
||||
}
|
||||
|
||||
.dbv-kit-icon-button--xlarge {
|
||||
--dbv-kit-icon-btn-size: var(--dbv-kit-icon-btn-xlarge-size);
|
||||
}
|
||||
|
||||
/* Variant styles */
|
||||
.dbv-kit-icon-button--primary {
|
||||
--dbv-kit-icon-btn-foreground: var(--dbv-kit-icon-btn-primary-foreground);
|
||||
--dbv-kit-icon-btn-foreground-hover: var(--dbv-kit-icon-btn-primary-foreground-hover);
|
||||
--dbv-kit-icon-btn-foreground-active: var(--dbv-kit-icon-btn-primary-foreground-active);
|
||||
--dbv-kit-icon-btn-background: var(--dbv-kit-icon-btn-primary-background);
|
||||
--dbv-kit-icon-btn-background-hover: var(--dbv-kit-icon-btn-primary-background-hover);
|
||||
--dbv-kit-icon-btn-background-active: var(--dbv-kit-icon-btn-primary-background-active);
|
||||
--dbv-kit-icon-btn-loader-color: var(--dbv-kit-icon-btn-primary-foreground);
|
||||
}
|
||||
|
||||
.dbv-kit-icon-button--secondary {
|
||||
--dbv-kit-icon-btn-foreground: var(--dbv-kit-icon-btn-secondary-foreground);
|
||||
--dbv-kit-icon-btn-foreground-hover: var(--dbv-kit-icon-btn-secondary-foreground-hover);
|
||||
--dbv-kit-icon-btn-foreground-active: var(--dbv-kit-icon-btn-secondary-foreground-active);
|
||||
--dbv-kit-icon-btn-background: var(--dbv-kit-icon-btn-secondary-background);
|
||||
--dbv-kit-icon-btn-background-hover: var(--dbv-kit-icon-btn-secondary-background-hover);
|
||||
--dbv-kit-icon-btn-background-active: var(--dbv-kit-icon-btn-secondary-background-active);
|
||||
--dbv-kit-icon-btn-border-color: var(--dbv-kit-icon-btn-secondary-border-color);
|
||||
--dbv-kit-icon-btn-loader-color: var(--dbv-kit-icon-btn-secondary-loader-color);
|
||||
}
|
||||
|
||||
.dbv-kit-icon-button--danger {
|
||||
--dbv-kit-icon-btn-foreground: var(--dbv-kit-icon-btn-danger-foreground);
|
||||
--dbv-kit-icon-btn-foreground-hover: var(--dbv-kit-icon-btn-danger-foreground-hover);
|
||||
--dbv-kit-icon-btn-foreground-active: var(--dbv-kit-icon-btn-danger-foreground-active);
|
||||
--dbv-kit-icon-btn-background: var(--dbv-kit-icon-btn-danger-background);
|
||||
--dbv-kit-icon-btn-background-hover: var(--dbv-kit-icon-btn-danger-background-hover);
|
||||
--dbv-kit-icon-btn-background-active: var(--dbv-kit-icon-btn-danger-background-active);
|
||||
--dbv-kit-icon-btn-loader-color: var(--dbv-kit-icon-btn-danger-loader-color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 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 { clsx } from 'clsx';
|
||||
|
||||
import { componentProviderWrapper } from '../componentProviderWrapper.js';
|
||||
import { ButtonRoot, type ButtonRootProps } from '../Button/ButtonRoot.js';
|
||||
import type { ControlSize } from '../types/controls.js';
|
||||
|
||||
import './IconButton.css';
|
||||
|
||||
export interface IconButtonProps extends ButtonRootProps {
|
||||
'aria-label': string;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: ControlSize;
|
||||
}
|
||||
|
||||
export function IconButtonBase({ className, variant = 'primary', size = 'medium', children, ...props }: IconButtonProps) {
|
||||
const classToApply = clsx('dbv-kit-icon-button', `dbv-kit-icon-button--${variant}`, `dbv-kit-icon-button--${size}`, className);
|
||||
|
||||
return (
|
||||
<ButtonRoot className={classToApply} {...props}>
|
||||
{children}
|
||||
</ButtonRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconButton = componentProviderWrapper('IconButton', IconButtonBase);
|
||||
@@ -0,0 +1,30 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/* Icon Button border variables */
|
||||
--dbv-kit-icon-btn-border-radius: var(--dbv-kit-btn-border-radius);
|
||||
--dbv-kit-icon-btn-border-width: var(--dbv-kit-btn-border-width);
|
||||
--dbv-kit-icon-btn-border-style: var(--dbv-kit-btn-border-style);
|
||||
--dbv-kit-icon-btn-border-color: var(--dbv-kit-btn-border-color);
|
||||
|
||||
/* Icon Button outline variables */
|
||||
--dbv-kit-icon-btn-outline-width: var(--dbv-kit-control-outline-width);
|
||||
--dbv-kit-icon-btn-outline-style: var(--dbv-kit-control-outline-style);
|
||||
--dbv-kit-icon-btn-outline-color: var(--dbv-kit-control-outline-color);
|
||||
--dbv-kit-icon-btn-outline-offset: var(--dbv-kit-control-outline-offset);
|
||||
|
||||
/* Default size */
|
||||
--dbv-kit-icon-btn-size: var(--dbv-kit-control-height-medium);
|
||||
|
||||
/* Default colors */
|
||||
--dbv-kit-icon-btn-foreground: var(--tw-color-gray-600);
|
||||
--dbv-kit-icon-btn-foreground-hover: var(--tw-color-gray-600);
|
||||
--dbv-kit-icon-btn-foreground-active: var(--tw-color-gray-700);
|
||||
--dbv-kit-icon-btn-background: transparent;
|
||||
--dbv-kit-icon-btn-background-hover: var(--tw-color-gray-100);
|
||||
--dbv-kit-icon-btn-background-active: var(--tw-color-gray-200);
|
||||
|
||||
/* Loader color */
|
||||
--dbv-kit-btn-loader-color: var(--dbv-kit-btn-foreground-hover);
|
||||
--dbv-kit-btn-loader-base-color: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--dbv-kit-icon-btn-small-size: var(--dbv-kit-control-height-small);
|
||||
--dbv-kit-icon-btn-medium-size: var(--dbv-kit-control-height-medium);
|
||||
--dbv-kit-icon-btn-large-size: var(--dbv-kit-control-height-large);
|
||||
--dbv-kit-icon-btn-xlarge-size: var(--dbv-kit-control-height-xlarge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/* Primary */
|
||||
--dbv-kit-icon-btn-primary-foreground: var(--tw-color-gray-600);
|
||||
--dbv-kit-icon-btn-primary-foreground-hover: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-icon-btn-primary-foreground-active: var(--dbv-kit-color-primary-700);
|
||||
--dbv-kit-icon-btn-primary-background: transparent;
|
||||
--dbv-kit-icon-btn-primary-background-hover: var(--tw-color-gray-100);
|
||||
--dbv-kit-icon-btn-primary-background-active: var(--tw-color-gray-200);
|
||||
--dbv-kit-icon-btn-primary-loader-color: var(--tw-color-gray-300);
|
||||
|
||||
/* Secondary */
|
||||
--dbv-kit-icon-btn-secondary-foreground: var(--tw-color-gray-500);
|
||||
--dbv-kit-icon-btn-secondary-foreground-hover: var(--dbv-kit-color-primary-600);
|
||||
--dbv-kit-icon-btn-secondary-foreground-active: var(--dbv-kit-color-primary-700);
|
||||
--dbv-kit-icon-btn-secondary-background: transparent;
|
||||
--dbv-kit-icon-btn-secondary-background-hover: transparent;
|
||||
--dbv-kit-icon-btn-secondary-background-active: transparent;
|
||||
--dbv-kit-icon-btn-secondary-border-color: transparent;
|
||||
--dbv-kit-icon-btn-secondary-loader-color: var(--tw-color-gray-300);
|
||||
|
||||
/* Danger */
|
||||
--dbv-kit-icon-btn-danger-foreground: var(--tw-color-red-600);
|
||||
--dbv-kit-icon-btn-danger-foreground-hover: var(--tw-color-red-700);
|
||||
--dbv-kit-icon-btn-danger-foreground-active: var(--tw-color-red-800);
|
||||
--dbv-kit-icon-btn-danger-background: transparent;
|
||||
--dbv-kit-icon-btn-danger-background-hover: var(--tw-color-red-100);
|
||||
--dbv-kit-icon-btn-danger-background-active: var(--tw-color-red-200);
|
||||
--dbv-kit-icon-btn-danger-loader-color: var(--tw-color-red-300);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
--dbv-kit-control-border-radius: var(--tw-radius-sm);
|
||||
--dbv-kit-control-border-width: 1px;
|
||||
--dbv-kit-control-disabled-opacity: 0.5;
|
||||
--dbv-kit-control-disabled-foreground: var(--tw-color-gray-400);
|
||||
--dbv-kit-control-disabled-background: var(--tw-color-gray-200);
|
||||
|
||||
/* Outline */
|
||||
--dbv-kit-control-outline-color: var(--tw-color-blue-500);
|
||||
|
||||
@@ -21,7 +21,8 @@ export {
|
||||
type FocusTrapRegionProps,
|
||||
} from '@ariakit/react';
|
||||
|
||||
export { Button, ButtonBase, type ButtonProps } from './Button/Button.js';
|
||||
export { Button, ButtonBase, type ButtonProps, ButtonIcon, type ButtonIconProps } from './Button/Button.js';
|
||||
export { IconButton, IconButtonBase, type IconButtonProps } from './IconButton/IconButton.js';
|
||||
export { Checkbox, CheckboxBase, type CheckboxProps } from './Checkbox/Checkbox.js';
|
||||
export { Input, InputBase, type InputProps } from './Input/Input.js';
|
||||
export {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Story } from '@ladle/react';
|
||||
import { Button, type ButtonProps } from '../../../index.js';
|
||||
import { Button, type ButtonProps, ButtonIcon } from '../../../index.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
export { Tokens } from './button-tokens.js';
|
||||
@@ -48,17 +48,24 @@ export const Variants = () => (
|
||||
<code><Button variant="primary"/></code>
|
||||
</div>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<Button loading>Loading</Button>
|
||||
<Button>Primary</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
</div>
|
||||
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<Button variant="secondary" loading>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<Button variant="danger" loading>
|
||||
Danger
|
||||
</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="danger" disabled>
|
||||
Disabled
|
||||
@@ -136,10 +143,10 @@ export const Sizes = () => (
|
||||
export const Icons = () => (
|
||||
<div>
|
||||
<h2>Icons</h2>
|
||||
<h3><Button.Icon></h3>
|
||||
<h3><ButtonIcon></h3>
|
||||
<code>.dbv-kit-button__icon</code>
|
||||
<p>
|
||||
To properly place an icon inside a button, you have to use Button.Icon component. That component will compensate outer padding to balance the
|
||||
To properly place an icon inside a button, you have to use ButtonIcon component. That component will compensate outer padding to balance the
|
||||
button view. You need to specify the placement property to adjust the icon position.
|
||||
</p>
|
||||
<dl>
|
||||
@@ -148,7 +155,7 @@ export const Icons = () => (
|
||||
</dl>
|
||||
<div className="codeblock">
|
||||
<code>
|
||||
<Button> <br /> <Button.Icon placement="start"/>
|
||||
<Button> <br /> <ButtonIcon placement="start"/>
|
||||
<br />
|
||||
Text
|
||||
<br /> <Button/>
|
||||
@@ -159,14 +166,14 @@ export const Icons = () => (
|
||||
<h4>Compare buttons:</h4>
|
||||
<div className="tw:flex tw:gap-4 tw:items-center tw:my-4">
|
||||
<Button variant="secondary">
|
||||
<Button.Icon placement="start">🧤</Button.Icon>
|
||||
<ButtonIcon placement="start">🧤</ButtonIcon>
|
||||
Text
|
||||
</Button>
|
||||
<Button variant="secondary">🧤 Text</Button>
|
||||
</div>
|
||||
<p>
|
||||
The left button uses Button.Icon component and looks more balanced. If you feel that in your case it's not needed, feel free to not use the
|
||||
<b> Button.Icon </b> component.
|
||||
The left button uses ButtonIcon component and looks more balanced. If you feel that in your case it's not needed, feel free to not use the
|
||||
<b> ButtonIcon </b> component.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -183,12 +190,12 @@ export const Icons = () => (
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<Button size="small">
|
||||
Love
|
||||
<Button.Icon placement="end">❤️</Button.Icon>
|
||||
<ButtonIcon placement="end">❤️</ButtonIcon>
|
||||
</Button>
|
||||
|
||||
<Button className="tw:text-lime-300">
|
||||
Have you seen
|
||||
<Button.Icon className="w-2">
|
||||
<ButtonIcon className="w-2">
|
||||
<svg width="16" height="16" fill="none">
|
||||
<g strokeWidth="0"></g>
|
||||
<g strokeLinecap="round" strokeLinejoin="round"></g>
|
||||
@@ -201,24 +208,24 @@ export const Icons = () => (
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</Button.Icon>
|
||||
</ButtonIcon>
|
||||
UFO?
|
||||
</Button>
|
||||
|
||||
<Button size="large">
|
||||
<Button.Icon placement="start">🎾</Button.Icon>
|
||||
<ButtonIcon placement="start">🎾</ButtonIcon>
|
||||
Tennis
|
||||
</Button>
|
||||
|
||||
<Button size="xlarge">
|
||||
<Button.Icon placement="start">🎾</Button.Icon>
|
||||
<ButtonIcon placement="start">🎾</ButtonIcon>
|
||||
Tennis is Love
|
||||
<Button.Icon placement="end">❤️</Button.Icon>
|
||||
<ButtonIcon placement="end">❤️</ButtonIcon>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<Button loading>
|
||||
<Button.Icon className="tw:w-8 tw:h-4" placement="start">
|
||||
<ButtonIcon className="tw:w-8 tw:h-4" placement="start">
|
||||
<svg width="16" height="16" fill="none">
|
||||
<g strokeWidth="0"></g>
|
||||
<g strokeLinecap="round" strokeLinejoin="round"></g>
|
||||
@@ -231,23 +238,23 @@ export const Icons = () => (
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</Button.Icon>
|
||||
</ButtonIcon>
|
||||
UFO
|
||||
</Button>
|
||||
<Button variant="secondary" loading>
|
||||
<Button.Icon placement="start">🎾</Button.Icon>
|
||||
<ButtonIcon placement="start">🎾</ButtonIcon>
|
||||
Tennis
|
||||
</Button>
|
||||
<Button disabled>
|
||||
<Button.Icon placement="start">🎾</Button.Icon>
|
||||
<ButtonIcon placement="start">🎾</ButtonIcon>
|
||||
Tennis
|
||||
</Button>
|
||||
<Button variant="danger">
|
||||
<Button.Icon placement="start">🎾</Button.Icon>
|
||||
<ButtonIcon placement="start">🎾</ButtonIcon>
|
||||
Tennis
|
||||
</Button>
|
||||
<Button>
|
||||
<Button.Icon>🎾</Button.Icon>
|
||||
<ButtonIcon>🎾</ButtonIcon>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import { IconButton } from '../../../index.js';
|
||||
|
||||
const example = `<IconButton
|
||||
variant="primary" | "secondary" | "danger"
|
||||
size="small" | "medium" | "large" | "xlarge"
|
||||
loading={boolean}
|
||||
disabled={boolean}
|
||||
onClick={function}
|
||||
aria-label="string"
|
||||
>
|
||||
{icon}
|
||||
</IconButton>`;
|
||||
|
||||
export const Documentation = () => {
|
||||
return (
|
||||
<div className="tw:space-y-4">
|
||||
<h1 className="tw:text-2xl tw:font-bold">IconButton</h1>
|
||||
<p>IconButton is a button component that displays an icon. It supports various sizes and styles, and can be disabled or in a loading state.</p>
|
||||
|
||||
<pre className="codeblock">{example}</pre>
|
||||
|
||||
<h2 className="tw:text-xl tw:font-semibold">Classnames</h2>
|
||||
<ul className="tw:list-disc tw:pl-5">
|
||||
<li>
|
||||
<code>dbv-kit-icon-button</code> - Main component class
|
||||
</li>
|
||||
<li>
|
||||
<code>dbv-kit-icon-button--primary/secondary/danger</code> - Variant classes
|
||||
</li>
|
||||
<li>
|
||||
<code>dbv-kit-icon-button--small/medium/large/xlarge</code> - Size classes
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="tw:text-xl tw:font-semibold">CSS Tokens</h2>
|
||||
<div className="tw:p-4 tw:rounded-md tw:overflow-auto">
|
||||
<h3 className="tw:font-medium">Structure</h3>
|
||||
<ul className="tw:pl-5 tw:mb-2">
|
||||
<li>
|
||||
<code>--dbv-kit-icon-btn-border-radius</code> - Controls button corner roundness
|
||||
</li>
|
||||
<li>
|
||||
<code>--dbv-kit-icon-btn-border-width/style/color</code> - Border properties
|
||||
</li>
|
||||
<li>
|
||||
<code>--dbv-kit-icon-btn-size</code> - Default button size (medium)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="tw:font-medium">Focus State</h3>
|
||||
<ul className="tw:pl-5 tw:mb-2">
|
||||
<li>
|
||||
<code>--dbv-kit-icon-btn-outline-color/offset</code> - Focus outline appearance
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="tw:font-medium">Colors</h3>
|
||||
<ul className="tw:pl-5">
|
||||
<li>
|
||||
<code>--dbv-kit-icon-btn-foreground/background</code> - Default state colors
|
||||
</li>
|
||||
<li>
|
||||
<code>--dbv-kit-icon-btn-foreground/background-hover</code> - Hover state colors
|
||||
</li>
|
||||
<li>
|
||||
<code>--dbv-kit-icon-btn-foreground/background-active</code> - Active state colors
|
||||
</li>
|
||||
<li>
|
||||
<code>--dbv-kit-btn-loader-color/base-color</code> - Loading spinner colors
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconButtons = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tw:flex tw:flex-col tw:gap-4 tw:my-4 tw:items-center">
|
||||
<div>
|
||||
<h2>Primary</h2>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<IconButton variant="primary" size="small" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="primary" size="medium" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="primary" size="large" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="primary" size="xlarge" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Secondary</h2>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<IconButton variant="secondary" size="small" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="secondary" size="medium" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="secondary" size="large" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="secondary" size="xlarge" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Danger</h2>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<IconButton variant="danger" size="small" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="danger" size="medium" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="danger" size="large" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="danger" size="xlarge" aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<IconButton variant="primary" size="small" disabled aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="secondary" size="medium" disabled aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="danger" size="large" disabled aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</div>
|
||||
<h2>Loading</h2>
|
||||
<div className="tw:flex tw:gap-4 tw:my-4 tw:items-center">
|
||||
<IconButton variant="primary" size="small" loading aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="secondary" loading={isLoading} onClick={handleClick} aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton size="large" loading={isLoading} onClick={handleClick} aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton variant="danger" size="xlarge" loading={isLoading} onClick={handleClick} aria-label="Add item">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/* this file required for tailwindcss vscode extension to work */
|
||||
@import './@dbeaver/ui-kit/.ladle/global.css';
|
||||
@@ -0,0 +1 @@
|
||||
# @dbeaver/table-data
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@dbeaver/table-data",
|
||||
"type": "module",
|
||||
"sideEffects": [],
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./*": "./lib/*.js"
|
||||
},
|
||||
"scripts": {
|
||||
"clear": "rimraf lib",
|
||||
"build": "tsc -b",
|
||||
"test": "dbeaver-test"
|
||||
},
|
||||
"files": [
|
||||
"package.json",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"lib",
|
||||
"!lib/**/*.d.ts.map",
|
||||
"!lib/**/*.test.js",
|
||||
"!lib/**/*.test.d.ts",
|
||||
"!lib/**/*.test.d.ts.map",
|
||||
"!lib/**/*.test.js.map",
|
||||
"!lib/tests",
|
||||
"!.tsbuildinfo"
|
||||
],
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"devDependencies": {
|
||||
"@dbeaver/cli": "workspace:^",
|
||||
"@dbeaver/tsconfig": "workspace:^",
|
||||
"rimraf": "^6",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3"
|
||||
},
|
||||
"dependencies": {
|
||||
"async-mutex": "^0",
|
||||
"nanoevents": "^9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ITableDataset } from './interfaces/ITableDataset.js';
|
||||
import type { ITableDatasetManager } from './interfaces/ITableDatasetManager.js';
|
||||
|
||||
export class TableDatasetManager<TColumn, TValue> implements ITableDatasetManager<TColumn, TValue> {
|
||||
get datasets(): ITableDataset<TColumn, TValue>[] {
|
||||
return this.#datasets;
|
||||
}
|
||||
|
||||
#datasets: ITableDataset<TColumn, TValue>[] = [];
|
||||
|
||||
constructor() {
|
||||
this.#datasets = [];
|
||||
}
|
||||
|
||||
setDatasets(datasets: ITableDataset<TColumn, TValue>[]): void {
|
||||
this.#datasets = datasets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Mutex } from 'async-mutex';
|
||||
import type { ITableSource } from './interfaces/ITableSource.js';
|
||||
import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents';
|
||||
import type { TableSourceEvents } from './interfaces/TableSourceEvents.js';
|
||||
import type { ITableDatasetManager } from './interfaces/ITableDatasetManager.js';
|
||||
import type { ITableSourceOptions } from './interfaces/ITableSourceOptions.js';
|
||||
|
||||
export abstract class TableSource<TColumn, TValue, TOptions extends ITableSourceOptions = ITableSourceOptions>
|
||||
implements ITableSource<TColumn, TValue, TOptions>
|
||||
{
|
||||
get isLoading(): boolean {
|
||||
return this.#loadingInProgress;
|
||||
}
|
||||
get isOutdated(): boolean {
|
||||
return this.#outdated;
|
||||
}
|
||||
error: Error | null;
|
||||
options: Partial<TOptions>;
|
||||
|
||||
#outdated: boolean;
|
||||
#loadingInProgress = false;
|
||||
#loadingPending = false;
|
||||
readonly #mutex: Mutex;
|
||||
readonly #emitter: Emitter<TableSourceEvents>;
|
||||
constructor(readonly datasetManager: ITableDatasetManager<TColumn, TValue>) {
|
||||
this.options = {};
|
||||
this.error = null;
|
||||
this.#outdated = false;
|
||||
this.#mutex = new Mutex();
|
||||
this.#emitter = createNanoEvents();
|
||||
}
|
||||
|
||||
setOptions(options: TOptions): void {
|
||||
this.options = options;
|
||||
this.setOutdated();
|
||||
}
|
||||
|
||||
setOutdated(): void {
|
||||
this.setOutdatedValue(true);
|
||||
if (this.#mutex.isLocked()) {
|
||||
this.#mutex.runExclusive(() => {
|
||||
this.setOutdatedValue(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
const release = await this.#mutex.acquire();
|
||||
try {
|
||||
this.setError(null);
|
||||
await this.saveData();
|
||||
this.emit('saved');
|
||||
} catch (error) {
|
||||
this.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
throw this.error;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.#loadingInProgress) {
|
||||
this.#loadingPending = true;
|
||||
return await this.#mutex.waitForUnlock();
|
||||
}
|
||||
this.setLoadingInProgress(true);
|
||||
try {
|
||||
// this waits for any save to finish, then runs loadData
|
||||
await this.#mutex.runExclusive(async () => {
|
||||
do {
|
||||
this.#loadingPending = false;
|
||||
this.setError(null);
|
||||
await this.loadData();
|
||||
this.emit('data');
|
||||
} while (this.#loadingPending);
|
||||
});
|
||||
} catch (error) {
|
||||
this.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
throw this.error;
|
||||
} finally {
|
||||
this.setOutdatedValue(false);
|
||||
this.setLoadingInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
on<TEvent extends keyof TableSourceEvents>(event: TEvent, listener: TableSourceEvents[TEvent]): Unsubscribe {
|
||||
return this.#emitter.on(event, listener);
|
||||
}
|
||||
|
||||
protected setLoadingInProgress(state: boolean): void {
|
||||
this.emit('loading', state);
|
||||
this.#loadingInProgress = state;
|
||||
}
|
||||
|
||||
protected setError(error: Error | null): void {
|
||||
this.error = error;
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
protected setOutdatedValue(state: boolean): void {
|
||||
this.#outdated = state;
|
||||
this.emit('outdated', state);
|
||||
}
|
||||
|
||||
protected emit<TEvent extends keyof TableSourceEvents>(event: TEvent, ...args: Parameters<TableSourceEvents[TEvent]>): void {
|
||||
this.#emitter.emit(event, ...args);
|
||||
}
|
||||
protected abstract saveData(): Promise<void>;
|
||||
protected abstract loadData(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Cell position types
|
||||
export interface ICellPosition {
|
||||
readonly rowIdx: number;
|
||||
readonly colIdx: number;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
import type { ITableEditorHistory } from './ITableEditorHistory.js';
|
||||
import type { TableEditorEventEmitter } from './TableEditorEvents.js';
|
||||
|
||||
// Main table editor interface
|
||||
export interface ITableEditor<TValue> extends TableEditorEventEmitter<TValue> {
|
||||
// Data access
|
||||
readonly data: readonly (readonly TValue[])[];
|
||||
readonly isEdited: boolean;
|
||||
readonly rowCount: number;
|
||||
|
||||
// Cell operations
|
||||
getCellValue(position: ICellPosition): TValue | undefined;
|
||||
setCellValue(position: ICellPosition, value: TValue): void;
|
||||
|
||||
// Row operations
|
||||
insertRow(rowIdx: number, rowData?: TValue[]): void;
|
||||
deleteRow(rowIdx: number): void;
|
||||
|
||||
// Data operations
|
||||
resetData(newData: TValue[][]): void;
|
||||
|
||||
// History operations
|
||||
readonly history: ITableEditorHistory<TValue>;
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ITableEditor } from './ITableEditor.js';
|
||||
|
||||
export interface ITableEditorFactory {
|
||||
create<TValue>(initialData?: TValue[][]): ITableEditor<TValue>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
|
||||
// History entry types
|
||||
export interface ITableEditorHistoryEntry<TValue> {
|
||||
readonly type: 'cell-edit' | 'row-insert' | 'row-delete';
|
||||
readonly timestamp: number;
|
||||
readonly data: ICellEditEntry<TValue> | IRowInsertEntry<TValue> | IRowDeleteEntry<TValue>;
|
||||
}
|
||||
|
||||
export interface ICellEditEntry<TValue> {
|
||||
readonly position: ICellPosition;
|
||||
readonly oldValue: TValue;
|
||||
readonly newValue: TValue;
|
||||
}
|
||||
|
||||
export interface IRowInsertEntry<TValue> {
|
||||
readonly rowIdx: number;
|
||||
readonly rowData: TValue[];
|
||||
}
|
||||
|
||||
export interface IRowDeleteEntry<TValue> {
|
||||
readonly rowIdx: number;
|
||||
readonly rowData: TValue[];
|
||||
}
|
||||
|
||||
// History management interface
|
||||
export interface ITableEditorHistory<TValue> {
|
||||
readonly canUndo: boolean;
|
||||
readonly canRedo: boolean;
|
||||
readonly size: number;
|
||||
readonly maxSize: number;
|
||||
|
||||
push(entry: ITableEditorHistoryEntry<TValue>): void;
|
||||
undo(): ITableEditorHistoryEntry<TValue> | null;
|
||||
redo(): ITableEditorHistoryEntry<TValue> | null;
|
||||
clear(): void;
|
||||
setMaxSize(size: number): void;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 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 { describe, expect, test, beforeEach } from 'vitest';
|
||||
|
||||
import { TableEditor } from './TableEditor.js';
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
|
||||
describe('TableEditor', () => {
|
||||
let tableEditor: TableEditor<string>;
|
||||
const initialData = [
|
||||
['A1', 'B1', 'C1'],
|
||||
['A2', 'B2', 'C2'],
|
||||
['A3', 'B3', 'C3'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
tableEditor = new TableEditor(initialData);
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
test('should initialize with provided data', () => {
|
||||
expect(tableEditor.rowCount).toBe(3);
|
||||
expect(tableEditor.data).toEqual(initialData);
|
||||
expect(tableEditor.isEdited).toBe(false);
|
||||
});
|
||||
|
||||
test('should get cell values correctly', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
expect(tableEditor.getCellValue(position)).toBe('B2');
|
||||
});
|
||||
|
||||
test('should return undefined for invalid positions', () => {
|
||||
const invalidPosition: ICellPosition = { rowIdx: 10, colIdx: 10 };
|
||||
expect(tableEditor.getCellValue(invalidPosition)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cell editing', () => {
|
||||
test('should set cell values and track changes', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
let changeCount = 0;
|
||||
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.setCellValue(position, 'NEW_VALUE');
|
||||
|
||||
expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE');
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should not emit events when setting same value', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
let changeCount = 0;
|
||||
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.setCellValue(position, 'B2'); // Same value
|
||||
|
||||
expect(changeCount).toBe(0);
|
||||
expect(tableEditor.isEdited).toBe(false);
|
||||
});
|
||||
|
||||
test('should throw error for invalid positions', () => {
|
||||
const invalidPosition: ICellPosition = { rowIdx: 10, colIdx: 10 };
|
||||
|
||||
expect(() => {
|
||||
tableEditor.setCellValue(invalidPosition, 'value');
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Row operations', () => {
|
||||
test('should insert rows correctly', () => {
|
||||
let changeCount = 0;
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.insertRow(1, ['X1', 'X2', 'X3']);
|
||||
|
||||
expect(tableEditor.rowCount).toBe(4);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('X1');
|
||||
expect(tableEditor.getCellValue({ rowIdx: 2, colIdx: 0 })).toBe('A2'); // Shifted down
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should delete rows correctly', () => {
|
||||
let changeCount = 0;
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
tableEditor.deleteRow(1);
|
||||
|
||||
expect(tableEditor.rowCount).toBe(2);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('A3');
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('History management', () => {
|
||||
test('should support undo/redo for cell edits', () => {
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
|
||||
// Make a change
|
||||
tableEditor.setCellValue(position, 'NEW_VALUE');
|
||||
expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE');
|
||||
expect(tableEditor.history.canUndo).toBe(true);
|
||||
expect(tableEditor.history.canRedo).toBe(false);
|
||||
|
||||
// Undo
|
||||
const undoResult = tableEditor.undo();
|
||||
expect(undoResult).toBe(true);
|
||||
expect(tableEditor.getCellValue(position)).toBe('B2');
|
||||
expect(tableEditor.history.canUndo).toBe(false);
|
||||
expect(tableEditor.history.canRedo).toBe(true);
|
||||
|
||||
// Redo
|
||||
const redoResult = tableEditor.redo();
|
||||
expect(redoResult).toBe(true);
|
||||
expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE');
|
||||
expect(tableEditor.history.canUndo).toBe(true);
|
||||
expect(tableEditor.history.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
test('should support undo/redo for row operations', () => {
|
||||
// Insert row
|
||||
tableEditor.insertRow(1, ['X1', 'X2', 'X3']);
|
||||
expect(tableEditor.rowCount).toBe(4);
|
||||
|
||||
// Undo insert
|
||||
tableEditor.undo();
|
||||
expect(tableEditor.rowCount).toBe(3);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('A2');
|
||||
|
||||
// Redo insert
|
||||
tableEditor.redo();
|
||||
expect(tableEditor.rowCount).toBe(4);
|
||||
expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('X1');
|
||||
});
|
||||
|
||||
test('should not fire change notifications for undo/redo operations', () => {
|
||||
let changeCount = 0;
|
||||
tableEditor.on('data-changed', () => {
|
||||
changeCount++;
|
||||
});
|
||||
|
||||
const position: ICellPosition = { rowIdx: 1, colIdx: 1 };
|
||||
|
||||
// Make a change - should fire change notification
|
||||
tableEditor.setCellValue(position, 'NEW_VALUE');
|
||||
expect(changeCount).toBe(1);
|
||||
|
||||
// Undo - should NOT fire change notification
|
||||
tableEditor.undo();
|
||||
expect(changeCount).toBe(1);
|
||||
|
||||
// Redo - should NOT fire change notification
|
||||
tableEditor.redo();
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data reset', () => {
|
||||
test('should reset data and clear history', () => {
|
||||
// Make some changes
|
||||
tableEditor.setCellValue({ rowIdx: 0, colIdx: 0 }, 'CHANGED');
|
||||
expect(tableEditor.isEdited).toBe(true);
|
||||
expect(tableEditor.history.canUndo).toBe(true);
|
||||
|
||||
// Reset data
|
||||
const newData = [['Z1', 'Z2'], ['Z3', 'Z4']];
|
||||
tableEditor.resetData(newData);
|
||||
|
||||
expect(tableEditor.data).toEqual(newData);
|
||||
expect(tableEditor.rowCount).toBe(2);
|
||||
expect(tableEditor.isEdited).toBe(false);
|
||||
expect(tableEditor.history.canUndo).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents';
|
||||
import type { ITableEditor } from './ITableEditor.js';
|
||||
import type { ICellPosition } from './ICellPosition.js';
|
||||
import type { ITableEditorHistory, ITableEditorHistoryEntry } from './ITableEditorHistory.js';
|
||||
import type { TableEditorEvents } from './TableEditorEvents.js';
|
||||
import { TableEditorHistory } from './TableEditorHistory.js';
|
||||
|
||||
export class TableEditor<TValue> implements ITableEditor<TValue> {
|
||||
private readonly _data: TValue[][];
|
||||
private readonly _history: ITableEditorHistory<TValue>;
|
||||
private readonly _emitter: Emitter<TableEditorEvents<TValue>>;
|
||||
private _isEdited = false;
|
||||
|
||||
constructor(initialData: TValue[][] = [], historyMaxSize = 100) {
|
||||
this._data = initialData.map(row => [...row]);
|
||||
this._history = new TableEditorHistory<TValue>(historyMaxSize);
|
||||
this._emitter = createNanoEvents();
|
||||
}
|
||||
|
||||
get data(): readonly (readonly TValue[])[] {
|
||||
return this._data.map(row => Object.freeze([...row]));
|
||||
}
|
||||
|
||||
get isEdited(): boolean {
|
||||
return this._isEdited;
|
||||
}
|
||||
|
||||
get rowCount(): number {
|
||||
return this._data.length;
|
||||
}
|
||||
|
||||
get history(): ITableEditorHistory<TValue> {
|
||||
return this._history;
|
||||
}
|
||||
|
||||
getCellValue(position: ICellPosition): TValue | undefined {
|
||||
if (!this.isValidPosition(position)) {
|
||||
return undefined;
|
||||
}
|
||||
return this._data[position.rowIdx]?.[position.colIdx];
|
||||
}
|
||||
|
||||
setCellValue(position: ICellPosition, value: TValue): void {
|
||||
if (!this.isValidPosition(position)) {
|
||||
throw new Error(`Invalid cell position: row ${position.rowIdx}, column ${position.colIdx}`);
|
||||
}
|
||||
|
||||
const row = this._data[position.rowIdx];
|
||||
if (!row) {
|
||||
throw new Error(`Row ${position.rowIdx} does not exist`);
|
||||
}
|
||||
|
||||
const oldValue = row[position.colIdx];
|
||||
|
||||
if (oldValue === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const historyEntry: ITableEditorHistoryEntry<TValue> = {
|
||||
type: 'cell-edit',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
position: { ...position },
|
||||
oldValue: oldValue as TValue,
|
||||
newValue: value,
|
||||
},
|
||||
};
|
||||
this._history.push(historyEntry);
|
||||
|
||||
row[position.colIdx] = value;
|
||||
|
||||
this._isEdited = true;
|
||||
|
||||
this.emit('data-changed');
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
insertRow(rowIdx: number, rowData?: TValue[]): void {
|
||||
if (rowIdx < 0 || rowIdx > this._data.length) {
|
||||
throw new Error(`Invalid row index: ${rowIdx}. Must be between 0 and ${this._data.length}`);
|
||||
}
|
||||
|
||||
const newRowData = rowData ? [...rowData] : [];
|
||||
|
||||
if (this._data.length > 0 && newRowData.length === 0) {
|
||||
const columnCount = Math.max(...this._data.map(row => row.length));
|
||||
newRowData.length = columnCount;
|
||||
newRowData.fill(undefined as any);
|
||||
}
|
||||
|
||||
const historyEntry: ITableEditorHistoryEntry<TValue> = {
|
||||
type: 'row-insert',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
rowIdx,
|
||||
rowData: [...newRowData],
|
||||
},
|
||||
};
|
||||
this._history.push(historyEntry);
|
||||
|
||||
this._data.splice(rowIdx, 0, newRowData);
|
||||
|
||||
this._isEdited = true;
|
||||
|
||||
this.emit('data-changed');
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
deleteRow(rowIdx: number): void {
|
||||
if (rowIdx < 0 || rowIdx >= this._data.length) {
|
||||
throw new Error(`Invalid row index: ${rowIdx}. Must be between 0 and ${this._data.length - 1}`);
|
||||
}
|
||||
|
||||
const row = this._data[rowIdx];
|
||||
if (!row) {
|
||||
throw new Error(`Row ${rowIdx} does not exist`);
|
||||
}
|
||||
|
||||
const deletedRowData = [...row];
|
||||
|
||||
const historyEntry: ITableEditorHistoryEntry<TValue> = {
|
||||
type: 'row-delete',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
rowIdx,
|
||||
rowData: deletedRowData,
|
||||
},
|
||||
};
|
||||
this._history.push(historyEntry);
|
||||
|
||||
this._data.splice(rowIdx, 1);
|
||||
|
||||
this._isEdited = true;
|
||||
|
||||
this.emit('data-changed');
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
resetData(newData: TValue[][]): void {
|
||||
this._data.length = 0;
|
||||
|
||||
newData.forEach(row => {
|
||||
this._data.push([...row]);
|
||||
});
|
||||
|
||||
this._history.clear();
|
||||
this._isEdited = false;
|
||||
|
||||
this.emit('data-reset', newData);
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
undo(): boolean {
|
||||
const entry = this._history.undo();
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.applyHistoryEntryReverse(entry);
|
||||
this.emitHistoryChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
const entry = this._history.redo();
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.applyHistoryEntry(entry);
|
||||
this.emitHistoryChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
on<TEvent extends keyof TableEditorEvents<TValue>>(
|
||||
event: TEvent,
|
||||
listener: TableEditorEvents<TValue>[TEvent]
|
||||
): Unsubscribe {
|
||||
return this._emitter.on(event, listener);
|
||||
}
|
||||
|
||||
emit<TEvent extends keyof TableEditorEvents<TValue>>(
|
||||
event: TEvent,
|
||||
...args: Parameters<TableEditorEvents<TValue>[TEvent]>
|
||||
): void {
|
||||
this._emitter.emit(event, ...args);
|
||||
}
|
||||
|
||||
private isValidPosition(position: ICellPosition): boolean {
|
||||
return (
|
||||
position.rowIdx >= 0 &&
|
||||
position.rowIdx < this._data.length &&
|
||||
position.colIdx >= 0 &&
|
||||
position.colIdx < (this._data[position.rowIdx]?.length || 0)
|
||||
);
|
||||
}
|
||||
|
||||
private emitHistoryChanged(): void {
|
||||
this.emit('history-changed', this._history.canUndo, this._history.canRedo);
|
||||
}
|
||||
|
||||
private applyHistoryEntry(entry: ITableEditorHistoryEntry<TValue>): void {
|
||||
switch (entry.type) {
|
||||
case 'cell-edit': {
|
||||
const data = entry.data as any;
|
||||
const { position, newValue } = data;
|
||||
const row = this._data[position.rowIdx];
|
||||
if (row) {
|
||||
row[position.colIdx] = newValue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'row-insert': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx, rowData } = data;
|
||||
this._data.splice(rowIdx, 0, [...rowData]);
|
||||
break;
|
||||
}
|
||||
case 'row-delete': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx } = data;
|
||||
this._data.splice(rowIdx, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyHistoryEntryReverse(entry: ITableEditorHistoryEntry<TValue>): void {
|
||||
switch (entry.type) {
|
||||
case 'cell-edit': {
|
||||
const data = entry.data as any;
|
||||
const { position, oldValue } = data;
|
||||
const row = this._data[position.rowIdx];
|
||||
if (row) {
|
||||
row[position.colIdx] = oldValue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'row-insert': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx } = data;
|
||||
this._data.splice(rowIdx, 1);
|
||||
break;
|
||||
}
|
||||
case 'row-delete': {
|
||||
const data = entry.data as any;
|
||||
const { rowIdx, rowData } = data;
|
||||
this._data.splice(rowIdx, 0, [...rowData]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EmitterMixin } from 'nanoevents';
|
||||
|
||||
export interface TableEditorEvents<TValue> {
|
||||
'data-changed': () => void;
|
||||
'data-reset': (newData: TValue[][]) => void;
|
||||
'history-changed': (canUndo: boolean, canRedo: boolean) => void;
|
||||
}
|
||||
|
||||
export type TableEditorEventEmitter<TValue> = EmitterMixin<TableEditorEvents<TValue>>;
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ITableEditorHistory, ITableEditorHistoryEntry } from './ITableEditorHistory.js';
|
||||
|
||||
/**
|
||||
* Implementation of table editor history with undo/redo functionality.
|
||||
* Maintains a circular buffer of history entries with a configurable maximum size.
|
||||
*/
|
||||
export class TableEditorHistory<TValue> implements ITableEditorHistory<TValue> {
|
||||
private entries: ITableEditorHistoryEntry<TValue>[] = [];
|
||||
private currentIndex: number = -1;
|
||||
private _maxSize: number;
|
||||
|
||||
constructor(maxSize: number = 100) {
|
||||
this._maxSize = Math.max(1, maxSize);
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return this.currentIndex >= 0;
|
||||
}
|
||||
|
||||
get canRedo(): boolean {
|
||||
return this.currentIndex < this.entries.length - 1;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
get maxSize(): number {
|
||||
return this._maxSize;
|
||||
}
|
||||
|
||||
push(entry: ITableEditorHistoryEntry<TValue>): void {
|
||||
// Remove any entries after the current index (when pushing after undo)
|
||||
if (this.currentIndex < this.entries.length - 1) {
|
||||
this.entries = this.entries.slice(0, this.currentIndex + 1);
|
||||
}
|
||||
|
||||
// Add the new entry
|
||||
this.entries.push(entry);
|
||||
this.currentIndex = this.entries.length - 1;
|
||||
|
||||
// Maintain max size by removing oldest entries
|
||||
if (this.entries.length > this._maxSize) {
|
||||
const removeCount = this.entries.length - this._maxSize;
|
||||
this.entries = this.entries.slice(removeCount);
|
||||
this.currentIndex -= removeCount;
|
||||
}
|
||||
}
|
||||
|
||||
undo(): ITableEditorHistoryEntry<TValue> | null {
|
||||
if (!this.canUndo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = this.entries[this.currentIndex];
|
||||
this.currentIndex--;
|
||||
return entry ?? null;
|
||||
}
|
||||
|
||||
redo(): ITableEditorHistoryEntry<TValue> | null {
|
||||
if (!this.canRedo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex++;
|
||||
const entry = this.entries[this.currentIndex];
|
||||
return entry ?? null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
setMaxSize(size: number): void {
|
||||
this._maxSize = Math.max(1, size);
|
||||
|
||||
// Trim entries if the new max size is smaller
|
||||
if (this.entries.length > this._maxSize) {
|
||||
const removeCount = this.entries.length - this._maxSize;
|
||||
this.entries = this.entries.slice(removeCount);
|
||||
this.currentIndex = Math.max(-1, this.currentIndex - removeCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Main interfaces
|
||||
export type { ITableEditor } from './ITableEditor.js';
|
||||
export type { ICellPosition } from './ICellPosition.js';
|
||||
export type { ITableEditorFactory } from './ITableEditorFactory.js';
|
||||
|
||||
// History interfaces
|
||||
export type {
|
||||
ITableEditorHistory,
|
||||
ITableEditorHistoryEntry,
|
||||
ICellEditEntry,
|
||||
IRowInsertEntry,
|
||||
IRowDeleteEntry,
|
||||
} from './ITableEditorHistory.js';
|
||||
|
||||
// History implementation
|
||||
export { TableEditorHistory } from './TableEditorHistory.js';
|
||||
|
||||
// Table editor implementation
|
||||
export { TableEditor } from './TableEditor.js';
|
||||
|
||||
// Event interfaces
|
||||
export type {
|
||||
TableEditorEvents,
|
||||
TableEditorEventEmitter,
|
||||
} from './TableEditorEvents.js';
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './editor/TableEditor.js';
|
||||
export * from './interfaces/ITableData.js';
|
||||
export * from './interfaces/ITableDataset.js';
|
||||
export * from './interfaces/ITableDatasetManager.js';
|
||||
export * from './interfaces/ITableSource.js';
|
||||
export * from './interfaces/ITableSourceOptions.js';
|
||||
export * from './interfaces/ITableSorting.js';
|
||||
export * from './interfaces/TableEvents.js';
|
||||
export * from './interfaces/TableSourceEvents.js';
|
||||
export * from './TableSource.js';
|
||||
export * from './TableDatasetManager.js';
|
||||
export * from './editor/index.js';
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EmitterMixin } from 'nanoevents';
|
||||
import type { ITableDataset } from './ITableDataset.js';
|
||||
import type { ITableSource } from './ITableSource.js';
|
||||
import type { TableEvents } from './TableEvents.js';
|
||||
import type { ITableSourceOptions } from './ITableSourceOptions.js';
|
||||
|
||||
export interface ITableData<TColumn, TValue, TOptions extends ITableSourceOptions = ITableSourceOptions> extends EmitterMixin<TableEvents> {
|
||||
readonly id: string;
|
||||
readonly isLoading: boolean;
|
||||
readonly isOutdated: boolean;
|
||||
readonly datasets: ITableDataset<TColumn, TValue>[];
|
||||
|
||||
readonly source: ITableSource<TColumn, TValue, TOptions>;
|
||||
|
||||
setSource(source: ITableSource<TColumn, TValue, TOptions>): void;
|
||||
|
||||
save(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ITableDataset<TColumn, TValue> {
|
||||
readonly id: string;
|
||||
readonly columns: TColumn[];
|
||||
readonly rows: TValue[][];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ITableDataset } from './ITableDataset.js';
|
||||
|
||||
export interface ITableDatasetManager<TColumn, TValue> {
|
||||
readonly datasets: ITableDataset<TColumn, TValue>[];
|
||||
|
||||
setDatasets(datasets: ITableDataset<TColumn, TValue>[]): void;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ITableSorting {
|
||||
column: number;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EmitterMixin } from 'nanoevents';
|
||||
import type { TableSourceEvents } from './TableSourceEvents.js';
|
||||
import type { ITableDatasetManager } from './ITableDatasetManager.js';
|
||||
import type { ITableSourceOptions } from './ITableSourceOptions.js';
|
||||
|
||||
export interface ITableSource<TColumn, TValue, TOptions extends ITableSourceOptions = ITableSourceOptions> extends EmitterMixin<TableSourceEvents> {
|
||||
readonly datasetManager: ITableDatasetManager<TColumn, TValue>;
|
||||
readonly options: Partial<TOptions>;
|
||||
readonly isLoading: boolean;
|
||||
readonly isOutdated: boolean;
|
||||
readonly error: Error | null;
|
||||
|
||||
setOptions(options: TOptions): void;
|
||||
setOutdated(): void;
|
||||
|
||||
save(): Promise<void>;
|
||||
load(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ITableSorting } from './ITableSorting.js';
|
||||
|
||||
export interface ITableSourceOptions {
|
||||
sorting?: ITableSorting[];
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { TableSourceEvents } from './TableSourceEvents.js';
|
||||
|
||||
export type TableEvents = TableSourceEvents & {};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type TableSourceEvents = {
|
||||
loading: (state: boolean) => void;
|
||||
outdated: (state: boolean) => void;
|
||||
saved: () => void;
|
||||
data: () => void;
|
||||
error: (error: Error | null) => void;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@dbeaver/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./lib/.tsbuildinfo",
|
||||
"rootDir": "src",
|
||||
"rootDirs": [
|
||||
"src"
|
||||
],
|
||||
"outDir": "lib",
|
||||
"incremental": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../cli"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2
|
||||
Bundle-Vendor: DBeaver Corp
|
||||
Bundle-Name: Cloudbeaver Web Model
|
||||
Bundle-SymbolicName: io.cloudbeaver.model;singleton:=true
|
||||
Bundle-Version: 1.0.77.qualifier
|
||||
Bundle-Release-Date: 20250519
|
||||
Bundle-Version: 1.0.79.qualifier
|
||||
Bundle-Release-Date: 20250616
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-17
|
||||
Bundle-ActivationPolicy: lazy
|
||||
Bundle-ClassPath: .
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
<artifactId>io.cloudbeaver.model</artifactId>
|
||||
<version>1.0.77-SNAPSHOT</version>
|
||||
<version>1.0.79-SNAPSHOT</version>
|
||||
<packaging>eclipse-plugin</packaging>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -69,4 +69,9 @@ public interface SMAuthProviderExternal<AUTH_SESSION extends SMSession> extends
|
||||
@NotNull WebUser user,
|
||||
boolean selfIdentity) throws DBException;
|
||||
|
||||
/**
|
||||
* Make some post authentication actions
|
||||
*/
|
||||
default void postAuthentication() {}
|
||||
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ public class ServletSystemInformationCollector<T extends ServletApplication> imp
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@PropertyGroup(order = 31, category = "Security manager database", id = "sm")
|
||||
@PropertyGroup(order = 31, category = "Security manager database", groupId = "sm")
|
||||
public DBPConnectionInformation getSmDatabaseInfo() {
|
||||
return smDatabaseInfo;
|
||||
}
|
||||
|
||||
@@ -23,11 +23,9 @@ import org.jkiss.dbeaver.Log;
|
||||
import org.jkiss.dbeaver.model.impl.PropertyDescriptor;
|
||||
import org.jkiss.dbeaver.registry.RegistryConstants;
|
||||
import org.jkiss.utils.ArrayUtils;
|
||||
import org.jkiss.utils.CommonUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
|
||||
public class WebAuthProviderRegistry {
|
||||
|
||||
@@ -36,6 +34,7 @@ public class WebAuthProviderRegistry {
|
||||
private static final String TAG_AUTH_PROVIDER = "authProvider"; //$NON-NLS-1$
|
||||
private static final String TAG_AUTH_PROVIDER_DISABLE = "authProviderDisable"; //$NON-NLS-1$
|
||||
private static final String TAG_COMMON_PROVIDER_PROPERTIES = "commonProviderProperties"; //$NON-NLS-1$
|
||||
private static final String ATTRIBUTE_EXTENDED = "extended";
|
||||
|
||||
private static WebAuthProviderRegistry instance = null;
|
||||
|
||||
@@ -54,34 +53,43 @@ public class WebAuthProviderRegistry {
|
||||
}
|
||||
|
||||
private void loadExtensions(IExtensionRegistry registry) {
|
||||
{
|
||||
IConfigurationElement[] extConfigs = registry.getConfigurationElementsFor(WebAuthProviderDescriptor.EXTENSION_ID);
|
||||
for (IConfigurationElement ext : extConfigs) {
|
||||
// Load webServices
|
||||
if (TAG_AUTH_PROVIDER.equals(ext.getName())) {
|
||||
WebAuthProviderDescriptor providerDescriptor;
|
||||
WebAuthProviderDescriptor webAuthProviderDescriptor = authProviders.get(ext.getAttribute(WebRegistryConstant.ATTR_EXTENDED));
|
||||
if (webAuthProviderDescriptor != null) {
|
||||
webAuthProviderDescriptor.loadExtraConfig(ext);
|
||||
} else {
|
||||
providerDescriptor = new WebAuthProviderDescriptor(ext);
|
||||
this.authProviders.put(providerDescriptor.getId(), providerDescriptor);
|
||||
}
|
||||
} else if (TAG_COMMON_PROVIDER_PROPERTIES.equals(ext.getName())) {
|
||||
var commonProperties = new WebCommonAuthProviderPropertyDescriptor(ext);
|
||||
this.commonProperties.add(commonProperties);
|
||||
}
|
||||
}
|
||||
IConfigurationElement[] extConfigs = registry.getConfigurationElementsFor(WebAuthProviderDescriptor.EXTENSION_ID);
|
||||
// Sort - parse providers with parent in the end
|
||||
Arrays.sort(extConfigs, (o1, o2) -> {
|
||||
String p1 = o1.getAttribute(ATTRIBUTE_EXTENDED);
|
||||
String p2 = o2.getAttribute(ATTRIBUTE_EXTENDED);
|
||||
if (CommonUtils.equalObjects(p1, p2)) return 0;
|
||||
if (p1 == null) return -1;
|
||||
if (p2 == null) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (IConfigurationElement ext : extConfigs) {
|
||||
// Disable auth providers
|
||||
if (TAG_AUTH_PROVIDER_DISABLE.equals(ext.getName())) {
|
||||
String providerId = ext.getAttribute(RegistryConstants.ATTR_ID);
|
||||
if (!this.authProviders.containsKey(providerId)) {
|
||||
log.warn("Can't disable auth provider '" + providerId + "' - no such provider found");
|
||||
} else {
|
||||
this.authProviders.remove(providerId);
|
||||
}
|
||||
for (IConfigurationElement ext : extConfigs) {
|
||||
// Load webServices
|
||||
if (TAG_AUTH_PROVIDER.equals(ext.getName())) {
|
||||
WebAuthProviderDescriptor providerDescriptor;
|
||||
WebAuthProviderDescriptor webAuthProviderDescriptor
|
||||
= authProviders.get(ext.getAttribute(WebRegistryConstant.ATTR_EXTENDED));
|
||||
if (webAuthProviderDescriptor != null) {
|
||||
webAuthProviderDescriptor.loadExtraConfig(ext);
|
||||
} else {
|
||||
providerDescriptor = new WebAuthProviderDescriptor(ext);
|
||||
this.authProviders.put(providerDescriptor.getId(), providerDescriptor);
|
||||
}
|
||||
} else if (TAG_COMMON_PROVIDER_PROPERTIES.equals(ext.getName())) {
|
||||
var commonProperties = new WebCommonAuthProviderPropertyDescriptor(ext);
|
||||
this.commonProperties.add(commonProperties);
|
||||
}
|
||||
}
|
||||
|
||||
for (IConfigurationElement ext : extConfigs) {
|
||||
// Disable auth providers
|
||||
if (TAG_AUTH_PROVIDER_DISABLE.equals(ext.getName())) {
|
||||
String providerId = ext.getAttribute(RegistryConstants.ATTR_ID);
|
||||
if (!this.authProviders.containsKey(providerId)) {
|
||||
log.warn("Can't disable auth provider '" + providerId + "' - no such provider found");
|
||||
} else {
|
||||
this.authProviders.remove(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* DBeaver - Universal Database Manager
|
||||
* Copyright (C) 2010-2024 DBeaver Corp and others
|
||||
* Copyright (C) 2010-2025 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -22,6 +22,7 @@ package io.cloudbeaver.server;
|
||||
public class CBConstants {
|
||||
public static final int STATIC_CACHE_SECONDS = 60 * 60 * 24 * 3;
|
||||
|
||||
public static final String CONF_DIR_NAME = "conf";
|
||||
public static final String RUNTIME_DATA_DIR_NAME = ".data";
|
||||
public static final String RUNTIME_APP_CONFIG_FILE_NAME = ".cloudbeaver.runtime.conf";
|
||||
public static final String RUNTIME_PRODUCT_CONFIG_FILE_NAME = ".product.runtime.conf";
|
||||
|
||||
@@ -20,4 +20,5 @@ public interface HttpConstants {
|
||||
String CONTENT_TYPE = "Content-Type";
|
||||
|
||||
String TYPE_JSON = "application/json";
|
||||
String HEADER_AUTHORIZATION = "Authorization";
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2
|
||||
Bundle-Vendor: DBeaver Corp
|
||||
Bundle-Name: Cloudbeaver Community Product
|
||||
Bundle-SymbolicName: io.cloudbeaver.product.ce;singleton:=true
|
||||
Bundle-Version: 25.0.5.qualifier
|
||||
Bundle-Release-Date: 20250519
|
||||
Bundle-Version: 25.1.1.qualifier
|
||||
Bundle-Release-Date: 20250616
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-17
|
||||
Bundle-ActivationPolicy: lazy
|
||||
Bundle-ClassPath: .
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
<artifactId>io.cloudbeaver.product.ce</artifactId>
|
||||
<version>25.0.5-SNAPSHOT</version>
|
||||
<version>25.1.1-SNAPSHOT</version>
|
||||
<packaging>eclipse-plugin</packaging>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -2,8 +2,8 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: Cloudbeaver Base JDBC drivers
|
||||
Bundle-SymbolicName: io.cloudbeaver.resources.drivers.base;singleton:=true
|
||||
Bundle-Version: 1.0.122.qualifier
|
||||
Bundle-Release-Date: 20250519
|
||||
Bundle-Version: 1.0.124.qualifier
|
||||
Bundle-Release-Date: 20250616
|
||||
Bundle-Vendor: DBeaver Corp
|
||||
Bundle-ActivationPolicy: lazy
|
||||
Automatic-Module-Name: io.cloudbeaver.resources.drivers.base
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
<resource name="drivers/mssql/new"/>
|
||||
<resource name="drivers/trino"/>
|
||||
<resource name="drivers/kyuubi"/>
|
||||
<resource name="drivers/databend"/>
|
||||
</extension>
|
||||
|
||||
<!-- Bundles -->
|
||||
@@ -39,7 +38,6 @@
|
||||
<bundle id="drivers.mssql.new" label="SQL Server drivers"/>
|
||||
<bundle id="drivers.trino" label="Trino drivers"/>
|
||||
<bundle id="drivers.kyuubi" label="Apache Kyuubi drivers"/>
|
||||
<bundle id="drivers.databend" label="Databend drivers"/>
|
||||
</extension>
|
||||
|
||||
<!-- Enabled drivers -->
|
||||
@@ -61,7 +59,6 @@
|
||||
<driver id="generic:trino_jdbc"/>
|
||||
<driver id="generic:duckdb_jdbc"/>
|
||||
<driver id="generic:kyuubi_hive"/>
|
||||
<driver id="databend:databend"/>
|
||||
</extension>
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
<artifactId>io.cloudbeaver.resources.drivers.base</artifactId>
|
||||
<version>1.0.122-SNAPSHOT</version>
|
||||
<version>1.0.124-SNAPSHOT</version>
|
||||
<packaging>eclipse-plugin</packaging>
|
||||
</project>
|
||||
|
||||
@@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2
|
||||
Bundle-Vendor: DBeaver Corp
|
||||
Bundle-Name: Cloudbeaver CE Server
|
||||
Bundle-SymbolicName: io.cloudbeaver.server.ce;singleton:=true
|
||||
Bundle-Version: 25.0.5.qualifier
|
||||
Bundle-Release-Date: 20250519
|
||||
Bundle-Version: 25.1.1.qualifier
|
||||
Bundle-Release-Date: 20250616
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-17
|
||||
Bundle-ActivationPolicy: lazy
|
||||
Bundle-Activator: io.cloudbeaver.server.CBPlatformActivator
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
<artifactId>io.cloudbeaver.server.ce</artifactId>
|
||||
<version>25.0.5-SNAPSHOT</version>
|
||||
<version>25.1.1-SNAPSHOT</version>
|
||||
<packaging>eclipse-plugin</packaging>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -557,8 +557,9 @@ public abstract class CBServerConfigurationController<T extends CBServerConfig>
|
||||
|
||||
private void patchConfigurationWithProperties(Map<String, Object> configProps) {
|
||||
IVariableResolver varResolver = new SystemVariablesResolver() {
|
||||
@Nullable
|
||||
@Override
|
||||
public String get(String name) {
|
||||
public String get(@NotNull String name) {
|
||||
String propValue = externalProperties.get(name);
|
||||
if (propValue != null) {
|
||||
return propValue;
|
||||
|
||||
@@ -62,7 +62,7 @@ public class CBSessionManager implements WebAppSessionManager {
|
||||
public BaseWebSession closeSession(@NotNull HttpServletRequest request) {
|
||||
HttpSession session = request.getSession();
|
||||
if (session != null) {
|
||||
return closeSession(session.getId());
|
||||
return closeSession(getSessionId(request));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -113,8 +113,7 @@ public class CBSessionManager implements WebAppSessionManager {
|
||||
@NotNull HttpServletResponse response,
|
||||
boolean errorOnNoFound
|
||||
) throws DBWebException {
|
||||
HttpSession httpSession = request.getSession(true);
|
||||
String sessionId = httpSession.getId();
|
||||
String sessionId = getSessionId(request);
|
||||
WebSession webSession;
|
||||
synchronized (sessionMap) {
|
||||
var baseWebSession = sessionMap.get(sessionId);
|
||||
@@ -139,6 +138,7 @@ public class CBSessionManager implements WebAppSessionManager {
|
||||
log.error("Failed to restore previous user session", e);
|
||||
}
|
||||
|
||||
HttpSession httpSession = request.getSession(false);
|
||||
if (!restored && errorOnNoFound && !httpSession.isNew()) {
|
||||
throw new DBWebException("Session has expired", DBWebException.ERROR_CODE_SESSION_EXPIRED);
|
||||
}
|
||||
@@ -159,6 +159,12 @@ public class CBSessionManager implements WebAppSessionManager {
|
||||
return webSession;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
protected String getSessionId(@NotNull HttpServletRequest request) {
|
||||
HttpSession httpSession = request.getSession(true);
|
||||
return httpSession.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns not expired session from cache, or restore it.
|
||||
*
|
||||
@@ -244,7 +250,7 @@ public class CBSessionManager implements WebAppSessionManager {
|
||||
@Override
|
||||
@Nullable
|
||||
public WebSession findWebSession(HttpServletRequest request) {
|
||||
String sessionId = request.getSession().getId();
|
||||
String sessionId = getSessionId(request);
|
||||
synchronized (sessionMap) {
|
||||
var session = sessionMap.get(sessionId);
|
||||
if (session instanceof WebSession) {
|
||||
@@ -282,8 +288,7 @@ public class CBSessionManager implements WebAppSessionManager {
|
||||
}
|
||||
|
||||
for (var session : expiredList) {
|
||||
log.debug("> Expire session '" + session.getSessionId() + "'");
|
||||
session.close();
|
||||
closeExpiredSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,4 +434,9 @@ public class CBSessionManager implements WebAppSessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void closeExpiredSession(@NotNull BaseWebSession session) {
|
||||
log.debug("> Expire session '" + session.getSessionId() + "'");
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ Bundle-ManifestVersion: 2
|
||||
Bundle-Vendor: DBeaver Corp
|
||||
Bundle-Name: Cloudbeaver Web Server
|
||||
Bundle-SymbolicName: io.cloudbeaver.server;singleton:=true
|
||||
Bundle-Version: 25.0.5.qualifier
|
||||
Bundle-Release-Date: 20250519
|
||||
Bundle-Version: 25.1.1.qualifier
|
||||
Bundle-Release-Date: 20250616
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-17
|
||||
Bundle-ActivationPolicy: lazy
|
||||
Bundle-ClassPath: .
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
<artifactId>io.cloudbeaver.server</artifactId>
|
||||
<version>25.0.5-SNAPSHOT</version>
|
||||
<version>25.1.1-SNAPSHOT</version>
|
||||
<packaging>eclipse-plugin</packaging>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -50,7 +50,6 @@ import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -126,32 +125,16 @@ public class GraphQLEndpoint extends HttpServlet {
|
||||
|
||||
private void setDevelHeaders(HttpServletRequest request, HttpServletResponse response) {
|
||||
if (ServletAppUtils.getServletApplication().getServerConfiguration().isDevelMode()) {
|
||||
// response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
||||
// response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_HEADERS, "*");
|
||||
// response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, "*");
|
||||
|
||||
String referrer = request.getHeader("referer");
|
||||
String origin = request.getHeader("origin");
|
||||
if (origin == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
URL url = new URL(referrer);
|
||||
String protocol = url.getProtocol();
|
||||
String host = url.getHost();
|
||||
int port = url.getPort();
|
||||
|
||||
String origin;
|
||||
|
||||
// if the port is not explicitly specified in the input, it will be -1.
|
||||
if (port == -1) {
|
||||
origin = String.format("%s://%s", protocol, host);
|
||||
} else {
|
||||
origin = String.format("%s://%s:%d", protocol, host, port);
|
||||
}
|
||||
|
||||
// for local machine must be defined explicitly:
|
||||
response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||||
response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_HEADERS, "Set-Cookie, Content-Type");
|
||||
response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
|
||||
} catch (Throwable t) {}
|
||||
// for local machine must be defined explicitly:
|
||||
response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||||
response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_HEADERS, "Set-Cookie, Content-Type, Authorization");
|
||||
response.setHeader(HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +186,13 @@ public class GraphQLEndpoint extends HttpServlet {
|
||||
|
||||
JsonElement operNameJSON = reqObject.get("operationName");
|
||||
|
||||
executeQuery(request, response, query.getAsString(), variables, operNameJSON == null || operNameJSON instanceof JsonNull ? null : operNameJSON.getAsString());
|
||||
executeQuery(
|
||||
request,
|
||||
response,
|
||||
query.getAsString(),
|
||||
variables,
|
||||
operNameJSON == null || operNameJSON instanceof JsonNull ? null : operNameJSON.getAsString()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -230,12 +219,19 @@ public class GraphQLEndpoint extends HttpServlet {
|
||||
}
|
||||
}
|
||||
|
||||
private void executeQuery(HttpServletRequest request, HttpServletResponse response, String query, Map<String, Object> variables, String operationName) throws IOException {
|
||||
private void executeQuery(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
String query,
|
||||
Map<String, Object> variables,
|
||||
String operationName
|
||||
) throws IOException {
|
||||
Map<String, Object> mapOfContext =
|
||||
Map.of(
|
||||
"request", request,
|
||||
"response", response,
|
||||
"bindingContext", bindingContext);
|
||||
"bindingContext", bindingContext
|
||||
);
|
||||
ExecutionInput.Builder contextBuilder = ExecutionInput.newExecutionInput()
|
||||
.graphQLContext(mapOfContext)
|
||||
.query(query);
|
||||
@@ -247,11 +243,11 @@ public class GraphQLEndpoint extends HttpServlet {
|
||||
}
|
||||
{
|
||||
String apiCall = operationName;
|
||||
// if (!CommonUtils.isEmpty(apiCall)) {
|
||||
// if (variables != null) {
|
||||
// apiCall += " (" + variables + ")";
|
||||
// }
|
||||
// }
|
||||
// if (!CommonUtils.isEmpty(apiCall)) {
|
||||
// if (variables != null) {
|
||||
// apiCall += " (" + variables + ")";
|
||||
// }
|
||||
// }
|
||||
String sessionId = GraphQLLoggerUtil.getSessionId(request);
|
||||
String userId = GraphQLLoggerUtil.getUserId(request);
|
||||
String loggerMessage = GraphQLLoggerUtil.buildLoggerMessage(sessionId, userId, variables);
|
||||
@@ -288,7 +284,10 @@ public class GraphQLEndpoint extends HttpServlet {
|
||||
if (exception instanceof InvocationTargetException) {
|
||||
exception = ((InvocationTargetException) exception).getTargetException();
|
||||
}
|
||||
log.debug("GraphQL call failed at '" + handlerParameters.getPath() + "'" /*+ ", " + handlerParameters.getArgumentValues()*/, exception);
|
||||
log.debug(
|
||||
"GraphQL call failed at '" + handlerParameters.getPath() + "'" /*+ ", " + handlerParameters.getArgumentValues()*/,
|
||||
exception
|
||||
);
|
||||
|
||||
// Log in session
|
||||
WebSession webSession = WebServiceBindingBase.findWebSession(handlerParameters.getDataFetchingEnvironment());
|
||||
|
||||
@@ -18,9 +18,8 @@ package io.cloudbeaver.server.websockets;
|
||||
|
||||
import io.cloudbeaver.model.session.WebHeadlessSession;
|
||||
import io.cloudbeaver.model.session.WebHttpRequestInfo;
|
||||
import io.cloudbeaver.server.HttpConstants;
|
||||
import io.cloudbeaver.server.WebAppSessionManager;
|
||||
import io.cloudbeaver.utils.ServletAppUtils;
|
||||
import jakarta.servlet.Servlet;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.websocket.HandshakeResponse;
|
||||
import jakarta.websocket.server.HandshakeRequest;
|
||||
@@ -52,7 +51,7 @@ public class CBWebSocketServerConfigurator extends ServerEndpointConfig.Configur
|
||||
|
||||
@Override
|
||||
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
|
||||
String sessionId = request.getHttpSession() instanceof HttpSession httpSession ? httpSession.getId() : null;
|
||||
String sessionId = getSessionId(request);
|
||||
|
||||
String userAgentHeader = request.getHeaders()
|
||||
.get(WebHttpRequestInfo.USER_AGENT)
|
||||
@@ -93,6 +92,15 @@ public class CBWebSocketServerConfigurator extends ServerEndpointConfig.Configur
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getSessionId(@NotNull HandshakeRequest request) {
|
||||
// complex auth uses bearer authentication
|
||||
List<String> authHeaders = WSClientUtils.getHeaders(request.getHeaders(), HttpConstants.HEADER_AUTHORIZATION);
|
||||
if (!CommonUtils.isEmpty(authHeaders) && authHeaders.get(0).startsWith("Bearer ")) {
|
||||
return authHeaders.get(0).substring(7);
|
||||
}
|
||||
return request.getHttpSession() instanceof HttpSession httpSession ? httpSession.getId() : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private WebHeadlessSession createHeadlessSession(
|
||||
|
||||
@@ -121,6 +121,11 @@ public class WebSQLCompletionContext implements SQLCompletionContext {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isForceQualifiedColumnNames() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SQLCompletionProposalBase createProposal(@NotNull SQLCompletionRequest request, @NotNull String displayString, @NotNull String replacementString, int cursorPosition, @Nullable DBPImage image, @NotNull DBPKeywordType proposalType, @Nullable String description, @Nullable DBPNamedObject object, @NotNull Map<String, Object> params) {
|
||||
return new SQLCompletionProposalBase(request, displayString, replacementString, cursorPosition, image, proposalType, description, object, params);
|
||||
|
||||