Back to blog
laravel reporting gridstack apexcharts nwidart-modules vanilla-js debugging

Building a Power BI Lite in a Day (and Renaming Everything Twice)

The Starting Point

Client needed a reporting module. Not a trivial one — "Power BI lite, but user-focused." The spec they handed me was comprehensive: whitelisted data sources, dashboards with drag-drop widgets, scheduled email exports, tokenised public share links, chart types covering bar/line/pie/gauge/KPI/table. The whole nine yards.

The spec assumed Laravel 11 with Livewire 3 and Tailwind. My reality: Laravel 12 with nwidart/laravel-modules v12, no Livewire installed, Blade views with ~550 lines of inline custom CSS in the layout file, vanilla JS and fetch() for everything, Chart.js loaded via CDN in blade templates, and vendor libraries served from public/vendor/ because production has no npm build step reliably running.

So step one was: spec vs reality check. No way I'm bolting Livewire onto an app that has zero Livewire. No way I'm rewriting the styling to use Tailwind utility classes when the entire app uses .card, .modal-overlay, .tab-btn custom classes. The question became: can I deliver the functionality from the spec using the app's existing patterns?

Yes, turned out.

What Actually Happened

Phase 1: Read the spec, ignore half of it

I spent the first hour mapping the spec's assumptions against what the app actually looks like. The spec wanted ApexCharts + GridStack, and those are fine — they're plain JS libraries. But every single UI pattern from the spec assumed Livewire components. I had to mentally translate every "Livewire component with reactive state" into "Blade view + vanilla JS class with AJAX endpoints."

Key decisions before writing a line of code:

  • No Livewire. Use the app's existing fetch() + CSRF token pattern.
  • ApexCharts + GridStack served from public/vendor/ — curl them down, commit them, done. No npm build dependency for these.
  • Multi-tenancy: drop it entirely. The spec assumed organisation_id scoping. This app is single-tenant for one customer. Just use created_by_user_id for ownership.
  • Match existing CSS patterns. Every modal I built needed .modal-overlay + .modal-content with the .active class toggle. Every form used .form-group + .form-control.

Phase 2: Database + models + registry

The core abstraction is a DataSourceRegistry. Developers register AbstractDataSource classes in a service provider. Each source declares whitelisted fields as FieldDefinition value objects. The whole thing syncs to a report_datasources table on boot via sync().

This is the security boundary. Users never type column names or SQL. They pick fields from a dropdown that's been explicitly exposed by a developer. The ReportRunner validates every field slug in a request against the whitelist before touching the query builder. No DB::raw() with user input. No whereRaw(). Operator whitelist (= != > < >= <= in not_in between like not_like null not_null). Aggregate function whitelist (count sum avg min max). Hard cap of 50k rows.

Tested it with an SQL injection attempt in a filter value — parameterised queries swallowed it cleanly, returned 0 rows, no error. Good.

I built 7 migrations and 6 Eloquent models. Three data sources to start: Accounts, Sites, Cases. Each with field definitions covering direct columns, relation columns (account_name via account.name), and count fields (sites_count via withCount).

Phase 3: Report builder UI

Most complex view in the module. A tabbed Blade page: Columns, Filters, Group & Aggregate, Sort & Limit, Preview. Left panel shows the whitelisted fields with type badges. Right panel has the config tabs.

The filter tree was the interesting bit. Recursive AND/OR with arbitrary nesting, rendered entirely client-side as DOM nodes. Each condition row is field select → operator select → value input, where the operator list is filtered by field type (dates get "before/after", enums get "in/not in", strings get "contains", etc.) and the value input adapts (date picker for dates, multi-select for "in" operators, text for strings).

Built it with a single JS class, no framework. Serialises back to JSON on save by walking the DOM tree. Works fine.

Phase 4: First rename — axios → fetch

