feat(redis): add hash field and value copy targets
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user