feat(editor): add searchable query selectors

This commit is contained in:
t8y2 committed 2026-05-15 16:58:23 +08:00
1 parent 0099e1a262
commit b15e22033e
8 files changed
+197 -37

No files matched your search

+40 -37
View File
@@ -16,6 +16,7 @@ import {
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -58,6 +59,7 @@ const activeDatabaseOptions = computed(() => {
return connection ? (databaseOptions.value[connection.id] ?? []) : [];
});
const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
const activeDatabaseValue = computed(() => props.activeTab.database || "");
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
const activeSchemaValue = computed(() => props.activeTab.schema || "");
@@ -98,6 +100,10 @@ function databaseDisplayName(database: string): string {
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
return database || t("editor.noDatabase");
}
function connectionById(connectionId: string): ConnectionConfig | undefined {
return connectionStore.getConfig(connectionId);
}
</script>
<template>
@@ -203,55 +209,52 @@ function databaseDisplayName(database: string): string {
class="h-4 w-1 rounded-full shrink-0"
:style="{ backgroundColor: activeConnection.color }"
/>
<Select :model-value="activeConnectionValue" @update:model-value="(v: any) => emit('changeConnection', v)">
<SelectTrigger
class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs font-medium text-foreground shadow-none focus:ring-0"
>
<SearchableSelect
:model-value="activeConnectionValue"
:options="connectionOptionIds"
:placeholder="t('editor.selectConnection')"
:search-placeholder="t('editor.searchConnection')"
:empty-text="t('grid.noSearchResults')"
:loading-text="t('common.loading')"
trigger-class="font-medium text-foreground"
:display-name="connectionDisplayName"
@update:model-value="(connectionId) => emit('changeConnection', connectionId)"
>
<template #trigger-label="{ label }">
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ connectionDisplayName(activeConnectionValue) }}</span>
<span class="truncate">{{ label }}</span>
</div>
<SelectValue v-else :placeholder="t('editor.selectConnection')" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="connection in connectionStore.connections" :key="connection.id" :value="connection.id">
<div class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connectionIconType(connection)" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ connection.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<span v-else class="truncate text-muted-foreground">{{ t("editor.selectConnection") }}</span>
</template>
<template #option-label="{ option, label }">
<div class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connectionIconType(connectionById(option))" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ label }}</span>
</div>
</template>
</SearchableSelect>
</div>
<div v-if="activeConnection?.db_type !== 'elasticsearch' && !isSingleDb" class="flex items-center gap-1">
<Database class="h-3.5 w-3.5 shrink-0" />
<Select
<SearchableSelect
:model-value="activeDatabaseValue"
@update:model-value="(v: any) => emit('changeDatabase', v)"
:options="
activeDatabaseOptions.length ? activeDatabaseOptions : activeDatabaseValue ? [activeDatabaseValue] : []
"
:placeholder="t('editor.selectDatabase')"
:search-placeholder="t('editor.searchDatabase')"
:empty-text="t('grid.noSearchResults')"
:loading-text="t('common.loading')"
:loading="loadingDatabaseOptions[activeConnection?.id || '']"
:display-name="databaseDisplayName"
@update:model-value="(database) => emit('changeDatabase', database)"
@update:open="
(open: boolean) => {
if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {});
}
"
>
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
<SelectValue
:placeholder="
loadingDatabaseOptions[activeConnection?.id || ''] ? t('common.loading') : t('editor.selectDatabase')
"
>
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectValue>
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="database in activeDatabaseOptions" :key="database" :value="database">
{{ databaseDisplayName(database) }}
</SelectItem>
<SelectItem v-if="!activeDatabaseOptions.length && activeDatabaseValue" :value="activeDatabaseValue">
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectItem>
</SelectContent>
</Select>
/>
<Button
v-if="activeDatabaseValue"
variant="ghost"
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import type { HTMLAttributes } from "vue";
import { Check, ChevronDown, Search } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { filterDatabaseOptions } from "@/lib/databaseOptionSearch";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<{
modelValue: string;
options: string[];
placeholder: string;
searchPlaceholder: string;
emptyText: string;
loadingText: string;
loading?: boolean;
triggerClass?: HTMLAttributes["class"];
contentClass?: HTMLAttributes["class"];
displayName?: (option: string) => string;
}>(),
{
loading: false,
displayName: (option: string) => option,
},
);
const emit = defineEmits<{
"update:modelValue": [value: string];
"update:open": [value: boolean];
}>();
const open = ref(false);
const searchText = ref("");
const searchInput = ref<InstanceType<typeof Input>>();
const selectedLabel = computed(() => {
if (!props.modelValue) return props.placeholder;
return props.displayName(props.modelValue);
});
const filteredOptions = computed(() => filterDatabaseOptions(props.options, searchText.value, props.displayName));
watch(open, async (value) => {
emit("update:open", value);
if (!value) {
searchText.value = "";
return;
}
await nextTick();
const input = searchInput.value?.$el as HTMLInputElement | undefined;
input?.focus();
});
function selectOption(option: string) {
emit("update:modelValue", option);
open.value = false;
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="ghost"
:class="
cn(
'h-6 w-auto max-w-56 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0',
triggerClass,
)
"
>
<slot name="trigger-label" :value="modelValue" :label="selectedLabel" :loading="loading">
<span class="truncate">{{ loading ? loadingText : selectedLabel }}</span>
</slot>
<ChevronDown class="h-3 w-3 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" :class="cn('w-52 gap-1 p-1.5', contentClass)">
<div class="flex items-center gap-1.5 rounded-sm border bg-background px-2">
<Search class="h-3 w-3 shrink-0 text-muted-foreground" />
<Input
ref="searchInput"
:model-value="searchText"
:placeholder="searchPlaceholder"
class="h-6 border-0 px-0 text-sm shadow-none focus-visible:ring-0"
@update:model-value="(value) => (searchText = String(value))"
/>
</div>
<div class="max-h-64 overflow-y-auto py-1">
<div v-if="loading" class="px-2 py-2 text-sm text-muted-foreground">
{{ loadingText }}
</div>
<template v-else-if="filteredOptions.length">
<button
v-for="option in filteredOptions"
:key="option"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="selectOption(option)"
>
<Check :class="cn('h-3.5 w-3.5 shrink-0', option === modelValue ? 'opacity-100' : 'opacity-0')" />
<slot name="option-label" :option="option" :label="displayName(option)">
<span class="truncate">{{ displayName(option) }}</span>
</slot>
</button>
</template>
<div v-else class="px-2 py-2 text-sm text-muted-foreground">
{{ emptyText }}
</div>
</div>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1 @@
export { default as SearchableSelect } from "./SearchableSelect.vue";
+2
View File
@@ -196,7 +196,9 @@ export default {
pressToSaveSql: "Press {mod}+S to save SQL",
noDatabase: "No database selected",
selectConnection: "Select connection",
searchConnection: "Search connections...",
selectDatabase: "Select database",
searchDatabase: "Search databases...",
selectSchema: "Select schema",
setDefaultDatabase: "Set Default",
defaultDatabase: "Default",
+2
View File
@@ -197,7 +197,9 @@ export default {
pressToSaveSql: "Presiona {mod}+S para guardar el SQL",
noDatabase: "Sin base de datos seleccionada",
selectConnection: "Seleccionar conexión",
searchConnection: "Buscar conexiones...",
selectDatabase: "Seleccionar base de datos",
searchDatabase: "Buscar bases de datos...",
selectSchema: "Seleccionar esquema",
setDefaultDatabase: "Establecer como predeterminada",
defaultDatabase: "Predeterminada",
+2
View File
@@ -193,7 +193,9 @@ export default {
pressToSaveSql: "按 {mod}+S 保存 SQL",
noDatabase: "未选择数据库",
selectConnection: "选择连接",
searchConnection: "搜索连接...",
selectDatabase: "选择数据库",
searchDatabase: "搜索数据库...",
selectSchema: "选择模式",
setDefaultDatabase: "设为默认",
defaultDatabase: "默认库",
+14
View File
@@ -0,0 +1,14 @@
export function filterDatabaseOptions(
options: string[],
query: string,
displayName: (option: string) => string = (option) => option,
): string[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return options;
return options.filter((option) => {
const raw = option.toLowerCase();
const label = displayName(option).toLowerCase();
return raw.includes(normalizedQuery) || label.includes(normalizedQuery);
});
}