mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:19:22 +00:00
feat: improve data grid selection and copy
This commit is contained in:
@@ -1925,9 +1925,14 @@ const {
|
||||
hasCellSelection,
|
||||
clearCellSelection,
|
||||
selectSingleCell,
|
||||
selectRow,
|
||||
selectColumn,
|
||||
selectAllCells,
|
||||
extendCellSelectionTo,
|
||||
finishCellSelection,
|
||||
extendCellSelection,
|
||||
cellIsSelected,
|
||||
columnIsSelected,
|
||||
selectedRangeStart,
|
||||
selectedRowIds,
|
||||
hasRowSelection,
|
||||
@@ -1951,6 +1956,12 @@ const multiRowCount = computed(() => {
|
||||
});
|
||||
|
||||
const isMultiRow = computed(() => multiRowCount.value > 1);
|
||||
const copyRowJsonLabel = computed(() => {
|
||||
if (isTransposeMode.value) {
|
||||
return isMultiRow.value ? t("grid.copyColumnsJson", { count: multiRowCount.value }) : t("grid.copyColumnJson");
|
||||
}
|
||||
return isMultiRow.value ? t("grid.copyRows", { count: multiRowCount.value }) : t("grid.copyRow");
|
||||
});
|
||||
|
||||
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
|
||||
hoveredDetailCell.value = { rowIndex, col: actualColIdx };
|
||||
@@ -2002,6 +2013,9 @@ function isRowActive(index: number): boolean {
|
||||
if (item && isRowSelected(item.id)) return true;
|
||||
const range = selectedRange.value;
|
||||
if (!range) return false;
|
||||
const coversAllVisibleRows = range.startRow === 0 && range.endRow >= displayItems.value.length - 1;
|
||||
const coversAllVisibleColumns = range.startCol === 0 && range.endCol >= visibleColumnCount.value - 1;
|
||||
if (coversAllVisibleRows && !coversAllVisibleColumns) return false;
|
||||
return index >= range.startRow && index <= range.endRow;
|
||||
}
|
||||
|
||||
@@ -2495,6 +2509,7 @@ const {
|
||||
copySelectionCsv,
|
||||
copySelectionJson,
|
||||
copySelectionSqlInList,
|
||||
copySelectedRowsTsv,
|
||||
exportCsv,
|
||||
exportJson,
|
||||
exportMarkdown,
|
||||
@@ -2530,6 +2545,37 @@ function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number,
|
||||
showCellDetails(rowIndex, actualColIdx);
|
||||
}
|
||||
|
||||
function transposeCellIsSelected(rowIndex: number, actualColIdx: number) {
|
||||
const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx);
|
||||
return visibleColIdx >= 0 && cellIsSelected(rowIndex, visibleColIdx);
|
||||
}
|
||||
|
||||
function onTransposeCellMouseenter(rowIndex: number, actualColIdx: number) {
|
||||
hoveredDetailCell.value = { rowIndex, col: actualColIdx };
|
||||
}
|
||||
|
||||
function selectTransposeCell(rowIndex: number, actualColIdx: number, event: MouseEvent) {
|
||||
const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx);
|
||||
if (visibleColIdx < 0) return;
|
||||
contextHeaderColumn.value = null;
|
||||
clearRowSelection();
|
||||
if (event.shiftKey || event.metaKey || event.ctrlKey) {
|
||||
extendCellSelectionTo(rowIndex, visibleColIdx);
|
||||
} else {
|
||||
selectSingleCell(rowIndex, visibleColIdx);
|
||||
}
|
||||
transposeRowIndex.value = rowIndex;
|
||||
showCellDetails(rowIndex, actualColIdx);
|
||||
gridRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function onTransposeCellContext(rowIndex: number, actualColIdx: number, event: MouseEvent) {
|
||||
selectTransposeCell(rowIndex, actualColIdx, event);
|
||||
const item = displayItems.value[rowIndex];
|
||||
contextCell.value = item ? { rowId: item.id, rowIndex, col: actualColIdx } : null;
|
||||
void prefetchCopyStatements();
|
||||
}
|
||||
|
||||
watch([selectedRange, showCellDetail, isEditingDetail], () => {
|
||||
const selectedCell = currentSelectedCellPosition();
|
||||
const target = linkedCellDetailTarget({
|
||||
@@ -2609,6 +2655,23 @@ function scrollCellIntoView(rowIndex: number, colIndex: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function scrollGridRowIntoView(rowIndex: number) {
|
||||
const target = Math.max(0, Math.min(displayItems.value.length - 1, rowIndex));
|
||||
nextTick(() => {
|
||||
const scroller = scrollerRef.value;
|
||||
if (scroller && !(scroller instanceof HTMLElement)) {
|
||||
scroller.scrollToItem?.(target);
|
||||
scroller.scrollToPosition?.(target * 26);
|
||||
} else if (scroller instanceof HTMLElement) {
|
||||
scroller.scrollTop = target * 26;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const rowEl = gridRef.value?.querySelector<HTMLElement>(`[data-row-index="${target}"]`);
|
||||
rowEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function currentTransposeRequestedRowIndex(): number {
|
||||
const position = currentSelectedCellPosition();
|
||||
if (position) return position.rowIndex;
|
||||
@@ -2618,10 +2681,11 @@ function currentTransposeRequestedRowIndex(): number {
|
||||
|
||||
function toggleKeyboardTranspose(): boolean {
|
||||
if (displayItems.value.length === 0) return false;
|
||||
const requestedRowIndex = currentTransposeRequestedRowIndex();
|
||||
const next = nextKeyboardTransposeState({
|
||||
showTranspose: showTranspose.value,
|
||||
transposeRowIndex: transposeRowIndex.value,
|
||||
requestedRowIndex: currentTransposeRequestedRowIndex(),
|
||||
requestedRowIndex,
|
||||
rowIds: displayItems.value.map((item) => item.id),
|
||||
selectedRowIds: selectedRowIds.value,
|
||||
selectedRange: selectedRange.value,
|
||||
@@ -2632,6 +2696,8 @@ function toggleKeyboardTranspose(): boolean {
|
||||
closeCellDetails();
|
||||
nextTick(updateTransposeViewport);
|
||||
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
|
||||
} else {
|
||||
scrollGridRowIntoView(requestedRowIndex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2746,9 +2812,23 @@ async function onGridKeydown(event: KeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
if (clipboardShortcut(event, "c")) {
|
||||
if (!hasCellSelection.value) return;
|
||||
if (!hasCellSelection.value && !hasRowSelection.value) return;
|
||||
event.preventDefault();
|
||||
copySelectionTsv();
|
||||
if (isTransposeMode.value && hasRowSelection.value) {
|
||||
copyRow();
|
||||
return;
|
||||
}
|
||||
if (hasCellSelection.value) {
|
||||
copySelectionTsv();
|
||||
} else {
|
||||
copySelectedRowsTsv();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clipboardShortcut(event, "a")) {
|
||||
if (!hasData.value) return;
|
||||
event.preventDefault();
|
||||
selectAllCells();
|
||||
return;
|
||||
}
|
||||
if (clipboardShortcut(event, "x")) {
|
||||
@@ -2905,9 +2985,17 @@ function onTransposeRecordResizeStart(event: MouseEvent) {
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function closeTranspose() {
|
||||
function currentTransposeViewportRowIndex(): number {
|
||||
if (displayItems.value.length === 0) return 0;
|
||||
const rowIndex = transposeRowIndex.value ?? transposeRecordWindow.value.start;
|
||||
return Math.max(0, Math.min(displayItems.value.length - 1, rowIndex));
|
||||
}
|
||||
|
||||
function closeTranspose(scrollToCurrentRecord = true) {
|
||||
const rowIndex = currentTransposeViewportRowIndex();
|
||||
showTranspose.value = false;
|
||||
transposeRowIndex.value = null;
|
||||
if (scrollToCurrentRecord) scrollGridRowIntoView(rowIndex);
|
||||
}
|
||||
|
||||
function openContextTranspose() {
|
||||
@@ -2941,12 +3029,27 @@ function toggleTranspose(rowIndex: number) {
|
||||
closeCellDetails();
|
||||
nextTick(updateTransposeViewport);
|
||||
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
|
||||
} else {
|
||||
scrollGridRowIntoView(rowIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function selectTransposeRecord(rowIndex: number) {
|
||||
function selectTransposeRecord(rowIndex: number, event?: MouseEvent) {
|
||||
if (rowIndex < 0 || rowIndex >= displayItems.value.length) return;
|
||||
transposeRowIndex.value = rowIndex;
|
||||
contextHeaderColumn.value = null;
|
||||
const item = displayItems.value[rowIndex];
|
||||
if (item) {
|
||||
if (event) {
|
||||
handleRowClick(rowIndex, item.id, event);
|
||||
} else {
|
||||
selectedRowIds.value = new Set([item.id]);
|
||||
selection.lastClickedRowIndex.value = rowIndex;
|
||||
selectRow(rowIndex);
|
||||
}
|
||||
contextCell.value = { rowId: item.id, rowIndex, col: -1 };
|
||||
void prefetchCopyStatements();
|
||||
}
|
||||
gridRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
@@ -2984,7 +3087,7 @@ watch(
|
||||
nextTransposeStateForRecordCount(showTranspose.value, transposeRowIndex.value, displayItems.value.length),
|
||||
);
|
||||
} else {
|
||||
closeTranspose();
|
||||
closeTranspose(false);
|
||||
}
|
||||
exitTransaction();
|
||||
},
|
||||
@@ -3329,37 +3432,8 @@ onUnmounted(() => {
|
||||
clearInterval(_loadingTimer);
|
||||
});
|
||||
|
||||
const SQL_KEYWORDS =
|
||||
/\b(CREATE|TABLE|INDEX|UNIQUE|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|NOT|NULL|DEFAULT|INT|INTEGER|BIGINT|SMALLINT|VARCHAR|CHARACTER|VARYING|TEXT|BOOLEAN|DOUBLE|PRECISION|REAL|FLOAT|NUMERIC|DECIMAL|TIMESTAMP|DATE|TIME|SERIAL|AUTOINCREMENT|AUTO_INCREMENT|IF|EXISTS|ON|SET|CASCADE|RESTRICT|CHECK|WITH|WITHOUT|ZONE)\b/gi;
|
||||
|
||||
const highlightedDdlContent = computed(() => highlight(ddlContent.value));
|
||||
|
||||
function highlightSql(sql: string): string {
|
||||
const tokens: string[] = [];
|
||||
let rest = sql;
|
||||
const re = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let last = 0;
|
||||
while ((match = re.exec(rest)) !== null) {
|
||||
if (match.index > last) tokens.push(escapeAndHighlightKeywords(rest.slice(last, match.index)));
|
||||
const q = match[1];
|
||||
const escaped = q.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const cls = q.startsWith('"') ? "ddl-ident" : "ddl-str";
|
||||
tokens.push(`<span class="${cls}">${escaped}</span>`);
|
||||
last = re.lastIndex;
|
||||
}
|
||||
if (last < rest.length) tokens.push(escapeAndHighlightKeywords(rest.slice(last)));
|
||||
return tokens.join("");
|
||||
}
|
||||
|
||||
function escapeAndHighlightKeywords(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(SQL_KEYWORDS, '<span class="ddl-kw">$1</span>');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
useTransaction,
|
||||
transactionActive,
|
||||
@@ -3920,7 +3994,8 @@ defineExpose({
|
||||
: 'bg-[rgb(239_239_239)] dark:bg-muted'
|
||||
"
|
||||
:style="{ width: `${transposeRecordWidth}px` }"
|
||||
@click="selectTransposeRecord(recordIndex)"
|
||||
@click="selectTransposeRecord(recordIndex, $event)"
|
||||
@contextmenu="selectTransposeRecord(recordIndex, $event)"
|
||||
>
|
||||
{{ recordIndex + 1 }}
|
||||
<div
|
||||
@@ -3950,6 +4025,12 @@ defineExpose({
|
||||
class="relative shrink-0 border-r border-border/70 px-2 py-1.5 font-mono truncate"
|
||||
:class="{
|
||||
'text-muted-foreground italic': cell.isNull,
|
||||
'cell-selected':
|
||||
transposeCellIsSelected(cell.recordIndex, cell.valueIndex) &&
|
||||
!displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'cell-selected-dirty':
|
||||
transposeCellIsSelected(cell.recordIndex, cell.valueIndex) &&
|
||||
displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'bg-primary/10': cell.recordIndex === transposeRowIndex,
|
||||
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'cursor-text hover:bg-accent/50': canEditCellItem(
|
||||
@@ -3959,7 +4040,10 @@ defineExpose({
|
||||
}"
|
||||
:style="{ width: `${transposeRecordWidth}px` }"
|
||||
:title="cell.display"
|
||||
@click="selectTransposeRecord(cell.recordIndex)"
|
||||
@click="selectTransposeCell(cell.recordIndex, cell.valueIndex, $event)"
|
||||
@mouseenter="onTransposeCellMouseenter(cell.recordIndex, cell.valueIndex)"
|
||||
@mouseleave="onCellMouseleave(cell.recordIndex, cell.valueIndex)"
|
||||
@contextmenu="onTransposeCellContext(cell.recordIndex, cell.valueIndex, $event)"
|
||||
@dblclick.stop="
|
||||
canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) &&
|
||||
startEdit(displayItems[cell.recordIndex].id, cell.valueIndex)
|
||||
@@ -3992,6 +4076,15 @@ defineExpose({
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ cell.display }}
|
||||
<button
|
||||
v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)"
|
||||
class="absolute right-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
|
||||
:title="t('grid.cellDetails')"
|
||||
@mousedown.stop
|
||||
@click.stop="showCellDetails(cell.recordIndex, cell.valueIndex)"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.afterWidth}px` }" />
|
||||
@@ -4007,8 +4100,9 @@ defineExpose({
|
||||
>
|
||||
<div class="flex text-xs font-semibold text-foreground" :style="{ width: 'var(--header-total-w)' }">
|
||||
<div
|
||||
class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none"
|
||||
class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none cursor-pointer hover:bg-accent/60"
|
||||
:style="{ width: 'var(--row-num-w)' }"
|
||||
@click="selectAllCells"
|
||||
>
|
||||
#
|
||||
</div>
|
||||
@@ -4018,18 +4112,16 @@ defineExpose({
|
||||
class="shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-accent/60 select-none relative overflow-hidden"
|
||||
:class="{
|
||||
'bg-primary/15 ring-1 ring-inset ring-primary/40':
|
||||
highlightedColumnIndex === actualColumnIndex(colIdx),
|
||||
highlightedColumnIndex === actualColumnIndex(colIdx) || columnIsSelected(colIdx),
|
||||
}"
|
||||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
:data-grid-column-index="actualColumnIndex(colIdx)"
|
||||
@click="selectColumn(colIdx, $event)"
|
||||
@contextmenu="onHeaderContext(col)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<span
|
||||
class="min-w-0 truncate cursor-pointer leading-4"
|
||||
@click="toggleSort(col, actualColumnIndex(colIdx))"
|
||||
>
|
||||
<span class="min-w-0 truncate leading-4">
|
||||
{{ col }}
|
||||
</span>
|
||||
<span
|
||||
@@ -5158,7 +5250,7 @@ defineExpose({
|
||||
<ContextMenuSubContent class="w-max max-w-[min(80vw,18rem)]">
|
||||
<ContextMenuItem v-if="contextColumn" @click="copyCell">{{ t("grid.copyCell") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyRow">
|
||||
{{ isMultiRow ? t("grid.copyRows", { count: multiRowCount }) : t("grid.copyRow") }}
|
||||
{{ copyRowJsonLabel }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem :disabled="!canCopyPreparedInsert(false)" @click="copyRowAsInsert">
|
||||
{{ isMultiRow ? t("grid.copyRowsInsert", { count: multiRowCount }) : t("grid.copyRowInsert") }}
|
||||
|
||||
@@ -320,6 +320,26 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
await copyText(formatSelectionAsSqlInList(selectedCells.value));
|
||||
}
|
||||
|
||||
async function copySelectedRowsTsv() {
|
||||
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
|
||||
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id)).map((item) => item.data);
|
||||
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }));
|
||||
}
|
||||
|
||||
function rowToJsonObject(item: RowItem): Record<string, unknown> {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.value.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function copyRowsAsJson(items: RowItem[]) {
|
||||
if (items.length === 0) return;
|
||||
const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject);
|
||||
await copyText(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
// --- Cell/row copy ---
|
||||
async function copyCell() {
|
||||
if (!contextCell.value || contextCell.value.col < 0) return;
|
||||
@@ -331,37 +351,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
async function copyRow() {
|
||||
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
|
||||
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
|
||||
const objects = items.map((item) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.value.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
await copyText(JSON.stringify(objects, null, 2));
|
||||
await copyRowsAsJson(items);
|
||||
return;
|
||||
}
|
||||
const range = selectedRange.value;
|
||||
if (range && range.startRow !== range.endRow) {
|
||||
const items = displayItems.value.slice(range.startRow, range.endRow + 1);
|
||||
const objects = items.map((item) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.value.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
await copyText(JSON.stringify(objects, null, 2));
|
||||
await copyRowsAsJson(items);
|
||||
return;
|
||||
}
|
||||
if (!contextCell.value) return;
|
||||
const item = getRowItem(contextCell.value.rowId);
|
||||
if (!item) return;
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.value.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
});
|
||||
await copyText(JSON.stringify(obj, null, 2));
|
||||
await copyRowsAsJson([item]);
|
||||
}
|
||||
|
||||
function insertEligibleRows(): RowItem[] {
|
||||
@@ -545,6 +547,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
copySelectionCsv,
|
||||
copySelectionJson,
|
||||
copySelectionSqlInList,
|
||||
copySelectedRowsTsv,
|
||||
exportCsv,
|
||||
exportJson,
|
||||
exportMarkdown,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ref, computed, type ComputedRef, type Ref } from "vue";
|
||||
import {
|
||||
allCellsSelectionRange,
|
||||
columnSelectionRange,
|
||||
extractSelection,
|
||||
isCellInSelection,
|
||||
normalizeSelectionRange,
|
||||
rowSelectionRange,
|
||||
type CellPosition,
|
||||
type CellSelectionRange,
|
||||
type SelectionData,
|
||||
@@ -39,6 +42,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
|
||||
const selectedRowIds = ref<Set<number>>(new Set());
|
||||
const lastClickedRowIndex = ref<number | null>(null);
|
||||
const lastClickedColumnIndex = ref<number | null>(null);
|
||||
const hasRowSelection = computed(() => selectedRowIds.value.size > 0);
|
||||
const selectedRowCount = computed(() => selectedRowIds.value.size);
|
||||
|
||||
@@ -74,9 +78,57 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
}
|
||||
|
||||
function selectRow(rowIndex: number) {
|
||||
if (columns.value.length === 0) return;
|
||||
selectionAnchor.value = { rowIndex, colIndex: 0 };
|
||||
selectionFocus.value = { rowIndex, colIndex: columns.value.length - 1 };
|
||||
const range = rowSelectionRange(rowIndex, columns.value.length);
|
||||
if (!range) return;
|
||||
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
|
||||
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
|
||||
}
|
||||
|
||||
function selectRows(startRow: number, endRow: number) {
|
||||
const range = rowSelectionRange(startRow, columns.value.length, endRow);
|
||||
if (!range) return;
|
||||
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
|
||||
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
|
||||
}
|
||||
|
||||
function selectColumns(startCol: number, endCol: number) {
|
||||
const range = columnSelectionRange(displayItems.value.length, startCol, endCol);
|
||||
if (!range) return;
|
||||
clearRowSelection();
|
||||
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
|
||||
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
|
||||
focusGridWithoutScrolling();
|
||||
}
|
||||
|
||||
function selectColumn(colIndex: number, event?: MouseEvent) {
|
||||
const isShift = Boolean(event?.shiftKey);
|
||||
const isMeta = Boolean(event?.metaKey || event?.ctrlKey);
|
||||
if (isShift && lastClickedColumnIndex.value !== null) {
|
||||
selectColumns(lastClickedColumnIndex.value, colIndex);
|
||||
} else if (isMeta && selectedRange.value) {
|
||||
selectColumns(Math.min(selectedRange.value.startCol, colIndex), Math.max(selectedRange.value.endCol, colIndex));
|
||||
} else {
|
||||
selectColumns(colIndex, colIndex);
|
||||
}
|
||||
lastClickedColumnIndex.value = colIndex;
|
||||
}
|
||||
|
||||
function selectAllCells() {
|
||||
const range = allCellsSelectionRange(displayItems.value.length, columns.value.length);
|
||||
if (!range) return;
|
||||
clearRowSelection();
|
||||
lastClickedColumnIndex.value = null;
|
||||
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
|
||||
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
|
||||
focusGridWithoutScrolling();
|
||||
}
|
||||
|
||||
function extendCellSelectionTo(rowIndex: number, colIndex: number) {
|
||||
if (!selectionAnchor.value) {
|
||||
selectSingleCell(rowIndex, colIndex);
|
||||
return;
|
||||
}
|
||||
selectionFocus.value = { rowIndex, colIndex };
|
||||
}
|
||||
|
||||
function handleRowClick(rowIndex: number, rowId: number, event: MouseEvent) {
|
||||
@@ -103,8 +155,10 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
if (item) next.add(item.id);
|
||||
}
|
||||
selectedRowIds.value = next;
|
||||
selectRows(start, end);
|
||||
} else {
|
||||
selectedRowIds.value = new Set([rowId]);
|
||||
selectRow(rowIndex);
|
||||
lastClickedRowIndex.value = rowIndex;
|
||||
}
|
||||
}
|
||||
@@ -125,6 +179,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
focusGridWithoutScrolling();
|
||||
selectSingleCell(rowIndex, colIndex);
|
||||
isSelectingCells.value = true;
|
||||
lastClickedColumnIndex.value = colIndex;
|
||||
if (showTranspose.value) transposeRowIndex.value = rowIndex;
|
||||
document.addEventListener("mouseup", finishCellSelection);
|
||||
}
|
||||
@@ -134,7 +189,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
selectionFocus.value = { rowIndex, colIndex };
|
||||
}
|
||||
|
||||
function handleDataCellMousedown(rowIndex: number, colIndex: number, rowId: number, event: MouseEvent) {
|
||||
function handleDataCellMousedown(rowIndex: number, colIndex: number, _rowId: number, event: MouseEvent) {
|
||||
if (event.button !== 0) return;
|
||||
if (editingCell.value) return;
|
||||
|
||||
@@ -144,7 +199,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
if (isMeta || isShift) {
|
||||
event.preventDefault();
|
||||
focusGridWithoutScrolling();
|
||||
handleRowClick(rowIndex, rowId, event);
|
||||
clearRowSelection();
|
||||
extendCellSelectionTo(rowIndex, colIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,6 +216,17 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
return isCellInSelection(rowIndex, colIndex, selectedRange.value);
|
||||
}
|
||||
|
||||
function columnIsSelected(colIndex: number): boolean {
|
||||
const range = selectedRange.value;
|
||||
if (!range) return false;
|
||||
return (
|
||||
range.startCol <= colIndex &&
|
||||
range.endCol >= colIndex &&
|
||||
range.startRow === 0 &&
|
||||
range.endRow >= displayItems.value.length - 1
|
||||
);
|
||||
}
|
||||
|
||||
function selectedRangeStart(): CellPosition | null {
|
||||
const range = selectedRange.value;
|
||||
if (!range) return null;
|
||||
@@ -177,10 +244,15 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
||||
clearCellSelection,
|
||||
selectSingleCell,
|
||||
selectRow,
|
||||
selectColumn,
|
||||
selectColumns,
|
||||
selectAllCells,
|
||||
extendCellSelectionTo,
|
||||
finishCellSelection,
|
||||
beginCellSelection,
|
||||
extendCellSelection,
|
||||
cellIsSelected,
|
||||
columnIsSelected,
|
||||
selectedRangeStart,
|
||||
selectedRowIds,
|
||||
lastClickedRowIndex,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sqlHighlighter";
|
||||
|
||||
|
||||
@@ -320,6 +320,7 @@ export default {
|
||||
copy: "Copy",
|
||||
copyCell: "Copy Cell",
|
||||
copyRow: "Copy Row (JSON)",
|
||||
copyColumnJson: "Copy Column (JSON)",
|
||||
copyRowInsert: "Copy as INSERT",
|
||||
copyRowInsertWithoutPrimaryKeys: "Copy as INSERT without Primary Keys",
|
||||
copyRowUpdate: "Copy as UPDATE",
|
||||
@@ -436,6 +437,7 @@ export default {
|
||||
cloneRows: "Clone {count} as New Rows",
|
||||
restoreRows: "Restore {count} Rows",
|
||||
copyRows: "Copy {count} Rows (JSON)",
|
||||
copyColumnsJson: "Copy {count} Columns (JSON)",
|
||||
copyRowsInsert: "Copy {count} Rows as INSERT",
|
||||
copyRowsInsertWithoutPrimaryKeys: "Copy {count} Rows as INSERT without Primary Keys",
|
||||
copyRowsUpdate: "Copy {count} Rows as UPDATE",
|
||||
|
||||
@@ -311,6 +311,7 @@ export default {
|
||||
copy: "Copiar",
|
||||
copyCell: "Copiar celda",
|
||||
copyRow: "Copiar fila (JSON)",
|
||||
copyColumnJson: "Copiar columna (JSON)",
|
||||
copyRowInsert: "Copiar como INSERT",
|
||||
copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sin claves primarias",
|
||||
copyRowUpdate: "Copiar como UPDATE",
|
||||
@@ -400,6 +401,7 @@ export default {
|
||||
cloneRows: "Clonar {count} como filas nuevas",
|
||||
restoreRows: "Restaurar {count} filas",
|
||||
copyRows: "Copiar {count} filas (JSON)",
|
||||
copyColumnsJson: "Copiar {count} columnas (JSON)",
|
||||
copyRowsInsert: "Copiar {count} filas como INSERT",
|
||||
copyRowsInsertWithoutPrimaryKeys: "Copiar {count} filas como INSERT sin claves primarias",
|
||||
copyRowsUpdate: "Copiar {count} filas como UPDATE",
|
||||
|
||||
@@ -318,6 +318,7 @@ export default {
|
||||
copy: "复制",
|
||||
copyCell: "复制单元格",
|
||||
copyRow: "复制行 (JSON)",
|
||||
copyColumnJson: "复制列 (JSON)",
|
||||
copyRowInsert: "复制为 INSERT 语句",
|
||||
copyRowInsertWithoutPrimaryKeys: "复制为 INSERT 语句(不含主键)",
|
||||
copyRowUpdate: "复制为 UPDATE 语句",
|
||||
@@ -433,6 +434,7 @@ export default {
|
||||
cloneRows: "克隆 {count} 行为新行",
|
||||
restoreRows: "恢复 {count} 行",
|
||||
copyRows: "复制 {count} 行 (JSON)",
|
||||
copyColumnsJson: "复制 {count} 列 (JSON)",
|
||||
copyRowsInsert: "复制 {count} 行为 INSERT",
|
||||
copyRowsInsertWithoutPrimaryKeys: "复制 {count} 行为 INSERT(不含主键)",
|
||||
copyRowsUpdate: "复制 {count} 行为 UPDATE",
|
||||
|
||||
@@ -26,6 +26,39 @@ export function normalizeSelectionRange(anchor: CellPosition, focus: CellPositio
|
||||
};
|
||||
}
|
||||
|
||||
export function rowSelectionRange(
|
||||
rowIndex: number,
|
||||
columnCount: number,
|
||||
endRowIndex = rowIndex,
|
||||
): CellSelectionRange | null {
|
||||
if (rowIndex < 0 || endRowIndex < 0 || columnCount <= 0) return null;
|
||||
return {
|
||||
startRow: Math.min(rowIndex, endRowIndex),
|
||||
endRow: Math.max(rowIndex, endRowIndex),
|
||||
startCol: 0,
|
||||
endCol: columnCount - 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function columnSelectionRange(
|
||||
rowCount: number,
|
||||
colIndex: number,
|
||||
endColIndex = colIndex,
|
||||
): CellSelectionRange | null {
|
||||
if (rowCount <= 0 || colIndex < 0 || endColIndex < 0) return null;
|
||||
return {
|
||||
startRow: 0,
|
||||
endRow: rowCount - 1,
|
||||
startCol: Math.min(colIndex, endColIndex),
|
||||
endCol: Math.max(colIndex, endColIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function allCellsSelectionRange(rowCount: number, columnCount: number): CellSelectionRange | null {
|
||||
if (rowCount <= 0 || columnCount <= 0) return null;
|
||||
return { startRow: 0, endRow: rowCount - 1, startCol: 0, endCol: columnCount - 1 };
|
||||
}
|
||||
|
||||
export function isCellInSelection(rowIndex: number, colIndex: number, range: CellSelectionRange | null): boolean {
|
||||
if (!range) return false;
|
||||
return (
|
||||
|
||||
@@ -1392,6 +1392,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![renamed, email],
|
||||
indexes: vec![old_index, email_index],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1418,6 +1420,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![created_at],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1439,6 +1443,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![created_at],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1467,6 +1473,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id, name],
|
||||
indexes: vec![idx],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1501,6 +1509,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.statements, Vec::<String>::new());
|
||||
@@ -1559,6 +1569,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id, email, name],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1583,6 +1595,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![email],
|
||||
indexes: vec![index("idx_users_email", &["email"])],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1610,6 +1624,8 @@ mod tests {
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![name, created_at],
|
||||
indexes: vec![index("idx_events_name", &["name"])],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1648,6 +1664,8 @@ mod tests {
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![source, status],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1687,6 +1705,8 @@ mod tests {
|
||||
table_name: "USERS".to_string(),
|
||||
columns: vec![name],
|
||||
indexes: vec![index("IDX_USERS_DISPLAY_NAME", &["DISPLAY_NAME"])],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1725,6 +1745,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1753,6 +1775,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1797,6 +1821,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![old_pk, new_pk],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1828,6 +1854,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
@@ -1856,6 +1884,8 @@ mod tests {
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.statements, Vec::<String>::new());
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("../../apps/desktop/src/components/grid/DataGrid.vue", import.meta.url), "utf8");
|
||||
const selectionSource = readFileSync(
|
||||
new URL("../../apps/desktop/src/composables/useDataGridSelection.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("data grid wires whole table, row, and column selection gestures", () => {
|
||||
assert.match(source, /@click="selectAllCells"/);
|
||||
assert.match(source, /@click="selectColumn\(colIdx, \$event\)"/);
|
||||
assert.match(source, /columnIsSelected\(colIdx\)/);
|
||||
assert.match(selectionSource, /function selectAllCells\(\)/);
|
||||
assert.match(selectionSource, /function selectColumn\(colIndex: number, event\?: MouseEvent\)/);
|
||||
assert.match(selectionSource, /lastClickedColumnIndex/);
|
||||
});
|
||||
|
||||
test("data grid intercepts copy and select-all shortcuts for grid selections", () => {
|
||||
assert.match(source, /clipboardShortcut\(event, "a"\)/);
|
||||
assert.match(source, /selectAllCells\(\)/);
|
||||
assert.match(source, /isTransposeMode\.value && hasRowSelection\.value/);
|
||||
assert.match(source, /copyRow\(\);/);
|
||||
assert.match(source, /if \(hasCellSelection\.value\) \{\s+copySelectionTsv\(\);/);
|
||||
assert.match(source, /copySelectedRowsTsv\(\)/);
|
||||
});
|
||||
|
||||
test("transpose cells reuse grid cell selection and details", () => {
|
||||
assert.match(source, /function selectTransposeCell\(rowIndex: number, actualColIdx: number, event: MouseEvent\)/);
|
||||
assert.match(source, /transposeCellIsSelected\(cell\.recordIndex, cell\.valueIndex\)/);
|
||||
assert.match(source, /@click="selectTransposeCell\(cell\.recordIndex, cell\.valueIndex, \$event\)"/);
|
||||
assert.match(source, /@contextmenu="onTransposeCellContext\(cell\.recordIndex, cell\.valueIndex, \$event\)"/);
|
||||
assert.match(source, /showCellDetails\(cell\.recordIndex, cell\.valueIndex\)/);
|
||||
});
|
||||
|
||||
test("transpose record headers copy selected records as rows", () => {
|
||||
assert.match(source, /function selectTransposeRecord\(rowIndex: number, event\?: MouseEvent\)/);
|
||||
assert.match(source, /handleRowClick\(rowIndex, item\.id, event\)/);
|
||||
assert.match(source, /@click="selectTransposeRecord\(recordIndex, \$event\)"/);
|
||||
assert.match(source, /@contextmenu="selectTransposeRecord\(recordIndex, \$event\)"/);
|
||||
assert.match(source, /copyRowJsonLabel/);
|
||||
assert.match(source, /if \(isTransposeMode\.value\)/);
|
||||
assert.match(source, /grid\.copyColumnJson/);
|
||||
assert.match(source, /grid\.copyColumnsJson/);
|
||||
});
|
||||
@@ -27,3 +27,12 @@ test("transpose mode follows appended rows and survives rollback refresh", () =>
|
||||
);
|
||||
assert.match(source, /nextTransposeStateForRecordCount/);
|
||||
});
|
||||
|
||||
test("closing transpose scrolls the normal grid to the active record", () => {
|
||||
assert.match(source, /function currentTransposeViewportRowIndex\(\)/);
|
||||
assert.match(source, /transposeRowIndex\.value \?\? transposeRecordWindow\.value\.start/);
|
||||
assert.match(source, /function scrollGridRowIntoView\(rowIndex: number\)/);
|
||||
assert.match(source, /scrollToItem\?\.\(target\)/);
|
||||
assert.match(source, /function closeTranspose\(scrollToCurrentRecord = true\)/);
|
||||
assert.match(source, /closeTranspose\(false\)/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
allCellsSelectionRange,
|
||||
columnSelectionRange,
|
||||
extractSelection,
|
||||
formatSelectionAsCsv,
|
||||
formatSelectionAsJson,
|
||||
@@ -8,13 +10,11 @@ import {
|
||||
formatSelectionAsTsv,
|
||||
isCellInSelection,
|
||||
normalizeSelectionRange,
|
||||
rowSelectionRange,
|
||||
} from "../../apps/desktop/src/lib/gridSelection.ts";
|
||||
|
||||
test("normalizes a dragged cell range in either direction", () => {
|
||||
const range = normalizeSelectionRange(
|
||||
{ rowIndex: 4, colIndex: 3 },
|
||||
{ rowIndex: 1, colIndex: 0 },
|
||||
);
|
||||
const range = normalizeSelectionRange({ rowIndex: 4, colIndex: 3 }, { rowIndex: 1, colIndex: 0 });
|
||||
|
||||
assert.deepEqual(range, {
|
||||
startRow: 1,
|
||||
@@ -44,25 +44,43 @@ test("extracts selection rows and columns from a rectangular range", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds whole row, column, and table selection ranges", () => {
|
||||
assert.deepEqual(rowSelectionRange(2, 4), { startRow: 2, endRow: 2, startCol: 0, endCol: 3 });
|
||||
assert.deepEqual(rowSelectionRange(4, 4, 2), { startRow: 2, endRow: 4, startCol: 0, endCol: 3 });
|
||||
assert.deepEqual(columnSelectionRange(5, 1), { startRow: 0, endRow: 4, startCol: 1, endCol: 1 });
|
||||
assert.deepEqual(columnSelectionRange(5, 3, 1), { startRow: 0, endRow: 4, startCol: 1, endCol: 3 });
|
||||
assert.deepEqual(allCellsSelectionRange(3, 2), { startRow: 0, endRow: 2, startCol: 0, endCol: 1 });
|
||||
assert.equal(rowSelectionRange(0, 0), null);
|
||||
assert.equal(columnSelectionRange(0, 0), null);
|
||||
assert.equal(allCellsSelectionRange(0, 2), null);
|
||||
});
|
||||
|
||||
test("formats selected cells as TSV, CSV, JSON, and SQL values", () => {
|
||||
const selection = {
|
||||
columns: ["name", "note"],
|
||||
rows: [
|
||||
["Ada", "math"],
|
||||
["Bob", "quote \"here\""],
|
||||
["Bob", 'quote "here"'],
|
||||
["O'Hara", null],
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(formatSelectionAsTsv(selection), "Ada\tmath\nBob\tquote \"here\"\nO'Hara\tNULL");
|
||||
assert.equal(formatSelectionAsCsv(selection), "\"name\",\"note\"\n\"Ada\",\"math\"\n\"Bob\",\"quote \"\"here\"\"\"\n\"O'Hara\",\"NULL\"");
|
||||
assert.equal(formatSelectionAsTsv(selection), 'Ada\tmath\nBob\tquote "here"\nO\'Hara\tNULL');
|
||||
assert.equal(
|
||||
formatSelectionAsCsv(selection),
|
||||
'"name","note"\n"Ada","math"\n"Bob","quote ""here"""\n"O\'Hara","NULL"',
|
||||
);
|
||||
assert.equal(
|
||||
formatSelectionAsJson(selection),
|
||||
JSON.stringify([
|
||||
{ name: "Ada", note: "math" },
|
||||
{ name: "Bob", note: "quote \"here\"" },
|
||||
{ name: "O'Hara", note: null },
|
||||
], null, 2),
|
||||
JSON.stringify(
|
||||
[
|
||||
{ name: "Ada", note: "math" },
|
||||
{ name: "Bob", note: 'quote "here"' },
|
||||
{ name: "O'Hara", note: null },
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
assert.equal(formatSelectionAsSqlInList(selection), "('Ada', 'math', 'Bob', 'quote \"here\"', 'O''Hara', NULL)");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user