Technical debt, part 4: I thought I was done… and the last one still reloaded the whole page
ContenidoContents
I’ve caught the bug and I couldn’t stop. So, taking advantage of it being Sunday, I sat down to knock out another loose end of the plugin. And this came out.
In part three I signed off saying the big refactor was done: external.php, lib.php, the video call and view.php. And it was… almost. Because while refactoring view.php I left, on purpose, one loose end for later: the recordings table. It was the last corner of the plugin still doing things like it’s 2015 —reloading the whole page every time you delete, hide or add a recording— with all the HTML of each row stitched by hand.
Spoiler of today’s moral: “being done” with a refactor is a moving horizon. Every time you think you’ve arrived, the next loose end shows up. This was the one due. All on the dev branch, already deployed and validated on my real Moodle (aula.sergiocomeron.com).
The last one that reloaded the whole page
The recordings CRUD (delete, hide, show, add, edit) was still a classic PRG: you submit a form, the server processes it, redirects you and reloads the full page. And, along the way, it dumps you at the very top, far from the recording you’d just touched.
Underneath, the other half of the problem: each row’s HTML was built by hand in view_table.php::col_id() —475 lines of concatenated strings—, with branches for GCS, Dropbox, YouTube, 8x8 and Jibri, plus the AI dropdown, the summary accordion, the segments bar and the heatmap. All mixed together: deciding, querying and painting in the same function.
The plan
Three goals for this batch:
- CRUD to Web Services + AMD AJAX, refreshing only the tab with a fragment re-fetch —no page reload—.
- Pull the HTML out of
col_id()into Mustache templates: PHP decides and builds the context, the template paints. - Keep the PRG and the
$stateparsing intact as a compatibility net for the Moodle mobile app.
Four Web Services and an event that repeated itself
The endpoints now live in classes/external/, following the same pattern I already had for the AI ones (cmid → context_module::instance → validate_context → require_capability → {success, message}):
delete_recording— capabilitymod/jitsi:deleterecord.set_recording_visibility— capabilitymod/jitsi:hide.add_recording_linkandupdate_recording_link— capabilitymod/jitsi:record.
Registered in db/services.php with ajax => true. The key part: the business logic already lived in classes/local/recording.php (from earlier batches), so the WS don’t reimplement anything; they only add context validation, capability and the event. And there I caught a duplication: the jitsi_delete_record event was fired in two places. I centralized it in recording::log_deletion(...), used by both the new WS and the fallback PRG. A single source of truth for the deletion log.
The table, into templates
col_id() was rewritten to build a context in PHP and delegate all the markup to five templates: embeddable video, external link, YouTube iframe, the action-buttons partial and the add/edit form.
And the very dynamic blocks —AI dropdown, accordion, segments bar, heatmap—? Those I didn’t rewrite in Mustache: I pre-render them in PHP with the renderers that already existed and inject them as raw HTML ({{{...}}}). Reuse instead of rewrite, keeping all the ids/classes the other JS modules consume. The rule I set myself: the template doesn’t think, it only paints.
The gem: refreshing without jumping to the top
Here’s the detail I’m proudest of, and it’s not the flashiest. After each change, the recordings_lazyload.js module does a fragment re-fetch and injects it. Easy. The hard part is that it shouldn’t be noticeable.
A naive AJAX deletes a row, reloads the container… and the page jumps to the top. To avoid it: I pin the container’s height while it loads (so the spinner doesn’t collapse the layout) and restore the exact scroll when injecting the new content. Result: you hide or delete a recording and you stay right where you were. That’s what separates an “AJAX that works” from one that feels right.
The environment war stories
As in every batch, the terrain bites:
- ESLint on goalkeeper duty again. The pre-push hook rejects
promise/no-nesting. The delete handler, which chained the confirmation with the call, I rewrote withasync/awaitto flatten the promises. (Same lesson as part 3: reproduce the strict CI lint locally before pushing. Will I ever learn?) - Mustache has its quirks. You can’t nest the
{{#str}}helper inside{{#pix}}: it doesn’t process reliably. The icon’s accessible text ended up in the button’stitleattribute. Small, but it costs you half an hour if you don’t know it.
Respecting the time capsule
Remember the phantom $state from part 3 —that parameter the Moodle mobile app builds, inherited from a “ionic 5 compatibility” commit—? The PRG and that parsing stay intact as a safety net. The web no longer uses them, but breaking them would break app users.
That said, I left myself the next step noted down: before ever removing the PRG, you have to instrument when $state != null arrives to confirm the app no longer needs it. Until I have that data, the capsule is respected. Deleting blindly something you don’t fully understand is exactly the mistake this series tries not to make.
What this closes
In metrics: the 475 lines of col_id() gone into templates, four new Web Services, the whole CRUD without a single reload, and the suite at 144 tests (5 new for the WS, 4 for template rendering), with ESLint clean and phpcs clean.
And the thesis of the whole series, taken to its conclusion: the frontend stops receiving HTML stitched from PHP and moves to consuming data via service and painting with templates. Which is —once more— the exact foundation that Moodle’s reactive UI, the one heading toward React, is going to need.
The refactor “done”? This time I don’t dare say it. What I take from these four parts is that code always has one more loose end —and that’s fine—. Refactoring isn’t a project with an ending: it’s a way of caring for what you maintain.
- “Being done” is a moving horizon. Each refactor uncovers the next. The question isn’t whether loose ends remain, but which one is due now.
- An AJAX that feels right is, above all, details. Preserving the scroll doesn’t show up in any metric, but it’s the difference between usable and pleasant.
- Respect the time capsules even when you no longer use them. Leave them as a fallback and instrument before removing them. Don’t blindly delete what someone put there for a reason you no longer see.