feat(redis): add local expiration modes

This commit is contained in:
onenewcode
2026-07-26 23:33:56 +08:00
committed by GitHub
parent 3a2dc4225a
commit 51fa058edb
35 changed files with 2918 additions and 98 deletions
@@ -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" />
@@ -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");
}
@@ -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";
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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: "キー名を入力してください",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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 名称",
+26 -1
View File
@@ -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 },
});
});
});
+1
View File
@@ -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");
+4
View File
@@ -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 });
}
+4
View File
@@ -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
View File
@@ -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);
});
});
+57
View File
@@ -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);
}
+65 -1
View File
@@ -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((
+29
View File
@@ -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,
+2
View File
@@ -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))
+49
View File
@@ -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>,
+1
View File
@@ -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",
+3
View File
@@ -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
+14
View File
@@ -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>>,
+1
View File
@@ -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,