diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 04a38ac..2cca677 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -34,6 +34,7 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) { $routes->post('delete/(:num)', 'Dashboard\DashboardController::delete/$1'); $routes->post('pin/(:num)', 'Dashboard\DashboardController::pinToggle/$1'); $routes->post('(:num)/layout', 'Dashboard\DashboardController::saveLayout/$1'); + $routes->get('(:num)/variables', 'Dashboard\DashboardController::variables/$1'); $routes->post('(:num)/widget', 'Dashboard\DashboardController::addWidget/$1'); $routes->post('widget/(:num)/update', 'Dashboard\DashboardController::updateWidget/$1'); $routes->post('widget/(:num)/delete', 'Dashboard\DashboardController::removeWidget/$1'); @@ -153,6 +154,8 @@ $routes->get('invite/(:segment)', 'Workspace\WorkspaceInvitationController::acce $routes->get('share/(:segment)', 'Share\PublicController::show/$1'); $routes->post('share/(:segment)/unlock', 'Share\PublicController::unlock/$1'); $routes->post('share/(:segment)/chart/(:num)/data', 'Share\PublicController::chartData/$1/$2'); +$routes->get('share/(:segment)/dashboard/variables', 'Share\PublicController::dashboardVariables/$1'); +$routes->get('share/(:segment)/saved-query/(:num)/variables', 'Share\PublicController::savedQueryVariables/$1/$2'); // API v1 routes $routes->group('api/v1', ['filter' => 'apiauth'], static function ($routes) { diff --git a/app/Controllers/Dashboard/DashboardController.php b/app/Controllers/Dashboard/DashboardController.php index 21cfdf3..a0ba279 100644 --- a/app/Controllers/Dashboard/DashboardController.php +++ b/app/Controllers/Dashboard/DashboardController.php @@ -7,6 +7,7 @@ use App\Libraries\AuditLogger; use App\Models\ChartModel; use App\Models\DashboardModel; use App\Models\DashboardWidgetModel; +use App\Models\QueryVariableModel; class DashboardController extends BaseController { @@ -104,6 +105,7 @@ class DashboardController extends BaseController 'chartData' => rtrim(base_url(), '/') . '/chart/', 'chartEdit' => rtrim(base_url(), '/') . '/chart/edit/', 'chartExportBase' => rtrim(base_url(), '/') . '/chart/', + 'dashboardVariables' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/variables', 'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout', 'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget', 'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/', @@ -166,6 +168,94 @@ class DashboardController extends BaseController ]); } + /** + * Return merged query variable definitions used by dashboard chart widgets. + */ + public function variables(int $id) + { + $workspaceId = (int) $this->session->get('active_workspace_id'); + $dashboard = (new DashboardModel())->findForWorkspace($id, $workspaceId); + if (! $dashboard) { + return $this->response->setJSON(['success' => false, 'message' => 'Dashboard not found.'])->setStatusCode(404); + } + + $widgets = (new DashboardWidgetModel())->forDashboard($id); + $queryIds = []; + foreach ($widgets as $w) { + if (($w['widget_type'] ?? '') !== 'chart') { + continue; + } + $qid = (int) ($w['saved_query_id'] ?? 0); + if ($qid > 0) { + $queryIds[$qid] = true; + } + } + $queryIds = array_keys($queryIds); + if ($queryIds === []) { + return $this->response->setJSON(['success' => true, 'variables' => []]); + } + + $rows = (new QueryVariableModel()) + ->whereIn('saved_query_id', $queryIds) + ->where('workspace_id', $workspaceId) + ->orderBy('saved_query_id', 'ASC') + ->orderBy('sort_order', 'ASC') + ->findAll(); + + $merged = []; + foreach ($rows as $row) { + $name = (string) ($row['name'] ?? ''); + if ($name === '') { + continue; + } + $options = []; + $optsRaw = $row['options_json'] ?? null; + if ($optsRaw !== null && $optsRaw !== '') { + $decoded = json_decode((string) $optsRaw, true); + if (is_array($decoded)) { + $options = $decoded; + } + } + + if (! isset($merged[$name])) { + $merged[$name] = [ + 'name' => $name, + 'label' => (string) ($row['label'] ?? $name), + 'type' => (string) ($row['type'] ?? 'text'), + 'default_value' => (string) ($row['default_value'] ?? ''), + 'is_required' => ! empty($row['is_required']), + 'options' => $options, + ]; + continue; + } + + $merged[$name]['is_required'] = $merged[$name]['is_required'] || ! empty($row['is_required']); + if ($merged[$name]['default_value'] === '' && (string) ($row['default_value'] ?? '') !== '') { + $merged[$name]['default_value'] = (string) $row['default_value']; + } + if (($merged[$name]['label'] ?? '') === '' && (string) ($row['label'] ?? '') !== '') { + $merged[$name]['label'] = (string) $row['label']; + } + if (($merged[$name]['type'] ?? 'text') === 'text' && (string) ($row['type'] ?? 'text') !== 'text') { + $merged[$name]['type'] = (string) $row['type']; + } + if (is_array($options) && $options !== []) { + $existing = is_array($merged[$name]['options']) ? $merged[$name]['options'] : []; + foreach ($options as $opt) { + if (! in_array($opt, $existing, true)) { + $existing[] = $opt; + } + } + $merged[$name]['options'] = $existing; + } + } + + return $this->response->setJSON([ + 'success' => true, + 'variables' => array_values($merged), + ]); + } + public function updateSettings(int $id) { $workspaceId = (int) $this->session->get('active_workspace_id'); diff --git a/app/Controllers/Share/PublicController.php b/app/Controllers/Share/PublicController.php index 5686b7a..d00a69c 100644 --- a/app/Controllers/Share/PublicController.php +++ b/app/Controllers/Share/PublicController.php @@ -9,6 +9,7 @@ use App\Models\ChartModel; use App\Models\DashboardModel; use App\Models\DashboardWidgetModel; use App\Models\DataSourceModel; +use App\Models\QueryVariableModel; use App\Models\SavedQueryModel; use App\Models\SharedLinkModel; use CodeIgniter\HTTP\ResponseInterface; @@ -180,6 +181,163 @@ class PublicController extends BaseController } } + public function dashboardVariables(string $token) + { + $this->applyEmbedHeaders(); + $model = new SharedLinkModel(); + $link = $model->findActiveByToken($token); + if (! $link || ($link['type'] ?? '') !== 'dashboard') { + return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403); + } + + $hash = $link['password_hash'] ?? null; + if ($hash && ! session()->get('share_unlocked_' . $token)) { + return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403); + } + + $dashId = (int) ($link['resource_id'] ?? 0); + $workspaceId = (int) ($link['workspace_id'] ?? 0); + if ($dashId <= 0 || $workspaceId <= 0) { + return $this->response->setJSON(['success' => false, 'message' => 'Invalid dashboard.'])->setStatusCode(422); + } + + $widgets = (new DashboardWidgetModel())->forDashboard($dashId); + $queryIds = []; + foreach ($widgets as $w) { + if (($w['widget_type'] ?? '') !== 'chart') { + continue; + } + $qid = (int) ($w['saved_query_id'] ?? 0); + if ($qid > 0) { + $queryIds[$qid] = true; + } + } + $queryIds = array_keys($queryIds); + if ($queryIds === []) { + return $this->response->setJSON(['success' => true, 'variables' => []]); + } + + $rows = (new QueryVariableModel()) + ->whereIn('saved_query_id', $queryIds) + ->where('workspace_id', $workspaceId) + ->orderBy('saved_query_id', 'ASC') + ->orderBy('sort_order', 'ASC') + ->findAll(); + + $merged = []; + foreach ($rows as $row) { + $name = (string) ($row['name'] ?? ''); + if ($name === '') { + continue; + } + $options = []; + $optsRaw = $row['options_json'] ?? null; + if ($optsRaw !== null && $optsRaw !== '') { + $decoded = json_decode((string) $optsRaw, true); + if (is_array($decoded)) { + $options = $decoded; + } + } + if (! isset($merged[$name])) { + $merged[$name] = [ + 'name' => $name, + 'label' => (string) ($row['label'] ?? $name), + 'type' => (string) ($row['type'] ?? 'text'), + 'default_value' => (string) ($row['default_value'] ?? ''), + 'is_required' => ! empty($row['is_required']), + 'options' => $options, + ]; + continue; + } + $merged[$name]['is_required'] = $merged[$name]['is_required'] || ! empty($row['is_required']); + if ($merged[$name]['default_value'] === '' && (string) ($row['default_value'] ?? '') !== '') { + $merged[$name]['default_value'] = (string) $row['default_value']; + } + if (($merged[$name]['label'] ?? '') === '' && (string) ($row['label'] ?? '') !== '') { + $merged[$name]['label'] = (string) $row['label']; + } + if (($merged[$name]['type'] ?? 'text') === 'text' && (string) ($row['type'] ?? 'text') !== 'text') { + $merged[$name]['type'] = (string) $row['type']; + } + if (is_array($options) && $options !== []) { + $existing = is_array($merged[$name]['options']) ? $merged[$name]['options'] : []; + foreach ($options as $opt) { + if (! in_array($opt, $existing, true)) { + $existing[] = $opt; + } + } + $merged[$name]['options'] = $existing; + } + } + + return $this->response->setJSON([ + 'success' => true, + 'variables' => array_values($merged), + ]); + } + + public function savedQueryVariables(string $token, int $savedQueryId) + { + $this->applyEmbedHeaders(); + $model = new SharedLinkModel(); + $link = $model->findActiveByToken($token); + if (! $link || ($link['type'] ?? '') !== 'dashboard') { + return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403); + } + + $hash = $link['password_hash'] ?? null; + if ($hash && ! session()->get('share_unlocked_' . $token)) { + return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403); + } + + $dashId = (int) ($link['resource_id'] ?? 0); + $workspaceId = (int) ($link['workspace_id'] ?? 0); + if ($dashId <= 0 || $workspaceId <= 0 || $savedQueryId <= 0) { + return $this->response->setJSON(['success' => false, 'message' => 'Invalid request.'])->setStatusCode(422); + } + + $widgets = (new DashboardWidgetModel())->forDashboard($dashId); + $allowed = false; + foreach ($widgets as $w) { + if (($w['widget_type'] ?? '') !== 'chart') { + continue; + } + if ((int) ($w['saved_query_id'] ?? 0) === $savedQueryId) { + $allowed = true; + break; + } + } + if (! $allowed) { + return $this->response->setJSON(['success' => false, 'message' => 'Query not in shared dashboard.'])->setStatusCode(403); + } + + $rows = (new QueryVariableModel()) + ->where('saved_query_id', $savedQueryId) + ->where('workspace_id', $workspaceId) + ->orderBy('sort_order', 'ASC') + ->findAll(); + + $vars = []; + foreach ($rows as $row) { + $optsRaw = $row['options_json'] ?? null; + $options = []; + if ($optsRaw !== null && $optsRaw !== '') { + $decoded = json_decode((string) $optsRaw, true); + $options = is_array($decoded) ? $decoded : []; + } + $vars[] = [ + 'name' => (string) ($row['name'] ?? ''), + 'label' => (string) ($row['label'] ?? ''), + 'type' => (string) ($row['type'] ?? 'text'), + 'default_value' => (string) ($row['default_value'] ?? ''), + 'is_required' => ! empty($row['is_required']), + 'options' => $options, + ]; + } + + return $this->response->setJSON(['success' => true, 'variables' => $vars]); + } + /** * @param array $link * @return string @@ -234,10 +392,11 @@ class PublicController extends BaseController 'charts' => [], 'urls' => [ 'chartData' => rtrim(base_url(), '/') . '/share/' . $token . '/chart/', + 'dashboardVariables' => rtrim(base_url(), '/') . '/share/' . $token . '/dashboard/variables', 'saveLayout' => '', 'addWidget' => '', 'delWidget' => '', - 'savedQueryVars' => '', + 'savedQueryVars' => rtrim(base_url(), '/') . '/share/' . $token . '/saved-query/', ], 'csrf' => [ 'name' => '', diff --git a/app/Views/chart/builder_scripts.php b/app/Views/chart/builder_scripts.php index 8ab71e2..e099b19 100644 --- a/app/Views/chart/builder_scripts.php +++ b/app/Views/chart/builder_scripts.php @@ -145,7 +145,7 @@ return rows.filter((r) => cols.some((c) => lower(r && r[c] != null ? r[c] : '').includes(q))); }; - const render = () => { + const render = (focusState) => { const filtered = buildFiltered(); const total = filtered.length; const totalPages = Math.max(1, Math.ceil(total / state.perPage)); @@ -199,9 +199,11 @@ const searchEl = el.querySelector('[data-cb-table-search]'); if (searchEl) { searchEl.addEventListener('input', (ev) => { + const selStart = Number.isInteger(ev.target.selectionStart) ? ev.target.selectionStart : null; + const selEnd = Number.isInteger(ev.target.selectionEnd) ? ev.target.selectionEnd : selStart; state.q = ev.target.value || ''; state.page = 1; - render(); + render({ focusSearch: true, selStart, selEnd }); }); } const perPageEl = el.querySelector('[data-cb-table-per-page]'); @@ -222,6 +224,18 @@ if (csvBtn) csvBtn.addEventListener('click', () => exportRowsAsCsv('table-preview.csv', cols, filtered)); const excelBtn = el.querySelector('[data-cb-table-export="excel"]'); if (excelBtn) excelBtn.addEventListener('click', () => exportRowsAsCsv('table-preview-excel.csv', cols, filtered)); + if (focusState && focusState.focusSearch) { + const nextSearch = el.querySelector('[data-cb-table-search]'); + if (nextSearch) { + nextSearch.focus(); + if (focusState.selStart != null && focusState.selEnd != null && typeof nextSearch.setSelectionRange === 'function') { + const len = String(nextSearch.value || '').length; + const s = Math.max(0, Math.min(len, focusState.selStart)); + const e = Math.max(s, Math.min(len, focusState.selEnd)); + nextSearch.setSelectionRange(s, e); + } + } + } }; render(); diff --git a/app/Views/dashboard/_view_scripts.php b/app/Views/dashboard/_view_scripts.php index 08bd231..cc2c000 100644 --- a/app/Views/dashboard/_view_scripts.php +++ b/app/Views/dashboard/_view_scripts.php @@ -9,6 +9,7 @@ let CB_CSRF = boot.csrf || { name: '', hash: '' }; const globalVars = {}; + let dashVarDefs = []; let grid = null; let editing = false; let refreshTimer = null; @@ -23,11 +24,75 @@ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } + function prettyHeaderName(raw) { + const t = String(raw ?? '').trim(); + if (!t) return ''; + return t + .replace(/[_\-]+/g, ' ') + .replace(/\s+/g, ' ') + .replace(/\b\w/g, (m) => m.toUpperCase()); + } + + function exportRowsAsCsv(filename, cols, rows) { + const esc = (v) => { + const s = String(v ?? ''); + if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; + return s; + }; + const lines = []; + lines.push(cols.map((c) => esc(prettyHeaderName(c))).join(',')); + rows.forEach((r) => { + lines.push(cols.map((c) => esc(r && r[c] != null ? r[c] : '')).join(',')); + }); + const blob = new Blob(["\uFEFF" + lines.join('\n')], { type: 'text/csv;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + } + function patchCsrf(j) { if (!j || !j.csrf) return; CB_CSRF = j.csrf; } + function readUrlVars() { + const out = {}; + try { + const usp = new URLSearchParams(window.location.search || ''); + usp.forEach((v, k) => { + if (!k.startsWith('var_')) return; + out[k.slice(4)] = v; + }); + } catch (e) { /* ignore */ } + return out; + } + + function writeUrlVars() { + if (!window.history || !window.history.replaceState) return; + try { + const url = new URL(window.location.href); + Array.from(url.searchParams.keys()).forEach((k) => { + if (k.startsWith('var_')) url.searchParams.delete(k); + }); + Object.keys(globalVars).forEach((k) => { + const v = globalVars[k]; + if (v === null || v === undefined) return; + if (Array.isArray(v)) { + if (!v.length) return; + url.searchParams.set('var_' + k, v.join(',')); + return; + } + const s = String(v).trim(); + if (s !== '') url.searchParams.set('var_' + k, s); + }); + window.history.replaceState({}, '', url.toString()); + } catch (e) { /* ignore */ } + } + function formatAxisNumber(val, fmt, dec) { const n = Number(val); if (Number.isNaN(n)) return val; @@ -116,16 +181,102 @@ if (payload.engine === 'table') { const cols = payload.columns || []; const rows = payload.rows || []; - let html = '
'; - cols.forEach((c) => { html += ''; }); - html += ''; - rows.forEach((r) => { - html += ''; - cols.forEach((c) => { html += ''; }); - html += ''; - }); - html += '
' + escapeHtml(c) + '
' + escapeHtml(r[c] != null ? String(r[c]) : '') + '
'; - el.innerHTML = html; + const state = { q: '', page: 1, perPage: 10 }; + const lower = (s) => String(s ?? '').toLowerCase(); + const buildFiltered = () => { + if (!state.q) return rows.slice(); + const q = lower(state.q); + return rows.filter((r) => cols.some((c) => lower(r && r[c] != null ? r[c] : '').includes(q))); + }; + + const renderTable = (focusState) => { + const filtered = buildFiltered(); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / state.perPage)); + if (state.page > totalPages) state.page = totalPages; + if (state.page < 1) state.page = 1; + const start = (state.page - 1) * state.perPage; + const paged = filtered.slice(start, start + state.perPage); + + let html = '
'; + html += '
'; + html += ''; + html += 'rows / page
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + + html += '
'; + cols.forEach((c) => { html += ''; }); + html += ''; + if (!paged.length) { + html += ''; + } else { + paged.forEach((r) => { + html += ''; + cols.forEach((c) => { html += ''; }); + html += ''; + }); + } + html += '
' + escapeHtml(prettyHeaderName(c)) + '
No matching rows.
' + escapeHtml(r && r[c] != null ? String(r[c]) : '') + '
'; + + const from = total === 0 ? 0 : start + 1; + const to = Math.min(start + state.perPage, total); + html += '
'; + html += 'Showing ' + from + ' to ' + to + ' of ' + total + ' entries'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + + el.innerHTML = html; + el.classList.add('cb-dash-table-host'); + + el.querySelector('[data-cb-table-search]')?.addEventListener('input', (ev) => { + const selStart = Number.isInteger(ev.target.selectionStart) ? ev.target.selectionStart : null; + const selEnd = Number.isInteger(ev.target.selectionEnd) ? ev.target.selectionEnd : selStart; + state.q = ev.target.value || ''; + state.page = 1; + renderTable({ focusSearch: true, selStart, selEnd }); + }); + el.querySelector('[data-cb-table-per-page]')?.addEventListener('change', (ev) => { + const next = Number(ev.target.value || 10); + state.perPage = [10, 25, 50, 100].includes(next) ? next : 10; + state.page = 1; + renderTable(); + }); + el.querySelector('[data-cb-table-page="prev"]')?.addEventListener('click', () => { + state.page -= 1; + renderTable(); + }); + el.querySelector('[data-cb-table-page="next"]')?.addEventListener('click', () => { + state.page += 1; + renderTable(); + }); + el.querySelector('[data-cb-table-export="csv"]')?.addEventListener('click', () => exportRowsAsCsv('dashboard-table.csv', cols, filtered)); + el.querySelector('[data-cb-table-export="excel"]')?.addEventListener('click', () => exportRowsAsCsv('dashboard-table-excel.csv', cols, filtered)); + + if (focusState && focusState.focusSearch) { + const nextSearch = el.querySelector('[data-cb-table-search]'); + if (nextSearch) { + nextSearch.focus(); + if (focusState.selStart != null && focusState.selEnd != null && typeof nextSearch.setSelectionRange === 'function') { + const len = String(nextSearch.value || '').length; + const s = Math.max(0, Math.min(len, focusState.selStart)); + const e = Math.max(s, Math.min(len, focusState.selEnd)); + nextSearch.setSelectionRange(s, e); + } + } + } + }; + + renderTable(); return; } if (payload.engine === 'kpi') { @@ -156,11 +307,20 @@ } function setGlobalVar(key, val) { - globalVars[String(key)] = val; - const k = String(key).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const normKey = String(key || '').trim(); + if (!normKey) return; + globalVars[normKey] = val; + const k = normKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); document.querySelectorAll('[data-cb-var-key="' + k + '"]').forEach((inp) => { - if (inp && inp.value !== undefined && document.activeElement !== inp) inp.value = val; + if (!inp || document.activeElement === inp) return; + if (inp.tagName === 'SELECT' && inp.multiple && Array.isArray(val)) { + const sv = new Set(val.map((x) => String(x))); + Array.from(inp.options).forEach((o) => { o.selected = sv.has(String(o.value)); }); + return; + } + if (inp.value !== undefined) inp.value = Array.isArray(val) ? val.join(',') : val; }); + writeUrlVars(); scheduleRefreshCharts(); } @@ -209,16 +369,6 @@ } } - async function loadVariableDefs(savedQueryId) { - if (!savedQueryId || !boot.urls || !boot.urls.savedQueryVars) return []; - try { - const r = await fetch(boot.urls.savedQueryVars + savedQueryId + '/variables', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); - const j = await r.json(); - if (!j.success) return []; - return j.variables || []; - } catch (e) { return []; } - } - function renderMarkdown(el, md) { if (!el || !md) { el.innerHTML = ''; return; } if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') { @@ -229,35 +379,121 @@ el.innerHTML = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } }); } - function buildVarInput(def) { + function renderVarInput(def) { const k = def.name; const lab = def.label || def.name; const req = def.is_required ? ' required' : ''; if (globalVars[k] === undefined && def.default_value != null) globalVars[k] = String(def.default_value); + const current = globalVars[k] ?? ''; + const labelHtml = ''; if (def.type === 'number') { - return ''; + return '
' + labelHtml + '
'; } if (def.type === 'date') { - return ''; + return '
' + labelHtml + '
'; } if (def.type === 'date_range') { - const parts = String(globalVars[k] || '').split('|'); - return '
'; + const parts = String(current || '').split('|'); + return '
' + labelHtml + '
'; } if (def.type === 'select' || def.type === 'multi_select') { const opts = Array.isArray(def.options) ? def.options : []; - let h = ''; if (def.type === 'select') h += ''; opts.forEach((o) => { const v = typeof o === 'object' ? (o.value ?? o.label) : o; const t = typeof o === 'object' ? (o.label ?? o.value) : o; - h += ''; + const vv = String(v); + const sel = def.type === 'multi_select' + ? (currentSet && currentSet.has(vv) ? ' selected' : '') + : (String(current) === vv ? ' selected' : ''); + h += ''; }); - h += ''; + h += ''; return h; } - return ''; + return '
' + labelHtml + '
'; + } + + async function loadDashboardVariables() { + if (!boot.urls || !boot.urls.dashboardVariables) return []; + try { + const r = await fetch(boot.urls.dashboardVariables, { + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + const j = await r.json(); + if (!j.success) return []; + return Array.isArray(j.variables) ? j.variables : []; + } catch (e) { + return []; + } + } + + async function loadVariableDefs(savedQueryId) { + if (!savedQueryId || !boot.urls || !boot.urls.savedQueryVars) return []; + try { + const r = await fetch(boot.urls.savedQueryVars + savedQueryId + '/variables', { + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + const j = await r.json(); + if (!j.success) return []; + return Array.isArray(j.variables) ? j.variables : []; + } catch (e) { + return []; + } + } + + function bindVarInputs(root) { + if (!root) return; + root.querySelectorAll('[data-cb-var-key]').forEach((inp) => { + const key = inp.getAttribute('data-cb-var-key'); + const apply = () => { + if (!inp.multiple) { + setGlobalVar(key, inp.value); + return; + } + const vals = Array.from(inp.selectedOptions || []).map((o) => o.value); + setGlobalVar(key, vals); + }; + inp.addEventListener('change', apply); + inp.addEventListener('input', apply); + }); + root.querySelectorAll('[data-cb-var-range]').forEach((inp) => { + inp.addEventListener('change', () => { + const parent = inp.getAttribute('data-cb-var-parent'); + const start = root.querySelector('[data-cb-var-range="start"][data-cb-var-parent="' + parent + '"]')?.value || ''; + const end = root.querySelector('[data-cb-var-range="end"][data-cb-var-parent="' + parent + '"]')?.value || ''; + setGlobalVar(parent, start + '|' + end); + }); + }); + } + + function renderDashboardVariablePanel() { + const host = document.getElementById('cbDashGlobalVars'); + const btn = document.getElementById('cbBtnVarsModal'); + if (!host) return; + if (READ_ONLY) { + if (btn) btn.classList.add('d-none'); + host.classList.add('d-none'); + host.innerHTML = ''; + return; + } + if (!dashVarDefs.length) { + if (btn) btn.classList.add('d-none'); + host.classList.add('d-none'); + host.innerHTML = ''; + return; + } + if (btn) btn.classList.remove('d-none'); + host.classList.remove('d-none'); + host.innerHTML = dashVarDefs.map((v) => renderVarInput(v)).join(''); + bindVarInputs(host); } function sanitizeFileName(raw) { @@ -538,45 +774,23 @@ const exportExcel = canExcel ? 'table_view' : ''; - const chartActions = exportPng + exportPdf + exportExcel + chartEditLink + rm; - const vars = await loadVariableDefs(w.saved_query_id); - let varsHtml = ''; - if (vars.length) { - varsHtml = '
' + vars.map(renderVarInput).join('') + '
'; + let varWarnHtml = ''; + if (READ_ONLY && w.saved_query_id) { + const vars = await loadVariableDefs(w.saved_query_id); + if (Array.isArray(vars) && vars.length) { + varWarnHtml = ''; + } } + const chartActions = varWarnHtml + exportPng + exportPdf + exportExcel + chartEditLink + rm; mount.innerHTML = '
' + '
' + escapeHtml(title) + '
' + chartActions + '
' + '
' + - varsHtml + '
' + '
' + '
Loading…
' + '
'; - mount.querySelectorAll('[data-cb-var-key]').forEach((inp) => { - const key = inp.getAttribute('data-cb-var-key'); - inp.addEventListener('change', () => setGlobalVar(key, inp.value)); - inp.addEventListener('input', () => setGlobalVar(key, inp.value)); - }); - mount.querySelectorAll('[data-cb-var-range]').forEach((inp) => { - inp.addEventListener('change', () => { - const parent = inp.getAttribute('data-cb-var-parent'); - const box = mount.querySelector('[data-cb-var-parent="' + parent + '"]')?.closest('.cb-dash-vars'); - if (!box) return; - const start = box.querySelector('[data-cb-var-range="start"]')?.value || ''; - const end = box.querySelector('[data-cb-var-range="end"]')?.value || ''; - setGlobalVar(parent, start + '|' + end); - }); - }); - mount.querySelectorAll('select[multiple][data-cb-var-key]').forEach((sel) => { - const key = sel.getAttribute('data-cb-var-key'); - sel.addEventListener('change', () => { - const vals = Array.from(sel.selectedOptions).map((o) => o.value); - setGlobalVar(key, vals); - }); - }); - await loadChartData(w); mount.querySelector('.cb-dash-export-png')?.addEventListener('click', () => exportChartPng(w)); mount.querySelector('.cb-dash-export-pdf')?.addEventListener('click', () => exportChartPdf(w)); @@ -611,16 +825,28 @@ const cfg = w.widget_config || {}; const sv = cfg.start_var || 'date_from'; const ev = cfg.end_var || 'date_to'; + const overlayHtml = (!READ_ONLY) + ? ('
' + actions + '
') + : ''; mount.innerHTML = - '
' + - '
' + escapeHtml(title) + '
' + actions + '
' + - '
' + - '
Date range → queries
' + - '
' + - '
' + - '
' + - '
'; + '
' + + overlayHtml + + '
' + + '
Date Range
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; mount.querySelectorAll('.cb-fdate').forEach((inp) => { + const fk = inp.getAttribute('data-fk'); + if (fk && globalVars[fk]) inp.value = String(globalVars[fk]); inp.addEventListener('change', () => { const fk = inp.getAttribute('data-fk'); globalVars[fk] = inp.value; @@ -635,12 +861,16 @@ const vn = cfg.var_name || 'filter'; const opts = Array.isArray(cfg.options) ? cfg.options : []; let optsHtml = opts.map((o) => '').join(''); + const overlayHtml = (!READ_ONLY) + ? ('
' + actions + '
') + : ''; mount.innerHTML = - '
' + - '
' + escapeHtml(title) + '
' + actions + '
' + - '
' + - '
Dropdown · ' + escapeHtml(vn) + '
' + - '' + + '
' + + overlayHtml + + '
' + + '
Dropdown
' + + '' + + '' + '
'; const sel = mount.querySelector('.cb-fdrop'); if (sel) sel.addEventListener('change', () => { @@ -859,7 +1089,7 @@ if (gs) gs.classList.toggle('cb-dash-editing', editing); document.querySelectorAll('.cb-dash-widget-remove').forEach((b) => b.classList.toggle('d-none', !editing)); document.querySelectorAll('.cb-dash-widget-edit').forEach((b) => b.classList.toggle('d-none', !editing)); - document.querySelectorAll('.cb-dash-widget-text-actions--overlay').forEach((box) => { + document.querySelectorAll('.cb-dash-widget-text-actions--overlay, .cb-dash-widget-filter-actions--overlay').forEach((box) => { const any = box.querySelector('.cb-dash-widget-edit:not(.d-none), .cb-dash-widget-remove:not(.d-none)'); box.classList.toggle('d-none', !any); }); @@ -1110,7 +1340,20 @@ initGrid(); layoutDirty = false; - initMounts().then(() => { + loadDashboardVariables().then((defs) => { + dashVarDefs = defs; + const qVars = readUrlVars(); + dashVarDefs.forEach((v) => { + const name = String(v.name || ''); + if (!name) return; + if (qVars[name] !== undefined) { + globalVars[name] = qVars[name]; + } else if (globalVars[name] === undefined) { + globalVars[name] = v.default_value != null ? String(v.default_value) : ''; + } + }); + renderDashboardVariablePanel(); + }).finally(() => initMounts().then(() => { finalizeLayoutFromServer(); /* GridStack may run resizeToContentCheck on a ~300ms timer after first layout; re-lock layout once after that. */ setTimeout(() => finalizeLayoutFromServer(), 360); @@ -1121,6 +1364,6 @@ if (sec > 0) { refreshTimer = setInterval(() => refreshAllCharts(), sec * 1000); } - }); + })); })(); diff --git a/app/Views/dashboard/view.php b/app/Views/dashboard/view.php index fa4c31e..b7a2bfc 100644 --- a/app/Views/dashboard/view.php +++ b/app/Views/dashboard/view.php @@ -38,6 +38,9 @@ $themeClass = match ($theme) {
+ @@ -98,6 +101,20 @@ $themeClass = match ($theme) {
+ +