Wrote the whole builder using axios.post() and axios.put(). Then realised halfway through testing that the app has no Vite-built app.js included in the layout. The existing codebase uses fetch() everywhere — there's no axios globally available in the browser. The app's bootstrap.js imports axios but app.js isn't even loaded from the layout (checked: no @vite directive anywhere).

So I did a bulk find-replace from axios.post(...) to fetch(url, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(...) }) across the builder, dashboard, and index views. The error handling shape is different too (no .response?.data?.error, instead unwrap r.json() with an ok flag). Pattern I ended up with:

fetch(url, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload) })
    .then(r => r.json().then(data => ({ ok: r.ok, data })))
    .then(({ ok, data }) => {
        if (!ok) { alert(data.error || 'Failed'); return; }
        // success handling
    });

Not as clean as axios, but consistent with the rest of the app. And no new dependency.

Phase 5: Dashboards and widgets

GridStack for drag-drop layout, ApexCharts for visualisations. Downloaded both from CDN into public/vendor/:

curl -sL "https://cdn.jsdelivr.net/npm/[email protected]/dist/gridstack-all.js" \
    -o public/vendor/gridstack/gridstack-all.js
curl -sL "https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.js" \
    -o public/vendor/apexcharts/apexcharts.min.js

Matches the DHTMLX pattern already used elsewhere in the app. Production deployments don't need npm to work.

Widget types: table, KPI, bar, line, area, pie, donut, gauge. WidgetRenderer service transforms query results into the shape each widget type wants (categories + series for bar/line, labels + series for pie, percentage for gauge, etc.). Auto-detects which field is the category axis vs. value axis if admin doesn't specify.

First bug: widget modal showed unstyled inline at the bottom of the page instead of as an overlay. Took me a minute to figure out why. The .modal-overlay CSS isn't in the layout file — each page that uses a modal defines the modal CSS in its own @section('styles') block. That's the existing convention. Copied the CSS block to the dashboard view. Same issue hit again on the report builder. Copied it there too.

Lesson: when there's no global CSS framework, CSS lives where the component does. Annoying but consistent.

Phase 6: First rename — "Reports" → "Report Builder"

Client came back: "Report Builder shouldn't be visible to regular users, only admins. Make it admin-only. Then regular users should see a different navigation that exposes curated Report Pages that combine reports and dashboards."

So I needed three layers:

  1. Report Builder (admin-only, at /report-builder/) — the creation tools
  2. Report Pages (new concept) — admin-curated pages mixing report tables and dashboard widgets
  3. Reports nav dropdown (all users) — the consumer-facing menu with nested folders

The existing routes all used names like reporting.index, reporting.reports.show, reporting.dashboards.edit. I had to rename all of them to report-builder.*. Did a bulk sed across the views:

find Modules/Reporting/resources/views -name "*.blade.php" \
    -exec sed -i '' "s/route('reporting\./route('report-builder./g" {} +

Plus the hardcoded AJAX paths in JavaScript:

find Modules/Reporting/resources/views -name "*.blade.php" \
    -exec sed -i '' "s|'/reporting/api/|'/report-builder/api/|g" {} +

Then the client refreshed and got:

[2026-04-21 15:41:17] local.ERROR: Route [reporting.index] not defined.
(View: /app/resources/views/layouts/app.blade.php)

The layout was still referencing reporting.index. Except I'd already changed it. But Laravel was caching the compiled views. Ran php artisan optimize:clear and npm run build. Fixed.

Client refreshed again: same error. This happened twice. Both times the fix was the same cache clear. Once the cache was cold, everything worked.

Phase 7: The menu system and the flyout trap

Built a self-referential report_menu_items table with parent_id for unlimited nesting. Items are either folder (container) or page (links to a Report Page). Plus a type: dashboard added later for linking to dashboards directly.

Rendering unlimited-depth nested menus in a nav bar is more fiddly than it sounds. The existing CRM and WMS sub-navs are one level deep (click parent → sub-nav slides down with flat list). For nested folders I went with CSS-only flyout sub-menus: position: relative on parent <li>, position: absolute; left: 100%; top: 0 on child <ul>, display controlled by :hover. Recursive Blade partial does the rendering.

