mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
🐛 fix(redis): 修复列表新增与精确删除
- 对齐新版 Redis List 头尾新增的 Wails 参数契约 - 分别使用 LPUSH 与 RPUSH 保证插入位置语义 - 按索引原子删除并避免重复值误删 - 补充多语言文案与前后端回归测试 Refs #639
This commit is contained in:
@@ -34,6 +34,7 @@ const redisBackend = vi.hoisted(() => ({
|
||||
RedisScanKeys: vi.fn(),
|
||||
RedisGetValue: vi.fn(),
|
||||
RedisGetListValue: vi.fn(),
|
||||
RedisListPush: vi.fn(),
|
||||
RedisListRemove: vi.fn(),
|
||||
RedisListSet: vi.fn(),
|
||||
RedisExportKeys: vi.fn(),
|
||||
@@ -44,6 +45,7 @@ const redisBackend = vi.hoisted(() => ({
|
||||
const antdState = vi.hoisted(() => ({
|
||||
treeProps: null as any,
|
||||
tableProps: [] as any[],
|
||||
modalConfirm: vi.fn(),
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
@@ -133,7 +135,7 @@ vi.mock('antd', async () => {
|
||||
onOk ? React.createElement('button', { key: 'ok', onClick: onOk, disabled: okButtonProps?.disabled }, 'modal-ok') : null,
|
||||
onCancel ? React.createElement('button', { key: 'cancel', onClick: onCancel }, 'modal-cancel') : null,
|
||||
]);
|
||||
}, { confirm: vi.fn() }),
|
||||
}, { confirm: antdState.modalConfirm }),
|
||||
Form: FormComponent,
|
||||
InputNumber: ({ ...props }: any) => React.createElement('input', props),
|
||||
Popconfirm: passthrough('span'),
|
||||
@@ -206,6 +208,7 @@ describe('RedisViewer tree interactions', () => {
|
||||
vi.clearAllMocks();
|
||||
antdState.treeProps = null;
|
||||
antdState.tableProps = [];
|
||||
antdState.modalConfirm.mockImplementation(() => ({ update: vi.fn(), destroy: vi.fn() }));
|
||||
storeState.connections = [
|
||||
{
|
||||
id: 'redis-1',
|
||||
@@ -238,6 +241,7 @@ describe('RedisViewer tree interactions', () => {
|
||||
success: true,
|
||||
data: { key: 'app:user:1', type: 'list', ttl: -1, value: [], length: 0 },
|
||||
});
|
||||
redisBackend.RedisListPush.mockResolvedValue({ success: true });
|
||||
redisBackend.RedisListRemove.mockResolvedValue({ success: true });
|
||||
redisBackend.RedisListSet.mockResolvedValue({ success: true });
|
||||
redisBackend.RedisExportKeys.mockResolvedValue({
|
||||
@@ -1183,10 +1187,13 @@ describe('RedisViewer tree interactions', () => {
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('removes one selected List value', async () => {
|
||||
it.each([
|
||||
{ buttonText: 'Push to tail', inputId: 'new-list-value', value: 'tail-item', position: 'right' },
|
||||
{ buttonText: 'Push to head', inputId: 'new-list-value-left', value: 'head-item', position: 'left' },
|
||||
] as const)('pushes a List value through the $buttonText action', async ({ buttonText, inputId, value, position }) => {
|
||||
redisBackend.RedisGetValue.mockResolvedValue({
|
||||
success: true,
|
||||
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['todo', 'review'], length: 2 },
|
||||
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['existing'], length: 1 },
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
@@ -1201,15 +1208,73 @@ describe('RedisViewer tree interactions', () => {
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const listTables = antdState.tableProps.filter((props) =>
|
||||
Array.isArray(props.dataSource) && props.dataSource[0]?.value === 'todo',
|
||||
const pushButton = findButtonByText(renderer!, buttonText);
|
||||
expect(pushButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
pushButton!.props.onClick?.();
|
||||
});
|
||||
|
||||
expect(antdState.modalConfirm).toHaveBeenCalledTimes(1);
|
||||
const modalConfig = antdState.modalConfirm.mock.calls[0][0];
|
||||
const getElementById = vi.fn((id: string) => id === inputId ? { value } : null);
|
||||
vi.stubGlobal('document', { getElementById });
|
||||
await act(async () => {
|
||||
await modalConfig.onOk();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(getElementById).toHaveBeenCalledWith(inputId);
|
||||
expect(redisBackend.RedisListPush).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
||||
'app:user:1',
|
||||
{ values: [value], position },
|
||||
);
|
||||
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('removes the selected duplicate List value by its original index after descending sort', async () => {
|
||||
redisBackend.RedisGetValue.mockResolvedValue({
|
||||
success: true,
|
||||
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['duplicate', 'middle', 'duplicate'], length: 3 },
|
||||
});
|
||||
redisBackend.RedisGetListValue.mockResolvedValue({
|
||||
success: true,
|
||||
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['duplicate', 'middle', 'duplicate'], length: 3 },
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const leafNode = findFirstLeafNode(antdState.treeProps.treeData);
|
||||
await act(async () => {
|
||||
antdState.treeProps.onSelect?.([leafNode.key]);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const listTables = antdState.tableProps.filter((props) => Array.isArray(props.dataSource)
|
||||
&& props.dataSource[0]?.value === 'duplicate'
|
||||
&& props.dataSource[0]?.index === 0);
|
||||
const listTable = listTables[listTables.length - 1];
|
||||
expect(listTable).toBeTruthy();
|
||||
const actionColumn = listTable.columns.find((column: any) => column.key === 'action');
|
||||
|
||||
await act(async () => {
|
||||
listTable.onChange?.({}, {}, { columnKey: 'index', order: 'descend' });
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const descendingTables = antdState.tableProps.filter((props) => Array.isArray(props.dataSource)
|
||||
&& props.dataSource[0]?.value === 'duplicate'
|
||||
&& props.dataSource[0]?.index === 2);
|
||||
const descendingTable = descendingTables[descendingTables.length - 1];
|
||||
expect(descendingTable).toBeTruthy();
|
||||
const actionColumn = descendingTable.columns.find((column: any) => column.key === 'action');
|
||||
let actionRenderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
actionRenderer = create(actionColumn.render(null, { index: 1, value: 'review' }));
|
||||
actionRenderer = create(actionColumn.render(null, descendingTable.dataSource[0]));
|
||||
});
|
||||
const confirmation = actionRenderer!.root
|
||||
.findAllByType('span')
|
||||
@@ -1224,7 +1289,8 @@ describe('RedisViewer tree interactions', () => {
|
||||
expect(redisBackend.RedisListRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
||||
'app:user:1',
|
||||
'review',
|
||||
2,
|
||||
'duplicate',
|
||||
);
|
||||
expect(antdState.message.success).toHaveBeenCalledWith('Deleted');
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { t, type I18nParams } from '../i18n';
|
||||
import { useOptionalI18n } from '../i18n/provider';
|
||||
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
|
||||
import RedisResizableDivider from './RedisResizableDivider';
|
||||
import { RedisListPush, RedisListRemove } from '../../wailsjs/go/app/App';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -1673,7 +1674,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!config) return;
|
||||
if (!await confirmRedisMutation(`db${redisDB} / ${selectedKey}`)) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
|
||||
const res = await RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
|
||||
if (res.success) {
|
||||
await loadKeyValue(selectedKey);
|
||||
message.success(tr('redis_viewer.message.add_success'));
|
||||
@@ -1685,12 +1686,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteListItem = async (value: string) => {
|
||||
const handleDeleteListItem = async (index: number, value: string) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
if (!await confirmRedisMutation(`db${redisDB} / ${selectedKey}`)) return;
|
||||
if (!await confirmRedisMutation(`db${redisDB} / ${selectedKey} / ${index}`)) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisListRemove(buildRpcConnectionConfig(config), selectedKey, value);
|
||||
const res = await RedisListRemove(buildRpcConnectionConfig(config), selectedKey, index, value);
|
||||
if (res.success) {
|
||||
await loadKeyValue(selectedKey);
|
||||
message.success(tr('redis_viewer.message.delete_success'));
|
||||
@@ -1827,7 +1828,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
setJsonEditModalOpen(true);
|
||||
}} />
|
||||
)}
|
||||
<Popconfirm title={tr('redis_viewer.confirm.delete_list_item')} onConfirm={() => handleDeleteListItem(record.value)}>
|
||||
<Popconfirm title={tr('redis_viewer.confirm.delete_list_item')} onConfirm={() => handleDeleteListItem(record.index, record.value)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
|
||||
4
frontend/wailsjs/go/app/App.d.ts
vendored
4
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -416,9 +416,9 @@ export function RedisImportKeys(arg1:connection.ConnectionConfig,arg2:app.RedisI
|
||||
|
||||
export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:app.RedisListPushOptions):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisListRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
export function RedisListRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
|
||||
@@ -822,8 +822,8 @@ export function RedisListPush(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RedisListRemove(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisListRemove'](arg1, arg2, arg3);
|
||||
export function RedisListRemove(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['RedisListRemove'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function RedisListSet(arg1, arg2, arg3, arg4) {
|
||||
|
||||
@@ -1325,6 +1325,20 @@ export namespace app {
|
||||
this.file = source["file"];
|
||||
}
|
||||
}
|
||||
export class RedisListPushOptions {
|
||||
values: string[];
|
||||
position: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RedisListPushOptions(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.values = source["values"];
|
||||
this.position = source["position"];
|
||||
}
|
||||
}
|
||||
export class SecurityUpdateOptions {
|
||||
allowPartial?: boolean;
|
||||
writeBackup?: boolean;
|
||||
@@ -2795,4 +2809,3 @@ export namespace sync {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user