mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(redis): add local expiration modes
This commit is contained in:
@@ -5,10 +5,9 @@ const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url),
|
||||
|
||||
describe("DataGrid native clipboard regions", () => {
|
||||
it("keeps table info text selection out of grid copy shortcuts", () => {
|
||||
const drawerStart = dataGridSource.indexOf('<div v-if="showTableInfo"');
|
||||
const drawerTagEnd = dataGridSource.indexOf(">", drawerStart);
|
||||
const drawerTag = dataGridSource.match(/<div\s+v-if="showTableInfo"[^>]*>/)?.[0];
|
||||
|
||||
expect(drawerStart).toBeGreaterThanOrEqual(0);
|
||||
expect(dataGridSource.slice(drawerStart, drawerTagEnd)).toContain("data-native-clipboard");
|
||||
expect(drawerTag).toBeDefined();
|
||||
expect(drawerTag).toContain("data-native-clipboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date";
|
||||
import { createApp, nextTick, type ComponentPublicInstance } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
redisScanKeysBatch: vi.fn(),
|
||||
redisGetValue: vi.fn(),
|
||||
redisSetString: vi.fn(),
|
||||
redisJsonSet: vi.fn(),
|
||||
redisHashSet: vi.fn(),
|
||||
redisListPush: vi.fn(),
|
||||
redisSetAdd: vi.fn(),
|
||||
redisZadd: vi.fn(),
|
||||
redisStreamAdd: vi.fn(),
|
||||
redisSetTtl: vi.fn(),
|
||||
redisSetExpireAt: vi.fn(),
|
||||
redisCheckJsonModule: vi.fn(),
|
||||
redisDeleteKey: vi.fn(),
|
||||
redisDeleteKeys: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
redisScanKeysBatch: mocks.redisScanKeysBatch,
|
||||
redisGetValue: mocks.redisGetValue,
|
||||
redisSetString: mocks.redisSetString,
|
||||
redisJsonSet: mocks.redisJsonSet,
|
||||
redisHashSet: mocks.redisHashSet,
|
||||
redisListPush: mocks.redisListPush,
|
||||
redisSetAdd: mocks.redisSetAdd,
|
||||
redisZadd: mocks.redisZadd,
|
||||
redisStreamAdd: mocks.redisStreamAdd,
|
||||
redisSetTtl: mocks.redisSetTtl,
|
||||
redisSetExpireAt: mocks.redisSetExpireAt,
|
||||
redisCheckJsonModule: mocks.redisCheckJsonModule,
|
||||
redisDeleteKey: mocks.redisDeleteKey,
|
||||
redisDeleteKeys: mocks.redisDeleteKeys,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: 100 }),
|
||||
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
|
||||
invalidateCompletionCache: vi.fn(),
|
||||
refreshRedisDbKeyCounts: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
|
||||
useEditorFontFamilyStyle: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { disabled: Boolean },
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h("button", { ...attrs, disabled: props.disabled }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: String, disabled: Boolean },
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () =>
|
||||
h("input", {
|
||||
...attrs,
|
||||
value: props.modelValue ?? "",
|
||||
disabled: props.disabled,
|
||||
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/badge", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Badge: defineComponent({
|
||||
setup:
|
||||
(_, { attrs, slots }) =>
|
||||
() =>
|
||||
h("span", attrs, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const slotContainer = defineComponent({
|
||||
setup:
|
||||
(_, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
return {
|
||||
Dialog: defineComponent({
|
||||
props: { open: Boolean },
|
||||
setup(props, { slots }) {
|
||||
return () => (props.open ? h("div", { "data-test-dialog": "" }, slots.default?.()) : null);
|
||||
},
|
||||
}),
|
||||
DialogContent: slotContainer,
|
||||
DialogFooter: slotContainer,
|
||||
DialogHeader: slotContainer,
|
||||
DialogTitle: slotContainer,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
type SelectRoot = HTMLElement & { selectTestValue?: (value: string) => void };
|
||||
const slotContainer = defineComponent({
|
||||
setup:
|
||||
(_, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
return {
|
||||
Select: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: String, disabled: Boolean },
|
||||
emits: ["update:modelValue", "update:open"],
|
||||
setup(props, { emit, slots }) {
|
||||
const selectValue = (value: string) => {
|
||||
if (!props.disabled) emit("update:modelValue", value);
|
||||
};
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-test-select-root": "",
|
||||
ref: (element: Element | ComponentPublicInstance | null) => {
|
||||
if (element instanceof HTMLElement) (element as SelectRoot).selectTestValue = selectValue;
|
||||
},
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
SelectContent: slotContainer,
|
||||
SelectItem: defineComponent({
|
||||
props: { value: String },
|
||||
setup(props, { slots }) {
|
||||
return () => h("button", { type: "button", "data-test-select-value": props.value }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
SelectTrigger: slotContainer,
|
||||
SelectValue: slotContainer,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/option-help-panel", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { OptionHelpPanel: defineComponent({ setup: () => () => h("div") }) };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const slotContainer = defineComponent({
|
||||
setup:
|
||||
(_, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
return { Tabs: slotContainer, TabsContent: slotContainer, TabsList: slotContainer, TabsTrigger: slotContainer };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/switch", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { Switch: defineComponent({ setup: () => () => h("button", { type: "button" }) }) };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/date-time-picker/DateTimePicker.vue", async () => {
|
||||
const { CalendarDateTime } = await import("@internationalized/date");
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: { disabled: Boolean },
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
disabled: props.disabled,
|
||||
"data-test-absolute-date": "",
|
||||
onClick: () => emit("update:modelValue", new CalendarDateTime(2030, 1, 2, 3, 4, 5)),
|
||||
},
|
||||
"Set date",
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
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 }));
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { default: defineComponent({ setup: () => () => h("div") }) };
|
||||
});
|
||||
|
||||
vi.mock("./RedisValueViewer.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { default: defineComponent({ setup: () => () => h("div") }) };
|
||||
});
|
||||
|
||||
vi.mock("./RedisPubSubPanel.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { default: defineComponent({ setup: () => () => h("div") }) };
|
||||
});
|
||||
|
||||
vi.mock("./RedisSlowlogPanel.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { default: defineComponent({ setup: () => () => h("div") }) };
|
||||
});
|
||||
|
||||
vi.mock("vue-virtual-scroller", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { RecycleScroller: defineComponent({ setup: () => () => h("div") }) };
|
||||
});
|
||||
|
||||
vi.mock("splitpanes", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const slotContainer = defineComponent({
|
||||
setup:
|
||||
(_, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
return { Splitpanes: slotContainer, Pane: slotContainer };
|
||||
});
|
||||
|
||||
import RedisKeyBrowser from "./RedisKeyBrowser.vue";
|
||||
|
||||
const KEY_NAME = "new-key";
|
||||
const KEY_RAW = "bmV3LWtleQ==";
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
type CreateType = "string" | "hash" | "list" | "set" | "zset" | "stream" | "json";
|
||||
type TestSelectRoot = HTMLElement & { selectTestValue?: (value: string) => void };
|
||||
function redisValue(keyRaw = KEY_RAW) {
|
||||
return {
|
||||
key_display: KEY_NAME,
|
||||
key_raw: keyRaw,
|
||||
ttl: 90,
|
||||
redis_type: "string" as const,
|
||||
data: { kind: "string" as const, content: { raw_base64: "dmFsdWU=", encoding: "utf8" as const } },
|
||||
};
|
||||
}
|
||||
|
||||
function redisKeyInfo(keyType = "json") {
|
||||
return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" };
|
||||
}
|
||||
|
||||
function resetApiMocks() {
|
||||
vi.clearAllMocks();
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
|
||||
mocks.redisSetString.mockResolvedValue(undefined);
|
||||
mocks.redisJsonSet.mockResolvedValue(undefined);
|
||||
mocks.redisHashSet.mockResolvedValue(undefined);
|
||||
mocks.redisListPush.mockResolvedValue(undefined);
|
||||
mocks.redisSetAdd.mockResolvedValue(undefined);
|
||||
mocks.redisZadd.mockResolvedValue(undefined);
|
||||
mocks.redisStreamAdd.mockResolvedValue(undefined);
|
||||
mocks.redisSetTtl.mockResolvedValue(undefined);
|
||||
mocks.redisSetExpireAt.mockResolvedValue(undefined);
|
||||
mocks.redisCheckJsonModule.mockResolvedValue(true);
|
||||
mocks.redisDeleteKey.mockResolvedValue(undefined);
|
||||
mocks.redisDeleteKeys.mockResolvedValue(0);
|
||||
}
|
||||
|
||||
function mountBrowser() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false });
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function requiredElement<T extends Element>(selector: string): T {
|
||||
const element = document.querySelector<T>(selector);
|
||||
expect(element, selector).not.toBeNull();
|
||||
return element!;
|
||||
}
|
||||
|
||||
function clickButtonWithText(text: string) {
|
||||
const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(button, text).toBeDefined();
|
||||
button!.click();
|
||||
}
|
||||
|
||||
async function setInput(selector: string, value: string) {
|
||||
const input = requiredElement<HTMLInputElement>(selector);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function select(value: string) {
|
||||
const item = requiredElement<HTMLButtonElement>(`[data-test-select-value="${value}"]`);
|
||||
const root = item.closest<TestSelectRoot>("[data-test-select-root]");
|
||||
expect(root).not.toBeNull();
|
||||
expect(root?.selectTestValue).toEqual(expect.any(Function));
|
||||
root!.selectTestValue!(value);
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function openCreateDialog() {
|
||||
requiredElement<HTMLButtonElement>('button[title="redis.createKey"]').click();
|
||||
await settle();
|
||||
await setInput('input[placeholder="redis.createKeyNamePlaceholder"]', KEY_NAME);
|
||||
}
|
||||
|
||||
async function fillCreateValue(type: CreateType) {
|
||||
if (type === "string") {
|
||||
const textarea = requiredElement<HTMLTextAreaElement>("textarea");
|
||||
textarea.value = "value";
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
return;
|
||||
}
|
||||
|
||||
await select(type);
|
||||
if (type === "json") {
|
||||
const textarea = requiredElement<HTMLTextAreaElement>("textarea");
|
||||
textarea.value = '{"value":true}';
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "hash" || type === "stream") {
|
||||
await setInput('input[placeholder="redis.createFieldPlaceholder"]', "field");
|
||||
await setInput('input[placeholder="redis.createValuePlaceholder"]', "value");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "zset") {
|
||||
await setInput('input[placeholder="0"]', "1");
|
||||
await setInput('input[placeholder="redis.createMember"]', "member");
|
||||
return;
|
||||
}
|
||||
|
||||
await setInput('input[placeholder="redis.createValuePlaceholder"]', "value");
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
clickButtonWithText("redis.createKeySubmit");
|
||||
await settle();
|
||||
}
|
||||
|
||||
function expectWriterBefore(mock: { mock: { invocationCallOrder: number[] } }, after: { mock: { invocationCallOrder: number[] } }) {
|
||||
expect(mock.mock.invocationCallOrder).toHaveLength(1);
|
||||
expect(after.mock.invocationCallOrder).toHaveLength(1);
|
||||
expect(mock.mock.invocationCallOrder[0]).toBeLessThan(after.mock.invocationCallOrder[0]!);
|
||||
}
|
||||
|
||||
const writerForType = {
|
||||
string: mocks.redisSetString,
|
||||
hash: mocks.redisHashSet,
|
||||
list: mocks.redisListPush,
|
||||
set: mocks.redisSetAdd,
|
||||
zset: mocks.redisZadd,
|
||||
stream: mocks.redisStreamAdd,
|
||||
json: mocks.redisJsonSet,
|
||||
} as const;
|
||||
|
||||
beforeEach(() => {
|
||||
resetApiMocks();
|
||||
setLocalTimeZone("UTC");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
resetLocalTimeZone();
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser expiry creation", () => {
|
||||
it.each(["string", "hash", "list", "set", "zset", "stream", "json"] as const)("writes %s before applying one relative TTL", async (type) => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCreateDialog();
|
||||
await fillCreateValue(type);
|
||||
await select("ttl");
|
||||
await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90");
|
||||
|
||||
await submitCreate();
|
||||
|
||||
const writer = writerForType[type];
|
||||
expect(mocks.redisSetTtl).toHaveBeenCalledWith("connection", 0, KEY_RAW, 90);
|
||||
expect(mocks.redisSetExpireAt).not.toHaveBeenCalled();
|
||||
expectWriterBefore(writer, mocks.redisSetTtl);
|
||||
});
|
||||
|
||||
it("uses PERSIST after a String write when no expiry is selected", async () => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCreateDialog();
|
||||
await fillCreateValue("string");
|
||||
|
||||
await submitCreate();
|
||||
|
||||
expect(mocks.redisSetTtl).toHaveBeenCalledWith("connection", 0, KEY_RAW, -1);
|
||||
expect(mocks.redisSetExpireAt).not.toHaveBeenCalled();
|
||||
expectWriterBefore(mocks.redisSetString, mocks.redisSetTtl);
|
||||
});
|
||||
|
||||
it("uses EXPIREAT after a String write when an absolute time is selected", async () => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCreateDialog();
|
||||
await fillCreateValue("string");
|
||||
await select("at");
|
||||
requiredElement<HTMLButtonElement>("[data-test-absolute-date]").click();
|
||||
await settle();
|
||||
|
||||
await submitCreate();
|
||||
|
||||
const expected = calendarDateTimeToUnixSeconds(new CalendarDateTime(2030, 1, 2, 3, 4, 5));
|
||||
expect(mocks.redisSetExpireAt).toHaveBeenCalledWith("connection", 0, KEY_RAW, expected);
|
||||
expect(mocks.redisSetTtl).not.toHaveBeenCalled();
|
||||
expectWriterBefore(mocks.redisSetString, mocks.redisSetExpireAt);
|
||||
});
|
||||
|
||||
it("does not roll back a written key when its expiry command fails and refreshes it", async () => {
|
||||
mocks.redisSetTtl.mockRejectedValueOnce(new Error("TTL command failed"));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCreateDialog();
|
||||
await fillCreateValue("string");
|
||||
await select("ttl");
|
||||
await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90");
|
||||
|
||||
await submitCreate();
|
||||
|
||||
expectWriterBefore(mocks.redisSetString, mocks.redisSetTtl);
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledWith("connection", 0, KEY_RAW);
|
||||
expect(mocks.redisDeleteKey).not.toHaveBeenCalled();
|
||||
expect(mocks.redisDeleteKeys).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000);
|
||||
});
|
||||
|
||||
it("removes an existing RedisJSON key only after recovery confirms its deletion", async () => {
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [redisKeyInfo()], total_keys: 1 });
|
||||
mocks.redisSetTtl.mockRejectedValueOnce(new Error("TTL command failed"));
|
||||
mocks.redisGetValue.mockRejectedValueOnce(new Error("RedisJSON key no longer exists")).mockRejectedValueOnce(new Error("RedisJSON key no longer exists"));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCreateDialog();
|
||||
await fillCreateValue("json");
|
||||
await select("ttl");
|
||||
await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90");
|
||||
|
||||
await submitCreate();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.updateRedisDbKeyStats).toHaveBeenCalledWith("connection", 0, { loaded: 0, totalDelta: -1 });
|
||||
expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000);
|
||||
});
|
||||
|
||||
it("keeps an existing RedisJSON key when retry cannot confirm its deletion", async () => {
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [redisKeyInfo()], total_keys: 1 });
|
||||
mocks.redisSetTtl.mockRejectedValueOnce(new Error("TTL command failed"));
|
||||
mocks.redisGetValue.mockRejectedValueOnce(new Error("RedisJSON key no longer exists")).mockRejectedValueOnce(new Error("network unavailable"));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCreateDialog();
|
||||
await fillCreateValue("json");
|
||||
await select("ttl");
|
||||
await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90");
|
||||
|
||||
await submitCreate();
|
||||
await settle();
|
||||
|
||||
expect(mocks.updateRedisDbKeyStats).not.toHaveBeenCalledWith("connection", 0, { loaded: 0, totalDelta: -1 });
|
||||
expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy } from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
@@ -14,6 +15,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { OptionHelpPanel } from "@/components/ui/option-help-panel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
@@ -37,8 +39,9 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
import { collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
|
||||
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
|
||||
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
|
||||
import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const editorFontFamilyStyle = useEditorFontFamilyStyle();
|
||||
@@ -67,6 +70,11 @@ const props = defineProps<{
|
||||
blockDangerousRedisCommands: boolean;
|
||||
}>();
|
||||
|
||||
const redisExpiryTransport = {
|
||||
setTtl: api.redisSetTtl,
|
||||
setExpireAt: api.redisSetExpireAt,
|
||||
};
|
||||
|
||||
const flatKeys = shallowRef<RedisKeyInfo[]>([]);
|
||||
const treeKeys = ref<RedisKeyTreeNode[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -102,10 +110,13 @@ const createKeyValue = ref("");
|
||||
const createKeyField = ref("");
|
||||
const createKeyScore = ref("0");
|
||||
const createKeyError = ref("");
|
||||
const createKeyExpiryMode = ref<RedisExpiryMode>("none");
|
||||
const createKeyTtl = ref("");
|
||||
const createKeyExpireAt = shallowRef<CalendarDateTime | null>(null);
|
||||
const createKeyEntries = ref<CreateKeyEntry[]>([]);
|
||||
const createKeyRawMode = ref(false);
|
||||
const createKeyEntryId = ref("*");
|
||||
const createKeyPartiallyWritten = ref(false);
|
||||
const jsonModuleAvailable = ref<boolean | null>(null);
|
||||
const checkingJsonModule = ref(false);
|
||||
const activeCreateKeyTypeHelp = ref<RedisCreateKeyType>();
|
||||
@@ -481,11 +492,14 @@ function onRowClick(node: RedisKeyTreeNode) {
|
||||
activeSidePanel.value = "detail";
|
||||
}
|
||||
|
||||
function onKeyDeleted() {
|
||||
if (!selectedKeyRaw.value) return;
|
||||
loadedKeyRaws.delete(selectedKeyRaw.value);
|
||||
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== selectedKeyRaw.value);
|
||||
selectedKeyRaw.value = null;
|
||||
function removeKnownKey(keyRaw: string) {
|
||||
if (!flatKeys.value.some((key) => key.key_raw === keyRaw)) return;
|
||||
loadedKeyRaws.delete(keyRaw);
|
||||
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw);
|
||||
if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null;
|
||||
const nextChecked = new Set(checkedKeys.value);
|
||||
nextChecked.delete(keyRaw);
|
||||
checkedKeys.value = nextChecked;
|
||||
rebuildTree(false);
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
@@ -493,6 +507,10 @@ function onKeyDeleted() {
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyDeleted(keyRaw: string) {
|
||||
removeKnownKey(keyRaw);
|
||||
}
|
||||
|
||||
function redisValueToKeyInfo(value: RedisValue): RedisKeyInfo {
|
||||
return {
|
||||
key_display: value.key_display,
|
||||
@@ -505,6 +523,10 @@ function redisValueToKeyInfo(value: RedisValue): RedisKeyInfo {
|
||||
}
|
||||
|
||||
function onKeyLoaded(value: RedisValue) {
|
||||
if (value.redis_type === "none") {
|
||||
removeKnownKey(value.key_raw);
|
||||
return;
|
||||
}
|
||||
const keyInfo = redisValueToKeyInfo(value);
|
||||
const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw);
|
||||
if (existingIndex < 0) return;
|
||||
@@ -705,6 +727,10 @@ function removeEntry(idx: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeWrittenCreateKeyEntry(entryId: number) {
|
||||
createKeyEntries.value = createKeyEntries.value.filter((entry) => entry.id !== entryId);
|
||||
}
|
||||
|
||||
function resetCreateKeyForm() {
|
||||
createKeyName.value = "";
|
||||
createKeyType.value = "string";
|
||||
@@ -712,9 +738,12 @@ function resetCreateKeyForm() {
|
||||
createKeyField.value = "";
|
||||
createKeyScore.value = "0";
|
||||
createKeyError.value = "";
|
||||
createKeyExpiryMode.value = "none";
|
||||
createKeyTtl.value = "";
|
||||
createKeyExpireAt.value = null;
|
||||
createKeyRawMode.value = false;
|
||||
createKeyEntryId.value = "*";
|
||||
createKeyPartiallyWritten.value = false;
|
||||
jsonModuleAvailable.value = null;
|
||||
checkingJsonModule.value = false;
|
||||
activeCreateKeyTypeHelp.value = undefined;
|
||||
@@ -723,7 +752,14 @@ function resetCreateKeyForm() {
|
||||
resetEntries();
|
||||
}
|
||||
|
||||
function expiryValidationMessage(reason: "ttl" | "date" | "past"): string {
|
||||
if (reason === "ttl") return t("redis.expiryTtlInvalid");
|
||||
if (reason === "date") return t("redis.expiryDateRequired");
|
||||
return t("redis.expiryDatePast");
|
||||
}
|
||||
|
||||
function onCreateKeyTypeChange(type: any) {
|
||||
if (creatingKey.value || createKeyPartiallyWritten.value) return;
|
||||
createKeyType.value = (type || "string") as RedisCreateKeyType;
|
||||
createKeyRawMode.value = false;
|
||||
jsonModuleAvailable.value = null;
|
||||
@@ -760,6 +796,12 @@ function openCreateKeyDialog() {
|
||||
showCreateKeyDialog.value = true;
|
||||
}
|
||||
|
||||
function onCreateKeyDialogOpenChange(open: boolean) {
|
||||
// A create request may have already written a Redis value; keep its recovery state visible.
|
||||
if (!open && creatingKey.value) return;
|
||||
showCreateKeyDialog.value = open;
|
||||
}
|
||||
|
||||
function upsertCreatedKey(value: RedisValue) {
|
||||
const keyInfo: RedisKeyInfo = {
|
||||
key_display: value.key_display,
|
||||
@@ -784,7 +826,42 @@ function upsertCreatedKey(value: RedisValue) {
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRedisMissingKeyError(error: unknown): boolean {
|
||||
return /^Redis(?:JSON)? key no longer exists(?:;|$)/.test(errorMessage(error));
|
||||
}
|
||||
|
||||
async function syncWrittenKey(keyRaw: string): Promise<boolean> {
|
||||
const created = await api.redisGetValue(props.connectionId, props.db, keyRaw);
|
||||
if (created.redis_type === "none") {
|
||||
removeKnownKey(keyRaw);
|
||||
return false;
|
||||
}
|
||||
upsertCreatedKey(created);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reflectWrittenKey(keyRaw: string) {
|
||||
try {
|
||||
await syncWrittenKey(keyRaw);
|
||||
} catch (error) {
|
||||
// RedisJSON can lose a key between TYPE and JSON.GET. Confirm the race
|
||||
// before removing a possibly recreated key from the browser.
|
||||
if (!isRedisMissingKeyError(error)) return;
|
||||
try {
|
||||
await syncWrittenKey(keyRaw);
|
||||
} catch (retryError) {
|
||||
if (isRedisMissingKeyError(retryError)) removeKnownKey(keyRaw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createRedisKey() {
|
||||
if (creatingKey.value) return;
|
||||
|
||||
const keyName = createKeyName.value.trim();
|
||||
if (!keyName) {
|
||||
createKeyError.value = t("redis.createKeyNameRequired");
|
||||
@@ -792,74 +869,137 @@ async function createRedisKey() {
|
||||
return;
|
||||
}
|
||||
|
||||
const expiryValidation = validateRedisExpiry(createKeyExpiryMode.value, createKeyTtl.value, createKeyExpireAt.value);
|
||||
if (!expiryValidation.valid) {
|
||||
const message = expiryValidationMessage(expiryValidation.reason);
|
||||
createKeyError.value = message;
|
||||
toast(message, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyType = createKeyType.value;
|
||||
const rawMode = createKeyRawMode.value;
|
||||
creatingKey.value = true;
|
||||
createKeyError.value = "";
|
||||
let wroteValue = false;
|
||||
let writtenKeyRaw: string | null = null;
|
||||
let writingStructuredEntries = false;
|
||||
try {
|
||||
const keyRaw = redisKeyTextToRaw(keyName);
|
||||
const ttl = createKeyTtl.value ? Number.parseInt(createKeyTtl.value) || undefined : undefined;
|
||||
writtenKeyRaw = keyRaw;
|
||||
const expiry = expiryValidation.policy;
|
||||
|
||||
if (createKeyType.value === "string" || createKeyType.value === "json" || createKeyRawMode.value) {
|
||||
if (keyType === "string" || keyType === "json" || rawMode) {
|
||||
// Raw text/JSON mode — single value
|
||||
if (createKeyType.value === "string") {
|
||||
await api.redisSetString(props.connectionId, props.db, keyRaw, createKeyValue.value, ttl ?? -1);
|
||||
} else if (createKeyType.value === "json") {
|
||||
await api.redisJsonSet(props.connectionId, props.db, keyRaw, createKeyValue.value, ttl);
|
||||
} else if (createKeyType.value === "hash") {
|
||||
await api.redisHashSet(props.connectionId, props.db, keyRaw, createKeyField.value, createKeyValue.value, ttl);
|
||||
} else if (createKeyType.value === "list") {
|
||||
await api.redisListPush(props.connectionId, props.db, keyRaw, createKeyValue.value, ttl);
|
||||
} else if (createKeyType.value === "set") {
|
||||
await api.redisSetAdd(props.connectionId, props.db, keyRaw, createKeyValue.value, ttl);
|
||||
} else if (createKeyType.value === "zset") {
|
||||
if (keyType === "string") {
|
||||
await api.redisSetString(props.connectionId, props.db, keyRaw, createKeyValue.value);
|
||||
wroteValue = true;
|
||||
} else if (keyType === "json") {
|
||||
await api.redisJsonSet(props.connectionId, props.db, keyRaw, createKeyValue.value);
|
||||
wroteValue = true;
|
||||
} else if (keyType === "hash") {
|
||||
await api.redisHashSet(props.connectionId, props.db, keyRaw, createKeyField.value, createKeyValue.value);
|
||||
wroteValue = true;
|
||||
} else if (keyType === "list") {
|
||||
await api.redisListPush(props.connectionId, props.db, keyRaw, createKeyValue.value);
|
||||
wroteValue = true;
|
||||
} else if (keyType === "set") {
|
||||
await api.redisSetAdd(props.connectionId, props.db, keyRaw, createKeyValue.value);
|
||||
wroteValue = true;
|
||||
} else if (keyType === "zset") {
|
||||
const score = Number.parseFloat(createKeyScore.value || "0");
|
||||
await api.redisZadd(props.connectionId, props.db, keyRaw, createKeyValue.value, score, ttl);
|
||||
await api.redisZadd(props.connectionId, props.db, keyRaw, createKeyValue.value, score);
|
||||
wroteValue = true;
|
||||
}
|
||||
} else {
|
||||
// Structured entries mode — insert each entry, then set TTL once
|
||||
if (createKeyType.value === "hash") {
|
||||
for (const entry of createKeyEntries.value) {
|
||||
// Write every member first so a single policy is applied to every key type.
|
||||
writingStructuredEntries = true;
|
||||
const pendingEntries = createKeyEntries.value.slice();
|
||||
if (keyType === "hash") {
|
||||
for (const entry of pendingEntries) {
|
||||
if (entry.field && entry.field.trim()) {
|
||||
await api.redisHashSet(props.connectionId, props.db, keyRaw, entry.field, entry.value);
|
||||
wroteValue = true;
|
||||
createKeyPartiallyWritten.value = true;
|
||||
removeWrittenCreateKeyEntry(entry.id);
|
||||
}
|
||||
}
|
||||
} else if (createKeyType.value === "list") {
|
||||
for (const entry of createKeyEntries.value) {
|
||||
} else if (keyType === "list") {
|
||||
for (const entry of pendingEntries) {
|
||||
if (entry.value) {
|
||||
await api.redisListPush(props.connectionId, props.db, keyRaw, entry.value);
|
||||
wroteValue = true;
|
||||
createKeyPartiallyWritten.value = true;
|
||||
removeWrittenCreateKeyEntry(entry.id);
|
||||
}
|
||||
}
|
||||
} else if (createKeyType.value === "set") {
|
||||
for (const entry of createKeyEntries.value) {
|
||||
} else if (keyType === "set") {
|
||||
for (const entry of pendingEntries) {
|
||||
if (entry.value) {
|
||||
await api.redisSetAdd(props.connectionId, props.db, keyRaw, entry.value);
|
||||
wroteValue = true;
|
||||
createKeyPartiallyWritten.value = true;
|
||||
removeWrittenCreateKeyEntry(entry.id);
|
||||
}
|
||||
}
|
||||
} else if (createKeyType.value === "zset") {
|
||||
for (const entry of createKeyEntries.value) {
|
||||
} else if (keyType === "zset") {
|
||||
for (const entry of pendingEntries) {
|
||||
if (entry.value) {
|
||||
const s = Number.parseFloat(entry.score || "0");
|
||||
if (!Number.isNaN(s)) {
|
||||
await api.redisZadd(props.connectionId, props.db, keyRaw, entry.value, s);
|
||||
wroteValue = true;
|
||||
createKeyPartiallyWritten.value = true;
|
||||
removeWrittenCreateKeyEntry(entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (createKeyType.value === "stream") {
|
||||
const fields: [string, string][] = createKeyEntries.value.filter((e) => e.field && e.field.trim()).map((e) => [e.field!.trim(), e.value]);
|
||||
} else if (keyType === "stream") {
|
||||
const fields: [string, string][] = pendingEntries.filter((e) => e.field && e.field.trim()).map((e) => [e.field!.trim(), e.value]);
|
||||
if (fields.length > 0) {
|
||||
const entryId = createKeyEntryId.value.trim() || "*";
|
||||
await api.redisStreamAdd(props.connectionId, props.db, keyRaw, entryId, fields, ttl);
|
||||
await api.redisStreamAdd(props.connectionId, props.db, keyRaw, entryId, fields);
|
||||
wroteValue = true;
|
||||
}
|
||||
}
|
||||
if (ttl) {
|
||||
await api.redisSetTtl(props.connectionId, props.db, keyRaw, ttl);
|
||||
}
|
||||
writingStructuredEntries = false;
|
||||
}
|
||||
|
||||
const created = await api.redisGetValue(props.connectionId, props.db, keyRaw);
|
||||
upsertCreatedKey(created);
|
||||
if (!wroteValue) {
|
||||
const message = keyType === "hash" || keyType === "stream" ? t("redis.fieldRequired") : t("redis.valueRequired");
|
||||
createKeyError.value = message;
|
||||
toast(message, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not roll back a successful write if this separate policy command fails.
|
||||
await applyRedisExpiryPolicy(redisExpiryTransport, props.connectionId, props.db, keyRaw, expiry);
|
||||
|
||||
if (!(await syncWrittenKey(keyRaw))) {
|
||||
showCreateKeyDialog.value = false;
|
||||
toast(t("redis.keyExpiredBeforeDisplay"), 3000);
|
||||
return;
|
||||
}
|
||||
showCreateKeyDialog.value = false;
|
||||
} catch (error) {
|
||||
createKeyError.value = error instanceof Error ? error.message : String(error);
|
||||
const message = errorMessage(error);
|
||||
if (writingStructuredEntries) {
|
||||
if (wroteValue && writtenKeyRaw) {
|
||||
await reflectWrittenKey(writtenKeyRaw);
|
||||
createKeyError.value = `${message} ${t("redis.createKeyPartialWrite")}`;
|
||||
toast(message, 5000);
|
||||
} else {
|
||||
createKeyError.value = message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (wroteValue && writtenKeyRaw) {
|
||||
await reflectWrittenKey(writtenKeyRaw);
|
||||
showCreateKeyDialog.value = false;
|
||||
toast(message, 5000);
|
||||
return;
|
||||
}
|
||||
createKeyError.value = message;
|
||||
} finally {
|
||||
creatingKey.value = false;
|
||||
}
|
||||
@@ -1371,8 +1511,8 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
|
||||
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="dangerMessage" :details="dangerDetails" :confirm-label="dangerConfirmLabel" @confirm="applyDangerAction" />
|
||||
|
||||
<Dialog v-model:open="showCreateKeyDialog">
|
||||
<DialogContent class="sm:max-w-md" :style="editorFontFamilyStyle">
|
||||
<Dialog :open="showCreateKeyDialog" @update:open="onCreateKeyDialogOpenChange">
|
||||
<DialogContent class="sm:max-w-md" :show-close-button="!creatingKey" :style="editorFontFamilyStyle">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("redis.createKey") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -1380,12 +1520,12 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<div class="grid gap-3">
|
||||
<label class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyName") }}</span>
|
||||
<Input v-model="createKeyName" class="dbx-editor-font-family h-8 text-xs" :placeholder="t('redis.createKeyNamePlaceholder')" @keydown.enter="createRedisKey" />
|
||||
<Input v-model="createKeyName" class="dbx-editor-font-family h-8 text-xs" :disabled="creatingKey || createKeyPartiallyWritten" :placeholder="t('redis.createKeyNamePlaceholder')" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyType") }}</span>
|
||||
<Select :model-value="createKeyType" @update:open="onCreateKeyTypeSelectOpen" @update:model-value="onCreateKeyTypeChange">
|
||||
<Select :model-value="createKeyType" :disabled="creatingKey || createKeyPartiallyWritten" @update:open="onCreateKeyTypeSelectOpen" @update:model-value="onCreateKeyTypeChange">
|
||||
<SelectTrigger class="h-8 text-xs" @keydown.capture="onCreateKeyTypeTriggerKeydown">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -1404,25 +1544,41 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
|
||||
<label v-if="createKeyType === 'hash' && createKeyRawMode" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createField") }}</span>
|
||||
<Input v-model="createKeyField" class="dbx-editor-font-family h-8 text-xs" :placeholder="t('redis.createFieldPlaceholder')" @keydown.enter="createRedisKey" />
|
||||
<Input v-model="createKeyField" class="dbx-editor-font-family h-8 text-xs" :disabled="creatingKey" :placeholder="t('redis.createFieldPlaceholder')" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
|
||||
<label v-if="createKeyType === 'zset' && createKeyRawMode" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createScore") }}</span>
|
||||
<Input v-model="createKeyScore" class="dbx-editor-font-family h-8 text-xs" placeholder="0" @keydown.enter="createRedisKey" />
|
||||
<Input v-model="createKeyScore" class="dbx-editor-font-family h-8 text-xs" :disabled="creatingKey" placeholder="0" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
|
||||
<!-- TTL input -- always visible -->
|
||||
<label class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyTtl") }}</span>
|
||||
<Input v-model="createKeyTtl" class="dbx-editor-font-family h-8 text-xs" type="number" min="0" :placeholder="t('redis.createKeyTtlPlaceholder')" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
<div class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.expiry") }}</span>
|
||||
<Select v-model="createKeyExpiryMode" :disabled="creatingKey">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{{ t("redis.expiryNone") }}</SelectItem>
|
||||
<SelectItem value="ttl">{{ t("redis.expiryTtl") }}</SelectItem>
|
||||
<SelectItem value="at">{{ t("redis.expiryAt") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label v-if="createKeyExpiryMode === 'ttl'" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyTtl") }}</span>
|
||||
<Input v-model="createKeyTtl" class="dbx-editor-font-family h-8 text-xs" :disabled="creatingKey" inputmode="numeric" :placeholder="t('redis.createKeyTtlPlaceholder')" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
<label v-else-if="createKeyExpiryMode === 'at'" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.expiryAt") }}</span>
|
||||
<DateTimePicker v-model="createKeyExpireAt" full-width :locale="locale" :disabled="creatingKey" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Raw mode toggle (non-string, non-stream, non-json types) -->
|
||||
<div v-if="createKeyType !== 'string' && createKeyType !== 'stream' && createKeyType !== 'json'" class="flex items-center justify-end gap-1.5">
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{{ t("redis.createKeyRawMode") }}</span>
|
||||
<Switch size="sm" v-model="createKeyRawMode" />
|
||||
<Switch size="sm" v-model="createKeyRawMode" :disabled="creatingKey || createKeyPartiallyWritten" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1431,13 +1587,13 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<!-- Stream entry ID -->
|
||||
<label v-if="createKeyType === 'stream'" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyEntryId") }}</span>
|
||||
<Input v-model="createKeyEntryId" class="dbx-editor-font-family h-8 text-xs font-mono" placeholder="*" />
|
||||
<Input v-model="createKeyEntryId" class="dbx-editor-font-family h-8 text-xs font-mono" :disabled="creatingKey" placeholder="*" />
|
||||
</label>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium">{{ t("redis.createKeyEntries") }}</span>
|
||||
<Button variant="outline" size="sm" class="h-6 gap-1 text-xs" @click="addEntry">
|
||||
<Button variant="outline" size="sm" class="h-6 gap-1 text-xs" :disabled="creatingKey" @click="addEntry">
|
||||
<Plus class="h-3 w-3" />
|
||||
{{ t("redis.createKeyAddEntry") }}
|
||||
</Button>
|
||||
@@ -1445,19 +1601,19 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<div v-for="(entry, idx) in createKeyEntries" :key="entry.id" class="flex items-start gap-2">
|
||||
<!-- Hash / Stream: field + value -->
|
||||
<template v-if="createKeyType === 'hash' || createKeyType === 'stream'">
|
||||
<Input v-model="entry.field" class="dbx-editor-font-family h-8 w-2/5 text-xs" :placeholder="t('redis.createFieldPlaceholder')" />
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
<Input v-model="entry.field" class="dbx-editor-font-family h-8 w-2/5 text-xs" :disabled="creatingKey" :placeholder="t('redis.createFieldPlaceholder')" />
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :disabled="creatingKey" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
</template>
|
||||
<!-- ZSet: score + member -->
|
||||
<template v-else-if="createKeyType === 'zset'">
|
||||
<Input v-model="entry.score" class="dbx-editor-font-family h-8 w-20 text-xs" type="number" step="any" placeholder="0" />
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :placeholder="t('redis.createMember')" />
|
||||
<Input v-model="entry.score" class="dbx-editor-font-family h-8 w-20 text-xs" :disabled="creatingKey" type="number" step="any" placeholder="0" />
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :disabled="creatingKey" :placeholder="t('redis.createMember')" />
|
||||
</template>
|
||||
<!-- List / Set: single value -->
|
||||
<template v-else>
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :disabled="creatingKey" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
</template>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-destructive" :disabled="createKeyEntries.length <= 1" @click="removeEntry(idx)">
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-destructive" :disabled="creatingKey || createKeyEntries.length <= 1" @click="removeEntry(idx)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1467,7 +1623,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<!-- Raw value textarea (string, json, or raw mode for other types) -->
|
||||
<label v-if="createKeyType === 'string' || createKeyType === 'json' || createKeyRawMode" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t(createKeyType === "set" || createKeyType === "zset" ? "redis.createMember" : "redis.createValue") }}</span>
|
||||
<textarea v-model="createKeyValue" class="dbx-editor-font-family min-h-28 resize-y rounded-md border bg-background p-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" spellcheck="false" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
<textarea v-model="createKeyValue" class="dbx-editor-font-family min-h-28 resize-y rounded-md border bg-background p-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" :disabled="creatingKey" spellcheck="false" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
</label>
|
||||
|
||||
<p v-if="createKeyError" class="text-xs text-destructive">{{ createKeyError }}</p>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// @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";
|
||||
import { calendarDateTimeToUnixSeconds, parseLocalDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
redisGetValue: vi.fn(),
|
||||
redisSetTtl: vi.fn(),
|
||||
redisSetExpireAt: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
redisGetValue: mocks.redisGetValue,
|
||||
redisSetTtl: mocks.redisSetTtl,
|
||||
redisSetExpireAt: mocks.redisSetExpireAt,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
|
||||
useEditorFontFamilyStyle: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/shikiJsonHighlighter", () => ({
|
||||
createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""),
|
||||
}));
|
||||
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function stringValue(rawBase64 = "dmFsdWU=", ttl = 60) {
|
||||
return {
|
||||
key_display: "key",
|
||||
key_raw: "key",
|
||||
ttl,
|
||||
redis_type: "string",
|
||||
data: { kind: "string" as const, content: { raw_base64: rawBase64, encoding: "utf8" as const } },
|
||||
};
|
||||
}
|
||||
|
||||
function missingValue() {
|
||||
return {
|
||||
key_display: "key",
|
||||
key_raw: "key",
|
||||
ttl: -2,
|
||||
redis_type: "none",
|
||||
data: { kind: "unknown" as const },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mountViewer(onDeleted: (keyRaw: string) => void) {
|
||||
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",
|
||||
onDeleted,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
}
|
||||
|
||||
async function saveTtlFromEditor() {
|
||||
await openTtlEditor();
|
||||
await saveOpenTtlEditor();
|
||||
}
|
||||
|
||||
async function openTtlEditor() {
|
||||
document.querySelector<HTMLButtonElement>("[data-slot='badge'][aria-label='redis.expiry']")!.click();
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function saveOpenTtlEditor() {
|
||||
document.querySelector<HTMLButtonElement>("[data-slot='button'][aria-label='grid.save']")!.click();
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function selectExpiryMode(mode: "none" | "at") {
|
||||
const trigger = document.querySelector<HTMLButtonElement>("[data-slot='select-trigger'][aria-label='redis.expiry']")!;
|
||||
trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
const index = mode === "none" ? 0 : 2;
|
||||
const option = document.querySelectorAll<HTMLElement>("[data-redis-expiry-mode-content] [role='option']")[index]!;
|
||||
option.focus();
|
||||
option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function setAbsoluteExpiry(value: string) {
|
||||
document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]")!.click();
|
||||
await settle();
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>("[data-date-time-picker-content] [data-date-time-picker-input]")!;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>("[data-date-time-picker-content] button"))
|
||||
.find((button) => button.textContent?.trim() === "Apply")!
|
||||
.click();
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function setStringDraft(value: string) {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>("textarea")!;
|
||||
textarea.value = value;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe("RedisValueViewer expiry saving", () => {
|
||||
it("removes a key that disappears while a TTL save fails", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(missingValue());
|
||||
mocks.redisSetTtl.mockRejectedValueOnce(new Error("Redis key no longer exists; EXPIRE was not applied"));
|
||||
const deleted = vi.fn();
|
||||
|
||||
mountViewer(deleted);
|
||||
await settle();
|
||||
|
||||
await saveTtlFromEditor();
|
||||
|
||||
expect(mocks.redisSetTtl).toHaveBeenCalledWith("connection", 0, "key", 60);
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
|
||||
expect(deleted).toHaveBeenCalledWith("key");
|
||||
expect(mocks.toast).toHaveBeenCalledWith("Redis key no longer exists; EXPIRE was not applied", 3000);
|
||||
});
|
||||
|
||||
it("keeps an unsaved value draft when a TTL save has an unrelated error", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(stringValue("c2VydmVy"));
|
||||
mocks.redisSetTtl.mockRejectedValueOnce(new Error("NOPERM this user has no permissions to run the 'expire' command"));
|
||||
const deleted = vi.fn();
|
||||
|
||||
mountViewer(deleted);
|
||||
await settle();
|
||||
|
||||
await setStringDraft("draft");
|
||||
await saveTtlFromEditor();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
|
||||
expect(document.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("draft");
|
||||
expect(deleted).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledWith("NOPERM this user has no permissions to run the 'expire' command", 3000);
|
||||
});
|
||||
|
||||
it("still removes a deleted key when another value draft is unsaved", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(missingValue());
|
||||
mocks.redisSetTtl.mockRejectedValueOnce(new Error("Redis key no longer exists; EXPIRE was not applied"));
|
||||
const deleted = vi.fn();
|
||||
|
||||
mountViewer(deleted);
|
||||
await settle();
|
||||
await setStringDraft("draft");
|
||||
await saveTtlFromEditor();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
|
||||
expect(deleted).toHaveBeenCalledWith("key");
|
||||
});
|
||||
|
||||
it("keeps an unsaved value draft after a successful TTL save", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(stringValue("c2VydmVy", 42));
|
||||
mocks.redisSetTtl.mockResolvedValueOnce(undefined);
|
||||
|
||||
mountViewer(vi.fn());
|
||||
await settle();
|
||||
await setStringDraft("draft");
|
||||
await saveTtlFromEditor();
|
||||
|
||||
expect(document.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("draft");
|
||||
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:42");
|
||||
});
|
||||
|
||||
it("removes a key that disappears while refreshing after a successful TTL save", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockRejectedValueOnce(new Error("RedisJSON key no longer exists")).mockResolvedValueOnce(missingValue());
|
||||
mocks.redisSetTtl.mockResolvedValueOnce(undefined);
|
||||
const deleted = vi.fn();
|
||||
|
||||
mountViewer(deleted);
|
||||
await settle();
|
||||
await saveTtlFromEditor();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(3);
|
||||
expect(deleted).toHaveBeenCalledWith("key");
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes a key when two refresh reads confirm a RedisJSON deletion", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockRejectedValueOnce(new Error("RedisJSON key no longer exists")).mockRejectedValueOnce(new Error("RedisJSON key no longer exists"));
|
||||
mocks.redisSetTtl.mockResolvedValueOnce(undefined);
|
||||
const deleted = vi.fn();
|
||||
|
||||
mountViewer(deleted);
|
||||
await settle();
|
||||
await saveTtlFromEditor();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(3);
|
||||
expect(deleted).toHaveBeenCalledOnce();
|
||||
expect(deleted).toHaveBeenCalledWith("key");
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not remove a key from an old expiry error when refresh cannot confirm its state", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockRejectedValueOnce(new Error("network unavailable")).mockRejectedValueOnce(new Error("network unavailable"));
|
||||
mocks.redisSetTtl.mockRejectedValueOnce(new Error("Redis key no longer exists; EXPIRE was not applied"));
|
||||
const deleted = vi.fn();
|
||||
|
||||
mountViewer(deleted);
|
||||
await settle();
|
||||
await saveTtlFromEditor();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(3);
|
||||
expect(deleted).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledWith("Redis key no longer exists; EXPIRE was not applied", 3000);
|
||||
});
|
||||
|
||||
it("persists a key when the editor is switched to no expiry", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(stringValue("dmFsdWU=", -1));
|
||||
mocks.redisSetTtl.mockResolvedValueOnce(undefined);
|
||||
|
||||
mountViewer(vi.fn());
|
||||
await settle();
|
||||
await openTtlEditor();
|
||||
await selectExpiryMode("none");
|
||||
await saveOpenTtlEditor();
|
||||
|
||||
expect(mocks.redisSetTtl).toHaveBeenCalledOnce();
|
||||
expect(mocks.redisSetTtl).toHaveBeenCalledWith("connection", 0, "key", -1);
|
||||
expect(mocks.redisSetExpireAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses EXPIREAT with the selected local date and time", async () => {
|
||||
const localDateTime = "2099-02-03 04:05:06";
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
|
||||
mocks.redisSetExpireAt.mockResolvedValueOnce(undefined);
|
||||
|
||||
mountViewer(vi.fn());
|
||||
await settle();
|
||||
await openTtlEditor();
|
||||
await selectExpiryMode("at");
|
||||
await setAbsoluteExpiry(localDateTime);
|
||||
await saveOpenTtlEditor();
|
||||
|
||||
expect(mocks.redisSetExpireAt).toHaveBeenCalledOnce();
|
||||
expect(mocks.redisSetExpireAt).toHaveBeenCalledWith("connection", 0, "key", calendarDateTimeToUnixSeconds(parseLocalDateTime(localDateTime)!));
|
||||
expect(mocks.redisSetTtl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit a second expiry policy while the first is pending", async () => {
|
||||
const pending = deferred<void>();
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(stringValue());
|
||||
mocks.redisSetTtl.mockImplementationOnce(() => pending.promise);
|
||||
|
||||
mountViewer(vi.fn());
|
||||
await settle();
|
||||
document.querySelector<HTMLButtonElement>("[data-slot='badge'][aria-label='redis.expiry']")!.click();
|
||||
await settle();
|
||||
|
||||
const save = document.querySelector<HTMLButtonElement>("[data-slot='button'][aria-label='grid.save']")!;
|
||||
save.click();
|
||||
await nextTick();
|
||||
expect(save.disabled).toBe(true);
|
||||
save.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(mocks.redisSetTtl).toHaveBeenCalledOnce();
|
||||
|
||||
pending.resolve();
|
||||
await settle();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import { computed, ref, shallowRef, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
|
||||
@@ -8,7 +9,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import JsonTree from "@/components/common/JsonTree.vue";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
@@ -20,7 +23,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
|
||||
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatTtl } from "@/lib/common/ttlFormat";
|
||||
import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
|
||||
import { computeAutoRefreshTick, computeDisplayTtl, computeTtlForExpiryEdit, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
|
||||
import {
|
||||
canRenderRedisValueFormat,
|
||||
canEditRedisMemberDetail,
|
||||
@@ -45,8 +48,10 @@ import {
|
||||
import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch";
|
||||
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
|
||||
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
|
||||
import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { isDark } = useTheme();
|
||||
const editorFontFamilyStyle = useEditorFontFamilyStyle();
|
||||
@@ -59,7 +64,12 @@ const props = defineProps<{
|
||||
metadata?: RedisKeyInfo | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ deleted: []; loaded: [value: RedisValue] }>();
|
||||
const redisExpiryTransport = {
|
||||
setTtl: api.redisSetTtl,
|
||||
setExpireAt: api.redisSetExpireAt,
|
||||
};
|
||||
|
||||
const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue] }>();
|
||||
|
||||
const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap";
|
||||
const REDIS_VALUE_FORMAT_STORAGE_KEY = "dbx-redis-value-format";
|
||||
@@ -79,12 +89,19 @@ const newScore = ref("");
|
||||
const showDeleteConfirm = ref(false);
|
||||
const showMemberDetail = ref(false);
|
||||
const editingTtl = ref(false);
|
||||
const savingTtl = ref(false);
|
||||
const ttlExpiryMode = ref<RedisExpiryMode>("none");
|
||||
const ttlInput = ref("");
|
||||
const ttlExpireAt = shallowRef<CalendarDateTime | null>(null);
|
||||
const ttlInputEl = ref<InstanceType<typeof Input>>();
|
||||
const editTtlWrapper = ref<HTMLElement>();
|
||||
onClickOutside(editTtlWrapper, () => {
|
||||
if (editingTtl.value) cancelEditTtl();
|
||||
});
|
||||
onClickOutside(
|
||||
editTtlWrapper,
|
||||
() => {
|
||||
if (editingTtl.value && !savingTtl.value) cancelEditTtl();
|
||||
},
|
||||
{ ignore: ["[data-date-time-picker-content]", "[data-redis-expiry-mode-content]"] },
|
||||
);
|
||||
const collectionItems = ref<RedisCollectionItem[]>([]);
|
||||
const scanCursor = ref<number | undefined>(undefined);
|
||||
const selectedMemberTitle = ref("");
|
||||
@@ -595,7 +612,28 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
|
||||
loading.value = true;
|
||||
try {
|
||||
const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
|
||||
if (requestId !== loadRequestId || (options.preserveDraft && hasUnsavedRedisDraft.value)) return false;
|
||||
if (requestId !== loadRequestId) return false;
|
||||
|
||||
// Redis reports a key that expired between refreshes as a `none` value.
|
||||
// Tell the browser to remove it instead of rendering a stale detail shell.
|
||||
if (loadedValue.redis_type === "none") {
|
||||
data.value = null;
|
||||
collectionItems.value = [];
|
||||
scanCursor.value = undefined;
|
||||
stopAutoRefresh();
|
||||
emit("deleted", props.keyRaw);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.preserveDraft && hasUnsavedRedisDraft.value) {
|
||||
const currentValue = data.value;
|
||||
if (currentValue) {
|
||||
const preservedValue = { ...currentValue, ttl: loadedValue.ttl };
|
||||
data.value = preservedValue;
|
||||
emit("loaded", preservedValue);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hashSearchTimer) clearTimeout(hashSearchTimer);
|
||||
hashSearchTimer = null;
|
||||
@@ -718,7 +756,7 @@ function discardRedisJsonEdit() {
|
||||
|
||||
async function applyDeleteKey() {
|
||||
await api.redisDeleteKey(props.connectionId, props.db, props.keyRaw);
|
||||
emit("deleted");
|
||||
emit("deleted", props.keyRaw);
|
||||
}
|
||||
|
||||
function requestDeleteKey() {
|
||||
@@ -1151,27 +1189,108 @@ function requestZsetRemove(member: string | null) {
|
||||
}
|
||||
|
||||
// TTL
|
||||
function startEditTtl() {
|
||||
if (!data.value) return;
|
||||
ttlInput.value = data.value.ttl > 0 ? String(data.value.ttl) : "";
|
||||
editingTtl.value = true;
|
||||
void nextTick(() => ttlInputEl.value?.$el?.focus());
|
||||
function currentEditableTtl(): number {
|
||||
if (!data.value) return -1;
|
||||
return computeTtlForExpiryEdit(autoRefreshEnabled.value, countdownTtl.value, data.value.ttl);
|
||||
}
|
||||
|
||||
function expiryValidationMessage(reason: "ttl" | "date" | "past"): string {
|
||||
if (reason === "ttl") return t("redis.expiryTtlInvalid");
|
||||
if (reason === "date") return t("redis.expiryDateRequired");
|
||||
return t("redis.expiryDatePast");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRedisMissingKeyError(error: unknown): boolean {
|
||||
return /^Redis(?:JSON)? key no longer exists(?:;|$)/.test(errorMessage(error));
|
||||
}
|
||||
|
||||
async function refreshTtlState(missingSignal?: unknown): Promise<unknown | null> {
|
||||
try {
|
||||
await load({ preserveDraft: true });
|
||||
return null;
|
||||
} catch (error) {
|
||||
// RedisJSON can lose a key between TYPE and JSON.GET. Retry that specific
|
||||
// race, but do not remove a key based only on an earlier command result.
|
||||
if (!isRedisMissingKeyError(missingSignal) && !isRedisMissingKeyError(error)) return error;
|
||||
try {
|
||||
await load({ preserveDraft: true });
|
||||
return null;
|
||||
} catch (retryError) {
|
||||
if (isRedisMissingKeyError(retryError)) {
|
||||
emit("deleted", props.keyRaw);
|
||||
return null;
|
||||
}
|
||||
return retryError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusTtlExpiryControl(mode = ttlExpiryMode.value) {
|
||||
void nextTick(() => {
|
||||
if (!editingTtl.value) return;
|
||||
if (mode === "ttl") {
|
||||
ttlInputEl.value?.$el?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const selector = mode === "at" ? "[data-date-time-picker-trigger]" : "[data-slot='select-trigger']";
|
||||
editTtlWrapper.value?.querySelector<HTMLElement>(selector)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function startEditTtl() {
|
||||
if (!data.value || savingTtl.value) return;
|
||||
const ttl = currentEditableTtl();
|
||||
ttlExpiryMode.value = redisExpiryModeForTtl(ttl);
|
||||
ttlInput.value = ttl > 0 ? String(ttl) : "";
|
||||
ttlExpireAt.value = null;
|
||||
editingTtl.value = true;
|
||||
focusTtlExpiryControl();
|
||||
}
|
||||
|
||||
watch(ttlExpiryMode, (mode, previousMode) => {
|
||||
const ttl = currentEditableTtl();
|
||||
if (mode === "at" && previousMode !== "at" && ttl > 0) {
|
||||
// Convert the live TTL at the moment of switching, not the stale fetched value.
|
||||
ttlExpireAt.value = unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl);
|
||||
}
|
||||
if (editingTtl.value && mode !== previousMode) focusTtlExpiryControl(mode);
|
||||
});
|
||||
|
||||
async function saveTtl() {
|
||||
const val = ttlInput.value.trim();
|
||||
const ttl = val === "" || val === "-1" ? -1 : parseInt(val, 10);
|
||||
if (isNaN(ttl)) {
|
||||
toast(t("redis.ttlInvalid"), 3000);
|
||||
if (savingTtl.value) return;
|
||||
const validation = validateRedisExpiry(ttlExpiryMode.value, ttlInput.value, ttlExpireAt.value);
|
||||
if (!validation.valid) {
|
||||
const message = expiryValidationMessage(validation.reason);
|
||||
toast(message, 3000);
|
||||
return;
|
||||
}
|
||||
await api.redisSetTtl(props.connectionId, props.db, props.keyRaw, ttl);
|
||||
editingTtl.value = false;
|
||||
await load();
|
||||
savingTtl.value = true;
|
||||
try {
|
||||
try {
|
||||
await applyRedisExpiryPolicy(redisExpiryTransport, props.connectionId, props.db, props.keyRaw, validation.policy);
|
||||
} catch (error) {
|
||||
// The expiry command may have raced with a deletion or succeeded before a transport error.
|
||||
await refreshTtlState(error);
|
||||
toast(errorMessage(error), 3000);
|
||||
return;
|
||||
}
|
||||
editingTtl.value = false;
|
||||
const refreshError = await refreshTtlState();
|
||||
if (refreshError) toast(errorMessage(refreshError), 3000);
|
||||
} finally {
|
||||
savingTtl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditTtl() {
|
||||
if (savingTtl.value) return;
|
||||
editingTtl.value = false;
|
||||
ttlExpireAt.value = null;
|
||||
}
|
||||
|
||||
// Hash
|
||||
@@ -1453,12 +1572,27 @@ defineExpose({ focusSearch });
|
||||
<Badge variant="secondary" class="dbx-editor-font-family text-xs uppercase">{{ data.redis_type }}</Badge>
|
||||
<Badge v-if="metadataSizeLabel" variant="outline" class="text-xs text-muted-foreground"> {{ t("redis.columnSize") }}: {{ metadataSizeLabel }} </Badge>
|
||||
<template v-if="!editingTtl">
|
||||
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">TTL: {{ formatTtl(computeDisplayTtl(autoRefreshEnabled, countdownTtl, data.ttl), t) }}</Badge>
|
||||
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">{{ t("redis.noExpiry") }}</Badge>
|
||||
<Badge v-if="data.ttl > 0" as="button" type="button" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" :disabled="savingTtl" :aria-label="t('redis.expiry')" @click="startEditTtl">
|
||||
TTL: {{ formatTtl(computeDisplayTtl(autoRefreshEnabled, countdownTtl, data.ttl), t) }}
|
||||
</Badge>
|
||||
<Badge v-else-if="data.ttl === -1" as="button" type="button" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" :disabled="savingTtl" :aria-label="t('redis.expiry')" @click="startEditTtl">
|
||||
{{ t("redis.noExpiry") }}
|
||||
</Badge>
|
||||
</template>
|
||||
<div ref="editTtlWrapper" v-else class="flex items-center gap-1">
|
||||
<Input ref="ttlInputEl" v-model="ttlInput" class="h-6 w-20 text-xs" placeholder="seconds (-1=no expiry)" @keydown.enter="saveTtl" @keydown.escape="cancelEditTtl" />
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="saveTtl"><Save class="h-3 w-3" /></Button>
|
||||
<div ref="editTtlWrapper" v-else class="flex min-w-0 max-w-full flex-wrap items-center gap-1">
|
||||
<Select v-model="ttlExpiryMode" :disabled="savingTtl">
|
||||
<SelectTrigger size="sm" class="h-6 max-w-[min(100%,14rem)] shrink-0 gap-1 py-0 pl-2 pr-1.5 text-[11px]" :aria-label="t('redis.expiry')">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent data-redis-expiry-mode-content class="min-w-[12rem]">
|
||||
<SelectItem value="none">{{ t("redis.expiryNone") }}</SelectItem>
|
||||
<SelectItem value="ttl">{{ t("redis.expiryTtl") }}</SelectItem>
|
||||
<SelectItem value="at">{{ t("redis.expiryAt") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input v-if="ttlExpiryMode === 'ttl'" ref="ttlInputEl" v-model="ttlInput" class="h-6 w-28 shrink-0 text-xs" :disabled="savingTtl" inputmode="numeric" :placeholder="t('redis.createKeyTtlPlaceholder')" @keydown.enter="saveTtl" @keydown.escape="cancelEditTtl" />
|
||||
<DateTimePicker v-else-if="ttlExpiryMode === 'at'" v-model="ttlExpireAt" compact :locale="locale" :disabled="savingTtl" />
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="savingTtl" :title="t('grid.save')" :aria-label="t('grid.save')" @click="saveTtl"><Save class="h-3 w-3" /></Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :class="{ 'text-primary bg-accent': autoRefreshEnabled }" :title="t('redis.autoRefresh')" @click="toggleAutoRefresh">
|
||||
<Clock class="h-3.5 w-3.5" />
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { CalendarDateTime } from "@internationalized/date";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import DateTimePicker from "./DateTimePicker.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function mountPickerInDialog() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const update = vi.fn();
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const dialogOpen = ref(true);
|
||||
return () =>
|
||||
h(
|
||||
Dialog,
|
||||
{
|
||||
open: dialogOpen.value,
|
||||
"onUpdate:open": (open: boolean) => {
|
||||
dialogOpen.value = open;
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
DialogContent,
|
||||
{ showCloseButton: false },
|
||||
{
|
||||
default: () => [
|
||||
h(DialogTitle, null, { default: () => "Create key" }),
|
||||
h("button", { type: "button", "data-dialog-outside": true }, "Dialog action"),
|
||||
h(DateTimePicker, {
|
||||
modelValue: new CalendarDateTime(2024, 2, 28, 8, 9, 10),
|
||||
"onUpdate:modelValue": update,
|
||||
}),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
return { update };
|
||||
}
|
||||
|
||||
async function waitForPopover() {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("DateTimePicker in a dialog", () => {
|
||||
it("opens its teleported picker when the trigger is clicked", async () => {
|
||||
mountPickerInDialog();
|
||||
await nextTick();
|
||||
const trigger = document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]");
|
||||
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger!.click();
|
||||
await waitForPopover();
|
||||
|
||||
const content = document.querySelector<HTMLElement>("[data-date-time-picker-content]");
|
||||
expect(content).not.toBeNull();
|
||||
expect(content?.getAttribute("data-state")).toBe("open");
|
||||
});
|
||||
|
||||
it("uses a roving date grid that supports arrow-key navigation", async () => {
|
||||
mountPickerInDialog();
|
||||
await nextTick();
|
||||
document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]")!.click();
|
||||
await waitForPopover();
|
||||
|
||||
const grid = document.querySelector<HTMLElement>("[data-date-time-picker-content] [role='grid']");
|
||||
const currentDay = document.querySelector<HTMLButtonElement>("[data-date-time-picker-day='2024-02-28']");
|
||||
const nextDay = document.querySelector<HTMLButtonElement>("[data-date-time-picker-day='2024-02-29']");
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(Array.from(grid!.children).filter((child) => child.getAttribute("role") === "row").length).toBeGreaterThan(1);
|
||||
expect(currentDay?.getAttribute("role")).toBeNull();
|
||||
expect(currentDay?.parentElement?.getAttribute("role")).toBe("gridcell");
|
||||
expect(currentDay?.tabIndex).toBe(0);
|
||||
|
||||
currentDay!.focus();
|
||||
currentDay!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(document.activeElement).toBe(nextDay);
|
||||
expect(nextDay?.parentElement?.getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
nextDay!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const nextWeek = document.querySelector<HTMLButtonElement>("[data-date-time-picker-day='2024-03-07']");
|
||||
expect(document.activeElement).toBe(nextWeek);
|
||||
expect(nextWeek?.parentElement?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("cancels its draft on a dialog-internal outside click without closing the dialog", async () => {
|
||||
const { update } = mountPickerInDialog();
|
||||
await nextTick();
|
||||
|
||||
document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]")!.click();
|
||||
await waitForPopover();
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>("[data-date-time-picker-input]")!;
|
||||
input.value = "2024-03-01 11:12:13";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(input.value).toBe("2024-03-01 11:12:13");
|
||||
|
||||
const dialogAction = document.querySelector<HTMLButtonElement>("[data-dialog-outside]")!;
|
||||
dialogAction.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
dialogAction.click();
|
||||
await waitForPopover();
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("[data-slot='dialog-content']")).not.toBeNull();
|
||||
expect(document.querySelector("[data-date-time-picker-content]")).toBeNull();
|
||||
|
||||
document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]")!.click();
|
||||
await waitForPopover();
|
||||
expect(document.querySelector<HTMLInputElement>("[data-date-time-picker-input]")?.value).toBe("2024-02-28 08:09:10");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { CalendarDateTime } from "@internationalized/date";
|
||||
import { nextTick } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { dispatch, findOne, hostText, mountComponent } from "@/components/grid/__tests__/vueHostHarness";
|
||||
|
||||
vi.mock("vue-i18n", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return {
|
||||
useI18n: () => ({ locale: ref("en"), t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
const icon = createPassthroughStub("Icon", "i");
|
||||
return { CalendarClock: icon, ChevronLeft: icon, ChevronRight: icon };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") }));
|
||||
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
return {
|
||||
Popover: createPassthroughStub("Popover"),
|
||||
PopoverContent: createPassthroughStub("PopoverContent"),
|
||||
PopoverTrigger: createPassthroughStub("PopoverTrigger"),
|
||||
};
|
||||
});
|
||||
|
||||
import DateTimePicker from "./DateTimePicker.vue";
|
||||
|
||||
function picker(options: Record<string, unknown> = {}) {
|
||||
return mountComponent(DateTimePicker, {
|
||||
open: true,
|
||||
modelValue: new CalendarDateTime(2024, 2, 28, 8, 9, 10),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function inputFor(mounted: ReturnType<typeof picker>) {
|
||||
return findOne(mounted.root, (node) => node.props["data-date-time-picker-input"] !== undefined);
|
||||
}
|
||||
|
||||
function buttonWithText(mounted: ReturnType<typeof picker>, text: string) {
|
||||
return findOne(mounted.root, (node) => node.type === "button" && hostText(node).trim() === text);
|
||||
}
|
||||
|
||||
describe("DateTimePicker", () => {
|
||||
it("keeps typed and calendar changes in the draft until Apply", async () => {
|
||||
const update = vi.fn();
|
||||
const mounted = picker({ "onUpdate:modelValue": update });
|
||||
|
||||
dispatch(inputFor(mounted), "input", { target: { value: "2024-02-29 11:12:13" } });
|
||||
await nextTick();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.props["data-date-time-picker-day"] === "2024-02-29"),
|
||||
"click",
|
||||
);
|
||||
dispatch(buttonWithText(mounted, "Apply"), "click");
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update.mock.calls[0][0]).toMatchObject({ year: 2024, month: 2, day: 29, hour: 11, minute: 12, second: 13 });
|
||||
});
|
||||
|
||||
it("updates second-level time segments without committing early", async () => {
|
||||
const update = vi.fn();
|
||||
const mounted = picker({ "onUpdate:modelValue": update });
|
||||
const hour = findOne(mounted.root, (node) => node.props["data-date-time-picker-time-part"] === "hour");
|
||||
|
||||
dispatch(hour, "input", { target: { value: "7" } });
|
||||
await nextTick();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
dispatch(hour, "keydown", { key: "Enter" });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
dispatch(buttonWithText(mounted, "Apply"), "click");
|
||||
expect(update.mock.calls[0][0]).toMatchObject({ hour: 7, minute: 9, second: 10 });
|
||||
});
|
||||
|
||||
it("discards the draft on Cancel, Escape, and popover outside-close", async () => {
|
||||
const cancel = vi.fn();
|
||||
const mounted = picker({ onCancel: cancel });
|
||||
|
||||
dispatch(inputFor(mounted), "input", { target: { value: "2024-03-01 11:12:13" } });
|
||||
dispatch(buttonWithText(mounted, "Cancel"), "click");
|
||||
await nextTick();
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(inputFor(mounted).props.value).toBe("2024-02-28 08:09:10");
|
||||
|
||||
dispatch(inputFor(mounted), "keydown", { key: "Escape" });
|
||||
expect(cancel).toHaveBeenCalledTimes(2);
|
||||
await nextTick();
|
||||
|
||||
const popover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover");
|
||||
popover.props["onUpdate:open"](false);
|
||||
expect(cancel).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("prevents invalid and disabled picker drafts from applying", async () => {
|
||||
const update = vi.fn();
|
||||
const mounted = picker({ min: new CalendarDateTime(2024, 3, 1, 0, 0, 0), "onUpdate:modelValue": update });
|
||||
|
||||
expect(buttonWithText(mounted, "Apply").props.disabled).toBe(true);
|
||||
dispatch(buttonWithText(mounted, "Apply"), "click");
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
await mounted.setProps({ disabled: true });
|
||||
expect(buttonWithText(mounted, "Apply").props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves popover autofocus enabled for keyboard access to teleported content", () => {
|
||||
const mounted = picker();
|
||||
const content = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
|
||||
|
||||
expect(content.props.onOpenAutoFocus).toBeUndefined();
|
||||
expect(content.props.onCloseAutoFocus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the popover within the available viewport height", () => {
|
||||
const mounted = picker();
|
||||
const content = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
|
||||
|
||||
expect(content.props["collision-padding"] ?? content.props.collisionPadding).toBe(8);
|
||||
expect(content.props.class).toContain("max-h-[var(--reka-popover-content-available-height)]");
|
||||
expect(content.props.class).toContain("overflow-y-auto");
|
||||
expect(content.props.class).toContain("max-w-[calc(100vw-1rem)]");
|
||||
});
|
||||
|
||||
it("can fill the available width when used as a form control", () => {
|
||||
const mounted = picker({ fullWidth: true });
|
||||
const control = findOne(mounted.root, (node) => node.props["data-date-time-picker"] !== undefined);
|
||||
const trigger = findOne(mounted.root, (node) => node.props["data-date-time-picker-trigger"] !== undefined);
|
||||
|
||||
expect(control.props.class).toContain("w-full");
|
||||
expect(trigger.props.class).toContain("w-full");
|
||||
});
|
||||
|
||||
it("keeps selectable adjacent-month dates at muted semantic contrast", () => {
|
||||
const mounted = picker();
|
||||
const adjacentDay = findOne(mounted.root, (node) => node.props["data-date-time-picker-day"] === "2024-01-28");
|
||||
|
||||
expect(adjacentDay.props.class).toContain("text-muted-foreground");
|
||||
expect(adjacentDay.props.class).not.toContain("text-muted-foreground/60");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, shallowRef, useId, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { endOfMonth, startOfMonth, startOfWeek } from "@internationalized/date";
|
||||
import { CalendarClock, ChevronLeft, ChevronRight } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { calendarDateTimeFromFields, currentLocalDateTime, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, type CalendarDateTimeLike } from "./dateTimePicker";
|
||||
|
||||
type TimePart = "hour" | "minute" | "second";
|
||||
|
||||
type DateTimePickerLabel = "open" | "inputLabel" | "inputPlaceholder" | "previousMonth" | "nextMonth" | "calendarLabel" | "timeLabel" | "hour" | "minute" | "second" | "cancel" | "apply" | "invalidDateTime" | "outOfRange";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: CalendarDateTimeLike | null;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
fullWidth?: boolean;
|
||||
locale?: string;
|
||||
min?: CalendarDateTimeLike | null;
|
||||
max?: CalendarDateTimeLike | null;
|
||||
placeholder?: string;
|
||||
labels?: Partial<Record<DateTimePickerLabel, string>>;
|
||||
}>(),
|
||||
{
|
||||
// Keep the picker uncontrolled when callers omit `open`.
|
||||
open: undefined,
|
||||
defaultOpen: false,
|
||||
disabled: false,
|
||||
compact: false,
|
||||
fullWidth: false,
|
||||
placeholder: undefined,
|
||||
labels: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: CalendarDateTime];
|
||||
"update:open": [value: boolean];
|
||||
apply: [value: CalendarDateTime];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const localOpen = ref(props.defaultOpen);
|
||||
const inputId = useId();
|
||||
const draft = shallowRef<CalendarDateTime | null>(null);
|
||||
const localInputText = ref("");
|
||||
const visibleMonth = shallowRef<CalendarDateTime>(currentLocalDateTime());
|
||||
const calendarGridRef = ref<HTMLElement>();
|
||||
const calendarFocus = shallowRef<CalendarDateTime | null>(null);
|
||||
const closeReason = ref<"apply" | "cancel" | null>(null);
|
||||
const timeInputs = reactive<Record<TimePart, string>>({ hour: "00", minute: "00", second: "00" });
|
||||
const invalidTimeInputs = reactive<Record<TimePart, boolean>>({ hour: false, minute: false, second: false });
|
||||
|
||||
const isOpen = computed(() => props.open ?? localOpen.value);
|
||||
const resolvedLocale = computed(() => props.locale || appLocale.value || "en");
|
||||
const parsedInput = computed(() => parseLocalDateTime(localInputText.value));
|
||||
const hasInvalidTimeInput = computed(() => Object.values(invalidTimeInputs).some(Boolean));
|
||||
const validationMessage = computed(() => {
|
||||
if (hasInvalidTimeInput.value || !parsedInput.value) return label("invalidDateTime", "Enter a valid local date and time.");
|
||||
if (!isValidLocalDateTime(parsedInput.value)) return label("invalidDateTime", "Enter a valid local date and time.");
|
||||
if (!isWithinRange(parsedInput.value)) return label("outOfRange", "Choose a date and time within the allowed range.");
|
||||
return null;
|
||||
});
|
||||
const canApply = computed(() => !props.disabled && !validationMessage.value && !!parsedInput.value);
|
||||
const triggerText = computed(() => (props.modelValue ? formatLocalDateTime(props.modelValue) : props.placeholder || label("inputPlaceholder", "YYYY-MM-DD HH:mm:ss")));
|
||||
const monthHeading = computed(() => formatMonth(visibleMonth.value, resolvedLocale.value));
|
||||
const weekDays = computed(() => calendarWeekDays(resolvedLocale.value));
|
||||
const calendarWeeks = computed(() => buildCalendarWeeks(visibleMonth.value, resolvedLocale.value));
|
||||
const today = computed(() => currentLocalDateTime());
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!isOpen.value) resetDraft();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open, previous) => {
|
||||
if (open === previous) return;
|
||||
if (open) beginDraft();
|
||||
else resetDraft();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled && isOpen.value) requestClose("cancel");
|
||||
},
|
||||
);
|
||||
|
||||
function label(key: DateTimePickerLabel, fallback: string): string {
|
||||
const override = props.labels[key];
|
||||
if (override) return override;
|
||||
const translationKey = `dateTimePicker.${key}`;
|
||||
const translated = t(translationKey);
|
||||
return translated === translationKey ? fallback : translated;
|
||||
}
|
||||
|
||||
function beginDraft() {
|
||||
const value = props.modelValue ? calendarDateTimeFromFields(props.modelValue) : currentLocalDateTime();
|
||||
draft.value = value;
|
||||
localInputText.value = formatLocalDateTime(value);
|
||||
visibleMonth.value = startOfMonth(value);
|
||||
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, value);
|
||||
syncTimeInputs(value);
|
||||
clearTimeInputErrors();
|
||||
}
|
||||
|
||||
function resetDraft() {
|
||||
const value = props.modelValue ? calendarDateTimeFromFields(props.modelValue) : currentLocalDateTime();
|
||||
draft.value = value;
|
||||
localInputText.value = formatLocalDateTime(value);
|
||||
visibleMonth.value = startOfMonth(value);
|
||||
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, value);
|
||||
syncTimeInputs(value);
|
||||
clearTimeInputErrors();
|
||||
}
|
||||
|
||||
function onPopoverOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
if (props.disabled || isOpen.value) return;
|
||||
beginDraft();
|
||||
if (props.open === undefined) localOpen.value = true;
|
||||
emit("update:open", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeReason.value) return;
|
||||
requestClose("cancel");
|
||||
}
|
||||
|
||||
function requestClose(reason: "apply" | "cancel") {
|
||||
if (closeReason.value || !isOpen.value) return;
|
||||
closeReason.value = reason;
|
||||
if (reason === "cancel") {
|
||||
resetDraft();
|
||||
emit("cancel");
|
||||
}
|
||||
if (props.open === undefined) localOpen.value = false;
|
||||
emit("update:open", false);
|
||||
void nextTick(() => {
|
||||
closeReason.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
const value = parsedInput.value;
|
||||
if (!value || !canApply.value) return;
|
||||
emit("update:modelValue", value);
|
||||
emit("apply", value);
|
||||
requestClose("apply");
|
||||
}
|
||||
|
||||
function handleEscape(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
requestClose("cancel");
|
||||
}
|
||||
|
||||
function updateTextInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
localInputText.value = value;
|
||||
const parsed = parseLocalDateTime(value);
|
||||
if (!parsed) return;
|
||||
draft.value = parsed;
|
||||
visibleMonth.value = startOfMonth(parsed);
|
||||
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, parsed);
|
||||
syncTimeInputs(parsed);
|
||||
clearTimeInputErrors();
|
||||
}
|
||||
|
||||
function normalizeTextInput() {
|
||||
const parsed = parsedInput.value;
|
||||
if (!parsed) return;
|
||||
setDraft(parsed);
|
||||
}
|
||||
|
||||
function selectDate(value: CalendarDateTime) {
|
||||
if (props.disabled || !isCalendarDateSelectable(value)) return;
|
||||
const base = draft.value ?? currentLocalDateTime();
|
||||
const next = base.set({ year: value.year, month: value.month, day: value.day });
|
||||
setDraft(next);
|
||||
visibleMonth.value = startOfMonth(next);
|
||||
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, next);
|
||||
}
|
||||
|
||||
function showPreviousMonth() {
|
||||
visibleMonth.value = startOfMonth(visibleMonth.value.subtract({ months: 1 }));
|
||||
calendarFocus.value = calendarFocusForMonth(visibleMonth.value);
|
||||
}
|
||||
|
||||
function showNextMonth() {
|
||||
visibleMonth.value = startOfMonth(visibleMonth.value.add({ months: 1 }));
|
||||
calendarFocus.value = calendarFocusForMonth(visibleMonth.value);
|
||||
}
|
||||
|
||||
function onCalendarDayKeydown(day: CalendarDateTime, event: KeyboardEvent) {
|
||||
const offset = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : event.key === "ArrowUp" ? -7 : event.key === "ArrowDown" ? 7 : null;
|
||||
if (offset === null) return;
|
||||
event.preventDefault();
|
||||
|
||||
const next = day.add({ days: offset });
|
||||
if (!isCalendarDateSelectable(next)) return;
|
||||
selectDate(next);
|
||||
void nextTick(() => {
|
||||
const selector = `[data-date-time-picker-day="${calendarDayValue(next)}"]`;
|
||||
const button = calendarGridRef.value?.querySelector?.(selector) as HTMLButtonElement | undefined;
|
||||
button?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function calendarFocusForMonth(month: CalendarDateTime, preferred = calendarFocus.value ?? draft.value ?? month): CalendarDateTime | null {
|
||||
const minimum = props.min ? calendarDateTimeFromFields(props.min) : null;
|
||||
const maximum = props.max ? calendarDateTimeFromFields(props.max) : null;
|
||||
const firstDay = month.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
const lastDay = endOfMonth(month).set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
|
||||
if ((minimum && lastDay.compare(minimum) < 0) || (maximum && firstDay.compare(maximum) > 0)) return null;
|
||||
|
||||
const candidate = preferred.set({ year: month.year, month: month.month, day: Math.min(preferred.day, lastDay.day) });
|
||||
if (isCalendarDateSelectable(candidate)) return candidate;
|
||||
if (minimum && firstDay.compare(minimum) < 0) return minimum;
|
||||
if (maximum && lastDay.compare(maximum) > 0) return maximum;
|
||||
return null;
|
||||
}
|
||||
|
||||
function calendarDayValue(value: CalendarDateTimeLike): string {
|
||||
return formatLocalDateTime(value).slice(0, 10);
|
||||
}
|
||||
|
||||
function updateTimeInput(part: TimePart, event: Event) {
|
||||
const maximum = part === "hour" ? 23 : 59;
|
||||
const raw = (event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 2);
|
||||
timeInputs[part] = raw;
|
||||
const value = Number(raw);
|
||||
invalidTimeInputs[part] = raw.length === 0 || !Number.isInteger(value) || value > maximum;
|
||||
if (invalidTimeInputs[part]) return;
|
||||
|
||||
const base = draft.value ?? currentLocalDateTime();
|
||||
const next = base.set({ [part]: value });
|
||||
draft.value = next;
|
||||
localInputText.value = formatLocalDateTime(next);
|
||||
}
|
||||
|
||||
function normalizeTimeInput(part: TimePart) {
|
||||
const value = draft.value ?? currentLocalDateTime();
|
||||
timeInputs[part] = twoDigits(value[part]);
|
||||
invalidTimeInputs[part] = false;
|
||||
}
|
||||
|
||||
function onTimeInputKeydown(part: TimePart, event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
handleEscape(event);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
normalizeTimeInput(part);
|
||||
}
|
||||
|
||||
function setDraft(value: CalendarDateTime) {
|
||||
draft.value = value;
|
||||
localInputText.value = formatLocalDateTime(value);
|
||||
syncTimeInputs(value);
|
||||
clearTimeInputErrors();
|
||||
}
|
||||
|
||||
function syncTimeInputs(value: CalendarDateTime) {
|
||||
timeInputs.hour = twoDigits(value.hour);
|
||||
timeInputs.minute = twoDigits(value.minute);
|
||||
timeInputs.second = twoDigits(value.second);
|
||||
}
|
||||
|
||||
function clearTimeInputErrors() {
|
||||
invalidTimeInputs.hour = false;
|
||||
invalidTimeInputs.minute = false;
|
||||
invalidTimeInputs.second = false;
|
||||
}
|
||||
|
||||
function isWithinRange(value: CalendarDateTime): boolean {
|
||||
const minimum = props.min ? calendarDateTimeFromFields(props.min) : null;
|
||||
const maximum = props.max ? calendarDateTimeFromFields(props.max) : null;
|
||||
return (!minimum || value.compare(minimum) >= 0) && (!maximum || value.compare(maximum) <= 0);
|
||||
}
|
||||
|
||||
function isCalendarDateSelectable(value: CalendarDateTime): boolean {
|
||||
const firstSecond = value.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
const lastSecond = value.set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
|
||||
const minimum = props.min ? calendarDateTimeFromFields(props.min) : null;
|
||||
const maximum = props.max ? calendarDateTimeFromFields(props.max) : null;
|
||||
return (!minimum || lastSecond.compare(minimum) >= 0) && (!maximum || firstSecond.compare(maximum) <= 0);
|
||||
}
|
||||
|
||||
function isSameDay(left: CalendarDateTimeLike, right: CalendarDateTimeLike | null | undefined): boolean {
|
||||
return !!right && left.year === right.year && left.month === right.month && left.day === right.day;
|
||||
}
|
||||
|
||||
function isOutsideVisibleMonth(value: CalendarDateTime): boolean {
|
||||
return value.year !== visibleMonth.value.year || value.month !== visibleMonth.value.month;
|
||||
}
|
||||
|
||||
function formatMonth(value: CalendarDateTime, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { calendar: "gregory", month: "long", year: "numeric", timeZone: "UTC" }).format(value.toDate("UTC"));
|
||||
}
|
||||
|
||||
function calendarWeekDays(locale: string): string[] {
|
||||
const firstSunday = parseLocalDateTime("2023-01-01 00:00:00")!;
|
||||
const firstDay = startOfWeek(firstSunday, locale);
|
||||
const formatter = new Intl.DateTimeFormat(locale, { calendar: "gregory", weekday: "short", timeZone: "UTC" });
|
||||
return Array.from({ length: 7 }, (_, index) => formatter.format(firstDay.add({ days: index }).toDate("UTC")));
|
||||
}
|
||||
|
||||
function buildCalendarWeeks(month: CalendarDateTime, locale: string): CalendarDateTime[][] {
|
||||
const firstDay = startOfWeek(startOfMonth(month), locale);
|
||||
const lastDay = endOfMonth(month);
|
||||
const numberOfWeeks = Math.ceil((daysBetween(firstDay, lastDay) + 1) / 7);
|
||||
const weeks = Math.max(4, Math.min(6, numberOfWeeks));
|
||||
return Array.from({ length: weeks }, (_, week) => Array.from({ length: 7 }, (_, day) => firstDay.add({ days: week * 7 + day })));
|
||||
}
|
||||
|
||||
function daysBetween(first: CalendarDateTime, last: CalendarDateTime): number {
|
||||
// CalendarDateTime has no duration-between helper; dates in this picker are Gregorian.
|
||||
return Math.round((last.toDate("UTC").getTime() - first.toDate("UTC").getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function twoDigits(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
if (isOpen.value) beginDraft();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover :open="isOpen" :modal="false" @update:open="onPopoverOpenChange">
|
||||
<div data-date-time-picker class="max-w-full min-w-0" :class="fullWidth ? 'w-full' : 'inline-flex'">
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="trigger" :open="isOpen" :value="modelValue ?? null">
|
||||
<button
|
||||
type="button"
|
||||
data-date-time-picker-trigger
|
||||
class="flex min-w-0 items-center gap-1.5 rounded-md border border-input bg-background text-left tabular-nums outline-none transition-colors hover:bg-muted/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="[compact ? 'h-6 px-1.5 text-xs' : 'h-8 px-2.5 text-sm', fullWidth ? 'w-full' : '']"
|
||||
:disabled="disabled"
|
||||
:aria-label="label('open', 'Open date and time picker')"
|
||||
>
|
||||
<CalendarClock class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate" :class="modelValue ? 'text-foreground' : 'text-muted-foreground'">{{ triggerText }}</span>
|
||||
</button>
|
||||
</slot>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
|
||||
<PopoverContent data-date-time-picker-content align="start" side="bottom" :collision-padding="8" class="w-[19.5rem] max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-1rem)] gap-3 overflow-y-auto rounded-lg p-3" @escape-key-down="handleEscape">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-foreground" :for="inputId">{{ label("inputLabel", "Local date and time") }}</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="localInputText"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-date-time-picker-input
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2.5 font-mono text-xs tabular-nums outline-none transition-colors placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20"
|
||||
:placeholder="label('inputPlaceholder', 'YYYY-MM-DD HH:mm:ss')"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="!!validationMessage"
|
||||
:aria-describedby="validationMessage ? `${inputId}-validation` : undefined"
|
||||
@input="updateTextInput"
|
||||
@blur="normalizeTextInput"
|
||||
@keydown.enter.prevent
|
||||
@keydown.escape="handleEscape"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2" :aria-label="label('calendarLabel', 'Calendar')">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button type="button" variant="ghost" size="icon-sm" :disabled="disabled" :aria-label="label('previousMonth', 'Previous month')" @click="showPreviousMonth">
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<div class="text-sm font-medium" aria-live="polite">{{ monthHeading }}</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" :disabled="disabled" :aria-label="label('nextMonth', 'Next month')" @click="showNextMonth">
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref="calendarGridRef" class="space-y-0.5 text-center text-[11px]" role="grid" :aria-label="label('calendarLabel', 'Calendar')">
|
||||
<div role="row" class="grid grid-cols-7 gap-0.5">
|
||||
<div v-for="weekDay in weekDays" :key="weekDay" role="columnheader" class="py-0.5 font-medium text-muted-foreground">{{ weekDay }}</div>
|
||||
</div>
|
||||
<div v-for="(week, weekIndex) in calendarWeeks" :key="weekIndex" role="row" class="grid grid-cols-7 gap-0.5">
|
||||
<div v-for="day in week" :key="day.toString()" role="gridcell" :aria-selected="isSameDay(day, draft)">
|
||||
<button
|
||||
type="button"
|
||||
:data-date-time-picker-day="calendarDayValue(day)"
|
||||
class="flex h-8 w-full items-center justify-center rounded-md text-xs tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/60"
|
||||
:class="[isOutsideVisibleMonth(day) ? 'text-muted-foreground' : 'text-foreground', isSameDay(day, draft) ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'hover:bg-muted', isSameDay(day, today) && !isSameDay(day, draft) ? 'ring-1 ring-primary/50' : '']"
|
||||
:disabled="disabled || !isCalendarDateSelectable(day)"
|
||||
:tabindex="isSameDay(day, calendarFocus) ? 0 : -1"
|
||||
:aria-current="isSameDay(day, today) ? 'date' : undefined"
|
||||
:aria-label="formatLocalDateTime(day)"
|
||||
@click="selectDate(day)"
|
||||
@keydown="onCalendarDayKeydown(day, $event)"
|
||||
>
|
||||
{{ day.day }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5" :aria-label="label('timeLabel', 'Time')">
|
||||
<div class="text-xs font-medium text-foreground">{{ label("timeLabel", "Time") }}</div>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-center gap-1">
|
||||
<input
|
||||
:value="timeInputs.hour"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
data-date-time-picker-time-part="hour"
|
||||
class="h-8 min-w-0 rounded-md border border-input bg-background px-1 text-center font-mono text-sm tabular-nums outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive"
|
||||
:disabled="disabled"
|
||||
:aria-label="label('hour', 'Hour')"
|
||||
:aria-invalid="invalidTimeInputs.hour"
|
||||
@input="updateTimeInput('hour', $event)"
|
||||
@blur="normalizeTimeInput('hour')"
|
||||
@keydown="onTimeInputKeydown('hour', $event)"
|
||||
/>
|
||||
<span class="text-muted-foreground">:</span>
|
||||
<input
|
||||
:value="timeInputs.minute"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
data-date-time-picker-time-part="minute"
|
||||
class="h-8 min-w-0 rounded-md border border-input bg-background px-1 text-center font-mono text-sm tabular-nums outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive"
|
||||
:disabled="disabled"
|
||||
:aria-label="label('minute', 'Minute')"
|
||||
:aria-invalid="invalidTimeInputs.minute"
|
||||
@input="updateTimeInput('minute', $event)"
|
||||
@blur="normalizeTimeInput('minute')"
|
||||
@keydown="onTimeInputKeydown('minute', $event)"
|
||||
/>
|
||||
<span class="text-muted-foreground">:</span>
|
||||
<input
|
||||
:value="timeInputs.second"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
data-date-time-picker-time-part="second"
|
||||
class="h-8 min-w-0 rounded-md border border-input bg-background px-1 text-center font-mono text-sm tabular-nums outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive"
|
||||
:disabled="disabled"
|
||||
:aria-label="label('second', 'Second')"
|
||||
:aria-invalid="invalidTimeInputs.second"
|
||||
@input="updateTimeInput('second', $event)"
|
||||
@blur="normalizeTimeInput('second')"
|
||||
@keydown="onTimeInputKeydown('second', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="validationMessage" :id="`${inputId}-validation`" class="text-xs text-destructive" role="alert">{{ validationMessage }}</p>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t pt-2.5">
|
||||
<Button type="button" variant="outline" size="sm" :disabled="disabled" @click="requestClose('cancel')">{{ label("cancel", "Cancel") }}</Button>
|
||||
<Button type="button" size="sm" :disabled="!canApply" @click="applyDraft">{{ label("apply", "Apply") }}</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
import { CalendarDateTime, fromDate, getLocalTimeZone, parseDateTime, toCalendarDateTime } from "@internationalized/date";
|
||||
|
||||
const LOCAL_DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/;
|
||||
|
||||
/**
|
||||
* The fields shared by CalendarDateTime and Vue's unwrapped representation of
|
||||
* it. Date values are normalized before calling methods that use private state.
|
||||
*/
|
||||
export interface CalendarDateTimeLike {
|
||||
readonly year: number;
|
||||
readonly month: number;
|
||||
readonly day: number;
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
readonly second: number;
|
||||
readonly millisecond: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a local, timezone-free ISO date-time. Both `T` and a space are accepted
|
||||
* between the date and time, and an omitted seconds segment defaults to zero.
|
||||
*/
|
||||
export function parseLocalDateTime(value: string): CalendarDateTime | null {
|
||||
const match = LOCAL_DATE_TIME_PATTERN.exec(value.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const [, yearText, monthText, dayText, hourText, minuteText, secondText = "00"] = match;
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
const hour = Number(hourText);
|
||||
const minute = Number(minuteText);
|
||||
const second = Number(secondText);
|
||||
if (year < 1 || month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return null;
|
||||
|
||||
try {
|
||||
return parseDateTime(`${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats a timezone-free value as the local ISO representation used by the picker. */
|
||||
export function formatLocalDateTime(value: CalendarDateTimeLike): string {
|
||||
return [pad(value.year, 4), pad(value.month), pad(value.day)].join("-") + ` ${pad(value.hour)}:${pad(value.minute)}:${pad(value.second)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interprets the timezone-free wall-clock value in the DBX device's system time
|
||||
* zone before converting it to Unix seconds.
|
||||
*/
|
||||
export function calendarDateTimeToUnixSeconds(value: CalendarDateTimeLike): number {
|
||||
// DST gaps and overlaps do not identify exactly one local instant. Reject them
|
||||
// instead of silently changing the wall-clock time chosen by the user.
|
||||
return Math.floor(calendarDateTimeFromFields(value).toDate(getLocalTimeZone(), "reject").getTime() / 1_000);
|
||||
}
|
||||
|
||||
/** True when a local wall-clock value identifies exactly one system-timezone instant. */
|
||||
export function isValidLocalDateTime(value: CalendarDateTimeLike): boolean {
|
||||
try {
|
||||
calendarDateTimeToUnixSeconds(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts Unix seconds to a timezone-free value in the DBX device's system time zone. */
|
||||
export function unixSecondsToCalendarDateTime(seconds: number): CalendarDateTime {
|
||||
if (!Number.isFinite(seconds)) throw new RangeError("Unix seconds must be a finite number");
|
||||
|
||||
const date = new Date(Math.trunc(seconds) * 1_000);
|
||||
if (Number.isNaN(date.getTime())) throw new RangeError("Unix seconds are outside the Date range");
|
||||
|
||||
const local = toCalendarDateTime(fromDate(date, getLocalTimeZone()));
|
||||
return local.set({ millisecond: 0 });
|
||||
}
|
||||
|
||||
export function currentLocalDateTime(): CalendarDateTime {
|
||||
return unixSecondsToCalendarDateTime(Math.floor(Date.now() / 1_000));
|
||||
}
|
||||
|
||||
export function calendarDateTimeFromFields(value: CalendarDateTimeLike): CalendarDateTime {
|
||||
return new CalendarDateTime(value.year, value.month, value.day, value.hour, value.minute, value.second, value.millisecond);
|
||||
}
|
||||
|
||||
function pad(value: number, width = 2): string {
|
||||
return String(value).padStart(width, "0");
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { calendarDateTimeToUnixSeconds, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime } from "./dateTimePicker";
|
||||
|
||||
afterEach(() => {
|
||||
resetLocalTimeZone();
|
||||
});
|
||||
|
||||
describe("local date-time helpers", () => {
|
||||
it("parses local ISO text with a space or T separator at second precision", () => {
|
||||
const space = parseLocalDateTime("2024-02-29 13:45:06");
|
||||
const t = parseLocalDateTime("2024-02-29T13:45");
|
||||
|
||||
expect(space && formatLocalDateTime(space)).toBe("2024-02-29 13:45:06");
|
||||
expect(t && formatLocalDateTime(t)).toBe("2024-02-29 13:45:00");
|
||||
});
|
||||
|
||||
it("rejects empty, malformed, and impossible local date-times", () => {
|
||||
expect(parseLocalDateTime("")).toBeNull();
|
||||
expect(parseLocalDateTime("2024-02-30 12:00:00")).toBeNull();
|
||||
expect(parseLocalDateTime("2024-01-01T24:00:00")).toBeNull();
|
||||
expect(parseLocalDateTime("2024-01-01 12:00:00Z")).toBeNull();
|
||||
});
|
||||
|
||||
it("converts through the local timezone rather than treating the wall-clock value as UTC", () => {
|
||||
setLocalTimeZone("America/Los_Angeles");
|
||||
const local = new CalendarDateTime(2024, 1, 15, 12, 34, 56);
|
||||
const seconds = calendarDateTimeToUnixSeconds(local);
|
||||
|
||||
expect(new Date(seconds * 1_000).toISOString()).toBe("2024-01-15T20:34:56.000Z");
|
||||
expect(formatLocalDateTime(unixSecondsToCalendarDateTime(seconds))).toBe("2024-01-15 12:34:56");
|
||||
});
|
||||
|
||||
it("rejects local times that are skipped or repeated by daylight-saving transitions", () => {
|
||||
setLocalTimeZone("America/Los_Angeles");
|
||||
const skipped = new CalendarDateTime(2024, 3, 10, 2, 30, 0);
|
||||
const repeated = new CalendarDateTime(2024, 11, 3, 1, 30, 0);
|
||||
|
||||
expect(isValidLocalDateTime(skipped)).toBe(false);
|
||||
expect(isValidLocalDateTime(repeated)).toBe(false);
|
||||
expect(() => calendarDateTimeToUnixSeconds(skipped)).toThrow();
|
||||
expect(() => calendarDateTimeToUnixSeconds(repeated)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as DateTimePicker } from "./DateTimePicker.vue";
|
||||
export { calendarDateTimeFromFields, calendarDateTimeToUnixSeconds, currentLocalDateTime, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime, type CalendarDateTimeLike } from "./dateTimePicker";
|
||||
@@ -1398,6 +1398,22 @@ export default {
|
||||
delete: "Delete",
|
||||
back: "Back",
|
||||
},
|
||||
dateTimePicker: {
|
||||
open: "Open date and time picker",
|
||||
inputLabel: "Date and time",
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
previousMonth: "Previous month",
|
||||
nextMonth: "Next month",
|
||||
calendarLabel: "Calendar",
|
||||
timeLabel: "Time",
|
||||
hour: "Hour",
|
||||
minute: "Minute",
|
||||
second: "Second",
|
||||
cancel: "Cancel",
|
||||
apply: "Apply",
|
||||
invalidDateTime: "Enter a valid date and time",
|
||||
outOfRange: "Date and time is outside the allowed range",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Search connections, databases, tables, SQL files, and other objects...",
|
||||
emptyPlaceholder: "Start typing to search",
|
||||
@@ -2728,6 +2744,14 @@ export default {
|
||||
loadedMembers: "{loaded} of {total} members loaded",
|
||||
entries: "{count} entries",
|
||||
noExpiry: "no expiry",
|
||||
expiry: "Expiration",
|
||||
expiryNone: "No expiry",
|
||||
expiryTtl: "TTL (seconds)",
|
||||
expiryAt: "Expiration time (local)",
|
||||
expiryTtlInvalid: "Enter a positive whole-number TTL in seconds",
|
||||
expiryDateRequired: "Select a valid expiration time",
|
||||
expiryDatePast: "Expiration time must be in the future",
|
||||
keyExpiredBeforeDisplay: "The key expired before it could be displayed.",
|
||||
autoRefresh: "Auto-refresh countdown",
|
||||
ttlDay: "{count}d",
|
||||
columnType: "Type",
|
||||
@@ -2774,11 +2798,12 @@ export default {
|
||||
createFieldPlaceholder: "name",
|
||||
createScore: "Score",
|
||||
createKeyTtl: "TTL (seconds)",
|
||||
createKeyTtlPlaceholder: "empty = no expiry",
|
||||
createKeyTtlPlaceholder: "positive whole seconds",
|
||||
createKeyAddEntry: "Add entry",
|
||||
createKeyEntryId: "Entry ID",
|
||||
createKeyRawMode: "Raw input",
|
||||
createKeyEntries: "Entries",
|
||||
createKeyPartialWrite: "Some entries were written. Fix the error and create again to write the remaining entries.",
|
||||
createValuePlaceholder: "value",
|
||||
createKeySubmit: "Create",
|
||||
createKeyNameRequired: "Enter a key name",
|
||||
|
||||
@@ -1341,6 +1341,22 @@ export default withEnglishFallback({
|
||||
delete: "Eliminar",
|
||||
back: "Volver",
|
||||
},
|
||||
dateTimePicker: {
|
||||
open: "Abrir selector de fecha y hora",
|
||||
inputLabel: "Fecha y hora",
|
||||
inputPlaceholder: "AAAA-MM-DD HH:mm:ss",
|
||||
previousMonth: "Mes anterior",
|
||||
nextMonth: "Mes siguiente",
|
||||
calendarLabel: "Calendario",
|
||||
timeLabel: "Hora",
|
||||
hour: "Hora",
|
||||
minute: "Minuto",
|
||||
second: "Segundo",
|
||||
cancel: "Cancelar",
|
||||
apply: "Aplicar",
|
||||
invalidDateTime: "Ingresa una fecha y hora válidas",
|
||||
outOfRange: "La fecha y hora están fuera del intervalo permitido",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Buscar conexiones, bases de datos, tablas, archivos SQL y otros objetos...",
|
||||
emptyPlaceholder: "Comienza a escribir para buscar",
|
||||
@@ -2589,6 +2605,14 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded} de {total} miembros cargados",
|
||||
entries: "{count} entradas",
|
||||
noExpiry: "sin expiración",
|
||||
expiry: "Expiración",
|
||||
expiryNone: "Sin expiración",
|
||||
expiryTtl: "TTL (segundos)",
|
||||
expiryAt: "Hora de expiración (local)",
|
||||
expiryTtlInvalid: "Ingresa un TTL positivo en segundos",
|
||||
expiryDateRequired: "Selecciona una hora de expiración válida",
|
||||
expiryDatePast: "La hora de expiración debe ser futura",
|
||||
keyExpiredBeforeDisplay: "La clave expiró antes de que pudiera mostrarse.",
|
||||
autoRefresh: "Actualización automática",
|
||||
ttlDay: "{count}d",
|
||||
columnType: "Tipo",
|
||||
@@ -2626,11 +2650,12 @@ export default withEnglishFallback({
|
||||
createFieldPlaceholder: "name",
|
||||
createScore: "Puntuación",
|
||||
createKeyTtl: "TTL (segundos)",
|
||||
createKeyTtlPlaceholder: "vacío = sin expiración",
|
||||
createKeyTtlPlaceholder: "segundos enteros positivos",
|
||||
createKeyAddEntry: "Agregar entrada",
|
||||
createKeyEntryId: "ID de entrada",
|
||||
createKeyRawMode: "Entrada bruta",
|
||||
createKeyEntries: "Entradas",
|
||||
createKeyPartialWrite: "Se escribieron algunas entradas. Corrige el error y vuelve a crear para escribir las restantes.",
|
||||
createValuePlaceholder: "valor",
|
||||
jsonModuleNotAvailable: "El módulo ReJSON no está cargado en este servidor Redis",
|
||||
createKeySubmit: "Crear",
|
||||
|
||||
@@ -1339,6 +1339,22 @@ export default withEnglishFallback({
|
||||
delete: "Elimina",
|
||||
back: "Indietro",
|
||||
},
|
||||
dateTimePicker: {
|
||||
open: "Apri selettore data e ora",
|
||||
inputLabel: "Data e ora",
|
||||
inputPlaceholder: "AAAA-MM-GG HH:mm:ss",
|
||||
previousMonth: "Mese precedente",
|
||||
nextMonth: "Mese successivo",
|
||||
calendarLabel: "Calendario",
|
||||
timeLabel: "Ora",
|
||||
hour: "Ora",
|
||||
minute: "Minuto",
|
||||
second: "Secondo",
|
||||
cancel: "Annulla",
|
||||
apply: "Applica",
|
||||
invalidDateTime: "Inserisci una data e ora valide",
|
||||
outOfRange: "La data e ora non rientra nell'intervallo consentito",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Cerca connessioni, database, tabelle, file SQL e altri oggetti...",
|
||||
emptyPlaceholder: "Inizia a digitare per cercare",
|
||||
@@ -2587,6 +2603,14 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded} di {total} membri caricati",
|
||||
entries: "{count} voci",
|
||||
noExpiry: "nessuna scadenza",
|
||||
expiry: "Scadenza",
|
||||
expiryNone: "Nessuna scadenza",
|
||||
expiryTtl: "TTL (secondi)",
|
||||
expiryAt: "Data e ora di scadenza (locale)",
|
||||
expiryTtlInvalid: "Inserisci un TTL positivo in secondi",
|
||||
expiryDateRequired: "Seleziona una data e ora di scadenza valida",
|
||||
expiryDatePast: "La data e ora di scadenza deve essere nel futuro",
|
||||
keyExpiredBeforeDisplay: "La chiave è scaduta prima di poter essere visualizzata.",
|
||||
autoRefresh: "Aggiornamento automatico",
|
||||
ttlDay: "{count}g",
|
||||
columnType: "Tipo",
|
||||
@@ -2624,11 +2648,12 @@ export default withEnglishFallback({
|
||||
createFieldPlaceholder: "nome",
|
||||
createScore: "Punteggio",
|
||||
createKeyTtl: "TTL (secondi)",
|
||||
createKeyTtlPlaceholder: "vuoto = senza scadenza",
|
||||
createKeyTtlPlaceholder: "secondi interi positivi",
|
||||
createKeyAddEntry: "Aggiungi voce",
|
||||
createKeyEntryId: "ID voce",
|
||||
createKeyRawMode: "Input raw",
|
||||
createKeyEntries: "Voci",
|
||||
createKeyPartialWrite: "Alcune voci sono state scritte. Correggi l'errore e crea di nuovo per scrivere le voci rimanenti.",
|
||||
createValuePlaceholder: "valore",
|
||||
createKeySubmit: "Crea",
|
||||
createKeyNameRequired: "Inserisci un nome chiave",
|
||||
|
||||
@@ -1340,6 +1340,22 @@ export default withEnglishFallback({
|
||||
delete: "削除",
|
||||
back: "戻る",
|
||||
},
|
||||
dateTimePicker: {
|
||||
open: "日時ピッカーを開く",
|
||||
inputLabel: "日時",
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
previousMonth: "前の月",
|
||||
nextMonth: "次の月",
|
||||
calendarLabel: "カレンダー",
|
||||
timeLabel: "時刻",
|
||||
hour: "時",
|
||||
minute: "分",
|
||||
second: "秒",
|
||||
cancel: "キャンセル",
|
||||
apply: "適用",
|
||||
invalidDateTime: "有効な日時を入力してください",
|
||||
outOfRange: "日時が許可された範囲外です",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "接続、データベース、テーブル、SQLファイル、その他のオブジェクトを検索...",
|
||||
emptyPlaceholder: "入力して検索を開始",
|
||||
@@ -2588,6 +2604,14 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded}/{total}メンバー読み込み完了",
|
||||
entries: "{count}エントリ",
|
||||
noExpiry: "期限なし",
|
||||
expiry: "有効期限",
|
||||
expiryNone: "期限なし",
|
||||
expiryTtl: "TTL(秒)",
|
||||
expiryAt: "有効期限日時(ローカル)",
|
||||
expiryTtlInvalid: "正の整数の TTL(秒)を入力してください",
|
||||
expiryDateRequired: "有効な有効期限日時を選択してください",
|
||||
expiryDatePast: "有効期限日時は現在より後にしてください",
|
||||
keyExpiredBeforeDisplay: "キーは表示される前に期限切れになりました。",
|
||||
autoRefresh: "自動更新(カウントダウン)",
|
||||
ttlDay: "{count}日",
|
||||
columnType: "型",
|
||||
@@ -2625,11 +2649,12 @@ export default withEnglishFallback({
|
||||
createFieldPlaceholder: "name",
|
||||
createScore: "スコア",
|
||||
createKeyTtl: "TTL(秒)",
|
||||
createKeyTtlPlaceholder: "空 = 期限なし",
|
||||
createKeyTtlPlaceholder: "正の整数秒",
|
||||
createKeyAddEntry: "エントリを追加",
|
||||
createKeyEntryId: "エントリID",
|
||||
createKeyRawMode: "直接入力",
|
||||
createKeyEntries: "エントリ",
|
||||
createKeyPartialWrite: "一部のエントリは書き込まれました。エラーを修正してから、もう一度作成して残りのエントリを書き込んでください。",
|
||||
createValuePlaceholder: "value",
|
||||
createKeySubmit: "作成",
|
||||
createKeyNameRequired: "キー名を入力してください",
|
||||
|
||||
@@ -1341,6 +1341,22 @@ export default withEnglishFallback({
|
||||
delete: "Excluir",
|
||||
back: "Voltar",
|
||||
},
|
||||
dateTimePicker: {
|
||||
open: "Abrir seletor de data e hora",
|
||||
inputLabel: "Data e hora",
|
||||
inputPlaceholder: "AAAA-MM-DD HH:mm:ss",
|
||||
previousMonth: "Mês anterior",
|
||||
nextMonth: "Próximo mês",
|
||||
calendarLabel: "Calendário",
|
||||
timeLabel: "Hora",
|
||||
hour: "Hora",
|
||||
minute: "Minuto",
|
||||
second: "Segundo",
|
||||
cancel: "Cancelar",
|
||||
apply: "Aplicar",
|
||||
invalidDateTime: "Insira uma data e hora válidas",
|
||||
outOfRange: "A data e hora está fora do intervalo permitido",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Pesquisar conexões, bancos de dados, tabelas, arquivos SQL e outros objetos...",
|
||||
emptyPlaceholder: "Comece a digitar para pesquisar",
|
||||
@@ -2589,6 +2605,14 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded} de {total} membros carregados",
|
||||
entries: "{count} entradas",
|
||||
noExpiry: "sem expiração",
|
||||
expiry: "Expiração",
|
||||
expiryNone: "Sem expiração",
|
||||
expiryTtl: "TTL (segundos)",
|
||||
expiryAt: "Data e hora de expiração (local)",
|
||||
expiryTtlInvalid: "Insira um TTL positivo em segundos",
|
||||
expiryDateRequired: "Selecione uma data e hora de expiração válida",
|
||||
expiryDatePast: "A data e hora de expiração deve ser futura",
|
||||
keyExpiredBeforeDisplay: "A chave expirou antes de poder ser exibida.",
|
||||
autoRefresh: "Atualização automática",
|
||||
ttlDay: "{count}d",
|
||||
columnType: "Tipo",
|
||||
@@ -2626,11 +2650,12 @@ export default withEnglishFallback({
|
||||
createFieldPlaceholder: "nome",
|
||||
createScore: "Pontuação",
|
||||
createKeyTtl: "TTL (segundos)",
|
||||
createKeyTtlPlaceholder: "vazio = sem expiração",
|
||||
createKeyTtlPlaceholder: "segundos inteiros positivos",
|
||||
createKeyAddEntry: "Adicionar entrada",
|
||||
createKeyEntryId: "ID da entrada",
|
||||
createKeyRawMode: "Entrada bruta",
|
||||
createKeyEntries: "Entradas",
|
||||
createKeyPartialWrite: "Algumas entradas foram gravadas. Corrija o erro e crie novamente para gravar as entradas restantes.",
|
||||
createValuePlaceholder: "valor",
|
||||
jsonModuleNotAvailable: "Módulo ReJSON não está carregado neste servidor Redis",
|
||||
createKeySubmit: "Criar",
|
||||
|
||||
@@ -1399,6 +1399,22 @@ export default withEnglishFallback({
|
||||
delete: "删除",
|
||||
back: "返回",
|
||||
},
|
||||
dateTimePicker: {
|
||||
open: "打开日期时间选择器",
|
||||
inputLabel: "日期和时间",
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
previousMonth: "上个月",
|
||||
nextMonth: "下个月",
|
||||
calendarLabel: "日历",
|
||||
timeLabel: "时间",
|
||||
hour: "时",
|
||||
minute: "分",
|
||||
second: "秒",
|
||||
cancel: "取消",
|
||||
apply: "应用",
|
||||
invalidDateTime: "请输入有效的日期和时间",
|
||||
outOfRange: "日期和时间超出允许范围",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "搜索连接、数据库、表、SQL文件和其他对象...",
|
||||
emptyPlaceholder: "开始输入进行搜索",
|
||||
@@ -2728,6 +2744,14 @@ export default withEnglishFallback({
|
||||
loadedMembers: "已加载 {loaded} / 共 {total} 个成员",
|
||||
entries: "{count} 条记录",
|
||||
noExpiry: "永不过期",
|
||||
expiry: "过期时间",
|
||||
expiryNone: "永不过期",
|
||||
expiryTtl: "TTL(秒)",
|
||||
expiryAt: "过期时间(本地时间)",
|
||||
expiryTtlInvalid: "请输入正整数 TTL(秒)",
|
||||
expiryDateRequired: "请选择有效的过期时间",
|
||||
expiryDatePast: "过期时间必须晚于当前时间",
|
||||
keyExpiredBeforeDisplay: "该键在显示前已过期。",
|
||||
autoRefresh: "自动刷新(倒计时)",
|
||||
ttlDay: "{count}天",
|
||||
columnType: "类型",
|
||||
@@ -2774,11 +2798,12 @@ export default withEnglishFallback({
|
||||
createFieldPlaceholder: "name",
|
||||
createScore: "分数",
|
||||
createKeyTtl: "TTL(秒)",
|
||||
createKeyTtlPlaceholder: "留空表示永不过期",
|
||||
createKeyTtlPlaceholder: "正整数秒",
|
||||
createKeyAddEntry: "添加条目",
|
||||
createKeyEntryId: "条目 ID",
|
||||
createKeyRawMode: "原始输入",
|
||||
createKeyEntries: "条目",
|
||||
createKeyPartialWrite: "部分条目已写入。请修复错误后再次创建以写入剩余条目。",
|
||||
createValuePlaceholder: "value",
|
||||
createKeySubmit: "新增",
|
||||
createKeyNameRequired: "请输入 key 名称",
|
||||
|
||||
@@ -1340,6 +1340,22 @@ export default withEnglishFallback({
|
||||
delete: "刪除",
|
||||
back: "返回",
|
||||
},
|
||||
dateTimePicker: {
|
||||
open: "開啟日期時間選擇器",
|
||||
inputLabel: "日期和時間",
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
previousMonth: "上個月",
|
||||
nextMonth: "下個月",
|
||||
calendarLabel: "日曆",
|
||||
timeLabel: "時間",
|
||||
hour: "時",
|
||||
minute: "分",
|
||||
second: "秒",
|
||||
cancel: "取消",
|
||||
apply: "套用",
|
||||
invalidDateTime: "請輸入有效的日期和時間",
|
||||
outOfRange: "日期和時間超出允許範圍",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "搜尋連線、資料庫、資料表、SQL檔案和其他物件……",
|
||||
emptyPlaceholder: "開始輸入進行搜尋",
|
||||
@@ -2400,6 +2416,14 @@ export default withEnglishFallback({
|
||||
loadedMembers: "已載入 {loaded} / 共 {total} 個成員",
|
||||
entries: "{count} 筆項目",
|
||||
noExpiry: "永不過期",
|
||||
expiry: "到期時間",
|
||||
expiryNone: "永不過期",
|
||||
expiryTtl: "TTL(秒)",
|
||||
expiryAt: "到期時間(本地時間)",
|
||||
expiryTtlInvalid: "請輸入正整數 TTL(秒)",
|
||||
expiryDateRequired: "請選擇有效的到期時間",
|
||||
expiryDatePast: "到期時間必須晚於目前時間",
|
||||
keyExpiredBeforeDisplay: "此鍵在顯示前已過期。",
|
||||
autoRefresh: "自動刷新(倒計時)",
|
||||
ttlDay: "{count}天",
|
||||
columnType: "類型",
|
||||
@@ -2437,11 +2461,12 @@ export default withEnglishFallback({
|
||||
createFieldPlaceholder: "name",
|
||||
createScore: "分數",
|
||||
createKeyTtl: "TTL(秒)",
|
||||
createKeyTtlPlaceholder: "留空表示永不過期",
|
||||
createKeyTtlPlaceholder: "正整數秒",
|
||||
createKeyAddEntry: "新增項目",
|
||||
createKeyEntryId: "項目 ID",
|
||||
createKeyRawMode: "原始輸入",
|
||||
createKeyEntries: "項目",
|
||||
createKeyPartialWrite: "部分項目已寫入。請修正錯誤後再次建立以寫入其餘項目。",
|
||||
createValuePlaceholder: "value",
|
||||
createKeySubmit: "建立",
|
||||
createKeyNameRequired: "請輸入 key 名稱",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const browserSource = readFileSync(new URL("../../../components/redis/RedisKeyBrowser.vue", import.meta.url), "utf8");
|
||||
const viewerSource = readFileSync(new URL("../../../components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
|
||||
const localeSources = {
|
||||
en: readFileSync(new URL("../../../i18n/locales/en.ts", import.meta.url), "utf8"),
|
||||
es: readFileSync(new URL("../../../i18n/locales/es.ts", import.meta.url), "utf8"),
|
||||
it: readFileSync(new URL("../../../i18n/locales/it.ts", import.meta.url), "utf8"),
|
||||
ja: readFileSync(new URL("../../../i18n/locales/ja.ts", import.meta.url), "utf8"),
|
||||
"pt-BR": readFileSync(new URL("../../../i18n/locales/pt-BR.ts", import.meta.url), "utf8"),
|
||||
"zh-CN": readFileSync(new URL("../../../i18n/locales/zh-CN.ts", import.meta.url), "utf8"),
|
||||
"zh-TW": readFileSync(new URL("../../../i18n/locales/zh-TW.ts", import.meta.url), "utf8"),
|
||||
};
|
||||
|
||||
function findFunction(source: string, name: string): ts.FunctionDeclaration {
|
||||
const parsed = parse(source, { filename: `${name}.vue` });
|
||||
expect(parsed.errors).toEqual([]);
|
||||
const script = parsed.descriptor.scriptSetup;
|
||||
expect(script).toBeDefined();
|
||||
const program = ts.createSourceFile(`${name}.ts`, script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const declaration = program.statements.find((statement): statement is ts.FunctionDeclaration => ts.isFunctionDeclaration(statement) && statement.name?.text === name);
|
||||
expect(declaration).toBeDefined();
|
||||
return declaration!;
|
||||
}
|
||||
|
||||
function callsIn(node: ts.Node): ts.CallExpression[] {
|
||||
const calls: ts.CallExpression[] = [];
|
||||
const visit = (child: ts.Node) => {
|
||||
if (ts.isCallExpression(child)) calls.push(child);
|
||||
ts.forEachChild(child, visit);
|
||||
};
|
||||
visit(node);
|
||||
return calls;
|
||||
}
|
||||
|
||||
function callName(call: ts.CallExpression): string | undefined {
|
||||
if (ts.isIdentifier(call.expression)) return call.expression.text;
|
||||
if (ts.isPropertyAccessExpression(call.expression)) return `${call.expression.expression.getText()}.${call.expression.name.text}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("Redis expiry mode wiring", () => {
|
||||
it("writes every create type before applying exactly one shared expiration policy", () => {
|
||||
const create = findFunction(browserSource, "createRedisKey");
|
||||
const calls = callsIn(create);
|
||||
const expectedWriterArgumentCounts: Record<string, number> = {
|
||||
"api.redisSetString": 4,
|
||||
"api.redisJsonSet": 4,
|
||||
"api.redisHashSet": 5,
|
||||
"api.redisListPush": 4,
|
||||
"api.redisSetAdd": 4,
|
||||
"api.redisZadd": 5,
|
||||
"api.redisStreamAdd": 5,
|
||||
};
|
||||
|
||||
for (const [name, argumentCount] of Object.entries(expectedWriterArgumentCounts)) {
|
||||
const writerCalls = calls.filter((call) => callName(call) === name);
|
||||
expect(writerCalls.length, `${name} should be used by the create flow`).toBeGreaterThan(0);
|
||||
expect(
|
||||
writerCalls.every((call) => call.arguments.length === argumentCount),
|
||||
`${name} must not receive a per-write TTL`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
expect(calls.map(callName)).toContain("applyRedisExpiryPolicy");
|
||||
expect(create.getText()).toContain("if (creatingKey.value) return;");
|
||||
expect(calls.map(callName)).not.toContain("api.redisSetTtl");
|
||||
expect(calls.map(callName)).not.toContain("api.redisSetExpireAt");
|
||||
expect(create.getText().lastIndexOf("applyRedisExpiryPolicy")).toBeGreaterThan(create.getText().lastIndexOf("api.redisStreamAdd"));
|
||||
expect(findFunction(browserSource, "syncWrittenKey").getText()).toContain('if (created.redis_type === "none")');
|
||||
expect(create.getText()).toContain('toast(t("redis.keyExpiredBeforeDisplay"), 3000)');
|
||||
expect(create.getText()).toContain("if (wroteValue && writtenKeyRaw)");
|
||||
expect(findFunction(browserSource, "reflectWrittenKey").getText()).toContain("removeKnownKey(keyRaw)");
|
||||
});
|
||||
|
||||
it("keeps only unwritten structured entries after a later writer fails", () => {
|
||||
const create = findFunction(browserSource, "createRedisKey").getText();
|
||||
const partialRecoveryStart = create.indexOf("if (writingStructuredEntries)");
|
||||
const genericRecoveryStart = create.lastIndexOf("if (wroteValue && writtenKeyRaw)");
|
||||
const partialRecovery = create.slice(partialRecoveryStart, genericRecoveryStart);
|
||||
|
||||
expect(create).toContain("let writingStructuredEntries = false;");
|
||||
expect(create).toContain("const pendingEntries = createKeyEntries.value.slice();");
|
||||
expect(create.match(/for \(const entry of pendingEntries\)/g)).toHaveLength(4);
|
||||
expect(create.match(/removeWrittenCreateKeyEntry\(entry\.id\)/g)).toHaveLength(4);
|
||||
expect(create).toContain("createKeyPartiallyWritten.value = true;");
|
||||
expect(partialRecovery).toContain("await reflectWrittenKey(writtenKeyRaw);");
|
||||
expect(partialRecovery).toContain('t("redis.createKeyPartialWrite")');
|
||||
expect(partialRecovery).not.toContain("showCreateKeyDialog.value = false;");
|
||||
expect(browserSource).toContain(':disabled="creatingKey || createKeyPartiallyWritten"');
|
||||
expect(findFunction(browserSource, "onCreateKeyDialogOpenChange").getText()).toContain("if (!open && creatingKey.value) return;");
|
||||
expect(findFunction(browserSource, "onCreateKeyTypeChange").getText()).toContain("if (creatingKey.value || createKeyPartiallyWritten.value) return;");
|
||||
});
|
||||
|
||||
it("defaults TTL editing by server state, supports absolute prefill, and keeps teleported controls inside the editor", () => {
|
||||
const start = findFunction(viewerSource, "startEditTtl").getText();
|
||||
const save = findFunction(viewerSource, "saveTtl").getText();
|
||||
const currentTtl = findFunction(viewerSource, "currentEditableTtl").getText();
|
||||
|
||||
expect(start).toContain("redisExpiryModeForTtl(ttl)");
|
||||
expect(currentTtl).toContain("computeTtlForExpiryEdit(autoRefreshEnabled.value, countdownTtl.value, data.value.ttl)");
|
||||
expect(viewerSource).toContain("unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl)");
|
||||
expect(save).toContain("validateRedisExpiry");
|
||||
expect(save).toContain("applyRedisExpiryPolicy");
|
||||
expect(save).toContain("toast(errorMessage(error), 3000)");
|
||||
expect(viewerSource).toContain('"[data-date-time-picker-content]"');
|
||||
expect(viewerSource).toContain('"[data-redis-expiry-mode-content]"');
|
||||
expect(viewerSource).toContain('v-model="ttlExpireAt" compact');
|
||||
expect(viewerSource.match(/as="button"/g) ?? []).toHaveLength(2);
|
||||
expect(viewerSource).toContain(":aria-label=\"t('redis.expiry')\"");
|
||||
expect(viewerSource).toContain(":aria-label=\"t('grid.save')\"");
|
||||
});
|
||||
|
||||
it("moves keyboard focus to the active TTL editor control", () => {
|
||||
const start = findFunction(viewerSource, "startEditTtl").getText();
|
||||
const focus = findFunction(viewerSource, "focusTtlExpiryControl").getText();
|
||||
|
||||
expect(start).toContain("focusTtlExpiryControl()");
|
||||
expect(focus).toContain('mode === "ttl"');
|
||||
expect(focus).toContain("ttlInputEl.value?.$el?.focus()");
|
||||
expect(focus).toContain("[data-slot='select-trigger']");
|
||||
expect(focus).toContain("[data-date-time-picker-trigger]");
|
||||
expect(viewerSource).toContain("if (editingTtl.value && mode !== previousMode) focusTtlExpiryControl(mode);");
|
||||
});
|
||||
|
||||
it("serializes expiry saves and preserves unrelated value drafts", () => {
|
||||
const save = findFunction(viewerSource, "saveTtl").getText();
|
||||
const load = findFunction(viewerSource, "load").getText();
|
||||
|
||||
expect(save).toContain("if (savingTtl.value) return;");
|
||||
expect(save).toContain("savingTtl.value = true;");
|
||||
expect(save).toContain("await refreshTtlState(error);");
|
||||
expect(save).toContain("const refreshError = await refreshTtlState();");
|
||||
expect(save).toContain("savingTtl.value = false;");
|
||||
expect(load).toContain("const preservedValue = { ...currentValue, ttl: loadedValue.ttl };");
|
||||
expect(viewerSource).toContain(':disabled="savingTtl"');
|
||||
});
|
||||
|
||||
it("keeps expiry controls compact and inline in the value header", () => {
|
||||
expect(viewerSource).toContain("flex min-w-0 max-w-full flex-wrap items-center gap-1");
|
||||
expect(viewerSource).toContain('SelectTrigger size="sm"');
|
||||
expect(viewerSource).toContain("max-w-[min(100%,14rem)]");
|
||||
expect(viewerSource).toContain('v-model="ttlExpireAt" compact');
|
||||
expect(viewerSource).not.toContain("full-width");
|
||||
expect(viewerSource).not.toContain("basis-full");
|
||||
expect(viewerSource).not.toContain("redis-expiry-mode-trigger");
|
||||
expect(viewerSource).not.toContain('class="h-6 w-30 text-xs"');
|
||||
});
|
||||
|
||||
it("uses the positive-seconds TTL placeholder in both expiration editors", () => {
|
||||
const obsoletePlaceholders = {
|
||||
en: "empty = no expiry",
|
||||
es: "vacío = sin expiración",
|
||||
it: "vuoto = senza scadenza",
|
||||
ja: "空 = 期限なし",
|
||||
"pt-BR": "vazio = sem expiração",
|
||||
"zh-CN": "留空表示永不过期",
|
||||
"zh-TW": "留空表示永不過期",
|
||||
};
|
||||
|
||||
for (const [locale, source] of Object.entries(localeSources)) {
|
||||
const ttlPlaceholder = source.match(/createKeyTtlPlaceholder: "([^"]+)"/)?.[1];
|
||||
|
||||
expect(ttlPlaceholder, locale).toBeDefined();
|
||||
expect(source, locale).toContain("createKeyPartialWrite:");
|
||||
expect(ttlPlaceholder, locale).not.toBe(obsoletePlaceholders[locale as keyof typeof obsoletePlaceholders]);
|
||||
}
|
||||
expect(browserSource).toContain("t('redis.createKeyTtlPlaceholder')");
|
||||
expect(viewerSource).toContain("t('redis.createKeyTtlPlaceholder')");
|
||||
});
|
||||
|
||||
it("removes a key that expires while its detail view is refreshing", () => {
|
||||
const load = findFunction(viewerSource, "load").getText();
|
||||
const deleted = findFunction(browserSource, "onKeyDeleted").getText();
|
||||
const loaded = findFunction(browserSource, "onKeyLoaded").getText();
|
||||
|
||||
expect(load).toContain('if (loadedValue.redis_type === "none")');
|
||||
expect(load).toContain('emit("deleted", props.keyRaw)');
|
||||
expect(deleted).toContain("removeKnownKey(keyRaw)");
|
||||
expect(loaded).toContain('if (value.redis_type === "none")');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
|
||||
import { computeAutoRefreshTick, computeDisplayTtl, computeTtlForExpiryEdit, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
|
||||
|
||||
describe("computeAutoRefreshTick", () => {
|
||||
it("returns idle when auto-refresh is disabled", () => {
|
||||
@@ -64,3 +64,17 @@ describe("computeDisplayTtl", () => {
|
||||
expect(computeDisplayTtl(true, -1, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTtlForExpiryEdit", () => {
|
||||
it("keeps a last-confirmed positive TTL during the in-flight zero-countdown window", () => {
|
||||
expect(computeTtlForExpiryEdit(true, 0, 5)).toBe(5);
|
||||
expect(computeTtlForExpiryEdit(true, -1, 5)).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the live positive countdown and preserves non-expiring server states", () => {
|
||||
expect(computeTtlForExpiryEdit(true, 3, 10)).toBe(3);
|
||||
expect(computeTtlForExpiryEdit(false, 3, 10)).toBe(10);
|
||||
expect(computeTtlForExpiryEdit(true, 0, -1)).toBe(-1);
|
||||
expect(computeTtlForExpiryEdit(true, 0, -2)).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: mocks.invoke,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Redis expiry Tauri API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("invokes EXPIREAT with the Unix timestamp", async () => {
|
||||
mocks.invoke.mockResolvedValue(undefined);
|
||||
const { redisSetExpireAt } = await import("@/lib/backend/tauri");
|
||||
|
||||
await redisSetExpireAt("redis-1", 2, "c2Vzc2lvbg==", 1_735_689_600);
|
||||
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("redis_set_expire_at", {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "c2Vzc2lvbg==",
|
||||
expireAt: 1_735_689_600,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards relative TTL and persist values unchanged", async () => {
|
||||
mocks.invoke.mockResolvedValue(undefined);
|
||||
const { redisSetTtl } = await import("@/lib/backend/tauri");
|
||||
|
||||
await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", 90);
|
||||
await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", -1);
|
||||
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "redis_set_ttl", {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "c2Vzc2lvbg==",
|
||||
ttl: 90,
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "redis_set_ttl", {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "c2Vzc2lvbg==",
|
||||
ttl: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Redis expiry HTTP API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubFetch() {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
|
||||
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
|
||||
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
it("posts relative TTL, persist, and absolute expiration requests", async () => {
|
||||
const fetchMock = stubFetch();
|
||||
const { redisSetExpireAt, redisSetTtl } = await import("@/lib/backend/http");
|
||||
|
||||
await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", 90);
|
||||
expect(lastCall(fetchMock)).toEqual({
|
||||
url: "/api/redis/set-ttl",
|
||||
body: { connectionId: "redis-1", db: 2, keyRaw: "c2Vzc2lvbg==", ttl: 90 },
|
||||
});
|
||||
|
||||
await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", -1);
|
||||
expect(lastCall(fetchMock)).toEqual({
|
||||
url: "/api/redis/set-ttl",
|
||||
body: { connectionId: "redis-1", db: 2, keyRaw: "c2Vzc2lvbg==", ttl: -1 },
|
||||
});
|
||||
|
||||
await redisSetExpireAt("redis-1", 2, "c2Vzc2lvbg==", 1_735_689_600);
|
||||
expect(lastCall(fetchMock)).toEqual({
|
||||
url: "/api/redis/set-expire-at",
|
||||
body: { connectionId: "redis-1", db: 2, keyRaw: "c2Vzc2lvbg==", expireAt: 1_735_689_600 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -395,6 +395,7 @@ export const redisStreamAdd = forward("redisStreamAdd");
|
||||
export const redisJsonSet = forward("redisJsonSet");
|
||||
export const redisCheckJsonModule = forward("redisCheckJsonModule");
|
||||
export const redisSetTtl = forward("redisSetTtl");
|
||||
export const redisSetExpireAt = forward("redisSetExpireAt");
|
||||
export const redisDeleteKeys = forward("redisDeleteKeys");
|
||||
export const redisFlushDb = forward("redisFlushDb");
|
||||
export const redisExecuteCommand = forward("redisExecuteCommand");
|
||||
|
||||
@@ -2012,6 +2012,10 @@ export async function redisSetTtl(connectionId: string, db: number, keyRaw: stri
|
||||
return post("/api/redis/set-ttl", { connectionId, db, keyRaw, ttl });
|
||||
}
|
||||
|
||||
export async function redisSetExpireAt(connectionId: string, db: number, keyRaw: string, expireAt: number): Promise<void> {
|
||||
return post("/api/redis/set-expire-at", { connectionId, db, keyRaw, expireAt });
|
||||
}
|
||||
|
||||
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
|
||||
return post("/api/redis/delete-keys", { connectionId, db, keyRaws });
|
||||
}
|
||||
|
||||
@@ -1731,6 +1731,10 @@ export async function redisSetTtl(connectionId: string, db: number, keyRaw: stri
|
||||
return invoke("redis_set_ttl", { connectionId, db, keyRaw, ttl });
|
||||
}
|
||||
|
||||
export async function redisSetExpireAt(connectionId: string, db: number, keyRaw: string, expireAt: number): Promise<void> {
|
||||
return invoke("redis_set_expire_at", { connectionId, db, keyRaw, expireAt });
|
||||
}
|
||||
|
||||
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
|
||||
return invoke("redis_delete_keys", { connectionId, db, keyRaws });
|
||||
}
|
||||
|
||||
@@ -41,3 +41,16 @@ export function shouldStopAutoRefresh(ttl: number): boolean {
|
||||
export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number {
|
||||
return autoRefreshEnabled ? Math.max(countdownTtl, 0) : serverTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the TTL used to initialize an expiry edit.
|
||||
*
|
||||
* A zero countdown can be a transient state while the final background refresh
|
||||
* is still in flight. Do not turn a last-confirmed positive Redis TTL into
|
||||
* PERSIST during that window.
|
||||
*/
|
||||
export function computeTtlForExpiryEdit(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number {
|
||||
if (serverTtl <= 0) return serverTtl;
|
||||
const displayedTtl = computeDisplayTtl(autoRefreshEnabled, countdownTtl, serverTtl);
|
||||
return displayedTtl > 0 ? displayedTtl : serverTtl;
|
||||
}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { calendarDateTimeToUnixSeconds, formatLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
import { applyRedisExpiryPolicy, parseRedisTtl, redisExpiryModeForTtl, validateRedisExpiry } from "./redisExpiry";
|
||||
|
||||
afterEach(() => {
|
||||
resetLocalTimeZone();
|
||||
});
|
||||
|
||||
describe("Redis expiry helpers", () => {
|
||||
it("only accepts safe positive whole-second TTLs", () => {
|
||||
expect(parseRedisTtl("")).toBeNull();
|
||||
expect(parseRedisTtl("0")).toBeNull();
|
||||
expect(parseRedisTtl("-1")).toBeNull();
|
||||
expect(parseRedisTtl("1.5")).toBeNull();
|
||||
expect(parseRedisTtl("1seconds")).toBeNull();
|
||||
expect(parseRedisTtl(String(Number.MAX_SAFE_INTEGER + 1))).toBeNull();
|
||||
expect(parseRedisTtl(" 60 ")).toBe(60);
|
||||
});
|
||||
|
||||
it("maps Redis TTL metadata to the expected initial mode", () => {
|
||||
expect(redisExpiryModeForTtl(-2)).toBe("none");
|
||||
expect(redisExpiryModeForTtl(-1)).toBe("none");
|
||||
expect(redisExpiryModeForTtl(1)).toBe("ttl");
|
||||
});
|
||||
|
||||
it("validates empty, invalid, past, and future absolute expiry times", () => {
|
||||
const now = new Date(2024, 1, 29, 12, 0, 0).getTime();
|
||||
const future = new CalendarDateTime(2024, 2, 29, 12, 0, 1);
|
||||
const past = new CalendarDateTime(2024, 2, 29, 11, 59, 59);
|
||||
|
||||
expect(validateRedisExpiry("none", "", null, now)).toEqual({ valid: true, policy: { mode: "none" } });
|
||||
expect(validateRedisExpiry("ttl", "0", null, now)).toEqual({ valid: false, reason: "ttl" });
|
||||
expect(validateRedisExpiry("at", "", null, now)).toEqual({ valid: false, reason: "date" });
|
||||
expect(validateRedisExpiry("at", "", past, now)).toEqual({ valid: false, reason: "past" });
|
||||
expect(validateRedisExpiry("at", "", future, now)).toEqual({
|
||||
valid: true,
|
||||
policy: { mode: "at", expireAt: calendarDateTimeToUnixSeconds(future) },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats DST gaps and overlaps as invalid absolute expiry times", () => {
|
||||
setLocalTimeZone("America/Los_Angeles");
|
||||
const now = new Date("2024-01-01T00:00:00Z").getTime();
|
||||
|
||||
expect(validateRedisExpiry("at", "", new CalendarDateTime(2024, 3, 10, 2, 30, 0), now)).toEqual({ valid: false, reason: "date" });
|
||||
expect(validateRedisExpiry("at", "", new CalendarDateTime(2024, 11, 3, 1, 30, 0), now)).toEqual({ valid: false, reason: "date" });
|
||||
});
|
||||
|
||||
it("keeps local calendar fields when parsing, formatting, and converting", () => {
|
||||
const leap = parseLocalDateTime("2024-02-29 23:45:06");
|
||||
expect(leap).not.toBeNull();
|
||||
expect(formatLocalDateTime(leap!)).toBe("2024-02-29 23:45:06");
|
||||
expect(parseLocalDateTime("2024-02-30 23:45:06")).toBeNull();
|
||||
expect(parseLocalDateTime("2024-02-29T23:45:06")?.toString()).toBe("2024-02-29T23:45:06");
|
||||
|
||||
const roundTrip = unixSecondsToCalendarDateTime(calendarDateTimeToUnixSeconds(leap!));
|
||||
expect(formatLocalDateTime(roundTrip)).toBe("2024-02-29 23:45:06");
|
||||
});
|
||||
|
||||
it("uses PERSIST, EXPIRE, or EXPIREAT once according to the selected policy", async () => {
|
||||
const transport = { setTtl: vi.fn().mockResolvedValue(undefined), setExpireAt: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await applyRedisExpiryPolicy(transport, "connection", 3, "key", { mode: "none" });
|
||||
await applyRedisExpiryPolicy(transport, "connection", 3, "key", { mode: "ttl", ttl: 45 });
|
||||
await applyRedisExpiryPolicy(transport, "connection", 3, "key", { mode: "at", expireAt: 1_735_689_600 });
|
||||
|
||||
expect(transport.setTtl).toHaveBeenNthCalledWith(1, "connection", 3, "key", -1);
|
||||
expect(transport.setTtl).toHaveBeenNthCalledWith(2, "connection", 3, "key", 45);
|
||||
expect(transport.setExpireAt).toHaveBeenCalledOnce();
|
||||
expect(transport.setExpireAt).toHaveBeenCalledWith("connection", 3, "key", 1_735_689_600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
|
||||
export type RedisExpiryMode = "none" | "ttl" | "at";
|
||||
|
||||
export type RedisExpiryPolicy = { mode: "none" } | { mode: "ttl"; ttl: number } | { mode: "at"; expireAt: number };
|
||||
|
||||
export type RedisExpiryValidation = { valid: true; policy: RedisExpiryPolicy } | { valid: false; reason: "ttl" | "date" | "past" };
|
||||
|
||||
export interface RedisExpiryTransport {
|
||||
setTtl: (connectionId: string, db: number, keyRaw: string, ttl: number) => Promise<void>;
|
||||
setExpireAt: (connectionId: string, db: number, keyRaw: string, expireAt: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Parse the EXPIRE argument without accepting partial, negative, or unsafe values. */
|
||||
export function parseRedisTtl(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^[1-9]\d*$/.test(trimmed)) return null;
|
||||
|
||||
const ttl = Number(trimmed);
|
||||
return Number.isSafeInteger(ttl) ? ttl : null;
|
||||
}
|
||||
|
||||
export function redisExpiryModeForTtl(ttl: number): RedisExpiryMode {
|
||||
return ttl > 0 ? "ttl" : "none";
|
||||
}
|
||||
|
||||
export function validateRedisExpiry(mode: RedisExpiryMode, ttlInput: string, expireAt: CalendarDateTime | null, now = Date.now()): RedisExpiryValidation {
|
||||
if (mode === "none") return { valid: true, policy: { mode } };
|
||||
|
||||
if (mode === "ttl") {
|
||||
const ttl = parseRedisTtl(ttlInput);
|
||||
return ttl === null ? { valid: false, reason: "ttl" } : { valid: true, policy: { mode, ttl } };
|
||||
}
|
||||
|
||||
if (!expireAt) return { valid: false, reason: "date" };
|
||||
let timestamp: number;
|
||||
try {
|
||||
timestamp = calendarDateTimeToUnixSeconds(expireAt);
|
||||
} catch {
|
||||
return { valid: false, reason: "date" };
|
||||
}
|
||||
return timestamp * 1_000 <= now ? { valid: false, reason: "past" } : { valid: true, policy: { mode, expireAt: timestamp } };
|
||||
}
|
||||
|
||||
/** Applies exactly one post-write Redis expiration command for a validated policy. */
|
||||
export async function applyRedisExpiryPolicy(transport: RedisExpiryTransport, connectionId: string, db: number, keyRaw: string, policy: RedisExpiryPolicy): Promise<void> {
|
||||
if (policy.mode === "none") {
|
||||
await transport.setTtl(connectionId, db, keyRaw, -1);
|
||||
return;
|
||||
}
|
||||
if (policy.mode === "ttl") {
|
||||
await transport.setTtl(connectionId, db, keyRaw, policy.ttl);
|
||||
return;
|
||||
}
|
||||
await transport.setExpireAt(connectionId, db, keyRaw, policy.expireAt);
|
||||
}
|
||||
@@ -2689,12 +2689,34 @@ where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
if ttl > 0 {
|
||||
redis::cmd("EXPIRE").arg(key).arg(ttl).query_async::<()>(con).await.map_err(|e| e.to_string())
|
||||
let applied =
|
||||
redis::cmd("EXPIRE").arg(key).arg(ttl).query_async::<i64>(con).await.map_err(|e| e.to_string())?;
|
||||
if applied == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Redis key no longer exists; EXPIRE was not applied".to_string())
|
||||
}
|
||||
} else {
|
||||
// Redis treats PERSIST as idempotent: a zero reply also means the key
|
||||
// was already persistent. Follow-up reads in the UI handle a key that
|
||||
// disappeared concurrently without requiring an extra ACL permission.
|
||||
redis::cmd("PERSIST").arg(key).query_async::<()>(con).await.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_expire_at<C>(con: &mut C, key: &[u8], expire_at: i64) -> Result<(), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let applied =
|
||||
redis::cmd("EXPIREAT").arg(key).arg(expire_at).query_async::<i64>(con).await.map_err(|e| e.to_string())?;
|
||||
if applied == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Redis key no longer exists; EXPIREAT was not applied".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_keys<C>(con: &mut C, keys: &[Vec<u8>]) -> Result<u64, String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
@@ -3200,6 +3222,48 @@ mod tests {
|
||||
assert!(!con.commands[0].contains("\r\nEXPIRE\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_expire_at_uses_expireat_with_the_unix_timestamp() {
|
||||
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(1)]);
|
||||
|
||||
super::set_expire_at(&mut con, b"session", 1_735_689_600).await.unwrap();
|
||||
|
||||
assert_eq!(con.commands.len(), 1);
|
||||
assert!(con.commands[0].contains("\r\nEXPIREAT\r\n"));
|
||||
assert!(con.commands[0].contains("\r\n1735689600\r\n"));
|
||||
assert_eq!(con.command_count("EVAL"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_expire_at_reports_when_the_key_disappears_before_expiration_is_applied() {
|
||||
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(0)]);
|
||||
|
||||
let error = super::set_expire_at(&mut con, b"session", 1_735_689_600).await.unwrap_err();
|
||||
|
||||
assert!(error.contains("EXPIREAT was not applied"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_ttl_reports_when_positive_expiration_is_not_applied() {
|
||||
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(0)]);
|
||||
|
||||
let error = super::set_ttl(&mut con, b"session", 60).await.unwrap_err();
|
||||
|
||||
assert!(error.contains("EXPIRE was not applied"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_is_idempotent_for_an_already_persistent_key() {
|
||||
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(0)]);
|
||||
|
||||
super::set_ttl(&mut con, b"session", 0).await.unwrap();
|
||||
|
||||
assert_eq!(con.commands.len(), 1);
|
||||
assert_eq!(con.command_count("PERSIST"), 1);
|
||||
assert_eq!(con.command_count("EXISTS"), 0);
|
||||
assert_eq!(con.command_count("EVAL"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_string_falls_back_to_pttl_and_px_when_keepttl_is_unsupported() {
|
||||
let unsupported = redis::RedisError::from((
|
||||
|
||||
@@ -645,6 +645,35 @@ pub async fn redis_set_ttl_in_db_core(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn redis_set_expire_at_in_db_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
db: u32,
|
||||
key_raw: &str,
|
||||
expire_at: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_redis_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(redis) => {
|
||||
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
|
||||
match redis {
|
||||
RedisConnection::Direct(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::select_db(&mut *con, db).await?;
|
||||
redis_driver::set_expire_at(&mut *con, &key, expire_at).await
|
||||
}
|
||||
RedisConnection::Cluster(cluster) => {
|
||||
redis_driver::ensure_cluster_db(db)?;
|
||||
let mut con = redis_driver::cluster_key_connection(cluster, &key).await?;
|
||||
redis_driver::set_expire_at(&mut con, &key, expire_at).await
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn redis_delete_keys_in_db_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
||||
@@ -435,6 +435,8 @@ async fn main() {
|
||||
.route("/redis/stream-add", post(routes::redis::stream_add))
|
||||
.route("/redis/json-set", post(routes::redis::json_set))
|
||||
.route("/redis/check-json-module", post(routes::redis::check_json_module))
|
||||
.route("/redis/set-ttl", post(routes::redis::set_ttl))
|
||||
.route("/redis/set-expire-at", post(routes::redis::set_expire_at))
|
||||
.route("/redis/delete-keys", post(routes::redis::delete_keys))
|
||||
.route("/redis/flush-db", post(routes::redis::flush_db))
|
||||
.route("/redis/execute-command", post(routes::redis::execute_command))
|
||||
|
||||
@@ -162,6 +162,24 @@ pub struct RedisJsonSetRequest {
|
||||
pub ttl: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisSetTtlRequest {
|
||||
pub connection_id: String,
|
||||
pub db: u32,
|
||||
pub key_raw: String,
|
||||
pub ttl: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisSetExpireAtRequest {
|
||||
pub connection_id: String,
|
||||
pub db: u32,
|
||||
pub key_raw: String,
|
||||
pub expire_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisKeysRequest {
|
||||
@@ -437,6 +455,7 @@ pub async fn set_remove(
|
||||
}
|
||||
|
||||
pub async fn zadd(State(state): State<Arc<WebState>>, Json(req): Json<RedisZaddRequest>) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "ZADD").await?;
|
||||
dbx_core::redis_ops::redis_zadd_in_db_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -455,6 +474,7 @@ pub async fn stream_add(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisStreamAddRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "XADD").await?;
|
||||
dbx_core::redis_ops::redis_stream_add_in_db_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -473,6 +493,7 @@ pub async fn json_set(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisJsonSetRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "JSON.SET").await?;
|
||||
dbx_core::redis_ops::redis_json_set_in_db_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -496,6 +517,34 @@ pub async fn check_json_module(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn set_ttl(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetTtlRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "EXPIRE").await?;
|
||||
dbx_core::redis_ops::redis_set_ttl_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, req.ttl)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_expire_at(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetExpireAtRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "EXPIREAT").await?;
|
||||
dbx_core::redis_ops::redis_set_expire_at_in_db_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
req.db,
|
||||
&req.key_raw,
|
||||
req.expire_at,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_keys(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeysRequest>,
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.0",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
|
||||
Generated
+3
@@ -53,6 +53,9 @@ importers:
|
||||
'@dbx-app/mongo-shell':
|
||||
specifier: workspace:^
|
||||
version: link:packages/mongo-shell
|
||||
'@internationalized/date':
|
||||
specifier: ^3.12.2
|
||||
version: 3.12.2
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
|
||||
@@ -235,6 +235,7 @@ pub async fn redis_stream_add(
|
||||
fields: Vec<(String, String)>,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "XADD").await?;
|
||||
dbx_core::redis_ops::redis_stream_add_in_db_core(&state, &connection_id, db, &key_raw, &entry_id, fields, ttl).await
|
||||
}
|
||||
|
||||
@@ -247,6 +248,7 @@ pub async fn redis_json_set(
|
||||
value: String,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "JSON.SET").await?;
|
||||
dbx_core::redis_ops::redis_json_set_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await
|
||||
}
|
||||
|
||||
@@ -271,6 +273,18 @@ pub async fn redis_set_ttl(
|
||||
dbx_core::redis_ops::redis_set_ttl_in_db_core(&state, &connection_id, db, &key_raw, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_set_expire_at(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
expire_at: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "EXPIREAT").await?;
|
||||
dbx_core::redis_ops::redis_set_expire_at_in_db_core(&state, &connection_id, db, &key_raw, expire_at).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_delete_keys(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
||||
@@ -1535,6 +1535,7 @@ pub fn run() {
|
||||
commands::redis_cmd::redis_json_set,
|
||||
commands::redis_cmd::redis_check_json_module,
|
||||
commands::redis_cmd::redis_set_ttl,
|
||||
commands::redis_cmd::redis_set_expire_at,
|
||||
commands::redis_cmd::redis_delete_keys,
|
||||
commands::redis_cmd::redis_flush_db,
|
||||
commands::redis_cmd::redis_execute_command,
|
||||
|
||||
Reference in New Issue
Block a user