1
0

refactor(js): migrate core list pages to list module factory

This commit is contained in:
2026-04-20 22:54:19 +02:00
parent 5219bbaff2
commit c163393cc4
10 changed files with 699 additions and 732 deletions

View File

@@ -37,6 +37,14 @@ else
ok "Frontend JS uses centralized app-http (except app-http/app-telemetry infrastructure)"
fi
list_module_hits="$(rg -n "initStandardListPage\\(" web/js/pages modules/*/web/js/pages -g '*.js' || true)"
if [[ -n "${list_module_hits}" ]]; then
fail "Legacy initStandardListPage(...) usage found in page modules (use createListPageModule):"
echo "${list_module_hits}" >&2
else
ok "List page modules use createListPageModule (no direct initStandardListPage calls)"
fi
runtime_files=(
"modules/helpdesk/web/js/helpdesk-detail.js"
"modules/helpdesk/web/js/helpdesk-domain-detail.js"

View File

@@ -20,10 +20,10 @@ class ListTemplateContractTest extends TestCase
$file . ' does not render toolbar directly and does not include shared list-filters partial.'
);
$this->assertTrue(
str_contains($moduleContent, 'initStandardListPage(')
|| str_contains($moduleContent, 'createListPageModule('),
$file . ' does not initialize list module via initStandardListPage/createListPageModule.'
str_contains($moduleContent, 'createListPageModule('),
$file . ' does not initialize list module via createListPageModule.'
);
$this->assertStringNotContainsString('initStandardListPage(', $moduleContent, $file);
$this->assertStringNotContainsString('gridFiltersFromSchema(', $content, $file);
$this->assertSame(0, preg_match('/<select id=\"[^\"]*-filter\"/', $content), $file);
$this->assertSame(0, preg_match('/<input[^>]*id=\"[^\"]*-filter\"/', $content), $file);

View File

@@ -1,17 +1,17 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, getAppBase } from './app-list-utils.js';
import { createListPageModule } from '../core/app-list-page-module.js';
import { badgeHtml } from './app-list-utils.js';
const config = readPageConfig('admin-departments-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
createListPageModule({
configId: 'admin-departments-index',
moduleId: 'admin-departments-index',
missingGridMessage: 'Departments grid init failed: Grid.js missing',
initErrorMessage: 'Departments grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const initDepartmentsGrid = () => {
const formatBadge = (value) => badgeHtml(gridjs, value);
const formatCount = (value) => {
const number = Number(value ?? 0);
@@ -28,76 +28,75 @@ if (config) {
const label = isInactive ? statusLabels.inactive : statusLabels.active;
return gridjs.html(`<span class="badge" data-variant="${variant}">${label}</span>`);
};
const gridOptions = {
gridjs: window.gridjs,
container: '#departments-grid',
dataUrl: 'admin/departments/data',
appBase,
columns: [
{ name: labels.description || 'Description', sort: true },
{
name: labels.status || 'Status',
sort: false,
formatter: (cell) => formatStatus(cell),
},
{ name: labels.code || 'Code', sort: true },
{ name: labels.costCenter || 'Cost center', sort: true },
{
name: labels.activeUsers || 'Active users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.inactiveUsers || 'Inactive users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.created || 'Created',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: labels.modified || 'Modified',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: 'UUID',
hidden: true,
},
],
sortColumns: ['description', null, 'code', 'cost_center', null, null, 'created', 'modified', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.description,
row.active,
row.code,
row.cost_center,
row.active_users ?? 0,
row.inactive_users ?? 0,
row.created,
row.modified,
row.uuid,
]),
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[8]?.data,
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[8]?.data;
return uuid ? new URL(`admin/departments/edit/${uuid}`, appBase).toString() : '';
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#departments-grid',
dataUrl: 'admin/departments/data',
appBase,
columns: [
{ name: labels.description || 'Description', sort: true },
{
name: labels.status || 'Status',
sort: false,
formatter: (cell) => formatStatus(cell),
},
{ name: labels.code || 'Code', sort: true },
{ name: labels.costCenter || 'Cost center', sort: true },
{
name: labels.activeUsers || 'Active users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.inactiveUsers || 'Inactive users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.created || 'Created',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: labels.modified || 'Modified',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: 'UUID',
hidden: true,
},
],
sortColumns: ['description', null, 'code', 'cost_center', null, null, 'created', 'modified', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.description,
row.active,
row.code,
row.cost_center,
row.active_users ?? 0,
row.inactive_users ?? 0,
row.created,
row.modified,
row.uuid,
]),
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[8]?.data,
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[8]?.data;
return uuid ? new URL(`admin/departments/edit/${uuid}`, appBase).toString() : '';
},
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
@@ -110,13 +109,8 @@ if (config) {
preserveFilterParams: ['tenant'],
},
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Departments grid init failed', { module: 'admin-departments-index', component: 'grid' });
return null;
}
return gridConfig;
};
}) || {};
initDepartmentsGrid();
}
return gridConfig;
},
});

