mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(grid): improve production watermark and sql formatting
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { locale } = useI18n();
|
||||
const watermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
|
||||
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ watermarkText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.production-watermark {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 3rem;
|
||||
overflow: hidden;
|
||||
padding: 3rem 2.5rem;
|
||||
}
|
||||
|
||||
.production-watermark__label {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
transform: rotate(-22deg);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.production-watermark {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -66,6 +66,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Input } from "@/components/ui/input";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import DataGridBusyOverlay from "@/components/grid/DataGridBusyOverlay.vue";
|
||||
import ProductionWatermark from "@/components/common/ProductionWatermark.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import LightDropdownMenu from "@/components/ui/LightDropdownMenu.vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
@@ -249,6 +250,7 @@ import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarSta
|
||||
import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh";
|
||||
import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
|
||||
import { sqlWithoutCommentsForCopy } from "@/lib/sql/sqlWithoutCommentsForCopy";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import {
|
||||
@@ -755,6 +757,7 @@ const columnTypeMap = computed(() => {
|
||||
});
|
||||
const resolvedConnectionConfig = computed(() => connectionStore.getConfig(props.connectionId ?? ""));
|
||||
const resolvedDatabaseType = computed(() => props.databaseType ?? effectiveDatabaseTypeForConnection(resolvedConnectionConfig.value));
|
||||
const productionContext = computed(() => productionContextForDatabase(resolvedConnectionConfig.value, props.database));
|
||||
// The collection grid and Mongo query-result grids share the document-grid value
|
||||
// encoding (BSON null sentinel + JSON-prefixed containers), so every display,
|
||||
// editor and clipboard path must decode it whenever MongoDB values are on screen.
|
||||
@@ -12757,294 +12760,301 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<RecycleScroller
|
||||
ref="transposeScrollRef"
|
||||
class="transpose-grid-scroller flex-1 min-h-0 overflow-auto overscroll-none bg-background"
|
||||
:class="{ 'is-scrolling': isScrolling }"
|
||||
:style="{
|
||||
'--transpose-total-w': `${transposeTotalWidth}px`,
|
||||
'--transpose-field-w': `${transposePinnedWidth}px`,
|
||||
}"
|
||||
:items="transposeRows"
|
||||
:item-size="transposeRowHeight"
|
||||
:buffer="400"
|
||||
key-field="id"
|
||||
@scroll="onTransposeScroll"
|
||||
@resize="updateTransposeViewport"
|
||||
>
|
||||
<template #before>
|
||||
<div class="data-grid-transpose-header data-grid-header-shell sticky top-0 z-20 flex h-7 border-b border-border font-semibold text-muted-foreground" :style="{ width: `${transposeTotalWidth}px` }">
|
||||
<div class="data-grid-header-cell sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 truncate 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: `${transposeBeforeSpacerWidth}px` }" />
|
||||
<div
|
||||
v-for="recordIndex in activeTransposeRecordIndexes"
|
||||
:key="`transpose-head-${recordIndex}`"
|
||||
data-grid-transpose-record-header
|
||||
:data-grid-transpose-record-index="recordIndex"
|
||||
class="shrink-0 border-r border-border px-2 py-1.5 text-left tabular-nums relative"
|
||||
:class="{
|
||||
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
|
||||
'transpose-record-header-active text-primary': transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
|
||||
'data-grid-header-cell': !transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
|
||||
'crosshair-column': !!crosshairTarget?.rowCrosshair && crosshairTarget.rowIndex === recordIndex && !transposeRecordUsesFramedHeader(recordIndex) && !transposeRecordUsesActiveHighlight(recordIndex),
|
||||
}"
|
||||
:style="{
|
||||
width: `${getTransposeRecordWidth(recordIndex)}px`,
|
||||
}"
|
||||
@click="selectTransposeRecord(recordIndex, $event)"
|
||||
@contextmenu="selectTransposeRecord(recordIndex, $event)"
|
||||
>
|
||||
{{ rowNumberText(displayItems[recordIndex]) }}
|
||||
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onTransposeRecordResizeStart(recordIndex, $event)" @dblclick.stop="autoFitTransposeRecord(recordIndex)" />
|
||||
</div>
|
||||
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ item, index }">
|
||||
<div
|
||||
class="data-grid-transpose-row flex border-b border-border/60"
|
||||
:style="{
|
||||
height: `${transposeRowHeight}px`,
|
||||
width: `${transposeTotalWidth}px`,
|
||||
}"
|
||||
>
|
||||
<LightTooltip :text="transposeFieldTitle(item)" side="right" :side-offset="6" :delay="250" :open-on-focus="false" surface="popover">
|
||||
<div
|
||||
data-native-clipboard
|
||||
:data-grid-transpose-column-index="visibleColumnIndexes[index]"
|
||||
class="sticky left-0 z-10 flex shrink-0 flex-col items-start justify-center overflow-hidden border-r border-border bg-background px-3 py-0"
|
||||
:class="{
|
||||
'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes[index],
|
||||
'bg-yellow-200/60 dark:bg-yellow-500/20': transposeHeaderIsSearchMatch(visibleColumnIndexes[index]),
|
||||
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': transposeHeaderIsCurrentMatch(visibleColumnIndexes[index]),
|
||||
}"
|
||||
:style="{ width: `${transposePinnedWidth}px` }"
|
||||
>
|
||||
<span class="flex w-full min-w-0 items-center gap-1 overflow-hidden pr-5">
|
||||
<KeyRound v-if="transposeColumnIndexKind(item.column) === 'primary'" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass('primary')" :title="transposeColumnIndexText('primary')" />
|
||||
<Hash v-else-if="transposeColumnIndexKind(item.column)" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass(transposeColumnIndexKind(item.column)!)" :title="transposeColumnIndexText(transposeColumnIndexKind(item.column)!)" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium leading-4">{{ item.column }}</span>
|
||||
</span>
|
||||
<LightDropdownMenu
|
||||
v-if="headerColumnSortable(visibleColumnIndexes[index])"
|
||||
:items="sortMenuItems(item.column, visibleColumnIndexes[index])"
|
||||
:open="headerSortMenuOpenColumn === visibleColumnIndexes[index]"
|
||||
:selected-value="selectedSortMenuValue(item.column, visibleColumnIndexes[index])"
|
||||
check-position="none"
|
||||
align="end"
|
||||
content-class="w-max min-w-28 p-0.5"
|
||||
item-class="gap-1 rounded-none px-1.5 py-0.5 text-xs"
|
||||
item-icon-class="h-3 w-3"
|
||||
:match-trigger-width="false"
|
||||
@update:open="(value: boolean) => (headerSortMenuOpenColumn = value ? visibleColumnIndexes[index] : null)"
|
||||
@select="(value: string) => selectHeaderSort(value, item.column, visibleColumnIndexes[index])"
|
||||
>
|
||||
<template #trigger="{ open, toggle }">
|
||||
<button
|
||||
data-grid-transpose-sort
|
||||
type="button"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 shrink-0 items-center justify-center rounded"
|
||||
:class="columnIsSorted(item.column, visibleColumnIndexes[index]) ? 'bg-primary text-primary-foreground opacity-100 shadow-sm hover:bg-primary/90' : 'text-muted-foreground opacity-80 hover:bg-accent hover:text-foreground'"
|
||||
:title="t('grid.sort')"
|
||||
:aria-label="`${t('grid.sort')}: ${item.column}`"
|
||||
:aria-expanded="open"
|
||||
@mousedown.stop
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<ArrowUp v-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowDown v-else-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
|
||||
<span data-grid-transpose-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 select-none" :class="typeColorClass(item.type)" :title="item.type">
|
||||
{{ item.type }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
|
||||
<span data-grid-transpose-comment-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground select-none" :title="item.comment">
|
||||
{{ item.comment }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ProductionWatermark v-if="productionContext.active" />
|
||||
<RecycleScroller
|
||||
ref="transposeScrollRef"
|
||||
class="transpose-grid-scroller h-full overflow-auto overscroll-none bg-background"
|
||||
:class="{ 'is-scrolling': isScrolling }"
|
||||
:style="{
|
||||
'--transpose-total-w': `${transposeTotalWidth}px`,
|
||||
'--transpose-field-w': `${transposePinnedWidth}px`,
|
||||
}"
|
||||
:items="transposeRows"
|
||||
:item-size="transposeRowHeight"
|
||||
:buffer="400"
|
||||
key-field="id"
|
||||
@scroll="onTransposeScroll"
|
||||
@resize="updateTransposeViewport"
|
||||
>
|
||||
<template #before>
|
||||
<div class="data-grid-transpose-header data-grid-header-shell sticky top-0 z-20 flex h-7 border-b border-border font-semibold text-muted-foreground" :style="{ width: `${transposeTotalWidth}px` }">
|
||||
<div class="data-grid-header-cell sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 truncate 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>
|
||||
<template #content>
|
||||
<div
|
||||
data-grid-transpose-field-tooltip
|
||||
class="grid max-h-[min(20rem,calc(100vh-1rem))] w-[min(24rem,calc(100vw-1rem))] grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1.5 overflow-auto rounded-md border border-border bg-popover px-3 py-2 text-left text-popover-foreground shadow-md"
|
||||
>
|
||||
<span class="text-muted-foreground">{{ t("grid.columnName") }}</span>
|
||||
<span class="min-w-0 break-all font-mono select-text">{{ item.column }}</span>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
|
||||
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
|
||||
<span class="min-w-0 break-all font-mono select-text" :class="typeColorClass(item.type)">{{ item.type }}</span>
|
||||
</template>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
|
||||
<span class="text-muted-foreground">{{ t("grid.columnComment") }}</span>
|
||||
<span class="min-w-0 whitespace-pre-wrap break-words select-text">{{ item.comment }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</LightTooltip>
|
||||
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
|
||||
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
|
||||
<div
|
||||
v-for="recordIndex in activeTransposeRecordIndexes"
|
||||
:key="`transpose-head-${recordIndex}`"
|
||||
data-grid-transpose-record-header
|
||||
:data-grid-transpose-record-index="recordIndex"
|
||||
class="shrink-0 border-r border-border px-2 py-1.5 text-left tabular-nums relative"
|
||||
:class="{
|
||||
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
|
||||
'transpose-record-header-active text-primary': transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
|
||||
'data-grid-header-cell': !transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
|
||||
'crosshair-column': !!crosshairTarget?.rowCrosshair && crosshairTarget.rowIndex === recordIndex && !transposeRecordUsesFramedHeader(recordIndex) && !transposeRecordUsesActiveHighlight(recordIndex),
|
||||
}"
|
||||
:style="{
|
||||
width: `${getTransposeRecordWidth(recordIndex)}px`,
|
||||
}"
|
||||
@click="selectTransposeRecord(recordIndex, $event)"
|
||||
@contextmenu="selectTransposeRecord(recordIndex, $event)"
|
||||
>
|
||||
{{ rowNumberText(displayItems[recordIndex]) }}
|
||||
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onTransposeRecordResizeStart(recordIndex, $event)" @dblclick.stop="autoFitTransposeRecord(recordIndex)" />
|
||||
</div>
|
||||
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ item, index }">
|
||||
<div
|
||||
v-for="cell in item.values"
|
||||
:key="`${item.id}:${cell.recordIndex}`"
|
||||
data-grid-transpose-cell
|
||||
:data-grid-transpose-record-index="cell.recordIndex"
|
||||
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0"
|
||||
:class="[
|
||||
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
|
||||
{
|
||||
'overflow-visible z-20 border-r-transparent': transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap': !transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
|
||||
'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],
|
||||
'cell-selected--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'crosshair-row':
|
||||
!!crosshairTarget?.columnCrosshair && cell.valueIndex === crosshairTarget.actualColIdx && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
||||
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
||||
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex),
|
||||
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(cell.recordIndex, cell.valueIndex),
|
||||
'cursor-text': !isScrolling,
|
||||
'hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800':
|
||||
!isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
||||
},
|
||||
]"
|
||||
class="data-grid-transpose-row flex border-b border-border/60"
|
||||
:style="{
|
||||
width: `${getTransposeRecordWidth(cell.recordIndex)}px`,
|
||||
height: `${transposeRowHeight}px`,
|
||||
width: `${transposeTotalWidth}px`,
|
||||
}"
|
||||
:title="cell.display"
|
||||
@mousedown="prepareTransposeCellMouseDown(cell.recordIndex, cell.valueIndex)"
|
||||
@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="onTransposeCellDblClick(cell.recordIndex, cell.valueIndex, cell.display, $event)"
|
||||
>
|
||||
<template v-if="readonlyTextCellMatches(displayItems[cell.recordIndex]?.id, cell.valueIndex)">
|
||||
<DataGridReadonlyTextSelection :value="readonlyTextCell!.value" :expanded="readonlyTextCell!.expanded" @close="closeReadonlyCellTextSelection" @escape="escapeReadonlyCellTextSelection" />
|
||||
</template>
|
||||
<template v-else-if="editingCell?.rowId === displayItems[cell.recordIndex]?.id && editingCell?.col === cell.valueIndex">
|
||||
<TemporalCellEditor
|
||||
v-if="temporalEditorConfigForColumn(cell.valueIndex)"
|
||||
v-model="editValue"
|
||||
:kind="temporalEditorConfigForColumn(cell.valueIndex)!.kind"
|
||||
:fraction-precision="temporalEditorConfigForColumn(cell.valueIndex)!.fractionPrecision"
|
||||
:normalize-value="(value) => normalizeTemporalCellEditorValue(value, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
@save="onTemporalCellEditorSave"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)"
|
||||
v-model="booleanEditorModelValue"
|
||||
:values="BOOLEAN_CELL_EDITOR_VALUES"
|
||||
:nullable="isBooleanGridColumnNullable(cell.valueIndex)"
|
||||
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitBooleanGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isEnumGridColumn(cell.valueIndex)"
|
||||
v-model="editValue"
|
||||
:values="enumValuesForGridColumn(cell.valueIndex)"
|
||||
:nullable="isEnumGridColumnNullable(cell.valueIndex)"
|
||||
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="cellUsesExpandedEditor(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
v-model="editValue"
|
||||
data-expanded-cell-editor="true"
|
||||
rows="1"
|
||||
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-1.5 py-1 leading-[18px] outline-none z-10"
|
||||
@blur="commitEditFromCellBlur"
|
||||
@click.stop
|
||||
@focus="onCellEditTextareaInput"
|
||||
@input="onCellEditTextareaInput"
|
||||
@keydown.stop="onCellEditKeydown"
|
||||
@paste.stop="onCellEditTextareaPaste"
|
||||
@wheel.stop
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="editValue"
|
||||
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-1.5 py-0 leading-[26px] outline-none z-10"
|
||||
@blur="commitEditFromCellBlur"
|
||||
@click.stop
|
||||
@input="onCellEditTextareaInput"
|
||||
@keydown.stop="onCellEditKeydown"
|
||||
@paste.stop="onCellEditTextareaPaste"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
|
||||
<span class="text-muted-foreground/70 italic">{{ firstLineCellDisplayValue(newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) ?? "", flatteningMultiLineEnabled) }}</span>
|
||||
</template>
|
||||
<template v-else>{{ gridCellDisplayValue(cell.display, flatteningMultiLineEnabled, showWhitespaceEnabled) }}</template>
|
||||
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<LightTooltip :text="transposeFieldTitle(item)" side="right" :side-offset="6" :delay="250" :open-on-focus="false" surface="popover">
|
||||
<div
|
||||
data-native-clipboard
|
||||
:data-grid-transpose-column-index="visibleColumnIndexes[index]"
|
||||
class="sticky left-0 z-10 flex shrink-0 flex-col items-start justify-center overflow-hidden border-r border-border bg-background px-3 py-0"
|
||||
:class="{
|
||||
'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes[index],
|
||||
'bg-yellow-200/60 dark:bg-yellow-500/20': transposeHeaderIsSearchMatch(visibleColumnIndexes[index]),
|
||||
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': transposeHeaderIsCurrentMatch(visibleColumnIndexes[index]),
|
||||
}"
|
||||
:style="{ width: `${transposePinnedWidth}px` }"
|
||||
>
|
||||
<span class="flex w-full min-w-0 items-center gap-1 overflow-hidden pr-5">
|
||||
<KeyRound v-if="transposeColumnIndexKind(item.column) === 'primary'" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass('primary')" :title="transposeColumnIndexText('primary')" />
|
||||
<Hash v-else-if="transposeColumnIndexKind(item.column)" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass(transposeColumnIndexKind(item.column)!)" :title="transposeColumnIndexText(transposeColumnIndexKind(item.column)!)" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium leading-4">{{ item.column }}</span>
|
||||
</span>
|
||||
<LightDropdownMenu
|
||||
v-if="canQuickDownloadCellValue(cell.recordIndex, cell.valueIndex)"
|
||||
:items="binaryCellDownloadMenuItems"
|
||||
:open="quickDownloadMenuOpenFor(cell.recordIndex, cell.valueIndex)"
|
||||
v-if="headerColumnSortable(visibleColumnIndexes[index])"
|
||||
:items="sortMenuItems(item.column, visibleColumnIndexes[index])"
|
||||
:open="headerSortMenuOpenColumn === visibleColumnIndexes[index]"
|
||||
:selected-value="selectedSortMenuValue(item.column, visibleColumnIndexes[index])"
|
||||
check-position="none"
|
||||
align="end"
|
||||
content-class="w-44"
|
||||
content-class="w-max min-w-28 p-0.5"
|
||||
item-class="gap-1 rounded-none px-1.5 py-0.5 text-xs"
|
||||
item-icon-class="h-3 w-3"
|
||||
:match-trigger-width="false"
|
||||
@update:open="(value: boolean) => handleQuickDownloadMenuOpenChange(value, cell.recordIndex, cell.valueIndex)"
|
||||
@select="(mode: string) => downloadCellBinaryValue(cell.recordIndex, cell.valueIndex, mode as BinaryCellDownloadMode)"
|
||||
@update:open="(value: boolean) => (headerSortMenuOpenColumn = value ? visibleColumnIndexes[index] : null)"
|
||||
@select="(value: string) => selectHeaderSort(value, item.column, visibleColumnIndexes[index])"
|
||||
>
|
||||
<template #trigger="{ open, toggle }">
|
||||
<button class="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.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
|
||||
<Download class="h-3 w-3" />
|
||||
<button
|
||||
data-grid-transpose-sort
|
||||
type="button"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 shrink-0 items-center justify-center rounded"
|
||||
:class="columnIsSorted(item.column, visibleColumnIndexes[index]) ? 'bg-primary text-primary-foreground opacity-100 shadow-sm hover:bg-primary/90' : 'text-muted-foreground opacity-80 hover:bg-accent hover:text-foreground'"
|
||||
:title="t('grid.sort')"
|
||||
:aria-label="`${t('grid.sort')}: ${item.column}`"
|
||||
:aria-expanded="open"
|
||||
@mousedown.stop
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<ArrowUp v-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowDown v-else-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
<button
|
||||
v-if="canOpenCellExternalUrl(cell.recordIndex, cell.valueIndex)"
|
||||
class="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.openUrl')"
|
||||
:aria-label="t('grid.openUrl')"
|
||||
@mousedown.stop
|
||||
@click.stop="openCellExternalUrl(cell.recordIndex, cell.valueIndex)"
|
||||
>
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
v-if="cellDetailButtonEnabled"
|
||||
class="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="showTransposeCellDetails(cell.recordIndex, cell.valueIndex)"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
|
||||
<span data-grid-transpose-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 select-none" :class="typeColorClass(item.type)" :title="item.type">
|
||||
{{ item.type }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
|
||||
<span data-grid-transpose-comment-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground select-none" :title="item.comment">
|
||||
{{ item.comment }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
data-grid-transpose-field-tooltip
|
||||
class="grid max-h-[min(20rem,calc(100vh-1rem))] w-[min(24rem,calc(100vw-1rem))] grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1.5 overflow-auto rounded-md border border-border bg-popover px-3 py-2 text-left text-popover-foreground shadow-md"
|
||||
>
|
||||
<span class="text-muted-foreground">{{ t("grid.columnName") }}</span>
|
||||
<span class="min-w-0 break-all font-mono select-text">{{ item.column }}</span>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
|
||||
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
|
||||
<span class="min-w-0 break-all font-mono select-text" :class="typeColorClass(item.type)">{{ item.type }}</span>
|
||||
</template>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnCommentsInHeader && item.comment">
|
||||
<span class="text-muted-foreground">{{ t("grid.columnComment") }}</span>
|
||||
<span class="min-w-0 whitespace-pre-wrap break-words select-text">{{ item.comment }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</LightTooltip>
|
||||
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
|
||||
<div
|
||||
v-for="cell in item.values"
|
||||
:key="`${item.id}:${cell.recordIndex}`"
|
||||
data-grid-transpose-cell
|
||||
:data-grid-transpose-record-index="cell.recordIndex"
|
||||
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0"
|
||||
:class="[
|
||||
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
|
||||
{
|
||||
'overflow-visible z-20 border-r-transparent': transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap': !transposeCellEditorActive(cell.recordIndex, cell.valueIndex),
|
||||
'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],
|
||||
'cell-selected--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'crosshair-row':
|
||||
!!crosshairTarget?.columnCrosshair &&
|
||||
cell.valueIndex === crosshairTarget.actualColIdx &&
|
||||
!transposeRecordUsesSelectionVisual(cell.recordIndex) &&
|
||||
!displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] &&
|
||||
!transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
||||
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
||||
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex),
|
||||
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(cell.recordIndex, cell.valueIndex),
|
||||
'cursor-text': !isScrolling,
|
||||
'hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800':
|
||||
!isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
||||
},
|
||||
]"
|
||||
:style="{
|
||||
width: `${getTransposeRecordWidth(cell.recordIndex)}px`,
|
||||
}"
|
||||
:title="cell.display"
|
||||
@mousedown="prepareTransposeCellMouseDown(cell.recordIndex, cell.valueIndex)"
|
||||
@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="onTransposeCellDblClick(cell.recordIndex, cell.valueIndex, cell.display, $event)"
|
||||
>
|
||||
<template v-if="readonlyTextCellMatches(displayItems[cell.recordIndex]?.id, cell.valueIndex)">
|
||||
<DataGridReadonlyTextSelection :value="readonlyTextCell!.value" :expanded="readonlyTextCell!.expanded" @close="closeReadonlyCellTextSelection" @escape="escapeReadonlyCellTextSelection" />
|
||||
</template>
|
||||
<template v-else-if="editingCell?.rowId === displayItems[cell.recordIndex]?.id && editingCell?.col === cell.valueIndex">
|
||||
<TemporalCellEditor
|
||||
v-if="temporalEditorConfigForColumn(cell.valueIndex)"
|
||||
v-model="editValue"
|
||||
:kind="temporalEditorConfigForColumn(cell.valueIndex)!.kind"
|
||||
:fraction-precision="temporalEditorConfigForColumn(cell.valueIndex)!.fractionPrecision"
|
||||
:normalize-value="(value) => normalizeTemporalCellEditorValue(value, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
@save="onTemporalCellEditorSave"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)"
|
||||
v-model="booleanEditorModelValue"
|
||||
:values="BOOLEAN_CELL_EDITOR_VALUES"
|
||||
:nullable="isBooleanGridColumnNullable(cell.valueIndex)"
|
||||
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitBooleanGridEdit"
|
||||
/>
|
||||
<EnumCellEditor
|
||||
v-else-if="isEnumGridColumn(cell.valueIndex)"
|
||||
v-model="editValue"
|
||||
:values="enumValuesForGridColumn(cell.valueIndex)"
|
||||
:nullable="isEnumGridColumnNullable(cell.valueIndex)"
|
||||
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="cellUsesExpandedEditor(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
|
||||
v-model="editValue"
|
||||
data-expanded-cell-editor="true"
|
||||
rows="1"
|
||||
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-1.5 py-1 leading-[18px] outline-none z-10"
|
||||
@blur="commitEditFromCellBlur"
|
||||
@click.stop
|
||||
@focus="onCellEditTextareaInput"
|
||||
@input="onCellEditTextareaInput"
|
||||
@keydown.stop="onCellEditKeydown"
|
||||
@paste.stop="onCellEditTextareaPaste"
|
||||
@wheel.stop
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="editValue"
|
||||
:inputmode="cellEditInputModeForColumn(cell.valueIndex)"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-1.5 py-0 leading-[26px] outline-none z-10"
|
||||
@blur="commitEditFromCellBlur"
|
||||
@click.stop
|
||||
@input="onCellEditTextareaInput"
|
||||
@keydown.stop="onCellEditKeydown"
|
||||
@paste.stop="onCellEditTextareaPaste"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
|
||||
<span class="text-muted-foreground/70 italic">{{ firstLineCellDisplayValue(newRowCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) ?? "", flatteningMultiLineEnabled) }}</span>
|
||||
</template>
|
||||
<template v-else>{{ gridCellDisplayValue(cell.display, flatteningMultiLineEnabled, showWhitespaceEnabled) }}</template>
|
||||
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<LightDropdownMenu
|
||||
v-if="canQuickDownloadCellValue(cell.recordIndex, cell.valueIndex)"
|
||||
:items="binaryCellDownloadMenuItems"
|
||||
:open="quickDownloadMenuOpenFor(cell.recordIndex, cell.valueIndex)"
|
||||
align="end"
|
||||
content-class="w-44"
|
||||
:match-trigger-width="false"
|
||||
@update:open="(value: boolean) => handleQuickDownloadMenuOpenChange(value, cell.recordIndex, cell.valueIndex)"
|
||||
@select="(mode: string) => downloadCellBinaryValue(cell.recordIndex, cell.valueIndex, mode as BinaryCellDownloadMode)"
|
||||
>
|
||||
<template #trigger="{ open, toggle }">
|
||||
<button class="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.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
|
||||
<Download class="h-3 w-3" />
|
||||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
<button
|
||||
v-if="canOpenCellExternalUrl(cell.recordIndex, cell.valueIndex)"
|
||||
class="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.openUrl')"
|
||||
:aria-label="t('grid.openUrl')"
|
||||
@mousedown.stop
|
||||
@click.stop="openCellExternalUrl(cell.recordIndex, cell.valueIndex)"
|
||||
>
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
v-if="cellDetailButtonEnabled"
|
||||
class="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="showTransposeCellDetails(cell.recordIndex, cell.valueIndex)"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
|
||||
</div>
|
||||
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Sticky header -->
|
||||
@@ -13562,6 +13572,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
`top`/`inset` offsets are measured from the body instead of from the
|
||||
grid's outer relative container (which starts at the header row). -->
|
||||
<div class="relative min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<ProductionWatermark v-if="productionContext.active" />
|
||||
<div v-if="!hasVisibleRows" class="relative min-h-0 flex-1">
|
||||
<div class="data-grid-scroller h-full overflow-x-auto overflow-y-hidden overscroll-none" :class="{ 'is-scrolling': isScrolling }" @scroll="onScrollerScroll" @wheel="onDomGridWheel">
|
||||
<div class="h-full min-h-[220px]" :style="{ width: 'max(100%, var(--total-w))' }" />
|
||||
|
||||
@@ -450,6 +450,8 @@ function errorMessage(value: unknown): string {
|
||||
:result="gridResult"
|
||||
context="results"
|
||||
database-type="hbase"
|
||||
:connection-id="props.connectionId"
|
||||
:database="props.namespace"
|
||||
:editable="!readOnly"
|
||||
:custom-save-handler="customSaveHandler"
|
||||
:allow-insert-rows="false"
|
||||
|
||||
@@ -68,6 +68,7 @@ import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
|
||||
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
|
||||
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
|
||||
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
|
||||
import ProductionWatermark from "@/components/common/ProductionWatermark.vue";
|
||||
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
|
||||
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
|
||||
import DataGridColumnWidthModeControl from "@/components/grid/DataGridColumnWidthModeControl.vue";
|
||||
@@ -252,7 +253,7 @@ const props = defineProps<
|
||||
|
||||
const emit = defineEmits<ContentAreaSurfaceEmits>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
/** Clear a consumed editor reveal request so a later normal tab re-visit doesn't re-jump. */
|
||||
@@ -383,7 +384,6 @@ const activeSqlStatementParameterOptions = computed(() =>
|
||||
sqlStatementParameterOptionsForCompatibility(activeEffectiveDatabaseType.value, activeEffectiveDatabaseType.value === "opengauss" ? connectionStore.databaseCompatibilityMode(activeResultConnectionId.value, activeResultDatabase.value) : undefined),
|
||||
);
|
||||
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
|
||||
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
|
||||
const productionSessionDetail = computed(() => {
|
||||
if (!activeProductionContext.value.active) return "";
|
||||
if (activeProductionContext.value.reason === "connection") return t("production.connection");
|
||||
@@ -1684,9 +1684,7 @@ defineExpose({
|
||||
{{ t("contextMenu.viewData") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
|
||||
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span>
|
||||
</div>
|
||||
<ProductionWatermark v-if="activeProductionContext.active" />
|
||||
<!-- issue #9035:源码 tab 先出现再加载。pending 期间不挂载编辑器
|
||||
(还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。
|
||||
issue #9387:DDL 新标签同样先出 tab 再加载,失败就地显示错误。 -->
|
||||
@@ -3197,28 +3195,6 @@ defineExpose({
|
||||
flex: 0 0 3px;
|
||||
}
|
||||
|
||||
.production-watermark {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 3rem;
|
||||
overflow: hidden;
|
||||
padding: 3rem 2.5rem;
|
||||
}
|
||||
|
||||
.production-watermark__label {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
transform: rotate(-22deg);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.production-watermark {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.result-tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -549,7 +549,7 @@ onBeforeUnmount(() => {
|
||||
<ErrorBanner v-if="error" :message="error" copy-mode="label" dismissible @dismiss="error = ''" />
|
||||
<div v-else-if="statusMessage" class="border-b bg-emerald-50 px-3 py-1.5 text-xs text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">{{ statusMessage }}</div>
|
||||
<QueryLoadingState v-if="loading && result.columns.length === 0" class="h-full" label-key="editor.fetching" :elapsed-seconds="elapsedSeconds" show-cancel :cancel-disabled="!executionId || cancelling" :cancelling="cancelling" @cancel="cancelRequest" />
|
||||
<DataGrid v-else class="h-full" :result="result" context="results" :sql="requestText" :loading="loading" @reload="refreshResult" />
|
||||
<DataGrid v-else class="h-full" :result="result" context="results" :sql="requestText" :connection-id="props.connectionId" :database="props.database" :database-type="props.databaseType" :loading="loading" @reload="refreshResult" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-6
@@ -146,14 +146,17 @@ describe("sqlFormatter", () => {
|
||||
expect(formatted).toBe('CREATE VIEW "TEMP_TEST_VIEW" AS\nSELECT trunc(sysdate) AS dates,\n -- 测试\n (SELECT sysdate FROM dual t) AS nows\nFROM dual;');
|
||||
});
|
||||
|
||||
it("collapses a short OceanBase Oracle view DDL onto one line (issue #7540)", async () => {
|
||||
// The default style joins a statement that fits the line width onto one
|
||||
// line, so a short view body comes back with its keywords cased rather
|
||||
// than spread over several.
|
||||
it("keeps a short OceanBase Oracle view projection readable (issue #7540)", async () => {
|
||||
// A view body still keeps each projected field on its own line while its
|
||||
// surrounding clauses retain the default layout.
|
||||
const singleLine = `create or replace view "APP"."ACTIVE_USERS" as select ID, NAME from USERS where STATUS = 'ACTIVE'`;
|
||||
const formatted = await formatSqlForDisplay(singleLine, sqlFormatDialectForDbType("oceanbase-oracle"));
|
||||
|
||||
expect(formatted).toBe(`CREATE OR REPLACE VIEW "APP"."ACTIVE_USERS" AS SELECT ID, NAME FROM USERS WHERE STATUS = 'ACTIVE'`);
|
||||
expect(formatted).toBe(`CREATE OR REPLACE VIEW "APP"."ACTIVE_USERS" AS
|
||||
SELECT ID,
|
||||
NAME
|
||||
FROM USERS
|
||||
WHERE STATUS = 'ACTIVE'`);
|
||||
expect(formatted).toContain("'ACTIVE'");
|
||||
expect(formatted).toContain('"ACTIVE_USERS"');
|
||||
});
|
||||
@@ -339,7 +342,10 @@ OR inside$$ as note
|
||||
|
||||
// Alias casing is the tokenizer's business, not the keyword casing's: the
|
||||
// date part stays an identifier, so `keywordCase: "upper"` leaves it alone.
|
||||
expect(formatted).toBe(`SELECT ${identifier}, t.${identifier} FROM material ${identifier} LIMIT 100;`);
|
||||
expect(formatted).toBe(`SELECT ${identifier},
|
||||
t.${identifier}
|
||||
FROM material ${identifier}
|
||||
LIMIT 100;`);
|
||||
expect(formatted).toContain("SELECT");
|
||||
expect(formatted).toContain("FROM");
|
||||
expect(formatted).toContain("LIMIT");
|
||||
|
||||
+47
-16
@@ -31,10 +31,8 @@ describe("sql layout", () => {
|
||||
// The subquery's clauses align under the `(` that opens it, and the closing
|
||||
// `)` stays on the last clause's line.
|
||||
//
|
||||
// The subquery's two select elements could be split; the screenshots in
|
||||
// #827 and #4850 disagree on that point (both are two-element lists, one
|
||||
// split, one not), so the keeping rule here is the one #4850's rendering
|
||||
// matches.
|
||||
// Expanded subqueries use the same one-field-per-line rule as the outer
|
||||
// SELECT, while their clauses remain aligned under the opening parenthesis.
|
||||
expect(await format(sql)).toBe(
|
||||
lines(
|
||||
"SELECT loc_id,",
|
||||
@@ -42,7 +40,8 @@ describe("sql layout", () => {
|
||||
" loc_type,",
|
||||
" delivery_emp_num,",
|
||||
" update_time",
|
||||
"FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY loc_id ORDER BY update_time DESC) AS rn",
|
||||
"FROM (SELECT *,",
|
||||
" ROW_NUMBER() OVER (PARTITION BY loc_id ORDER BY update_time DESC) AS rn",
|
||||
" FROM responsible_area_info",
|
||||
" WHERE DATE_FORMAT(update_time, '%Y-%m') = DATE_FORMAT(NOW(), '%Y-%m')) t",
|
||||
"WHERE rn = 1;",
|
||||
@@ -62,9 +61,12 @@ describe("sql layout", () => {
|
||||
// subquery expands rather than pushing `ON` onto a line of its own.
|
||||
expect(await format(sql)).toBe(
|
||||
lines(
|
||||
"SELECT HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID, HIMMS_XDSBDJ.YNKE, himms_zwxsysj_sysj(V_HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID) AS SJSYSJ",
|
||||
"SELECT HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID,",
|
||||
" HIMMS_XDSBDJ.YNKE,",
|
||||
" himms_zwxsysj_sysj(V_HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID) AS SJSYSJ",
|
||||
"FROM HIMMS_XDSBDJ",
|
||||
" LEFT JOIN (SELECT himms_xdsbdj_id, MAX(SJ) SJ",
|
||||
" LEFT JOIN (SELECT himms_xdsbdj_id,",
|
||||
" MAX(SJ) SJ",
|
||||
" FROM himms_zwxqdjc",
|
||||
" GROUP BY himms_xdsbdj_id) SUB_QDJC ON HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID = SUB_QDJC.himms_xdsbdj_id",
|
||||
"WHERE HIMMS_XDSBDJ.tybz IS NULL;",
|
||||
@@ -121,7 +123,7 @@ describe("sql layout", () => {
|
||||
// `logicalOperatorNewline: "before"` starts each continuation line with the
|
||||
// operator, aligned under the condition's first operand like any other
|
||||
// clause item.
|
||||
expect(await format(sql)).toBe(lines("SELECT a, b", "FROM t", "WHERE alpha = 1", " AND beta = 2", " AND gamma = 3", " AND delta = 4", " AND epsilon = 5", " AND zeta = 6", " AND eta = 7", " AND theta = 8;"));
|
||||
expect(await format(sql)).toBe(lines("SELECT a,", " b", "FROM t", "WHERE alpha = 1", " AND beta = 2", " AND gamma = 3", " AND delta = 4", " AND epsilon = 5", " AND zeta = 6", " AND eta = 7", " AND theta = 8;"));
|
||||
});
|
||||
|
||||
it("puts a wrapped condition's operators where the setting asks", async () => {
|
||||
@@ -130,7 +132,7 @@ describe("sql layout", () => {
|
||||
// `after` ends the previous line with the operator instead.
|
||||
expect(await format(sql, { logicalOperatorNewline: "after" })).toContain("WHERE alpha = 1 AND\n beta = 2 AND");
|
||||
// `none` keeps the condition on the clause's line, however long it gets.
|
||||
expect(await format(sql, { logicalOperatorNewline: "none" })).toBe(lines("SELECT a, b", "FROM t", "WHERE alpha = 1 AND beta = 2 AND gamma = 3 AND delta = 4 AND epsilon = 5 AND zeta = 6 AND eta = 7 AND theta = 8;"));
|
||||
expect(await format(sql, { logicalOperatorNewline: "none" })).toBe(lines("SELECT a,", " b", "FROM t", "WHERE alpha = 1 AND beta = 2 AND gamma = 3 AND delta = 4 AND epsilon = 5 AND zeta = 6 AND eta = 7 AND theta = 8;"));
|
||||
});
|
||||
|
||||
it("expands a parenthesized list that does not fit, aligned under its first item", async () => {
|
||||
@@ -167,13 +169,40 @@ describe("sql layout", () => {
|
||||
it("collapses each clause independently", async () => {
|
||||
const sql = "SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count, c.name FROM customers c;";
|
||||
|
||||
// The scalar subquery fits, the select list does not: only the second one
|
||||
// breaks.
|
||||
expect(await format(sql)).toBe(lines("SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count, c.name", "FROM customers c;"));
|
||||
// The scalar subquery stays compact within its own SELECT field.
|
||||
expect(await format(sql)).toBe(lines("SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count,", " c.name", "FROM customers c;"));
|
||||
});
|
||||
|
||||
it("never joins a CASE onto one line", async () => {
|
||||
expect(await format("SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label FROM t;")).toBe(lines("SELECT CASE", " WHEN a = 1 THEN 'one'", " WHEN a = 2 THEN 'two'", " ELSE 'other'", " END AS label", "FROM t;"));
|
||||
it("keeps a CASE expression compact (#10783)", async () => {
|
||||
expect(await format("SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label FROM t;")).toBe("SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label FROM t;");
|
||||
});
|
||||
|
||||
it("puts each SELECT field on its own line even in a short query (#10783)", async () => {
|
||||
expect(await format("SELECT id, CASE WHEN a = 1 THEN 'one' ELSE 'other' END AS label, coalesce(a, b) FROM t;")).toBe(lines("SELECT id,", " CASE WHEN a = 1 THEN 'one' ELSE 'other' END AS label,", " coalesce(a, b)", "FROM t;"));
|
||||
});
|
||||
|
||||
it("puts fields on separate lines when SELECT has a modifier (#10783)", async () => {
|
||||
expect(await format("SELECT DISTINCT a, b FROM t;")).toBe(lines("SELECT DISTINCT a,", " b", "FROM t;"));
|
||||
expect(await format("SELECT DISTINCT ON (key) a, b FROM t;")).toBe(lines("SELECT DISTINCT ON (KEY) a,", " b", "FROM t;"));
|
||||
});
|
||||
|
||||
it("keeps nested and simple CASE expressions compact", async () => {
|
||||
const sql = "SELECT SUM(CASE WHEN a = 1 AND b = 2 THEN 1 ELSE 0 END) AS total, CASE a WHEN 1 THEN CASE b WHEN 2 THEN 'x y' END ELSE 'other' END AS label FROM t;";
|
||||
const expected = lines("SELECT SUM(CASE WHEN a = 1 AND b = 2 THEN 1 ELSE 0 END) AS total,", " CASE a WHEN 1 THEN CASE b WHEN 2 THEN 'x y' END ELSE 'other' END AS label", "FROM t;");
|
||||
expect(await format(sql)).toBe(expected);
|
||||
expect(await format(expected)).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves comments and width wrapping inside CASE expressions", async () => {
|
||||
const commented = await format(lines("SELECT id, CASE WHEN a = 1 THEN 'one' -- retain this", "ELSE 'other' END AS label FROM t;"));
|
||||
expect(commented).toContain("-- retain this\n");
|
||||
expect(commented).toContain("ELSE 'other'");
|
||||
expect(await format(commented)).toBe(commented);
|
||||
|
||||
const longCase = await format(`SELECT id, CASE WHEN a = 1 THEN '${"x".repeat(100)}' ELSE 'other' END AS label FROM t;`);
|
||||
expect(longCase).toContain("CASE\n");
|
||||
expect(longCase).toContain("END AS label");
|
||||
expect(await format(longCase)).toBe(longCase);
|
||||
});
|
||||
|
||||
it("gives a set operation its own line", async () => {
|
||||
@@ -187,7 +216,7 @@ describe("sql layout", () => {
|
||||
});
|
||||
|
||||
it("keeps PostgreSQL casts tight", async () => {
|
||||
expect(await format("select coalesce(a, b)::text, (x + y)::int from t;", {}, "postgres")).toBe("SELECT coalesce(a, b)::text, (x + y)::int FROM t;");
|
||||
expect(await format("select coalesce(a, b)::text, (x + y)::int from t;", {}, "postgres")).toBe(lines("SELECT coalesce(a, b)::text,", " (x + y)::int", "FROM t;"));
|
||||
});
|
||||
|
||||
it("keeps the comma of a MySQL LIMIT offset", async () => {
|
||||
@@ -243,7 +272,9 @@ describe("sql layout", () => {
|
||||
// keyword alone deleted every branch after the first one.
|
||||
expect(await format(sql)).toBe(
|
||||
lines(
|
||||
"SELECT a.id, a.starttime, t.name AS taskName",
|
||||
"SELECT a.id,",
|
||||
" a.starttime,",
|
||||
" t.name AS taskName",
|
||||
"FROM ((SELECT l.id, l.starttime FROM `log_1_4` l WHERE l.taskid = 'abc' ORDER BY l.starttime DESC LIMIT 0, 10)",
|
||||
" UNION ALL",
|
||||
" (SELECT l.id, l.starttime FROM `log_2_4` l WHERE l.taskid = 'abc' ORDER BY l.starttime DESC LIMIT 0, 10)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AstNode, ClauseNode, KeywordNode, LimitClauseNode, ParenthesisNode, SetOperationNode } from "sql-formatter/dist/esm/parser/ast.js";
|
||||
import type { AstNode, CaseExpressionNode, ClauseNode, KeywordNode, LimitClauseNode, ParenthesisNode, SetOperationNode } from "sql-formatter/dist/esm/parser/ast.js";
|
||||
import type { SqlLayoutRenderers } from "./internals";
|
||||
|
||||
/**
|
||||
@@ -242,6 +242,79 @@ export function keywordText(text: string, ctx: SqlLayoutContext): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an AST value contains comments that must retain their own lines. */
|
||||
function containsComments(value: unknown, seen = new Set<object>()): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const object = value as object;
|
||||
if (seen.has(object)) return false;
|
||||
seen.add(object);
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "line_comment" || record.type === "block_comment" || record.type === "disable_comment") return true;
|
||||
if (Array.isArray(record.leadingComments) && record.leadingComments.length > 0) return true;
|
||||
if (Array.isArray(record.trailingComments) && record.trailingComments.length > 0) return true;
|
||||
|
||||
return Object.entries(record).some(([key, child]) => key !== "leadingComments" && key !== "trailingComments" && containsComments(child, seen));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a CASE expression as one expression when every branch fits. The
|
||||
* regular sql-formatter inline renderer deliberately expands CASE branches;
|
||||
* keeping the expression together makes a CASE projection obey the same
|
||||
* one-field-per-line rule as every other SELECT item.
|
||||
*/
|
||||
function renderCompactCaseExpression(ctx: SqlLayoutContext, node: CaseExpressionNode, width: number): string | null {
|
||||
if (containsComments(node)) return null;
|
||||
|
||||
const parts: string[] = [keywordText(node.caseKw.text, ctx)];
|
||||
const append = (text: string | null): boolean => {
|
||||
if (!text) return false;
|
||||
parts.push(text);
|
||||
return parts.join(" ").length <= width;
|
||||
};
|
||||
|
||||
if (node.expr.length > 0 && !append(renderInline(collapsedContext(ctx), node.expr, width))) return null;
|
||||
|
||||
for (const clause of node.clauses) {
|
||||
if (clause.type === "case_when") {
|
||||
if (!append(keywordText(clause.whenKw.text, ctx))) return null;
|
||||
if (!append(renderInline(collapsedContext(ctx), clause.condition, width))) return null;
|
||||
if (!append(keywordText(clause.thenKw.text, ctx))) return null;
|
||||
if (!append(renderInline(collapsedContext(ctx), clause.result, width))) return null;
|
||||
} else {
|
||||
if (!append(keywordText(clause.elseKw.text, ctx))) return null;
|
||||
if (!append(renderInline(collapsedContext(ctx), clause.result, width))) return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!append(keywordText(node.endKw.text, ctx))) return null;
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Keeps CASE expressions inside functions/parentheses compact as well. */
|
||||
function compactNestedCases(ctx: SqlLayoutContext, node: AstNode, width: number): AstNode {
|
||||
switch (node.type) {
|
||||
case "case_expression": {
|
||||
const text = renderCompactCaseExpression(ctx, node, width);
|
||||
// This already-formatted expression is opaque to the upstream renderer;
|
||||
// the original AST remains available for the multiline fallback.
|
||||
return text ? ({ type: "literal", text } as AstNode) : node;
|
||||
}
|
||||
case "parenthesis":
|
||||
return { ...node, children: node.children.map((child) => compactNestedCases(ctx, child, width)) };
|
||||
case "function_call":
|
||||
case "parameterized_data_type":
|
||||
case "array_subscript":
|
||||
return { ...node, parenthesis: compactNestedCases(ctx, node.parenthesis, width) as ParenthesisNode };
|
||||
case "property_access":
|
||||
return { ...node, object: compactNestedCases(ctx, node.object, width) };
|
||||
case "between_predicate":
|
||||
return { ...node, expr1: node.expr1.map((child) => compactNestedCases(ctx, child, width)), expr2: node.expr2.map((child) => compactNestedCases(ctx, child, width)) };
|
||||
default:
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
export function splitByComma(children: AstNode[]): AstNode[][] {
|
||||
const groups: AstNode[][] = [];
|
||||
let current: AstNode[] = [];
|
||||
@@ -257,6 +330,11 @@ export function splitByComma(children: AstNode[]): AstNode[][] {
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Whether a clause name is SELECT, including modifiers such as DISTINCT or ALL. */
|
||||
export function isProjectionClauseName(name: string): boolean {
|
||||
return /^SELECT(?:\s|$)/i.test(name.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Keywords that introduce an object name rather than an expression operand, so
|
||||
* a parenthesis directly after that name is a definition list, not a call:
|
||||
@@ -343,7 +421,27 @@ export function renderInline(ctx: SqlLayoutContext, nodes: AstNode[], width: num
|
||||
if (group) return group;
|
||||
}
|
||||
|
||||
const direct = ctx.renderers.inline(nodes, width);
|
||||
const caseAt = nodes.findIndex((node) => node.type === "case_expression");
|
||||
if (caseAt >= 0) {
|
||||
const prefix = caseAt === 0 ? "" : renderInline(collapsedContext(ctx), nodes.slice(0, caseAt), width);
|
||||
if (prefix === null) return null;
|
||||
const caseText = renderCompactCaseExpression(ctx, nodes[caseAt] as CaseExpressionNode, width - prefix.length - (prefix ? 1 : 0));
|
||||
if (!caseText) return null;
|
||||
|
||||
let text = prefix ? `${prefix}${separatorBefore(nodes[caseAt], ctx)}${caseText}` : caseText;
|
||||
const tailNodes = nodes.slice(caseAt + 1);
|
||||
if (tailNodes.length > 0) {
|
||||
const tail = renderInline(collapsedContext(ctx), tailNodes, width - text.length - 1);
|
||||
if (!tail) return null;
|
||||
text += `${separatorBefore(tailNodes[0], ctx)}${tail}`;
|
||||
}
|
||||
return text.length <= width ? text : null;
|
||||
}
|
||||
|
||||
const direct = ctx.renderers.inline(
|
||||
nodes.map((node) => compactNestedCases(ctx, node, width)),
|
||||
width,
|
||||
);
|
||||
if (direct) return direct;
|
||||
|
||||
const joinsOperators = ctx.joinLogicalOperators ?? ctx.options.logicalOperatorNewline === "none";
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import type { AstNode, ClauseNode, LimitClauseNode, ParenthesisNode, SetOperationNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
|
||||
import { createTableLayout, isCreateTable } from "./ddl";
|
||||
import { clauseChildContext, collapsedContext, emitText, endsWithLineComment, isBodyNode, isCallParen, isJoinKeyword, isLineComment, isLogicalOperator, isParenthesis, keywordText, limitChildren, renderInline, splitByComma, splitLogicalOperands, Writer, type SqlLayoutContext } from "./primitives";
|
||||
import {
|
||||
clauseChildContext,
|
||||
collapsedContext,
|
||||
emitText,
|
||||
endsWithLineComment,
|
||||
isBodyNode,
|
||||
isCallParen,
|
||||
isJoinKeyword,
|
||||
isLineComment,
|
||||
isLogicalOperator,
|
||||
isParenthesis,
|
||||
isProjectionClauseName,
|
||||
keywordText,
|
||||
limitChildren,
|
||||
renderInline,
|
||||
splitByComma,
|
||||
splitLogicalOperands,
|
||||
Writer,
|
||||
type SqlLayoutContext,
|
||||
} from "./primitives";
|
||||
|
||||
export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
|
||||
|
||||
@@ -13,9 +32,10 @@ export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
|
||||
* them.
|
||||
* - A clause's first item stays on the keyword's line, and its remaining items
|
||||
* align under the first item's column.
|
||||
* - Short element lists stay collapsed.
|
||||
* - A statement whose clauses all fit the line width is emitted as a single
|
||||
* line.
|
||||
* - SELECT fields each get their own line; other short element lists stay
|
||||
* collapsed.
|
||||
* - A statement with a single SELECT field whose clauses all fit the line
|
||||
* width is emitted as a single line.
|
||||
* - Join keywords are indented relative to their FROM clause.
|
||||
*
|
||||
* Alignment is *column-relative*, not indent-level-relative: a subquery's
|
||||
@@ -30,19 +50,20 @@ export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
|
||||
|
||||
/**
|
||||
* Emits one clause: its keyword, then its items — the first kept on the keyword's
|
||||
* line and the rest aligned under it — unless the whole list is short enough to
|
||||
* stay on one line.
|
||||
* line and the rest aligned under it. SELECT fields always split; other lists
|
||||
* stay on one line when they are short enough.
|
||||
*/
|
||||
function printClause(writer: Writer, clause: ClauseNode, baseColumn: number, ctx: SqlLayoutContext): void {
|
||||
const keyword = keywordText(clause.nameKw.text, ctx);
|
||||
const items = splitByComma(clause.children);
|
||||
const itemColumn = baseColumn + keyword.length + 1;
|
||||
const projection = isProjectionClauseName(clause.nameKw.text);
|
||||
// `FROM` is the one clause DBX lets users push its first source off of, so it
|
||||
// skips both the collapse and the "element on the keyword's line" rule.
|
||||
const sourceOnOwnLine = clause.nameKw.text.toUpperCase() === "FROM" && !ctx.options.fromClauseSourceOnSameLine;
|
||||
const childCtx = clauseChildContext(ctx, clause);
|
||||
|
||||
if (!sourceOnOwnLine && items.length <= ctx.options.keepElementsOnOneLine) {
|
||||
if (!sourceOnOwnLine && !(projection && items.length > 1) && items.length <= ctx.options.keepElementsOnOneLine) {
|
||||
const flat = renderInline(childCtx, clause.children, ctx.options.lineWidth - writer.column - keyword.length - 1);
|
||||
if (flat) {
|
||||
writer.write(`${keyword} ${flat}`);
|
||||
@@ -282,7 +303,8 @@ function writeBodyNode(writer: Writer, node: AstNode, baseColumn: number, ctx: S
|
||||
|
||||
/** Emits a whole statement, one clause per line unless the statement collapses. */
|
||||
function printStatement(statement: StatementNode, ctx: SqlLayoutContext): string | null {
|
||||
const collapsed = renderInline(collapsedContext(ctx), statement.children, ctx.options.lineWidth);
|
||||
const hasMultiItemProjection = statement.children.some((node) => node.type === "clause" && isProjectionClauseName(node.nameKw.text) && splitByComma(node.children).length > 1);
|
||||
const collapsed = hasMultiItemProjection ? null : renderInline(collapsedContext(ctx), statement.children, ctx.options.lineWidth);
|
||||
if (collapsed) return statement.hasSemicolon ? `${collapsed};` : collapsed;
|
||||
|
||||
if (isCreateTable(statement)) return createTableLayout(statement, ctx);
|
||||
|
||||
+9
-2
@@ -29,10 +29,17 @@ test("formats SQL with uppercase keywords and readable line breaks by default",
|
||||
assert.match(formatted, /\nORDER BY\b/);
|
||||
});
|
||||
|
||||
test("collapses a statement that fits on one line", async () => {
|
||||
test("puts each SELECT field on its own line even when the statement fits on one line", async () => {
|
||||
const formatted = await formatSqlText("select id, name from users where active = 1 order by name", "postgres");
|
||||
|
||||
assert.equal(formatted, "SELECT id, name FROM users WHERE active = 1 ORDER BY name");
|
||||
assert.equal(formatted, "SELECT id,\n name\nFROM users\nWHERE active = 1\nORDER BY name");
|
||||
assert.equal(await formatSqlText(formatted, "postgres"), formatted);
|
||||
});
|
||||
|
||||
test("collapses a single-field statement that fits on one line", async () => {
|
||||
const formatted = await formatSqlText("select id from users where active = 1 order by id", "postgres");
|
||||
|
||||
assert.equal(formatted, "SELECT id FROM users WHERE active = 1 ORDER BY id");
|
||||
});
|
||||
|
||||
test("formats SQL with custom keyword case and indentation settings", async () => {
|
||||
|
||||
Reference in New Issue
Block a user