Skip to content
1 change: 1 addition & 0 deletions administrator/language/en-GB/joomla.ini
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ JGLOBAL_CHOOSE_COMPONENT_LABEL="Choose a component"
JGLOBAL_CLICK_TO_SORT_THIS_COLUMN="Select to sort by this column"
JGLOBAL_CLICK_TO_TOGGLE_STATE="Select icon to toggle state."
JGLOBAL_COLUMNS="Columns"
JGLOBAL_COLUMNS_SAVE_HIDDEN="Save hidden columns"
JGLOBAL_CONFIRM_DELETE="Are you sure you want to delete? Confirming will permanently delete the selected item(s)!"
JGLOBAL_COPY="(copy)"
JGLOBAL_CREATED="Created"
Expand Down
106 changes: 94 additions & 12 deletions build/media_source/system/js/table-columns.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class TableColumns {
this.tableName = tableName;
this.storageKey = `joomla-tablecolumns-${this.tableName}`;

this.$headers = [].slice.call($table.querySelector('thead tr').children);
this.$headers = Array.from($table.querySelector('thead tr').children);
this.$rows = $table.querySelectorAll('tbody tr');
this.listOfHidden = [];

Expand All @@ -17,7 +17,7 @@ class TableColumns {
// Find protected columns
this.protectedCols = [0];
if (this.$rows[0]) {
[].slice.call(this.$rows[0].children).forEach(($el, index) => {
Array.from(this.$rows[0].children).forEach(($el, index) => {
if ($el.nodeName === 'TH') {
this.protectedCols.push(index);

Expand All @@ -39,6 +39,16 @@ class TableColumns {
});
}

/**
* Parse a comma-separated string of column indices into an array of integers.
*
* @param {String} str
* @returns {Number[]}
*/
parseIndices(str) {
return str.split(',').map((val) => parseInt(val, 10)).filter((val) => !isNaN(val));
}

/**
* Create a controls to select visible columns
*/
Expand Down Expand Up @@ -75,8 +85,8 @@ class TableColumns {
$input.classList.add('form-check-input', 'me-1');
$input.type = 'checkbox';
$input.name = 'table[column][]';
$input.checked = this.listOfHidden.indexOf(index) === -1;
$input.disabled = this.protectedCols.indexOf(index) !== -1;
$input.checked = !this.listOfHidden.includes(index);
$input.disabled = this.protectedCols.includes(index);
$input.value = index;

// Find the header name
Expand All @@ -98,6 +108,24 @@ class TableColumns {
$ul.appendChild($li);
});

// Add "Save hidden columns" button at the bottom of the dropdown (admin only)
if (Joomla.getOptions('table.columns.sync', false)) {
const $saveLi = document.createElement('li');
$saveLi.classList.add('pt-2', 'mt-1', 'border-top');

const $saveButton = document.createElement('button');
$saveButton.type = 'button';
$saveButton.textContent = Joomla.Text._('JGLOBAL_COLUMNS_SAVE_HIDDEN');
$saveButton.classList.add('btn', 'btn-secondary', 'btn-sm', 'w-100');
$saveButton.addEventListener('click', () => {
this.syncToServer();
bootstrap.Dropdown.getInstance($button)?.hide();
});

$saveLi.appendChild($saveButton);
$ul.appendChild($saveLi);
}

this.$table.insertAdjacentElement('beforebegin', $divouter);
$divouter.appendChild($button);
$divouter.appendChild($divinner);
Expand All @@ -114,7 +142,7 @@ class TableColumns {
$el.classList.remove('d-none', 'd-xs-table-cell', 'd-sm-table-cell', 'd-md-table-cell', 'd-lg-table-cell', 'd-xl-table-cell', 'd-xxl-table-cell');
});
this.$rows.forEach(($row) => {
[].slice.call($row.children).forEach(($el) => {
Array.from($row.children).forEach(($el) => {
$el.classList.remove('d-none', 'd-xs-table-cell', 'd-sm-table-cell', 'd-md-table-cell', 'd-lg-table-cell', 'd-xl-table-cell', 'd-xxl-table-cell');
});
});
Expand Down Expand Up @@ -146,7 +174,7 @@ class TableColumns {
if (!this.$headers[index]) return;

// Skip the protected columns
if (this.protectedCols.indexOf(index) !== -1) return;
if (this.protectedCols.includes(index)) return;

const i = this.listOfHidden.indexOf(index);

Expand All @@ -166,28 +194,82 @@ class TableColumns {
}

/**
* Save state, list of hidden columns
* Save state to localStorage and mark as dirty (unsaved changes pending).
* The dirty flag stores the current session token so it becomes stale on login.
*/
saveState() {
window.localStorage.setItem(this.storageKey, this.listOfHidden.join(','));
const value = this.listOfHidden.join(',');
window.localStorage.setItem(this.storageKey, value);
window.localStorage.setItem(`${this.storageKey}-dirty`, Joomla.getOptions('csrf.token', '1'));
}

/**
* Load state, list of hidden columns
* Sync current hidden columns to the server (fire-and-forget).
* Only called explicitly via the "Save hidden columns" button.
* Clears the dirty flag so other browsers pick up the new server state.
*/
syncToServer() {
const token = Joomla.getOptions('csrf.token', '');
if (!token) return;

const value = this.listOfHidden.join(',');
const body = new URLSearchParams({
[token]: '1',
tableName: this.tableName,
hidden: value,
});
fetch(
'index.php?option=com_ajax&plugin=usercolumns&group=system&format=json',
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }
).then(() => {
window.localStorage.setItem(this.storageKey, value);
window.localStorage.removeItem(`${this.storageKey}-dirty`);
}).catch(() => {});
}

/**
* Load state, list of hidden columns.
* If there are unsaved local changes (dirty flag set), localStorage wins.
* Otherwise server state is authoritative so that saves from other browsers
* are picked up correctly.
*/
loadState() {
const stored = window.localStorage.getItem(this.storageKey);
const serverState = Joomla.getOptions('table.columns.state', {});
const currentToken = Joomla.getOptions('csrf.token', '');
const dirtyToken = window.localStorage.getItem(`${this.storageKey}-dirty`);
const dirty = dirtyToken !== null && dirtyToken === currentToken;

// Use localStorage only when the user has unsaved local changes
if (dirty) {
const stored = window.localStorage.getItem(this.storageKey);
if (stored !== null) {
this.listOfHidden = this.parseIndices(stored);
return;
}
}

// Server state is authoritative (explicitly saved, possibly from another browser)
if (serverState[this.tableName] !== undefined) {
const value = serverState[this.tableName];
this.listOfHidden = this.parseIndices(value);
window.localStorage.setItem(this.storageKey, value);
window.localStorage.removeItem(`${this.storageKey}-dirty`);
return;
}

// Fall back to localStorage (guests / no server state yet)
const stored = window.localStorage.getItem(this.storageKey);
if (stored) {
this.listOfHidden = stored.split(',').map((val) => parseInt(val, 10));
this.listOfHidden = this.parseIndices(stored);
}
}
}

if (window.innerWidth > 992) {
// Look for dataset name else page-title
[...document.querySelectorAll('table:not(.columns-order-ignore)')].forEach(($table) => {
const tableName = ($table.dataset.name ? $table.dataset.name : document.querySelector('.page-title')?.textContent.trim()
const tableName = ($table.dataset.name ? $table.dataset.name : document.querySelector('.page-title')
.textContent.trim()
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase()
);
Expand Down
1 change: 1 addition & 0 deletions libraries/src/WebAsset/AssetItem/TableColumnsAssetItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ public function onAttachCallback(Document $doc)
{
// Add table-columns.js language strings
Text::script('JGLOBAL_COLUMNS');
Text::script('JGLOBAL_COLUMNS_SAVE_HIDDEN');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
; Joomla! Project
; (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
; Joomla! Project
; (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8

PLG_SYSTEM_USERCOLUMNS="System - User Column Preferences"
PLG_SYSTEM_USERCOLUMNS_XML_DESCRIPTION="Persists admin list column visibility per user account across browsers and sessions."
46 changes: 46 additions & 0 deletions plugins/system/usercolumns/services/provider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/**
* @package Joomla.Plugin
* @subpackage System.usercolumns
*
* @copyright (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/

\defined('_JEXEC') or die;

use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Plugin\System\UserColumns\Extension\UserColumns;

return new class () implements ServiceProviderInterface {
/**
* Registers the service provider with a DI container.
*
* @param Container $container The DI container.
*
* @return void
*
* @since __DEPLOY_VERSION__
*/
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new UserColumns(
(array) PluginHelper::getPlugin('system', 'usercolumns')
);
$plugin->setApplication(Factory::getApplication());
$plugin->setDatabase($container->get(DatabaseInterface::class));

return $plugin;
}
);
}
};
Loading