feat(editor): add searchable query selectors
This commit is contained in:
1 parent
0099e1a262
commit
b15e22033e
8 files changed
+197
-37
No files matched your search
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -193,7 +193,9 @@ export default {
|
||||
pressToSaveSql: "按 {mod}+S 保存 SQL",
|
||||
noDatabase: "未选择数据库",
|
||||
selectConnection: "选择连接",
|
||||
searchConnection: "搜索连接...",
|
||||
selectDatabase: "选择数据库",
|
||||
searchDatabase: "搜索数据库...",
|
||||
selectSchema: "选择模式",
|
||||
setDefaultDatabase: "设为默认",
|
||||
defaultDatabase: "默认库",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user