feat(grid): add conditional bulk updates for MySQL
This commit is contained in:
29 files changed
+1813
-85
No files matched your search
@@ -66,6 +66,7 @@ import DataGridToolbar from "@/components/grid/DataGridToolbar.vue";
|
||||
import DataGridExtractorDialog from "@/components/grid/DataGridExtractorDialog.vue";
|
||||
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
|
||||
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
|
||||
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
|
||||
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
|
||||
@@ -101,7 +102,7 @@ import {
|
||||
isTdengineExistingRowReadonlyColumn,
|
||||
usesSyntheticRowIdKey,
|
||||
} from "@/lib/table/tableEditing";
|
||||
import { buildDataGridColumnDistinctValuesSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import { buildDataGridColumnDistinctValuesSql, buildDataGridConditionalUpdateSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import {
|
||||
buildVisibleTransposeRows,
|
||||
averageTransposeRecordWidth,
|
||||
@@ -836,6 +837,17 @@ function onGridContextMenuClose() {
|
||||
|
||||
const bulkEditDialogOpen = ref(false);
|
||||
const bulkEditValue = ref("");
|
||||
const bulkEditScope = ref<"selection" | "condition">("selection");
|
||||
const conditionalBulkEditConditionSource = ref<"current" | "builder" | "sql">("builder");
|
||||
const conditionalBulkEditCurrentWhereInput = ref("");
|
||||
const conditionalBulkEditWhereInput = ref("");
|
||||
const conditionalBulkEditMatchCount = ref<number | undefined>(undefined);
|
||||
const conditionalBulkEditMatchCountLoading = ref(false);
|
||||
const conditionalBulkEditMatchCountError = ref("");
|
||||
const conditionalBulkEditMatchCountStale = ref(false);
|
||||
const conditionalBulkEditConfirmOpen = ref(false);
|
||||
const conditionalBulkEditStatement = ref("");
|
||||
let conditionalBulkEditMatchCountRequestId = 0;
|
||||
const inlineBulkEditActive = ref(false);
|
||||
const copyColumnNamesDialogOpen = ref(false);
|
||||
const copyColumnNamesDialogColumns = ref<string[]>([]);
|
||||
@@ -966,27 +978,70 @@ const filterBuilderColumns = computed(() => props.tableMeta?.columns ?? []);
|
||||
const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name));
|
||||
const structuredFilterCacheKey = computed(() => props.cacheKey || [props.connectionId ?? "", props.database ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? ""].join("\u0001"));
|
||||
const structuredFilterScopeKey = computed(() => [props.connectionId ?? "", props.database ?? "", props.schema ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", filterBuilderColumnOptions.value.join("\0")].join("\u0001"));
|
||||
function isStructuredFilterRuleComplete(rule: StructuredFilterRule): boolean {
|
||||
return filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value) && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue);
|
||||
}
|
||||
|
||||
async function buildStructuredFilterCondition(rule: StructuredFilterRule): Promise<string | undefined> {
|
||||
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName);
|
||||
const usesList = filterModeUsesList(rule.mode);
|
||||
const usesRange = filterModeUsesRange(rule.mode);
|
||||
return (
|
||||
(await buildDataGridContextFilterCondition({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
columnName: rule.columnName,
|
||||
columnInfo,
|
||||
mode: rule.mode,
|
||||
value: !usesList && filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo, resolvedDatabaseType.value) : null,
|
||||
values: usesList ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : undefined,
|
||||
endValue: usesRange ? parseFilterValue(rule.rawEndValue, columnInfo, resolvedDatabaseType.value) : undefined,
|
||||
})) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
const filterBuilder = useDataGridFilterBuilder({
|
||||
columns: filterBuilderColumnOptions,
|
||||
createId: uuid,
|
||||
isComplete: (rule) => filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value) && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue),
|
||||
buildCondition: async (rule) => {
|
||||
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName);
|
||||
const usesList = filterModeUsesList(rule.mode);
|
||||
const usesRange = filterModeUsesRange(rule.mode);
|
||||
return (
|
||||
(await buildDataGridContextFilterCondition({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnName: rule.columnName,
|
||||
columnInfo,
|
||||
mode: rule.mode,
|
||||
value: !usesList && filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo, resolvedDatabaseType.value) : null,
|
||||
values: usesList ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : undefined,
|
||||
endValue: usesRange ? parseFilterValue(rule.rawEndValue, columnInfo, resolvedDatabaseType.value) : undefined,
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
isComplete: isStructuredFilterRuleComplete,
|
||||
buildCondition: buildStructuredFilterCondition,
|
||||
});
|
||||
const conditionalBulkEditFilterBuilder = useDataGridFilterBuilder({
|
||||
columns: filterBuilderColumnOptions,
|
||||
createId: uuid,
|
||||
isComplete: isStructuredFilterRuleComplete,
|
||||
buildCondition: buildStructuredFilterCondition,
|
||||
});
|
||||
const conditionalBulkEditRules = conditionalBulkEditFilterBuilder.rules;
|
||||
const conditionalBulkEditColumnSearch = conditionalBulkEditFilterBuilder.columnSearch;
|
||||
const filteredConditionalBulkEditColumns = conditionalBulkEditFilterBuilder.filteredColumns;
|
||||
|
||||
function addConditionalBulkEditRule() {
|
||||
if (!conditionalBulkEditRules.value.length) {
|
||||
conditionalBulkEditFilterBuilder.ensureRule();
|
||||
return;
|
||||
}
|
||||
conditionalBulkEditFilterBuilder.addRule();
|
||||
}
|
||||
|
||||
function resetConditionalBulkEditMatchCountPreview() {
|
||||
conditionalBulkEditMatchCountRequestId++;
|
||||
conditionalBulkEditMatchCount.value = undefined;
|
||||
conditionalBulkEditMatchCountLoading.value = false;
|
||||
conditionalBulkEditMatchCountError.value = "";
|
||||
conditionalBulkEditMatchCountStale.value = false;
|
||||
}
|
||||
|
||||
function invalidateConditionalBulkEditMatchCountPreview() {
|
||||
conditionalBulkEditMatchCountRequestId++;
|
||||
if (conditionalBulkEditMatchCount.value !== undefined || conditionalBulkEditMatchCountLoading.value) conditionalBulkEditMatchCountStale.value = true;
|
||||
conditionalBulkEditMatchCount.value = undefined;
|
||||
conditionalBulkEditMatchCountLoading.value = false;
|
||||
conditionalBulkEditMatchCountError.value = "";
|
||||
}
|
||||
|
||||
watch([conditionalBulkEditConditionSource, conditionalBulkEditCurrentWhereInput, conditionalBulkEditWhereInput, conditionalBulkEditRules], invalidateConditionalBulkEditMatchCountPreview, { deep: true });
|
||||
|
||||
const structuredFilterRules = filterBuilder.rules;
|
||||
const filterBuilderOpen = filterBuilder.open;
|
||||
const filterBuilderColumnSearch = filterBuilder.columnSearch;
|
||||
@@ -3571,6 +3626,8 @@ const {
|
||||
requestDeleteRows,
|
||||
cloneRows: cloneEditorRows,
|
||||
saveChanges,
|
||||
executeConditionalUpdate,
|
||||
cancelConditionalUpdate,
|
||||
discardChanges,
|
||||
canUndoPendingChange,
|
||||
canRedoPendingChange,
|
||||
@@ -3579,6 +3636,9 @@ const {
|
||||
rowDataWithChanges,
|
||||
ensureQuickEntryDraftRow,
|
||||
isSavingNewRow,
|
||||
isConditionalUpdateActive,
|
||||
conditionalUpdateExecution,
|
||||
coerceCellValue,
|
||||
canEditColumn,
|
||||
resetGridVerticalScroll,
|
||||
getResetScrollAfterResult,
|
||||
@@ -3696,7 +3756,7 @@ const showDataGridTopbar = computed(
|
||||
);
|
||||
|
||||
function canEditRowItem(item: RowItem | undefined): boolean {
|
||||
return !!props.editable && !!item && !item.isDeleted && (item.isNew || item.isDraft || canEditExistingRows.value);
|
||||
return !isConditionalUpdateActive.value && !!props.editable && !!item && !item.isDeleted && (item.isNew || item.isDraft || canEditExistingRows.value);
|
||||
}
|
||||
|
||||
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
|
||||
@@ -7632,17 +7692,159 @@ function setSelectionNull() {
|
||||
|
||||
function openBulkEditDialog() {
|
||||
if (!props.editable || !selectionHasEditableCells()) return;
|
||||
const currentWhere = currentWhereInput()?.trim() ?? "";
|
||||
bulkEditValue.value = "";
|
||||
bulkEditScope.value = "selection";
|
||||
conditionalBulkEditCurrentWhereInput.value = currentWhere;
|
||||
conditionalBulkEditWhereInput.value = currentWhere;
|
||||
conditionalBulkEditConditionSource.value = currentWhere ? "current" : "builder";
|
||||
conditionalBulkEditFilterBuilder.reset();
|
||||
resetConditionalBulkEditMatchCountPreview();
|
||||
bulkEditDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function applyBulkEditValue() {
|
||||
interface ConditionalBulkEditTarget {
|
||||
columnIndex: number;
|
||||
columnName: string;
|
||||
}
|
||||
|
||||
const conditionalBulkEditTarget = computed<ConditionalBulkEditTarget | null>(() => {
|
||||
if (props.context !== "table-data" || resolvedDatabaseType.value !== "mysql" || !props.connectionId || !props.tableMeta || props.customSaveHandler || hasPendingChanges.value) return null;
|
||||
|
||||
let visibleColumnIndex: number | undefined;
|
||||
if (selectedRange.value) {
|
||||
if (selectedRange.value.startCol !== selectedRange.value.endCol) return null;
|
||||
visibleColumnIndex = selectedRange.value.startCol;
|
||||
} else if (hasColumnSelection.value) {
|
||||
const selectedColumns = selectedVisibleColumnIndexes();
|
||||
if (selectedColumns.length !== 1) return null;
|
||||
visibleColumnIndex = selectedColumns[0];
|
||||
}
|
||||
if (visibleColumnIndex === undefined) return null;
|
||||
|
||||
const columnIndex = actualColumnIndex(visibleColumnIndex);
|
||||
const sourceColumn = props.sourceColumns ? props.sourceColumns[columnIndex] : props.result.columns[columnIndex];
|
||||
if (!sourceColumn) return null;
|
||||
const columnInfo = props.tableMeta.columns.find((column) => column.name.toLowerCase() === sourceColumn.toLowerCase());
|
||||
if (!columnInfo) return null;
|
||||
if (columnInfo.is_primary_key || props.tableMeta.primaryKeys.some((primaryKey) => primaryKey.toLowerCase() === columnInfo.name.toLowerCase())) return null;
|
||||
const extra = columnInfo.extra?.toLowerCase() ?? "";
|
||||
if (extra.includes("generated always as") && !extra.includes("identity")) return null;
|
||||
if (!selectionHasEditableCells()) return null;
|
||||
const hasExistingTarget = editableSelectionCells().some(({ item }) => !item.isNew && !item.isDraft);
|
||||
if (!hasExistingTarget) return null;
|
||||
return { columnIndex, columnName: columnInfo.name };
|
||||
});
|
||||
|
||||
async function currentConditionalBulkEditWhere(): Promise<string> {
|
||||
switch (conditionalBulkEditConditionSource.value) {
|
||||
case "current":
|
||||
return conditionalBulkEditCurrentWhereInput.value.trim();
|
||||
case "builder":
|
||||
return conditionalBulkEditFilterBuilder.buildWhere();
|
||||
case "sql":
|
||||
return conditionalBulkEditWhereInput.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function previewConditionalBulkEditMatchCount() {
|
||||
if (!props.connectionId || !props.tableMeta) return;
|
||||
const requestId = ++conditionalBulkEditMatchCountRequestId;
|
||||
conditionalBulkEditMatchCount.value = undefined;
|
||||
conditionalBulkEditMatchCountError.value = "";
|
||||
conditionalBulkEditMatchCountStale.value = false;
|
||||
conditionalBulkEditMatchCountLoading.value = true;
|
||||
try {
|
||||
const whereInput = await currentConditionalBulkEditWhere();
|
||||
if (!whereInput) {
|
||||
conditionalBulkEditMatchCountError.value = t("grid.conditionalBulkEditConditionRequired");
|
||||
return;
|
||||
}
|
||||
const sql = await buildDataGridCountSql({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
catalog: props.tableMeta.catalog,
|
||||
database: props.tableMeta.database,
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
whereInput,
|
||||
});
|
||||
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, undefined, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId), settingsStore.editorSettings.globalQueryTimeoutSecs));
|
||||
const count = Number(result.rows?.[0]?.[0] ?? 0);
|
||||
if (!Number.isFinite(count) || count < 0) throw new Error("Invalid row count returned by the database");
|
||||
if (requestId !== conditionalBulkEditMatchCountRequestId) return;
|
||||
conditionalBulkEditMatchCount.value = count;
|
||||
} catch (error: any) {
|
||||
if (requestId !== conditionalBulkEditMatchCountRequestId) return;
|
||||
conditionalBulkEditMatchCountError.value = t("grid.conditionalBulkEditPreviewCountFailed", { message: error?.message || String(error) });
|
||||
} finally {
|
||||
if (requestId === conditionalBulkEditMatchCountRequestId) conditionalBulkEditMatchCountLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBulkEditValue() {
|
||||
if (bulkEditScope.value === "condition") {
|
||||
const target = conditionalBulkEditTarget.value;
|
||||
if (!target || !props.tableMeta) {
|
||||
toast(t("grid.conditionalBulkEditUnavailable"), 5000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const whereInput = await currentConditionalBulkEditWhere();
|
||||
if (!whereInput) {
|
||||
toast(t("grid.conditionalBulkEditConditionRequired"), 5000);
|
||||
return;
|
||||
}
|
||||
const value = bulkEditValue.value === "" ? null : coerceCellValue(bulkEditValue.value, undefined, target.columnIndex);
|
||||
const statement = await buildDataGridConditionalUpdateSql({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
tableMeta: props.tableMeta,
|
||||
columnName: target.columnName,
|
||||
value,
|
||||
whereInput,
|
||||
});
|
||||
if (!statement) {
|
||||
toast(t("grid.conditionalBulkEditUnavailable"), 5000);
|
||||
return;
|
||||
}
|
||||
conditionalBulkEditStatement.value = statement;
|
||||
bulkEditDialogOpen.value = false;
|
||||
conditionalBulkEditConfirmOpen.value = true;
|
||||
} catch (error: any) {
|
||||
toast(t("grid.conditionalBulkEditPrepareFailed", { message: error?.message || String(error) }), 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty input sets the selected cells to SQL NULL (the placeholder hints "Value, or NULL").
|
||||
const value = bulkEditValue.value === "" ? null : bulkEditValue.value;
|
||||
if (!fillSelectionWithValue(value)) return;
|
||||
bulkEditDialogOpen.value = false;
|
||||
}
|
||||
|
||||
async function confirmConditionalBulkEdit() {
|
||||
const statement = conditionalBulkEditStatement.value;
|
||||
if (!statement) return;
|
||||
conditionalBulkEditConfirmOpen.value = false;
|
||||
const result = await executeConditionalUpdate(statement);
|
||||
if (!result) {
|
||||
if (saveError.value) toast(saveError.value, 5000);
|
||||
return;
|
||||
}
|
||||
conditionalBulkEditStatement.value = "";
|
||||
if (typeof result.affectedRows === "number") {
|
||||
toast(t("grid.conditionalBulkEditAppliedCount", { count: result.affectedRows }));
|
||||
} else {
|
||||
toast(t("grid.conditionalBulkEditApplied"));
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRunningConditionalBulkEdit() {
|
||||
const cancelled = await cancelConditionalUpdate();
|
||||
if (!cancelled && saveError.value) toast(saveError.value, 5000);
|
||||
}
|
||||
|
||||
interface EditableSelectionCell {
|
||||
item: RowItem;
|
||||
visibleCol: number;
|
||||
@@ -10731,7 +10933,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
ref="gridRef"
|
||||
data-grid-root
|
||||
data-grid-active="true"
|
||||
class="h-full flex flex-col overflow-hidden outline-none"
|
||||
class="relative h-full flex flex-col overflow-hidden outline-none"
|
||||
:class="{
|
||||
'data-grid--editing-cell': !!editingCell,
|
||||
'data-grid--dark': isDark,
|
||||
@@ -12594,7 +12796,45 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
:copy-column-detail-column-name="copyColumnDetailColumnName"
|
||||
/>
|
||||
|
||||
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
|
||||
<DataGridBulkEditDialog
|
||||
v-if="bulkEditDialogMounted"
|
||||
v-model:open="bulkEditDialogOpen"
|
||||
v-model:value="bulkEditValue"
|
||||
v-model:scope="bulkEditScope"
|
||||
v-model:condition-source="conditionalBulkEditConditionSource"
|
||||
v-model:where-input="conditionalBulkEditWhereInput"
|
||||
:selected-cell-count="selectedCellCount"
|
||||
:conditional-available="!!conditionalBulkEditTarget"
|
||||
:conditional-column="conditionalBulkEditTarget?.columnName"
|
||||
:current-where="conditionalBulkEditCurrentWhereInput"
|
||||
:conditional-count="conditionalBulkEditMatchCount"
|
||||
:conditional-count-loading="conditionalBulkEditMatchCountLoading"
|
||||
:conditional-count-error="conditionalBulkEditMatchCountError"
|
||||
:conditional-count-stale="conditionalBulkEditMatchCountStale"
|
||||
@apply="applyBulkEditValue"
|
||||
@preview-count="previewConditionalBulkEditMatchCount"
|
||||
>
|
||||
<template #condition-builder>
|
||||
<div class="space-y-2">
|
||||
<DataGridFilterBuilder
|
||||
:rules="conditionalBulkEditRules"
|
||||
:columns="filterBuilderColumnOptions"
|
||||
:filtered-columns="filteredConditionalBulkEditColumns"
|
||||
:mode-options="filterModeOptions"
|
||||
:column-search="conditionalBulkEditColumnSearch"
|
||||
:show-header="false"
|
||||
:show-footer="false"
|
||||
layout="panel"
|
||||
@add="addConditionalBulkEditRule"
|
||||
@remove="conditionalBulkEditFilterBuilder.removeRule($event)"
|
||||
@move="(id, targetIndex) => conditionalBulkEditFilterBuilder.moveRule(id, targetIndex)"
|
||||
@update-rule="(id, patch) => conditionalBulkEditFilterBuilder.updateRule(id, patch)"
|
||||
@update:column-search="conditionalBulkEditColumnSearch = $event"
|
||||
/>
|
||||
<Button variant="outline" size="sm" :disabled="!filterBuilderColumnOptions.length" @click="addConditionalBulkEditRule">{{ t("grid.filterBuilderAddRule") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataGridBulkEditDialog>
|
||||
<DataGridInsertRowsDialog v-if="insertRowsDialogMounted" v-model:open="insertRowsDialogOpen" :can-place-at-selection="canPlaceInsertAtSelection" :initial-position="insertPosition" @insert="insertRows" />
|
||||
|
||||
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :preference="selectedCopyPreference" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="copyPreferenceMenuItems" :preview="previewWithPreference" @save="saveExtractorConfiguration" />
|
||||
@@ -12627,6 +12867,18 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="conditionalBulkEditConfirmOpen"
|
||||
:title="t('grid.conditionalBulkEditConfirmTitle')"
|
||||
:message="t('grid.conditionalBulkEditConfirmMessage')"
|
||||
:sql="conditionalBulkEditStatement"
|
||||
:confirm-label="t('grid.conditionalBulkEditConfirm')"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmConditionalBulkEdit"
|
||||
/>
|
||||
<div v-if="isConditionalUpdateActive" class="absolute inset-0 z-50 flex items-center justify-center bg-background/75 backdrop-blur-[1px]">
|
||||
<QueryLoadingState class="p-6" :show-cancel="true" :cancelling="conditionalUpdateExecution?.cancelling" @cancel="cancelRunningConditionalBulkEdit" />
|
||||
</div>
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteRowConfirm"
|
||||
:message="
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -6,18 +7,143 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const value = defineModel<string>("value", { default: "" });
|
||||
defineProps<{ selectedCellCount: number }>();
|
||||
const emit = defineEmits<{ apply: [] }>();
|
||||
const scope = defineModel<"selection" | "condition">("scope", { default: "selection" });
|
||||
const conditionSource = defineModel<"current" | "builder" | "sql">("conditionSource", { default: "builder" });
|
||||
const whereInput = defineModel<string>("whereInput", { default: "" });
|
||||
defineProps<{
|
||||
selectedCellCount: number;
|
||||
conditionalAvailable?: boolean;
|
||||
conditionalColumn?: string;
|
||||
currentWhere?: string;
|
||||
conditionalCount?: number;
|
||||
conditionalCountLoading?: boolean;
|
||||
conditionalCountError?: string;
|
||||
conditionalCountStale?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ apply: []; previewCount: [] }>();
|
||||
|
||||
const dragOffset = ref({ x: 0, y: 0 });
|
||||
const isDragging = ref(false);
|
||||
const dragStartPosition = ref({ x: 0, y: 0 });
|
||||
const dragStartOffset = ref({ x: 0, y: 0 });
|
||||
const activePointerId = ref<number | null>(null);
|
||||
const dialogContentStyle = computed(() => {
|
||||
if (isDragging.value || dragOffset.value.x !== 0 || dragOffset.value.y !== 0) {
|
||||
return {
|
||||
transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`,
|
||||
transition: isDragging.value ? "none" : "transform 0.15s ease-out",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
function resetDialogDragOffset() {
|
||||
dragOffset.value = { x: 0, y: 0 };
|
||||
isDragging.value = false;
|
||||
activePointerId.value = null;
|
||||
}
|
||||
|
||||
function startDialogDrag(event: PointerEvent) {
|
||||
if (event.button !== undefined && event.button !== 0) return;
|
||||
isDragging.value = true;
|
||||
activePointerId.value = event.pointerId;
|
||||
dragStartPosition.value = { x: event.clientX, y: event.clientY };
|
||||
dragStartOffset.value = { ...dragOffset.value };
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function moveDialogDrag(event: PointerEvent) {
|
||||
if (!isDragging.value || event.pointerId !== activePointerId.value) return;
|
||||
dragOffset.value = {
|
||||
x: dragStartOffset.value.x + event.clientX - dragStartPosition.value.x,
|
||||
y: dragStartOffset.value.y + event.clientY - dragStartPosition.value.y,
|
||||
};
|
||||
}
|
||||
|
||||
function endDialogDrag(event: PointerEvent) {
|
||||
if (!isDragging.value || event.pointerId !== activePointerId.value) return;
|
||||
isDragging.value = false;
|
||||
activePointerId.value = null;
|
||||
try {
|
||||
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture may already have been released by the browser.
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
void nextTick(resetDialogDragOffset);
|
||||
} else {
|
||||
resetDialogDragOffset();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader
|
||||
<DialogContent :style="dialogContentStyle" class="sm:max-w-[680px]">
|
||||
<DialogHeader class="cursor-move select-none" @pointerdown="startDialogDrag" @pointermove="moveDialogDrag" @pointerup="endDialogDrag" @pointercancel="endDialogDrag"
|
||||
><DialogTitle>{{ t("grid.bulkEditTitle") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">{{ t("grid.bulkEditDescription", { count: selectedCellCount }) }}</p>
|
||||
<div v-if="conditionalAvailable" class="grid gap-2 pb-1">
|
||||
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="scope === 'selection' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="scope = 'selection'">
|
||||
<span class="block text-sm font-medium">{{ t("grid.bulkEditSelectionScope") }}</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditSelectionScopeDescription", { count: selectedCellCount }) }}</span>
|
||||
</button>
|
||||
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="scope === 'condition' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="scope = 'condition'">
|
||||
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalScope") }}</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalScopeDescription", { column: conditionalColumn }) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted-foreground">{{ t("grid.bulkEditDescription", { count: selectedCellCount }) }}</p>
|
||||
<div v-if="scope === 'condition'" class="space-y-2">
|
||||
<div class="text-sm font-medium">{{ t("grid.bulkEditConditionalConditionSource") }}</div>
|
||||
<div class="grid gap-2" :class="currentWhere ? 'sm:grid-cols-3' : 'sm:grid-cols-2'">
|
||||
<button v-if="currentWhere" type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="conditionSource === 'current' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="conditionSource = 'current'">
|
||||
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalUseCurrentFilter") }}</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalUseCurrentFilterDescription") }}</span>
|
||||
</button>
|
||||
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="conditionSource === 'builder' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="conditionSource = 'builder'">
|
||||
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalBuildFilter") }}</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalBuildFilterDescription") }}</span>
|
||||
</button>
|
||||
<button type="button" class="rounded-md border px-3 py-2 text-left transition-colors" :class="conditionSource === 'sql' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'" @click="conditionSource = 'sql'">
|
||||
<span class="block text-sm font-medium">{{ t("grid.bulkEditConditionalAdvancedWhere") }}</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalAdvancedWhereDescription") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="conditionSource === 'current' && currentWhere" class="rounded-md bg-muted/60 px-2.5 py-2">
|
||||
<code class="block max-h-20 overflow-auto whitespace-pre-wrap break-all text-xs">{{ currentWhere }}</code>
|
||||
</div>
|
||||
<div v-else-if="conditionSource === 'builder'" class="max-h-72 overflow-auto rounded-md border bg-muted/10 p-2">
|
||||
<slot name="condition-builder" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<label class="text-sm font-medium" for="bulk-edit-where-input">{{ t("grid.bulkEditConditionalWhere") }}</label>
|
||||
<textarea
|
||||
id="bulk-edit-where-input"
|
||||
v-model="whereInput"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
rows="3"
|
||||
class="dbx-data-grid-value-font min-h-20 w-full min-w-0 resize-y rounded-[6px] border border-input bg-transparent px-2.5 py-1.5 text-base outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm"
|
||||
:placeholder="t('grid.bulkEditConditionalWherePlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t("grid.bulkEditConditionalWhereDescription") }}</p>
|
||||
</template>
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" :disabled="conditionalCountLoading" @click="emit('previewCount')">
|
||||
{{ conditionalCountLoading ? t("grid.conditionalBulkEditPreviewingCount") : t("grid.conditionalBulkEditPreviewCount") }}
|
||||
</Button>
|
||||
<span v-if="conditionalCount !== undefined" class="text-sm font-medium text-foreground">{{ t("grid.conditionalBulkEditPreviewCountResult", { count: conditionalCount }) }}</span>
|
||||
<span v-else-if="conditionalCountStale" class="text-xs text-muted-foreground">{{ t("grid.conditionalBulkEditPreviewCountStale") }}</span>
|
||||
</div>
|
||||
<p v-if="conditionalCountError" class="text-xs text-destructive">{{ conditionalCountError }}</p>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="value"
|
||||
autocapitalize="off"
|
||||
|
||||
Reference in new issue
Block a user