Replaced the custom controller for fragment replacements with symfony streams

This does not require a complete new endpoint
This commit is contained in:
Jan Böhmer 2026-02-22 19:14:35 +01:00
parent 05ee3157fb
commit 910ad939df
5 changed files with 65 additions and 198 deletions

View file

@ -20,6 +20,10 @@
import { Controller } from '@hotwired/stimulus';
import { Toast } from 'bootstrap';
/**
* The purpose of this controller, is to show all containers.
* They should already be added via turbo-streams, but have to be called for to show them.
*/
export default class extends Controller {
connect() {
//Move all toasts from the page into our toast container and show them
@ -33,4 +37,4 @@ export default class extends Controller {
const toast = new Toast(this.element);
toast.show();
}
}
}

View file

@ -30,9 +30,9 @@ export default class extends Controller {
_submitting = false;
_lastDecodedText = "";
_onInfoChange = null;
_onFormSubmit = null;
connect() {
// Prevent double init if connect fires twice
if (this._scanner) return;
@ -45,22 +45,6 @@ export default class extends Controller {
info.addEventListener("change", this._onInfoChange);
}
// Stop camera cleanly before manual form submit (prevents broken camera after reload)
const form = document.getElementById("scan_dialog_form");
if (form) {
this._onFormSubmit = () => {
try {
const p = this._scanner?.clear?.();
if (p && typeof p.then === "function") p.catch(() => {});
} catch (_) {
// ignore
}
};
// capture=true so we run before other handlers / navigation
form.addEventListener("submit", this._onFormSubmit, { capture: true });
}
const isMobile = window.matchMedia("(max-width: 768px)").matches;
//This function ensures, that the qrbox is 70% of the total viewport
@ -94,10 +78,10 @@ export default class extends Controller {
}
disconnect() {
// If we already stopped/cleared before submit, nothing to do.
const scanner = this._scanner;
this._scanner = null;
this._submitting = false;
this._lastDecodedText = "";
// Unbind info-mode change handler (always do this, even if scanner is null)
@ -107,13 +91,6 @@ export default class extends Controller {
}
this._onInfoChange = null;
// remove the onForm submit handler
const form = document.getElementById("scan_dialog_form");
if (form && this._onFormSubmit) {
form.removeEventListener("submit", this._onFormSubmit, { capture: true });
}
this._onFormSubmit = null;
if (!scanner) return;
try {
@ -125,7 +102,7 @@ export default class extends Controller {
}
async onScanSuccess(decodedText) {
onScanSuccess(decodedText) {
if (!decodedText) return;
const normalized = String(decodedText).trim();
@ -134,94 +111,11 @@ export default class extends Controller {
// scan once per barcode
if (normalized === this._lastDecodedText) return;
// If a request/submit is in-flight, ignore scans.
if (this._submitting) return;
// Mark as handled immediately (prevents spam even if callback fires repeatedly)
this._lastDecodedText = normalized;
this._submitting = true;
// Clear previous augmented result immediately to avoid stale info
// lingering when the next scan is not augmented (or is transient/junk).
const el = document.getElementById("scan-augmented-result");
if (el) el.innerHTML = "";
//Put our decoded Text into the input box
const input = document.getElementById("scan_dialog_input");
if (input) input.value = decodedText;
const infoMode = !!document.getElementById("scan_dialog_info_mode")?.checked;
try {
const data = await this.lookup(normalized, infoMode);
// ok:false = transient junk decode; ignore without wiping UI
if (!data || data.ok !== true) {
this._lastDecodedText = ""; // allow retry
return;
}
// If info mode is OFF and part was found -> redirect
if (!infoMode && data.found && data.redirectUrl) {
window.location.assign(data.redirectUrl);
return;
}
// If info mode is OFF and part was NOT found, redirect to create part URL
if (!infoMode && !data.found && data.createUrl) {
window.location.assign(data.createUrl);
return;
}
// Otherwise render returned fragment HTML
if (typeof data.html === "string" && data.html !== "") {
const el = document.getElementById("scan-augmented-result");
if (el) el.innerHTML = data.html;
}
} catch (e) {
console.warn("[barcode_scan] lookup failed", e);
// allow retry on failure
this._lastDecodedText = "";
} finally {
this._submitting = false;
}
}
async lookup(decodedText, infoMode) {
const form = document.getElementById("scan_dialog_form");
if (!form) return { ok: false };
generateCsrfToken(form);
const mode =
document.querySelector('input[name="scan_dialog[mode]"]:checked')?.value ?? "";
const body = new URLSearchParams();
body.set("input", decodedText);
if (mode !== "") body.set("mode", mode);
body.set("info_mode", infoMode ? "1" : "0");
const headers = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
...generateCsrfHeaders(form),
};
const url = this.element.dataset.lookupUrl;
if (!url) throw new Error("Missing data-lookup-url on #reader-box");
const resp = await fetch(url, {
method: "POST",
headers,
body: body.toString(),
credentials: "same-origin",
});
if (!resp.ok) {
throw new Error(`lookup failed: HTTP ${resp.status}`);
}
return await resp.json();
document.getElementById('scan_dialog_input').value = decodedText;
//Submit form
document.getElementById('scan_dialog_form').requestSubmit();
}
}

View file

@ -64,6 +64,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use App\Entity\Parts\Part;
use \App\Entity\Parts\StorageLocation;
use Symfony\UX\Turbo\TurboBundle;
/**
* @see \App\Tests\Controller\ScanControllerTest
@ -131,6 +132,18 @@ class ScanController extends AbstractController
if ($dbEntity === null) {
$createUrl = $this->buildCreateUrlForScanResult($scan);
}
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [
'decoded' => $decoded,
'entity' => $dbEntity,
'part' => $resolvedPart,
'openUrl' => $openUrl,
'createUrl' => $createUrl,
]);
}
}
} catch (\Throwable $e) {
// Keep fallback user-friendly; avoid 500
@ -138,6 +151,13 @@ class ScanController extends AbstractController
}
}
//When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
//Only send our flash message, so the client can show it without a full page reload
return $this->renderBlock('_turbo_control.html.twig', 'flashes');
}
return $this->render('label_system/scanner/scanner.html.twig', [
'form' => $form,
@ -197,69 +217,4 @@ class ScanController extends AbstractController
return null;
}
/**
* Provides XHR endpoint for looking up barcode information and return JSON response
* @param Request $request
* @return JsonResponse
*/
#[Route(path: '/lookup', name: 'scan_lookup', methods: ['POST'])]
public function lookup(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('@tools.label_scanner');
$input = trim($request->request->getString('input', ''));
// We cannot use getEnum here, because we get an empty string for mode, when auto mode is selected
$mode = $request->request->getString('mode', '');
if ($mode === '') {
$modeEnum = null;
} else {
$modeEnum = BarcodeSourceType::from($mode); // validate enum value; will throw if invalid
}
$infoMode = $request->request->getBoolean('info_mode', false);
if ($input === '') {
return new JsonResponse(['ok' => false], 200);
}
try {
$scan = $this->barcodeNormalizer->scanBarcodeContent($input, $modeEnum);
} catch (InvalidArgumentException) {
// Camera sometimes produces garbage decodes for a frame; ignore those.
return new JsonResponse(['ok' => false], 200);
}
$decoded = $scan->getDecodedForInfoMode();
//Try to resolve to an entity, to enhance info mode with entity-specific data
$dbEntity = $this->resultHandler->resolveEntity($scan);
$resolvedPart = $this->resultHandler->resolvePart($scan);
$openUrl = $this->resultHandler->getInfoURL($scan);
//If no entity is found, try to create an URL for creating a new part (only for vendor codes)
$createUrl = null;
if ($dbEntity === null) {
$createUrl = $this->buildCreateUrlForScanResult($scan);
}
// Render fragment (use openUrl for universal "Open" link)
$html = $this->renderView('label_system/scanner/_info_mode.html.twig', [
'decoded' => $decoded,
'entity' => $dbEntity,
'part' => $resolvedPart,
'openUrl' => $openUrl,
'createUrl' => $createUrl,
]);
return new JsonResponse([
'ok' => true,
'found' => $openUrl !== null, // we consider the code "found", if we can at least show an info page (even if the part is not found, but we can show the decoded data and a "create" button)
'redirectUrl' => $openUrl, // client redirects only when infoMode=false
'createUrl' => $createUrl,
'html' => $html,
'infoMode' => $infoMode,
], 200);
}
}

View file

@ -1,14 +1,20 @@
{# Insert flashes #}
<div class="toasts-global d-none">
{% for label, messages in app.flashes() %}
{% for message in messages %}
{{ include('_toast.html.twig', {
'label': label,
'message': message
}) }}
{% endfor %}
{% endfor %}
</div>
{% block flashes %}
{# Insert flashes #}
<turbo-stream action="replace" action="morph" target="toast-container">
<template>
<div class="toast-container" id="toast-container">
{% for label, messages in app.flashes() %}
{% for message in messages %}
{{ include('_toast.html.twig', {
'label': label,
'message': message
}) }}
{% endfor %}
{% endfor %}
</div>
</template>
</turbo-stream>
{% endblock %}
{# Allow pages to request a fully reload of everything #}
{% if global_reload_needed is defined and global_reload_needed %}

View file

@ -10,7 +10,6 @@
<div class="">
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<div class="img-thumbnail" style="max-width: 600px;">
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}
data-lookup-url="{{ path('scan_lookup') }}"></div>
@ -18,13 +17,22 @@
</div>
</div>
<div id="scan-augmented-result" class="mt-3">
{% include "label_system/scanner/_info_mode.html.twig" %}
</div>
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{{ form_end(form) }}
</div>
<div id="scan-augmented-result" class="mt-3">
{% include "label_system/scanner/_info_mode.html.twig" %}
</div>
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{{ form_end(form) }}
{% endblock %}
{% block scan_results %}
<turbo-stream action="replace" action="morph" target="scan-augmented-result">
<template>
<div id="scan-augmented-result" class="mt-3">
{% include "label_system/scanner/_info_mode.html.twig" %}
</div>
</template>
</turbo-stream>
{% endblock %}