Improve data grid cell detail, navigation, and transpose UX (#342)
* fix(mysql): avoid metadata collation conflicts Split MySQL column and primary key metadata lookups so MariaDB and OceanBase do not join information_schema tables with incompatible collations. * feat(grid): add invert column visibility action * feat(grid): improve cell detail editing * feat(grid): add keyboard cell navigation * feat(grid): add column name copy in header tooltip * feat(grid): improve transpose navigation
This commit is contained in:
parent
b9bcb90ac7
commit
49a7541450
|
|
@ -119,6 +119,7 @@ import {
|
|||
} from "@/lib/paginationPageSize";
|
||||
import {
|
||||
filterColumnVisibilityOptions,
|
||||
invertedHiddenColumnIndexes,
|
||||
nextHiddenColumnIndexes,
|
||||
visibleColumnIndexesForFilter,
|
||||
} from "@/lib/dataGridColumnVisibility";
|
||||
|
|
@ -1076,6 +1077,9 @@ function toggleColumnVisibility(columnIndex: number) {
|
|||
function showAllColumns() {
|
||||
hiddenColumnIndexes.value = new Set();
|
||||
}
|
||||
function invertColumnVisibility() {
|
||||
hiddenColumnIndexes.value = invertedHiddenColumnIndexes(displayableColumnIndexes.value, hiddenColumnIndexes.value);
|
||||
}
|
||||
const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0);
|
||||
function actualColumnIndex(visibleColumnIndex: number): number {
|
||||
return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex;
|
||||
|
|
@ -1716,6 +1720,21 @@ const activeCellDetail = computed(() => {
|
|||
|
||||
const detailEditValue = ref("");
|
||||
const isEditingDetail = ref(false);
|
||||
const detailTemporalEditorKind = computed(() => {
|
||||
const detail = activeCellDetail.value;
|
||||
return detail ? temporalEditorKindForColumn(detail.colIndex) : undefined;
|
||||
});
|
||||
|
||||
function resetDetailEdit() {
|
||||
isEditingDetail.value = false;
|
||||
detailEditValue.value = "";
|
||||
}
|
||||
|
||||
function closeCellDetails() {
|
||||
resetDetailEdit();
|
||||
showCellDetail.value = false;
|
||||
detailCell.value = null;
|
||||
}
|
||||
|
||||
function startDetailEdit() {
|
||||
const detail = activeCellDetail.value;
|
||||
|
|
@ -1759,7 +1778,7 @@ function commitDetailEdit() {
|
|||
}
|
||||
|
||||
function cancelDetailEdit() {
|
||||
isEditingDetail.value = false;
|
||||
resetDetailEdit();
|
||||
}
|
||||
|
||||
function setDetailNull() {
|
||||
|
|
@ -1772,7 +1791,7 @@ function setDetailNull() {
|
|||
if (item.isNew && item.newIndex !== undefined) {
|
||||
newRows.value[item.newIndex][detail.colIndex] = null;
|
||||
newRows.value = [...newRows.value];
|
||||
isEditingDetail.value = false;
|
||||
resetDetailEdit();
|
||||
detailCell.value = { ...detailCell.value! };
|
||||
return;
|
||||
}
|
||||
|
|
@ -1785,7 +1804,7 @@ function setDetailNull() {
|
|||
if (useTransaction.value && !transactionActive.value) {
|
||||
enterTransaction();
|
||||
}
|
||||
isEditingDetail.value = false;
|
||||
resetDetailEdit();
|
||||
detailCell.value = { ...detailCell.value! };
|
||||
}
|
||||
|
||||
|
|
@ -2002,6 +2021,7 @@ const {
|
|||
|
||||
// --- Cell selection and detail ---
|
||||
function showCellDetails(rowIndex: number, colIndex: number) {
|
||||
resetDetailEdit();
|
||||
detailCell.value = { rowIndex, col: colIndex };
|
||||
showCellDetail.value = true;
|
||||
}
|
||||
|
|
@ -2059,6 +2079,48 @@ function cutSelection() {
|
|||
}
|
||||
}
|
||||
|
||||
function currentSelectedCellPosition() {
|
||||
const range = selectedRange.value;
|
||||
if (!range) return null;
|
||||
return { rowIndex: range.startRow, colIndex: range.startCol };
|
||||
}
|
||||
|
||||
function scrollCellIntoView(rowIndex: number, colIndex: number) {
|
||||
nextTick(() => {
|
||||
const rowEl = gridRef.value?.querySelector<HTMLElement>(`[data-row-index="${rowIndex}"]`);
|
||||
const cellEl = rowEl?.querySelector<HTMLElement>(`[data-visible-col-index="${colIndex}"]`);
|
||||
(cellEl ?? rowEl)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
function moveSelectedCell(rowDelta: number, colDelta: number): boolean {
|
||||
const position = currentSelectedCellPosition();
|
||||
if (!position || editingCell.value || displayItems.value.length === 0 || visibleColumnIndexes.value.length === 0)
|
||||
return false;
|
||||
const rowIndex = Math.max(0, Math.min(displayItems.value.length - 1, position.rowIndex + rowDelta));
|
||||
const colIndex = Math.max(0, Math.min(visibleColumnIndexes.value.length - 1, position.colIndex + colDelta));
|
||||
selectSingleCell(rowIndex, colIndex);
|
||||
clearRowSelection();
|
||||
if (showTranspose.value) transposeRowIndex.value = rowIndex;
|
||||
scrollCellIntoView(rowIndex, colIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
function editSelectedCell(): boolean {
|
||||
const position = currentSelectedCellPosition();
|
||||
if (!position || editingCell.value) return false;
|
||||
const item = displayItems.value[position.rowIndex];
|
||||
const actualColIndex = actualColumnIndex(position.colIndex);
|
||||
if (!item || !canEditCellItem(item, actualColIndex)) return false;
|
||||
startEdit(item.id, actualColIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
function commitGridEdit() {
|
||||
commitEdit();
|
||||
nextTick(() => gridRef.value?.focus({ preventScroll: true }));
|
||||
}
|
||||
|
||||
async function onGridKeydown(event: KeyboardEvent) {
|
||||
if (isFocusSearchShortcut(event)) {
|
||||
event.preventDefault();
|
||||
|
|
@ -2066,6 +2128,34 @@ async function onGridKeydown(event: KeyboardEvent) {
|
|||
return;
|
||||
}
|
||||
if (eventTargetAllowsNativeClipboard(event)) return;
|
||||
if (event.key === "ArrowLeft" && moveTransposeRecordSelection(-1)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight" && moveTransposeRecordSelection(1)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp" && moveSelectedCell(-1, 0)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" && moveSelectedCell(1, 0)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft" && moveSelectedCell(0, -1)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight" && moveSelectedCell(0, 1)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && editSelectedCell()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (clipboardShortcut(event, "c")) {
|
||||
if (!hasCellSelection.value) return;
|
||||
event.preventDefault();
|
||||
|
|
@ -2092,6 +2182,12 @@ function copyDetailValue() {
|
|||
copyText(text);
|
||||
}
|
||||
|
||||
function copyDetailFormattedJson() {
|
||||
const detail = activeCellDetail.value;
|
||||
if (!detail?.formattedJson) return;
|
||||
copyText(detail.formattedJson);
|
||||
}
|
||||
|
||||
function copyDetailColumnName() {
|
||||
if (!activeCellDetail.value) return;
|
||||
copyText(activeCellDetail.value.column);
|
||||
|
|
@ -2105,8 +2201,14 @@ function copyDetailSqlCondition() {
|
|||
copyText(condition);
|
||||
}
|
||||
|
||||
const TRANSPOSE_RECORD_WIDTH = 168;
|
||||
const transposePinnedWidth = computed(() => transposeFieldWidth(visibleColumns.value));
|
||||
const TRANSPOSE_RECORD_DEFAULT_WIDTH = 168;
|
||||
const TRANSPOSE_RECORD_MIN_WIDTH = 96;
|
||||
const TRANSPOSE_PINNED_MIN_WIDTH = 104;
|
||||
const transposeRecordWidth = ref(TRANSPOSE_RECORD_DEFAULT_WIDTH);
|
||||
const transposePinnedWidthOverride = ref<number | null>(null);
|
||||
const transposePinnedWidth = computed(
|
||||
() => transposePinnedWidthOverride.value ?? transposeFieldWidth(visibleColumns.value),
|
||||
);
|
||||
|
||||
const transposeRows = computed(() => {
|
||||
return buildTransposeRows({
|
||||
|
|
@ -2123,7 +2225,7 @@ const transposeRecordWindow = computed(() =>
|
|||
scrollLeft: transposeScrollLeft.value,
|
||||
viewportWidth: transposeViewportWidth.value,
|
||||
pinnedWidth: transposePinnedWidth.value,
|
||||
recordWidth: TRANSPOSE_RECORD_WIDTH,
|
||||
recordWidth: transposeRecordWidth.value,
|
||||
overscan: 2,
|
||||
}),
|
||||
);
|
||||
|
|
@ -2132,7 +2234,7 @@ const visibleTransposeRecordIndexes = computed(() => {
|
|||
return Array.from({ length: window.end - window.start }, (_, offset) => window.start + offset);
|
||||
});
|
||||
const transposeTotalWidth = computed(
|
||||
() => transposePinnedWidth.value + displayItems.value.length * TRANSPOSE_RECORD_WIDTH,
|
||||
() => transposePinnedWidth.value + displayItems.value.length * transposeRecordWidth.value,
|
||||
);
|
||||
|
||||
function transposeScrollElement(): HTMLElement | undefined {
|
||||
|
|
@ -2161,13 +2263,54 @@ function scrollTransposeRecordIntoView(rowIndex: number) {
|
|||
totalRecords: displayItems.value.length,
|
||||
viewportWidth: el.clientWidth,
|
||||
pinnedWidth: transposePinnedWidth.value,
|
||||
recordWidth: TRANSPOSE_RECORD_WIDTH,
|
||||
recordWidth: transposeRecordWidth.value,
|
||||
});
|
||||
updateTransposeViewport();
|
||||
});
|
||||
}
|
||||
|
||||
function onTransposePinnedResizeStart(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = transposePinnedWidth.value;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
transposePinnedWidthOverride.value = Math.max(TRANSPOSE_PINNED_MIN_WIDTH, startWidth + e.clientX - startX);
|
||||
updateTransposeViewport();
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function onTransposeRecordResizeStart(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = transposeRecordWidth.value;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
transposeRecordWidth.value = Math.max(TRANSPOSE_RECORD_MIN_WIDTH, startWidth + e.clientX - startX);
|
||||
updateTransposeViewport();
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function closeTranspose() {
|
||||
showTranspose.value = false;
|
||||
transposeRowIndex.value = null;
|
||||
}
|
||||
|
||||
function openContextTranspose() {
|
||||
if (showTranspose.value) {
|
||||
closeTranspose();
|
||||
return;
|
||||
}
|
||||
if (!contextCell.value) return;
|
||||
const next = nextContextTransposeState({
|
||||
showTranspose: showTranspose.value,
|
||||
|
|
@ -2180,7 +2323,7 @@ function openContextTranspose() {
|
|||
transposeRowIndex.value = next.transposeRowIndex;
|
||||
showTranspose.value = next.showTranspose;
|
||||
if (next.showTranspose) {
|
||||
showCellDetail.value = false;
|
||||
closeCellDetails();
|
||||
nextTick(updateTransposeViewport);
|
||||
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
|
||||
}
|
||||
|
|
@ -2191,19 +2334,29 @@ function toggleTranspose(rowIndex: number) {
|
|||
transposeRowIndex.value = next.transposeRowIndex;
|
||||
showTranspose.value = next.showTranspose;
|
||||
if (next.showTranspose) {
|
||||
showCellDetail.value = false;
|
||||
closeCellDetails();
|
||||
nextTick(updateTransposeViewport);
|
||||
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function selectTransposeRecord(rowIndex: number) {
|
||||
if (rowIndex < 0 || rowIndex >= displayItems.value.length) return;
|
||||
transposeRowIndex.value = rowIndex;
|
||||
gridRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function moveTransposeRecordSelection(delta: number): boolean {
|
||||
if (!isTransposeMode.value || displayItems.value.length === 0) return false;
|
||||
const current = transposeRowIndex.value ?? 0;
|
||||
const next = Math.max(0, Math.min(displayItems.value.length - 1, current + delta));
|
||||
transposeRowIndex.value = next;
|
||||
scrollTransposeRecordIntoView(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
function transposeNav(delta: number) {
|
||||
if (transposeRowIndex.value === null) return;
|
||||
const next = transposeRowIndex.value + delta;
|
||||
if (next >= 0 && next < displayItems.value.length) {
|
||||
transposeRowIndex.value = next;
|
||||
scrollTransposeRecordIntoView(next);
|
||||
}
|
||||
moveTransposeRecordSelection(delta);
|
||||
}
|
||||
|
||||
watch(isTransposeMode, (active) => {
|
||||
|
|
@ -2219,10 +2372,8 @@ watch(
|
|||
}
|
||||
clearCellSelection();
|
||||
clearRowSelection();
|
||||
showCellDetail.value = false;
|
||||
detailCell.value = null;
|
||||
showTranspose.value = false;
|
||||
transposeRowIndex.value = null;
|
||||
closeCellDetails();
|
||||
closeTranspose();
|
||||
exitTransaction();
|
||||
},
|
||||
);
|
||||
|
|
@ -2546,6 +2697,7 @@ defineExpose({
|
|||
isColumnVisible,
|
||||
toggleColumnVisibility,
|
||||
showAllColumns,
|
||||
invertColumnVisibility,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -2885,7 +3037,7 @@ defineExpose({
|
|||
>
|
||||
<ChevronRight class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showTranspose = false">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeTranspose">
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -2895,7 +3047,7 @@ defineExpose({
|
|||
:style="{
|
||||
'--transpose-total-w': `${transposeTotalWidth}px`,
|
||||
'--transpose-field-w': `${transposePinnedWidth}px`,
|
||||
'--transpose-record-w': `${TRANSPOSE_RECORD_WIDTH}px`,
|
||||
'--transpose-record-w': `${transposeRecordWidth}px`,
|
||||
}"
|
||||
:items="transposeRows"
|
||||
:item-size="30"
|
||||
|
|
@ -2909,24 +3061,33 @@ defineExpose({
|
|||
:style="{ width: `${transposeTotalWidth}px` }"
|
||||
>
|
||||
<div
|
||||
class="sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 bg-[rgb(239_239_239)] truncate dark:bg-muted"
|
||||
class="sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 bg-[rgb(239_239_239)] truncate dark:bg-muted relative"
|
||||
:style="{ width: `${transposePinnedWidth}px` }"
|
||||
>
|
||||
{{ t("grid.columnName") }}
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
|
||||
@mousedown.stop="onTransposePinnedResizeStart"
|
||||
/>
|
||||
</div>
|
||||
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.beforeWidth}px` }" />
|
||||
<div
|
||||
v-for="recordIndex in visibleTransposeRecordIndexes"
|
||||
:key="`transpose-head-${recordIndex}`"
|
||||
class="shrink-0 border-r border-border px-2 py-1.5 text-center tabular-nums"
|
||||
class="shrink-0 border-r border-border px-2 py-1.5 text-center tabular-nums relative"
|
||||
:class="
|
||||
recordIndex === transposeRowIndex
|
||||
? 'bg-primary/15 text-primary font-semibold'
|
||||
: 'bg-[rgb(239_239_239)] dark:bg-muted'
|
||||
"
|
||||
:style="{ width: `${TRANSPOSE_RECORD_WIDTH}px` }"
|
||||
:style="{ width: `${transposeRecordWidth}px` }"
|
||||
@click="selectTransposeRecord(recordIndex)"
|
||||
>
|
||||
{{ recordIndex + 1 }}
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
|
||||
@mousedown.stop="onTransposeRecordResizeStart"
|
||||
/>
|
||||
</div>
|
||||
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.afterWidth}px` }" />
|
||||
</div>
|
||||
|
|
@ -2952,8 +3113,9 @@ defineExpose({
|
|||
'text-muted-foreground italic': item.values[recordIndex]?.isNull,
|
||||
'bg-primary/10': recordIndex === transposeRowIndex,
|
||||
}"
|
||||
:style="{ width: `${TRANSPOSE_RECORD_WIDTH}px` }"
|
||||
:style="{ width: `${transposeRecordWidth}px` }"
|
||||
:title="item.values[recordIndex]?.display"
|
||||
@click="selectTransposeRecord(recordIndex)"
|
||||
>
|
||||
{{ item.values[recordIndex]?.display }}
|
||||
</div>
|
||||
|
|
@ -3397,16 +3559,27 @@ defineExpose({
|
|||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
v-if="columnTypeMap.get(col) || columnCommentMap.get(col)"
|
||||
side="bottom"
|
||||
class="text-xs grid grid-cols-[auto_1fr] gap-x-2"
|
||||
class="grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs"
|
||||
>
|
||||
<span class="text-background/70">{{ t("grid.columnName") }}</span>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ col }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10"
|
||||
:title="t('grid.copyColumnName')"
|
||||
@click.stop="copyText(col)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<template v-if="columnTypeMap.get(col)">
|
||||
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
|
||||
<span class="text-background/70">{{ t("grid.columnType") }}</span>
|
||||
<span :class="typeColorClass(columnTypeMap.get(col)!)">{{ columnTypeMap.get(col) }}</span>
|
||||
</template>
|
||||
<template v-if="columnCommentMap.get(col)">
|
||||
<span class="text-muted-foreground">{{ t("grid.columnComment") }}</span>
|
||||
<span class="text-background/70">{{ t("grid.columnComment") }}</span>
|
||||
<span>{{ columnCommentMap.get(col) }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
|
|
@ -3509,6 +3682,7 @@ defineExpose({
|
|||
@mousedown="handleDataCellMousedown(index, visibleColIdx, item.id, $event)"
|
||||
@mouseenter="extendCellSelection(index, visibleColIdx)"
|
||||
@dblclick="canEditCellItem(item, actualColIdx) && startEdit(item.id, actualColIdx)"
|
||||
:data-visible-col-index="visibleColIdx"
|
||||
@contextmenu="onCellContext(item.id, index, actualColIdx, visibleColIdx)"
|
||||
>
|
||||
<template v-if="editingCell?.rowId === item.id && editingCell?.col === actualColIdx">
|
||||
|
|
@ -3517,7 +3691,7 @@ defineExpose({
|
|||
v-model="editValue"
|
||||
:kind="temporalEditorKindForColumn(actualColIdx)!"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
|
|
@ -3721,7 +3895,7 @@ defineExpose({
|
|||
<div class="h-9 flex items-center gap-2 px-3 border-b shrink-0 bg-muted/20">
|
||||
<Info class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showCellDetail = false">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeCellDetails">
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -3780,9 +3954,20 @@ defineExpose({
|
|||
</a>
|
||||
</div>
|
||||
<template v-if="isEditingDetail">
|
||||
<textarea
|
||||
<TemporalCellEditor
|
||||
v-if="detailTemporalEditorKind"
|
||||
v-model="detailEditValue"
|
||||
class="w-full h-40 rounded border bg-background p-2 font-mono text-xs outline-none resize-y focus:border-primary"
|
||||
:kind="detailTemporalEditorKind"
|
||||
variant="inline"
|
||||
:commit-on-close="false"
|
||||
@cancel="cancelDetailEdit"
|
||||
@commit="commitDetailEdit"
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
v-model="detailEditValue"
|
||||
wrap="off"
|
||||
class="w-full h-40 overflow-auto rounded border bg-background p-2 font-mono text-xs outline-none resize-y focus:border-primary"
|
||||
@keydown.escape.stop="cancelDetailEdit"
|
||||
/>
|
||||
<div class="flex gap-1 mt-1">
|
||||
|
|
@ -3796,7 +3981,7 @@ defineExpose({
|
|||
</template>
|
||||
<pre
|
||||
v-else
|
||||
class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words cursor-pointer hover:border-primary/50"
|
||||
class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre cursor-pointer hover:border-primary/50"
|
||||
:class="{ 'cursor-text': activeCellDetail.isEditable }"
|
||||
@dblclick="startDetailEdit"
|
||||
>{{ activeCellDetail.displayValue }}</pre
|
||||
|
|
@ -3809,12 +3994,22 @@ defineExpose({
|
|||
>{{ activeCellDetail.rawValue }}</pre
|
||||
>
|
||||
</div>
|
||||
<div v-if="activeCellDetail.formattedJson" class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
<div v-if="activeCellDetail.formattedJson" class="mt-2 space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
:title="t('grid.copyValue')"
|
||||
@click="copyDetailFormattedJson"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-72 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
>
|
||||
{{ activeCellDetail.formattedJson }}</pre
|
||||
>{{ activeCellDetail.formattedJson }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { FocusOutsideEvent, PointerDownOutsideEvent } from "reka-ui";
|
||||
import { CalendarClock, ChevronDown, ChevronUp, CircleSlash } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { formatTemporalInputValue, type TemporalCellEditorKind } from "@/lib/dataGridTemporalEditor";
|
||||
|
||||
const props = defineProps<{
|
||||
kind: TemporalCellEditorKind;
|
||||
modelValue: string;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: TemporalCellEditorKind;
|
||||
modelValue: string;
|
||||
variant?: "cell" | "inline";
|
||||
commitOnClose?: boolean;
|
||||
}>(),
|
||||
{
|
||||
variant: "cell",
|
||||
commitOnClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
|
|
@ -19,7 +25,6 @@ const emit = defineEmits<{
|
|||
cancel: [];
|
||||
}>();
|
||||
|
||||
const { locale } = useI18n();
|
||||
const open = ref(true);
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null);
|
||||
let closeHandled = false;
|
||||
|
|
@ -27,14 +32,11 @@ let closeHandled = false;
|
|||
const hasDate = computed(() => props.kind !== "time");
|
||||
const hasTime = computed(() => props.kind !== "date");
|
||||
const displayValue = computed(() => props.modelValue || "NULL");
|
||||
const monthOptions = computed(() => {
|
||||
const formatter = new Intl.DateTimeFormat(locale.value, { month: "short" });
|
||||
return Array.from({ length: 12 }, (_, index) => ({
|
||||
value: index + 1,
|
||||
label: formatter.format(new Date(2026, index, 1)),
|
||||
}));
|
||||
});
|
||||
|
||||
const triggerClass = computed(() =>
|
||||
props.variant === "inline"
|
||||
? "cell-edit-input flex h-9 w-full items-center gap-2 rounded border bg-background px-2 text-left text-xs outline-none hover:border-primary/60 focus:border-primary"
|
||||
: "cell-edit-input absolute inset-0 z-10 flex items-center gap-1 border-2 border-primary bg-background px-2 py-0.5 text-left text-xs outline-none",
|
||||
);
|
||||
const dateParts = computed(() => {
|
||||
const text = formatTemporalInputValue(props.modelValue, "date");
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
||||
|
|
@ -61,7 +63,7 @@ onMounted(() => {
|
|||
|
||||
function setOpen(value: boolean) {
|
||||
open.value = value;
|
||||
if (!value && !closeHandled) finishCommit();
|
||||
if (!value && props.commitOnClose && !closeHandled) finishCommit();
|
||||
}
|
||||
|
||||
function setModelValue(value: string) {
|
||||
|
|
@ -69,18 +71,22 @@ function setModelValue(value: string) {
|
|||
}
|
||||
|
||||
function updateDate(part: "day" | "month" | "year", rawValue: string | number) {
|
||||
const next = { ...dateParts.value, [part]: Number(rawValue) || dateParts.value[part] };
|
||||
const numberValue = Number(rawValue);
|
||||
const next = { ...dateParts.value };
|
||||
if (Number.isNaN(numberValue)) return;
|
||||
if (part === "year") next.year = Math.max(1, Math.min(9999, numberValue));
|
||||
else if (part === "month") next.month = Math.max(1, Math.min(12, numberValue));
|
||||
else next.day = Math.max(1, Math.min(31, numberValue));
|
||||
const maxDay = daysInMonth(next.year, next.month);
|
||||
next.day = Math.max(1, Math.min(maxDay, next.day));
|
||||
setDateTimeValue(next.year, next.month, next.day, timeValue.value);
|
||||
}
|
||||
|
||||
function updateDateFromSelect(part: "day" | "month", rawValue: unknown) {
|
||||
if (rawValue === null || rawValue === undefined) return;
|
||||
updateDate(part, String(rawValue));
|
||||
function updateDateFromInput(part: "day" | "month" | "year", event: Event) {
|
||||
updateDate(part, (event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function stepDate(part: "year", delta: number) {
|
||||
function stepDate(part: "day" | "month" | "year", delta: number) {
|
||||
updateDate(part, dateParts.value[part] + delta);
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +100,10 @@ function updateTime(part: "hour" | "minute" | "second", rawValue: string | numbe
|
|||
setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime);
|
||||
}
|
||||
|
||||
function updateTimeFromInput(part: "hour" | "minute" | "second", event: Event) {
|
||||
updateTime(part, (event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function stepTime(part: "hour" | "minute" | "second", delta: number) {
|
||||
const max = part === "hour" ? 23 : 59;
|
||||
const current = Number(timeParts.value[part]) || 0;
|
||||
|
|
@ -154,7 +164,8 @@ function isSelectInteractionTarget(target: EventTarget | null): boolean {
|
|||
}
|
||||
|
||||
function normalizeTimePart(value: string | number, max: number): string {
|
||||
const numberValue = Math.max(0, Math.min(max, Number(value) || 0));
|
||||
const parsed = Number(value);
|
||||
const numberValue = Math.max(0, Math.min(max, Number.isNaN(parsed) ? 0 : parsed));
|
||||
return String(numberValue).padStart(2, "0");
|
||||
}
|
||||
|
||||
|
|
@ -178,13 +189,7 @@ function twoDigit(value: string | number): string {
|
|||
<template>
|
||||
<Popover :open="open" @update:open="setOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
class="cell-edit-input absolute inset-0 z-10 flex items-center gap-1 border-2 border-primary bg-background px-2 py-0.5 text-left text-xs outline-none"
|
||||
@keydown.stop="onKeydown"
|
||||
@click.stop
|
||||
>
|
||||
<button ref="triggerRef" type="button" :class="triggerClass" @keydown.stop="onKeydown" @click.stop>
|
||||
<CalendarClock class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ displayValue }}</span>
|
||||
</button>
|
||||
|
|
@ -197,47 +202,16 @@ function twoDigit(value: string | number): string {
|
|||
@keydown.stop="onKeydown"
|
||||
@interact-outside="onPopoverInteractOutside"
|
||||
>
|
||||
<div v-if="hasDate" class="grid grid-cols-[3.5rem_4.75rem_5rem] gap-1.5">
|
||||
<Select
|
||||
:model-value="String(dateParts.day)"
|
||||
@update:model-value="(value) => updateDateFromSelect('day', value)"
|
||||
<div v-if="hasDate" class="grid grid-cols-[4.5rem_4.5rem_4.5rem] gap-1.5">
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<SelectTrigger class="h-7 w-full rounded-md px-2 text-[13px] tabular-nums">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="min-w-16">
|
||||
<SelectItem
|
||||
v-for="day in daysInMonth(dateParts.year, dateParts.month)"
|
||||
:key="day"
|
||||
:value="String(day)"
|
||||
class="py-0.5 text-xs"
|
||||
>
|
||||
{{ day }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
:model-value="String(dateParts.month)"
|
||||
@update:model-value="(value) => updateDateFromSelect('month', value)"
|
||||
>
|
||||
<SelectTrigger class="h-7 w-full rounded-md px-2 text-[13px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="min-w-20">
|
||||
<SelectItem
|
||||
v-for="month in monthOptions"
|
||||
:key="month.value"
|
||||
:value="String(month.value)"
|
||||
class="py-0.5 text-xs"
|
||||
>
|
||||
{{ month.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">{{ dateParts.year }}</div>
|
||||
<input
|
||||
:value="dateParts.year"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('year', $event)"
|
||||
/>
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('year', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
|
|
@ -251,13 +225,64 @@ function twoDigit(value: string | number): string {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(dateParts.month)"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('month', $event)"
|
||||
/>
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('month', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepDate('month', -1)"
|
||||
>
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(dateParts.day)"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('day', $event)"
|
||||
/>
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('day', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepDate('day', -1)"
|
||||
>
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasTime" class="grid grid-cols-[3.5rem_0.5rem_3.5rem_0.5rem_3.5rem] items-center gap-1.5">
|
||||
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">
|
||||
{{ twoDigit(timeParts.hour) }}
|
||||
</div>
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.hour)"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('hour', $event)"
|
||||
/>
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('hour', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
|
|
@ -272,10 +297,15 @@ function twoDigit(value: string | number): string {
|
|||
</div>
|
||||
</div>
|
||||
<span class="text-center text-xs text-muted-foreground">:</span>
|
||||
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">
|
||||
{{ twoDigit(timeParts.minute) }}
|
||||
</div>
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.minute)"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('minute', $event)"
|
||||
/>
|
||||
<div class="grid border-l">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -294,10 +324,15 @@ function twoDigit(value: string | number): string {
|
|||
</div>
|
||||
</div>
|
||||
<span class="text-center text-xs text-muted-foreground">:</span>
|
||||
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">
|
||||
{{ twoDigit(timeParts.second) }}
|
||||
</div>
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.second)"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('second', $event)"
|
||||
/>
|
||||
<div class="grid border-l">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type DataGridHandle = {
|
|||
isColumnVisible: (columnIndex: number) => boolean;
|
||||
toggleColumnVisibility: (columnIndex: number) => void;
|
||||
showAllColumns: () => void;
|
||||
invertColumnVisibility: () => void;
|
||||
showDdl: boolean;
|
||||
toggleDdl: () => void;
|
||||
};
|
||||
|
|
@ -496,15 +497,26 @@ defineExpose({ focusSearch, refreshData });
|
|||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
|
||||
@click="dataGridRef?.showAllColumns()"
|
||||
>
|
||||
{{ t("grid.showAllColumns") }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1"
|
||||
@click="dataGridRef?.invertColumnVisibility()"
|
||||
>
|
||||
{{ t("grid.invertColumnVisibility") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
|
||||
@click="dataGridRef?.showAllColumns()"
|
||||
>
|
||||
{{ t("grid.showAllColumns") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
nextTick(focusScrollerWithoutScrolling);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ export default {
|
|||
columnVisibility: "Columns",
|
||||
columnVisibilityHint: "At least one column stays visible.",
|
||||
searchColumns: "Search columns...",
|
||||
invertColumnVisibility: "Invert",
|
||||
showAllColumns: "Show all",
|
||||
moreValues: "{count} more values, keep typing to narrow results",
|
||||
filterByValue: "Filter by This Value",
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export default {
|
|||
columnVisibility: "Columnas",
|
||||
columnVisibilityHint: "Al menos una columna permanece visible.",
|
||||
searchColumns: "Buscar columnas...",
|
||||
invertColumnVisibility: "Invertir",
|
||||
showAllColumns: "Mostrar todo",
|
||||
moreValues: "{count} valores más, sigue escribiendo para acotar los resultados",
|
||||
filterByValue: "Filtrar por este valor",
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ export default {
|
|||
columnVisibility: "字段筛选",
|
||||
columnVisibilityHint: "至少保留一列可见。",
|
||||
searchColumns: "搜索字段...",
|
||||
invertColumnVisibility: "反选",
|
||||
showAllColumns: "显示全部",
|
||||
moreValues: "还有 {count} 个值,输入关键词继续缩小范围",
|
||||
filterByValue: "筛选此值",
|
||||
|
|
|
|||
|
|
@ -33,3 +33,14 @@ export function nextHiddenColumnIndexes(options: {
|
|||
next.add(options.columnIndex);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function invertedHiddenColumnIndexes(
|
||||
availableIndexes: number[],
|
||||
hiddenIndexes: ReadonlySet<number>,
|
||||
): Set<number> {
|
||||
const next = new Set(availableIndexes.filter((index) => !hiddenIndexes.has(index)));
|
||||
if (next.size === availableIndexes.length && availableIndexes.length > 0) {
|
||||
next.delete(availableIndexes[0]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { strict as assert } from "node:assert";
|
|||
import test from "node:test";
|
||||
import {
|
||||
filterColumnVisibilityOptions,
|
||||
invertedHiddenColumnIndexes,
|
||||
nextHiddenColumnIndexes,
|
||||
visibleColumnIndexesForFilter,
|
||||
} from "../../apps/desktop/src/lib/dataGridColumnVisibility.ts";
|
||||
|
|
@ -37,3 +38,15 @@ test("shows a hidden column again when toggled", () => {
|
|||
|
||||
assert.deepEqual([...hidden].sort(), [2]);
|
||||
});
|
||||
|
||||
test("inverts hidden column indexes", () => {
|
||||
const hidden = invertedHiddenColumnIndexes([0, 1, 2, 3], new Set([1, 3]));
|
||||
|
||||
assert.deepEqual([...hidden].sort(), [0, 2]);
|
||||
});
|
||||
|
||||
test("keeps one column visible when inverting all visible columns", () => {
|
||||
const hidden = invertedHiddenColumnIndexes([0, 1, 2], new Set());
|
||||
|
||||
assert.deepEqual([...hidden].sort(), [1, 2]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue