Compare commits

...

13 Commits

Author SHA1 Message Date
Stela Augustinova 2a88ed38c4 Added translation tags to TableCellView component, updated decimal handling 2025-12-08 16:45:18 +01:00
Stela Augustinova 6ebee92542 Merge branch 'master' into feature/table-cell-data-view 2025-12-08 16:07:55 +01:00
Jan Prochazka 7024e4b40d Merge pull request #1289 from dbgate/feature/postgresql-decimal
Postgresql decimal
2025-12-08 15:42:19 +01:00
Jan Prochazka bc2e27d7da Merge pull request #1290 from david-pivonka/feature/table-cell-data-view
Add Table format to Cell data view sidebar
2025-12-08 15:35:35 +01:00
David Pivoňka 142ebe3d27 Fix scrolling in Table - Row view
Use absolute positioning pattern for proper scrolling behavior
when many columns are displayed.
2025-12-08 15:23:59 +01:00
David Pivoňka 38c25cae74 Add multi-row selection support with bulk editing
- Show "(Multiple values)" when selected rows have different values
- Allow bulk editing: changes apply to all selected rows
- Rename format to "Table - Row" for clarity
2025-12-08 15:01:02 +01:00
David Pivoňka 190c610466 Add column filter/search to Table cell data view
Adds a search input at the top of the Table view that filters columns
by name with regex support.
2025-12-08 14:31:38 +01:00
David Pivoňka d220525ac7 Use braces for isChangedRef.get() blocks to match codebase style 2025-12-08 13:47:35 +01:00
David Pivoňka 5e4a631ff2 Remove comments and apply early return pattern 2025-12-08 13:43:41 +01:00
David Pivoňka 9099ce42b9 Add Table format to Cell data view sidebar
Adds a new "Table" format option to the Cell data view widget that
displays the selected row as a vertical list with column names above
values, inspired by TablePlus.

