diff --git a/pkg/helm/templates/deployment.yaml b/pkg/helm/templates/deployment.yaml
index 5c12294868b..854e809180c 100644
--- a/pkg/helm/templates/deployment.yaml
+++ b/pkg/helm/templates/deployment.yaml
@@ -32,12 +32,12 @@ spec:
{{- with omit .Values.commonLabels "app" }}
{{- . | toYaml | nindent 8 }}
{{- end }}
- {{- if or (not (empty .Values.commonAnnotations)) (not .Values.existingSecret) .Values.preferences.enabled .Values.serverDefinitions.enabled }}
+ {{- if or (not (empty .Values.commonAnnotations)) (empty .Values.auth.existingSecret) .Values.preferences.enabled .Values.serverDefinitions.enabled }}
annotations:
{{- with .Values.commonAnnotations }}
{{- . | toYaml | nindent 8 }}
{{- end }}
- {{- if not .Values.existingSecret }}
+ {{- if empty .Values.auth.existingSecret }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- end }}
{{- if and .Values.config_local.enabled (empty .Values.config_local.existingSecret) }}
diff --git a/web/pgadmin/browser/server_groups/servers/roles/__init__.py b/web/pgadmin/browser/server_groups/servers/roles/__init__.py
index a2f407cda68..63a21d7887e 100644
--- a/web/pgadmin/browser/server_groups/servers/roles/__init__.py
+++ b/web/pgadmin/browser/server_groups/servers/roles/__init__.py
@@ -619,6 +619,7 @@ def _check_action(action, kwargs):
return fetch_name, check_permission, forbidden_msg
def _check_permission(self, check_permission, action, kwargs):
+ self.membership_only_update = False
if check_permission:
user = self.manager.user_info
@@ -627,6 +628,15 @@ def _check_permission(self, check_permission, action, kwargs):
(action != 'update' or 'rid' in kwargs) and \
kwargs['rid'] != -1 and \
user['id'] != kwargs['rid']:
+ # A role that only has ADMIN OPTION on this specific role
+ # (rather than being a superuser or having CREATEROLE) may
+ # still manage that role's membership, so don't forbid the
+ # request outright; the update handler restricts what such
+ # a request is allowed to change to membership only.
+ if action == 'update' and getattr(
+ self, 'has_admin_option', False):
+ self.membership_only_update = True
+ return False
return True
return False
@@ -658,6 +668,7 @@ def _check_and_fetch_name(self, fetch_name, kwargs):
self.role = row['rolname']
self.rolCanLogin = row['rolcanlogin']
self.rolSuper = row['rolsuper']
+ self.has_admin_option = row.get('has_admin_option', False)
return False, ''
@@ -713,16 +724,20 @@ def wrapped(self, **kwargs):
fetch_name, check_permission, \
forbidden_msg = RoleView._check_action(action, kwargs)
- is_permission_error = self._check_permission(check_permission,
- action, kwargs)
- if is_permission_error:
- return forbidden(forbidden_msg)
-
+ # Fetched first: the permission check needs to know
+ # whether the current user holds ADMIN OPTION on this
+ # role before it can decide whether to forbid the
+ # request.
is_error, errmsg = self._check_and_fetch_name(fetch_name,
kwargs)
if is_error:
return errmsg
+ is_permission_error = self._check_permission(check_permission,
+ action, kwargs)
+ if is_permission_error:
+ return forbidden(forbidden_msg)
+
return f(self, **kwargs)
return wrapped
@@ -1023,6 +1038,13 @@ def create(self, gid, sid):
@check_precondition(action='update')
@validate_request
def update(self, gid, sid, rid):
+ if getattr(self, 'membership_only_update', False) and \
+ not set(self.request) <= {'rolmembers'}:
+ return forbidden(
+ _("The current user does not have permission to update "
+ "the role. Users with ADMIN OPTION on this role may "
+ "only manage its membership.")
+ )
sql = render_template(
self.sql_path + self._UPDATE_SQL,
diff --git a/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js b/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js
index 68ff085dacd..b23f06e0640 100644
--- a/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js
+++ b/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js
@@ -55,6 +55,18 @@ export default class RoleSchema extends BaseUISchema {
return (!(user.is_superuser || user.can_create_role) && user.id != state.oid);
}
+ // A role that isn't a superuser or CREATEROLE holder can still manage
+ // this role's membership if they hold ADMIN OPTION on it themselves.
+ isMemberAdmin(state) {
+ return (state.rolmembers ?? []).some(
+ (member) => member.role === this.user.name && member.admin
+ );
+ }
+
+ membersReadOnly(state) {
+ return this.readOnly(state) && !this.isMemberAdmin(state);
+ }
+
memberDataFormatter(rawData) {
let members = '';
if(_.isObject(rawData)) {
@@ -194,8 +206,8 @@ export default class RoleSchema extends BaseUISchema {
mode: ['edit', 'create'], cell: 'text',
type: 'collection',
schema: obj.membershipSchema,
- disabled: obj.readOnly,
- canDelete: (state) => !obj.readOnly(state),
+ disabled: (state) => obj.membersReadOnly(state),
+ canDelete: (state) => !obj.membersReadOnly(state),
canDeleteRow: true,
helpMessage: obj.isReadOnly ? gettext('Select the checkbox for roles to include WITH ADMIN OPTION.') : gettext('Roles shown with a check mark have the WITH ADMIN OPTION set.'),
},
diff --git a/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql b/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql
index 66b931cd970..7f3febc6645 100644
--- a/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql
+++ b/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql
@@ -1,5 +1,14 @@
SELECT
- rolname, rolcanlogin, rolsuper
+ rolname, rolcanlogin, rolsuper,
+ EXISTS (
+ SELECT 1 FROM pg_catalog.pg_auth_members am
+ WHERE am.roleid = {{ rid }}::OID
+ AND am.member = (
+ SELECT oid FROM pg_catalog.pg_roles
+ WHERE rolname = current_user
+ )
+ AND am.admin_option
+ ) AS has_admin_option
FROM
pg_catalog.pg_roles
WHERE oid = {{ rid }}::OID
diff --git a/web/pgadmin/browser/server_groups/servers/roles/tests/test_role_check_permission_unit_test.py b/web/pgadmin/browser/server_groups/servers/roles/tests/test_role_check_permission_unit_test.py
new file mode 100644
index 00000000000..4319ec9ff4d
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/roles/tests/test_role_check_permission_unit_test.py
@@ -0,0 +1,62 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+from unittest.mock import MagicMock
+
+from pgadmin.utils.route import BaseTestGenerator
+from pgadmin.browser.server_groups.servers.roles import RoleView
+
+
+class RoleCheckPermissionTest(BaseTestGenerator):
+ """Unit tests for RoleView._check_permission's ADMIN OPTION carve-out.
+
+ A role holder who is neither a superuser nor a CREATEROLE holder, but
+ who has been granted ADMIN OPTION on the specific role being updated,
+ should be allowed through the permission gate so they can manage that
+ role's membership - but only for 'update', never for 'drop', and the
+ view should record that the request must be restricted to membership
+ changes only.
+ """
+ scenarios = [
+ ('Check Role Node', dict(url='/browser/role/obj/'))
+ ]
+
+ def setUp(self):
+ pass
+
+ def runTest(self):
+ view = RoleView(cmd=None)
+ view.manager = MagicMock()
+
+ # Plain user, no admin option: update is forbidden.
+ view.manager.user_info = {
+ 'is_superuser': False, 'can_create_role': False, 'id': 5
+ }
+ view.has_admin_option = False
+ self.assertTrue(view._check_permission(True, 'update', {'rid': 10}))
+ self.assertFalse(view.membership_only_update)
+
+ # Same user, but with ADMIN OPTION on the target role: allowed
+ # through, flagged as membership-only.
+ view.has_admin_option = True
+ self.assertFalse(view._check_permission(True, 'update', {'rid': 10}))
+ self.assertTrue(view.membership_only_update)
+
+ # ADMIN OPTION does not extend to dropping the role.
+ self.assertTrue(view._check_permission(True, 'drop', {'rid': 10}))
+
+ # Superusers are unaffected by the ADMIN OPTION check.
+ view.manager.user_info = {
+ 'is_superuser': True, 'can_create_role': False, 'id': 5
+ }
+ view.has_admin_option = False
+ self.assertFalse(view._check_permission(True, 'update', {'rid': 10}))
+
+ def tearDown(self):
+ pass
diff --git a/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx b/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx
index 8366cd44ccd..d497c90811e 100644
--- a/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx
+++ b/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx
@@ -122,12 +122,25 @@ export default function DataGridView({
)
).includes(true);
+ // Virtualising a small grid buys nothing (there's no offscreen window to
+ // skip rendering) but still pays for measureElement's per-row
+ // getBoundingClientRect on every mount/remeasure. That remeasure is
+ // exactly what fires when a dialog tab holding the grid is hidden via
+ // `display: none` and then shown again, since the scroll viewport
+ // momentarily measures 0 and the virtualizer's ResizeObserver treats
+ // that as a real resize. Below the threshold we skip virtualisation
+ // entirely and render every row in normal document flow, so showing a
+ // hidden tab is a pure CSS toggle again.
+ const virtualiseThreshold = viewHelperProps.virtualiseThreshold ?? 100;
+ const shouldVirtualise = rows.length > virtualiseThreshold;
+
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableEleRef.current,
estimateSize: () => 50,
measureElement:
- typeof window !== 'undefined' &&
+ shouldVirtualise &&
+ typeof window !== 'undefined' &&
navigator.userAgent.indexOf('Firefox') === -1
? element => element?.getBoundingClientRect().height
: undefined,
@@ -152,22 +165,29 @@ export default function DataGridView({
ref={tableEleRef} table={table} data-test="data-grid-view"
tableClassName='DataGridView-table'>
-
+
{
- virtualizer.getVirtualItems().map((virtualRow) => {
+ (
+ shouldVirtualise
+ ? virtualizer.getVirtualItems()
+ : rows.map((_row, index) => ({index, start: 0}))
+ ).map((virtualRow) => {
const row = rows[virtualRow.index];
return (
virtualizer.measureElement(node)}
- style={{
- // This should always be a `style` as it changes on
- // scroll.
- transform: `translateY(${virtualRow.start}px)`,
- }}
+ ref={shouldVirtualise ? node => virtualizer.measureElement(node) : undefined}
+ className={shouldVirtualise ? undefined : 'pgrt-row--static'}
+ style={
+ shouldVirtualise ? {
+ // This should always be a `style` as it changes
+ // on scroll.
+ transform: `translateY(${virtualRow.start}px)`,
+ } : undefined
+ }
>
({
position: 'absolute',
width: '100%',
+ // Opted out of the virtualizer's absolute positioning for grids
+ // small enough that virtualisation isn't used. Keeps the row in
+ // normal document flow so a hidden/shown dialog tab is a pure CSS
+ // toggle instead of triggering a virtualizer remeasure.
+ '&.pgrt-row--static': {
+ position: 'static',
+ },
+
'& .pgrt-row-content': {
display: 'flex',
minHeight: 0,
diff --git a/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql b/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql
index 32abb115f71..35312924227 100644
--- a/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql
+++ b/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql
@@ -14,7 +14,6 @@
{% if data.vacuum_parallel %}{{ maintenance_options.append('PARALLEL ' + data.vacuum_parallel) or "" }}{% endif %}
{% if data.buffer_usage_limit %}{{ maintenance_options.append('BUFFER_USAGE_LIMIT "' + data.buffer_usage_limit + '"') or "" }}{% endif %}
{% if data.reindex_tablespace %}{{ maintenance_options.append('TABLESPACE ' + conn|qtIdent(data.reindex_tablespace)) or "" }}{% endif %}
-{% if data.reindex_concurrently %}{{ maintenance_options.append('CONCURRENTLY') or "" }}{% endif %}
{% if data.op == "VACUUM" %}
VACUUM{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %}{% if data.schema %} {{ conn|qtIdent(data.schema) }}.{{ conn|qtIdent(data.table) }}{% endif %};
{% endif %}
@@ -23,9 +22,9 @@ ANALYZE{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{
{% endif %}
{% if data.op == "REINDEX" %}
{% if index_name %}
-REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %} INDEX {{ conn|qtIdent(data.schema, index_name) }};
+REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %} INDEX{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.schema, index_name) }};
{% else %}
-REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %}{% if not data.schema and not data.reindex_system %} DATABASE {{ conn|qtIdent(data.database) }}{% elif not data.schema and data.reindex_system%} SYSTEM {{ conn|qtIdent(data.database) }}{% elif data.schema and not data.table and not data.primary_key and not data.unique_constraint and not data.index and not data.mview %} SCHEMA {{ conn|qtIdent(data.schema) }}{% else %} TABLE {{ conn|qtIdent(data.schema, data.table) }}{% endif %};
+REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %}{% if not data.schema and not data.reindex_system %} DATABASE{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.database) }}{% elif not data.schema and data.reindex_system%} SYSTEM {{ conn|qtIdent(data.database) }}{% elif data.schema and not data.table and not data.primary_key and not data.unique_constraint and not data.index and not data.mview %} SCHEMA{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.schema) }}{% else %} TABLE{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.schema, data.table) }}{% endif %};
{% endif %}
{% endif %}
{% if data.op == "CLUSTER" %}
diff --git a/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py b/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py
index 0b6f265b9ee..54fdfebc670 100644
--- a/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py
+++ b/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py
@@ -537,7 +537,7 @@ class MaintenanceCreateJobTest(BaseTestGenerator):
verbose=True
),
url=MAINTENANCE_URL,
- expected_cmd_opts=['REINDEX (VERBOSE, CONCURRENTLY) DATABASE '
+ expected_cmd_opts=['REINDEX (VERBOSE) DATABASE CONCURRENTLY '
'postgres;\n'],
server_min_version=120000,
message='REINDEX CONCURRENTLY is not supported by EPAS/PG server '
@@ -643,7 +643,7 @@ class MaintenanceCreateJobTest(BaseTestGenerator):
verbose=True
),
url=MAINTENANCE_URL,
- expected_cmd_opts=['REINDEX (VERBOSE, CONCURRENTLY) TABLE '
+ expected_cmd_opts=['REINDEX (VERBOSE) TABLE CONCURRENTLY '
'my_schema.my_table;\n'],
server_min_version=120000,
message='REINDEX CONCURRENTLY TABLE is not supported by '
@@ -710,7 +710,7 @@ class MaintenanceCreateJobTest(BaseTestGenerator):
verbose=True
),
url=MAINTENANCE_URL,
- expected_cmd_opts=['REINDEX (VERBOSE, CONCURRENTLY) INDEX '
+ expected_cmd_opts=['REINDEX (VERBOSE) INDEX CONCURRENTLY '
'my_schema.my_index;\n'],
server_min_version=120000,
message='REINDEX CONCURRENTLY is not supported by EPAS/PG server '
diff --git a/web/pgadmin/utils/__init__.py b/web/pgadmin/utils/__init__.py
index d459e72b99d..0a57d7e6c0f 100644
--- a/web/pgadmin/utils/__init__.py
+++ b/web/pgadmin/utils/__init__.py
@@ -649,9 +649,11 @@ def check_is_integer(value):
"found for server '%s'" % server
)
else:
- errmsg = check_attrib("Username")
- if errmsg:
- return errmsg
+ if not obj.get("Username"):
+ return gettext(
+ "'Username' attribute not found for server '%s'" %
+ server
+ )
errmsg = check_attrib("MaintenanceDB")
if errmsg:
diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py
index d07a16cefcd..d8a6cd53172 100644
--- a/web/pgadmin/utils/driver/psycopg3/connection.py
+++ b/web/pgadmin/utils/driver/psycopg3/connection.py
@@ -1173,6 +1173,19 @@ def execute_void(self, query, params=None, formatted_exception_msg=False):
if not status:
return False, str(cur)
+
+ if isinstance(cur, AsyncDictServerCursor):
+ # A named/server-side cursor's execute() always runs the query
+ # as `DECLARE ... CURSOR FOR `, which cannot express a
+ # transaction-control statement such as BEGIN/COMMIT/ROLLBACK.
+ # Run this one statement through a throwaway plain cursor
+ # instead, leaving the cached server-side cursor untouched, and
+ # treat it as leaving no result set for whatever poll() call
+ # comes next.
+ cur = self.conn.cursor()
+ self.column_info = None
+ self.row_count = 0
+
query_id = str(secrets.choice(range(1, 9999999)))
current_app.logger.log(
diff --git a/web/pgadmin/utils/driver/psycopg3/tests/test_execute_void_server_cursor.py b/web/pgadmin/utils/driver/psycopg3/tests/test_execute_void_server_cursor.py
new file mode 100644
index 00000000000..c885f66df8e
--- /dev/null
+++ b/web/pgadmin/utils/driver/psycopg3/tests/test_execute_void_server_cursor.py
@@ -0,0 +1,85 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Regression test: ``execute_void()`` must not run a transaction-control
+statement (BEGIN/COMMIT/ROLLBACK) through a cached named/server-side
+cursor.
+
+A named cursor's ``execute()`` always wraps the statement as
+``DECLARE ... CURSOR FOR ``, which cannot express BEGIN/COMMIT/
+ROLLBACK. Before the fix, the Commit/Rollback buttons under "server
+cursor" mode silently did nothing: the DECLARE-wrapped call failed
+(actually failing one step earlier, on a ``prepare`` keyword the
+server-side cursor's ``execute()`` doesn't accept at all), the exception
+was swallowed by the background query thread, and the next poll() then
+reported the *previous* query's leftover column info, making the result
+grid appear instead of the Messages tab (pgAdmin issue #8991)."""
+
+from unittest.mock import MagicMock, patch
+
+from pgadmin.utils.driver.psycopg3.connection import Connection
+from pgadmin.utils.driver.psycopg3.cursor import AsyncDictServerCursor
+from pgadmin.utils.route import BaseTestGenerator
+
+
+class ExecuteVoidServerCursorTest(BaseTestGenerator):
+
+ scenarios = [
+ ('COMMIT with a cached server-side cursor runs on a throwaway '
+ 'plain cursor and clears stale column info', dict(sql='COMMIT;')),
+ ('ROLLBACK with a cached server-side cursor runs on a throwaway '
+ 'plain cursor and clears stale column info',
+ dict(sql='ROLLBACK;')),
+ ]
+
+ def runTest(self):
+ manager = MagicMock(sid=1)
+ conn = Connection(manager, 'test-conn-id', 'testdb')
+ conn.python_encoding = 'utf-8'
+
+ # Leftover state from a previous SELECT executed through the
+ # server-side cursor.
+ conn.column_info = [{'name': 'x'}]
+ conn.row_count = 1
+
+ server_cursor = MagicMock(spec=AsyncDictServerCursor)
+ server_cursor.closed = False
+
+ plain_cursor = MagicMock()
+ plain_cursor.closed = False
+
+ conn.conn = MagicMock()
+ conn.conn.cursor.return_value = plain_cursor
+ conn.conn.info.user = 'postgres'
+ conn.conn.info.host = 'localhost'
+ conn.conn.info.dbname = 'testdb'
+
+ # current_user needs a real request context to resolve at all;
+ # patch it only once inside that context, to a stand-in with the
+ # attribute execute_void()'s log line reads.
+ with self.app.test_request_context():
+ with patch(
+ 'pgadmin.utils.driver.psycopg3.connection.current_user',
+ MagicMock(email='test@example.com')
+ ), patch.object(Connection, '_Connection__cursor',
+ return_value=(True, server_cursor)):
+ status, result = conn.execute_void(self.sql)
+
+ self.assertTrue(status)
+ self.assertIsNone(result)
+
+ # The statement ran on the throwaway plain cursor, not the
+ # cached server-side one.
+ plain_cursor.execute.assert_called_once()
+ server_cursor.execute.assert_not_called()
+
+ # Stale result-set state from the prior SELECT must not leak
+ # into whatever poll() call comes next.
+ self.assertIsNone(conn.column_info)
+ self.assertEqual(conn.row_count, 0)
diff --git a/web/pgadmin/utils/tests/test_validate_json_data.py b/web/pgadmin/utils/tests/test_validate_json_data.py
index b5525d8eed3..4360f9dccb0 100644
--- a/web/pgadmin/utils/tests/test_validate_json_data.py
+++ b/web/pgadmin/utils/tests/test_validate_json_data.py
@@ -47,6 +47,20 @@ class TestValidateJsonData(BaseTestGenerator):
expected_error="'Username' attribute not found",
expected_servers=["1"]
)),
+ ('A non-shared server with an empty username is rejected',
+ dict(
+ servers={"1": server(Username="")},
+ is_admin=True,
+ expected_error="'Username' attribute not found",
+ expected_servers=["1"]
+ )),
+ ('A non-shared server with a null username is rejected',
+ dict(
+ servers={"1": server(Username=None)},
+ is_admin=True,
+ expected_error="'Username' attribute not found",
+ expected_servers=["1"]
+ )),
('A shared server with only a shared username is valid',
dict(
servers={"1": server(Shared=True, SharedUsername="postgres")},
diff --git a/web/regression/javascript/SchemaView/SchemaDialogView.spec.js b/web/regression/javascript/SchemaView/SchemaDialogView.spec.js
index 9c4c944138e..fbfad366241 100644
--- a/web/regression/javascript/SchemaView/SchemaDialogView.spec.js
+++ b/web/regression/javascript/SchemaView/SchemaDialogView.spec.js
@@ -172,6 +172,41 @@ describe('SchemaView', ()=>{
await user.type(ctrl.container.querySelectorAll('[name="field5"]')[1], 'rval51');
expect(ctrl.container.querySelector('[data-test="notifier-message"]')).toHaveTextContent('Field5 in FieldColl must be unique.');
});
+
+ it('does not virtualise a small grid, rendering rows in static flow', async ()=>{
+ await simulateValidData();
+
+ const dataRows = ctrl.container.querySelectorAll('[data-test="data-table-row"]');
+ expect(dataRows.length).toBe(2);
+
+ // Every row should be fully mounted and opted out of the
+ // virtualizer's absolute positioning, so a hidden dialog tab is a
+ // pure CSS toggle rather than something the virtualizer has to
+ // remeasure when the tab is shown again.
+ const pgrtRows = ctrl.container.querySelectorAll('.pgrt-row');
+ expect(pgrtRows.length).toBe(2);
+ pgrtRows.forEach((rowEl)=>{
+ expect(rowEl.classList.contains('pgrt-row--static')).toBe(true);
+ expect(rowEl.style.transform).toBe('');
+ });
+ });
+
+ it('virtualises a large grid, mounting only a window of rows', async ()=>{
+ const manyRows = Array.from({length: 150}, (_, i)=>(
+ {field3: i, field4: 'field4val', field5: `field5val${i}`}
+ ));
+
+ await ctrlMount({
+ getInitData: ()=>Promise.resolve({fieldcoll: manyRows}),
+ });
+
+ const pgrtRows = ctrl.container.querySelectorAll('.pgrt-row');
+ expect(pgrtRows.length).toBeGreaterThan(0);
+ expect(pgrtRows.length).toBeLessThan(manyRows.length);
+ pgrtRows.forEach((rowEl)=>{
+ expect(rowEl.classList.contains('pgrt-row--static')).toBe(false);
+ });
+ });
});
describe('SQL tab', ()=>{
diff --git a/web/regression/javascript/schema_ui_files/role.ui.spec.js b/web/regression/javascript/schema_ui_files/role.ui.spec.js
index 63bc47fda6d..7760ac71baf 100644
--- a/web/regression/javascript/schema_ui_files/role.ui.spec.js
+++ b/web/regression/javascript/schema_ui_files/role.ui.spec.js
@@ -45,5 +45,32 @@ describe('RoleSchema', ()=>{
it('properties', async ()=>{
await getPropertiesView(createSchemaObject(), getInitData);
});
+
+ describe('membersReadOnly', ()=>{
+ it('is read only for a plain user who is not an admin member', ()=>{
+ const schemaObj = createSchemaObject();
+ const state = {oid: 123, rolmembers: [{role: 'postgres', admin: false}]};
+ expect(schemaObj.membersReadOnly(state)).toBe(true);
+ });
+
+ it('is editable for a user with ADMIN OPTION on the role', ()=>{
+ const schemaObj = createSchemaObject();
+ const state = {oid: 123, rolmembers: [{role: 'postgres', admin: true}]};
+ expect(schemaObj.membersReadOnly(state)).toBe(false);
+ });
+
+ it('is editable regardless when the user is a superuser/can create roles', ()=>{
+ const schemaObj = new RoleSchema(
+ ()=>new MockSchema(),
+ ()=>new MockSchema(),
+ {
+ role: ()=>[],
+ nodeInfo: {server: {user: {name: 'postgres', id: 0, is_superuser: true}}}
+ },
+ );
+ const state = {oid: 123, rolmembers: []};
+ expect(schemaObj.membersReadOnly(state)).toBe(false);
+ });
+ });
});