Use stimulus for parts tables and select actions.

This commit is contained in:
Jan Böhmer 2022-08-01 00:31:49 +02:00
parent 565cb3a790
commit 452f0a8362
9 changed files with 1369 additions and 1053 deletions

View file

@ -0,0 +1,145 @@
import {Controller} from "@hotwired/stimulus";
//Styles
import 'datatables.net-bs5/css/dataTables.bootstrap5.css'
import 'datatables.net-buttons-bs5/css/buttons.bootstrap5.css'
import 'datatables.net-fixedheader-bs5/css/fixedHeader.bootstrap5.css'
import 'datatables.net-select-bs5/css/select.bootstrap5.css'
import 'datatables.net-responsive-bs5/css/responsive.bootstrap5.css';
//JS
import 'datatables.net-bs5';
import 'datatables.net-buttons-bs5';
import 'datatables.net-buttons/js/buttons.colVis.js';
import 'datatables.net-fixedheader-bs5';
import 'datatables.net-select-bs5';
import 'datatables.net-colreorder-bs5';
import 'datatables.net-responsive-bs5';
import '../../../js/lib/datatables';
const EVENT_DT_LOADED = 'dt:loaded';
export default class extends Controller {
static targets = ['dt'];
/** The datatable instance associated with this controller instance */
_dt;
connect() {
//$($.fn.DataTable.tables()).DataTable().fixedHeader.disable();
//$($.fn.DataTable.tables()).DataTable().destroy();
const settings = JSON.parse(this.element.dataset.dtSettings);
if(!settings) {
throw new Error("No settings provided for datatable!");
}
//Add url info, as the one available in the history is not enough, as Turbo may have not changed it yet
settings.url = this.element.dataset.dtUrl;
//@ts-ignore
const promise = $(this.dtTarget).initDataTables(settings,
{
colReorder: true,
responsive: true,
fixedHeader: {
header: $(window).width() >= 768, //Only enable fixedHeaders on devices with big screen. Fixes scrolling issues on smartphones.
headerOffset: $("#navbar").height()
},
buttons: [{
"extend": 'colvis',
'className': 'mr-2 btn-light',
"text": "<i class='fa fa-cog'></i>"
}],
select: this.isSelectable(),
rowCallback: this._rowCallback.bind(this),
})
//Register error handler
.catch(err => {
console.error("Error initializing datatables: " + err);
});
//Dispatch an event to let others know that the datatables has been loaded
promise.then((dt) => {
const event = new CustomEvent(EVENT_DT_LOADED, {bubbles: true});
this.element.dispatchEvent(event);
this._dt = dt;
});
//Register event handlers
promise.then((dt) => {
dt.on('select.dt deselect.dt', this._onSelectionChange.bind(this));
});
//Allow to further configure the datatable
promise.then(this._afterLoaded.bind(this));
//Register links.
/*promise.then(function() {
//Set the correct title in the table.
let title = $('#part-card-header-src');
$('#part-card-header').html(title.html());
$(document).trigger('ajaxUI:dt_loaded');
if($table.data('part_table')) {
//@ts-ignore
$('#dt').on( 'select.dt deselect.dt', function ( e, dt, items ) {
let selected_elements = dt.rows({selected: true});
let count = selected_elements.count();
if(count > 0) {
$('#select_panel').removeClass('d-none');
} else {
$('#select_panel').addClass('d-none');
}
$('#select_count').text(count);
let selected_ids_string = selected_elements.data().map(function(value, index) {
return value['id']; }
).join(",");
$('#select_ids').val(selected_ids_string);
} );
}
//Attach event listener to update links after new page selection:
$('#dt').on('draw.dt column-visibility.dt', function() {
//ajaxUI.registerLinks();
$(document).trigger('ajaxUI:dt_loaded');
});
});*/
console.debug('Datatables inited.');
}
_rowCallback(row, data, index) {
//Empty by default but can be overridden by child classes
}
_onSelectionChange(e, dt, items ) {
//Empty by default but can be overridden by child classes
alert("Test");
}
_afterLoaded(dt) {
//Empty by default but can be overridden by child classes
}
/**
* Check if this datatable has selection feature enabled
*/
isSelectable()
{
return this.element.dataset.select ?? false;
}
}