Features:
- Shows all columns from the selected row in grid display order
- Inline editing support for regular values (double-click to edit)
- JSON values open Edit Cell modal on double-click
- Open-in-new button for JSON values to view in JSON tab
2025-12-08 13:37:55 +01:00
Jan Prochazka df226fea22 import test - greater timeout 2025-12-08 13:12:08 +01:00
SPRINX0\prochazka 89121a2608 handled UTF-8 BOM in CSV input 2025-12-04 16:44:08 +01:00
SPRINX0\prochazka 23cf264d4d fix 2025-12-04 16:29:06 +01:00
6 changed files with 435 additions and 4 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ Builds:
- ADDED: SQL AI assistant - powered by database chat, could help you to write SQL queries (Premium)
- ADDED: Explain SQL error (powered by AI) (Premium)
- ADDED: Database chat (and SQL AI Assistant) now supports showing charts (Premium)
- FIXED: Fxied editing new files and roles (Team Premium)
- FIXED: Fixed editing new files and roles (Team Premium)
- FIXED: Connection to standalone database could be now pinned
- FIXED: Cannot open up large JSON file #1215
+2 -2
View File
@@ -237,7 +237,7 @@ describe('Import CSV - source error', () => {
cy.testid('ImportExportTab_preview_content').contains('Invalid Closing Quote').should('be.visible');
cy.testid('ImportExportTab_executeButton').click();
cy.testid('ImportExportConfigurator_errorInfoIcon_customers-20-err').click();
cy.testid('ImportExportConfigurator_errorInfoIcon_customers-20-err', { timeout: 10000 }).click();
cy.testid('ErrorMessageModal_message').contains('Invalid Closing Quote').should('be.visible');
});
@@ -256,7 +256,7 @@ describe('Import CSV - target error', () => {
cy.contains('customers-20');
cy.testid('ImportExportConfigurator_targetName_customers-20').clear().type('system."]`');
cy.testid('ImportExportTab_executeButton').click();
cy.testid('ImportExportConfigurator_errorInfoIcon_customers-20').click();
cy.testid('ImportExportConfigurator_errorInfoIcon_customers-20', { timeout: 10000 }).click();
cy.testid('ErrorMessageModal_message').should('be.visible');
});
});
@@ -0,0 +1,356 @@
<script lang="ts">
import _ from 'lodash';
import { tick } from 'svelte';
import CellValue from '../datagrid/CellValue.svelte';
import { isJsonLikeLongString, safeJsonParse, parseCellValue, stringifyCellValue } from 'dbgate-tools';
import keycodes from '../utility/keycodes';
import createRef from '../utility/createRef';
import { showModal } from '../modals/modalTools';
import EditCellDataModal from '../modals/EditCellDataModal.svelte';
import ShowFormButton from '../formview/ShowFormButton.svelte';
import { openJsonDocument } from '../tabs/JsonTab.svelte';
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
import SearchInput from '../elements/SearchInput.svelte';
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
import { _t } from '../translations'
export let selection;
$: firstSelection = selection?.[0];
$: rowData = firstSelection?.rowData;
$: editable = firstSelection?.editable;
$: editorTypes = firstSelection?.editorTypes;
$: columns = selection?.columns || [];
$: realColumnUniqueNames = selection?.realColumnUniqueNames || [];
$: setCellValue = selection?.setCellValue;
$: uniqueRows = _.uniqBy(selection || [], 'row');
$: isMultipleRows = uniqueRows.length > 1;
function areValuesEqual(val1, val2) {
if (val1 === val2) return true;
if (val1 == null && val2 == null) return true;
if (val1 == null || val2 == null) return false;
return _.isEqual(val1, val2);
}
function getFieldValue(colName) {
if (!isMultipleRows) return { value: rowData?.[colName], hasMultipleValues: false };
const values = uniqueRows.map(sel => sel.rowData?.[colName]);
const firstValue = values[0];
const allSame = values.every(v => areValuesEqual(v, firstValue));
return allSame ? { value: firstValue, hasMultipleValues: false } : { value: null, hasMultipleValues: true };
}
let filter = '';
$: orderedFields = realColumnUniqueNames
.map(colName => {
const col = columns.find(c => c.uniqueName === colName);
if (!col) return null;
const { value, hasMultipleValues } = getFieldValue(colName);
return {
columnName: col.columnName || colName,
uniqueName: colName,
value,
hasMultipleValues,
col,
};
})
.filter(Boolean);
$: filteredFields = orderedFields.filter(field => {
if (!filter) return true;
try {
const regex = new RegExp(filter, 'i');
return regex.test(field.columnName);
} catch (e) {
return field.columnName.toLowerCase().includes(filter.toLowerCase());
}
});
let editingColumn = null;
let editValue = '';
let domEditor = null;
const isChangedRef = createRef(false);
function isJsonValue(value) {
if (_.isPlainObject(value) && !(value?.type == 'Buffer' && _.isArray(value.data)) && !value.$oid && !value.$bigint && !value.$decimal) {
return true;
}
if (_.isArray(value)) return true;
if (typeof value !== 'string') return false;
if (!isJsonLikeLongString(value)) return false;
const parsed = safeJsonParse(value);
return parsed !== null && (_.isPlainObject(parsed) || _.isArray(parsed));
}
function getJsonObject(value) {
if (_.isPlainObject(value) || _.isArray(value)) return value;
if (typeof value === 'string') return safeJsonParse(value);
return null;
}
function handleDoubleClick(field) {
if (!editable || !setCellValue) return;
if (isJsonValue(field.value) && !field.hasMultipleValues) {
openEditModal(field);
return;
}
startEditing(field);
}
function startEditing(field) {
if (!editable || !setCellValue) return;
editingColumn = field.uniqueName;
editValue = field.hasMultipleValues ? '' : stringifyCellValue(field.value, 'inlineEditorIntent', editorTypes).value;
isChangedRef.set(false);
tick().then(() => {
if (!domEditor) return;
domEditor.focus();
if (!field.hasMultipleValues) domEditor.select();
});
}
function handleKeyDown(event, field) {
switch (event.keyCode) {
case keycodes.escape:
isChangedRef.set(false);
editingColumn = null;
break;
case keycodes.enter:
if (isChangedRef.get()) {
saveValue(field);
}
editingColumn = null;
event.preventDefault();
break;
case keycodes.tab:
if (isChangedRef.get()) {
saveValue(field);
}
editingColumn = null;
event.preventDefault();
moveToNextField(field, event.shiftKey);
break;
}
}
function moveToNextField(field, reverse) {
const currentIndex = filteredFields.findIndex(f => f.uniqueName === field.uniqueName);
const nextIndex = reverse ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= filteredFields.length) return;
tick().then(() => {
const nextField = filteredFields[nextIndex];
if (isJsonValue(nextField.value)) {
openEditModal(nextField);
} else {
startEditing(nextField);
}
});
}
function handleSearchKeyDown(e) {
if (e.keyCode === keycodes.backspace && (e.metaKey || e.ctrlKey)) {
filter = '';
e.stopPropagation();
e.preventDefault();
}
}
function handleBlur(field) {
if (isChangedRef.get()) {
saveValue(field);
}
editingColumn = null;
}
function saveValue(field) {
if (!setCellValue) return;
const parsedValue = parseCellValue(editValue, editorTypes);
setCellValue(field.uniqueName, parsedValue);
isChangedRef.set(false);
}
function openEditModal(field) {
if (!setCellValue) return;
showModal(EditCellDataModal, {
value: field.value,
dataEditorTypesBehaviour: editorTypes,
onSave: value => setCellValue(field.uniqueName, value),
});
}
function openJsonInNewTab(field) {
const jsonObj = getJsonObject(field.value);
if (jsonObj) openJsonDocument(jsonObj, undefined, true);
}
function getJsonParsedValue(value) {
if (editorTypes?.explicitDataType) return null;
if (!isJsonLikeLongString(value)) return null;
return safeJsonParse(value);
}
</script>
<div class="outer">
<div class="content">
{#if rowData}
<div class="search-wrapper" on:keydown={handleSearchKeyDown}>
<SearchBoxWrapper noMargin>
<SearchInput placeholder={_t('tableCell.filterColumns', { defaultMessage: "Filter columns (regex)" })} bind:value={filter} />
<CloseSearchButton bind:filter />
</SearchBoxWrapper>
</div>
{/if}
<div class="inner">
{#if !rowData}
<div class="no-data">{_t('tableCell.noDataSelected', { defaultMessage: "No data selected" })}</div>
{:else}
{#each filteredFields as field (field.uniqueName)}
<div class="field">
<div class="field-name">{field.columnName}</div>
<div
class="field-value"
class:editable
on:dblclick={() => handleDoubleClick(field)}
>
{#if editingColumn === field.uniqueName}
<div class="editor-wrapper">
<input
type="text"
bind:this={domEditor}
bind:value={editValue}
on:input={() => isChangedRef.set(true)}
on:keydown={e => handleKeyDown(e, field)}
on:blur={() => handleBlur(field)}
class="inline-editor"
/>
{#if editable && !field.hasMultipleValues}
<ShowFormButton
icon="icon edit"
on:click={() => {
editingColumn = null;
openEditModal(field);
}}
/>
{/if}
</div>
{:else if field.hasMultipleValues}
<span class="multiple-values">({_t('tableCell.multipleValues', { defaultMessage: "Multiple values" })})</span>
{:else}
<CellValue
{rowData}
value={field.value}
jsonParsedValue={getJsonParsedValue(field.value)}
{editorTypes}
/>
{#if isJsonValue(field.value)}
<ShowFormButton
icon="icon open-in-new"
on:click={() => openJsonInNewTab(field)}
/>
{/if}
{/if}
</div>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.outer {
flex: 1;
position: relative;
}
.content {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.search-wrapper {
padding: 4px 4px 0 4px;
flex-shrink: 0;
}
.inner {
overflow: auto;
flex: 1;
padding: 4px;
}
.no-data {
color: var(--theme-font-3);
font-style: italic;
padding: 8px;
}
.field {
margin-bottom: 8px;
border: 1px solid var(--theme-border);
border-radius: 3px;
overflow: hidden;
}
.field-name {
background: var(--theme-bg-1);
padding: 4px 8px;
font-weight: 500;
font-size: 11px;
color: var(--theme-font-2);
border-bottom: 1px solid var(--theme-border);
}
.field-value {
padding: 6px 8px;
background: var(--theme-bg-0);
min-height: 20px;
word-break: break-all;
position: relative;
}
.field-value.editable {
cursor: text;
}
.field-value.editable:hover {
background: var(--theme-bg-hover);
}
.editor-wrapper {
display: flex;
align-items: center;
}
.inline-editor {
flex: 1;
border: none;
outline: none;
background: var(--theme-bg-0);
color: var(--theme-font-1);
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
}
.inline-editor:focus {
outline: none;
}
.multiple-values {
color: var(--theme-font-3);
font-style: italic;
}
</style>
@@ -1258,9 +1258,27 @@
condition: display?.getChangeSetCondition(rowData),
insertedRowIndex: grider?.getInsertedRowIndex(row),
rowStatus: grider.getRowStatus(row),
onSetValue: value => grider.setCellValue(row, column, value),
editable: grider.editable,
editorTypes: display?.driver?.dataEditorTypesBehaviour,
};
})
.filter(x => x.column);
res.columns = columns;
res.realColumnUniqueNames = realColumnUniqueNames;
if (res.length > 0) {
const uniqueRowIndices = _.uniq(res.map(x => x.row));
res.setCellValue = (columnName, value) => {
grider.beginUpdate();
for (const row of uniqueRowIndices) {
grider.setCellValue(row, columnName, value);
}
grider.endUpdate();
};
}
return res;
}
@@ -14,6 +14,12 @@
component: TextCellViewNoWrap,
single: false,
},
{
type: 'table',
title: 'Table - Row',
component: TableCellView,
single: false,
},
{
type: 'json',
title: 'Json',
@@ -92,6 +98,7 @@
import JsonRowView from '../celldata/JsonRowView.svelte';
import MapCellView from '../celldata/MapCellView.svelte';
import PictureCellView from '../celldata/PictureCellView.svelte';
import TableCellView from '../celldata/TableCellView.svelte';
import TextCellViewNoWrap from '../celldata/TextCellViewNoWrap.svelte';
import TextCellViewWrap from '../celldata/TextCellViewWrap.svelte';
import ErrorInfo from '../elements/ErrorInfo.svelte';
@@ -6,6 +6,56 @@ const lineReader = require('line-reader');
let dbgateApi;
class StripUtf8BomTransform extends stream.Transform {
constructor(options) {
super(options);
this._checkedBOM = false;
this._pending = Buffer.alloc(0); // store initial bytes until we know if BOM is present
}
_transform(chunk, encoding, callback) {
if (this._checkedBOM) {
// We already handled BOM decision, just pass through
this.push(chunk);
return callback();
}
// Accumulate into pending until we can decide
this._pending = Buffer.concat([this._pending, chunk]);
if (this._pending.length < 3) {
// Still don't know if it's BOM or not (need at least 3 bytes)
return callback();
}
// Now we can check the first 3 bytes
const BOM = [0xef, 0xbb, 0xbf];
const hasBom = this._pending[0] === BOM[0] && this._pending[1] === BOM[1] && this._pending[2] === BOM[2];
if (hasBom) {
// Drop the BOM, push the rest
this.push(this._pending.slice(3));
} else {
// No BOM, push everything as-is
this.push(this._pending);
}
this._pending = Buffer.alloc(0);
this._checkedBOM = true;
callback();
}
_flush(callback) {
// Stream ended but we never had enough bytes to decide (length < 3)
if (!this._checkedBOM && this._pending.length > 0) {
// If it's less than 3 bytes, it can't be a UTF-8 BOM, so just pass it through
this.push(this._pending);
}
this._pending = Buffer.alloc(0);
callback();
}
}
function readFirstLine(file) {
return new Promise((resolve, reject) => {
lineReader.open(file, (err, reader) => {
@@ -95,7 +145,7 @@ async function reader({ fileName, encoding = 'utf-8', header = true, delimiter,
});
const fileStream = fs.createReadStream(downloadedFile, encoding);
const csvPrepare = new CsvPrepareStream({ header });
return [fileStream, csvStream, csvPrepare];
return [fileStream, new StripUtf8BomTransform(), csvStream, csvPrepare];
// fileStream.pipe(csvStream);
// csvStream.pipe(csvPrepare);
// return csvPrepare;