feat(grid): improve WHERE condition completion
This commit is contained in:
parent
d163136b6f
commit
c68a5adb25
|
|
@ -216,7 +216,7 @@ import { supportsTableStructureEditing } from "@/lib/database/databaseCapabiliti
|
|||
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
import { restoreDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { dataGridConditionColumnOptions } from "@/lib/dataGrid/dataGridConditionCompletion";
|
||||
import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion";
|
||||
import { isMacOS } from "@/lib/backend/platform";
|
||||
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||||
|
|
@ -615,6 +615,7 @@ const { searchText, deferredSearchText: deferredClientSearchText, overlayVisible
|
|||
const orderByInput = ref(props.initialOrderByInput ?? "");
|
||||
const whereFilterInput = ref(props.initialWhereInput ?? "");
|
||||
const conditionColumns = computed(() => dataGridConditionColumnOptions(props.tableMeta?.columns ?? props.result.columns, resolvedDatabaseType.value));
|
||||
const conditionIdentifierQuote = computed(() => dataGridConditionIdentifierQuote(resolvedDatabaseType.value, connectionStore.connectionIdentifierQuote?.(props.connectionId)));
|
||||
const conditionHistoryScope = computed(() => ({
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
|
|
@ -7774,6 +7775,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
v-model:filter-builder-open="filterBuilderOpen"
|
||||
:columns="props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns"
|
||||
:condition-columns="conditionColumns"
|
||||
:identifier-quote="conditionIdentifierQuote"
|
||||
:history-scope="conditionHistoryScope"
|
||||
:can-use-where-search="canUseWhereSearch"
|
||||
:compact="compactDataGridToolbar"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch, type CSSProperties } from "vue";
|
||||
import { ChevronDown, X } from "@lucide/vue";
|
||||
import { useDataGridConditionEditor, type DataGridConditionColumnOption, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
|
||||
import { completeDataGridConditionQuote, useDataGridConditionEditor, type DataGridConditionColumnOption, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
|
||||
import { getDataGridConditionSuggestionPosition, getDataGridConditionSuggestionPreferredWidth } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
|
||||
import type { DataGridConditionHistoryKind, DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ const props = withDefaults(
|
|||
ariaLabel?: string;
|
||||
historyEmptyText?: string;
|
||||
historyNoMatchesText?: string;
|
||||
identifierQuote?: string;
|
||||
suggestionProvider?: DataGridConditionSuggestionProvider;
|
||||
suggestionDebounceMs?: number;
|
||||
disabled?: boolean;
|
||||
|
|
@ -39,6 +40,8 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const inputRef = ref<HTMLTextAreaElement>();
|
||||
const selectionStart = ref(modelValue.value.length);
|
||||
const selectionEnd = ref(modelValue.value.length);
|
||||
const suggestionListId = `${useId()}-${props.kind}-condition-suggestions`;
|
||||
const overlayRef = ref<HTMLTextAreaElement>();
|
||||
const controlRef = ref<HTMLDivElement>();
|
||||
|
|
@ -56,6 +59,9 @@ let expandAfterComposition = false;
|
|||
const editor = useDataGridConditionEditor({
|
||||
kind: props.kind,
|
||||
value: modelValue,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
identifierQuote: () => props.identifierQuote,
|
||||
columns: () => props.columns,
|
||||
historyScope: () => props.historyScope,
|
||||
suggestionProvider: props.suggestionProvider,
|
||||
|
|
@ -166,9 +172,9 @@ function resizeEditor(forceExpand = false) {
|
|||
void nextTick(() => {
|
||||
const overlay = overlayRef.value;
|
||||
if (!overlay || composing.value) return;
|
||||
const start = input.selectionStart;
|
||||
syncSelection(input);
|
||||
overlay.focus();
|
||||
overlay.setSelectionRange(start, start);
|
||||
overlay.setSelectionRange(selectionStart.value, selectionEnd.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -188,7 +194,31 @@ function onCompositionEnd() {
|
|||
function focus(select = false) {
|
||||
const target = activeEditor.value ?? inputRef.value;
|
||||
target?.focus();
|
||||
if (select) target?.select();
|
||||
if (select) {
|
||||
target?.select();
|
||||
if (target) syncSelection(target);
|
||||
} else {
|
||||
target?.setSelectionRange(selectionStart.value, selectionEnd.value);
|
||||
}
|
||||
resizeEditor(true);
|
||||
}
|
||||
|
||||
function syncSelection(target: HTMLTextAreaElement) {
|
||||
selectionStart.value = target.selectionStart;
|
||||
selectionEnd.value = target.selectionEnd;
|
||||
}
|
||||
|
||||
function onFocus(event: FocusEvent) {
|
||||
syncSelection(event.currentTarget as HTMLTextAreaElement);
|
||||
resizeEditor(true);
|
||||
}
|
||||
|
||||
function onSelectionChange(event: Event) {
|
||||
syncSelection(event.currentTarget as HTMLTextAreaElement);
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
onSelectionChange(event);
|
||||
resizeEditor(true);
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +231,8 @@ function scheduleCollapse() {
|
|||
}, 0);
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
function onInput(event: Event) {
|
||||
syncSelection(event.currentTarget as HTMLTextAreaElement);
|
||||
resizeEditor(true);
|
||||
updateSuggestionPosition();
|
||||
}
|
||||
|
|
@ -221,11 +252,29 @@ async function clearCondition() {
|
|||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (completeQuote(event)) return;
|
||||
const action = editor.handleKeydown(event);
|
||||
if (action === "apply") void applyCondition();
|
||||
if (action === "accept") void nextTick(() => focus());
|
||||
}
|
||||
|
||||
function completeQuote(event: KeyboardEvent) {
|
||||
if (props.kind !== "where" || (event.key !== "'" && event.key !== '"') || event.isComposing || event.keyCode === 229 || event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
const target = event.currentTarget as HTMLTextAreaElement;
|
||||
const completion = completeDataGridConditionQuote(modelValue.value, target.selectionStart, target.selectionEnd, event.key);
|
||||
event.preventDefault();
|
||||
modelValue.value = completion.value;
|
||||
selectionStart.value = completion.selectionStart;
|
||||
selectionEnd.value = completion.selectionEnd;
|
||||
editor.dismiss();
|
||||
void nextTick(() => {
|
||||
const active = activeEditor.value;
|
||||
active?.focus();
|
||||
active?.setSelectionRange(completion.selectionStart, completion.selectionEnd);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
editor.openHistory();
|
||||
updateSuggestionPosition();
|
||||
|
|
@ -340,9 +389,11 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
|
|||
class="data-grid-topbar-condition-input absolute inset-x-0 top-0 h-6 min-w-0 resize-none bg-transparent outline-none"
|
||||
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
|
||||
style="height: 24px"
|
||||
@focus="resizeEditor(true)"
|
||||
@focus="onFocus"
|
||||
@blur="scheduleCollapse"
|
||||
@click="resizeEditor(true)"
|
||||
@click="onClick"
|
||||
@select="onSelectionChange"
|
||||
@keyup="onSelectionChange"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionend="onCompositionEnd"
|
||||
@input="onInput"
|
||||
|
|
@ -373,6 +424,10 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
|
|||
class="data-grid-topbar-condition-input data-grid-topbar-condition-input--expanded absolute resize-none outline-none"
|
||||
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
|
||||
@blur="scheduleCollapse"
|
||||
@focus="onFocus"
|
||||
@click="onSelectionChange"
|
||||
@select="onSelectionChange"
|
||||
@keyup="onSelectionChange"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionend="onCompositionEnd"
|
||||
@input="onInput"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const props = defineProps<{
|
|||
orderByInput: string;
|
||||
columns: readonly string[];
|
||||
conditionColumns: readonly DataGridConditionColumnOption[];
|
||||
identifierQuote?: string;
|
||||
historyScope: DataGridConditionHistoryScope;
|
||||
canUseWhereSearch: boolean;
|
||||
compact: boolean;
|
||||
|
|
@ -179,6 +180,7 @@ onUnmounted(onResizeEnd);
|
|||
:model-value="whereInput"
|
||||
kind="where"
|
||||
:columns="conditionColumns"
|
||||
:identifier-quote="identifierQuote"
|
||||
:history-scope="historyScope"
|
||||
placeholder="WHERE"
|
||||
:history-empty-text="t('grid.conditionHistoryEmpty')"
|
||||
|
|
@ -204,6 +206,7 @@ onUnmounted(onResizeEnd);
|
|||
:model-value="orderByInput"
|
||||
kind="orderBy"
|
||||
:columns="conditionColumns"
|
||||
:identifier-quote="identifierQuote"
|
||||
:history-scope="historyScope"
|
||||
placeholder="ORDER BY"
|
||||
:history-empty-text="t('grid.conditionHistoryEmpty')"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import DataGridConditionEditor from "@/components/grid/DataGridConditionEditor.vue";
|
||||
import type { DataGridConditionHistoryKind } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
function mountEditor(kind: DataGridConditionHistoryKind, initialValue: string, options: { columns?: string[]; identifierQuote?: string } = {}) {
|
||||
const value = ref(initialValue);
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(DataGridConditionEditor, {
|
||||
kind,
|
||||
modelValue: value.value,
|
||||
"onUpdate:modelValue": (nextValue: string) => (value.value = nextValue),
|
||||
historyScope: {},
|
||||
columns: options.columns,
|
||||
identifierQuote: options.identifierQuote,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
return { value, input: host.querySelector("textarea") as HTMLTextAreaElement };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("DataGridConditionEditor quote completion", () => {
|
||||
it("inserts paired quotes in WHERE and places the caret between them", async () => {
|
||||
const { value, input } = mountEditor("where", "id = ");
|
||||
input.focus();
|
||||
input.setSelectionRange(5, 5);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "'", bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(value.value).toBe("id = ''");
|
||||
expect(input.selectionStart).toBe(6);
|
||||
expect(input.selectionEnd).toBe(6);
|
||||
});
|
||||
|
||||
it("wraps selected WHERE text and skips an existing closing quote", async () => {
|
||||
const { value, input } = mountEditor("where", "name");
|
||||
input.focus();
|
||||
input.setSelectionRange(0, 4);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: '"', bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(value.value).toBe('"name"');
|
||||
expect(input.selectionStart).toBe(1);
|
||||
expect(input.selectionEnd).toBe(5);
|
||||
|
||||
input.setSelectionRange(5, 5);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: '"', bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
expect(value.value).toBe('"name"');
|
||||
expect(input.selectionStart).toBe(6);
|
||||
});
|
||||
|
||||
it("does not intercept quotes in ORDER BY", () => {
|
||||
const { value, input } = mountEditor("orderBy", "name");
|
||||
input.focus();
|
||||
input.setSelectionRange(4, 4);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: '"', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(value.value).toBe("name");
|
||||
});
|
||||
|
||||
it("passes the textarea caret range through when accepting a suggestion", async () => {
|
||||
const { value, input } = mountEditor("where", "status = cus AND enabled = 1", { columns: ["customer_id"] });
|
||||
input.focus();
|
||||
input.setSelectionRange(12, 12);
|
||||
input.dispatchEvent(new Event("select", { bubbles: true }));
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="option"]')?.textContent).toContain("customer_id"));
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(value.value).toBe("status = customer_id AND enabled = 1");
|
||||
expect(input.selectionStart).toBe(20);
|
||||
expect(input.selectionEnd).toBe(20);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { effectScope, nextTick, ref } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
|
||||
import { completeDataGridConditionQuote, useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
|
||||
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
|
|
@ -41,6 +41,155 @@ describe("useDataGridConditionEditor", () => {
|
|||
expect(value.value).toBe("status = customer_name");
|
||||
});
|
||||
|
||||
it("builds and applies suggestions at the current caret instead of the value end", async () => {
|
||||
const value = ref("");
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind: "where",
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
columns: ["customer_id", "customer_name"],
|
||||
historyScope: {},
|
||||
});
|
||||
|
||||
value.value = "status = cus AND enabled = 1";
|
||||
selectionStart.value = 12;
|
||||
selectionEnd.value = 12;
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["customer_id", "customer_name"]));
|
||||
|
||||
expect(editor.accept(0)).toBe(true);
|
||||
expect(value.value).toBe("status = customer_id AND enabled = 1");
|
||||
expect(selectionStart.value).toBe(20);
|
||||
expect(selectionEnd.value).toBe(20);
|
||||
});
|
||||
|
||||
it("uses the current selection as an explicit replacement range", async () => {
|
||||
const value = ref("");
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const editor = useDataGridConditionEditor({ kind: "where", value, selectionStart, selectionEnd, columns: ["old_value"], historyScope: {} });
|
||||
|
||||
value.value = "status = old AND enabled = 1";
|
||||
selectionStart.value = 9;
|
||||
selectionEnd.value = 12;
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["old_value"]));
|
||||
|
||||
expect(editor.accept()).toBe(true);
|
||||
expect(value.value).toBe("status = old_value AND enabled = 1");
|
||||
});
|
||||
|
||||
it("suggests WHERE connectors after a completed expression", async () => {
|
||||
const value = ref("");
|
||||
const editor = useDataGridConditionEditor({ kind: "where", value, columns: ["account_id", "owner_id"], historyScope: {} });
|
||||
|
||||
value.value = "owner_id = 1 a";
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value).toEqual([{ value: "AND", kind: "keyword" }]));
|
||||
expect(editor.accept()).toBe(true);
|
||||
expect(value.value).toBe("owner_id = 1 AND");
|
||||
await nextTick();
|
||||
|
||||
value.value = "owner_id = 1 o";
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value).toEqual([{ value: "OR", kind: "keyword" }]));
|
||||
});
|
||||
|
||||
it("suggests fields after a WHERE connector even when the active token is empty", async () => {
|
||||
const value = ref("");
|
||||
const editor = useDataGridConditionEditor({ kind: "where", value, columns: ["account_id", "owner_id"], historyScope: {} });
|
||||
|
||||
value.value = "status = 'active' AND ";
|
||||
await nextTick();
|
||||
await vi.waitFor(() =>
|
||||
expect(editor.suggestions.value).toEqual([
|
||||
{ value: "account_id", kind: "column" },
|
||||
{ value: "owner_id", kind: "column" },
|
||||
]),
|
||||
);
|
||||
expect(editor.accept(1)).toBe(true);
|
||||
expect(value.value).toBe("status = 'active' AND owner_id");
|
||||
});
|
||||
|
||||
it("does not offer connectors inside quoted values or in ORDER BY", async () => {
|
||||
const whereValue = ref("");
|
||||
const whereEditor = useDataGridConditionEditor({ kind: "where", value: whereValue, columns: ["name"], historyScope: {} });
|
||||
whereValue.value = "name = 'Alice a";
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(whereEditor.suggestions.value).toEqual([]));
|
||||
|
||||
const orderByValue = ref("");
|
||||
const orderByEditor = useDataGridConditionEditor({ kind: "orderBy", value: orderByValue, columns: ["amount"], historyScope: {} });
|
||||
orderByValue.value = "created_at a";
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(orderByEditor.suggestions.value).toEqual([]));
|
||||
});
|
||||
|
||||
it.each(["deleted_at IS ", "deleted_at IS a", "deleted_at IS NOT o", "name LIKE o", "id IN a", "score BETWEEN o"])("does not offer connectors while the keyword operator is incomplete: %s", async (condition) => {
|
||||
const value = ref("");
|
||||
const editor = useDataGridConditionEditor({ kind: "where", value, columns: ["account_id"], historyScope: {}, suggestionDebounceMs: 1 });
|
||||
|
||||
value.value = condition;
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(editor.suggestions.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("completes inside dialect quoted identifiers without treating them as strings", async () => {
|
||||
const value = ref("");
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind: "where",
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
identifierQuote: '"',
|
||||
columns: [{ name: "name", insertText: '"name"' }],
|
||||
historyScope: {},
|
||||
});
|
||||
|
||||
value.value = '"na" = 1';
|
||||
selectionStart.value = 3;
|
||||
selectionEnd.value = 3;
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["name"]));
|
||||
expect(editor.accept()).toBe(true);
|
||||
expect(value.value).toBe('"name" = 1');
|
||||
});
|
||||
|
||||
it("keeps double quotes as string delimiters when the dialect uses another identifier quote", async () => {
|
||||
const value = ref("");
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind: "where",
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
identifierQuote: "`",
|
||||
columns: ["name"],
|
||||
historyScope: {},
|
||||
suggestionDebounceMs: 1,
|
||||
});
|
||||
|
||||
value.value = '"na" = 1';
|
||||
selectionStart.value = 3;
|
||||
selectionEnd.value = 3;
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(editor.suggestions.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("pairs WHERE quotes, wraps selections, and skips an existing closing quote", () => {
|
||||
expect(completeDataGridConditionQuote("id = ", 5, 5, "'")).toEqual({ value: "id = ''", selectionStart: 6, selectionEnd: 6 });
|
||||
expect(completeDataGridConditionQuote("name", 0, 4, '"')).toEqual({ value: '"name"', selectionStart: 1, selectionEnd: 5 });
|
||||
expect(completeDataGridConditionQuote("id = ''", 6, 6, "'")).toEqual({ value: "id = ''", selectionStart: 7, selectionEnd: 7 });
|
||||
});
|
||||
|
||||
it("reuses column comments for field suggestions without adding them to history", async () => {
|
||||
const scope = { connectionId: "connection", database: "db", tableName: "users" };
|
||||
rememberDataGridConditionHistory("where", scope, "customer_id = 1");
|
||||
|
|
@ -161,6 +310,32 @@ describe("useDataGridConditionEditor", () => {
|
|||
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
|
||||
});
|
||||
|
||||
it("passes the cursor text and replacement range to asynchronous providers", async () => {
|
||||
const value = ref("");
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const suggestionProvider = vi.fn(() => ["customer_id"]);
|
||||
useDataGridConditionEditor({ kind: "where", value, selectionStart, selectionEnd, historyScope: {}, suggestionProvider });
|
||||
|
||||
value.value = "status = cus AND enabled = 1";
|
||||
selectionStart.value = 12;
|
||||
selectionEnd.value = 12;
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(suggestionProvider).toHaveBeenCalledOnce());
|
||||
|
||||
expect(suggestionProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: "status = cus AND enabled = 1",
|
||||
valueBeforeCursor: "status = cus",
|
||||
token: "cus",
|
||||
from: 9,
|
||||
to: 12,
|
||||
selectionStart: 12,
|
||||
selectionEnd: 12,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads, filters, accepts, and deletes scoped history", () => {
|
||||
const scope = { connectionId: "connection", database: "db", tableName: "users" };
|
||||
rememberDataGridConditionHistory("where", scope, "status = 'active'");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
|
||||
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
export type DataGridConditionSuggestionKind = "column" | "history";
|
||||
export type DataGridConditionSuggestionKind = "column" | "keyword" | "history";
|
||||
|
||||
export interface DataGridConditionColumnSuggestion {
|
||||
name: string;
|
||||
|
|
@ -21,7 +21,12 @@ export interface DataGridConditionSuggestion {
|
|||
export interface DataGridConditionSuggestionContext {
|
||||
kind: DataGridConditionHistoryKind;
|
||||
value: string;
|
||||
valueBeforeCursor: string;
|
||||
token: string;
|
||||
from: number;
|
||||
to: number;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +35,9 @@ export type DataGridConditionSuggestionProvider = (context: DataGridConditionSug
|
|||
export interface UseDataGridConditionEditorOptions {
|
||||
kind: DataGridConditionHistoryKind;
|
||||
value: Ref<string>;
|
||||
selectionStart?: Ref<number>;
|
||||
selectionEnd?: Ref<number>;
|
||||
identifierQuote?: MaybeRefOrGetter<string | undefined>;
|
||||
columns?: MaybeRefOrGetter<readonly DataGridConditionColumnOption[] | undefined>;
|
||||
historyScope: MaybeRefOrGetter<DataGridConditionHistoryScope>;
|
||||
suggestionProvider?: DataGridConditionSuggestionProvider;
|
||||
|
|
@ -39,25 +47,152 @@ export interface UseDataGridConditionEditorOptions {
|
|||
|
||||
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
|
||||
const ORDER_BY_TOKEN_PATTERN = /([^\s,()]+)$/;
|
||||
const WHERE_TOKEN_FORWARD_PATTERN = /^([^\s,()><=!&|]+)/;
|
||||
const ORDER_BY_TOKEN_FORWARD_PATTERN = /^([^\s,()]+)/;
|
||||
const WHERE_CONNECTOR_KEYWORDS = ["AND", "OR"] as const;
|
||||
const WHERE_VALUE_OPERATOR_PATTERN = /(?:^|[\s(])(?:IS(?:\s+NOT)?|(?:NOT\s+)?(?:LIKE|ILIKE|IN|BETWEEN)|SIMILAR\s+TO|REGEXP|RLIKE|GLOB|MATCH)\s*$/i;
|
||||
|
||||
interface DataGridConditionCompletionTarget {
|
||||
value: string;
|
||||
valueBeforeCursor: string;
|
||||
token: string;
|
||||
from: number;
|
||||
to: number;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
quotedIdentifier: boolean;
|
||||
insideString: boolean;
|
||||
}
|
||||
|
||||
interface ActiveQuote {
|
||||
kind: "identifier" | "string";
|
||||
close: string;
|
||||
contentStart: number;
|
||||
}
|
||||
|
||||
function normalizedColumnComment(column: DataGridConditionColumnOption): string | undefined {
|
||||
if (typeof column === "string" || typeof column.comment !== "string") return undefined;
|
||||
return column.comment.trim() || undefined;
|
||||
}
|
||||
|
||||
function activeToken(kind: DataGridConditionHistoryKind, value: string): string {
|
||||
return (
|
||||
value
|
||||
.trim()
|
||||
.split(kind === "where" ? /[\s,()><=!&|]+/ : /[\s,()]+/)
|
||||
.pop() ?? ""
|
||||
);
|
||||
function normalizedIdentifierQuote(identifierQuote: string | undefined): string | undefined {
|
||||
const quote = identifierQuote?.trim();
|
||||
return quote && quote !== "'" ? quote : undefined;
|
||||
}
|
||||
|
||||
function replaceActiveToken(kind: DataGridConditionHistoryKind, value: string, replacement: string): string {
|
||||
const match = value.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN);
|
||||
if (!match) return value;
|
||||
return `${value.slice(0, -match[1].length)}${replacement}`;
|
||||
function identifierCloseQuote(open: string): string {
|
||||
return open === "[" ? "]" : open;
|
||||
}
|
||||
|
||||
function activeQuoteAt(value: string, cursor: number, identifierQuote: string | undefined): ActiveQuote | undefined {
|
||||
const identifierOpen = normalizedIdentifierQuote(identifierQuote);
|
||||
const identifierClose = identifierOpen ? identifierCloseQuote(identifierOpen) : undefined;
|
||||
let active: ActiveQuote | undefined;
|
||||
for (let index = 0; index < cursor; index += 1) {
|
||||
const character = value[index];
|
||||
if (!active) {
|
||||
if (identifierOpen && value.startsWith(identifierOpen, index)) {
|
||||
active = { kind: "identifier", close: identifierClose!, contentStart: index + identifierOpen.length };
|
||||
index += identifierOpen.length - 1;
|
||||
} else if (character === "'" || (character === '"' && identifierOpen !== '"')) {
|
||||
active = { kind: "string", close: character, contentStart: index + 1 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (active.kind === "string" && character === "\\") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!value.startsWith(active.close, index)) continue;
|
||||
if (value.startsWith(active.close + active.close, index) && index + active.close.length * 2 <= cursor) {
|
||||
index += active.close.length * 2 - 1;
|
||||
continue;
|
||||
}
|
||||
index += active.close.length - 1;
|
||||
active = undefined;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function clampedSelection(value: string, selectionStart: number | undefined, selectionEnd: number | undefined): { start: number; end: number } {
|
||||
const start = Math.min(Math.max(selectionStart ?? value.length, 0), value.length);
|
||||
const end = Math.min(Math.max(selectionEnd ?? start, start), value.length);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function conditionCompletionTarget(kind: DataGridConditionHistoryKind, value: string, selectionStart: number | undefined, selectionEnd: number | undefined, identifierQuote: string | undefined): DataGridConditionCompletionTarget {
|
||||
const selection = clampedSelection(value, selectionStart, selectionEnd);
|
||||
const valueBeforeCursor = value.slice(0, selection.start);
|
||||
const quote = kind === "where" ? activeQuoteAt(value, selection.start, identifierQuote) : undefined;
|
||||
if (quote?.kind === "string") {
|
||||
return { value, valueBeforeCursor, token: "", from: selection.start, to: selection.end, selectionStart: selection.start, selectionEnd: selection.end, quotedIdentifier: false, insideString: true };
|
||||
}
|
||||
if (quote?.kind === "identifier") {
|
||||
const closeIndex = selection.start === selection.end ? value.indexOf(quote.close, selection.start) : -1;
|
||||
return {
|
||||
value,
|
||||
valueBeforeCursor,
|
||||
token: selection.start === selection.end ? value.slice(quote.contentStart, selection.start) : value.slice(selection.start, selection.end),
|
||||
from: selection.start === selection.end ? quote.contentStart : selection.start,
|
||||
to: selection.start === selection.end && closeIndex >= 0 ? closeIndex : selection.end,
|
||||
selectionStart: selection.start,
|
||||
selectionEnd: selection.end,
|
||||
quotedIdentifier: true,
|
||||
insideString: false,
|
||||
};
|
||||
}
|
||||
if (selection.start !== selection.end) {
|
||||
return {
|
||||
value,
|
||||
valueBeforeCursor,
|
||||
token: value.slice(selection.start, selection.end),
|
||||
from: selection.start,
|
||||
to: selection.end,
|
||||
selectionStart: selection.start,
|
||||
selectionEnd: selection.end,
|
||||
quotedIdentifier: false,
|
||||
insideString: false,
|
||||
};
|
||||
}
|
||||
const beforeMatch = valueBeforeCursor.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN);
|
||||
const token = beforeMatch?.[1] ?? "";
|
||||
const afterMatch = value.slice(selection.start).match(kind === "where" ? WHERE_TOKEN_FORWARD_PATTERN : ORDER_BY_TOKEN_FORWARD_PATTERN);
|
||||
return {
|
||||
value,
|
||||
valueBeforeCursor,
|
||||
token,
|
||||
from: selection.start - token.length,
|
||||
to: selection.start + (afterMatch?.[1].length ?? 0),
|
||||
selectionStart: selection.start,
|
||||
selectionEnd: selection.end,
|
||||
quotedIdentifier: false,
|
||||
insideString: false,
|
||||
};
|
||||
}
|
||||
|
||||
function whereSuggestionRole(target: DataGridConditionCompletionTarget): "field" | "connector" | "none" {
|
||||
if (target.insideString) return "none";
|
||||
if (target.quotedIdentifier) return "field";
|
||||
const prefix = target.value.slice(0, target.from).trimEnd();
|
||||
if (WHERE_VALUE_OPERATOR_PATTERN.test(prefix)) return "none";
|
||||
if (!prefix || /(?:^|\s)(?:AND|OR|NOT)$/i.test(prefix) || /[,(<>=!~+\-*/]$/.test(prefix)) return "field";
|
||||
return "connector";
|
||||
}
|
||||
|
||||
export interface DataGridConditionQuoteCompletion {
|
||||
value: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export function completeDataGridConditionQuote(value: string, selectionStart: number, selectionEnd: number, quote: "'" | '"'): DataGridConditionQuoteCompletion {
|
||||
if (selectionStart === selectionEnd && value[selectionStart] === quote) {
|
||||
return { value, selectionStart: selectionStart + 1, selectionEnd: selectionStart + 1 };
|
||||
}
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const nextValue = `${value.slice(0, selectionStart)}${quote}${selected}${quote}${value.slice(selectionEnd)}`;
|
||||
if (selected) return { value: nextValue, selectionStart: selectionStart + 1, selectionEnd: selectionEnd + 1 };
|
||||
return { value: nextValue, selectionStart: selectionStart + 1, selectionEnd: selectionStart + 1 };
|
||||
}
|
||||
|
||||
export function useDataGridConditionEditor(options: UseDataGridConditionEditorOptions) {
|
||||
|
|
@ -65,6 +200,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
const highlightedIndex = ref(-1);
|
||||
const historyOpen = ref(false);
|
||||
const suggestionsLoading = ref(false);
|
||||
const replacementRange = ref<{ from: number; to: number }>();
|
||||
let suggestionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let suggestionRequestId = 0;
|
||||
let suggestionAbortController: AbortController | undefined;
|
||||
|
|
@ -86,35 +222,48 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
suggestions.value = [];
|
||||
highlightedIndex.value = -1;
|
||||
historyOpen.value = false;
|
||||
replacementRange.value = undefined;
|
||||
}
|
||||
|
||||
function defaultSuggestions(token: string): DataGridConditionSuggestion[] {
|
||||
const normalizedToken = token.toLowerCase();
|
||||
if (!normalizedToken) return [];
|
||||
function defaultSuggestions(target: DataGridConditionCompletionTarget): DataGridConditionSuggestion[] {
|
||||
const role = options.kind === "where" ? whereSuggestionRole(target) : "field";
|
||||
if (role === "none") return [];
|
||||
const normalizedToken = target.token.toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
const suggestions: DataGridConditionSuggestion[] = [];
|
||||
for (const column of toValue(options.columns) ?? []) {
|
||||
const value = typeof column === "string" ? column : column.name;
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (!normalizedValue.startsWith(normalizedToken) || normalizedValue === normalizedToken || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
const comment = normalizedColumnComment(column);
|
||||
const insertText = typeof column === "string" ? value : column.insertText;
|
||||
suggestions.push({ value, kind: "column", ...(insertText !== undefined && insertText !== value ? { insertText } : {}), ...(comment ? { comment } : {}) });
|
||||
if (role === "field") {
|
||||
for (const column of toValue(options.columns) ?? []) {
|
||||
const columnValue = typeof column === "string" ? column : column.name;
|
||||
const normalizedValue = columnValue.toLowerCase();
|
||||
if ((normalizedToken && (!normalizedValue.startsWith(normalizedToken) || normalizedValue === normalizedToken)) || seen.has(columnValue)) continue;
|
||||
seen.add(columnValue);
|
||||
const comment = normalizedColumnComment(column);
|
||||
const insertText = target.quotedIdentifier ? columnValue : typeof column === "string" ? columnValue : column.insertText;
|
||||
suggestions.push({ value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) });
|
||||
}
|
||||
} else {
|
||||
for (const keyword of WHERE_CONNECTOR_KEYWORDS) {
|
||||
if (!keyword.toLowerCase().startsWith(normalizedToken) || keyword.toLowerCase() === normalizedToken) continue;
|
||||
suggestions.push({ value: keyword, kind: "keyword" });
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function loadSuggestions(value: string, requestId: number, controller: AbortController) {
|
||||
const token = activeToken(options.kind, value);
|
||||
if (!token) return;
|
||||
async function loadSuggestions(target: DataGridConditionCompletionTarget, requestId: number, controller: AbortController) {
|
||||
if (!target.token && (options.kind !== "where" || !target.value.trim())) return;
|
||||
suggestionsLoading.value = true;
|
||||
try {
|
||||
const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : undefined;
|
||||
const role = options.kind === "where" ? whereSuggestionRole(target) : "field";
|
||||
const values =
|
||||
options.suggestionProvider && target.token && role === "field"
|
||||
? await options.suggestionProvider({ kind: options.kind, value: target.value, valueBeforeCursor: target.valueBeforeCursor, token: target.token, from: target.from, to: target.to, selectionStart: target.selectionStart, selectionEnd: target.selectionEnd, signal: controller.signal })
|
||||
: undefined;
|
||||
// A slower request must never replace suggestions for a newer editor value.
|
||||
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== value || historyOpen.value) return;
|
||||
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== target.value || historyOpen.value) return;
|
||||
const limit = options.suggestionLimit ?? 8;
|
||||
suggestions.value = values ? [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" })) : defaultSuggestions(token).slice(0, limit);
|
||||
suggestions.value = values ? [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" })) : defaultSuggestions(target).slice(0, limit);
|
||||
replacementRange.value = { from: target.from, to: target.to };
|
||||
highlightedIndex.value = suggestions.value.length > 0 ? 0 : -1;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted && requestId === suggestionRequestId) {
|
||||
|
|
@ -127,19 +276,21 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
}
|
||||
}
|
||||
|
||||
function scheduleSuggestions(value: string) {
|
||||
function scheduleSuggestions(value: string, selectionStart = options.selectionStart?.value, selectionEnd = options.selectionEnd?.value) {
|
||||
cancelSuggestionRequest();
|
||||
suggestions.value = [];
|
||||
highlightedIndex.value = -1;
|
||||
historyOpen.value = false;
|
||||
if (!value.trim()) return;
|
||||
|
||||
const target = conditionCompletionTarget(options.kind, value, selectionStart, selectionEnd, toValue(options.identifierQuote));
|
||||
|
||||
const requestId = suggestionRequestId;
|
||||
const controller = new AbortController();
|
||||
suggestionAbortController = controller;
|
||||
suggestionTimer = setTimeout(() => {
|
||||
suggestionTimer = undefined;
|
||||
void loadSuggestions(value, requestId, controller);
|
||||
void loadSuggestions(target, requestId, controller);
|
||||
}, options.suggestionDebounceMs ?? 0);
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +301,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
return;
|
||||
}
|
||||
historyOpen.value = true;
|
||||
replacementRange.value = undefined;
|
||||
suggestions.value = loadDataGridConditionHistory(options.kind, toValue(options.historyScope), options.value.value).map((value) => ({ value, kind: "history" }));
|
||||
highlightedIndex.value = -1;
|
||||
}
|
||||
|
|
@ -179,9 +331,22 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
function accept(index = highlightedIndex.value) {
|
||||
const suggestion = suggestions.value[index];
|
||||
if (!suggestion) return false;
|
||||
suppressNextValueSuggestion = true;
|
||||
// History is already executable SQL; only fresh column suggestions apply dialect quoting.
|
||||
options.value.value = suggestion.kind === "history" ? suggestion.value : replaceActiveToken(options.kind, options.value.value, suggestion.insertText ?? suggestion.value);
|
||||
let caret: number;
|
||||
if (suggestion.kind === "history") {
|
||||
suppressNextValueSuggestion = true;
|
||||
options.value.value = suggestion.value;
|
||||
caret = suggestion.value.length;
|
||||
} else {
|
||||
const range = replacementRange.value;
|
||||
const currentTarget = conditionCompletionTarget(options.kind, options.value.value, options.selectionStart?.value, options.selectionEnd?.value, toValue(options.identifierQuote));
|
||||
if (!range || currentTarget.from !== range.from || currentTarget.to !== range.to) return false;
|
||||
const replacement = suggestion.insertText ?? suggestion.value;
|
||||
suppressNextValueSuggestion = true;
|
||||
options.value.value = `${options.value.value.slice(0, range.from)}${replacement}${options.value.value.slice(range.to)}`;
|
||||
caret = range.from + replacement.length;
|
||||
}
|
||||
if (options.selectionStart) options.selectionStart.value = caret;
|
||||
if (options.selectionEnd) options.selectionEnd.value = caret;
|
||||
dismiss();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -199,28 +364,30 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
return "navigate";
|
||||
}
|
||||
if (suggestions.value.length > 0 && event.key === "Tab") {
|
||||
if (!accept()) return undefined;
|
||||
event.preventDefault();
|
||||
accept();
|
||||
return "accept";
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (suggestions.value.length > 0 && highlightedIndex.value >= 0) {
|
||||
accept();
|
||||
return "accept";
|
||||
if (accept()) return "accept";
|
||||
}
|
||||
return "apply";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
watch(options.value, (value) => {
|
||||
if (suppressNextValueSuggestion) {
|
||||
suppressNextValueSuggestion = false;
|
||||
return;
|
||||
}
|
||||
scheduleSuggestions(value);
|
||||
});
|
||||
watch(
|
||||
() => [options.value.value, options.selectionStart?.value, options.selectionEnd?.value] as const,
|
||||
([value, selectionStart, selectionEnd]) => {
|
||||
if (suppressNextValueSuggestion) {
|
||||
suppressNextValueSuggestion = false;
|
||||
return;
|
||||
}
|
||||
scheduleSuggestions(value, selectionStart, selectionEnd);
|
||||
},
|
||||
);
|
||||
if (getCurrentScope()) onScopeDispose(cancelSuggestionRequest);
|
||||
|
||||
return {
|
||||
|
|
@ -228,6 +395,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
highlightedIndex,
|
||||
historyOpen,
|
||||
suggestionsLoading,
|
||||
replacementRange,
|
||||
dropdownOpen,
|
||||
scheduleSuggestions,
|
||||
openHistory,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { dataGridConditionColumnOptions } from "@/lib/dataGrid/dataGridConditionCompletion";
|
||||
import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion";
|
||||
|
||||
describe("dataGridConditionColumnOptions", () => {
|
||||
it("reuses PostgreSQL completion quoting while preserving display metadata", () => {
|
||||
|
|
@ -17,4 +17,12 @@ describe("dataGridConditionColumnOptions", () => {
|
|||
{ name: "order", insertText: "order" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the active dialect identifier quote while preserving runtime overrides", () => {
|
||||
expect(dataGridConditionIdentifierQuote("postgres")).toBe('"');
|
||||
expect(dataGridConditionIdentifierQuote("oracle")).toBe('"');
|
||||
expect(dataGridConditionIdentifierQuote("sqlite")).toBe('"');
|
||||
expect(dataGridConditionIdentifierQuote("mysql")).toBe("`");
|
||||
expect(dataGridConditionIdentifierQuote("kingbase", "`")).toBe("`");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,10 +34,11 @@ describe("getDataGridConditionSuggestionPosition", () => {
|
|||
expect(position.width).toBeLessThanOrEqual(520);
|
||||
});
|
||||
|
||||
test("keeps normal width when suggestions have no comments or are history entries", () => {
|
||||
test("keeps normal width when suggestions have no comments or are keyword/history entries", () => {
|
||||
expect(
|
||||
getDataGridConditionSuggestionPreferredWidth([
|
||||
{ value: "customer_id", kind: "column", comment: "" },
|
||||
{ value: "AND", kind: "keyword" },
|
||||
{ value: "status = 'active'", kind: "history" },
|
||||
]),
|
||||
).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { DataGridConditionColumnOption } from "@/composables/useDataGridConditionEditor";
|
||||
import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect";
|
||||
import { quoteSqlIdentifier } from "@/lib/sql/sqlCompletion";
|
||||
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export function dataGridConditionColumnOptions(columns: readonly DataGridConditionColumnOption[], databaseType?: DatabaseType): DataGridConditionColumnOption[] {
|
||||
|
|
@ -12,3 +13,8 @@ export function dataGridConditionColumnOptions(columns: readonly DataGridConditi
|
|||
return { name, insertText, ...(comment !== undefined ? { comment } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
export function dataGridConditionIdentifierQuote(databaseType?: DatabaseType, runtimeQuote?: string): string | undefined {
|
||||
if (runtimeQuote !== undefined) return runtimeQuote || undefined;
|
||||
return sqlSemanticDialectFor({ databaseType }).identifierQuotes[0]?.open;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export interface DataGridConditionSuggestionPosition {
|
|||
|
||||
export interface DataGridConditionSuggestionContent {
|
||||
value: string;
|
||||
kind: "column" | "history";
|
||||
kind: "column" | "keyword" | "history";
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue