feat(redis): edit hash fields
This commit is contained in:
1 parent
75c2dc5e17
commit
0f9d32e1f5
18 files changed
+470
-14
No files matched your search
@@ -173,6 +173,10 @@ const selectedMemberContext = ref<RedisMemberContext | null>(null);
|
||||
const isEditingMember = ref(false);
|
||||
const savingMember = ref(false);
|
||||
const memberEditValue = ref("");
|
||||
const memberFieldEditValue = ref("");
|
||||
// Keep the original hash field separate from the editable draft so a rename
|
||||
// remains visible (and protected from refresh) after the detail dialog closes.
|
||||
const memberFieldDraftBaseline = ref("");
|
||||
const editingZsetMemberKey = ref<string | null>(null);
|
||||
const savingZsetMember = ref(false);
|
||||
const zsetInlineMember = ref("");
|
||||
@@ -590,17 +594,22 @@ const hasRetainedStringDraft = computed(() => isStringDraftDirty(stringDraftForm
|
||||
const redisJsonValueChanged = computed(() => data.value?.data.kind === "json" && editValue.value !== redisJsonDraftBaseline.value);
|
||||
const canEditCurrentMemberFormat = computed(() => selectedMemberCanEdit.value && memberValueView.value === "utf8");
|
||||
const isEditingHashJson = computed(() => selectedMemberContext.value?.kind === "hash" && selectedMemberCanEdit.value && memberValueView.value === "json" && Boolean(selectedMemberDetail.value.json));
|
||||
const memberFieldChanged = computed(() => {
|
||||
const context = selectedMemberContext.value;
|
||||
return context?.kind === "hash" && context.canEdit && memberFieldEditValue.value !== memberFieldDraftBaseline.value;
|
||||
});
|
||||
const memberValueChanged = computed(() => {
|
||||
if (!selectedMemberCanEdit.value) return false;
|
||||
const original = selectedMemberDetail.value.rawText;
|
||||
if (isEditingHashJson.value && selectedMemberDetail.value.json) return memberEditValue.value !== memberJsonDraftBaseline.value;
|
||||
return memberEditValue.value !== original;
|
||||
const valueChanged = isEditingHashJson.value && selectedMemberDetail.value.json ? memberEditValue.value !== memberJsonDraftBaseline.value : memberEditValue.value !== original;
|
||||
return valueChanged || memberFieldChanged.value;
|
||||
});
|
||||
// A member sheet can close without discarding its draft, so its dirty state
|
||||
// must outlive the sheet and whichever display format is currently selected.
|
||||
const memberDraftFormat = ref<"utf8" | "json" | null>(null);
|
||||
const hasRetainedMemberDraft = computed(() => {
|
||||
const format = memberDraftFormat.value;
|
||||
if (memberFieldChanged.value) return true;
|
||||
if (!format || !selectedMemberCanEdit.value) return false;
|
||||
|
||||
const original = selectedMemberDetail.value.rawText;
|
||||
@@ -609,7 +618,7 @@ const hasRetainedMemberDraft = computed(() => {
|
||||
}
|
||||
return memberEditValue.value !== original;
|
||||
});
|
||||
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value);
|
||||
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || memberFieldChanged.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value);
|
||||
const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0);
|
||||
const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null));
|
||||
const hashFieldTtlSupported = computed(() => {
|
||||
@@ -1824,6 +1833,8 @@ function selectMember(title: string, value: unknown, context: RedisMemberContext
|
||||
memberJsonRawBaseline.value = detail.json?.formattedText ?? "";
|
||||
memberJsonDraftBaseline.value = jsonDraftBaseline(memberJsonRawBaseline.value, redisJsonDecoded.value);
|
||||
memberEditValue.value = memberValueView.value === "json" && detail.json ? memberJsonDraftBaseline.value : detail.rawText;
|
||||
memberFieldDraftBaseline.value = context.kind === "hash" ? (context.field ?? "") : "";
|
||||
memberFieldEditValue.value = memberFieldDraftBaseline.value;
|
||||
memberDraftFormat.value = context.kind === "hash" && context.canEdit && memberValueView.value === "json" && detail.json ? "json" : null;
|
||||
}
|
||||
|
||||
@@ -1834,6 +1845,8 @@ function clearSelectedMember() {
|
||||
selectedMemberContext.value = null;
|
||||
isEditingMember.value = false;
|
||||
memberEditValue.value = "";
|
||||
memberFieldEditValue.value = "";
|
||||
memberFieldDraftBaseline.value = "";
|
||||
memberJsonDraftBaseline.value = "";
|
||||
memberDraftFormat.value = null;
|
||||
}
|
||||
@@ -1857,7 +1870,9 @@ function viewMember(title: string, value: unknown, context: RedisMemberContext,
|
||||
|
||||
function handleMemberDetailOpenChange(open: boolean) {
|
||||
showMemberDetail.value = open;
|
||||
if (!open) isEditingMember.value = false;
|
||||
if (!open) {
|
||||
isEditingMember.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function finishMemberDetailClose() {
|
||||
@@ -1988,6 +2003,11 @@ function startEditMember() {
|
||||
if (!memberValueChanged.value) memberEditValue.value = selectedMemberDetail.value.rawText;
|
||||
// Do not demote a retained JSON draft to utf8; save still needs compact normalization.
|
||||
if (memberDraftFormat.value !== "json") memberDraftFormat.value = "utf8";
|
||||
// A closed dialog can be reopened into the same edit. Keep a dirty field
|
||||
// draft instead of replacing it with the currently stored field name.
|
||||
if (selectedMemberContext.value?.kind === "hash" && !memberFieldChanged.value) {
|
||||
memberFieldEditValue.value = selectedMemberContext.value.field ?? "";
|
||||
}
|
||||
isEditingMember.value = true;
|
||||
nextTick(() => memberTextareaRef.value?.focus());
|
||||
}
|
||||
@@ -1996,6 +2016,7 @@ function cancelEditMember() {
|
||||
memberEditValue.value = selectedMemberDetail.value.rawText;
|
||||
memberDraftFormat.value = null;
|
||||
isEditingMember.value = false;
|
||||
memberFieldEditValue.value = memberFieldDraftBaseline.value;
|
||||
}
|
||||
|
||||
function discardHashJsonEdit() {
|
||||
@@ -2020,13 +2041,24 @@ async function saveMemberEdit() {
|
||||
}
|
||||
|
||||
let nextContext: RedisMemberContext = context;
|
||||
const nextHashField = context.kind === "hash" ? memberFieldEditValue.value : "";
|
||||
if (context.kind === "hash" && nextHashField.length === 0) {
|
||||
toast(t("redis.fieldRequired"), 3000);
|
||||
return;
|
||||
}
|
||||
savingMember.value = true;
|
||||
try {
|
||||
if (context.kind === "list") {
|
||||
await api.redisListSet(props.connectionId, props.db, props.keyRaw, context.index, writeValue);
|
||||
} else if (context.kind === "hash") {
|
||||
if (!context.field) return;
|
||||
await api.redisHashSet(props.connectionId, props.db, props.keyRaw, context.field, writeValue);
|
||||
if (context.field == null) return;
|
||||
try {
|
||||
await api.redisHashFieldUpdate(props.connectionId, props.db, props.keyRaw, context.field, nextHashField, writeValue);
|
||||
} catch (error) {
|
||||
toast(errorMessage(error), 3000);
|
||||
return;
|
||||
}
|
||||
nextContext = { kind: "hash", field: nextHashField, canEdit: canEditRedisMemberDetail("hash", writeValue) };
|
||||
} else if (context.kind === "set") {
|
||||
if (!context.member) return;
|
||||
await api.redisSetRemove(props.connectionId, props.db, props.keyRaw, context.member);
|
||||
@@ -2149,7 +2181,7 @@ function resolveSelectedMember(context: RedisMemberContext): { title: string; va
|
||||
};
|
||||
}
|
||||
case "hash": {
|
||||
if (redisKind.value !== "hash" || !context.field) return null;
|
||||
if (redisKind.value !== "hash" || context.field == null) return null;
|
||||
const item = (collectionItems.value as RedisHashItem[]).find((candidate) => redisBlobText(candidate.field) === context.field);
|
||||
if (!item) return null;
|
||||
const field = redisBlobText(item.field);
|
||||
@@ -3480,7 +3512,8 @@ defineExpose({ focusSearch });
|
||||
/>
|
||||
<DialogHeader class="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<span class="truncate">{{ selectedMemberTitle ? formatValue(selectedMemberTitle) : t("redis.memberDetail") }}</span>
|
||||
<Input v-if="isEditingMember && selectedMemberContext?.kind === 'hash'" v-model="memberFieldEditValue" class="h-7 min-w-0 flex-1 text-sm" :placeholder="t('redis.field')" :aria-label="t('redis.field')" @keydown.enter="saveMemberEdit" />
|
||||
<span v-else class="truncate">{{ selectedMemberTitle ? formatValue(selectedMemberTitle) : t("redis.memberDetail") }}</span>
|
||||
<Badge variant="outline" class="shrink-0 text-xs">{{ redisFormatLabel(memberValueView, selectedMemberDetail.rawLabel) }}</Badge>
|
||||
<Badge v-if="memberGzipBadge && memberValueCodec === 'none'" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')">
|
||||
<FileArchive class="h-3 w-3 mr-1" />
|
||||
@@ -3596,7 +3629,7 @@ defineExpose({ focusSearch });
|
||||
<Button variant="ghost" :disabled="savingMember" @click="cancelEditMember">
|
||||
{{ t("grid.discard") }}
|
||||
</Button>
|
||||
<Button :disabled="savingMember" @click="saveMemberEdit">
|
||||
<Button :disabled="savingMember || !memberValueChanged" @click="saveMemberEdit">
|
||||
<Loader2 v-if="savingMember" class="h-4 w-4 animate-spin" />
|
||||
<Save v-else class="h-4 w-4" />
|
||||
{{ t("grid.save") }}
|
||||
|
||||
@@ -4727,6 +4727,7 @@ export default {
|
||||
createFieldRequired: "Enter a hash field",
|
||||
createScoreInvalid: "Enter a valid score",
|
||||
jsonModuleNotAvailable: "ReJSON module is not loaded on this Redis server",
|
||||
field: "Field",
|
||||
fieldRequired: "Enter a field name",
|
||||
valueRequired: "Enter a value",
|
||||
memberRequired: "Enter a member",
|
||||
|
||||
@@ -4647,6 +4647,7 @@ export default withEnglishFallback({
|
||||
keyspace: "Espacio de claves",
|
||||
},
|
||||
},
|
||||
field: "campo",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documentos",
|
||||
|
||||
@@ -4645,6 +4645,7 @@ export default withEnglishFallback({
|
||||
keyspace: "Spazio delle chiavi",
|
||||
},
|
||||
},
|
||||
field: "campo",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documenti",
|
||||
|
||||
@@ -4674,6 +4674,7 @@ export default withEnglishFallback({
|
||||
keyspace: "キースペース",
|
||||
},
|
||||
},
|
||||
field: "フィールド",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count}ドキュメント",
|
||||
|
||||
@@ -4647,6 +4647,7 @@ export default withEnglishFallback({
|
||||
keyspace: "Espaço de chaves",
|
||||
},
|
||||
},
|
||||
field: "campo",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documentos",
|
||||
|
||||
@@ -4712,6 +4712,7 @@ export default withEnglishFallback({
|
||||
createFieldRequired: "请输入 hash 字段",
|
||||
createScoreInvalid: "请输入有效分数",
|
||||
jsonModuleNotAvailable: "该 Redis 服务器未加载 ReJSON 模块",
|
||||
field: "字段",
|
||||
fieldRequired: "请输入字段名",
|
||||
valueRequired: "请输入值",
|
||||
memberRequired: "请输入成员",
|
||||
|
||||
@@ -3867,6 +3867,7 @@ export default withEnglishFallback({
|
||||
createFieldRequired: "請輸入 hash 欄位",
|
||||
createScoreInvalid: "請輸入有效分數",
|
||||
jsonModuleNotAvailable: "該 Redis 伺服器未載入 ReJSON 模組",
|
||||
field: "欄位",
|
||||
fieldRequired: "請輸入欄位名稱",
|
||||
valueRequired: "請輸入值",
|
||||
memberRequired: "請輸入成員",
|
||||
|
||||
@@ -172,13 +172,13 @@ describe("native RedisJSON editor", () => {
|
||||
expect(saveStringCalls).toContain("api.redisSetString");
|
||||
expect(saveStringText).toMatch(/redisSetString\([\s\S]*\bvalue\b/);
|
||||
|
||||
// Hash JSON draft (editor open or retained after leaving JSON tab) compact-writes via HSET.
|
||||
// Hash JSON draft (editor open or retained after leaving JSON tab) compact-writes via the atomic field update.
|
||||
expect(saveMemberText).toContain("savingHashJson");
|
||||
expect(saveMemberText).toContain('memberDraftFormat.value === "json"');
|
||||
expect(saveMemberCalls).toContain("normalizeRedisJsonDraft");
|
||||
expect(saveMemberText).toContain("writeValue = normalized.compactText");
|
||||
expect(saveMemberCalls).toContain("api.redisHashSet");
|
||||
expect(saveMemberText).toMatch(/redisHashSet\([\s\S]*\bwriteValue\b/);
|
||||
expect(saveMemberCalls).toContain("api.redisHashFieldUpdate");
|
||||
expect(saveMemberText).toMatch(/redisHashFieldUpdate\([\s\S]*\bwriteValue\b/);
|
||||
|
||||
// Editor pretty baseline also uses the source-preserving formatter.
|
||||
expect(findFunction("formatJsonText").getText()).toContain("formatJsonSource");
|
||||
@@ -208,12 +208,12 @@ describe("native RedisJSON editor", () => {
|
||||
const saved = normalizeRedisJsonDraft(opened.json!.formattedText);
|
||||
expect(saved).toEqual({ ok: true, compactText: DUPLICATE_MEMBER_COMPACT });
|
||||
|
||||
// Wiring: hash path only normalizes when savingHashJson, then HSETs writeValue.
|
||||
// Wiring: hash path only normalizes when savingHashJson, then atomically updates the field with writeValue.
|
||||
const saveMemberEdit = findFunction("saveMemberEdit");
|
||||
const text = saveMemberEdit.getText();
|
||||
expect(text).toContain("if (savingHashJson)");
|
||||
expect(text).toContain("writeValue = normalized.compactText");
|
||||
expect(callsIn(saveMemberEdit).map(calledName)).toEqual(expect.arrayContaining(["normalizeRedisJsonDraft", "api.redisHashSet"]));
|
||||
expect(callsIn(saveMemberEdit).map(calledName)).toEqual(expect.arrayContaining(["normalizeRedisJsonDraft", "api.redisHashFieldUpdate"]));
|
||||
});
|
||||
|
||||
it("keeps the native editor and export paths on the RedisJSON value-text contract", () => {
|
||||
@@ -241,6 +241,11 @@ describe("native RedisJSON editor", () => {
|
||||
const hashSearch = findFunction("onHashSearch");
|
||||
const viewMember = findFunction("viewMember");
|
||||
const setMemberValueFormat = findFunction("setMemberValueFormat");
|
||||
const selectMember = findFunction("selectMember");
|
||||
const startEditMember = findFunction("startEditMember");
|
||||
const saveMemberEdit = findFunction("saveMemberEdit");
|
||||
const memberValueChanged = findVariableInitializer("memberValueChanged");
|
||||
const memberFieldChanged = findVariableInitializer("memberFieldChanged");
|
||||
const unsavedDraft = findVariableInitializer("hasUnsavedRedisDraft");
|
||||
const textFormat = findFunction("isTextRedisFormat");
|
||||
const labels = templateElements(parsedViewer.descriptor.template!.ast as unknown as TemplateElement);
|
||||
@@ -256,6 +261,14 @@ describe("native RedisJSON editor", () => {
|
||||
expect(setMemberValueFormat.getText()).toContain('memberDraftFormat.value = "utf8"');
|
||||
expect(unsavedDraft.getText()).toContain("hasRetainedStringDraft.value");
|
||||
expect(unsavedDraft.getText()).toContain("hasRetainedMemberDraft.value");
|
||||
expect(unsavedDraft.getText()).toContain("memberFieldChanged.value");
|
||||
expect(memberFieldChanged.getText()).toContain("memberFieldDraftBaseline.value");
|
||||
expect(memberValueChanged.getText()).toContain("memberFieldChanged.value");
|
||||
expect(selectMember.getText()).toContain("memberFieldDraftBaseline.value");
|
||||
expect(startEditMember.getText()).toContain("!memberFieldChanged.value");
|
||||
expect(saveMemberEdit.getText()).toContain("memberFieldEditValue.value");
|
||||
expect(saveMemberEdit.getText()).not.toContain("memberFieldEditValue.value.trim()");
|
||||
expect(viewerSource).toContain(':disabled="savingMember || !memberValueChanged"');
|
||||
expect(textFormat.getText()).toContain('format === "json"');
|
||||
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(stringValueView) || activeStructuredStringDetail || isDecompressCodec(stringValueCodec)")).toBe(true);
|
||||
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(memberValueView) || activeStructuredMemberDetail || isDecompressCodec(memberValueCodec)")).toBe(true);
|
||||
|
||||
@@ -468,6 +468,7 @@ export const redisDeleteKey = forward("redisDeleteKey");
|
||||
export const redisRenameKey = forward("redisRenameKey");
|
||||
export const redisHashSet = forward("redisHashSet");
|
||||
export const redisHashDel = forward("redisHashDel");
|
||||
export const redisHashFieldUpdate = forward("redisHashFieldUpdate");
|
||||
export const redisHashFieldSetTtl = forward("redisHashFieldSetTtl");
|
||||
export const redisHashFieldSetExpireAt = forward("redisHashFieldSetExpireAt");
|
||||
export const redisListPush = forward("redisListPush");
|
||||
|
||||
@@ -2703,6 +2703,17 @@ export async function redisHashDel(connectionId: string, db: number, keyRaw: str
|
||||
return post("/api/redis/hash-del", { connectionId, db, keyRaw, field });
|
||||
}
|
||||
|
||||
export async function redisHashFieldUpdate(connectionId: string, db: number, keyRaw: string, oldField: string, newField: string, value: string): Promise<void> {
|
||||
return post("/api/redis/hash-field-update", {
|
||||
connectionId,
|
||||
db,
|
||||
keyRaw,
|
||||
oldField,
|
||||
newField,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
export async function redisHashFieldSetTtl(connectionId: string, db: number, keyRaw: string, field: string, ttl: number): Promise<void> {
|
||||
return post("/api/redis/hash-field-set-ttl", { connectionId, db, keyRaw, field, ttl });
|
||||
}
|
||||
|
||||
@@ -2590,6 +2590,17 @@ export async function redisHashDel(connectionId: string, db: number, keyRaw: str
|
||||
return invoke("redis_hash_del", { connectionId, db, keyRaw, field });
|
||||
}
|
||||
|
||||
export async function redisHashFieldUpdate(connectionId: string, db: number, keyRaw: string, oldField: string, newField: string, value: string): Promise<void> {
|
||||
return invoke("redis_hash_field_update", {
|
||||
connectionId,
|
||||
db,
|
||||
keyRaw,
|
||||
oldField,
|
||||
newField,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
export async function redisHashFieldSetTtl(connectionId: string, db: number, keyRaw: string, field: string, ttl: number): Promise<void> {
|
||||
return invoke("redis_hash_field_set_ttl", { connectionId, db, keyRaw, field, ttl });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user