View File

@@ -1,101 +1,94 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
import { createListPageModule } from '../core/app-list-page-module.js';
const config = readPageConfig('admin-mail-log-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
createListPageModule({
configId: 'admin-mail-log-index',
moduleId: 'admin-mail-log-index',
missingGridMessage: 'Mail log grid init failed: Grid.js missing',
initErrorMessage: 'Mail log grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const initMailLogGrid = () => {
const gridOptions = {
gridjs: window.gridjs,
container: '#mail-log-grid',
dataUrl: 'admin/mail-log/data',
appBase,
columns: [
{
name: labels.created || 'Created',
sort: true,
},
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('');
}
const variant = cell.variant || 'neutral';
const label = cell.label || '';
return gridjs.html(`<span class="badge" data-variant="${variant}">${label}</span>`);
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#mail-log-grid',
dataUrl: 'admin/mail-log/data',
appBase,
columns: [
{
name: labels.created || 'Created',
sort: true,
},
},
{
name: labels.recipient || 'Recipient',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('');
}
const variant = cell.variant || 'neutral';
const label = cell.label || '';
return gridjs.html(`<span class="badge" data-variant="${variant}">${label}</span>`);
},
},
{
name: labels.recipient || 'Recipient',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(String(cell ?? ''));
}
return gridjs.html(String(cell ?? ''));
}
return gridjs.html(String(cell ?? ''));
},
},
{
name: labels.subject || 'Subject',
sort: true,
},
{
name: labels.template || 'Template',
sort: false,
},
{
name: 'ID',
hidden: true,
},
],
sortColumns: ['created_at', 'status', 'to_email', 'subject', null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.created_at,
{
variant: row.status_badge,
label: row.status_label,
},
row.to_email,
row.subject,
row.template || '-',
row.id,
]),
rowInteraction: { linkColumn: 3 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[5]?.data;
return id ? new URL(`admin/mail-log/view/${id}`, appBase).toString() : '';
},
},
{
name: labels.subject || 'Subject',
sort: true,
},
{
name: labels.template || 'Template',
sort: false,
},
{
name: 'ID',
hidden: true,
},
],
sortColumns: ['created_at', 'status', 'to_email', 'subject', null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.created_at,
{
variant: row.status_badge,
label: row.status_label,
},
row.to_email,
row.subject,
row.template || '-',
row.id,
]),
rowInteraction: { linkColumn: 3 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[5]?.data;
return id ? new URL(`admin/mail-log/view/${id}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#mail-log-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Mail log grid init failed', { module: 'admin-mail-log-index', component: 'grid' });
return null;
}
return gridConfig;
};
}) || {};
initMailLogGrid();
}
return gridConfig;
},
});

View File

@@ -1,79 +1,72 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, getAppBase } from './app-list-utils.js';
import { createListPageModule } from '../core/app-list-page-module.js';
import { badgeHtml } from './app-list-utils.js';
const config = readPageConfig('admin-permissions-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
createListPageModule({
configId: 'admin-permissions-index',
moduleId: 'admin-permissions-index',
missingGridMessage: 'Permissions grid init failed: Grid.js missing',
initErrorMessage: 'Permissions grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const initPermissionsGrid = () => {
const gridOptions = {
gridjs: window.gridjs,
container: '#permissions-grid',
dataUrl: 'admin/permissions/data',
appBase,
columns: [
{ name: labels.description || 'Description', sort: true },
{ name: labels.permissionKey || 'Permission key', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => cell
? gridjs.html(`<span class="badge" data-variant="success">${labels.active || 'Active'}</span>`)
: gridjs.html(`<span class="badge" data-variant="danger">${labels.inactive || 'Inactive'}</span>`),
},
{
name: labels.system || 'System',
sort: false,
formatter: (cell) => cell
? gridjs.html(`<span class="badge" data-variant="neutral">${labels.system || 'System'}</span>`)
: gridjs.html(''),
},
{ name: labels.created || 'Created', sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
{ name: 'ID', hidden: true },
],
sortColumns: ['description', 'key', 'active', null, 'created', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.description,
row.key,
row.active,
row.is_system,
row.created,
row.id,
]),
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (row) => {
const id = row?.cells?.[5]?.data;
return id ? new URL(`admin/permissions/edit/${id}`, appBase).toString() : '';
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#permissions-grid',
dataUrl: 'admin/permissions/data',
appBase,
columns: [
{ name: labels.description || 'Description', sort: true },
{ name: labels.permissionKey || 'Permission key', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => cell
? gridjs.html(`<span class="badge" data-variant="success">${labels.active || 'Active'}</span>`)
: gridjs.html(`<span class="badge" data-variant="danger">${labels.inactive || 'Inactive'}</span>`),
},
{
name: labels.system || 'System',
sort: false,
formatter: (cell) => cell
? gridjs.html(`<span class="badge" data-variant="neutral">${labels.system || 'System'}</span>`)
: gridjs.html(''),
},
{ name: labels.created || 'Created', sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
{ name: 'ID', hidden: true },
],
sortColumns: ['description', 'key', 'active', null, 'created', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.description,
row.key,
row.active,
row.is_system,
row.created,
row.id,
]),
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (row) => {
const id = row?.cells?.[5]?.data;
return id ? new URL(`admin/permissions/edit/${id}`, appBase).toString() : '';
},
},
search: gridSearch,
filterSchema,
urlSync: true,
},
search: gridSearch,
filterSchema,
urlSync: true,
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#permission-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Permissions grid init failed', { module: 'admin-permissions-index', component: 'grid' });
return null;
}
return gridConfig;
};
}) || {};
initPermissionsGrid();
}
return gridConfig;
},
});

View File

@@ -1,127 +1,121 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, getAppBase } from './app-list-utils.js';
import { createListPageModule } from '../core/app-list-page-module.js';
import { badgeHtml } from './app-list-utils.js';
const config = readPageConfig('admin-roles-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
createListPageModule({
configId: 'admin-roles-index',
moduleId: 'admin-roles-index',
missingGridMessage: 'Roles grid init failed: Grid.js missing',
initErrorMessage: 'Roles grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const initRolesGrid = () => {
const formatBadge = (value) => badgeHtml(gridjs, value);
const formatCount = (value) => {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
};
const gridOptions = {
gridjs: window.gridjs,
container: '#roles-grid',
dataUrl: 'admin/roles/data',
appBase,
columns: [
{
name: labels.description || 'Description',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(String(cell ?? ''));
}
const label = cell.label ?? '';
return gridjs.html(`${label}`);
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#roles-grid',
dataUrl: 'admin/roles/data',
appBase,
columns: [
{
name: labels.description || 'Description',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(String(cell ?? ''));
}
const label = cell.label ?? '';
return gridjs.html(`${label}`);
},
},
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => cell
? gridjs.html(`<span class="badge" data-variant="success">${labels.active || 'Active'}</span>`)
: gridjs.html(`<span class="badge" data-variant="danger">${labels.inactive || 'Inactive'}</span>`),
},
{
name: labels.code || 'Code',
sort: true,
formatter: (cell) => gridjs.html(String(cell ?? '')),
},
{
name: labels.activeUsers || 'Active users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.inactiveUsers || 'Inactive users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.permissions || 'Permissions',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.created || 'Created',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: labels.modified || 'Modified',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: 'UUID',
hidden: true,
},
],
sortColumns: ['description', 'active', 'code', null, null, null, 'created', 'modified', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
{
label: row.description,
is_default: row.is_default ? 1 : 0,
},
row.active,
row.code,
row.active_users ?? 0,
row.inactive_users ?? 0,
row.permission_count ?? 0,
row.created,
row.modified,
row.uuid,
]),
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[8]?.data,
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[8]?.data;
return uuid ? new URL(`admin/roles/edit/${uuid}`, appBase).toString() : '';
},
},
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => cell
? gridjs.html(`<span class="badge" data-variant="success">${labels.active || 'Active'}</span>`)
: gridjs.html(`<span class="badge" data-variant="danger">${labels.inactive || 'Inactive'}</span>`),
},
{
name: labels.code || 'Code',
sort: true,
formatter: (cell) => gridjs.html(String(cell ?? '')),
},
{
name: labels.activeUsers || 'Active users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.inactiveUsers || 'Inactive users',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.permissions || 'Permissions',
sort: false,
formatter: (cell) => formatCount(cell),
},
{
name: labels.created || 'Created',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: labels.modified || 'Modified',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: 'UUID',
hidden: true,
},
],
sortColumns: ['description', 'active', 'code', null, null, null, 'created', 'modified', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
{
label: row.description,
is_default: row.is_default ? 1 : 0,
},
row.active,
row.code,
row.active_users ?? 0,
row.inactive_users ?? 0,
row.permission_count ?? 0,
row.created,
row.modified,
row.uuid,
]),
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[8]?.data,
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[8]?.data;
return uuid ? new URL(`admin/roles/edit/${uuid}`, appBase).toString() : '';
},
},
};
const { gridConfig: standardGridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#role-search'],
},
});
if (!standardGridConfig || !standardGridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Roles grid init failed', { module: 'admin-roles-index', component: 'grid' });
return null;
}
return standardGridConfig;
};
}) || {};
initRolesGrid();
}
return gridConfig;
},
});

View File

@@ -1,97 +1,94 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
import { createListPageModule } from '../core/app-list-page-module.js';
const config = readPageConfig('admin-scheduled-jobs-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
createListPageModule({
configId: 'admin-scheduled-jobs-index',
moduleId: 'admin-scheduled-jobs-index',
missingGridMessage: 'Scheduled jobs grid init failed: Grid.js missing',
initErrorMessage: 'Scheduled jobs grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const gridOptions = {
gridjs: window.gridjs,
container: '#scheduled-jobs-grid',
dataUrl: 'admin/scheduled-jobs/data',
appBase,
columns: [
{
name: labels.job || 'Job',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
const fallbackLabel = String(cell.job_key || '')
.toLowerCase()
.replace(/_/g, ' ')
.replace(/^./, (char) => char.toUpperCase());
return gridjs.html(cell.label || fallbackLabel || '-');
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#scheduled-jobs-grid',
dataUrl: 'admin/scheduled-jobs/data',
appBase,
columns: [
{
name: labels.job || 'Job',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
const fallbackLabel = String(cell.job_key || '')
.toLowerCase()
.replace(/_/g, ' ')
.replace(/^./, (char) => char.toUpperCase());
return gridjs.html(cell.label || fallbackLabel || '-');
},
},
{
name: labels.enabled || 'Enabled',
sort: true,
formatter: (cell) => {
const enabled = Number(cell) === 1;
const variant = enabled ? 'success' : 'neutral';
const label = enabled ? (labels.enabled || 'Enabled') : (labels.disabled || 'Disabled');
return gridjs.html(`<span class="badge" data-variant="${variant}">${label}</span>`);
},
},
{ name: labels.nextRun || 'Next run', sort: true },
{ name: labels.schedule || 'Schedule', sort: false },
{ name: labels.lastRun || 'Last run', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || '-'}</span>`);
},
},
{ name: labels.lastError || 'Last error', sort: false },
{ name: 'ID', hidden: true },
],
sortColumns: ['job_key', 'enabled', 'next_run_at', null, 'last_run_finished_at', 'last_run_status', null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
{ id: row.id, label: row.label || row.job_key, job_key: row.job_key },
row.enabled,
row.next_run_at || '-',
row.schedule_summary || '-',
row.last_run_finished_at || '-',
{ variant: row.last_run_status_badge, label: row.last_run_status_label || row.last_run_status || '' },
row.last_error || '-',
row.id,
]),
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[7]?.data;
return id ? new URL(`admin/scheduled-jobs/edit/${id}`, appBase).toString() : '';
},
},
},
{
name: labels.enabled || 'Enabled',
sort: true,
formatter: (cell) => {
const enabled = Number(cell) === 1;
const variant = enabled ? 'success' : 'neutral';
const label = enabled ? (labels.enabled || 'Enabled') : (labels.disabled || 'Disabled');
return gridjs.html(`<span class="badge" data-variant="${variant}">${label}</span>`);
},
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#scheduled-jobs-search'],
},
{ name: labels.nextRun || 'Next run', sort: true },
{ name: labels.schedule || 'Schedule', sort: false },
{ name: labels.lastRun || 'Last run', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || '-'}</span>`);
},
},
{ name: labels.lastError || 'Last error', sort: false },
{ name: 'ID', hidden: true },
],
sortColumns: ['job_key', 'enabled', 'next_run_at', null, 'last_run_finished_at', 'last_run_status', null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
{ id: row.id, label: row.label || row.job_key, job_key: row.job_key },
row.enabled,
row.next_run_at || '-',
row.schedule_summary || '-',
row.last_run_finished_at || '-',
{ variant: row.last_run_status_badge, label: row.last_run_status_label || row.last_run_status || '' },
row.last_error || '-',
row.id,
]),
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[7]?.data;
return id ? new URL(`admin/scheduled-jobs/edit/${id}`, appBase).toString() : '';
},
},
};
}) || {};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#scheduled-jobs-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Scheduled jobs grid init failed', { module: 'admin-scheduled-jobs-index', component: 'grid' });
}
}
return gridConfig;
},
});

View File

@@ -1,17 +1,17 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, escapeHtml, getAppBase } from './app-list-utils.js';
import { createListPageModule } from '../core/app-list-page-module.js';
import { badgeHtml, escapeHtml } from './app-list-utils.js';
const config = readPageConfig('admin-tenants-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
createListPageModule({
configId: 'admin-tenants-index',
moduleId: 'admin-tenants-index',
missingGridMessage: 'Tenants grid init failed: Grid.js missing',
initErrorMessage: 'Tenants grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const initTenantsGrid = () => {
const tenantUuidIndex = 8;
const tenantDescriptionIndex = 1;
const formatBadge = (value) => badgeHtml(gridjs, value);
@@ -55,117 +55,112 @@ if (config) {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
};
const gridOptions = {
gridjs: window.gridjs,
container: '#tenants-grid',
dataUrl: 'admin/tenants/data',
appBase,
columns: [
{
name: labels.avatar || 'Avatar',
sort: false,
formatter: (cell, row) => {
if (!cell) {
const initials = escapeHtml(initialsForRow(row));
return gridjs.html(`<span class="grid-avatar grid-avatar-tenant grid-avatar-placeholder">${initials}</span>`);
}
const uuid = encodeURIComponent(String(cell));
const src = new URL(`admin/tenants/avatar-file?uuid=${uuid}&size=64`, appBase).toString();
const full = new URL(`admin/tenants/avatar-file?uuid=${uuid}&size=256`, appBase).toString();
return gridjs.html(`<a data-fslightbox="tenant-avatars" href="${full}"><img class="grid-avatar grid-avatar-tenant" src="${src}" alt="" loading="lazy"></a>`);
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#tenants-grid',
dataUrl: 'admin/tenants/data',
appBase,
columns: [
{
name: labels.avatar || 'Avatar',
sort: false,
formatter: (cell, row) => {
if (!cell) {
const initials = escapeHtml(initialsForRow(row));
return gridjs.html(`<span class="grid-avatar grid-avatar-tenant grid-avatar-placeholder">${initials}</span>`);
}
const uuid = encodeURIComponent(String(cell));
const src = new URL(`admin/tenants/avatar-file?uuid=${uuid}&size=64`, appBase).toString();
const full = new URL(`admin/tenants/avatar-file?uuid=${uuid}&size=256`, appBase).toString();
return gridjs.html(`<a data-fslightbox="tenant-avatars" href="${full}"><img class="grid-avatar grid-avatar-tenant" src="${src}" alt="" loading="lazy"></a>`);
},
},
},
{
name: labels.description || 'Description',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(escapeHtml(String(cell ?? '')));
}
const label = escapeHtml(cell.label ?? '');
return gridjs.html(label);
{
name: labels.description || 'Description',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(escapeHtml(String(cell ?? '')));
}
const label = escapeHtml(cell.label ?? '');
return gridjs.html(label);
},
},
{
name: labels.status || 'Status',
sort: false,
formatter: (cell) => formatStatus(cell),
},
{
name: labels.theme || 'Theme',
sort: true,
formatter: (cell) => formatTheme(cell),
},
{
name: labels.sso || 'SSO',
sort: true,
formatter: (cell) => formatSso(cell),
},
{
name: labels.activeUsers || 'Active users',
sort: true,
formatter: (cell) => formatCount(cell),
},
{
name: labels.inactiveUsers || 'Inactive users',
sort: true,
formatter: (cell) => formatCount(cell),
},
{
name: labels.created || 'Created',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: 'UUID',
hidden: true,
},
],
sortColumns: [null, 'description', 'status', 'theme', 'sso', 'active_users', 'inactive_users', 'created', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.has_avatar ? row.uuid : '',
{
label: row.description,
is_default: row.is_default ? 1 : 0,
},
{ variant: row.status_badge || 'neutral', label: row.status_label || row.status || '-' },
row.theme ?? null,
row.sso ?? null,
row.active_users ?? 0,
row.inactive_users ?? 0,
row.created,
row.uuid,
]),
rowInteraction: { linkColumn: 1 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[tenantUuidIndex]?.data,
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[tenantUuidIndex]?.data;
return uuid ? new URL(`admin/tenants/edit/${uuid}`, appBase).toString() : '';
},
},
{
name: labels.status || 'Status',
sort: false,
formatter: (cell) => formatStatus(cell),
},
{
name: labels.theme || 'Theme',
sort: true,
formatter: (cell) => formatTheme(cell),
},
{
name: labels.sso || 'SSO',
sort: true,
formatter: (cell) => formatSso(cell),
},
{
name: labels.activeUsers || 'Active users',
sort: true,
formatter: (cell) => formatCount(cell),
},
{
name: labels.inactiveUsers || 'Inactive users',
sort: true,
formatter: (cell) => formatCount(cell),
},
{
name: labels.created || 'Created',
sort: true,
formatter: (cell) => formatBadge(cell),
},
{
name: 'UUID',
hidden: true,
},
],
sortColumns: [null, 'description', 'status', 'theme', 'sso', 'active_users', 'inactive_users', 'created', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.has_avatar ? row.uuid : '',
{
label: row.description,
is_default: row.is_default ? 1 : 0,
},
{ variant: row.status_badge || 'neutral', label: row.status_label || row.status || '-' },
row.theme ?? null,
row.sso ?? null,
row.active_users ?? 0,
row.inactive_users ?? 0,
row.created,
row.uuid,
]),
rowInteraction: { linkColumn: 1 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[tenantUuidIndex]?.data,
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[tenantUuidIndex]?.data;
return uuid ? new URL(`admin/tenants/edit/${uuid}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#tenant-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Tenants grid init failed', { module: 'admin-tenants-index', component: 'grid' });
return null;
}
return gridConfig;
};
}) || {};
initTenantsGrid();
}
return gridConfig;
},
});

