3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/resources/assets/js/snipeit.js

1589 lines
67 KiB
JavaScript
Executable File

var jQuery = require('jquery');
window.jQuery = jQuery
window.$ = jQuery
// window._ = require('lodash'); //the only place I saw this used was vue.js, and we don't use that anymore
/****************************************
Much of what you'll see below is just plain require()'ed, this is because
it is mostly jQuery stuff, which attaches itself to the $() function/object
So we don't have to assign it to anything, it will just automagically attach
itself
*****************************************/
require("jquery-ui/dist/jquery-ui")
jQuery.fn.uitooltip = jQuery.fn.tooltip;
require('bootstrap-less');
require('select2');
require('admin-lte');
require('tether');
require('jquery-slimscroll');
require('jquery.iframe-transport'); //probably not needed anymore, if I'm honest
require('blueimp-file-upload')
require('bootstrap-colorpicker')
// eonasdan-bootstrap-datetimepicker (BS3) needs moment on window before it loads
window.moment = require('moment')
require('eonasdan-bootstrap-datetimepicker')
require('ekko-lightbox') //TODO - this doesn't seem jquery-ish, we might need to do something weird here
// it *does* require Bootstrap, which requires jquery, so maybe that's OK
// it seems to work...
require('./extensions/pGenerator.jquery'); //WEIRD, but works
//require('chart.js') // Weirdly, this seems to "just work." Without this line, the dashboard blows up
// but it's *HUGE* - and we only use it one place. So we're taking it out of the bundle
window.SignaturePad = require('./signature_pad'); //ALSO WEIRD - but works
require('jquery-validation')
window.List = require('list.js')
window.ClipboardJS = require('clipboard')
// TODO - find everything using moment.js and kill it or upgrade it? It's huge
// - adminLTE (UGH)
// - bootstrap-daterangepicker
// - fullcalendar (what's that? it's used by AdminLTE)
/**
* Module containing core application logic.
* @param {jQuery} $ Insulated jQuery object
* @param {JSON} settings Insulated `window.snipeit.settings` object.
* @return {IIFE} Immediately invoked. Returns self.
*/
lineOptions = {
legend: {
position: "bottom"
},
scales: {
yAxes: [{
ticks: {
fontColor: "rgba(0,0,0,0.5)",
fontStyle: "bold",
beginAtZero: true,
maxTicksLimit: 5,
padding: 20
},
gridLines: {
drawTicks: false,
display: false
}
}],
xAxes: [{
gridLines: {
zeroLineColor: "transparent"
},
ticks: {
padding: 20,
fontColor: "rgba(0,0,0,0.5)",
fontStyle: "bold"
}
}]
}
};
pieOptions = {
//Boolean - Whether we should show a stroke on each segment
segmentShowStroke: true,
//String - The colour of each segment stroke
segmentStrokeColor: "#fff",
//Number - The width of each segment stroke
segmentStrokeWidth: 1,
//Number - The percentage of the chart that we cut out of the middle
percentageInnerCutout: 50, // This is 0 for Pie charts
//Number - Amount of animation steps
animationSteps: 100,
//String - Animation easing effect
animationEasing: "easeOutBounce",
//Boolean - Whether we animate the rotation of the Doughnut
animateRotate: true,
//Boolean - Whether we animate scaling the Doughnut from the centre
animateScale: false,
//Boolean - whether to make the chart responsive to window resizing
responsive: true,
// Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container
maintainAspectRatio: false,
//String - A legend template
legendTemplate: "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<segments.length; i++){%><li>" +
"<i class='fas fa-circle-o' style='color: <%=segments[i].fillColor%>'></i>" +
"<%if(segments[i].label){%><%=segments[i].label%><%}%> foo</li><%}%></ul>",
//String - A tooltip template
tooltipTemplate: "<%=value %> <%=label%> "
};
//-----------------
//- END PIE CHART -
//-----------------
var baseUrl = $('meta[name="baseUrl"]').attr('content');
$(function () {
var $el = $('body');
// confirm restore modal
$el.on('click', '.restore-asset', function (evnt) {
var $context = $(this);
var $restoreConfirmModal = $('#restoreConfirmModal');
var href = $context.attr('href');
var message = $context.attr('data-content');
var title = $context.attr('data-title');
$('#confirmModalLabel').text(title);
$restoreConfirmModal.find('.modal-body').text(message);
$('#restoreForm').attr('action', href);
$restoreConfirmModal.modal({
show: true
});
return false;
});
// Mark-a-maintenance-complete modal (green checkmark button in the
// maintenances table actions column). Sets the modal form's action to
// the row's completion URL and opens it.
$el.on('click', '.complete-maintenance', function () {
var url = $(this).data('url');
$('#completeMaintenanceForm').attr('action', url);
$('#completionNote').val('');
$('#completeMaintenanceModal').modal('show');
});
// Adjust-quantity modal (plus-minus button on accessory/consumable/
// component list and view pages). Sets the modal form's action from
// the trigger's data-adjust-url and populates the header + current
// quantity display from the trigger's data attributes. Clears the
// signed amount + note + order every open so nothing bleeds between
// clicks.
$el.on('click', '.adjust-quantity', function () {
var $btn = $(this);
var $modal = $('#adjustQuantityModal');
var $amount = $modal.find('#adjustQuantityAmount');
// data-available is the trigger's authoritative floor for a decrement
// (available = qty - currentlyInUseCount). A delta smaller than
// -available would decrement the on-hand qty below what's already
// checked out, and AdjustsQuantity::adjustQuantity throws
// DomainException. Mirror that server-side floor on the input's min
// attribute so the browser stepper refuses to go below and the
// constraint-validation message surfaces before submit.
var available = parseInt($btn.data('available'), 10);
$('#adjustQuantityForm').attr('action', $btn.data('adjust-url'));
$modal.find('.adjust-quantity-item-name').text($btn.data('item-name') || '');
$modal.find('.adjust-quantity-available').text(!isNaN(available) ? available : '');
if (!isNaN(available)) {
$amount.attr('min', -available);
} else {
$amount.removeAttr('min');
}
$amount.val('');
$modal.find('#adjustQuantityOrder').val('');
// Reset the acquisition-metadata fields between opens so an
// order left half-filled by one operator doesn't bleed into the
// next click. Supplier is a select2, so use .val('').trigger('change')
// rather than setting the raw <select>; currency reverts to
// whatever the modal was originally rendered with (its DOM value
// attribute, i.e. the system default_currency).
$modal.find('#adjustQuantitySupplier').val('').trigger('change');
// Reset purchase_date to today on every open (server-rendered
// default is today too). Prevents an operator's earlier
// backdate from bleeding into the next event.
var todayIso = new Date().toISOString().slice(0, 10);
$modal.find('#adjustQuantityPurchaseDate').val(todayIso);
// Pre-populate unit_cost + currency from the trigger's data-last-*
// attrs (server-rendered from the item's most recent OrderItem).
// When both are present the "pre-populated from last order" hint
// shows underneath the row; the hint gets hidden as soon as the
// operator edits either field so it disappears the moment they
// override the pre-fill.
var lastUnitCost = $btn.data('last-unit-cost');
var lastCurrency = $btn.data('last-currency');
var $unitCost = $modal.find('#adjustQuantityUnitCost');
var $currency = $modal.find('#adjustQuantityCurrency');
var $costHint = $modal.find('#adjustQuantityCostHint');
$unitCost.val(lastUnitCost !== undefined && lastUnitCost !== '' ? lastUnitCost : '');
$currency.val(lastCurrency !== undefined && lastCurrency !== '' ? lastCurrency : ($currency.prop('defaultValue') || ''));
if ((lastUnitCost !== undefined && lastUnitCost !== '') || (lastCurrency !== undefined && lastCurrency !== '')) {
$costHint.show();
} else {
$costHint.hide();
}
// Rebind on every open so multiple modal opens don't stack listeners.
$unitCost.off('input.adjustCostHint').on('input.adjustCostHint', function () { $costHint.hide(); });
$currency.off('input.adjustCostHint').on('input.adjustCostHint', function () { $costHint.hide(); });
$modal.find('#adjustQuantityNote').val('');
$modal.find('#adjustQuantityFile').val('');
// js-uploadFile paints selected filenames into #{id}-info; clear it too
// so stale filenames from a previous open don't linger in the new modal.
$modal.find('#adjustQuantityFile-info').empty();
// Acquisition-metadata fields (order number, supplier, unit cost,
// currency) only make sense when the qty change is a positive
// addition (a purchase). Zero or negative amounts represent
// corrections / consumption / losses, not acquisitions, so hide
// those fields — and blank their values so a submit from that
// state doesn't ship stale purchase metadata alongside the log
// entry. Show them again the moment the operator types a
// positive number. The date label swaps to a generic "Date"
// when the event isn't a purchase.
//
// On modal open the amount is empty ("we don't know yet"), so
// stay in the default acquisition-visible state — prefilled
// supplier / cost / currency from the last order are preserved
// and the hint stays visible if it was shown. We only clear
// when the operator actually commits to a 0/negative value.
var $acquisitionFields = $modal.find('#adjustQuantityAcquisitionFields');
var $costRow = $modal.find('#adjustQuantityCostRow');
var $dateLabel = $modal.find('#adjustQuantityPurchaseDateLabel');
var purchaseLabel = $dateLabel.data('label-purchase');
var genericLabel = $dateLabel.data('label-generic');
var syncAcquisitionFieldsVisibility = function () {
var raw = $amount.val();
// Treat "no value yet" as still-a-purchase for the visibility
// toggle: prefilled acquisition metadata stays intact until
// the operator explicitly types a non-positive number.
if (raw === '' || raw === null || raw === undefined) {
$acquisitionFields.show();
$costRow.show();
$dateLabel.text(purchaseLabel);
return;
}
var num = parseFloat(raw);
var isPurchase = !isNaN(num) && num > 0;
$acquisitionFields.toggle(isPurchase);
$costRow.toggle(isPurchase);
$costHint.toggle(isPurchase && $costHint.data('has-prefill') === true);
$dateLabel.text(isPurchase ? purchaseLabel : genericLabel);
if (!isPurchase) {
$modal.find('#adjustQuantityOrder').val('');
$modal.find('#adjustQuantitySupplier').val('').trigger('change');
$modal.find('#adjustQuantityUnitCost').val('');
$modal.find('#adjustQuantityCurrency').val('');
}
};
// Track the prefill state on the hint so the visibility toggle
// can restore it correctly when qty flips positive again.
$costHint.data('has-prefill', $costHint.is(':visible'));
$amount.off('input.adjustAcquisition').on('input.adjustAcquisition', syncAcquisitionFieldsVisibility);
// Initial call keeps everything in the default "purchase" state
// (empty amount) so the prefill logic above stays authoritative.
syncAcquisitionFieldsVisibility();
$modal.modal('show');
});
// confirm delete modal
$el.on('click', '.delete-asset', function (evnt) {
var $context = $(this);
var $dataConfirmModal = $('#dataConfirmModal');
// Anchors keep the URL in href; buttons keep it in data-href
// (buttons don't semantically support href per HTML5).
var href = $context.attr('data-href') || $context.attr('href');
var message = $context.attr('data-content');
var headericon = $context.attr('data-icon');
var title = $context.attr('data-title');
// deleteForm is the ID of the modal form itself
$('#deleteForm').attr('action', href);
$dataConfirmModal.find('.modal-header-icon').addClass(headericon);
$dataConfirmModal.find('.modal-title').text('').text(title).prepend('<i class="fa ' + headericon + '"></i> ');
$dataConfirmModal.find('.modal-body').text('').text(message);
$dataConfirmModal.attr('action', href);
// Fire the modal
$dataConfirmModal.modal({
show: true
});
return false;
});
/*
* Select2
*/
$('select.select2:not(".select2-hidden-accessible")').each(function (i,obj) {
{
$(obj).select2();
}
});
// $('.datepicker').datepicker();
// var datepicker = $.fn.datepicker.noConflict(); // return $.fn.datepicker to previously assigned value
// $.fn.bootstrapDP = datepicker;
// $('.datepicker').datepicker();
// Crazy select2 rich dropdowns with images!
$('.js-data-ajax').each( function (i,item) {
var link = $(item);
var endpoint = link.data("endpoint");
var select = link.data("select");
link.select2({
/**
* Adds an empty placeholder, allowing every select2 instance to be cleared.
* This placeholder can be overridden with the "data-placeholder" attribute.
*/
placeholder: '',
allowClear: true,
language: $('meta[name="language"]').attr('content'),
dir: $('meta[name="language-direction"]').attr('content'),
ajax: {
// the baseUrl includes a trailing slash
url: baseUrl + 'api/v1/' + endpoint + '/selectlist',
dataType: 'json',
delay: 250,
headers: {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
},
data: function (params) {
var data = {
search: params.term,
page: params.page || 1,
statusType: link.data("asset-status-type"),
companyId: link.data("company-ids") || link.data("company-id"),
excludeId: link.data("exclude-id"),
// When true, the companies selectlist marks child companies
// (those with a parent of their own) as disabled — used by
// the parent-company picker so users can't choose options
// that would fail the parent_must_be_top_level validator.
onlyTopLevel: link.data("only-top-level"),
};
return data;
},
/* processResults: function (data, params) {
params.page = params.page || 1;
var answer = {
results: data.items,
pagination: {
more: data.pagination.more
}
};
return answer;
}, */
cache: true
},
//escapeMarkup: function (markup) { return markup; }, // let our custom formatter work
templateResult: formatDatalistSafe,
//templateSelection: formatDataSelection
});
});
function getSelect2Value(element) {
// if the passed object is not a jquery object, assuming 'element' is a selector
if (!(element instanceof jQuery)) element = $(element);
var select = element.data("select2");
// There's two different locations where the select2-generated input element can be.
searchElement = select.dropdown.$search || select.$container.find(".select2-search__field");
var value = searchElement.val();
return value;
}
$(".select2-hidden-accessible").on('select2:selecting', function (e) {
var data = e.params.args.data;
var isMouseUp = false;
var element = $(this);
var value = getSelect2Value(element);
if(e.params.args.originalEvent) isMouseUp = e.params.args.originalEvent.type == "mouseup";
// if selected item does not match typed text, do not allow it to pass - force close for ajax.
if(!isMouseUp) {
if(value.toLowerCase() && data.text.toLowerCase().indexOf(value) < 0) {
e.preventDefault();
element.select2('close');
// if it does match, we set a flag in the event (which gets passed to subsequent events), telling it not to worry about the ajax
} else if(value.toLowerCase() && data.text.toLowerCase().indexOf(value) > -1) {
e.params.args.noForceAjax = true;
}
}
});
$(".select2-hidden-accessible").on('select2:closing', function (e) {
var element = $(this);
var value = getSelect2Value(element);
var noForceAjax = false;
var isMouseUp = false;
if(e.params.args.originalSelect2Event) noForceAjax = e.params.args.originalSelect2Event.noForceAjax;
if(e.params.args.originalEvent) isMouseUp = e.params.args.originalEvent.type == "mouseup";
if(value && !noForceAjax && !isMouseUp) {
var endpoint = element.data("endpoint");
var statusType = element.data("asset-status-type");
$.ajax({
url: baseUrl + 'api/v1/' + endpoint + '/selectlist?search=' + value + '&page=1' + (statusType ? '&statusType=' + statusType : ''),
dataType: 'json',
headers: {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
},
}).done(function(response) {
var currentlySelected = element.select2('data').map(function (x){
return +x.id;
}).filter(function (x) {
return x !== 0;
});
// makes sure we're not selecting the same thing twice for multiples
var filteredResponse = response.results.filter(function(item) {
return currentlySelected.indexOf(+item.id) < 0;
});
var first = (currentlySelected.length > 0) ? filteredResponse[0] : response.results[0];
if(first && first.id) {
first.selected = true;
if($("option[value='" + first.id + "']", element).length < 1) {
var option = new Option(first.text, first.id, true, true);
element.append(option);
} else {
var isMultiple = element.attr("multiple") == "multiple";
element.val(isMultiple? element.val().concat(first.id) : element.val(first.id));
}
element.trigger('change');
element.trigger({
type: 'select2:select',
params: {
data: first
}
});
}
});
}
});
function formatDatalist (datalist) {
var loading_markup = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> Loading...';
if (datalist.loading) {
return loading_markup;
}
var markup = '<div class="clearfix">' ;
markup += '<div class="pull-left" style="padding-right: 10px;">';
if (datalist.image) {
markup += "<div style='width: 30px;'><img src='" + datalist.image + "' style='max-height: 20px; max-width: 30px;' alt='" + datalist.text + "'></div>";
} else {
markup += '<div style="height: 20px; width: 30px;"></div>';
}
markup += "</div><div>" + datalist.text + "</div>";
markup += "</div>";
return markup;
}
function formatDatalistSafe(datalist) {
if (datalist.loading) {
return $('<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> Loading...');
}
var root_div = $("<div class='clearfix'>") ;
var left_pull = $("<div class='pull-left' style='padding-right: 10px;'>");
if (datalist.image) {
var inner_div = $("<div style='width: 20px;'>");
/******************************************************************
*
* We are specifically chosing empty alt-text below, because this
* image conveys no additional information, relative to the text
* that will *always* be there in any select2 list that is in use
* in Snipe-IT. If that changes, we would probably want to change
* some signatures of some functions, but right now, we don't want
* screen readers to say "HP SuperJet 5000, .... picture of HP
* SuperJet 5000..." and so on, for every single row in a list of
* assets or models or whatever.
*
*******************************************************************/
var img = $("<img src='' style='max-height: 20px; max-width: 20px;' alt=''>");
img.attr("src", datalist.image);
inner_div.append(img)
} else if (datalist.tag_color) {
var inner_div = $("<div style='width: 20px;'>");
var icon = $('<i class="fa-solid fa-square" style="font-size: 20px;" aria-hidden="true"></i>');
icon.css("color", datalist.tag_color );
inner_div.append(icon)
} else {
var inner_div=$("<div style='height: 20px; width: 20px;'></div>");
}
left_pull.append(inner_div);
root_div.append(left_pull);
var name_div = $("<div>");
name_div.text(datalist.text);
root_div.append(name_div)
var safe_html = root_div.get(0).outerHTML;
var old_html = formatDatalist(datalist);
if(safe_html != old_html) {
//console.log("HTML MISMATCH: ");
//console.log("FormatDatalistSafe: ");
// console.dir(root_div.get(0));
//console.log(safe_html);
//console.log("FormatDataList: ");
//console.log(old_html);
}
return root_div;
}
function formatDataSelection (datalist) {
// This a heinous workaround for a known bug in Select2.
// Without this, the rich selectlists are vulnerable to XSS.
// Many thanks to @uberbrady for this fix. It ain't pretty,
// but it resolves the issue until Select2 addresses it on their end.
//
// Bug was reported in 2016 :{
// https://github.com/select2/select2/issues/4587
return datalist.text.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// This handles the radio button selectors for the checkout-to-foo options
// on asset checkout and also on asset edit
$(function() {
var checkoutToTypeInputs = $('input[name=checkout_to_type]');
if (!checkoutToTypeInputs.length) {
return;
}
function syncCheckoutToTypeUi(resetSelections) {
var assignto_type = $('input[name=checkout_to_type]:checked').val();
var userid = $('#assigned_user option:selected').val();
if (assignto_type == 'asset') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').show();
$('#assigned_user').hide();
$('#assigned_location').hide();
$('.notification-callout').fadeOut();
if (resetSelections) {
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else if (assignto_type == 'location') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').hide();
$('#assigned_user').hide();
$('#assigned_location').show();
$('.notification-callout').fadeOut();
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else {
$('#assigned_asset').hide();
$('#assigned_user').show();
$('#assigned_location').hide();
if (userid) {
$('#current_assets_box').fadeIn();
}
$('.notification-callout').fadeIn();
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
}
}
}
checkoutToTypeInputs.on('change', function () {
syncCheckoutToTypeUi(true);
});
// Expose so pages that reveal #assignto_selector later (asset edit's
// user_add() flow, etc.) can trigger the sync once the selector is
// visible. Standalone checkout pages don't need to call this — the
// initial-render block below handles them.
window.snipeitSyncCheckoutToTypeUi = syncCheckoutToTypeUi;
// Apply the current radio selection on initial render unless the page
// has explicitly hidden the selector via an inline style="display:none"
// (asset create/edit start that way and reveal it from user_add() after
// a deployability AJAX call). Using getAttribute('style') instead of
// jQuery's :visible avoids false negatives on pages like the standalone
// /hardware/{id}/checkout, where the selector is visible from the start
// but :visible can transiently return false during select2 boot — that
// was what hid the acceptance-options callout until a radio was toggled.
var selectorStyle = ($('#assignto_selector').attr('style') || '').toLowerCase();
if (selectorStyle.indexOf('display:none') === -1 && selectorStyle.indexOf('display: none') === -1) {
syncCheckoutToTypeUi(false);
}
});
// ------------------------------------------------
// Deep linking for Bootstrap tabs
// ------------------------------------------------
var taburl = document.location.toString();
// Allow full page URL to activate a tab's ID
// ------------------------------------------------
// This allows linking to a tab on page load via the address bar.
// So a URL such as, http://snipe-it.local/hardware/2/#my_tab will
// cause the tab on that page with an ID of “my_tab” to be active.
if (taburl.match('#') ) {
$('.nav-tabs a[href="#'+taburl.split('#')[1]+'"]').tab('show');
}
// Allow internal page links to activate a tab's ID.
// ------------------------------------------------
// This allows you to link to a tab from anywhere on the page
// including from within another tab. Also note that internal page
// links either inside or out of the tabs need to include data-toggle="tab"
// Ex: <a href="#my_tab" data-toggle="tab">Click me</a>
$('a[data-toggle="tab"]').click(function (e) {
var href = $(this).attr("href");
history.pushState(null, null, href);
e.preventDefault();
$('a[href="' + $(this).attr('href') + '"]').tab('show');
});
// Tables inside a hidden tab pane initialize with a zero-width
// container, so their column widths and any sticky-column offsets
// computed from those widths never recover on their own once the
// pane becomes visible. Force a resetView on any snipe-tables
// inside the newly-shown pane so column widths + sticky offsets
// re-measure against the now-visible container.
$('body').on('shown.bs.tab', 'a[data-toggle="tab"]', function (e) {
var pane = $(e.target).attr('href');
if (!pane) return;
$(pane).find('.snipe-table').each(function () {
if ($(this).data('bootstrap.table')) {
$(this).bootstrapTable('resetView');
}
});
});
// Same story for viewport resizes: bootstrap-table caches column
// widths from the initial layout and doesn't recompute when the
// window width changes. Debounce so a drag-resize doesn't fire
// resetView on every intermediate pixel.
var snipeTableResizeTimer;
$(window).on('resize', function () {
clearTimeout(snipeTableResizeTimer);
snipeTableResizeTimer = setTimeout(function () {
$('.snipe-table').each(function () {
if ($(this).data('bootstrap.table')) {
$(this).bootstrapTable('resetView');
}
});
}, 150);
});
// ------------------------------------------------
// End Deep Linking for Bootstrap tabs
// ------------------------------------------------
// Image preview
function readURL(input, $preview) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function(e) {
$preview.attr('src', e.target.result);
};
reader.readAsDataURL(input.files[0]);
}
}
function formatBytes(bytes) {
if(bytes < 1024) return bytes + " Bytes";
else if(bytes < 1048576) return(bytes / 1024).toFixed(2) + " KB";
else if(bytes < 1073741824) return(bytes / 1048576).toFixed(2) + " MB";
else return(bytes / 1073741824).toFixed(2) + " GB";
}
// File size validation
$('.js-uploadFile').bind('change', function() {
var $this = $(this);
var id = '#' + $this.attr('id');
var status = id + '-status';
var $status = $(status);
var delete_id = $(id + '-deleteCheckbox');
var preview_container = $(id + '-previewContainer');
$status.removeClass('text-success').removeClass('text-danger');
$(status + ' .goodfile').remove();
$(status + ' .badfile').remove();
$(status + ' .previewSize').hide();
preview_container.hide();
$(id + '-info').html('');
var max_size = $this.data('maxsize');
var total_size = 0;
for (var i = 0; i < this.files.length; i++) {
total_size += this.files[i].size;
$(id + '-info').append('<span class="label label-default">' + htmlEntities(this.files[i].name) + ' (' + formatBytes(this.files[i].size) + ')</span> ');
}
if (total_size > max_size) {
$status.addClass('text-danger').removeClass('help-block').prepend('<i class="badfile fas fa-times"></i> ').append('<span class="previewSize"> Upload is ' + formatBytes(total_size) + '.</span>');
} else {
$status.addClass('text-success').removeClass('help-block').prepend('<i class="goodfile fas fa-check"></i> ');
var $preview = $(id + '-imagePreview');
readURL(this, $preview);
$preview.fadeIn();
preview_container.fadeIn();
delete_id.hide();
}
});
});
function htmlEntities(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Toggle disabled
*/
(function($){
$.fn.toggleDisabled = function(callback){
return this.each(function(){
var disabled, $this = $(this);
if($this.attr('disabled')){
$this.removeAttr('disabled');
disabled = false;
} else {
$this.attr('disabled', 'disabled');
disabled = true;
}
if(callback && typeof callback === 'function'){
callback(this, disabled);
}
});
};
})(jQuery);
$(document).ready(function () {
// Password-reveal eye. data-toggle is a jQuery selector — usually one
// input id, but a multi-selector like "#password, #password_confirm"
// lets a single click flip every matched input at once (the confirm
// field on the user create/edit form uses this so revealing the
// password reveals its confirmation too). Every .toggle-password
// sharing the same data-toggle string flips its icon together so the
// eye state doesn't visually drift between the two addons.
$(document).on('click', '.toggle-password', function () {
var toggleTarget = $(this).attr('data-toggle');
var $inputs = $(toggleTarget);
var reveal = $inputs.first().attr('type') === 'password';
$inputs.attr('type', reveal ? 'text' : 'password');
var $eyes = $('.toggle-password[data-toggle="' + toggleTarget + '"]');
$eyes.toggleClass('fa-eye', ! reveal);
$eyes.toggleClass('fa-eye-slash', reveal);
});
// Auto-init eonasdan datetimepickers. bootstrap-datepicker has a native
// data-provide auto-init; eonasdan does not, so we do it ourselves.
// Options are read from data-attributes on the wrapper so blade components
// can tune format/side-by-side without touching this JS.
//
// Icon set is overridden to Font Awesome — the picker defaults to
// Glyphicon classes, which we do not ship, so up/down arrows and clock
// glyphs would otherwise render as empty boxes.
// Exposed so callers who insert new [data-provide="datetimepicker"]
// wrappers into the DOM post-load (e.g., AJAX-loaded custom fields on
// asset create/edit when the model changes) can re-run the init on the
// freshly-inserted elements. Pass a jQuery scope to narrow the search;
// omit to init every uninitialised picker on the page.
window.snipeitInitDatetimepickers = function (scope) {
var $targets = scope ? $(scope).find('[data-provide="datetimepicker"]') : $('[data-provide="datetimepicker"]');
$targets.each(initDatetimepicker);
};
function initDatetimepicker() {
var $wrapper = $(this);
// Skip if this wrapper already has an eonasdan instance attached
// (data('DateTimePicker') is set by the library on init).
if ($wrapper.data('DateTimePicker')) {
return;
}
var $input = $wrapper.find('input');
var existingValue = ($input.val() || '').trim();
var options = {
format: $wrapper.data('format') || 'YYYY-MM-DD HH:mm:ss',
// Default to the compact (collapsed) view — calendar shows first
// and a small clock icon toggles the time view. Callers that want
// date + time visible side by side can set data-side-by-side="true".
sideBySide: $wrapper.data('side-by-side') === true,
showClear: true,
showClose: true,
showTodayButton: true,
// In sideBySide mode the toolbar row (Today/Clear/Close) is only
// rendered when placement is explicitly 'top' or 'bottom'; the
// library drops it entirely on the default 'default' placement.
toolbarPlacement: 'bottom',
// Open the popup on any focus/click of the input (not just the
// calendar addon icon), matching the behavior of the bootstrap
// datepicker used elsewhere in the app.
allowInputToggle: true,
locale: $wrapper.data('locale') || 'en',
icons: {
time: 'fa-regular fa-clock',
date: 'fa-regular fa-calendar',
up: 'fa-solid fa-chevron-up',
down: 'fa-solid fa-chevron-down',
previous: 'fa-solid fa-chevron-left',
next: 'fa-solid fa-chevron-right',
today: 'fa-solid fa-calendar-day',
clear: 'fa-solid fa-trash',
close: 'fa-solid fa-xmark',
},
};
// Pre-fill empty inputs with the user's current local datetime by
// default. Callers that render a picker where "now" is NOT a safe
// default (e.g., user-defined custom fields) can opt out by setting
// data-default-now="false" on the wrapper.
var wantsDefaultNow = $wrapper.data('default-now') !== false;
if (existingValue === '' && wantsDefaultNow) {
options.defaultDate = moment();
}
// data-max-date="today" caps the picker at today (replaces the
// bootstrap-datepicker era's data-date-end-date="0d"); any other
// value is parsed as a moment-compatible date string.
var maxDate = $wrapper.data('max-date');
if (maxDate) {
options.maxDate = maxDate === 'today' ? moment().endOf('day') : moment(maxDate);
}
$wrapper.datetimepicker(options);
}
// Wires up the linked-pickers pattern for <x-input.date-range>. Each
// .js-date-range wrapper holds a .js-date-range-start and .js-date-range-end
// datetimepicker; changing one bounds the other so a user can't pick an
// end date before the start (or vice versa). Runs after the plain
// datetimepicker init above so both instances already exist.
function initDateRangeLinking() {
$('.js-date-range').each(function () {
var $start = $(this).find('.js-date-range-start');
var $end = $(this).find('.js-date-range-end');
if (!$start.length || !$end.length) {
return;
}
$start.off('dp.change.snipeitDateRange').on('dp.change.snipeitDateRange', function (e) {
var picker = $end.data('DateTimePicker');
if (picker) {
picker.minDate(e.date);
}
});
$end.off('dp.change.snipeitDateRange').on('dp.change.snipeitDateRange', function (e) {
var picker = $start.data('DateTimePicker');
if (picker) {
picker.maxDate(e.date);
}
});
});
}
// Push the app's "week starts on" setting into moment's active locale so
// the eonasdan datetimepicker (which reads firstDayOfWeek from moment
// locale data, not from its own options) opens with the calendar column
// order the admin picked in Localization settings. Runs once before any
// picker is initialized; downstream code that formats using moment's w/W
// tokens will pick up the same value.
if (window.snipeit && window.snipeit.settings && typeof window.snipeit.settings.first_day_of_week === 'number') {
moment.updateLocale(moment.locale(), { week: { dow: window.snipeit.settings.first_day_of_week } });
}
window.snipeitInitDatetimepickers();
initDateRangeLinking();
});
/**
* Universal Livewire Select2 integration
*
* How to use:
*
* 1. Set the class of your select2 elements to 'livewire-select2').
* 2. Name your element to match a property in your Livewire component
* 3. Add an attribute called 'data-livewire-component' that points to $this->getId() (via `{{ }}` if you're in a blade,
* or just $this->getId() if not).
*/
document.addEventListener('livewire:init', () => {
$('.livewire-select2').select2()
$(document).on('select2:select', '.livewire-select2', function (event) {
var target = $(event.target)
if(!event.target.name || !target.data('livewire-component')) {
console.error("You need to set both name (which should match a Livewire property) and data-livewire-component on your Livewire-ed select2 elements!")
console.error("For data-livewire-component, you probably want to use $this->getId() or {{ $this->getId() }}, as appropriate")
return false
}
// PHP property names cannot start with a digit — skip bare numeric names (e.g. "0") that would cause a 500
if (/^\d+$/.test(event.target.name)) {
console.error("Livewire select2: name attribute '" + event.target.name + "' is not a valid Livewire property name — skipping")
return false
}
Livewire.find(target.data('livewire-component')).set(event.target.name, this.options[this.selectedIndex].value)
});
Livewire.interceptMessage(({ onFinish }) => {
onFinish(() => {
// Runs after DOM morph completes (or on error/cancel)
queueMicrotask(() => {
$(".livewire-select2").select2();
});
});
}
);
});
// Check/Uncheck all radio buttons in the permissions group
$('.header-row input:radio').change(function() {
value = $(this).attr('value');
area = $(this).data('checker-group');
$('.radiochecker-'+area+'[value='+value+']').prop('checked', true);
});
// Generic toggleable callouts with remember state
$(".remember-toggle").on("click",function(){
var toggleable_callout_id = $(this).attr('id');
var toggle_content_class = 'toggle-content-'+$(this).attr('id');
var toggle_arrow = '#toggle-arrow-' + toggleable_callout_id;
var toggle_cookie_name='toggle_state_'+toggleable_callout_id;
$('.'+toggle_content_class).fadeToggle(100);
$(toggle_arrow).toggleClass('fa-caret-right fa-caret-down');
var toggle_open = $(toggle_arrow).hasClass('fa-caret-down');
document.cookie=toggle_cookie_name+"="+toggle_open+';path=/';
});
var all_cookies = document.cookie.split(';')
for (var i in all_cookies) {
var trimmed_cookie = all_cookies[i].trim(' ')
elems = trimmed_cookie.split('=', 2);
// We have to do more here since we don't know the name of the selector
if (trimmed_cookie.startsWith('toggle_state_')) {
var toggle_selector_name = elems[0].replace('toggle_state_','');
if (elems[1] != "true") {
$('#'+toggle_selector_name+'.remember-toggle').trigger('click')
}
}
}
/**
* This handles the show/hide of superuser and admin specific permissions
* on the group edit and user edit pages
*/
if ($("#superuser_allow").is(':checked')) {
// Hide here instead of fadeout on pageload to prevent what looks like Flash Of Unstyled Content (FOUC)
$(".nonsuperuser").hide();
$(".nonsuperuser").attr('display','none');
}
$(".superuser").change(function() {
if ($(this).val() == '1') {
$(".nonsuperuser").fadeOut();
$(".nonsuperuser").attr('display','none');
$(".nonadmin").fadeOut();
$(".nonadmin").attr('display','none');
} else if ($(this).val() != '1') {
$(".nonsuperuser").fadeIn();
$(".nonsuperuser").attr('display','block');
// If the superuser button has been set to deny, we need to
// check that the admin button isn't set to allow, before we show non-admin stuff
if ($("#admin_allow").is(':checked')) {
// Hide here instead of fadeout on pageload to prevent what looks like Flash Of Unstyled Content (FOUC)
$(".nonadmin").hide();
$(".nonadmin").attr('display','none');
}
}
});
if ($("#admin_allow").is(':checked')) {
// Hide here instead of fadeout on pageload to prevent what looks like Flash Of Unstyled Content (FOUC)
$(".nonadmin").hide();
$(".nonadmin").attr('display','none');
}
$(".admin").change(function() {
if ($(this).val() == '1') {
$(".nonadmin").fadeOut();
$(".nonadmin").attr('display','none');
} else if ($(this).val() != '1') {
$(".nonadmin").fadeIn();
$(".nonadmin").attr('display','block');
}
});
// Handle the select/deselect of the select boxes with the button from right to left
$(function () {
function moveItems(origin, dest) {
$(origin).find(':selected').appendTo(dest);
$(dest).attr('selected', true);
$(dest).sort_select_box();
}
function moveAllItems(origin, dest) {
$(origin).children("option:visible").appendTo(dest);
$(dest).attr('selected', true);
$(dest).sort_select_box();
}
$('.left').on('click', function () {
var container = $(this).closest('.addremove-multiselect');
moveItems($(container).find('select.multiselect.selected'), $(container).find('select.multiselect.available'));
});
$('.right').on('click', function () {
var container = $(this).closest('.addremove-multiselect');
moveItems($(container).find('select.multiselect.available'), $(container).find('select.multiselect.selected'));
});
$('.leftall').on('click', function () {
var container = $(this).closest('.addremove-multiselect');
moveAllItems($(container).find('select.multiselect.selected'), $(container).find('select.multiselect.available'));
});
$('.rightall').on('click', function () {
var container = $(this).closest('.addremove-multiselect');
moveAllItems($(container).find('select.multiselect.available'), $(container).find('select.multiselect.selected'));
});
$('select.multiselect.selected').on('dblclick keyup',function(e){
if(e.which == 13 || e.type == 'dblclick') {
var container = $(this).closest('.addremove-multiselect');
moveItems($(container).find('select.multiselect.selected'), $(container).find('select.multiselect.available'));
}
});
$('select.multiselect.available').on('dblclick keyup',function(e){
if(e.which == 13 || e.type == 'dblclick') {
var container = $(this).closest('.addremove-multiselect');
moveItems($(container).find('select.multiselect.available'), $(container).find('select.multiselect.selected'));
$('#hidden_ids_box').val($('#selected-select').val());
}
});
});
$.fn.sort_select_box = function(){
// Get options from select box
var selected_options = $(this).children('option');
// sort alphabetically
selected_options.sort(function(a,b) {
if (a.text > b.text) return 1;
else if (a.text < b.text) return -1;
else return 0
})
//replace with sorted my_options;
$(this).empty().append(selected_options);
var selected_in_box = $('#selected-select option').toArray().map(item => item.value).join();
$('#hidden_ids_box').empty().val(selected_in_box);
$('#count_selected_box').html($('#selected-select option').length);
$('#count_unselected_box').html($('#available-select option').length);
// clearing any selections
$("#"+this.attr('id')+" option").attr('selected', true);
}
/*
* Data-attribute driven initializers. Blades attach behavior by adding
* `data-toggle="..."` (plus supporting data-* attributes) to elements
* instead of shipping an inline <script> block. Add new handlers here
* as inline scripts get migrated out of blades.
*/
$(function () {
// Sound preview on account/profile. Fires the URL in data-sound-url
// when the user toggles the checkbox on.
$(document).on('click', '[data-toggle="sound-test"]', function () {
if (!$(this).is(':checked')) return;
var url = $(this).data('sound-url');
if (!url) return;
new Audio(url).play();
});
// Confetti preview on account/profile. Same shape as sound-test.
$(document).on('click', '[data-toggle="confetti-test"]', function () {
if (!$(this).is(':checked')) return;
var duration = 1500;
var animationEnd = Date.now() + duration;
var defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
var interval = setInterval(function () {
var timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
var particleCount = 50 * (timeLeft / duration);
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 250);
});
// Live color preview for the nav-link colorpicker on account/profile.
// The colorpicker widget itself is initialized by $(".color").colorpicker()
// in the default layout; this just wires the changeColor listener.
if ($('#nav-link-color').length) {
$('#nav-link-color').on('changeColor', function (e) {
var color = e.color.toString('rgba');
$('.navbar-nav > li > a:link').attr('style', 'color: ' + color + ' !important');
$('.btn-theme').attr('style', 'color: ' + color + ' !important');
});
}
// Branding settings page: live preview + reset for the four tenant
// colorpickers. The pickers themselves are initialized by the global
// $(".color").colorpicker() call in the default layout; this only wires
// the changeColor listeners and the reset button. Guarded on the reset
// button ID so it only runs on Settings > Branding, not on every page
// that happens to have a #header-color or #nav-link-color widget.
//
// Only header + nav-link get live preview. Link light/dark previews are
// deliberately skipped: they would recolor the buttons and other UI on
// this form itself, which can make it unreadable if the operator picks
// a low-contrast color. Those two settings just save and take effect on
// reload.
if (document.getElementById('branding-colors-reset')) {
var BRANDING_DEFAULTS = {
header_color: '#3c8dbc',
nav_link_color: '#ffffff',
link_light_color: '#296282',
link_dark_color: '#5fa4cc',
};
// Live preview works by writing the tenant CSS variables inline on
// <html>. Inline element style beats the :root and [data-theme]
// declarations in overrides.less on specificity, so this works even
// for the many rules those declarations lock in with !important.
var applyBrandingHeader = function (color) {
document.documentElement.style.setProperty('--main-theme-color', color);
};
var applyBrandingNavLink = function (color) {
document.documentElement.style.setProperty('--btn-theme-text-color', color);
document.documentElement.style.setProperty('--nav-hover-text-color', color);
document.documentElement.style.setProperty('--nav-primary-text-color', color);
};
$('#header-color').on('changeColor', function (e) {
applyBrandingHeader(e.color.toString('rgba'));
});
$('#nav-link-color').on('changeColor', function (e) {
applyBrandingNavLink(e.color.toString('rgba'));
});
// Reset: restore each picker's swatch and input to the stock
// defaults. Using setValue on the plugin (not just .val() on the
// input) fires the plugin's internal changeColor event, which
// re-runs applyBrandingHeader / applyBrandingNavLink automatically.
$('#branding-colors-reset').on('click', function () {
$('#header-color').colorpicker('setValue', BRANDING_DEFAULTS.header_color);
$('#nav-link-color').colorpicker('setValue', BRANDING_DEFAULTS.nav_link_color);
$('#link-light-color').colorpicker('setValue', BRANDING_DEFAULTS.link_light_color);
$('#link-dark-color').colorpicker('setValue', BRANDING_DEFAULTS.link_dark_color);
});
}
// Reset the localStorage theme override when the user clicks the
// "system default" link (any element carrying data-theme-toggle-clear).
document.querySelectorAll('[data-theme-toggle-clear]').forEach(function (el) {
el.addEventListener('click', function () {
localStorage.removeItem('theme');
});
});
// Master checkbox → target field disabled state. Callers pair a
// <input type="checkbox" data-toggle="disable-when-unchecked"
// data-disable-target="#some-field"> with a target rendered
// server-side with the matching @disabled state (avoids FOUC).
// Handler keeps them in sync on change.
$(document).on('change', '[data-toggle="disable-when-unchecked"]', function () {
var target = $(this).data('disable-target');
if (target) {
$(target).prop('disabled', !$(this).is(':checked'));
}
});
// Disable empty REQUIRED inputs on submit so browser HTML5 validation
// doesn't block the request before Laravel's form-request validator
// gets a chance to return a nicer error. Non-required empties (like a
// "Do not change" select with an explicit value="" option) are left
// enabled so they submit their intentional empty value. Opt in per
// form with data-disable-empty-on-submit.
$(document).on('submit', 'form[data-disable-empty-on-submit]', function () {
$(this).find(':input[required]').filter(function () { return !this.value; }).attr('disabled', 'disabled');
});
// Master checkbox → toggle every non-disabled checkbox in the closest
// form or table (or a caller-specified selector via data-check-scope).
// Used by bulk-delete confirmation pages to select or deselect the
// whole list of rows at once.
$(document).on('change', '[data-toggle="check-all"]', function () {
var $master = $(this);
var scope = $master.data('check-scope');
var $container = scope ? $(scope) : $master.closest('form, table');
$container.find('input[type="checkbox"]').not($master).not(':disabled').prop('checked', $master.prop('checked'));
});
// When the "This user can login" (activated) checkbox is off, the
// password + confirmation fields are functionally useless because
// login is gated by the activated flag. Hide the whole form-group
// (or dynamic-form-row in the modal) so the form doesn't show
// fields the user can't meaningfully fill in, and also drop the
// HTML `required` attribute so the browser doesn't block submission.
// The server side already skips the password rule for this case
// via SaveUserRequest::rules(), and the controller stores
// User::noPassword() raw so no Hash::check can ever match.
// Applies to both the main users/edit create form and the
// users/modal form since they share the input names.
//
// Required-state preservation: the server renders password/password_
// confirmation with `required` only on create (see users/edit.blade.php
// and modals/user.blade.php). We cache that server-rendered state on
// the first call so subsequent activated-toggles only ever re-apply
// the ORIGINAL server intent — otherwise editing an existing
// (activated) user would silently flip password to required on page
// load and jQuery Validate would block Save with the password empty.
var syncPasswordFields = function ($checkbox) {
var $form = $checkbox.closest('form');
var $passwords = $form.find(
'input[name="password"], input[name="password_confirmation"]'
);
var activated = $checkbox.is(':checked');
$passwords.each(function () {
if (this.dataset.serverRequired === undefined) {
this.dataset.serverRequired = this.required ? '1' : '0';
}
this.required = activated && this.dataset.serverRequired === '1';
var $wrap = $(this).closest('.form-group, .dynamic-form-row');
if (activated) {
$wrap.show();
} else {
$wrap.hide();
}
});
};
// Sensitive fields (username, email, password) ship with a
// `readonly` + onfocus-removes-readonly anti-autofill trick to
// stop password managers from prefilling or overwriting the
// operator's own login credentials on user-create forms. The
// side-effect is that HTML5 `required` constraint validation is
// SILENTLY skipped for readonly inputs, so hitting submit without
// ever focusing a required field lets the empty form through the
// browser check entirely.
//
// On submit-button click we strip `readonly` from any
// required+readonly input inside the form. The browser then runs
// its normal constraint check (all fields participating) and
// shows the "please fill in this field" popup on empties. Autofill
// was already prevented at page load, so removing readonly at
// click time doesn't reopen that hole.
$(document).on('click', 'button[type="submit"], input[type="submit"]', function () {
var $form = $(this).closest('form');
if (! $form.length) {
return;
}
$form.find('input[required][readonly]').each(function () {
this.removeAttribute('readonly');
});
});
$('input[name="activated"][type="checkbox"]').each(function () {
syncPasswordFields($(this));
});
$(document).on('change', 'input[name="activated"][type="checkbox"]', function () {
syncPasswordFields($(this));
});
// Generic "typing into input A enables checkbox B" pattern. Server
// marks the input with data-toggles-checkbox="{selector-of-target}".
// Threshold is 6 chars, which matches the legacy user-create
// behaviour of only enabling the send-welcome checkbox once the
// email is plausibly valid. Server omits the data-attribute when
// the enable-side should never fire (e.g. app.lock_passwords is on)
// so the target stays permanently disabled.
$(document).on('keyup', 'input[data-toggles-checkbox]', function () {
var $target = $($(this).data('toggles-checkbox'));
if (! $target.length) {
return;
}
if (this.value.length > 5) {
$target.prop('disabled', false);
$target.closest('.form-control').removeClass('form-control--disabled');
} else {
$target.prop('disabled', true).prop('checked', false);
$target.closest('.form-control').addClass('form-control--disabled');
}
});
// Bootstrap tooltips on any element carrying .tooltip-base.
// Attaching to body avoids clipping inside overflow-hidden panels.
$('.tooltip-base').tooltip({ container: 'body' });
// Password generator button. Server puts the desired length on the
// button as data-password-length (typically pwd_secure_min + 9) so
// this JS doesn't have to know about app settings. Falls back to 16
// if the attribute is missing.
$('a[id="genPassword"], button[id="genPassword"]').each(function () {
var $btn = $(this);
if (typeof $btn.pGenerator !== 'function' || ! $('#password').length) {
return;
}
$btn.pGenerator({
bind: 'click',
passwordElement: '#password',
passwordLength: parseInt($btn.data('password-length') || '16', 10),
uppercase: true,
lowercase: true,
numbers: true,
specialChars: true,
onPasswordGenerated: function () {
$('#password_confirm').val($('#password').val());
},
});
});
// A <select data-gates-submit> disables the submit button(s) in its
// form until a value is chosen. Used by users/confirm-bulk-delete
// where the operator must pick a status for the deleted users' assets
// before the form can be submitted. Runs once on load to reflect
// whatever value was pre-selected (old input after a validation
// redirect) and re-syncs on change and on select2's own event.
$('select[data-gates-submit]').each(function () {
var $select = $(this);
var $submits = $select.closest('form').find(':submit');
var sync = function () {
$submits.prop('disabled', ! $select.val());
};
sync();
$select.on('change select2:select', sync);
});
// Auto-focus the first select2 search input on pages that ask for it.
// Bulk-checkout uses this so the operator lands directly on the
// assets-to-checkout picker and can start typing immediately. Results
// are hidden until the first keystroke so the operator doesn't see a
// full-list flash on open.
if ($('[data-autofocus-select2-search]').length) {
setTimeout(function () {
var $searchField = $('.select2-search__field');
var $results = $('.select2-results');
$searchField.focus();
$results.hide();
$searchField.on('input', function () {
$results.show();
});
}, 0);
}
// Hardware bulk edit: clear-radio buttons blank every input of a
// named radio group so the caller can back out of a picked value.
// The .clear-radio button carries a data-target-name matching the
// radio group's name attribute.
document.querySelectorAll('.clear-radio').forEach(function (button) {
button.addEventListener('click', function () {
var name = this.dataset.targetName;
var radios = document.querySelectorAll('input[type="radio"][name="' + name + '"]');
radios.forEach(function (radio) { radio.checked = false; });
});
});
// Hardware bulk edit: live status-deployable check. When the user
// picks a status, hit the deployable API and update the inline
// status indicator so the operator knows whether that status will
// pull an asset out of active service. Translated labels ride on
// the element's data attributes so this handler doesn't need to be
// a Blade-compiled inline script. Guarded on the data-deployable-
// label attribute (not just the id) because #selected_status_status
// also appears in partials/forms/edit/status.blade.php — used by
// hardware/edit — which has its own inline user_add() handler and
// doesn't render the labels, so we'd otherwise double-fire and
// overwrite that handler's output with an icon-only string.
var statusStatusEl = document.getElementById('selected_status_status');
if (statusStatusEl && statusStatusEl.dataset.deployableLabel) {
var deployableLabel = statusStatusEl.dataset.deployableLabel || '';
var notDeployableLabel = statusStatusEl.dataset.notDeployableLabel || '';
var runStatusDeployableCheck = function () {
var statusId = $('select[name="status_id"]').val();
if (statusId === '') {
return;
}
$('.status_spinner').css('display', 'inline');
$.ajax({
url: '/api/v1/statuslabels/' + statusId + '/deployable',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content'),
},
success: function (data) {
$('.status_spinner').css('display', 'none');
$('#selected_status_status').fadeIn();
if (data == true) {
$('#selected_status_status')
.removeClass('text-danger')
.addClass('text-success')
.html('<i class="fa-solid fa-check" aria-hidden="true"></i> ' + deployableLabel);
} else {
$('#assignto_selector').hide();
$('#selected_status_status')
.removeClass('text-success')
.addClass('text-danger')
.html('<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> ' + notDeployableLabel);
}
},
});
};
$('select[name="status_id"]').on('change', runStatusDeployableCheck);
}
// Hardware checkin: requestable-toggle wrapper. Show or hide the
// "make this asset requestable after checkin" checkbox depending on
// whether the currently-selected status is deployable. Preserve the
// checkbox state when hiding so a status bounce doesn't blow it
// away — the server only applies the value when the status is
// deployable anyway.
var requestableWrapper = document.getElementById('requestable-wrapper');
if (requestableWrapper) {
var deployableStatusIds = [];
try {
deployableStatusIds = JSON.parse(requestableWrapper.dataset.deployableStatusIds || '[]');
} catch (e) {
// Malformed data — leave the wrapper in its server-rendered state.
}
var statusSelect = document.getElementById('modal-statuslabel_types')
|| document.querySelector('select[name="status_id"]');
if (statusSelect) {
var toggleRequestableWrapper = function () {
var value = statusSelect.value;
var statusId = Number.parseInt(value, 10);
var isDeployable = value !== ''
&& Number.isInteger(statusId)
&& deployableStatusIds.indexOf(statusId) !== -1;
requestableWrapper.style.display = isDeployable ? '' : 'none';
};
statusSelect.addEventListener('change', toggleRequestableWrapper);
if (window.jQuery) {
window.jQuery(statusSelect).on('select2:select select2:clear', toggleRequestableWrapper);
}
toggleRequestableWrapper();
}
}
// Hardware checkin: per-user localStorage preference for the
// requestable-checkbox default. Namespaced by user id so a shared
// browser doesn't leak one user's habit to another. Bypassed when
// the checkbox was repopulated from a validation-error redirect —
// old() beats the stored preference. On submit, save whatever the
// user actually chose so the preference tracks their real habit.
var requestableCheckbox = document.getElementById('requestable');
if (requestableCheckbox && requestableCheckbox.dataset.userPreferenceKey) {
var storageKey = requestableCheckbox.dataset.userPreferenceKey;
var hadOldInput = requestableCheckbox.dataset.hadOldInput === '1';
var form = requestableCheckbox.closest('form');
if (form) {
if (!hadOldInput) {
var stored = null;
try {
stored = window.localStorage.getItem(storageKey);
} catch (e) {
// localStorage may be unavailable (private mode, disabled).
}
if (stored === '1' || stored === '0') {
requestableCheckbox.checked = stored === '1';
}
}
form.addEventListener('submit', function () {
try {
window.localStorage.setItem(storageKey, requestableCheckbox.checked ? '1' : '0');
} catch (e) {
// Non-fatal: preference just won't persist this time.
}
});
}
}
});