diff --git a/resources/lang/en-US/table.php b/resources/lang/en-US/table.php index 749127d9c6..fb8144f2a0 100644 --- a/resources/lang/en-US/table.php +++ b/resources/lang/en-US/table.php @@ -7,5 +7,9 @@ return [ 'by' => 'By', 'item' => 'Item', 'no_matching_records' => 'No matching records found', + 'load_error_title' => 'Could not load results', + 'load_error_body' => 'The server returned an error. Please try again in a moment. If this keeps happening, check the application log.', + 'load_error_http_status' => 'HTTP status', + 'load_error_session_expired' => 'Your session has expired. Reloading the page.', ]; diff --git a/resources/views/partials/bootstrap-table.blade.php b/resources/views/partials/bootstrap-table.blade.php index 99b2ee7475..da59bb6390 100644 --- a/resources/views/partials/bootstrap-table.blade.php +++ b/resources/views/partials/bootstrap-table.blade.php @@ -1,6 +1,21 @@ @push('css') + {{-- Bootstrap's default a:hover paints links Bootstrap-blue on hover. + For the advanced-search "clear all" pill and the individual + tag-remove pills, we want the label chip's white text to stay + white on hover (they already carry a colored background). --}} + @endpush @push('js') @@ -36,6 +51,56 @@ return (operator || defaultAdvancedSearchOperator).toString().toLowerCase() === 'or' ? 'or' : 'and'; }; + // Shared teardown for the transport-error state onLoadError puts up. + // Called from onLoadSuccess (retry succeeded), onRefresh (user clicked + // the reload button), and any future entry points that should return + // the table to its clean baseline. + var clearBootstrapTableLoadError = function (instance) { + if (!instance || !instance.$el) { + return; + } + var $wrap = instance.$el.closest('.bootstrap-table'); + $wrap.find('.table-load-error').remove(); + $wrap.find('.fixed-table-body').show(); + $wrap.find('.fixed-table-pagination').show(); + }; + + // Both onLoadError banners (session-expired 401/419 and the generic + // 500-level failure) share the same DOM shape, insertion point, and + // cleanup rules. innerHtml must already be XSS-safe — callers HTML- + // encode dynamic pieces before passing them in. + // + // aria-live=polite is always emitted. role="alert" alone implies + // assertive, which interrupts screen readers mid-utterance; a table + // that failed to load doesn't warrant that. Polite queues the + // announcement for the next natural pause. + var renderBootstrapTableLoadErrorCallout = function (options) { + var $wrap = options.wrap; + $wrap.find('.table-load-error').remove(); + $wrap.find('.fixed-table-body').hide(); + $wrap.find('.fixed-table-pagination').hide(); + + // Insert below the advanced-search pill container if it's present + // (populated by an active filter), otherwise below the toolbar. + // Either way the banner ends up above .fixed-table-container. + var $tagContainer = $wrap.children('.snipe-advanced-search-tags'); + var $anchor = $tagContainer.length && $tagContainer.children().length + ? $tagContainer + : $wrap.find('.fixed-table-toolbar').first(); + + // Marker class table-load-error MUST live on the outermost wrapper + // so the cleanup selector removes the whole banner in one shot. + // A nested layout would leave orphan outer wells behind on each + // re-render and stack up. + $anchor.after( + '
', + ); + }; + var getStoredAdvancedSearchOperator = function () { try { var storedOperator = localStorage.getItem(advancedSearchOperatorStorageKey); @@ -730,6 +795,95 @@ exportTypes: ['xlsx', 'csv', 'pdf', 'json', 'xml', 'txt', 'sql', 'doc'], onLoadSuccess: function () { // possible 'fixme'? this might be for contents, not for headers? $('[data-tooltip="true"]').tooltip(); // Needed to attach tooltips after ajax call + + // Clear any lingering "load failed" banner + restore the table body + // that onLoadError hid. Bootstrap-table appends the table instance as + // the last callback arg and binds `this` to the options bag (not the + // DOM), so reach for the table element via arguments rather than + // $(this). + var instance = arguments[arguments.length - 1]; + if (instance && instance.$el) { + clearBootstrapTableLoadError(instance); + } + }, + onRefresh: function () { + // Fires the moment the user clicks the toolbar's refresh button, + // before the retry AJAX call is issued. Clear the callout + restore + // the body/pagination up-front so that even if the retry also + // errors, we're not stacking state on top of the previous error. + // If the retry succeeds, onLoadSuccess is a no-op reassertion. + // If the retry errors, onLoadError re-hides the body and re-shows + // the callout with the new status. + var instance = arguments[arguments.length - 1]; + if (instance && instance.$el) { + clearBootstrapTableLoadError(instance); + } + }, + onLoadError: function (status, jqXHR) { + // Fires on any real transport-level failure (500, 502, 504, network + // error, 419 CSRF expiry, 401 unauthenticated, etc). Snipe's API + // convention is to return {status: "error"} at HTTP 200 for + // validation/business errors, so anything reaching this callback + // is a genuine "the request itself failed" case that would + // otherwise render as an empty "No matching records found", a + // silent, misleading empty-state. + // + // Blade's JSON directive below is what makes each translation + // string safe to embed in this JS context. It produces a + // properly quoted JS string literal that handles any characters + // the translation might contain (quotes, backslashes, newlines, + // unicode). Every dynamic value from the server response is then + // HTML-encoded via jQuery's .text().html() idiom before we + // concat it into the callout markup, so a poisoned + // responseJSON.message cannot inject script tags or event handlers. + // + // `this` inside bootstrap-table event callbacks is the options + // bag, not the DOM element. The library appends the table + // instance as the trailing callback arg, so we reach the + // wrapper via arguments[last].$el. + var instance = arguments[arguments.length - 1]; + if (!instance || !instance.$el) { + return; + } + var $wrap = instance.$el.closest('.bootstrap-table'); + + // 401 / 419 mean the user's session died; show the session- + // expired message and reload so the login redirect can take over. + if (jqXHR && (jqXHR.status === 401 || jqXHR.status === 419)) { + renderBootstrapTableLoadErrorCallout({ + wrap: $wrap, + innerHtml: + '' + + $('
').text(@json(trans('table.load_error_session_expired'))).html(), + }); + window.setTimeout(function () { + window.location.reload(); + }, 1500); + return; + } + + // Deliberately do NOT surface the raw server response body / + // statusText / responseJSON.message. On PHP fatals, Laravel's + // debug renderer can leak PDO DSN strings (with hostnames), file + // paths from the stack trace, and other environment detail that + // shouldn't reach a browser. The HTTP status alone is enough + // signal for the user to know something's wrong and enough + // signal for an admin to correlate in the app log. + var httpStatus = (jqXHR && jqXHR.status) || status || '?'; + + renderBootstrapTableLoadErrorCallout({ + wrap: $wrap, + innerHtml: + '
' + + '' + + '' + $('
').text(@json(trans('table.load_error_title'))).html() + '' + + '
' + + '
' + + $('
').text(@json(trans('table.load_error_body'))).html() + + ' ' + $('
').text(@json(trans('table.load_error_http_status'))).html() + ': ' + + $('
').text(httpStatus).html() + '' + + '
', + }); }, onPostHeader: function () { var lookup = {};