Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions web/pgadmin/browser/static/js/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -991,13 +991,15 @@ define('pgadmin.browser', [
_.extend(this.d, this.new);
this.t.update(ctx.i, this.d);
this.t.setLabel(ctx.i, {label: this.new.label});
// Re-sort the renamed node amongst its siblings.
let moved = this.t.resortItem(ctx.i, _.escape(this.new._label));
this.t.addIcon(ctx.i, {icon: this.new.icon});
this.t.setId(ctx.i, {id: this.new.id});
this.t.openPath(this.i);
this.t.deselect(this.i);

// select tree item
self.t.select(self.i);
// select tree item, scrolling to it if it has moved
self.t.select(self.i, moved);
}
}
let success = this.o?.success;
Expand Down
27 changes: 27 additions & 0 deletions web/pgadmin/static/js/tree/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,33 @@ export class Tree {
}
}

/*
* Move an item to its sorted position amongst its siblings after its
* label has changed, so that a renamed node lands where a reload would
* put it. The tree sorts on the item's file name, which is the escaped
* label it was loaded with (reduced to its base name, as on load), so
* that is updated first. Columns are left alone because they keep their
* attribute number order, and the item's resolved path is untouched
* because pgAdmin paths are built from ids. Returns true if the item was
* repositioned.
*/
resortItem(item, label) {
const parent = item?.parent;
if (!parent?._children ||
item.getMetadata('data')?._type === 'column') {
return false;
}
const fileName = item.root.pathfx.basename(label);
const oldIndex = parent._children.indexOf(item);
if (oldIndex === -1 || item.fileName === fileName) {
return false;
}
item._fileName = fileName;
parent.unlinkItem(item, true);
parent.insertItem(item);
return parent._children.indexOf(item) !== oldIndex;
}

async setInode(item) {
if (item._children) item._children = null;
await this.tree.closeDirectory(item);
Expand Down
121 changes: 121 additions & 0 deletions web/regression/javascript/tree/tree_resort_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////

import { Directory, FileType } from 'react-aspen';
import { Tree } from '../../../pgadmin/static/js/tree/tree';
import { ManageTreeNodes } from '../../../pgadmin/static/js/tree/tree_nodes';
import { TreeModelX } from '../../../pgadmin/static/js/components/PgTree';

// The ordering rules the Object Explorer host applies, using a plain string
// comparison in place of pgAdmin.natural_sort, which the Jest setup fakes.
const compare = (a, b) => (a < b ? -1 : (a > b ? 1 : 0));
const sortComparator = (a, b) => {
if (a._metadata && a._metadata.data._type == 'column') return 0;
if (a.constructor === b.constructor) {
return compare(a.fileName, b.fileName);
}
if (a.constructor === Directory) return -1;
if (b.constructor === Directory) return 1;
return 0;
};

const rawItem = (name, _type, inode = true) => ({
type: inode ? FileType.Directory : FileType.File,
name,
metadata: {data: {_type, id: name, label: name}},
});

const structure = {
'/browser': [
rawItem('Alpha', 'server_group'),
rawItem('Beta', 'server_group'),
rawItem('Gamma', 'server_group'),
],
'/browser/Alpha': [
rawItem('Server A', 'server', false),
rawItem('Server B', 'server', false),
],
'/browser/Beta': [],
'/browser/Gamma': [],
};

const rowLabels = (model) => {
const labels = [];
for (let i = 0; i < model.root.branchSize; i++) {
labels.push(model.root.getFileEntryAtIndex(i).fileName);
}
return labels;
};

describe('Tree#resortItem', () => {
let model, tree;

const child = (dir, name) =>
dir.children.find((c) => c.getMetadata('data').id === name);

beforeEach(async () => {
model = new TreeModelX({
pathStyle: 'unix',
getItems: async (path) => structure[path] || [],
sortComparator,
}, '/browser');
await model.root.ensureLoaded();
tree = new Tree(
{onTreeEvents: jest.fn()}, new ManageTreeNodes(), {Nodes: {}}
);
});

it('moves a renamed node to its sorted position', () => {
const item = child(model.root, 'Alpha');

expect(tree.resortItem(item, 'Zulu')).toBe(true);
expect(model.root.children.map((c) => c.fileName))
.toEqual(['Beta', 'Gamma', 'Zulu']);
expect(rowLabels(model)).toEqual(['Beta', 'Gamma', 'Zulu']);
});

it('keeps the children of an expanded node directly below it', async () => {
const item = child(model.root, 'Alpha');
await item.setExpanded();
expect(rowLabels(model)).toEqual(
['Alpha', 'Server A', 'Server B', 'Beta', 'Gamma']
);

expect(tree.resortItem(item, 'Delta')).toBe(true);
expect(rowLabels(model)).toEqual(
['Beta', 'Delta', 'Server A', 'Server B', 'Gamma']
);
});

it('does not change the path of the renamed node', () => {
const item = child(model.root, 'Gamma');
const path = item.path;

tree.resortItem(item, 'Aardvark');
expect(item.path).toEqual(path);
expect(model.root.children[0]).toBe(item);
});

it('reports no move when the sorted position is unchanged', () => {
const item = child(model.root, 'Beta');

expect(tree.resortItem(item, 'Bravo')).toBe(false);
expect(item.fileName).toEqual('Bravo');
expect(tree.resortItem(item, 'Bravo')).toBe(false);
});

it('leaves columns in their original order', () => {
const item = child(model.root, 'Alpha');
item.getMetadata('data')._type = 'column';

expect(tree.resortItem(item, 'Zulu')).toBe(false);
expect(item.fileName).toEqual('Alpha');
expect(model.root.children[0]).toBe(item);
});
});
Loading