feat(redis): add sheet UI components, list set API, and member detail utils
- Add shadcn Sheet UI components for member detail drawer - Add redisListSet API for editing list items by index - Add member detail presentation helpers (format, highlight, edit, resize) - Add tests for redis value presentation
This commit is contained in:
parent
90e8bbe757
commit
4dc3a1b3c0
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui";
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<DialogRootProps>();
|
||||
const emits = defineEmits<DialogRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui";
|
||||
import { DialogClose } from "reka-ui";
|
||||
|
||||
const props = defineProps<DialogCloseProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose data-slot="sheet-close" v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { XIcon } from "lucide-vue-next";
|
||||
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import SheetOverlay from "./SheetOverlay.vue";
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes["class"];
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||
side: "right",
|
||||
showCloseButton: true,
|
||||
});
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "side", "showCloseButton");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogClose as-child>
|
||||
<SheetOverlay />
|
||||
</DialogClose>
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:data-side="side"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose v-if="showCloseButton" data-slot="sheet-close" as-child>
|
||||
<Button variant="ghost" class="absolute top-3 right-3" size="icon-sm">
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogDescription } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="sheet-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="sheet-footer" :class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="sheet-header" :class="cn('gap-0.5 p-4 flex flex-col', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogTitle } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="sheet-title"
|
||||
:class="cn('text-foreground text-base font-medium cn-font-heading', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui";
|
||||
import { DialogTrigger } from "reka-ui";
|
||||
|
||||
const props = defineProps<DialogTriggerProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger data-slot="sheet-trigger" v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export { default as Sheet } from "./Sheet.vue";
|
||||
export { default as SheetClose } from "./SheetClose.vue";
|
||||
export { default as SheetContent } from "./SheetContent.vue";
|
||||
export { default as SheetDescription } from "./SheetDescription.vue";
|
||||
export { default as SheetFooter } from "./SheetFooter.vue";
|
||||
export { default as SheetHeader } from "./SheetHeader.vue";
|
||||
export { default as SheetTitle } from "./SheetTitle.vue";
|
||||
export { default as SheetTrigger } from "./SheetTrigger.vue";
|
||||
|
|
@ -111,6 +111,7 @@ export const redisDeleteKey = forward("redisDeleteKey");
|
|||
export const redisHashSet = forward("redisHashSet");
|
||||
export const redisHashDel = forward("redisHashDel");
|
||||
export const redisListPush = forward("redisListPush");
|
||||
export const redisListSet = forward("redisListSet");
|
||||
export const redisListRemove = forward("redisListRemove");
|
||||
export const redisSetAdd = forward("redisSetAdd");
|
||||
export const redisSetRemove = forward("redisSetRemove");
|
||||
|
|
|
|||
|
|
@ -597,6 +597,16 @@ export async function redisListPush(connectionId: string, db: number, keyRaw: st
|
|||
return post("/api/redis/list-push", { connectionId, db, keyRaw, value });
|
||||
}
|
||||
|
||||
export async function redisListSet(
|
||||
connectionId: string,
|
||||
db: number,
|
||||
keyRaw: string,
|
||||
index: number,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
return post("/api/redis/list-set", { connectionId, db, keyRaw, index, value });
|
||||
}
|
||||
|
||||
export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise<void> {
|
||||
return post("/api/redis/list-remove", { connectionId, db, keyRaw, index });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,23 @@ export interface RedisMemberDetail {
|
|||
format: RedisMemberDetailFormat;
|
||||
}
|
||||
|
||||
export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream";
|
||||
|
||||
export const REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH = 360;
|
||||
export const REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH = 900;
|
||||
|
||||
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind): boolean {
|
||||
return kind !== "stream";
|
||||
}
|
||||
|
||||
export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: number): number {
|
||||
const viewportMax = Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, viewportWidth - 32);
|
||||
return Math.min(
|
||||
Math.min(REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH, viewportMax),
|
||||
Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, width),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
|
|
@ -24,3 +41,22 @@ export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
|
|||
return { text: String(value), format: "text" };
|
||||
}
|
||||
}
|
||||
|
||||
export function getRedisMemberSelectionKey(title: string, value: unknown): string {
|
||||
return `${title}\n${formatRedisMemberDetail(value).text}`;
|
||||
}
|
||||
|
||||
export function highlightRedisJsonDetail(json: string): string {
|
||||
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
return escaped.replace(
|
||||
/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(match) => {
|
||||
let cls = "json-number";
|
||||
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
|
||||
else if (match === "true" || match === "false") cls = "json-boolean";
|
||||
else if (match === "null") cls = "json-null";
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -429,6 +429,16 @@ export async function redisListPush(connectionId: string, db: number, keyRaw: st
|
|||
return invoke("redis_list_push", { connectionId, db, keyRaw, value });
|
||||
}
|
||||
|
||||
export async function redisListSet(
|
||||
connectionId: string,
|
||||
db: number,
|
||||
keyRaw: string,
|
||||
index: number,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
return invoke("redis_list_set", { connectionId, db, keyRaw, index, value });
|
||||
}
|
||||
|
||||
export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise<void> {
|
||||
return invoke("redis_list_remove", { connectionId, db, keyRaw, index });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { formatRedisMemberDetail } from "../src/lib/redisValuePresentation.ts";
|
||||
import {
|
||||
canEditRedisMemberDetail,
|
||||
clampRedisMemberDetailSheetWidth,
|
||||
formatRedisMemberDetail,
|
||||
getRedisMemberSelectionKey,
|
||||
highlightRedisJsonDetail,
|
||||
} from "../src/lib/redisValuePresentation.ts";
|
||||
|
||||
test("formats JSON object strings for Redis member details", () => {
|
||||
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
||||
|
|
@ -22,3 +28,35 @@ test("formats non-string Redis member values as JSON", () => {
|
|||
assert.equal(detail.format, "json");
|
||||
assert.equal(detail.text, '{\n "field": "name",\n "value": "{\\"nested\\":true}"\n}');
|
||||
});
|
||||
|
||||
test("builds stable Redis member selection keys from title and formatted value", () => {
|
||||
const key = getRedisMemberSelectionKey("#2", '{"id":240,"kind":"json"}');
|
||||
|
||||
assert.equal(key, '#2\n{\n "id": 240,\n "kind": "json"\n}');
|
||||
});
|
||||
|
||||
test("highlights formatted Redis JSON detail safely", () => {
|
||||
const html = highlightRedisJsonDetail('{"id":240,"name":"<script>","active":true,"meta":null}');
|
||||
|
||||
assert.match(html, /<span class="json-key">"id":<\/span>/);
|
||||
assert.match(html, /<span class="json-number">240<\/span>/);
|
||||
assert.match(html, /<span class="json-string">"<script>"<\/span>/);
|
||||
assert.match(html, /<span class="json-boolean">true<\/span>/);
|
||||
assert.match(html, /<span class="json-null">null<\/span>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
});
|
||||
|
||||
test("allows editing Redis collection members except stream fields", () => {
|
||||
assert.equal(canEditRedisMemberDetail("list"), true);
|
||||
assert.equal(canEditRedisMemberDetail("hash"), true);
|
||||
assert.equal(canEditRedisMemberDetail("set"), true);
|
||||
assert.equal(canEditRedisMemberDetail("zset"), true);
|
||||
assert.equal(canEditRedisMemberDetail("stream"), false);
|
||||
});
|
||||
|
||||
test("clamps Redis member detail sheet width to viewport and usable bounds", () => {
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(200, 1200), 360);
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(640, 1200), 640);
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(1200, 1400), 900);
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(900, 500), 468);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue