feat(redis): add hash field and value copy targets

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-08 22:34:36 +08:00
1 parent 798733fd29
commit 4528a15492
13 files changed
+293 -1

No files matched your search

@@ -0,0 +1,204 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
redisGetValue: vi.fn(),
redisGetTtl: vi.fn(),
redisSetTtl: vi.fn(),
redisSetExpireAt: vi.fn(),
redisLoadMore: vi.fn(),
copyToClipboard: vi.fn().mockResolvedValue(undefined),
toast: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
redisGetValue: mocks.redisGetValue,
redisGetTtl: mocks.redisGetTtl,
redisSetTtl: mocks.redisSetTtl,
redisSetExpireAt: mocks.redisSetExpireAt,
redisLoadMore: mocks.redisLoadMore,
}));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
useEditorFontFamilyStyle: () => ({}),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/lib/common/shikiJsonHighlighter", () => ({
createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""),
}));
// The row menu only needs a trigger that forwards attrs and an item that emits
// `select`; real reka-ui behaviour is out of scope for this test.
vi.mock("@/components/ui/dropdown-menu", async () => {
const { defineComponent, h } = await import("vue");
const slotHost = defineComponent({
setup:
(_props, { slots }) =>
() =>
h("div", slots.default?.()),
});
return {
DropdownMenu: slotHost,
DropdownMenuContent: slotHost,
DropdownMenuLabel: slotHost,
DropdownMenuSeparator: slotHost,
DropdownMenuTrigger: defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
}),
DropdownMenuItem: defineComponent({
inheritAttrs: false,
emits: ["select"],
setup(_props, { attrs, slots, emit }) {
return () => h("button", { ...attrs, onClick: () => emit("select") }, slots.default?.());
},
}),
};
});
import RedisValueViewer from "./RedisValueViewer.vue";
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
function createLocalStorage(): Storage {
const entries = new Map<string, string>();
return {
get length() {
return entries.size;
},
clear() {
entries.clear();
},
getItem(key) {
return entries.get(key) ?? null;
},
key(index) {
return [...entries.keys()][index] ?? null;
},
removeItem(key) {
entries.delete(key);
},
setItem(key, value) {
entries.set(key, String(value));
},
};
}
afterEach(() => {
for (const { unmount, host } of mountedApps.splice(0)) {
unmount();
host.remove();
}
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("localStorage", createLocalStorage());
});
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
function blob(text: string) {
return { raw_base64: btoa(text), encoding: "utf8" as const };
}
function hashValue() {
return {
key_display: "log:processed:2026-09-08:24",
key_raw: "log:processed:2026-09-08:24",
ttl: 200000,
redis_type: "hash",
data: {
kind: "hash" as const,
items: [{ field: blob("1653624095260016668"), value: blob("S") }],
total: 1,
scan_cursor: undefined,
},
};
}
const testI18nMessages = {
en: {
grid: { copyValue: "Copy value" },
redis: {
copyField: "Copy field",
copyFieldValue: "Copy field + value",
copyOptions: "Copy options",
copied: "Copied",
},
},
};
function mountViewer() {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () => h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: "key", keyRaw: "key" });
},
}),
);
app.use(createI18n({ legacy: false, locale: "en", messages: testI18nMessages, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
}
function clickCopy(selector: string) {
const button = document.querySelector<HTMLButtonElement>(selector);
expect(button).not.toBeNull();
button!.click();
}
describe("Redis hash row copy menu", () => {
it("copies the value from the main button without opening the member detail", async () => {
mocks.redisGetValue.mockResolvedValue(hashValue());
mountViewer();
await settle();
clickCopy("[data-redis-copy-value]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenCalledWith("S");
expect(document.querySelector("[data-redis-member-utf8-viewer]")).toBeNull();
});
it("offers field, value and field + value targets in the dropdown", async () => {
mocks.redisGetValue.mockResolvedValue(hashValue());
mountViewer();
await settle();
expect(document.querySelector("[data-redis-copy-menu]")).not.toBeNull();
clickCopy("[data-redis-copy-item-field]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("1653624095260016668");
clickCopy("[data-redis-copy-item-value]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("S");
clickCopy("[data-redis-copy-item-field-value]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("1653624095260016668\tS");
});
});
@@ -41,6 +41,7 @@ import {
redisJsonValueText,
normalizeRedisJsonDraft,
redisClipboardSafeText,
redisHashRowCopyText,
redisMemberCopyText,
redisValueCopyText,
redisValueCollectionItems,
@@ -54,6 +55,7 @@ import {
jsonToYamlText,
REDIS_VALUE_CODEC_ORDER,
type RedisCollectionItem,
type RedisHashRowCopyTarget,
type RedisValueCodec,
type RedisValueFormat,
} from "@/lib/redis/redisValuePresentation";
@@ -1863,6 +1865,10 @@ function copyMember(value: unknown) {
void copyText(redisMemberCopyText(value));
}
function copyHashRow(item: RedisHashItem, target: RedisHashRowCopyTarget) {
void copyText(redisHashRowCopyText(item.field, item.value, target));
}
function selectMember(title: string, value: unknown, context: RedisMemberContext, identity?: string) {
const detail = formatRedisMemberDetail(value, { allowJsonText: true });
selectedMemberTitle.value = title;
@@ -3124,7 +3130,19 @@ defineExpose({ focusSearch });
@click.stop="viewMember(formatValue(row.value.field), row.value.value, { kind: 'hash', field: redisBlobText(row.value.field), canEdit: redisBlobText(row.value.field) != null && canEditRedisMemberDetail('hash', row.value.value) })"
><Eye class="w-3 h-3"
/></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(row.value.value)"><Copy class="w-3 h-3" /></Button>
<div class="flex h-5 shrink-0 overflow-hidden rounded opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100" @click.stop>
<Button data-redis-copy-value variant="ghost" size="icon" class="h-5 w-[18px] rounded-none px-0" :title="t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyHashRow(row.value, 'value')"><Copy class="w-3 h-3" /></Button>
<DropdownMenu :key="row.id">
<DropdownMenuTrigger as-child>
<Button data-redis-copy-menu variant="ghost" size="icon" class="h-5 w-4 rounded-none border-l px-0" :title="t('redis.copyOptions')" :aria-label="t('redis.copyOptions')"><ChevronDown class="h-2.5 w-2.5" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuItem data-redis-copy-item-field @select="copyHashRow(row.value, 'field')">{{ t("redis.copyField") }}</DropdownMenuItem>
<DropdownMenuItem data-redis-copy-item-value @select="copyHashRow(row.value, 'value')">{{ t("grid.copyValue") }}</DropdownMenuItem>
<DropdownMenuItem data-redis-copy-item-field-value @select="copyHashRow(row.value, 'fieldValue')">{{ t("redis.copyFieldValue") }}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" :disabled="!canDeleteHashItem(row.value)" @click.stop="requestHashDel(redisBlobText(row.value.field))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>