Phase 8: The toggle JS bug that nearly bit me

The existing navigation toggle JS was doing this:

function toggleCrmNav() {
    var parents = document.querySelectorAll('.nav-parent');
    var label = parents[0].querySelector('.nav-parent-label');
    // ...
    if (isOpening) {
        parents[1].classList.remove('open');  // hardcoded index!
    }
}

Two dropdowns (CRM and WMS), so parents[0] was CRM and parents[1] was WMS. It worked. But adding a third Reports dropdown would shift the indices — WMS would no longer be parents[1], Reports would be. The toggles would silently target the wrong elements.

Refactored to ID-based:

function closeAllNavs() {
    ['crmSubNav', 'wmsSubNav', 'reportsSubNav', 'dashSubNav'].forEach(function(id) {
        var el = document.getElementById(id);
        if (el) el.classList.remove('open');
    });
    // ...
}
function toggleNav(navId, parentId) { /* ... */ }
function toggleCrmNav() { toggleNav('crmSubNav', 'crmNavParent'); }
function toggleWmsNav() { toggleNav('wmsSubNav', 'wmsNavParent'); }
function toggleReportsNav() { toggleNav('reportsSubNav', 'reportsNavParent'); }

Added matching id="crmNavParent" etc. to the existing <li> elements. Mutual exclusivity now works for N dropdowns without relying on array order. This is the kind of bug that would have silently broken when nobody was watching.

Phase 9: Where do dashboards live?

Final pivot. Auto-categorisation had been working: adding a Dashboard via the Menu Builder automatically created a "Dashboards" folder under the Reports dropdown and placed the item there. Reports pages went under a "Reports" folder. Clean, organised.

Client: "Wait, dashboards shouldn't be under Reports. They should be under the top-level Dashboard nav item."

Problem: Dashboard was a flat link (<a href="/dashboard">Dashboard</a>), not a dropdown. Turned it into a dropdown that's only a dropdown when there are published dashboards — otherwise falls back to a flat link:

@if($dashboardMenuItems->count() > 0)
<li class="nav-parent" id="dashNavParent">
    <span class="nav-parent-label" onclick="toggleDashNav()">
        Dashboard <svg class="chevron">...</svg>
    </span>
</li>
@else
<li><a href="{{ route('dashboard') }}">Dashboard</a></li>
@endif

When the dropdown opens: "Home" (system dashboard) first, then all published report dashboards. Auto-categorisation now routes dashboard menu items to top level (no folder needed), and report page items into a "Reports" folder under the Reports dropdown.

The dashboard viewer view already existed from the admin Report Builder. I added a route guard so non-admins view it via /reports/dashboard/{id} without seeing edit buttons or "Back to Dashboards" links:

@php $isPublicView = request()->routeIs('reports.dashboard'); @endphp
@if(!$isPublicView)
    <a href="..." class="back-link">Back to Dashboards</a>
@endif

Same view, two different contexts. Widget AJAX URLs also branch by context:

const widgetDataBase = isPublicView
    ? '/reports/dashboard/' + dashboardId + '/widget/' + widgetId + '/data'
    : '/report-builder/api/widgets/' + widgetId + '/data';

What Worked

Full module delivered:

  • 3 data sources registered (Accounts, Sites, Cases; Asset Audits added later as an example)
  • 12 controllers, 11 migrations, 9 Eloquent models (original reporting models + 3 new for pages/items/menu)
  • 44 admin routes + 2 public routes for viewing published content
  • Report Builder at /report-builder/ with report definition CRUD, dashboard CRUD, Report Page CRUD, Menu Builder
  • Published Pages at /reports/{slug} (mix of tables and charts, read-only for non-admins)
  • Nav dropdown with unlimited nesting, auto-categorisation (report pages → "Reports" folder; dashboards → top-level Dashboard dropdown)
  • CSV, XLSX, PDF exports for any saved report
  • Scheduled reports via artisan command + cron expression parser + mailable
  • Public tokenised share links for dashboards (no auth required)
  • Admin settings page for toggling data sources on/off and enabling/disabling the module

