feat(redis): add key directory context menu
This commit is contained in:
10 files changed
+116
-8
No files matched your search
@@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
listRedisCompletionCommandDocs: vi.fn(),
|
||||
listRedisCompletionKeys: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
redisScanPageSize: 100,
|
||||
infiniteScroll: false,
|
||||
queryResultMaxRowsEnabled: true,
|
||||
@@ -81,6 +82,10 @@ vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/clipboard", () => ({
|
||||
copyToClipboard: mocks.copyToClipboard,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
@@ -202,8 +207,26 @@ vi.mock("@/components/ui/CustomContextMenu.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", slots.default?.({ onContextMenu: () => undefined }));
|
||||
props: { items: { type: [Array, Function], required: true } },
|
||||
setup(props, { slots }) {
|
||||
return () => {
|
||||
const items = typeof props.items === "function" ? props.items() : props.items;
|
||||
return h("div", [
|
||||
slots.default?.({ onContextMenu: () => undefined }),
|
||||
...items.map((item: any) =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-context-menu-item": item.label,
|
||||
disabled: typeof item.disabled === "function" ? item.disabled() : item.disabled,
|
||||
onClick: item.action,
|
||||
},
|
||||
item.label,
|
||||
),
|
||||
),
|
||||
]);
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -307,6 +330,7 @@ function expandGroup(host: HTMLElement, segments: string[]) {
|
||||
expect(checkbox, `group row for ${segments.join(":")}`).toBeDefined();
|
||||
const row = checkbox!.closest<HTMLElement>("div.cursor-pointer");
|
||||
expect(row?.className).toContain("cursor-pointer");
|
||||
expect(row?.className).toContain("select-none");
|
||||
row!.click();
|
||||
}
|
||||
|
||||
@@ -504,3 +528,43 @@ describe("RedisKeyBrowser expansion persistence across refresh (issue #7173)", (
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser directory context menu (issue #8068)", () => {
|
||||
it("prefills a new key and copies the directory path", async () => {
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [keyInfo("folder:child")], total_keys: 1 });
|
||||
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
|
||||
const create = host.querySelector<HTMLButtonElement>('button[data-context-menu-item="redis.createKey"]');
|
||||
const copyPath = host.querySelector<HTMLButtonElement>('button[data-context-menu-item="redis.copyKeyPath"]');
|
||||
const deleteKeys = host.querySelector<HTMLButtonElement>('button[data-context-menu-item="redis.deleteGroupKeys"]');
|
||||
expect(create).not.toBeNull();
|
||||
expect(copyPath).not.toBeNull();
|
||||
expect(deleteKeys?.disabled).toBe(false);
|
||||
|
||||
create!.click();
|
||||
await settle();
|
||||
expect(host.querySelector<HTMLInputElement>('input[placeholder="redis.createKeyNamePlaceholder"]')?.value).toBe("folder:");
|
||||
|
||||
copyPath!.click();
|
||||
await settle();
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith("folder");
|
||||
});
|
||||
|
||||
it("hides directory deletion for fuzzy hierarchy results", async () => {
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [keyInfo("folder:child")], total_keys: 1 });
|
||||
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
|
||||
const input = host.querySelector<HTMLInputElement>("[data-redis-search-input]")!;
|
||||
input.value = "folder";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
await nextTick();
|
||||
host.querySelector<HTMLButtonElement>('button[title="redis.fuzzyMatchTitle"]')!.click();
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector('button[data-context-menu-item="redis.deleteGroupKeys"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1107,8 +1107,8 @@ function requestBatchDelete() {
|
||||
showDangerConfirm.value = true;
|
||||
}
|
||||
|
||||
function requestGroupDelete(node: RedisKeyTreeNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
function requestGroupDelete(node: RedisKeyTreeNode, event?: Event) {
|
||||
event?.stopPropagation();
|
||||
if (node.kind !== "group" || selectionBusy.value) return;
|
||||
const keyRaws = collectRedisGroupKeyRaws(node);
|
||||
if (keyRaws.length === 0) return;
|
||||
@@ -1143,6 +1143,34 @@ async function copyRedisKeyName(keyName: string) {
|
||||
}
|
||||
|
||||
function redisKeyContextMenuItems(node: RedisKeyTreeNode): ContextMenuItem[] {
|
||||
if (node.kind === "group") {
|
||||
const groupPath = node.pathSegments.join(redisKeySeparator.value);
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
label: t("redis.createKey"),
|
||||
icon: Plus,
|
||||
action: () => openCreateKeyDialog(`${groupPath}${redisKeySeparator.value}`),
|
||||
},
|
||||
{
|
||||
label: t("redis.copyKeyPath"),
|
||||
icon: Copy,
|
||||
action: () => copyRedisKeyName(groupPath),
|
||||
},
|
||||
];
|
||||
// Fuzzy hierarchy represents only the loaded matching subset. Keep its
|
||||
// destructive actions hidden, matching the inline group-delete button.
|
||||
if (!isFuzzyHierarchyView.value) {
|
||||
items.push({
|
||||
label: t("redis.deleteGroupKeys"),
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
action: () => requestGroupDelete(node),
|
||||
disabled: () => selectionBusy.value,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const copyText = redisKeyNameCopyText(node);
|
||||
if (copyText === null) return [];
|
||||
return [
|
||||
@@ -1155,8 +1183,7 @@ function redisKeyContextMenuItems(node: RedisKeyTreeNode): ContextMenuItem[] {
|
||||
}
|
||||
|
||||
function onRedisRowContextMenu(event: MouseEvent, node: RedisKeyTreeNode, openContextMenu: (event: MouseEvent) => void) {
|
||||
if (node.kind !== "leaf") return;
|
||||
selectedKeyRaw.value = node.keyRaw;
|
||||
if (node.kind === "leaf") selectedKeyRaw.value = node.keyRaw;
|
||||
openContextMenu(event);
|
||||
}
|
||||
|
||||
@@ -1400,8 +1427,9 @@ function onCreateKeyTypeChange(type: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateKeyDialog() {
|
||||
function openCreateKeyDialog(initialKeyName = "") {
|
||||
resetCreateKeyForm();
|
||||
createKeyName.value = initialKeyName;
|
||||
showCreateKeyDialog.value = true;
|
||||
}
|
||||
|
||||
@@ -2354,7 +2382,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<template #default="{ item: row }">
|
||||
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer group"
|
||||
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer select-none group"
|
||||
:class="[
|
||||
isOpen || (row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40',
|
||||
row.node.kind === 'leaf' ? (isLeafChecked(row.node.keyRaw) && selectedKeyRaw !== row.node.keyRaw ? 'bg-primary/10' : undefined) : groupSelectedCount(row.node) > 0 ? 'bg-primary/10' : undefined,
|
||||
|
||||
@@ -4911,6 +4911,8 @@ export default {
|
||||
viewMember: "View full value",
|
||||
copyMember: "Copy member",
|
||||
copyKeyName: "Copy key name",
|
||||
copyKeyPath: "Copy key path",
|
||||
deleteGroupKeys: "Delete loaded keys",
|
||||
copied: "Copied",
|
||||
copyInsertStatement: "Copy Insert Statement",
|
||||
copyInsertStatementBinary: "Cannot generate insert statement for binary data",
|
||||
|
||||
@@ -4708,6 +4708,8 @@ export default withEnglishFallback({
|
||||
viewMember: "Ver valor completo",
|
||||
copyMember: "Copiar miembro",
|
||||
copyKeyName: "Copiar nombre de clave",
|
||||
copyKeyPath: "Copiar ruta de clave",
|
||||
deleteGroupKeys: "Eliminar claves cargadas",
|
||||
copied: "Copiado",
|
||||
editMember: "Editar",
|
||||
jsonView: "Vista JSON",
|
||||
|
||||
@@ -4706,6 +4706,8 @@ export default withEnglishFallback({
|
||||
viewMember: "Visualizza valore completo",
|
||||
copyMember: "Copia membro",
|
||||
copyKeyName: "Copia nome chiave",
|
||||
copyKeyPath: "Copia percorso chiave",
|
||||
deleteGroupKeys: "Elimina chiavi caricate",
|
||||
copied: "Copiato",
|
||||
copyInsertStatement: "Copia Istruzione di Inserimento",
|
||||
copyInsertStatementBinary: "Impossibile generare l'istruzione di inserimento per dati binari",
|
||||
|
||||
@@ -4735,6 +4735,8 @@ export default withEnglishFallback({
|
||||
viewMember: "完全な値を表示",
|
||||
copyMember: "メンバーをコピー",
|
||||
copyKeyName: "キー名をコピー",
|
||||
copyKeyPath: "キーパスをコピー",
|
||||
deleteGroupKeys: "キーを一括削除",
|
||||
copied: "コピーしました",
|
||||
copyInsertStatement: "INSERT文をコピー",
|
||||
copyInsertStatementBinary: "バイナリデータのINSERT文は生成できません",
|
||||
|
||||
@@ -4332,6 +4332,8 @@ export default withEnglishFallback({
|
||||
viewMember: "전체 값 보기",
|
||||
copyMember: "멤버 복사",
|
||||
copyKeyName: "키 이름 복사",
|
||||
copyKeyPath: "키 경로 복사",
|
||||
deleteGroupKeys: "키 일괄 삭제",
|
||||
copied: "복사했습니다",
|
||||
copyInsertStatement: "INSERT 구문 복사",
|
||||
copyInsertStatementBinary: "바이너리 데이터에 대한 INSERT 구문을 생성할 수 없습니다",
|
||||
|
||||
@@ -4708,6 +4708,8 @@ export default withEnglishFallback({
|
||||
viewMember: "Ver valor completo",
|
||||
copyMember: "Copiar membro",
|
||||
copyKeyName: "Copiar nome da chave",
|
||||
copyKeyPath: "Copiar caminho da chave",
|
||||
deleteGroupKeys: "Excluir chaves carregadas",
|
||||
copied: "Copiado",
|
||||
copyInsertStatement: "Copiar instrução de inserção",
|
||||
copyInsertStatementBinary: "Não é possível gerar instrução de inserção para dados binários",
|
||||
|
||||
@@ -4895,6 +4895,8 @@ export default withEnglishFallback({
|
||||
viewMember: "查看完整内容",
|
||||
copyMember: "复制成员",
|
||||
copyKeyName: "复制键名",
|
||||
copyKeyPath: "复制路径",
|
||||
deleteGroupKeys: "批量删除键",
|
||||
copied: "已复制",
|
||||
copyInsertStatement: "复制插入语句",
|
||||
copyInsertStatementBinary: "二进制数据无法生成插入语句",
|
||||
|
||||
@@ -4032,6 +4032,8 @@ export default withEnglishFallback({
|
||||
viewMember: "檢視完整內容",
|
||||
copyMember: "複製成員",
|
||||
copyKeyName: "複製鍵名",
|
||||
copyKeyPath: "複製路徑",
|
||||
deleteGroupKeys: "批量刪除 key",
|
||||
copied: "已複製",
|
||||
copyInsertStatement: "複製為 INSERT 語句",
|
||||
copyInsertStatementBinary: "無法為二進位資料產生 INSERT 語句",
|
||||
|
||||
Reference in new issue
Block a user