mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(redis): add hash field and value copy targets
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -5086,6 +5086,9 @@ export default {
|
||||
memberDetail: "Member detail",
|
||||
viewMember: "View full value",
|
||||
copyMember: "Copy member",
|
||||
copyField: "Copy field",
|
||||
copyFieldValue: "Copy field + value",
|
||||
copyOptions: "Copy options",
|
||||
copyKeyName: "Copy key name",
|
||||
copyKeyPath: "Copy key path",
|
||||
deleteGroupKeys: "Delete loaded keys",
|
||||
|
||||
@@ -4799,6 +4799,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "Detalle del miembro",
|
||||
viewMember: "Ver valor completo",
|
||||
copyMember: "Copiar miembro",
|
||||
copyField: "Copiar campo",
|
||||
copyFieldValue: "Copiar campo + valor",
|
||||
copyOptions: "Opciones de copia",
|
||||
copyKeyName: "Copiar nombre de clave",
|
||||
copyKeyPath: "Copiar ruta de clave",
|
||||
deleteGroupKeys: "Eliminar claves cargadas",
|
||||
|
||||
@@ -4797,6 +4797,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "Dettaglio membro",
|
||||
viewMember: "Visualizza valore completo",
|
||||
copyMember: "Copia membro",
|
||||
copyField: "Copia campo",
|
||||
copyFieldValue: "Copia campo + valore",
|
||||
copyOptions: "Opzioni di copia",
|
||||
copyKeyName: "Copia nome chiave",
|
||||
copyKeyPath: "Copia percorso chiave",
|
||||
deleteGroupKeys: "Elimina chiavi caricate",
|
||||
|
||||
@@ -4826,6 +4826,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "メンバー詳細",
|
||||
viewMember: "完全な値を表示",
|
||||
copyMember: "メンバーをコピー",
|
||||
copyField: "フィールドをコピー",
|
||||
copyFieldValue: "フィールドと値をコピー",
|
||||
copyOptions: "コピーオプション",
|
||||
copyKeyName: "キー名をコピー",
|
||||
copyKeyPath: "キーパスをコピー",
|
||||
deleteGroupKeys: "キーを一括削除",
|
||||
|
||||
@@ -4423,6 +4423,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "멤버 상세",
|
||||
viewMember: "전체 값 보기",
|
||||
copyMember: "멤버 복사",
|
||||
copyField: "필드 복사",
|
||||
copyFieldValue: "필드 및 값 복사",
|
||||
copyOptions: "복사 옵션",
|
||||
copyKeyName: "키 이름 복사",
|
||||
copyKeyPath: "키 경로 복사",
|
||||
deleteGroupKeys: "키 일괄 삭제",
|
||||
|
||||
@@ -4799,6 +4799,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "Detalhe do membro",
|
||||
viewMember: "Ver valor completo",
|
||||
copyMember: "Copiar membro",
|
||||
copyField: "Copiar campo",
|
||||
copyFieldValue: "Copiar campo + valor",
|
||||
copyOptions: "Opções de cópia",
|
||||
copyKeyName: "Copiar nome da chave",
|
||||
copyKeyPath: "Copiar caminho da chave",
|
||||
deleteGroupKeys: "Excluir chaves carregadas",
|
||||
|
||||
@@ -4984,6 +4984,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "Üye ayrıntısı",
|
||||
viewMember: "Tam değeri görüntüle",
|
||||
copyMember: "Üyeyi kopyala",
|
||||
copyField: "Alanı kopyala",
|
||||
copyFieldValue: "Alan + değer kopyala",
|
||||
copyOptions: "Kopyalama seçenekleri",
|
||||
copyKeyName: "Anahtar adını kopyala",
|
||||
copyKeyPath: "Anahtar yolunu kopyala",
|
||||
deleteGroupKeys: "Yüklenen anahtarları sil",
|
||||
|
||||
@@ -5070,6 +5070,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "成员详情",
|
||||
viewMember: "查看完整内容",
|
||||
copyMember: "复制成员",
|
||||
copyField: "复制字段",
|
||||
copyFieldValue: "复制字段和值",
|
||||
copyOptions: "复制选项",
|
||||
copyKeyName: "复制键名",
|
||||
copyKeyPath: "复制路径",
|
||||
deleteGroupKeys: "批量删除键",
|
||||
|
||||
@@ -4120,6 +4120,9 @@ export default withEnglishFallback({
|
||||
memberDetail: "成員詳情",
|
||||
viewMember: "檢視完整內容",
|
||||
copyMember: "複製成員",
|
||||
copyField: "複製欄位",
|
||||
copyFieldValue: "複製欄位和值",
|
||||
copyOptions: "複製選項",
|
||||
copyKeyName: "複製鍵名",
|
||||
copyKeyPath: "複製路徑",
|
||||
deleteGroupKeys: "批量刪除 key",
|
||||
|
||||
@@ -381,6 +381,19 @@ export function redisMemberCopyText(value: unknown): string {
|
||||
return redisClipboardSafeText(text);
|
||||
}
|
||||
|
||||
/** Copy targets for one hash row: its field, its value, or both. */
|
||||
export type RedisHashRowCopyTarget = "field" | "value" | "fieldValue";
|
||||
|
||||
/**
|
||||
* Copy text for one hash row. `fieldValue` joins the two with a tab so the
|
||||
* result pastes into a spreadsheet as two columns, matching the grid's TSV copy.
|
||||
*/
|
||||
export function redisHashRowCopyText(field: unknown, value: unknown, target: RedisHashRowCopyTarget): string {
|
||||
if (target === "value") return redisMemberCopyText(value);
|
||||
const fieldText = redisMemberCopyText(field);
|
||||
return target === "field" ? fieldText : `${fieldText}\t${redisMemberCopyText(value)}`;
|
||||
}
|
||||
|
||||
export function redisClipboardSafeText(value: string): string {
|
||||
let output = "";
|
||||
for (const ch of value) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseRedisJsonDetail,
|
||||
preferredRedisValueFormat,
|
||||
redisClipboardSafeText,
|
||||
redisHashRowCopyText,
|
||||
redisMemberCopyText,
|
||||
redisValueCopyText,
|
||||
} from "../../apps/desktop/src/lib/redis/redisValuePresentation.ts";
|
||||
@@ -318,3 +319,32 @@ test("renders JSON values as YAML and XML text", () => {
|
||||
assert.equal(jsonToYamlText({ id: 1, tags: ["a", "b"] }), "id: 1\ntags:\n - a\n - b\n");
|
||||
assert.equal(jsonToXmlText({ id: 1 }), '<?xml version="1.0" encoding="UTF-8"?>\n<root>\n <id>1</id>\n</root>');
|
||||
});
|
||||
|
||||
test("copies hash row field, value, and field+value targets", () => {
|
||||
const field = blobFromText("1653624095260016668");
|
||||
const value = blobFromText("S");
|
||||
|
||||
assert.equal(redisHashRowCopyText(field, value, "field"), "1653624095260016668");
|
||||
assert.equal(redisHashRowCopyText(field, value, "value"), "S");
|
||||
assert.equal(redisHashRowCopyText(field, value, "fieldValue"), "1653624095260016668\tS");
|
||||
});
|
||||
|
||||
test("escapes clipboard-unsafe controls in hash row copies", () => {
|
||||
const field = blobFromText("name\x00suffix");
|
||||
const value = blobFromText("Ada");
|
||||
|
||||
assert.equal(redisHashRowCopyText(field, value, "field"), "name\\x00suffix");
|
||||
assert.equal(redisHashRowCopyText(field, value, "fieldValue"), "name\\x00suffix\tAda");
|
||||
});
|
||||
|
||||
test("copies plain string hash rows without JSON reformatting", () => {
|
||||
assert.equal(redisHashRowCopyText('{"id":1}', "1", "field"), '{"id":1}');
|
||||
assert.equal(redisHashRowCopyText('{"id":1}', "1", "fieldValue"), '{"id":1}\t1');
|
||||
});
|
||||
|
||||
test("copies the value target without resolving the field", () => {
|
||||
// The default row copy must stay a pure value read: a hash row always has a
|
||||
// field, but the value path must not depend on the field being copyable.
|
||||
assert.equal(redisHashRowCopyText(undefined, blobFromText("S"), "value"), "S");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user