View File

@@ -1,27 +1,27 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { createListPageModule } from '../core/app-list-page-module.js';
import { initUsersListPage } from './app-users-list.js';
import { bindBulkVisibility } from '../components/app-bulk-selection.js';
import { buildUrl, badgeHtml, getAppBase, escapeHtml, buildBadgeList } from './app-list-utils.js';
import { buildUrl, badgeHtml, escapeHtml, buildBadgeList } from './app-list-utils.js';
import { showAsyncFlash, withLoading } from '../components/app-async-flash.js';
const config = readPageConfig('admin-users-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const usersFilterChipMeta = config.filterChipMeta ?? [];
const currentUserUuid = String(config.currentUserUuid || '');
const canUpdateUsers = config.canUpdateUsers === true;
const canUpdateSelf = config.canUpdateSelf === true;
const csrf = config.csrf ?? null;
const labels = config.labels ?? {};
createListPageModule({
configId: 'admin-users-index',
moduleId: 'admin-users-index',
missingGridMessage: 'Users grid init failed: Grid.js missing',
initErrorMessage: 'Users grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const usersFilterChipMeta = config.filterChipMeta ?? [];
const currentUserUuid = String(config.currentUserUuid || '');
const canUpdateUsers = config.canUpdateUsers === true;
const canUpdateSelf = config.canUpdateSelf === true;
const csrf = config.csrf ?? null;
const labels = config.labels ?? {};
const buildBulkUrl = (action) => buildUrl(appBase, `admin/users/bulk/${action}`);
const buildBulkUrl = (action) => buildUrl(appBase, `admin/users/bulk/${action}`);
const initUsersGrid = () => {
const rowSelection = window.gridjs?.plugins?.selection?.RowSelection;
const rowSelection = gridjs?.plugins?.selection?.RowSelection;
if (!rowSelection) {
throw new Error('Grid.js RowSelection plugin is not available.');
}
@@ -29,7 +29,7 @@ if (config) {
const uuidIndex = 2;
const canEditRow = (row) => {
const uuid = row?.cells?.[uuidIndex]?.data;
if (!uuid) {return false;}
if (!uuid) { return false; }
if (uuid === currentUserUuid) {
return canUpdateUsers || canUpdateSelf;
}
@@ -91,68 +91,66 @@ if (config) {
{ name: 'ID', hidden: true },
];
const gridOptions = {
gridjs: window.gridjs,
container: '#users-grid',
dataUrl: 'admin/users/data',
appBase,
columns,
sortColumns: [null, null, 'first_name', 'last_name', 'email', 'active', null, null, null, null, null, null, 'created', 'modified', 'last_login_at', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.has_avatar ? row.uuid : '',
row.uuid,
row.first_name,
row.last_name,
row.email,
row.active,
row.tenants,
row.departments,
row.roles,
row.phone,
row.mobile,
row.short_dial,
row.created,
row.modified,
row.last_login,
row.id,
]),
selection: {
enabled: true,
id: 'selectRow',
component: rowSelection,
selectAllLabel: labels.selectAll || 'Select all',
props: {
id: (row) => row.cell(uuidIndex).data,
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#users-grid',
dataUrl: 'admin/users/data',
appBase,
columns,
sortColumns: [null, null, 'first_name', 'last_name', 'email', 'active', null, null, null, null, null, null, 'created', 'modified', 'last_login_at', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.has_avatar ? row.uuid : '',
row.uuid,
row.first_name,
row.last_name,
row.email,
row.active,
row.tenants,
row.departments,
row.roles,
row.phone,
row.mobile,
row.short_dial,
row.created,
row.modified,
row.last_login,
row.id,
]),
selection: {
enabled: true,
id: 'selectRow',
component: rowSelection,
selectAllLabel: labels.selectAll || 'Select all',
props: {
id: (row) => row.cell(uuidIndex).data,
},
getSelectedIds: ({ container }) => {
if (!container) { return []; }
return Array.from(container.querySelectorAll('.gridjs-tr-selected'))
.map((row) => row.getAttribute('data-uuid'))
.filter(Boolean);
},
},
getSelectedIds: ({ container }) => {
if (!container) {return [];}
return Array.from(container.querySelectorAll('.gridjs-tr-selected'))
.map((row) => row.getAttribute('data-uuid'))
.filter(Boolean);
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
rowInteraction: { linkColumn: 2 },
rowDblClick: {
getUrl: (rowData) => {
if (!canEditRow(rowData)) {
return '';
}
const uuid = rowData?.cells?.[uuidIndex]?.data;
return uuid ? new URL(`admin/users/edit/${uuid}`, appBase).toString() : '';
},
},
},
search: gridSearch,
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
rowInteraction: { linkColumn: 2 },
rowDblClick: {
getUrl: (rowData) => {
if (!canEditRow(rowData)) {
return '';
}
const uuid = rowData?.cells?.[uuidIndex]?.data;
return uuid ? new URL(`admin/users/edit/${uuid}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: usersFilterChipMeta,
@@ -165,37 +163,32 @@ if (config) {
preserveFilterParams: ['tenant'],
},
},
}) || {};
const usersGridConfig = gridConfig ? { ...gridConfig, indices: { activeIndex, uuidIndex } } : null;
initUsersListPage({
gridConfig: usersGridConfig,
appBase,
csrf,
labels,
buildBulkUrl,
bulkDownloadForms: {
'access-pdf': '#users-access-pdf-bulk-form',
},
resetOptions: {
resetPage: true,
urlHistoryMode: 'push',
preserveFilterParams: ['tenant'],
},
showFlash: showAsyncFlash,
withLoading,
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Users grid init failed', { module: 'admin-users-index', component: 'grid' });
return null;
}
bindBulkVisibility({
containerSelector: '#users-grid',
buttonSelector: '[data-users-bulk]',
});
return { ...gridConfig, indices: { activeIndex, uuidIndex } };
};
const gridConfig = initUsersGrid();
initUsersListPage({
gridConfig,
appBase,
csrf,
labels,
buildBulkUrl,
bulkDownloadForms: {
'access-pdf': '#users-access-pdf-bulk-form',
},
resetOptions: {
resetPage: true,
urlHistoryMode: 'push',
preserveFilterParams: ['tenant'],
},
showFlash: showAsyncFlash,
withLoading,
});
bindBulkVisibility({
containerSelector: '#users-grid',
buttonSelector: '[data-users-bulk]',
});
}
return usersGridConfig;
},
});

View File

@@ -1,26 +1,27 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase, escapeHtml } from './app-list-utils.js';
import { createListPageModule } from '../core/app-list-page-module.js';
import { escapeHtml } from './app-list-utils.js';
const config = readPageConfig('search-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const labels = config.labels ?? {};
createListPageModule({
configId: 'search-index',
moduleId: 'search-index',
missingGridMessage: 'Search results grid init failed: Grid.js missing',
initErrorMessage: 'Search results grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const labels = config.labels ?? {};
const renderResult = (cell) => {
const title = escapeHtml(cell?.title ?? '');
const description = escapeHtml(cell?.description ?? '');
const url = cell?.url ?? '#';
const image = cell?.image ?? '';
const icon = escapeHtml(cell?.icon ?? 'bi-search');
const avatar = image
? `<img class="search-result-avatar" src="${image}" alt="" loading="lazy">`
: `<span class="search-result-avatar-placeholder"><i class="bi ${icon}"></i></span>`;
const renderResult = (cell) => {
const title = escapeHtml(cell?.title ?? '');
const description = escapeHtml(cell?.description ?? '');
const url = cell?.url ?? '#';
const image = cell?.image ?? '';
const icon = escapeHtml(cell?.icon ?? 'bi-search');
const avatar = image
? `<img class="search-result-avatar" src="${image}" alt="" loading="lazy">`
: `<span class="search-result-avatar-placeholder"><i class="bi ${icon}"></i></span>`;
return gridjs.html(`
return gridjs.html(`
<div class="search-result-row">
${avatar}
<div class="search-result-content">
@@ -29,42 +30,41 @@ if (config) {
</div>
</div>
`);
};
};
const renderType = (cell) => gridjs.html(`<span class="search-result-type">${escapeHtml(cell ?? '')}</span>`);
const renderType = (cell) => gridjs.html(`<span class="search-result-type">${escapeHtml(cell ?? '')}</span>`);
const { gridConfig } = initStandardListPage({
grid: {
gridjs: window.gridjs,
container: '#search-results-grid',
dataUrl: 'search/data',
appBase,
columns: [
{
name: labels.result || 'Result',
sort: false,
formatter: (cell) => renderResult(cell),
},
{
name: labels.type || 'Type',
sort: false,
formatter: (cell) => renderType(cell),
},
],
sortColumns: [null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [row, row.type]),
search: gridSearch,
filterSchema,
urlSync: true,
},
filters: {
mode: 'search-only',
},
});
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#search-results-grid',
dataUrl: 'search/data',
appBase,
columns: [
{
name: labels.result || 'Result',
sort: false,
formatter: (cell) => renderResult(cell),
},
{
name: labels.type || 'Type',
sort: false,
formatter: (cell) => renderType(cell),
},
],
sortColumns: [null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [row, row.type]),
search: gridSearch,
filterSchema,
urlSync: true,
},
filters: {
mode: 'search-only',
},
}) || {};
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Search results grid init failed', { module: 'search-index', component: 'grid' });
}
}
return gridConfig;
},
});