View file

@ -0,0 +1,32 @@
import DatatablesController from "./datatables_controller.js";
/**
* This is the datatables controller for log pages, it includes an mechanism to color lines based on their level.
*/
export default class extends DatatablesController {
_rowCallback(row, data, index) {
//Check if we have a level, then change color of this row
if (data.level) {
let style = "";
switch (data.level) {
case "emergency":
case "alert":
case "critical":
case "error":
style = "table-danger";
break;
case "warning":
style = "table-warning";
break;
case "notice":
style = "table-info";
break;
}
if (style) {
$(row).addClass(style);
}
}
}
}

View file

@ -0,0 +1,83 @@
import DatatablesController from "./datatables_controller.js";
/**
* This is the datatables controller for parts lists
*/
export default class extends DatatablesController {
static targets = ['dt', 'selectPanel', 'selectIDs', 'selectCount', 'selectTargetPicker'];
isSelectable() {
//Parts controller is always selectable
return true;
}
_onSelectionChange(e, dt, items) {
const selected_elements = dt.rows({selected: true});
const count = selected_elements.count();
const selectPanel = this.selectPanelTarget;
//Hide/Unhide panel with the selection tools
if (count > 0) {
selectPanel.classList.remove('d-none');
} else {
selectPanel.classList.add('d-none');
}
//Update selection count text
this.selectCountTarget.innerText = count;
//Fill selection ID input
let selected_ids_string = selected_elements.data().map(function(value, index) {
return value['id']; }
).join(",");
this.selectIDsTarget.value = selected_ids_string;
}
updateOptions(select_element, json)
{
//Clear options
select_element.innerHTML = null;
$(select_element).selectpicker('destroy');
for(let i=0; i<json.length; i++) {
let json_opt = json[i];
let opt = document.createElement('option');
opt.value = json_opt.value;
opt.innerHTML = json_opt.text;
if(json_opt['data-subtext']) {
opt.dataset.subtext = json_opt['data-subtext'];
}
select_element.appendChild(opt);
}
$(select_element).selectpicker('show');
}
updateTargetPicker(event) {
const element = event.target;
//Extract the url from the selected option
const selected_option = element.options[element.options.selectedIndex];
const url = selected_option.dataset.url;
const select_target = this.selectTargetPickerTarget;
if (url) {
fetch(url)
.then(response => {
response.json().then(json => {
this.updateOptions(select_target, json);
});
});
} else {
$(select_target).selectpicker('hide');
}
}
}

View file