The DataSourceRegistry pattern turned out to be genuinely extensible. Adding a new data source is:

  1. Write one class extending AbstractDataSource with slug(), name(), modelClass(), and a fields() array of FieldDefinition objects.
  2. Register it in the service provider with $registry->register(new WhateverDataSource()).

The sync() call on boot upserts the DB row. New source appears in the builder dropdown and on the admin page automatically.

What I Learned

  • Cache-aware Laravel apps have delayed feedback loops. When you change route names, views that call route('old.name') throw RouteNotFoundException at render time, not boot time. If the view is cached, you don't see the error until the cache clears. Changed the route, worked for me, client got a 500. php artisan optimize:clear in your back pocket always.

  • Hardcoded array indices in nav toggle JS are a time bomb. querySelectorAll index order is DOM order, which shifts every time you add an element. IDs are cheap. Use them.

  • Nested flyout menus in CSS alone work fine up to 3-4 levels deep. position: absolute; left: 100%; top: 0 on the submenu, :hover on the parent .has-submenu. No JS needed. Works for "unlimited nesting" in practice because nobody actually builds 6-level-deep menus.

  • When there's no global CSS framework, accept that modal styles live with the modal. The copy-paste is annoying, but trying to centralise CSS after the fact means fighting the existing convention. Pick your battles.

  • Axios vs fetch: use whatever the app uses. The codebase had 0 axios usages and dozens of fetch calls. I wasted time writing the builder in axios only to rewrite it all in fetch. Read the codebase conventions before you write code in it.

  • "Power BI lite" is achievable in a session if the abstractions are right. The DataSourceRegistry + ReportRunner + WidgetRenderer split meant I could add features without touching the query engine. Dashboard → widget → chart was three distinct transformations, each testable on its own.

  • Scope renames are not free. Renaming reporting.* routes to report-builder.* touched: routes file, controllers, views (every blade template), JavaScript (hardcoded AJAX URLs), sidebar partials, admin page links. Sed handled most of it but three caches had to be cleared and I had to find references I'd missed in JS string literals. Budget for this.

  • A 2-dropdown nav can ship with hardcoded array indices. A 3-dropdown nav cannot. Every growth point exposes lazy assumptions from the earlier, simpler version.

Tech Notes

Stack:

  • Laravel 12 with nwidart/laravel-modules v12 — 19 modules now including the new one
  • Blade + vanilla JS + fetch() — no Livewire, no Vue, no React
  • ApexCharts 4.5.0 from public/vendor/apexcharts/ — gauges via radialBar, all other chart types out of the box
  • GridStack 10.3.1 from public/vendor/gridstack/ — drag-drop dashboard grid
  • PhpOffice/PhpSpreadsheet for XLSX exports, barryvdh/laravel-dompdf for PDF exports (both were already installed)
  • Streaming fputcsv for CSV exports so large reports don't blow up memory

Patterns that scaled:

  • AbstractDataSource with a fields(): array method returning FieldDefinition value objects
  • Cache::remember("reporting:widget:{$id}:" . md5($filters), 300, fn() => ...) for widget data
  • Recursive Blade partials for self-referential menu trees (@each('partial', $children, 'item'))
  • Route binding middleware stack for multi-context views (route-builder.* for admin, reports.* for public) sharing the same Blade

Commands worth remembering:

  • php artisan optimize:clear — when route renames fail silently, this is always the first thing to run
  • php artisan module:enable Reporting — don't forget this after composer dump-autoload finds the new module
  • curl -sL "https://cdn.jsdelivr.net/npm/<pkg>@<ver>/dist/<file>" -o public/vendor/<pkg>/<file> — vendor libs without npm
  • find <dir> -name "*.blade.php" -exec sed -i '' "s/old/new/g" {} + — bulk rename across a module's views
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.