NocoDB Issue #13572
BUG CONFIRMED — RED on v0.301.3
Spec exit code: 1 (1 test failed, 1 passed)
Primary assertion: expect(orderAfter).not.toEqual(orderBefore)
Root cause: Sortable.js is conditionally initialized on .nc-bases-grid only for super admins.
Elevated users see the same UI but no Sortable instance exists on their grid — drag gestures are a complete no-op.
Root Cause Detail
Sortable.js instance check on .nc-bases-grid:
Object.keys(document.querySelector('.nc-bases-grid')).some(k => k.startsWith('Sortable'))
Admin: true (Sortable1776951510459 attached)
Elevated user: false (no Sortable instance — drag is a no-op)
There is no drag handle element in the modal — the entire .nc-base-node card is the drag target.
The bug is that the Sortable.js library is only wired up to the grid for super admins.
The API endpoint PATCH /api/v1/db/meta/projects/{id} is accessible to elevated users (returns 200 when called directly),
confirming the restriction is purely frontend.
Test Results
User
Role
Sortable on .nc-bases-grid
DOM order changed
PATCH calls
Result
Super Admin
org-level-creator,super
yes
yes
1 — HTTP 200{"order": 4.5}
PASS
Elevated User
org-level-creator + base owner
no
no
0 — no call made
FAIL (bug)
DOM Order Evidence
Before: ["Database 2", "Database 3", "Database 1", ...]
After: ["Database 3", "Database 2", "Database 1", ...]
Changed: YES
Before: ["Database 3", "Database 2", "Database 1", ...]
After: ["Database 3", "Database 2", "Database 1", ...]
Changed: NO
Screenshots — Admin (Super Admin, drag works)
Admin: modal opened, bases visible
Admin: DURING drag (mouse down, mid-move — proof drag started)
Admin: after drag — order changed
Screenshots — Elevated User (drag silently blocked)
Elevated: modal opened — same UI as admin
Elevated: DURING drag (mouse down, mid-move — drag executed but no Sortable)
Elevated: after drag — order UNCHANGED (bug)
Elevated: Playwright failure screenshot
Spec Source
/**
* Spec: NocoDB issue #13572
* "Only Super Admin can change database order"
* Bug version: 0.301.3
*
* Root cause: Sortable.js is only initialized on .nc-bases-grid for super admins.
* Elevated users (org-level-creator + base owner) see the same UI but .nc-bases-grid
* has NO Sortable instance — drag gestures have no effect.
*
* Test 1 (admin): drag succeeds → DOM order changes → PASS
* Test 2 (elevated): drag silently blocked → DOM order unchanged → FAIL (bug)
*
* Exit code: 1 (RED) on v0.301.3
*/
const { test, expect } = require('/home/bacher/prj/prv/vlyby-kyron/playwright/node_modules/@playwright/test');
const http = require('http');
const BASE_URL = process.env.NC_BASE || 'http://localhost:8383';
const ADMIN_EMAIL = process.env.NC_ADMIN;
const ELEV_EMAIL = process.env.NC_USER2;
const PASSWORD = process.env.NC_APWD;
// ─────────────────────────── helpers ───────────────────────────
function apiReq(method, path, body, token) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const urlObj = new URL(BASE_URL + path);
const opts = {
hostname: urlObj.hostname,
port: urlObj.port || 80,
path: urlObj.pathname + (urlObj.search || ''),
method,
headers: {
'Content-Type': 'application/json',
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
...(token ? { 'xc-auth': token } : {}),
},
};
const req = http.request(opts, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(d) }); }
catch (e) { resolve({ status: res.statusCode, body: d }); }
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}
async function getToken(email) {
const res = await apiReq('POST', '/api/v1/auth/user/signin', { email, password: PASSWORD }, null);
if (!res.body.token) throw new Error(`Login failed for ${email}: ${JSON.stringify(res.body)}`);
return res.body.token;
}
async function ensureBase(title, token) {
const listRes = await apiReq('GET', '/api/v1/db/meta/projects/', null, token);
const existing = (listRes.body.list || []).find(b => b.title === title);
if (existing) return existing.id;
const createRes = await apiReq('POST', '/api/v1/db/meta/projects/', { title }, token);
return createRes.body.id;
}
async function ensureElevatedIsOwner(baseId, adminToken) {
const r = await apiReq('POST', `/api/v1/db/meta/projects/${baseId}/users`,
{ email: ELEV_EMAIL, roles: 'owner' }, adminToken);
return r;
}
// ─────────────────────── beforeAll setup ────────────────────────
let adminToken;
let alphaBaseId;
let betaBaseId;
test.beforeAll(async () => {
adminToken = await getToken(ADMIN_EMAIL);
alphaBaseId = await ensureBase('AlphaBase', adminToken);
betaBaseId = await ensureBase('BetaBase', adminToken);
await ensureElevatedIsOwner(alphaBaseId, adminToken);
await ensureElevatedIsOwner(betaBaseId, adminToken);
console.log(`Setup: AlphaBase=${alphaBaseId} BetaBase=${betaBaseId}`);
});
// ─────────────────────── page helpers ────────────────────────────
async function loginViaUI(page, email) {
await page.goto(`${BASE_URL}/dashboard/#/signin`);
await page.waitForSelector('input[type="email"]', { timeout: 20000 });
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', PASSWORD);
await Promise.all([
page.waitForURL(url => !url.toString().includes('signin'), { timeout: 20000 }).catch(() => {}),
page.click('button[type="submit"]'),
]);
await page.waitForTimeout(3000);
}
async function dismissSurvey(page) {
const skipBtn = page.locator('button', { hasText: 'Skip' });
if (await skipBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await skipBtn.click();
await page.waitForTimeout(2000);
}
}
async function openWorkspaceModal(page) {
const wsBtn = page.locator('.nc-mini-sidebar-ws-item').first();
await expect(wsBtn).toBeVisible({ timeout: 15000 });
await wsBtn.click();
await page.waitForTimeout(2000);
const modal = page.locator('.nc-workspace-base-list-modal');
await expect(modal).toBeVisible({ timeout: 8000 });
}
/** Read DOM order of base node titles */
async function getBaseOrder(page) {
return page.evaluate(() =>
Array.from(document.querySelectorAll('.nc-base-node'))
.map(el => (el.querySelector('.truncate')?.textContent || '').trim())
.filter(Boolean)
);
}
/** Check whether the Sortable.js instance is attached to .nc-bases-grid */
async function hasSortableInstance(page) {
return page.evaluate(() => {
const grid = document.querySelector('.nc-bases-grid');
if (!grid) return false;
return Object.keys(grid).some(k => k.startsWith('Sortable'));
});
}
/**
* Drag the first visible base node toward the second.
* Takes a mid-drag screenshot as proof that the drag gesture was executed.
* Returns { orderBefore, orderAfter, patchCalls, sortablePresent }.
*/
async function dragFirstToSecond(page, screenshotBase) {
const orderBefore = await getBaseOrder(page);
console.log(`Order before drag: ${JSON.stringify(orderBefore)}`);
const sortablePresent = await hasSortableInstance(page);
console.log(`Sortable instance on .nc-bases-grid: ${sortablePresent}`);
const patchCalls = [];
page.on('request', req => {
if (req.method() === 'PATCH' && req.url().includes('/api/v1/db/meta/projects/')) {
patchCalls.push({ url: req.url().replace(BASE_URL, ''), body: req.postData() });
}
});
const baseNodes = page.locator('.nc-base-node');
await expect(baseNodes.first()).toBeVisible({ timeout: 5000 });
const count = await baseNodes.count();
if (count < 2) throw new Error(`Not enough base nodes: ${count}`);
const srcBox = await baseNodes.first().boundingBox();
const dstBox = await baseNodes.nth(1).boundingBox();
const srcX = srcBox.x + srcBox.width / 2;
const srcY = srcBox.y + srcBox.height / 2;
const dstX = dstBox.x + dstBox.width / 2;
const dstY = dstBox.y + dstBox.height / 2;
console.log(`Drag: (${srcX.toFixed(0)},${srcY.toFixed(0)}) → (${dstX.toFixed(0)},${dstY.toFixed(0)})`);
// Drag sequence
await page.mouse.move(srcX, srcY);
await page.mouse.down();
await page.waitForTimeout(300);
await page.mouse.move(dstX, dstY, { steps: 20 });
// Screenshot WHILE mouse is down — proves drag was executed
await page.screenshot({ path: `test-results/${screenshotBase}-during-drag.png` });
await page.mouse.up();
// Wait for DOM to settle; watch for PATCH response if one fires
await Promise.race([
page.waitForResponse(
r => r.url().includes('/api/v1/db/meta/projects/') && r.request().method() === 'PATCH',
{ timeout: 3000 }
).catch(() => null),
page.waitForTimeout(3000),
]);
await page.waitForTimeout(500);
const orderAfter = await getBaseOrder(page);
console.log(`Order after drag: ${JSON.stringify(orderAfter)}`);
await page.screenshot({ path: `test-results/${screenshotBase}-after-drag.png` });
return { orderBefore, orderAfter, patchCalls, sortablePresent };
}
// ─────────────────────── TEST 1: Super Admin ─────────────────────
test('super admin can reorder databases via drag-and-drop', async ({ page }) => {
await loginViaUI(page, ADMIN_EMAIL);
await dismissSurvey(page);
await openWorkspaceModal(page);
await page.screenshot({ path: 'test-results/spec-admin-01-modal.png' });
const { orderBefore, orderAfter, patchCalls, sortablePresent } = await dragFirstToSecond(page, 'spec-admin');
console.log(`Admin PATCH calls: ${patchCalls.length}`, JSON.stringify(patchCalls));
// Confirm Sortable IS initialized for admin (secondary, documents root cause)
expect(sortablePresent, 'Admin: Sortable.js must be initialized on .nc-bases-grid').toBe(true);
// PRIMARY assertion: DOM order must change after admin drag
expect(orderAfter, 'Admin: DOM order of bases must change after drag').not.toEqual(orderBefore);
});
// ─────────────────────── TEST 2: Elevated User ───────────────────
test('elevated user (org-level-creator + base owner) can reorder databases via drag-and-drop', async ({ page }) => {
await loginViaUI(page, ELEV_EMAIL);
await dismissSurvey(page);
await openWorkspaceModal(page);
await page.screenshot({ path: 'test-results/spec-elevated-01-modal.png' });
const { orderBefore, orderAfter, patchCalls, sortablePresent } = await dragFirstToSecond(page, 'spec-elevated');
console.log(`Elevated PATCH calls: ${patchCalls.length}`, JSON.stringify(patchCalls));
console.log(`Elevated Sortable initialized: ${sortablePresent}`);
// PRIMARY assertion: DOM order MUST change after drag
// BUG on v0.301.3: Sortable.js not initialized for non-super-admin →
// drag has no effect → DOM order unchanged → this assertion FAILS
expect(orderAfter,
`Elevated user: DOM order must change after drag — BUG: Sortable.js not initialized (sortablePresent=${sortablePresent}), 0 PATCH calls fired`
).not.toEqual(orderBefore);
});
Evidence JSON
{
"issue": "https://github.com/nocodb/nocodb/issues/13572",
"title": "Only Super Admin can change database order",
"version": "0.301.3",
"reproduced": true,
"verdict": "BUG_CONFIRMED",
"date": "2026-04-23",
"environment": {
"docker_image": "nocodb/nocodb:0.301.3",
"base_url": "http://localhost:8383",
"os": "Linux WSL2",
"playwright": "latest"
},
"users": {
"admin": {
"email": "admin@kyron.local",
"role": "org-level-creator,super",
"description": "Super admin with full workspace permissions"
},
"elevated": {
"email": "elevated@kyron.local",
"role": "org-level-creator",
"description": "Elevated user with creator role, added as owner to AlphaBase and BetaBase"
}
},
"root_cause": "Sortable.js (drag-and-drop library) is conditionally initialized on .nc-bases-grid only for super admins. For elevated users (org-level-creator + base owner), no Sortable instance is created on the grid, so drag gestures have no effect.",
"bug_mechanism": {
"description": "Sortable.js instance absent for non-super-admin on .nc-bases-grid",
"admin": {
"sortable_instance_on_grid": true,
"drag_effect": "DOM order changes",
"patch_calls_fired": 1,
"patch_endpoint": "/api/v1/db/meta/projects/{id}",
"patch_body": "{\"order\": 4.5}",
"http_status": 200
},
"elevated": {
"sortable_instance_on_grid": false,
"drag_effect": "No effect — drag gesture is a no-op",
"patch_calls_fired": 0,
"http_status": "N/A (no call made)"
}
},
"test_results": {
"admin": {
"test": "super admin can reorder databases via drag-and-drop",
"result": "PASS",
"order_before": [
"Database 2",
"Database 3",
"Database 1",
"Getting Started",
"AlphaBase",
"BetaBase"
],
"order_after": [
"Database 3",
"Database 2",
"Database 1",
"Getting Started",
"AlphaBase",
"BetaBase"
],
"order_changed": true
},
"elevated": {
"test": "elevated user (org-level-creator + base owner) can reorder databases via drag-and-drop",
"result": "FAIL",
"order_before": [
"Database 3",
"Database 2",
"Database 1",
"Getting Started",
"AlphaBase",
"BetaBase"
],
"order_after": [
"Database 3",
"Database 2",
"Database 1",
"Getting Started",
"AlphaBase",
"BetaBase"
],
"order_changed": false,
"failure_reason": "DOM order unchanged after drag — Sortable.js not initialized for non-super-admin"
}
},
"spec_file": "spec.spec.js",
"spec_exit_code": 1,
"spec_iterations": 1,
"primary_assertion": "expect(orderAfter).not.toEqual(orderBefore)",
"secondary_evidence": "Sortable instance check: Object.keys(.nc-bases-grid).some(k => k.startsWith('Sortable'))",
"screenshots": {
"admin_modal": "test-results/spec-admin-01-modal.png",
"admin_during_drag": "test-results/spec-admin-during-drag.png",
"admin_after_drag": "test-results/spec-admin-after-drag.png",
"elevated_modal": "test-results/spec-elevated-01-modal.png",
"elevated_during_drag": "test-results/spec-elevated-during-drag.png",
"elevated_after_drag": "test-results/spec-elevated-after-drag.png",
"elevated_failure": "test-results/spec-elevated-failed.png"
},
"api_endpoint": "PATCH /api/v1/db/meta/projects/{baseId}",
"api_body_format": "{\"order\": <number>}"
}
How to Reproduce
Start NocoDB v0.301.3 (docker compose up)
Create a super admin and an elevated user (org-level-creator + base owner)
Log in as elevated user
Click workspace icon in mini-sidebar → base list modal opens
Drag a database card to a new position
Observe: no reorder occurs (DOM order unchanged, no PATCH call, no Sortable instance on grid)
Log in as super admin → same drag → order changes (Sortable.js active, PATCH fired)
Generated by Kyron @reprod agent • 2026-04-24T08:03:57.773Z
Generated by Kyron • Automated bug reproduction