@ -43,7 +43,7 @@
return new Promise((fulfill, reject) => { return new Promise((fulfill, reject) => {
// Perform initial load // Perform initial load
$.ajax(config.url, { $.ajax(typeof config.url === 'function' ? config.url(null) : config.url, {
method: config.method, method: config.method,
data: { data: {
_dt: config.name, _dt: config.name,
@ -53,15 +53,17 @@
var baseState; var baseState;
// Merge all options from different sources together and add the Ajax loader // Merge all options from different sources together and add the Ajax loader
var dtOpts = $.extend({}, data.options, config.options, options, persistOptions, { var dtOpts = $.extend({}, data.options, typeof config.options === 'function' ? {} : config.options, options, persistOptions, {
ajax: function (request, drawCallback, settings) { ajax: function (request, drawCallback, settings) {
if (data) { if (data) {
data.draw = request.draw; data.draw = request.draw;
drawCallback(data); drawCallback(data);
data = null; data = null;
if (Object.keys(state).length && dt.state != null) { if (Object.keys(state).length) {
var merged = $.extend(true, {}, dt.state(), state); var api = new $.fn.dataTable.Api( settings );
dt var merged = $.extend(true, {}, api.state(), state);
api
.order(merged.order) .order(merged.order)
.search(merged.search.search) .search(merged.search.search)
.page.len(merged.length) .page.len(merged.length)
@ -70,7 +72,7 @@
} }
} else { } else {
request._dt = config.name; request._dt = config.name;
$.ajax(config.url, { $.ajax(typeof config.url === 'function' ? config.url(dt) : config.url, {
method: config.method, method: config.method,
data: request data: request
}).done(function(data) { }).done(function(data) {
@ -80,6 +82,10 @@
} }
}); });
if (typeof config.options === 'function') {
dtOpts = config.options(dtOpts);
}
root.html(data.template); root.html(data.template);
dt = $('table', root).DataTable(dtOpts); dt = $('table', root).DataTable(dtOpts);
if (config.state !== 'none') { if (config.state !== 'none') {
@ -122,6 +128,80 @@
url: window.location.origin + window.location.pathname url: window.location.origin + window.location.pathname
}; };
/**
* Server-side export.
*/
$.fn.initDataTables.exportBtnAction = function(exporterName, settings) {
settings = $.extend({}, $.fn.initDataTables.defaults, settings);
return function(e, dt) {
const params = $.param($.extend({}, dt.ajax.params(), {'_dt': settings.name, '_exporter': exporterName}));
// Credit: https://stackoverflow.com/a/23797348
const xhr = new XMLHttpRequest();
xhr.open(settings.method, settings.method === 'GET' ? (settings.url + '?' + params) : settings.url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
if (this.status === 200) {
let filename = "";
const disposition = xhr.getResponseHeader('Content-Disposition');
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
const matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
}
}
const type = xhr.getResponseHeader('Content-Type');
let blob;
if (typeof File === 'function') {
try {
blob = new File([this.response], filename, { type: type });
} catch (e) { /* Edge */ }
}
if (typeof blob === 'undefined') {
blob = new Blob([this.response], { type: type });
}
if (typeof window.navigator.msSaveBlob !== 'undefined') {
// IE workaround for "HTML7007: One or more blob URLs were revoked by closing the blob for which they were created. These URLs will no longer resolve as the data backing the URL has been freed."
window.navigator.msSaveBlob(blob, filename);
}
else {
const URL = window.URL || window.webkitURL;
const downloadUrl = URL.createObjectURL(blob);
if (filename) {
// use HTML5 a[download] attribute to specify filename
const a = document.createElement("a");
// safari doesn't support this yet
if (typeof a.download === 'undefined') {
window.location = downloadUrl;
}
else {
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
}
}
else {
window.location = downloadUrl;
}
setTimeout(function() { URL.revokeObjectURL(downloadUrl); }, 100); // cleanup
}
}
};
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send(settings.method === 'POST' ? params : null);
}
};
/** /**
* Convert a querystring to a proper array - reverses $.param * Convert a querystring to a proper array - reverses $.param
*/ */
@ -182,4 +262,4 @@
return obj; return obj;
} }
}($)); }(jQuery));

View file

@ -1,16 +1,11 @@
{% import "components/datatables.macro.html.twig" as datatables %}
<form method="post" action="{{ url("log_undo") }}" <form method="post" action="{{ url("log_undo") }}"
{{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }} {{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
data-delete-title="{% trans %}log.undo.confirm_title{% endtrans %}" data-delete-title="{% trans %}log.undo.confirm_title{% endtrans %}"
data-delete-message="{% trans %}log.undo.confirm_message{% endtrans %}"> data-delete-message="{% trans %}log.undo.confirm_message{% endtrans %}">
<input type="hidden" name="redirect_back" value="{{ app.request.uri }}"> <input type="hidden" name="redirect_back" value="{{ app.request.uri }}">
<div id="part_list" class="" data-datatable data-settings='{{ datatable_settings(datatable) }}'>
<div class="card-body"> {{ datatables.logDataTable(datatable) }}
<div class="card">
<div class="card-body">
<h4>{% trans %}part_list.loading.caption{% endtrans %}</h4>
<h6>{% trans %}part_list.loading.message{% endtrans %}</h6>
</div>
</div>
</div>
</div>
</form> </form>

View file

@ -1,90 +1,3 @@
<form method="post" action="{{ url("table_action") }}"> {% import "components/datatables.macro.html.twig" as datatables %}
<input type="hidden" name="_token" value="{{ csrf_token('table_action') }}">
<input type="hidden" name="redirect_back" value="{{ app.request.uri }}"> {{ datatables.partsDatatableWithForm(datatable) }}
<input type="hidden" name="ids" id="select_ids" value="">
<div class="d-none mb-2" id="select_panel">
{# <span id="select_count"></span> #}
<span class="badge bg-secondary">{% trans with {'%count%': '<span id="select_count"></span>'} %}part_list.action.part_count{% endtrans %}</span>
<select class="selectpicker" name="action" id="select_action" data-controller="elements--selectpicker"
title="{% trans %}part_list.action.action.title{% endtrans %}" onchange="updateTargetSelect()" required>
<optgroup label="{% trans %}part_list.action.action.group.favorite{% endtrans %}">
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="favorite">{% trans %}part_list.action.action.favorite{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="unfavorite">{% trans %}part_list.action.action.unfavorite{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.action.group.change_field{% endtrans %}">
<option {% if not is_granted('@parts_category.edit') %}disabled{% endif %} value="change_category" data-url="{{ path('select_category') }}">{% trans %}part_list.action.action.change_category{% endtrans %}</option>
<option {% if not is_granted('@parts_footprint.edit') %}disabled{% endif %} value="change_footprint" data-url="{{ path('select_footprint') }}">{% trans %}part_list.action.action.change_footprint{% endtrans %}</option>
<option {% if not is_granted('@parts_manufacturer.edit') %}disabled{% endif %} value="change_manufacturer" data-url="{{ path('select_manufacturer') }}">{% trans %}part_list.action.action.change_manufacturer{% endtrans %}</option>
<option {% if not is_granted('@parts_unit.edit') %}disabled{% endif %} value="change_unit" data-url="{{ path('select_measurement_unit') }}">{% trans %}part_list.action.action.change_unit{% endtrans %}</option>
</optgroup>
<option {% if not is_granted('@parts.delete') %}disabled{% endif %} value="delete">{% trans %}part_list.action.action.delete{% endtrans %}</option>
</select>
<select class="" style="display: none;" data-live-search="true" name="target" id="select_target">
{# This is left empty, as this will be filled by Javascript #}
</select>
<button type="submit" class="btn btn-secondary">{% trans %}part_list.action.submit{% endtrans %}</button>
</div>
<div id="part_list" class="" data-select="true" data-part_table="true" data-datatable data-settings='{{ datatable_settings(datatable)|escape('html_attr') }}'>
<div class="card-body">
<div class="card">
<div class="card-body">
<h4>{% trans %}part_list.loading.caption{% endtrans %}</h4>
<h6>{% trans %}part_list.loading.message{% endtrans %}</h6>
</div>
</div>
</div>
</div>
</form>
<script>
function updateOptions(selector, json)
{
var select = document.querySelector(selector);
//Clear options
select.innerHTML = null;
for(i=0; i<json.length; i++) {
var json_opt = json[i];
var opt = document.createElement('option');
opt.value = json_opt.value;
opt.innerHTML = json_opt.text;
if(json_opt['data-subtext']) {
opt.dataset.subtext = json_opt['data-subtext'];
}
select.appendChild(opt);
}
}
function updateTargetSelect() {
var element = document.querySelector('#select_action');
var selected = element.options[element.options.selectedIndex];
var url = selected.dataset.url;
if (url) {
fetch(url)
.then(response => response.json())
.then(data => updateOptions('#select_target', data))
.then(data => $('#select_target').selectpicker('refresh'));
} else {
$('#select_target').selectpicker('hide');
}
}
</script>

View file

@ -1,16 +1,9 @@
{% extends "base.html.twig" %} {% extends "base.html.twig" %}
{% import "components/datatables.macro.html.twig" as datatables %}
{% block title %}{% trans %}attachment.list.title{% endtrans %}{% endblock %} {% block title %}{% trans %}attachment.list.title{% endtrans %}{% endblock %}
{% block content %} {% block content %}
<div id="part_list" class="" data-datatable data-settings='{{ datatable_settings(datatable) }}'> {{ datatables.datatable(datatable) }}
<div class="card-body">
<div class="card">
<div class="card-body">
<h4>{% trans %}part_list.loading.caption{% endtrans %}</h4>
<h6>{% trans %}part_list.loading.message{% endtrans %}</h6>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,69 @@
{% macro datatable(datatable, controller = 'elements/datatables/datatables') %}
<div {{ stimulus_controller(controller) }} data-dt-settings='{{ datatable_settings(datatable)|escape('html_attr') }}' data-dt-url="{{ app.request.pathInfo }}">
<div {{ stimulus_target(controller, 'dt') }}>
<div class="card-body">
<div class="card">
<div class="card-body">
<h4>{% trans %}part_list.loading.caption{% endtrans %}</h4>
<h6>{% trans %}part_list.loading.message{% endtrans %}</h6>
</div>
</div>
</div>
</div>
</div>
{% endmacro %}
{% macro logDataTable(dt) %}
{{ _self.datatable(dt, 'elements/datatables/log') }}
{% endmacro %}
{% macro partsDatatableWithForm(datatable) %}
<form method="post" action="{{ path("table_action") }}"
{{ stimulus_controller('elements/datatables/parts') }} data-dt-settings='{{ datatable_settings(datatable)|escape('html_attr') }}' data-dt-url="{{ app.request.pathInfo }}">
<input type="hidden" name="_token" value="{{ csrf_token('table_action') }}">
<input type="hidden" name="redirect_back" value="{{ app.request.uri }}">
<input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value="">
<div class="d-none mb-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}>
{# <span id="select_count"></span> #}
<span class="badge bg-secondary">{% trans with {'%count%': '<span ' ~ stimulus_target('elements/datatables/parts', 'selectCount') ~ '></span>'} %}part_list.action.part_count{% endtrans %}</span>
<select class="selectpicker" name="action" data-controller="elements--selectpicker" {{ stimulus_action('elements/datatables/parts', 'updateTargetPicker', 'change') }}
title="{% trans %}part_list.action.action.title{% endtrans %}" required>
<optgroup label="{% trans %}part_list.action.action.group.favorite{% endtrans %}">
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="favorite">{% trans %}part_list.action.action.favorite{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="unfavorite">{% trans %}part_list.action.action.unfavorite{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.action.group.change_field{% endtrans %}">
<option {% if not is_granted('@parts_category.edit') %}disabled{% endif %} value="change_category" data-url="{{ path('select_category') }}">{% trans %}part_list.action.action.change_category{% endtrans %}</option>
<option {% if not is_granted('@parts_footprint.edit') %}disabled{% endif %} value="change_footprint" data-url="{{ path('select_footprint') }}">{% trans %}part_list.action.action.change_footprint{% endtrans %}</option>
<option {% if not is_granted('@parts_manufacturer.edit') %}disabled{% endif %} value="change_manufacturer" data-url="{{ path('select_manufacturer') }}">{% trans %}part_list.action.action.change_manufacturer{% endtrans %}</option>
<option {% if not is_granted('@parts_unit.edit') %}disabled{% endif %} value="change_unit" data-url="{{ path('select_measurement_unit') }}">{% trans %}part_list.action.action.change_unit{% endtrans %}</option>
</optgroup>
<option {% if not is_granted('@parts.delete') %}disabled{% endif %} value="delete">{% trans %}part_list.action.action.delete{% endtrans %}</option>
</select>
<select class="" style="display: none;" data-live-search="true" name="target" {{ stimulus_target('elements/datatables/parts', 'selectTargetPicker') }}>
{# This is left empty, as this will be filled by Javascript #}
</select>
<button type="submit" class="btn btn-secondary">{% trans %}part_list.action.submit{% endtrans %}</button>
</div>
<div {{ stimulus_target('elements/datatables/parts', 'dt') }}>
<div class="card-body">
<div class="card">
<div class="card-body">
<h4>{% trans %}part_list.loading.caption{% endtrans %}</h4>
<h6>{% trans %}part_list.loading.message{% endtrans %}</h6>
</div>
</div>
</div>
</div>
</form>
{% endmacro %}

File diff suppressed because it is too large Load diff