Merge branch 'filter_system'

This commit is contained in:
Jan Böhmer 2022-09-11 23:53:29 +02:00
commit 8d9145a002
107 changed files with 7052 additions and 1528 deletions

View file

@ -43,7 +43,7 @@ jobs:
run: ./bin/console lint:xliff translations
- name: Check dependencies for security
uses: symfonycorp/security-checker-action@v2
uses: symfonycorp/security-checker-action@v3
- name: Check doctrine mapping
run: ./bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction

View file

@ -103,12 +103,20 @@ export default class extends Controller {
}
deleteElement(event) {
bootbox.confirm(this.deleteMessageValue, (result) => {
if(result) {
const target = event.target;
//Remove the row element from the table
target.closest("tr").remove();
}
});
const del = () => {
const target = event.target;
//Remove the row element from the table
target.closest("tr").remove();
}
if(this.deleteMessageValue) {
bootbox.confirm(this.deleteMessageValue, (result) => {
if (result) {
del();
}
});
} else {
del();
}
}
}

View file

@ -4,8 +4,8 @@ import {Controller} from "@hotwired/stimulus";
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';
import 'datatables.net-select-bs5/css/select.bootstrap5.css';
//JS
import 'datatables.net-bs5';
@ -23,9 +23,33 @@ export default class extends Controller {
static targets = ['dt'];
static values = {
stateSaveTag: String
};
/** The datatable instance associated with this controller instance */
_dt;
getStateSaveKey() {
let key = 'dt_state_'
if(this.stateSaveTagValue) { //If a tag is provided, use it to store the state
key += this.stateSaveTagValue;
} else { //Otherwise generate one from the current url
key += window.location.pathname;
}
return key;
}
stateSaveCallback(settings, data) {
localStorage.setItem( this.getStateSaveKey(), JSON.stringify(data) );
}
stateLoadCallback(settings) {
return JSON.parse( localStorage.getItem(this.getStateSaveKey()) );
}
connect() {
//$($.fn.DataTable.tables()).DataTable().fixedHeader.disable();
//$($.fn.DataTable.tables()).DataTable().destroy();
@ -39,23 +63,36 @@ export default class extends Controller {
settings.url = this.element.dataset.dtUrl;
let options = {
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',
'columns': ':not(.no-colvis)',
"text": "<i class='fa fa-cog'></i>"
}],
rowCallback: this._rowCallback.bind(this),
stateSave: true,
stateSaveCallback: this.stateSaveCallback.bind(this),
stateLoadCallback: this.stateLoadCallback.bind(this),
};
if(this.isSelectable()) {
options.select = {
style: 'multi+shift',
selector: 'td.select-checkbox'
};
}
//@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),
})
const promise = $(this.dtTarget).initDataTables(settings, options)
//Register error handler
.catch(err => {
console.error("Error initializing datatables: " + err);
@ -78,46 +115,6 @@ export default class extends Controller {
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.');
}

View file

@ -0,0 +1,15 @@
import {Controller} from "@hotwired/stimulus";
import TomSelect from "tom-select";
export default class extends Controller {
_tomSelect;
connect() {
this._tomSelect = new TomSelect(this.element, {
maxItems: 1000,
allowEmptyOption: true,
plugins: ['remove_button'],
});
}
}

View file

@ -2,7 +2,7 @@ const bootstrap = window.bootstrap = require('bootstrap'); // without this boots
require('bootstrap-select/js/bootstrap-select'); // we have to manually require the working js file
import {Controller} from "@hotwired/stimulus";
import "bootstrap-select/dist/css/bootstrap-select.css";
import "../../css/lib/boostrap-select.css";
import "../../css/selectpicker_extensions.css";
export default class extends Controller {

View file

@ -8,7 +8,6 @@ export default class extends Controller {
_tomSelect;
connect() {
let settings = {
plugins: {
remove_button:{

View file

@ -0,0 +1,26 @@
import {Controller} from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["operator", "thingsToHide"];
connect() {
this.update();
}
/**
* Updates the visibility state of the value2 input, based on the operator selection.
*/
update()
{
const two_element_values = [
"BETWEEN",
'RANGE_IN_RANGE',
'RANGE_INTERSECT_RANGE'
];
for (const thingToHide of this.thingsToHideTargets) {
thingToHide.classList.toggle("d-none", !two_element_values.includes(this.operatorTarget.value));
}
}
}

View file

@ -0,0 +1,48 @@
import {Controller} from "@hotwired/stimulus";
/**
* Purpose of this controller is to clean up the form before it is finally submitted. This means empty fields get disabled, so they are not submitted.
* This is especially useful for GET forms, to prevent very long URLs
*/
export default class extends Controller {
/**
* Call during the submit event of the form. This will disable all empty fields, so they are not submitted.
* @param event
*/
submit(event) {
/** Find the form this event belongs to */
/** @type {HTMLFormElement} */
const form = event.target.closest('form');
for(const element of form.elements) {
if(! element.value) {
element.disabled = true;
}
//Workaround for tristate checkboxes which use a hidden field to store the value
if ((element.type === 'hidden' || element.type === 'checkbox') && element.value === 'indeterminate') {
element.disabled = true;
}
}
}
/**
* Submits the form with all form elements disabled, so they are not submitted. This is useful for GET forms, to reset the form to not filled state.
* @param event
*/
clearAll(event)
{
const form = event.target.closest('form');
for(const element of form.elements) {
// Do not clear elements with data-no-clear attribute
if(element.dataset.noClear) {
continue;
}
element.disabled = true;
}
form.submit();
}
}

View file

@ -0,0 +1,21 @@
import {Controller} from "@hotwired/stimulus";
import katex from "katex";
import "katex/dist/katex.css";
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ["input", "preview"];
updatePreview()
{
katex.render(this.inputTarget.value, this.previewTarget, {
throwOnError: false,
});
}
connect()
{
this.updatePreview();
this.inputTarget.addEventListener('input', this.updatePreview.bind(this));
}
}

View file

@ -0,0 +1,99 @@
import {Controller} from "@hotwired/stimulus";
import TomSelect from "tom-select";
import katex from "katex";
import "katex/dist/katex.css";
/* stimulusFetch: 'lazy' */
export default class extends Controller
{
static values = {
url: String,
}
static targets = ["name", "symbol", "unit"]
onItemAdd(value, item) {
//Retrieve the unit and symbol from the item
const symbol = item.dataset.symbol;
const unit = item.dataset.unit;
if (this.symbolTarget && symbol !== undefined) {
this.symbolTarget.value = symbol;
//Trigger input event to update the preview
this.symbolTarget.dispatchEvent(new Event('input'));
}
if (this.unitTarget && unit !== undefined) {
this.unitTarget.value = unit;
//Trigger input event to update the preview
this.unitTarget.dispatchEvent(new Event('input'));
}
}
connect() {
const settings = {
plugins: {
clear_button:{}
},
persistent: false,
maxItems: 1,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
createOnBlur: true,
create: true,
searchField: "name",
//labelField: "name",
valueField: "name",
onItemAdd: this.onItemAdd.bind(this),
render: {
option: (data, escape) => {
let tmp = '<div>'
+ '<span>' + escape(data.name) + '</span><br>';
if (data.symbol) {
tmp += '<span>' + katex.renderToString(data.symbol) + '</span>'
}
if (data.unit) {
tmp += '<span class="ms-2">' + katex.renderToString('[' + data.unit + ']') + '</span>'
}
//+ '<span class="text-muted">' + escape(data.unit) + '</span>'
tmp += '</div>';
return tmp;
},
item: (data, escape) => {
//We use the item to transfert data to the onItemAdd function using data attributes
const element = document.createElement('div');
element.innerText = data.name;
if(data.unit !== undefined) {
element.dataset.unit = data.unit;
}
if (data.symbol !== undefined) {
element.dataset.symbol = data.symbol;
}
return element.outerHTML;
}
}
};
if(this.urlValue) {
const base_url = this.urlValue;
settings.load = (query, callback) => {
const url = base_url.replace('__QUERY__', encodeURIComponent(query));
fetch(url)
.then(response => response.json())
.then(json => {
//const data = json.map(x => {return {"value": x, "text": x}});
callback(json);
}).catch(()=>{
callback();
});
}
}
this._tomSelect = new TomSelect(this.nameTarget, settings);
}
}

View file

@ -754,6 +754,12 @@ div.dataTables_wrapper div.dataTables_info {
display: inline-flex;
}
/** Fix datatables select-checkbox position */
table.dataTable tr.selected td.select-checkbox:after, table.dataTable tr.selected th.select-checkbox:after {
margin-top: -28px !important;
}
/******************************************
* Typeahead menu
*******************************************/

View file

@ -0,0 +1,491 @@
/*!
* Bootstrap-select v1.14.0-beta3 (https://developer.snapappointments.com/bootstrap-select)
*
* Copyright 2012-2022 SnapAppointments, LLC
* Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
*/
/**
* Modified by Jan Böhmer 2022, to improve styling with BS5:
* - Keep border around selectpicker in a form-control
*/
@-webkit-keyframes bs-notify-fadeOut {
0% {
opacity: 0.9;
}
100% {
opacity: 0;
}
}
@-o-keyframes bs-notify-fadeOut {
0% {
opacity: 0.9;
}
100% {
opacity: 0;
}
}
@keyframes bs-notify-fadeOut {
0% {
opacity: 0.9;
}
100% {
opacity: 0;
}
}
select.bs-select-hidden,
.bootstrap-select > select.bs-select-hidden,
select.selectpicker {
display: none !important;
}
.bootstrap-select {
width: 220px;
vertical-align: middle;
}
.bootstrap-select > .dropdown-toggle {
position: relative;
width: 100%;
text-align: right;
white-space: nowrap;
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
}
.bootstrap-select > .dropdown-toggle:after {
margin-top: -1px;
}
.bootstrap-select > .dropdown-toggle.bs-placeholder,
.bootstrap-select > .dropdown-toggle.bs-placeholder:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder:active {
color: #999;
}
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:active,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:active,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:active,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:active,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:active,
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:active {
color: rgba(255, 255, 255, 0.5);
}
.bootstrap-select > select {
position: absolute !important;
bottom: 0;
left: 50%;
display: block !important;
width: 0.5px !important;
height: 100% !important;
padding: 0 !important;
opacity: 0 !important;
border: none;
z-index: 0 !important;
}
.bootstrap-select > select.mobile-device {
top: 0;
left: 0;
display: block !important;
width: 100% !important;
z-index: 2 !important;
}
.has-error .bootstrap-select .dropdown-toggle,
.error .bootstrap-select .dropdown-toggle,
.bootstrap-select.is-invalid .dropdown-toggle,
.was-validated .bootstrap-select select:invalid + .dropdown-toggle {
border-color: #b94a48;
}
.bootstrap-select.is-valid .dropdown-toggle,
.was-validated .bootstrap-select select:valid + .dropdown-toggle {
border-color: #28a745;
}
.bootstrap-select.fit-width {
width: auto !important;
}
.bootstrap-select:not([class*="col-"]):not([class*="form-control"]):not(.input-group-btn) {
width: 220px;
}
.bootstrap-select > select.mobile-device:focus + .dropdown-toggle,
.bootstrap-select .dropdown-toggle:focus {
outline: thin dotted #333333 !important;
outline: 5px auto -webkit-focus-ring-color !important;
outline-offset: -2px;
}
.bootstrap-select.form-control {
margin-bottom: 0;
padding: 0;
/*border: none;*/
height: auto;
}
:not(.input-group) > .bootstrap-select.form-control:not([class*="col-"]) {
width: 100%;
}
.bootstrap-select.form-control.input-group-btn {
float: none;
z-index: auto;
}
.form-inline .bootstrap-select,
.form-inline .bootstrap-select.form-control:not([class*="col-"]) {
width: auto;
}
.bootstrap-select:not(.input-group-btn),
.bootstrap-select[class*="col-"] {
float: none;
display: inline-block;
margin-left: 0;
}
.bootstrap-select.dropdown-menu-right,
.bootstrap-select[class*="col-"].dropdown-menu-right,
.row .bootstrap-select[class*="col-"].dropdown-menu-right {
float: right;
}
.form-inline .bootstrap-select,
.form-horizontal .bootstrap-select,
.form-group .bootstrap-select {
margin-bottom: 0;
}
.form-group-lg .bootstrap-select.form-control,
.form-group-sm .bootstrap-select.form-control {
padding: 0;
}
.form-group-lg .bootstrap-select.form-control .dropdown-toggle,
.form-group-sm .bootstrap-select.form-control .dropdown-toggle {
height: 100%;
font-size: inherit;
line-height: inherit;
border-radius: inherit;
}
.bootstrap-select.form-control-sm .dropdown-toggle,
.bootstrap-select.form-control-lg .dropdown-toggle {
font-size: inherit;
line-height: inherit;
border-radius: inherit;
}
.bootstrap-select.form-control-sm .dropdown-toggle {
padding: 0.25rem 0.5rem;
}
.bootstrap-select.form-control-lg .dropdown-toggle {
padding: 0.5rem 1rem;
}
.form-inline .bootstrap-select .form-control {
width: 100%;
}
.bootstrap-select.disabled,
.bootstrap-select > .disabled {
cursor: not-allowed;
}
.bootstrap-select.disabled:focus,
.bootstrap-select > .disabled:focus {
outline: none !important;
}
.bootstrap-select.bs-container {
position: absolute;
top: 0;
left: 0;
height: 0 !important;
padding: 0 !important;
}
.bootstrap-select.bs-container .dropdown-menu {
z-index: 1060;
}
.bootstrap-select .dropdown-toggle .filter-option {
position: static;
top: 0;
left: 0;
float: left;
height: 100%;
width: 100%;
text-align: left;
overflow: hidden;
-webkit-box-flex: 0;
-webkit-flex: 0 1 auto;
-ms-flex: 0 1 auto;
flex: 0 1 auto;
}
.bs3.bootstrap-select .dropdown-toggle .filter-option {
padding-right: inherit;
}
.input-group .bs3-has-addon.bootstrap-select .dropdown-toggle .filter-option {
position: absolute;
padding-top: inherit;
padding-bottom: inherit;
padding-left: inherit;
float: none;
}
.input-group .bs3-has-addon.bootstrap-select .dropdown-toggle .filter-option .filter-option-inner {
padding-right: inherit;
}
.bootstrap-select .dropdown-toggle .filter-option-inner-inner {
overflow: hidden;
}
.bootstrap-select .dropdown-toggle .filter-expand {
width: 0 !important;
float: left;
opacity: 0 !important;
overflow: hidden;
}
.bootstrap-select .dropdown-toggle .caret {
position: absolute;
top: 50%;
right: 12px;
margin-top: -2px;
vertical-align: middle;
}
.bootstrap-select .dropdown-toggle .bs-select-clear-selected {
position: relative;
display: block;
margin-right: 5px;
text-align: center;
}
.bs3.bootstrap-select .dropdown-toggle .bs-select-clear-selected {
padding-right: inherit;
}
.bootstrap-select .dropdown-toggle .bs-select-clear-selected span {
position: relative;
top: -webkit-calc(((-1em / 1.5) + 1ex) / 2);
top: calc(((-1em / 1.5) + 1ex) / 2);
pointer-events: none;
}
.bs3.bootstrap-select .dropdown-toggle .bs-select-clear-selected span {
top: auto;
}
.bootstrap-select .dropdown-toggle.bs-placeholder .bs-select-clear-selected {
display: none;
}
.input-group .bootstrap-select.form-control .dropdown-toggle {
border-radius: inherit;
}
.bootstrap-select[class*="col-"] .dropdown-toggle {
width: 100%;
}
.bootstrap-select .dropdown-menu {
min-width: 100%;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.bootstrap-select .dropdown-menu > .inner:focus {
outline: none !important;
}
.bootstrap-select .dropdown-menu.inner {
position: static;
float: none;
border: 0;
padding: 0;
margin: 0;
border-radius: 0;
-webkit-box-shadow: none;
box-shadow: none;
}
.bootstrap-select .dropdown-menu li {
position: relative;
}
.bootstrap-select .dropdown-menu li.active small {
color: rgba(255, 255, 255, 0.5) !important;
}
.bootstrap-select .dropdown-menu li.disabled a {
cursor: not-allowed;
}
.bootstrap-select .dropdown-menu li a {
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.bootstrap-select .dropdown-menu li a.opt {
position: relative;
padding-left: 2.25em;
}
.bootstrap-select .dropdown-menu li a span.check-mark {
display: none;
}
.bootstrap-select .dropdown-menu li a span.text {
display: inline-block;
}
.bootstrap-select .dropdown-menu li small {
padding-left: 0.5em;
}
.bootstrap-select .dropdown-menu .notify {
position: absolute;
bottom: 5px;
width: 96%;
margin: 0 2%;
min-height: 26px;
padding: 3px 5px;
background: #f5f5f5;
border: 1px solid #e3e3e3;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
pointer-events: none;
opacity: 0.9;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.bootstrap-select .dropdown-menu .notify.fadeOut {
-webkit-animation: 300ms linear 750ms forwards bs-notify-fadeOut;
-o-animation: 300ms linear 750ms forwards bs-notify-fadeOut;
animation: 300ms linear 750ms forwards bs-notify-fadeOut;
}
.bootstrap-select .no-results {
padding: 3px;
background: #f5f5f5;
margin: 0 5px;
white-space: nowrap;
}
.bootstrap-select.fit-width .dropdown-toggle .filter-option {
position: static;
display: inline;
padding: 0;
}
.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner,
.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner-inner {
display: inline;
}
.bootstrap-select.fit-width .dropdown-toggle .bs-caret:before {
content: '\00a0';
}
.bootstrap-select.fit-width .dropdown-toggle .caret {
position: static;
top: auto;
margin-top: -1px;
}
.bootstrap-select.show-tick .dropdown-menu .selected span.check-mark {
position: absolute;
display: inline-block;
right: 15px;
top: 5px;
}
.bootstrap-select.show-tick .dropdown-menu li a span.text {
margin-right: 34px;
}
.bootstrap-select .bs-ok-default:after {
content: '';
display: block;
width: 0.5em;
height: 1em;
border-style: solid;
border-width: 0 0.26em 0.26em 0;
-webkit-transform-style: preserve-3d;
transform-style: preserve-3d;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
-o-transform: rotate(45deg);
transform: rotate(45deg);
}
.bootstrap-select.show-menu-arrow.open > .dropdown-toggle,
.bootstrap-select.show-menu-arrow.show > .dropdown-toggle {
z-index: 1061;
}
.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:before {
content: '';
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid rgba(204, 204, 204, 0.2);
position: absolute;
bottom: -4px;
left: 9px;
display: none;
}
.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:after {
content: '';
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid white;
position: absolute;
bottom: -4px;
left: 10px;
display: none;
}
.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:before {
bottom: auto;
top: -4px;
border-top: 7px solid rgba(204, 204, 204, 0.2);
border-bottom: 0;
}
.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:after {
bottom: auto;
top: -4px;
border-top: 6px solid white;
border-bottom: 0;
}
.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:before {
right: 12px;
left: auto;
}
.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:after {
right: 13px;
left: auto;
}
.bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:before,
.bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:before,
.bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:after,
.bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:after {
display: block;
}
.bs-searchbox,
.bs-actionsbox,
.bs-donebutton {
padding: 4px 8px;
}
.bs-actionsbox {
width: 100%;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.bs-actionsbox .btn-group {
display: block;
}
.bs-actionsbox .btn-group button {
width: 50%;
}
.bs-donebutton {
float: left;
width: 100%;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.bs-donebutton .btn-group {
display: block;
}
.bs-donebutton .btn-group button {
width: 100%;
}
.bs-searchbox + .bs-actionsbox {
padding: 0 8px 4px;
}
.bs-searchbox .form-control {
margin-bottom: 0;
width: 100%;
float: none;
}
/*# sourceMappingURL=bootstrap-select.css.map */

View file

@ -10,6 +10,61 @@ class ErrorHandlerHelper {
const content = document.getElementById('content');
content.addEventListener('turbo:before-fetch-response', (event) => this.handleError(event));
$(document).ajaxError(this.handleJqueryErrror.bind(this));
}
_showAlert(statusText, statusCode, location, responseHTML)
{
//Create error text
const title = statusText + ' (Status ' + statusCode + ')';
let trimString = function (string, length) {
return string.length > length ?
string.substring(0, length) + '...' :
string;
};
const short_location = trimString(location, 50);
const alert = bootbox.alert(
{
size: 'large',
message: function() {
let url = location;
let msg = `Error calling <a href="${url}">${short_location}</a>.<br>`;
msg += '<b>Try to reload the page or contact the administrator if this error persists.</b>';
msg += '<br><br><a class=\"btn btn-outline-secondary mb-2\" data-bs-toggle=\"collapse\" href=\"#iframe_div\" >' + 'View details' + "</a>";
msg += "<div class=\" collapse\" id='iframe_div'><iframe height='512' width='100%' id='error-iframe'></iframe></div>";
return msg;
},
title: title,
callback: function () {
//Remove blur
$('#content').removeClass('loading-content');
}
});
alert.init(function (){
var dstFrame = document.getElementById('error-iframe');
//@ts-ignore
var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document;
dstDoc.write(responseHTML)
dstDoc.close();
});
}
handleJqueryErrror(event, jqXHR, ajaxSettings, thrownError)
{
//Ignore status 422 as this means a symfony validation error occured and we need to show it to user. This is no (unexpected) error.
if (jqXHR.status === 422) {
return;
}
this._showAlert(jqXHR.statusText, jqXHR.status, ajaxSettings.url, jqXHR.responseText);
}
handleError(event) {
@ -27,52 +82,10 @@ class ErrorHandlerHelper {
}
if(fetchResponse.failed) {
//Create error text
let title = response.statusText + ' (Status ' + response.status + ')';
/**
switch(response.status) {
case 500:
title = 'Internal Server Error!';
break;
case 404:
title = "Site not found!";
break;
case 403:
title = "Permission denied!";
break;
} **/
const alert = bootbox.alert(
{
size: 'large',
message: function() {
let url = fetchResponse.location.toString();
let msg = `Error calling <a href="${url}">${url}</a>. `;
msg += 'Try to reload the page or contact the administrator if this error persists.'
msg += '<br><br><a class=\"btn btn-link\" data-bs-toggle=\"collapse\" href=\"#iframe_div\" >' + 'View details' + "</a>";
msg += "<div class=\" collapse\" id='iframe_div'><iframe height='512' width='100%' id='error-iframe'></iframe></div>";
return msg;
},
title: title,
callback: function () {
//Remove blur
$('#content').removeClass('loading-content');
}
});
//@ts-ignore
alert.init(function (){
response.text().then( (html) => {
var dstFrame = document.getElementById('error-iframe');
//@ts-ignore
var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document;
dstDoc.write(html)
dstDoc.close();
});
response.text().then(responseHTML => {
this._showAlert(response.statusText, response.status, fetchResponse.location.toString(), responseHTML);
}).catch(err => {
this._showAlert(response.statusText, response.status, fetchResponse.location.toString(), '<pre>' + err + '</pre>');
});
}
}

View file

@ -0,0 +1,207 @@
const DEFAULT_OPTIONS = {
true: "true",
false: "false",
null: "indeterminate",
};
/**
* A simple tristate checkbox
*/
export default class TristateCheckbox {
static instances = new Map();
/**
*
* @type {null|boolean}
* @private
*/
_state = false;
/**
* The element representing the checkbox.
* @type {HTMLInputElement}
* @private
*/
_element = null;
/**
* The hidden input element representing the value of the checkbox
* @type {HTMLInputElement}
* @private
*/
_hiddenInput = null;
/**
* The values of the checkbox.
* @type {{null: string, true: string, false: string}}
* @private
*/
_options = DEFAULT_OPTIONS;
/**
* Retrieve the instance of the TristateCheckbox for the given element if already existing, otherwise a new one is created.
* @param element
* @param options
* @return {any}
*/
static getInstance(element, options = {})
{
if(!TristateCheckbox.instances.has(element)) {
TristateCheckbox.instances.set(element, new TristateCheckbox(element, options));
}
return TristateCheckbox.instances.get(element);
}
/**
* @param {HTMLElement} element
*/
constructor(element, options = {})
{
if(!element instanceof HTMLInputElement || !(element.tagName === 'INPUT' && element.type === 'checkbox')) {
throw new Error("The given element is not an input checkbox");
}
//Apply options
this._options = Object.assign(this._options, options);
this._element = element;
//Set the state of our element to the value of the passed input value
this._parseInitialState();
//Create a hidden input field to store the value of the checkbox, because this will be always be submitted in the form
this._hiddenInput = document.createElement('input');
this._hiddenInput.type = 'hidden';
this._hiddenInput.name = this._element.name;
this._hiddenInput.value = this._element.value;
//Insert the hidden input field after the checkbox and remove the checkbox from form submission (by removing the name property)
element.after(this._hiddenInput);
this._element.removeAttribute('name');
//Do a refresh to set the correct styling of the checkbox
this._refresh();
this._element.addEventListener('click', this.click.bind(this));
}
/**
* Parse the attributes of the checkbox and set the correct state.
* @private
*/
_parseInitialState()
{
if(this._element.hasAttribute('value')) {
this._state = this._stringToState(this._element.getAttribute('value'));
return;
}
if(this._element.checked) {
this._state = true;
return;
}
if(this._element.indeterminate) {
this._state = null;
return;
}
this._state = false;
}
_refresh()
{
this._element.indeterminate = this._state === null;
this._element.checked = this._state === true;
//Set the value field of the checkbox and the hidden input to correct value
this._element.value = this._stateToString(this._state);
this._hiddenInput.value = this._stateToString(this._state);
}
/**
* Returns the current state of the checkbox. True if checked, false if unchecked, null if indeterminate.
* @return {boolean|null}
*/
get state() {
return this._state;
}
/**
* Sets the state of the checkbox. True if checked, false if unchecked, null if indeterminate.
* @param state
*/
set state(state) {
this._state = state;
this._refresh();
}
/**
* Returns the current state of the checkbox as string, according to the options.
* @return {string}
*/
get stateString() {
return this._stateToString(this._state);
}
set stateString(string) {
this.state = this._stringToState(string);
this._refresh();
}
/**
* @param {boolean|null} state
* @return string
* @private
*/
_stateToString(state)
{
if (this.state === null) {
return this._options.null;
} else if (this.state === true) {
return this._options.true;
} else if (this.state === false) {
return this._options.false;
}
throw new Error("Invalid state " + state);
}
/**
* Converts a string to a state according to the options.
* @param string
* @param throwError
* @return {null|boolean}
* @private
*/
_stringToState(string, throwError = true)
{
if (string === this._options.true) {
return true;
} else if (string === this._options.false) {
return false;
} else if (string === this._options.null) {
return null;
}
if(throwError) {
throw new Error("Invalid state string " + string);
} else {
return null;
}
}
click()
{
switch (this._state) {
case true: this._state = false; break;
case false: this._state = null; break;
default: this._state = true; break;
}
this._refresh();
}
}

View file

@ -1,213 +0,0 @@
/*jslint devel: true, bitwise: true, regexp: true, browser: true, confusion: true, unparam: true, eqeq: true, white: true, nomen: true, plusplus: true, maxerr: 50, indent: 4 */
/*globals jQuery */
/*!
* Tristate v1.2.1
*
* Copyright (c) 2013-2017 Martijn W. van der Lee
* Licensed under the MIT.
*/
/* Based on work by:
* Chris Coyier (http://css-tricks.com/indeterminate-checkboxes/)
*
* Tristate checkbox with support features
* pseudo selectors
* val() overwrite
*/
;(function($, undefined) {
'use strict';
var pluginName = 'tristate',
defaults = {
'change': undefined,
'checked': undefined,
'indeterminate': undefined,
'init': undefined,
'reverse': false,
'state': undefined,
'unchecked': undefined,
'value': undefined // one-way only!
},
valFunction = $.fn.val;
function Plugin(element, options) {
if($(element).is(':checkbox')) {
this.element = $(element);
this.settings = $.extend( {}, defaults, options );
this._create();
}
}
$.extend(Plugin.prototype, {
_create: function() {
var that = this,
state;
// Fix for #1
if (window.navigator.userAgent.indexOf('Trident') >= 0) {
this.element.click(function(e) {
that._change.call(that, e);
that.element.closest('form').change();
});
} else {
this.element.change(function(e) {
that._change.call(that, e);
});
}
this.settings.checked = this.element.attr('checkedvalue') || this.settings.checked;
this.settings.unchecked = this.element.attr('uncheckedvalue') || this.settings.unchecked;
this.settings.indeterminate = this.element.attr('indeterminatevalue') || this.settings.indeterminate;
// Initially, set state based on option state or attributes
if (typeof this.settings.state === 'undefined') {
this.settings.state = typeof this.element.attr('indeterminate') !== 'undefined'? null : this.element.is(':checked');
}
// If value specified, overwrite with value
if (typeof this.settings.value !== 'undefined') {
state = this._parseValue(this.settings.value);
if (typeof state !== 'undefined') {
this.settings.state = state;
}
}
this._refresh(this.settings.init);
return this;
},
_change: function(e) {
if (e.isTrigger || !e.hasOwnProperty('which')) {
e.preventDefault();
}
switch (this.settings.state) {
case true: this.settings.state = (this.settings.reverse ? false : null); break;
case false: this.settings.state = (this.settings.reverse ? null : true); break;
default: this.settings.state = (this.settings.reverse ? true : false); break;
}
this._refresh(this.settings.change);
},
_refresh: function(callback) {
var value = this.value();
this.element.data("vanderlee." + pluginName, value);
this.element[this.settings.state === null ? 'attr' : 'removeAttr']('indeterminate', 'indeterminate');
this.element.prop('indeterminate', this.settings.state === null);
this.element.get(0).indeterminate = this.settings.state === null;
this.element[this.settings.state === true ? 'attr' : 'removeAttr']('checked', true);
this.element.prop('checked', this.settings.state === true);
if ($.isFunction(callback)) {
callback.call(this.element, this.settings.state, this.value());
}
},
state: function(value) {
if (typeof value === 'undefined') {
return this.settings.state;
} else if (value === true || value === false || value === null) {
this.settings.state = value;
this._refresh(this.settings.change);
}
return this;
},
_parseValue: function(value) {
if (value === this.settings.checked) {
return true;
} else if (value === this.settings.unchecked) {
return false;
} else if (value === this.settings.indeterminate) {
return null;
}
},
value: function(value) {
if (typeof value === 'undefined') {
var value;
switch (this.settings.state) {
case true:
value = this.settings.checked;
break;
case false:
value = this.settings.unchecked;
break;
case null:
value = this.settings.indeterminate;
break;
}
return typeof value === 'undefined'? this.element.attr('value') : value;
} else {
var state = this._parseValue(value);
if (typeof state !== 'undefined') {
this.settings.state = state;
this._refresh(this.settings.change);
}
}
}
});
$.fn[pluginName] = function (options, value) {
var result = this;
this.each(function() {
if (!$.data(this, "plugin.vanderlee." + pluginName)) {
$.data(this, "plugin.vanderlee." + pluginName, new Plugin(this, options));
} else if (typeof options === 'string') {
if (typeof value === 'undefined') {
result = $(this).data("plugin.vanderlee." + pluginName)[options]();
return false;
} else {
$(this).data("plugin.vanderlee." + pluginName)[options](value);
}
}
});
return result;
};
// Overwrite fn.val
$.fn.val = function(value) {
var data = this.data("vanderlee." + pluginName);
if (typeof data === 'undefined') {
if (typeof value === 'undefined') {
return valFunction.call(this);
} else {
return valFunction.call(this, value);
}
} else {
if (typeof value === 'undefined') {
return data;
} else {
this.data("vanderlee." + pluginName, value);
return this;
}
}
};
// :indeterminate pseudo selector
$.expr.filters.indeterminate = function(element) {
var $element = $(element);
return typeof $element.data("vanderlee." + pluginName) !== 'undefined' && $element.prop('indeterminate');
};
// :determinate pseudo selector
$.expr.filters.determinate = function(element) {
return !($.expr.filters.indeterminate(element));
};
// :tristate selector
$.expr.filters.tristate = function(element) {
return typeof $(element).data("vanderlee." + pluginName) !== 'undefined';
};
})(jQuery);

View file

@ -55,6 +55,7 @@ class TabRememberHelper {
while(parent) {
//Invoker can either be a button or a element
let tabInvoker = document.querySelector("button[data-content='#" + parent.id + "']")
?? document.querySelector("button[data-bs-target='#" + parent.id + "']")
?? document.querySelector("a[href='#" + parent.id + "']");
Tab.getOrCreateInstance(tabInvoker).show();

View file

@ -1,49 +1,37 @@
'use strict';
import "./lib/jquery.tristate"
import TristateCheckbox from "./lib/TristateCheckbox";
class TristateHelper {
constructor() {
this.registerTriStateCheckboxes();
this.registerSubmitHandler();
}
registerSubmitHandler() {
document.addEventListener("turbo:submit-start", (e) => {
var form = e.detail.formSubmission.formElement;
var formData = e.detail.formSubmission.formData;
var $tristate_checkboxes = $('.tristate:checkbox', form);
//Iterate over each tristate checkbox in the form and set formData to the correct value
$tristate_checkboxes.each(function() {
var $checkbox = $(this);
var state = $checkbox.tristate('state');
formData.set($checkbox.attr('name'), state);
});
});
}
registerTriStateCheckboxes() {
//Initialize tristate checkboxes and if needed the multicheckbox functionality
const listener = () => {
$(".tristate").tristate( {
checked: "true",
unchecked: "false",
indeterminate: "indeterminate",
const tristates = document.querySelectorAll("input.tristate");
tristates.forEach(tristate => {
TristateCheckbox.getInstance(tristate);
});
$('.permission_multicheckbox:checkbox').change(function() {
//Find the other checkboxes in this row, and change their value
var $row = $(this).parents('tr');
//@ts-ignore
var new_state = $(this).tristate('state');
//Register multi checkboxes in permission tables
const multicheckboxes = document.querySelectorAll("input.permission_multicheckbox");
multicheckboxes.forEach(multicheckbox => {
multicheckbox.addEventListener("change", (event) => {
const newValue = TristateCheckbox.getInstance(event.target).state;
const row = event.target.closest("tr");
//@ts-ignore
$('.tristate:checkbox', $row).tristate('state', new_state);
//Find all tristate checkboxes in the same row and set their state to the new value
const tristateCheckboxes = row.querySelectorAll("input.tristate");
tristateCheckboxes.forEach(tristateCheckbox => {
TristateCheckbox.getInstance(tristateCheckbox).state = newValue;
});
});
});
}

446
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,9 @@ datatables:
language_from_cdn: false
# Set to none, as we override the bundle mechanism with our own custom one
persist_state: none
# Set options, as documented at https://datatables.net/reference/option/
options:
lengthMenu : [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]]

View file

@ -32,4 +32,5 @@ doctrine:
dql:
string_functions:
regexp: DoctrineExtensions\Query\Mysql\Regexp
regexp: DoctrineExtensions\Query\Mysql\Regexp
ifnull: DoctrineExtensions\Query\Mysql\IfNull

View file

@ -58,3 +58,9 @@ nelmio_security:
- 'data:'
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
when@dev:
# disables the Content-Security-Policy header
nelmio_security:
csp:
enabled: false

View file

@ -1,6 +1,6 @@
twig:
default_path: '%kernel.project_dir%/templates'
form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'Form/extendedBootstrap4_layout.html.twig', 'Form/permissionLayout.html.twig' ]
form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'Form/extendedBootstrap4_layout.html.twig', 'Form/permissionLayout.html.twig', 'Form/FilterTypesLayout.html.twig']
paths:
'%kernel.project_dir%/assets/css': css

View file

@ -43,9 +43,15 @@ declare(strict_types=1);
namespace App\Controller;
use App\DataTables\AttachmentDataTable;
use App\DataTables\Filters\AttachmentFilter;
use App\DataTables\Filters\PartFilter;
use App\DataTables\PartsDataTable;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\PartAttachment;
use App\Form\Filters\AttachmentFilterType;
use App\Form\Filters\PartFilterType;
use App\Services\Attachments\AttachmentManager;
use App\Services\Trees\NodesListBuilder;
use Omines\DataTablesBundle\DataTableFactory;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -123,11 +129,19 @@ class AttachmentFileController extends AbstractController
*
* @return JsonResponse|Response
*/
public function attachmentsTable(DataTableFactory $dataTable, Request $request)
public function attachmentsTable(Request $request, DataTableFactory $dataTableFactory, NodesListBuilder $nodesListBuilder)
{
$this->denyAccessUnlessGranted('read', new PartAttachment());
$table = $dataTable->createFromType(AttachmentDataTable::class)
$formRequest = clone $request;
$formRequest->setMethod('GET');
$filter = new AttachmentFilter($nodesListBuilder);
$filterForm = $this->createForm(AttachmentFilterType::class, $filter, ['method' => 'GET']);
$filterForm->handleRequest($formRequest);
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter])
->handleRequest($request);
if ($table->isCallback()) {
@ -136,6 +150,7 @@ class AttachmentFileController extends AbstractController
return $this->render('attachment_list.html.twig', [
'datatable' => $table,
'filterForm' => $filterForm->createView(),
]);
}
}

View file

@ -42,6 +42,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\DataTables\Filters\LogFilter;
use App\DataTables\LogDataTable;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LogSystem\AbstractLogEntry;
@ -49,6 +50,7 @@ use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Form\Filters\LogFilterType;
use App\Services\LogSystem\EventUndoHelper;
use App\Services\LogSystem\TimeTravel;
use Doctrine\ORM\EntityManagerInterface;
@ -86,7 +88,17 @@ class LogController extends AbstractController
{
$this->denyAccessUnlessGranted('@system.show_logs');
$table = $dataTable->createFromType(LogDataTable::class)
$formRequest = clone $request;
$formRequest->setMethod('GET');
$filter = new LogFilter();
$filterForm = $this->createForm(LogFilterType::class, $filter, ['method' => 'GET']);
$filterForm->handleRequest($formRequest);
$table = $dataTable->createFromType(LogDataTable::class, [
'filter' => $filter,
])
->handleRequest($request);
if ($table->isCallback()) {
@ -95,6 +107,7 @@ class LogController extends AbstractController
return $this->render('LogSystem/log_list.html.twig', [
'datatable' => $table,
'filterForm' => $filterForm->createView(),
]);
}

View file

@ -42,16 +42,21 @@ declare(strict_types=1);
namespace App\Controller;
use App\DataTables\Filters\PartFilter;
use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier;
use App\Form\Filters\PartFilterType;
use App\Services\Parts\PartsTableActionHandler;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -60,10 +65,14 @@ use Symfony\Component\Routing\Annotation\Route;
class PartListsController extends AbstractController
{
private $entityManager;
private $nodesListBuilder;
private $dataTableFactory;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(EntityManagerInterface $entityManager, NodesListBuilder $nodesListBuilder, DataTableFactory $dataTableFactory)
{
$this->entityManager = $entityManager;
$this->nodesListBuilder = $nodesListBuilder;
$this->dataTableFactory = $dataTableFactory;
}
/**
@ -98,24 +107,81 @@ class PartListsController extends AbstractController
}
/**
* @Route("/category/{id}/parts", name="part_list_category")
*
* @return JsonResponse|Response
* Disable the given form interface after creation of the form by removing and reattaching the form.
* @param FormInterface $form
* @return void
*/
public function showCategory(Category $category, Request $request, DataTableFactory $dataTable)
private function disableFormFieldAfterCreation(FormInterface $form, bool $disabled = true): void
{
$table = $dataTable->createFromType(PartsDataTable::class, ['category' => $category])
$attrs = $form->getConfig()->getOptions();
$attrs['disabled'] = $disabled;
$parent = $form->getParent();
if ($parent === null) {
throw new \RuntimeException('This function can only be used on form fields that are children of another form!');
}
$parent->remove($form->getName());
$parent->add($form->getName(), get_class($form->getConfig()->getType()->getInnerType()), $attrs);
}
/**
* Common implementation for the part list pages.
* @param Request $request The request to parse
* @param string $template The template that should be rendered
* @param callable|null $filter_changer A function that is called with the filter object as parameter. This function can be used to customize the filter
* @param callable|null $form_changer A function that is called with the form object as parameter. This function can be used to customize the form
* @param array $additonal_template_vars Any additional template variables that should be passed to the template
* @param array $additional_table_vars Any additional variables that should be passed to the table creation
* @return Response
*/
protected function showListWithFilter(Request $request, string $template, ?callable $filter_changer = null, ?callable $form_changer = null, array $additonal_template_vars = [], array $additional_table_vars = []): Response
{
$formRequest = clone $request;
$formRequest->setMethod('GET');
$filter = new PartFilter($this->nodesListBuilder);
if($filter_changer !== null){
$filter_changer($filter);
}
$filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']);
if($form_changer !== null) {
$form_changer($filterForm);
}
$filterForm->handleRequest($formRequest);
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(['filter' => $filter], $additional_table_vars))
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Parts/lists/category_list.html.twig', [
return $this->render($template, array_merge([
'datatable' => $table,
'entity' => $category,
'repo' => $this->entityManager->getRepository(Category::class),
]);
'filterForm' => $filterForm->createView(),
], $additonal_template_vars));
}
/**
* @Route("/category/{id}/parts", name="part_list_category")
*
* @return JsonResponse|Response
*/
public function showCategory(Category $category, Request $request)
{
return $this->showListWithFilter($request,
'Parts/lists/category_list.html.twig',
function (PartFilter $filter) use ($category) {
$filter->getCategory()->setOperator('INCLUDING_CHILDREN')->setValue($category);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('category')->get('value'));
}, [
'entity' => $category,
'repo' => $this->entityManager->getRepository(Category::class),
]
);
}
/**
@ -123,20 +189,19 @@ class PartListsController extends AbstractController
*
* @return JsonResponse|Response
*/
public function showFootprint(Footprint $footprint, Request $request, DataTableFactory $dataTable)
public function showFootprint(Footprint $footprint, Request $request)
{
$table = $dataTable->createFromType(PartsDataTable::class, ['footprint' => $footprint])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Parts/lists/footprint_list.html.twig', [
'datatable' => $table,
'entity' => $footprint,
'repo' => $this->entityManager->getRepository(Footprint::class),
]);
return $this->showListWithFilter($request,
'Parts/lists/footprint_list.html.twig',
function (PartFilter $filter) use ($footprint) {
$filter->getFootprint()->setOperator('INCLUDING_CHILDREN')->setValue($footprint);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('footprint')->get('value'));
}, [
'entity' => $footprint,
'repo' => $this->entityManager->getRepository(Footprint::class),
]
);
}
/**
@ -144,20 +209,19 @@ class PartListsController extends AbstractController
*
* @return JsonResponse|Response
*/
public function showManufacturer(Manufacturer $manufacturer, Request $request, DataTableFactory $dataTable)
public function showManufacturer(Manufacturer $manufacturer, Request $request)
{
$table = $dataTable->createFromType(PartsDataTable::class, ['manufacturer' => $manufacturer])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Parts/lists/manufacturer_list.html.twig', [
'datatable' => $table,
'entity' => $manufacturer,
'repo' => $this->entityManager->getRepository(Manufacturer::class),
]);
return $this->showListWithFilter($request,
'Parts/lists/manufacturer_list.html.twig',
function (PartFilter $filter) use ($manufacturer) {
$filter->getManufacturer()->setOperator('INCLUDING_CHILDREN')->setValue($manufacturer);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('manufacturer')->get('value'));
}, [
'entity' => $manufacturer,
'repo' => $this->entityManager->getRepository(Manufacturer::class),
]
);
}
/**
@ -165,20 +229,19 @@ class PartListsController extends AbstractController
*
* @return JsonResponse|Response
*/
public function showStorelocation(Storelocation $storelocation, Request $request, DataTableFactory $dataTable)
public function showStorelocation(Storelocation $storelocation, Request $request)
{
$table = $dataTable->createFromType(PartsDataTable::class, ['storelocation' => $storelocation])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Parts/lists/store_location_list.html.twig', [
'datatable' => $table,
'entity' => $storelocation,
'repo' => $this->entityManager->getRepository(Storelocation::class),
]);
return $this->showListWithFilter($request,
'Parts/lists/store_location_list.html.twig',
function (PartFilter $filter) use ($storelocation) {
$filter->getStorelocation()->setOperator('INCLUDING_CHILDREN')->setValue($storelocation);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('storelocation')->get('value'));
}, [
'entity' => $storelocation,
'repo' => $this->entityManager->getRepository(Storelocation::class),
]
);
}
/**
@ -186,20 +249,19 @@ class PartListsController extends AbstractController
*
* @return JsonResponse|Response
*/
public function showSupplier(Supplier $supplier, Request $request, DataTableFactory $dataTable)
public function showSupplier(Supplier $supplier, Request $request)
{
$table = $dataTable->createFromType(PartsDataTable::class, ['supplier' => $supplier])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Parts/lists/supplier_list.html.twig', [
'datatable' => $table,
'entity' => $supplier,
'repo' => $this->entityManager->getRepository(Supplier::class),
]);
return $this->showListWithFilter($request,
'Parts/lists/supplier_list.html.twig',
function (PartFilter $filter) use ($supplier) {
$filter->getSupplier()->setOperator('INCLUDING_CHILDREN')->setValue($supplier);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('supplier')->get('value'));
}, [
'entity' => $supplier,
'repo' => $this->entityManager->getRepository(Supplier::class),
]
);
}
/**
@ -210,17 +272,37 @@ class PartListsController extends AbstractController
public function showTag(string $tag, Request $request, DataTableFactory $dataTable)
{
$tag = trim($tag);
$table = $dataTable->createFromType(PartsDataTable::class, ['tag' => $tag])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->showListWithFilter($request,
'Parts/lists/tags_list.html.twig',
function (PartFilter $filter) use ($tag) {
$filter->getTags()->setOperator('ANY')->setValue($tag);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('tags')->get('value'));
}, [
'tag' => $tag,
]
);
}
return $this->render('Parts/lists/tags_list.html.twig', [
'tag' => $tag,
'datatable' => $table,
]);
private function searchRequestToFilter(Request $request): PartSearchFilter
{
$filter = new PartSearchFilter($request->query->get('keyword', ''));
$filter->setName($request->query->getBoolean('name', true));
$filter->setCategory($request->query->getBoolean('category', true));
$filter->setDescription($request->query->getBoolean('description', true));
$filter->setTags($request->query->getBoolean('tags', true));
$filter->setStorelocation($request->query->getBoolean('storelocation', true));
$filter->setComment($request->query->getBoolean('comment', true));
$filter->setOrdernr($request->query->getBoolean('ordernr', true));
$filter->setSupplier($request->query->getBoolean('supplier', false));
$filter->setManufacturer($request->query->getBoolean('manufacturer', false));
$filter->setFootprint($request->query->getBoolean('footprint', false));
$filter->setRegex($request->query->getBoolean('regex', false));
return $filter;
}
/**
@ -230,35 +312,20 @@ class PartListsController extends AbstractController
*/
public function showSearch(Request $request, DataTableFactory $dataTable)
{
$search = $request->query->get('keyword', '');
$search_options = [
'name' => $request->query->getBoolean('name'),
'description' => $request->query->getBoolean('description'),
'comment' => $request->query->getBoolean('comment'),
'category' => $request->query->getBoolean('category'),
'store_location' => $request->query->getBoolean('storelocation'),
'supplier' => $request->query->getBoolean('supplier'),
'ordernr' => $request->query->getBoolean('ordernr'),
'manufacturer' => $request->query->getBoolean('manufacturer'),
'footprint' => $request->query->getBoolean('footprint'),
'tags' => $request->query->getBoolean('tags'),
'regex' => $request->query->getBoolean('regex'),
];
$searchFilter = $this->searchRequestToFilter($request);
$table = $dataTable->createFromType(PartsDataTable::class, [
'search' => $search,
'search_options' => $search_options,
])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Parts/lists/search_list.html.twig', [
'datatable' => $table,
'keyword' => $search,
]);
return $this->showListWithFilter($request,
'Parts/lists/search_list.html.twig',
null,
null,
[
'keyword' => $searchFilter->getKeyword(),
'searchFilter' => $searchFilter,
],
[
'search' => $searchFilter,
]
);
}
/**
@ -268,13 +335,6 @@ class PartListsController extends AbstractController
*/
public function showAll(Request $request, DataTableFactory $dataTable)
{
$table = $dataTable->createFromType(PartsDataTable::class)
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Parts/lists/all_list.html.twig', ['datatable' => $table]);
return $this->showListWithFilter($request,'Parts/lists/all_list.html.twig');
}
}

View file

@ -42,9 +42,22 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter;
use App\Entity\Parameters\DeviceParameter;
use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\MeasurementUnitParameter;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\PriceInformations\Currency;
use App\Repository\ParameterRepository;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\BuiltinAttachmentsFinder;
use App\Services\TagFinder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Asset\Packages;
use Symfony\Component\HttpFoundation\JsonResponse;
@ -99,6 +112,58 @@ class TypeaheadController extends AbstractController
return new JsonResponse($data, 200, [], true);
}
/**
* This functions map the parameter type to the class, so we can access its repository
* @param string $type
* @return class-string
*/
private function typeToParameterClass(string $type): string
{
switch ($type) {
case 'category':
return CategoryParameter::class;
case 'part':
return PartParameter::class;
case 'device':
return DeviceParameter::class;
case 'footprint':
return FootprintParameter::class;
case 'manufacturer':
return ManufacturerParameter::class;
case 'storelocation':
return StorelocationParameter::class;
case 'supplier':
return SupplierParameter::class;
case 'attachment_type':
return AttachmentTypeParameter::class;
case 'group':
return GroupParameter::class;
case 'measurement_unit':
return MeasurementUnitParameter::class;
case 'currency':
return Currency::class;
default:
throw new \InvalidArgumentException('Invalid parameter type: '.$type);
}
}
/**
* @Route("/parameters/{type}/search/{query}", name="typeahead_parameters", requirements={"type" = ".+"})
* @param string $query
* @return JsonResponse
*/
public function parameters(string $type, EntityManagerInterface $entityManager, string $query = ""): JsonResponse
{
$class = $this->typeToParameterClass($type);
/** @var ParameterRepository $repository */
$repository = $entityManager->getRepository($class);
$data = $repository->autocompleteParamName($query);
return new JsonResponse($data);
}
/**
* @Route("/tags/search/{query}", name="typeahead_tags", requirements={"query"= ".+"})
*/

View file

@ -43,14 +43,16 @@ declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\PrettyBoolColumn;
use App\DataTables\Filters\AttachmentFilter;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\BoolColumn;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
@ -79,6 +81,7 @@ final class AttachmentDataTable implements DataTableTypeInterface
{
$dataTable->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, Attachment $context) {
if ($context->isPicture()
&& !$context->isExternal()
@ -166,7 +169,7 @@ final class AttachmentDataTable implements DataTableTypeInterface
}
return sprintf(
'<span class="badge badge-warning">
'<span class="badge bg-warning">
<i class="fas fa-exclamation-circle fa-fw"></i>%s
</span>',
$this->translator->trans('attachment.file_not_found')
@ -184,37 +187,25 @@ final class AttachmentDataTable implements DataTableTypeInterface
'visible' => false,
]);
$dataTable->add('show_in_table', BoolColumn::class, [
$dataTable->add('show_in_table', PrettyBoolColumn::class, [
'label' => 'attachment.edit.show_in_table',
'trueValue' => $this->translator->trans('true'),
'falseValue' => $this->translator->trans('false'),
'nullValue' => '',
'visible' => false,
]);
$dataTable->add('isPicture', BoolColumn::class, [
$dataTable->add('isPicture', PrettyBoolColumn::class, [
'label' => 'attachment.edit.isPicture',
'trueValue' => $this->translator->trans('true'),
'falseValue' => $this->translator->trans('false'),
'nullValue' => '',
'visible' => false,
'propertyPath' => 'picture',
]);
$dataTable->add('is3DModel', BoolColumn::class, [
$dataTable->add('is3DModel', PrettyBoolColumn::class, [
'label' => 'attachment.edit.is3DModel',
'trueValue' => $this->translator->trans('true'),
'falseValue' => $this->translator->trans('false'),
'nullValue' => '',
'visible' => false,
'propertyPath' => '3dmodel',
]);
$dataTable->add('isBuiltin', BoolColumn::class, [
$dataTable->add('isBuiltin', PrettyBoolColumn::class, [
'label' => 'attachment.edit.isBuiltin',
'trueValue' => $this->translator->trans('true'),
'falseValue' => $this->translator->trans('false'),
'nullValue' => '',
'visible' => false,
'propertyPath' => 'builtin',
]);
@ -224,6 +215,12 @@ final class AttachmentDataTable implements DataTableTypeInterface
'query' => function (QueryBuilder $builder): void {
$this->getQuery($builder);
},
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
]);
}
@ -236,4 +233,18 @@ final class AttachmentDataTable implements DataTableTypeInterface
->leftJoin('attachment.attachment_type', 'attachment_type');
//->leftJoin('attachment.element', 'element');
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
//We do the most stuff here in the filter class
if (isset($options['filter'])) {
if(!$options['filter'] instanceof AttachmentFilter) {
throw new \Exception('filter must be an instance of AttachmentFilter!');
}
$filter = $options['filter'];
$filter->apply($builder);
}
}
}

View file

@ -58,7 +58,7 @@ class LocaleDateTimeColumn extends AbstractColumn
{
/**
* @param $value
* @return bool|mixed|string
* @return string
* @throws Exception
*/
public function normalize($value): string

View file

@ -0,0 +1,49 @@
<?php
namespace App\DataTables\Column;
use Omines\DataTablesBundle\Column\AbstractColumn;
use Symfony\Contracts\Translation\TranslatorInterface;
class PrettyBoolColumn extends AbstractColumn
{
protected $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}
public function normalize($value): ?bool
{
if (null === $value) {
return null;
}
return (bool) $value;
}
public function render($value, $context)
{
if ($value === true) {
return '<span class="badge bg-success"><i class="fa-solid fa-circle-check fa-fw"></i> '
. $this->translator->trans('bool.true')
. '</span>';
}
if ($value === false) {
return '<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark fa-fw"></i> '
. $this->translator->trans('bool.false')
. '</span>';
}
if ($value === null) {
return '<span class="badge bg-secondary>"<i class="fa-solid fa-circle-question fa-fw"></i> '
. $this->translator->trans('bool.unknown')
. '</span>';
}
throw new \RuntimeException('Unexpected value!');
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\DataTables\Column;
use App\Services\SIFormatter;
use Omines\DataTablesBundle\Column\AbstractColumn;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SIUnitNumberColumn extends AbstractColumn
{
protected $formatter;
public function __construct(SIFormatter $formatter)
{
$this->formatter = $formatter;
}
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefault('precision', 2);
$resolver->setDefault('unit', '');
return $this;
}
public function normalize($value)
{
//Ignore null values
if ($value === null) {
return '';
}
return $this->formatter->format((float) $value, $this->options['unit'], $this->options['precision']);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\DataTables\Column;
use Omines\DataTablesBundle\Column\AbstractColumn;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A column representing the checkboxes for select extensions.
*/
class SelectColumn extends AbstractColumn
{
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'label' => '',
'orderable' => false,
'searchable' => false,
'className' => 'select-checkbox no-colvis',
'visible' => true,
]);
return $this;
}
public function normalize($value)
{
return $value;
}
public function render($value, $context)
{
//Return empty string, as it this column is filled by datatables on client side
return '';
}
}

View file

@ -79,7 +79,7 @@ class TagsColumn extends AbstractColumn
break;
}
$html .= sprintf(
'<a href="%s" class="badge badge-primary badge-table">%s</a>',
'<a href="%s" class="badge bg-primary badge-table">%s</a>',
$this->urlGenerator->generate('part_list_tags', ['tag' => $tag]),
htmlspecialchars($tag)
);

View file

@ -0,0 +1,121 @@
<?php
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\InstanceOfConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
class AttachmentFilter implements FilterInterface
{
use CompoundFilterTrait;
/** @var NumberConstraint */
protected $dbId;
/** @var InstanceOfConstraint */
protected $targetType;
/** @var TextConstraint */
protected $name;
/** @var EntityConstraint */
protected $attachmentType;
/** @var BooleanConstraint */
protected $showInTable;
/** @var DateTimeConstraint */
protected $lastModified;
/** @var DateTimeConstraint */
protected $addedDate;
public function __construct(NodesListBuilder $nodesListBuilder)
{
$this->dbId = new IntConstraint('attachment.id');
$this->name = new TextConstraint('attachment.name');
$this->targetType = new InstanceOfConstraint('attachment');
$this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, 'attachment.attachment_type');
$this->lastModified = new DateTimeConstraint('attachment.lastModified');
$this->addedDate = new DateTimeConstraint('attachment.addedDate');
$this->showInTable = new BooleanConstraint('attachment.show_in_table');
}
public function apply(QueryBuilder $queryBuilder): void
{
$this->applyAllChildFilters($queryBuilder);
}
/**
* @return NumberConstraint
*/
public function getDbId(): NumberConstraint
{
return $this->dbId;
}
/**
* @return TextConstraint
*/
public function getName(): TextConstraint
{
return $this->name;
}
/**
* @return DateTimeConstraint
*/
public function getLastModified(): DateTimeConstraint
{
return $this->lastModified;
}
/**
* @return DateTimeConstraint
*/
public function getAddedDate(): DateTimeConstraint
{
return $this->addedDate;
}
/**
* @return BooleanConstraint
*/
public function getShowInTable(): BooleanConstraint
{
return $this->showInTable;
}
/**
* @return EntityConstraint
*/
public function getAttachmentType(): EntityConstraint
{
return $this->attachmentType;
}
/**
* @return InstanceOfConstraint
*/
public function getTargetType(): InstanceOfConstraint
{
return $this->targetType;
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\DataTables\Filters;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\QueryBuilder;
trait CompoundFilterTrait
{
/**
* Find all child filters that are contained in this filter using reflection.
* A map is returned to the form "property_name" => $filter_object
* @return FilterInterface[]
*/
protected function findAllChildFilters(): array
{
$filters = [];
$reflection = new \ReflectionClass($this);
foreach ($reflection->getProperties() as $property) {
//Set property to accessible (otherwise we run into problems on PHP < 8.1)
$property->setAccessible(true);
$value = $property->getValue($this);
//We only want filters (objects implementing FilterInterface)
if($value instanceof FilterInterface) {
$filters[$property->getName()] = $value;
}
//Add filters in collections
if ($value instanceof Collection) {
foreach ($value as $key => $filter) {
if($filter instanceof FilterInterface) {
$filters[$property->getName() . '.' . (string) $key] = $filter;
}
}
}
}
return $filters;
}
/**
* Applies all children filters that are declared as property of this filter using reflection.
* @param QueryBuilder $queryBuilder
* @return void
*/
protected function applyAllChildFilters(QueryBuilder $queryBuilder): void
{
//Retrieve all child filters and apply them
$filters = $this->findAllChildFilters();
foreach ($filters as $filter) {
$filter->apply($queryBuilder);
}
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\DataTables\Filters\Constraints;
use App\DataTables\Filters\FilterInterface;
use Doctrine\ORM\QueryBuilder;
abstract class AbstractConstraint implements FilterInterface
{
use FilterTrait;
/**
* @var string The property where this BooleanConstraint should apply to
*/
protected $property;
/**
* @var string
*/
protected $identifier;
/**
* Determines whether this constraint is active or not. This should be decided accordingly to the value of the constraint
* @return bool True if the constraint is active, false otherwise
*/
abstract public function isEnabled(): bool;
public function __construct(string $property, string $identifier = null)
{
$this->property = $property;
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\DataTables\Filters\Constraints;
use App\DataTables\Filters\FilterInterface;
use Doctrine\ORM\QueryBuilder;
class BooleanConstraint extends AbstractConstraint
{
/** @var bool|null The value of our constraint */
protected $value;
public function __construct(string $property, string $identifier = null, ?bool $default_value = null)
{
parent::__construct($property, $identifier);
$this->value = $default_value;
}
/**
* Gets the value of this constraint. Null means "don't filter", true means "filter for true", false means "filter for false".
* @return bool|null
*/
public function getValue(): ?bool
{
return $this->value;
}
/**
* Sets the value of this constraint. Null means "don't filter", true means "filter for true", false means "filter for false".
* @param bool|null $value
*/
public function setValue(?bool $value): void
{
$this->value = $value;
}
public function isEnabled(): bool
{
return $this->value !== null;
}
public function apply(QueryBuilder $queryBuilder): void
{
//Do not apply a filter if value is null (filter is set to ignore)
if(!$this->isEnabled()) {
return;
}
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, '=', $this->value);
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
class ChoiceConstraint extends AbstractConstraint
{
public const ALLOWED_OPERATOR_VALUES = ['ANY', 'NONE'];
/**
* @var string[]|int[] The values to compare to
*/
protected $value;
/**
* @var string The operator to use
*/
protected $operator;
/**
* @return string[]|int[]
*/
public function getValue(): array
{
return $this->value;
}
/**
* @param string[]|int[] $value
* @return ChoiceConstraint
*/
public function setValue(array $value): ChoiceConstraint
{
$this->value = $value;
return $this;
}
/**
* @return string
*/
public function getOperator(): string
{
return $this->operator;
}
/**
* @param string $operator
* @return ChoiceConstraint
*/
public function setOperator(string $operator): ChoiceConstraint
{
$this->operator = $operator;
return $this;
}
public function isEnabled(): bool
{
return !empty($this->operator);
}
public function apply(QueryBuilder $queryBuilder): void
{
//If no value is provided then we do not apply a filter
if (!$this->isEnabled()) {
return;
}
//Ensure we have an valid operator
if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) {
throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
if ($this->operator === 'ANY') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'IN', $this->value);
} elseif ($this->operator === 'NONE') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $this->value);
} else {
throw new \RuntimeException('Unknown operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\DataTables\Filters\Constraints;
/**
* An alias of NumberConstraint to use to filter on a DateTime
*/
class DateTimeConstraint extends NumberConstraint
{
}

View file

@ -0,0 +1,184 @@
<?php
namespace App\DataTables\Filters\Constraints;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
/**
* @template T of AbstractDBElement
*/
class EntityConstraint extends AbstractConstraint
{
private const ALLOWED_OPERATOR_VALUES_BASE = ['=', '!='];
private const ALLOWED_OPERATOR_VALUES_STRUCTURAL = ['INCLUDING_CHILDREN', 'EXCLUDING_CHILDREN'];
/**
* @var NodesListBuilder
*/
protected $nodesListBuilder;
/**
* @var class-string<T> The class to use for the comparison
*/
protected $class;
/**
* @var string|null The operator to use
*/
protected $operator;
/**
* @var T The value to compare to
*/
protected $value;
/**
* @param NodesListBuilder|null $nodesListBuilder
* @param class-string<T> $class
* @param string $property
* @param string|null $identifier
* @param null $value
* @param string $operator
*/
public function __construct(?NodesListBuilder $nodesListBuilder, string $class, string $property, string $identifier = null, $value = null, string $operator = '')
{
$this->nodesListBuilder = $nodesListBuilder;
$this->class = $class;
if ($nodesListBuilder === null && $this->isStructural()) {
throw new \InvalidArgumentException('NodesListBuilder must be provided for structural entities');
}
parent::__construct($property, $identifier);
$this->value = $value;
$this->operator = $operator;
}
public function getClass(): string
{
return $this->class;
}
/**
* @return string|null
*/
public function getOperator(): ?string
{
return $this->operator;
}
/**
* @param string|null $operator
*/
public function setOperator(?string $operator): self
{
$this->operator = $operator;
return $this;
}
/**
* @return T|null
*/
public function getValue(): ?AbstractDBElement
{
return $this->value;
}
/**
* @param T|null $value
*/
public function setValue(?AbstractDBElement $value): void
{
if (!$value instanceof $this->class) {
throw new \InvalidArgumentException('The value must be an instance of ' . $this->class);
}
$this->value = $value;
}
/**
* Checks whether the constraints apply to a structural type or not
* @return bool
*/
public function isStructural(): bool
{
return is_subclass_of($this->class, AbstractStructuralDBElement::class);
}
/**
* Returns a list of operators which are allowed with the given class.
* @return string[]
*/
public function getAllowedOperatorValues(): array
{
//Base operators are allowed for everything
$tmp = self::ALLOWED_OPERATOR_VALUES_BASE;
if ($this->isStructural()) {
$tmp = array_merge($tmp, self::ALLOWED_OPERATOR_VALUES_STRUCTURAL);
}
return $tmp;
}
public function isEnabled(): bool
{
return !empty($this->operator);
}
public function apply(QueryBuilder $queryBuilder): void
{
//If no value is provided then we do not apply a filter
if (!$this->isEnabled()) {
return;
}
//Ensure we have an valid operator
if(!in_array($this->operator, $this->getAllowedOperatorValues(), true)) {
throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', $this->getAllowedOperatorValues()));
}
//We need to handle null values differently, as they can not be compared with == or !=
if ($this->value === null) {
if($this->operator === '=' || $this->operator === 'INCLUDING_CHILDREN') {
$queryBuilder->andWhere(sprintf("%s IS NULL", $this->property));
return;
}
if ($this->operator === '!=' || $this->operator === 'EXCLUDING_CHILDREN') {
$queryBuilder->andWhere(sprintf("%s IS NOT NULL", $this->property));
return;
}
throw new \RuntimeException('Unknown operator '. $this->operator . ' provided. Valid operators are '. implode(', ', $this->getAllowedOperatorValues()));
}
if($this->operator === '=' || $this->operator === '!=') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value);
return;
}
//Otherwise retrieve the children list and apply the operator to it
if($this->isStructural()) {
$list = $this->nodesListBuilder->getChildrenFlatList($this->value);
//Add the element itself to the list
$list[] = $this->value;
if ($this->operator === 'INCLUDING_CHILDREN') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'IN', $list);
return;
}
if ($this->operator === 'EXCLUDING_CHILDREN') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $list);
return;
}
} else {
throw new \RuntimeException('Cannot apply operator '. $this->operator . ' to non-structural type');
}
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\QueryBuilder;
trait FilterTrait
{
protected $useHaving = false;
public function useHaving($value = true): self
{
$this->useHaving = $value;
return $this;
}
/**
* Checks if the given input is an aggregateFunction like COUNT(part.partsLot) or so
* @return bool
*/
protected function isAggregateFunctionString(string $input): bool
{
return preg_match('/^[a-zA-Z]+\(.*\)$/', $input) === 1;
}
/**
* Generates a parameter identifier that can be used for the given property. It gives random results, to be unique, so you have to cache it.
* @param string $property
* @return string
*/
protected function generateParameterIdentifier(string $property): string
{
//Replace all special characters with underscores
$property = preg_replace('/[^a-zA-Z0-9_]/', '_', $property);
//Add a random number to the end of the property name for uniqueness
return $property . '_' . uniqid("", false);
}
/**
* Adds a simple constraint in the form of (property OPERATOR value) (e.g. "part.name = :name") to the given query builder.
* @param QueryBuilder $queryBuilder
* @param string $property
* @param string $comparison_operator
* @param mixed $value
* @return void
*/
protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, $value): void
{
if ($comparison_operator === 'IN' || $comparison_operator === 'NOT IN') {
$expression = sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier);
} else {
$expression = sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier);
}
if($this->useHaving || $this->isAggregateFunctionString($property)) { //If the property is an aggregate function, we have to use the "having" instead of the "where"
$queryBuilder->andHaving($expression);
} else {
$queryBuilder->andWhere($expression);
}
$queryBuilder->setParameter($parameterIdentifier, $value);
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
/**
* This constraint allows to filter by a given list of classes, that the given property should be an instance of
*/
class InstanceOfConstraint extends AbstractConstraint
{
public const ALLOWED_OPERATOR_VALUES = ['ANY', 'NONE'];
/**
* @var string[] The values to compare to (fully qualified class names)
*/
protected $value;
/**
* @var string The operator to use
*/
protected $operator;
/**
* @return string[]
*/
public function getValue(): array
{
return $this->value;
}
/**
* @param string[] $value
* @return $this
*/
public function setValue(array $value): self
{
$this->value = $value;
return $this;
}
/**
* @return string
*/
public function getOperator(): string
{
return $this->operator;
}
/**
* @param string $operator
* @return $this
*/
public function setOperator(string $operator): self
{
$this->operator = $operator;
return $this;
}
public function isEnabled(): bool
{
return !empty($this->operator);
}
public function apply(QueryBuilder $queryBuilder): void
{
//If no value is provided then we do not apply a filter
if (!$this->isEnabled()) {
return;
}
//Ensure we have an valid operator
if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) {
throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
$expressions = [];
if ($this->operator === 'ANY' || $this->operator === 'NONE') {
foreach($this->value as $value) {
//We cannnot use an paramater here, as this is the only way to pass the FCQN to the query (via binded params, we would need to use ClassMetaData). See: https://github.com/doctrine/orm/issues/4462
$expressions[] = ($queryBuilder->expr()->isInstanceOf($this->property, $value));
}
if($this->operator === 'ANY') {
$queryBuilder->andWhere($queryBuilder->expr()->orX(...$expressions));
} else { //NONE
$queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$expressions)));
}
} else {
throw new \RuntimeException('Unknown operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
class IntConstraint extends NumberConstraint
{
public function apply(QueryBuilder $queryBuilder): void
{
if($this->value1 !== null) {
$this->value1 = (int) $this->value1;
}
if($this->value2 !== null) {
$this->value2 = (int) $this->value2;
}
parent::apply($queryBuilder);
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\QueryBuilder;
use \RuntimeException;
class NumberConstraint extends AbstractConstraint
{
public const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN'];
/**
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
* @var float|null|int|\DateTimeInterface
*/
protected $value1;
/**
* The second value used when operator is RANGE; this is the upper bound of the range
* @var float|null|int|\DateTimeInterface
*/
protected $value2;
/**
* @var string The operator to use
*/
protected $operator;
/**
* @return float|int|null|\DateTimeInterface
*/
public function getValue1()
{
return $this->value1;
}
/**
* @param float|int|\DateTimeInterface|null $value1
*/
public function setValue1($value1): void
{
$this->value1 = $value1;
}
/**
* @return float|int|null
*/
public function getValue2()
{
return $this->value2;
}
/**
* @param float|int|null $value2
*/
public function setValue2($value2): void
{
$this->value2 = $value2;
}
/**
* @return string
*/
public function getOperator(): string
{
return $this->operator;
}
/**
* @param string $operator
*/
public function setOperator(?string $operator): void
{
$this->operator = $operator;
}
public function __construct(string $property, string $identifier = null, $value1 = null, string $operator = null, $value2 = null)
{
parent::__construct($property, $identifier);
$this->value1 = $value1;
$this->value2 = $value2;
$this->operator = $operator;
}
public function isEnabled(): bool
{
return $this->value1 !== null
&& !empty($this->operator);
}
public function apply(QueryBuilder $queryBuilder): void
{
//If no value is provided then we do not apply a filter
if (!$this->isEnabled()) {
return;
}
//Ensure we have an valid operator
if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) {
throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
if ($this->operator !== 'BETWEEN') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value1);
} else {
if ($this->value2 === null) {
throw new RuntimeException("Cannot use operator BETWEEN without value2!");
}
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '1', '>=', $this->value1);
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '2', '<=', $this->value2);
}
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Parameters\PartParameter;
use Doctrine\ORM\QueryBuilder;
use Svg\Tag\Text;
class ParameterConstraint extends AbstractConstraint
{
/** @var string */
protected $name;
/** @var string */
protected $symbol;
/** @var string */
protected $unit;
/** @var TextConstraint */
protected $value_text;
/** @var ParameterValueConstraint */
protected $value;
/** @var string The alias to use for the subquery */
protected $alias;
public function __construct()
{
parent::__construct("parts.parameters");
//The alias has to be uniq for each subquery, so generate a random one
$this->alias = uniqid('param_', false);
$this->value_text = new TextConstraint($this->alias . '.value_text');
$this->value = new ParameterValueConstraint($this->alias );
}
public function isEnabled(): bool
{
return true;
}
public function apply(QueryBuilder $queryBuilder): void
{
//Create a new qb to build the subquery
$subqb = new QueryBuilder($queryBuilder->getEntityManager());
$subqb->select('COUNT(' . $this->alias . ')')
->from(PartParameter::class, $this->alias)
->where($this->alias . '.element = part');
if (!empty($this->name)) {
$paramName = $this->generateParameterIdentifier('params.name');
$subqb->andWhere($this->alias . '.name = :' . $paramName);
$queryBuilder->setParameter($paramName, $this->name);
}
if (!empty($this->symbol)) {
$paramName = $this->generateParameterIdentifier('params.symbol');
$subqb->andWhere($this->alias . '.symbol = :' . $paramName);
$queryBuilder->setParameter($paramName, $this->symbol);
}
if (!empty($this->unit)) {
$paramName = $this->generateParameterIdentifier('params.unit');
$subqb->andWhere($this->alias . '.unit = :' . $paramName);
$queryBuilder->setParameter($paramName, $this->unit);
}
//Apply all subfilters
$this->value_text->apply($subqb);
$this->value->apply($subqb);
//Copy all parameters from the subquery to the main query
//We can not use setParameters here, as this would override the exiting paramaters in queryBuilder
foreach ($subqb->getParameters() as $parameter) {
$queryBuilder->setParameter($parameter->getName(), $parameter->getValue());
}
$queryBuilder->andWhere('(' . $subqb->getDQL() . ') > 0');
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
* @return ParameterConstraint
*/
public function setName(string $name): ParameterConstraint
{
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getSymbol(): string
{
return $this->symbol;
}
/**
* @param string $symbol
* @return ParameterConstraint
*/
public function setSymbol(string $symbol): ParameterConstraint
{
$this->symbol = $symbol;
return $this;
}
/**
* @return string
*/
public function getUnit(): string
{
return $this->unit;
}
/**
* @param string $unit
* @return ParameterConstraint
*/
public function setUnit(string $unit): ParameterConstraint
{
$this->unit = $unit;
return $this;
}
/**
* @return TextConstraint
*/
public function getValueText(): TextConstraint
{
return $this->value_text;
}
/**
* @return ParameterValueConstraint
*/
public function getValue(): ParameterValueConstraint
{
return $this->value;
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\NumberConstraint;
use Doctrine\ORM\QueryBuilder;
class ParameterValueConstraint extends NumberConstraint
{
protected $alias;
public const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN',
//Additional operators
'IN_RANGE', 'NOT_IN_RANGE', 'GREATER_THAN_RANGE', 'GREATER_EQUAL_RANGE', 'LESS_THAN_RANGE', 'LESS_EQUAL_RANGE', 'RANGE_IN_RANGE', 'RANGE_INTERSECT_RANGE'];
/**
* @param string $alias The alias which is used in the sub query of ParameterConstraint
*/
public function __construct(string $alias) {
$this->alias = $alias;
parent::__construct($alias . '.value_typical');
}
public function apply(QueryBuilder $queryBuilder): void
{
//Skip if not enabled
if(!$this->isEnabled()) {
return;
}
$paramName1 = $this->generateParameterIdentifier('value1');
$paramName2 = $this->generateParameterIdentifier('value2');
if ($this->operator === 'IN_RANGE') {
$queryBuilder->andWhere(
"({$this->alias}.value_min <= :{$paramName1} AND {$this->alias}.value_max >= :{$paramName1}) OR
({$this->alias}.value_typical = :{$paramName1})"
);
$queryBuilder->setParameter($paramName1, $this->value1);
return;
}
if ($this->operator === 'NOT_IN_RANGE') {
$queryBuilder->andWhere(
"({$this->alias}.value_min > :{$paramName1} OR {$this->alias}.value_max < :{$paramName1}) AND
({$this->alias}.value_typical IS NULL OR {$this->alias}.value_typical != :{$paramName1})"
);
$queryBuilder->setParameter($paramName1, $this->value1);
return;
}
if ($this->operator === 'GREATER_THAN_RANGE') {
$queryBuilder->andWhere(
"{$this->alias}.value_max < :{$paramName1} OR {$this->alias}.value_typical < :{$paramName1}"
);
$queryBuilder->setParameter($paramName1, $this->value1);
return;
}
if ($this->operator === 'GREATER_EQUAL_RANGE') {
$queryBuilder->andWhere(
"{$this->alias}.value_max <= :{$paramName1} OR {$this->alias}.value_typical <= :{$paramName1}"
);
$queryBuilder->setParameter($paramName1, $this->value1);
return;
}
if ($this->operator === 'LESS_THAN_RANGE') {
$queryBuilder->andWhere(
"{$this->alias}.value_min > :{$paramName1} OR {$this->alias}.value_typical > :{$paramName1}"
);
$queryBuilder->setParameter($paramName1, $this->value1);
return;
}
if ($this->operator === 'LESS_EQUAL_RANGE') {
$queryBuilder->andWhere(
"{$this->alias}.value_min >= :{$paramName1} OR {$this->alias}.value_typical >= :{$paramName1}"
);
$queryBuilder->setParameter($paramName1, $this->value1);
return;
}
// This operator means the constraint range must lie completely within the parameter value range
if ($this->operator === 'RANGE_IN_RANGE') {
$queryBuilder->andWhere(
"({$this->alias}.value_min <= :{$paramName1} AND {$this->alias}.value_max >= :{$paramName2}) OR
({$this->alias}.value_typical >= :{$paramName1} AND {$this->alias}.value_typical <= :{$paramName2})"
);
$queryBuilder->setParameter($paramName1, $this->value1);
$queryBuilder->setParameter($paramName2, $this->value2);
return;
}
if ($this->operator === 'RANGE_INTERSECT_RANGE') {
$queryBuilder->andWhere(
//The ORs are important here!!
"({$this->alias}.value_min <= :{$paramName1} OR {$this->alias}.value_min <= :{$paramName2}) OR
({$this->alias}.value_max >= :{$paramName1} OR {$this->alias}.value_max >= :{$paramName2}) OR
({$this->alias}.value_typical >= :{$paramName1} AND {$this->alias}.value_typical <= :{$paramName2})"
);
$queryBuilder->setParameter($paramName1, $this->value1);
$queryBuilder->setParameter($paramName2, $this->value2);
return;
}
//For all other cases use the default implementation
parent::apply($queryBuilder);
}
}

View file

@ -0,0 +1,137 @@
<?php
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class TagsConstraint extends AbstractConstraint
{
public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
/**
* @var string|null The operator to use
*/
protected $operator;
/**
* @var string The value to compare to
*/
protected $value;
public function __construct(string $property, string $identifier = null, $value = null, string $operator = '')
{
parent::__construct($property, $identifier);
$this->value = $value;
$this->operator = $operator;
}
/**
* @return string
*/
public function getOperator(): ?string
{
return $this->operator;
}
/**
* @param string $operator
*/
public function setOperator(?string $operator): self
{
$this->operator = $operator;
return $this;
}
/**
* @return string
*/
public function getValue(): string
{
return $this->value;
}
/**
* @param string $value
*/
public function setValue(string $value): self
{
$this->value = $value;
return $this;
}
public function isEnabled(): bool
{
return $this->value !== null
&& !empty($this->operator);
}
/**
* Returns a list of tags based on the comma separated tags list
* @return string[]
*/
public function getTags(): array
{
return explode(',', trim($this->value, ','));
}
/**
* Builds an expression to query for a single tag
* @param QueryBuilder $queryBuilder
* @param string $tag
* @return Expr\Orx
*/
protected function getExpressionForTag(QueryBuilder $queryBuilder, string $tag): Expr\Orx
{
$tag_identifier_prefix = uniqid($this->identifier . '_', false);
$expr = $queryBuilder->expr();
$tmp = $expr->orX(
$expr->like($this->property, ':' . $tag_identifier_prefix . '_1'),
$expr->like($this->property, ':' . $tag_identifier_prefix . '_2'),
$expr->like($this->property, ':' . $tag_identifier_prefix . '_3'),
$expr->eq($this->property, ':' . $tag_identifier_prefix . '_4'),
);
//Set the parameters for the LIKE expression, in each variation of the tag (so with a comma, at the end, at the beginning, and on both ends, and equaling the tag)
$queryBuilder->setParameter($tag_identifier_prefix . '_1', '%,' . $tag . ',%');
$queryBuilder->setParameter($tag_identifier_prefix . '_2', '%,' . $tag);
$queryBuilder->setParameter($tag_identifier_prefix . '_3', $tag . ',%');
$queryBuilder->setParameter($tag_identifier_prefix . '_4', $tag);
return $tmp;
}
public function apply(QueryBuilder $queryBuilder): void
{
if(!$this->isEnabled()) {
return;
}
if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) {
throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
$tagsExpressions = [];
foreach ($this->getTags() as $tag) {
$tagsExpressions[] = $this->getExpressionForTag($queryBuilder, $tag);
}
if ($this->operator === 'ANY') {
$queryBuilder->andWhere($queryBuilder->expr()->orX(...$tagsExpressions));
return;
}
if ($this->operator === 'ALL') {
$queryBuilder->andWhere($queryBuilder->expr()->andX(...$tagsExpressions));
return;
}
if ($this->operator === 'NONE') {
$queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions)));
return;
}
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
class TextConstraint extends AbstractConstraint
{
public const ALLOWED_OPERATOR_VALUES = ['=', '!=', 'STARTS', 'ENDS', 'CONTAINS', 'LIKE', 'REGEX'];
/**
* @var string|null The operator to use
*/
protected $operator;
/**
* @var string The value to compare to
*/
protected $value;
public function __construct(string $property, string $identifier = null, $value = null, string $operator = '')
{
parent::__construct($property, $identifier);
$this->value = $value;
$this->operator = $operator;
}
/**
* @return string
*/
public function getOperator(): ?string
{
return $this->operator;
}
/**
* @param string $operator
*/
public function setOperator(?string $operator): self
{
$this->operator = $operator;
return $this;
}
/**
* @return string
*/
public function getValue(): string
{
return $this->value;
}
/**
* @param string $value
*/
public function setValue(string $value): self
{
$this->value = $value;
return $this;
}
public function isEnabled(): bool
{
return $this->value !== null
&& !empty($this->operator);
}
public function apply(QueryBuilder $queryBuilder): void
{
if(!$this->isEnabled()) {
return;
}
if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) {
throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
//Equal and not equal can be handled easily
if($this->operator === '=' || $this->operator === '!=') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value);
return;
}
//The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator but we have to build the value string differently
$like_value = null;
if ($this->operator === 'LIKE') {
$like_value = $this->value;
} else if ($this->operator === 'STARTS') {
$like_value = $this->value . '%';
} else if ($this->operator === 'ENDS') {
$like_value = '%' . $this->value;
} else if ($this->operator === 'CONTAINS') {
$like_value = '%' . $this->value . '%';
}
if ($like_value !== null) {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'LIKE', $like_value);
return;
}
//Regex is only supported on MySQL and needs a special function
if ($this->operator === 'REGEX') {
$queryBuilder->andWhere(sprintf('REGEXP(%s, :%s) = 1', $this->property, $this->identifier));
$queryBuilder->setParameter($this->identifier, $this->value);
}
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\DataTables\Filters;
use Doctrine\ORM\QueryBuilder;
interface FilterInterface
{
/**
* Apply the given filter to the given query builder on the given property
* @param QueryBuilder $queryBuilder
* @return void
*/
public function apply(QueryBuilder $queryBuilder): void;
}

View file

@ -0,0 +1,110 @@
<?php
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\InstanceOfConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
use App\Entity\UserSystem\User;
use Doctrine\ORM\QueryBuilder;
class LogFilter implements FilterInterface
{
use CompoundFilterTrait;
/** @var DateTimeConstraint */
protected $timestamp;
/** @var IntConstraint */
protected $dbId;
/** @var ChoiceConstraint */
protected $level;
/** @var InstanceOfConstraint */
protected $eventType;
/** @var ChoiceConstraint */
protected $targetType;
/** @var IntConstraint */
protected $targetId;
/** @var EntityConstraint */
protected $user;
public function __construct()
{
$this->timestamp = new DateTimeConstraint('log.timestamp');
$this->dbId = new IntConstraint('log.id');
$this->level = new ChoiceConstraint('log.level');
$this->eventType = new InstanceOfConstraint('log');
$this->user = new EntityConstraint(null, User::class, 'log.user');
$this->targetType = new ChoiceConstraint('log.target_type');
$this->targetId = new IntConstraint('log.target_id');
}
public function apply(QueryBuilder $queryBuilder): void
{
$this->applyAllChildFilters($queryBuilder);
}
/**
* @return DateTimeConstraint
*/
public function getTimestamp(): DateTimeConstraint
{
return $this->timestamp;
}
/**
* @return IntConstraint|NumberConstraint
*/
public function getDbId()
{
return $this->dbId;
}
/**
* @return ChoiceConstraint
*/
public function getLevel(): ChoiceConstraint
{
return $this->level;
}
/**
* @return InstanceOfConstraint
*/
public function getEventType(): InstanceOfConstraint
{
return $this->eventType;
}
/**
* @return ChoiceConstraint
*/
public function getTargetType(): ChoiceConstraint
{
return $this->targetType;
}
/**
* @return IntConstraint
*/
public function getTargetId(): IntConstraint
{
return $this->targetId;
}
public function getUser(): EntityConstraint
{
return $this->user;
}
}

View file

@ -0,0 +1,427 @@
<?php
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier;
use App\Services\Trees\NodesListBuilder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\QueryBuilder;
use Svg\Tag\Text;
class PartFilter implements FilterInterface
{
use CompoundFilterTrait;
/** @var NumberConstraint */
protected $dbId;
/** @var TextConstraint */
protected $name;
/** @var TextConstraint */
protected $description;
/** @var TextConstraint */
protected $comment;
/** @var TagsConstraint */
protected $tags;
/** @var NumberConstraint */
protected $minAmount;
/** @var BooleanConstraint */
protected $favorite;
/** @var BooleanConstraint */
protected $needsReview;
/** @var NumberConstraint */
protected $mass;
/** @var DateTimeConstraint */
protected $lastModified;
/** @var DateTimeConstraint */
protected $addedDate;
/** @var EntityConstraint */
protected $category;
/** @var EntityConstraint */
protected $footprint;
/** @var EntityConstraint */
protected $manufacturer;
/** @var ChoiceConstraint */
protected $manufacturing_status;
/** @var EntityConstraint */
protected $supplier;
/** @var IntConstraint */
protected $orderdetailsCount;
/** @var BooleanConstraint */
protected $obsolete;
/** @var EntityConstraint */
protected $storelocation;
/** @var IntConstraint */
protected $lotCount;
/** @var NumberConstraint */
protected $amountSum;
/** @var BooleanConstraint */
protected $lotNeedsRefill;
/** @var TextConstraint */
protected $lotDescription;
/** @var BooleanConstraint */
protected $lotUnknownAmount;
/** @var DateTimeConstraint */
protected $lotExpirationDate;
/** @var EntityConstraint */
protected $measurementUnit;
/** @var TextConstraint */
protected $manufacturer_product_url;
/** @var TextConstraint */
protected $manufacturer_product_number;
/** @var IntConstraint */
protected $attachmentsCount;
/** @var EntityConstraint */
protected $attachmentType;
/** @var TextConstraint */
protected $attachmentName;
/** @var ArrayCollection<int, ParameterConstraint> */
protected $parameters;
/** @var IntConstraint */
protected $parametersCount;
public function __construct(NodesListBuilder $nodesListBuilder)
{
$this->name = new TextConstraint('part.name');
$this->description = new TextConstraint('part.description');
$this->comment = new TextConstraint('part.comment');
$this->category = new EntityConstraint($nodesListBuilder, Category::class, 'part.category');
$this->footprint = new EntityConstraint($nodesListBuilder, Footprint::class, 'part.footprint');
$this->tags = new TagsConstraint('part.tags');
$this->favorite = new BooleanConstraint('part.favorite');
$this->needsReview = new BooleanConstraint('part.needs_review');
$this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit');
$this->mass = new NumberConstraint('part.mass');
$this->dbId = new IntConstraint('part.id');
$this->addedDate = new DateTimeConstraint('part.addedDate');
$this->lastModified = new DateTimeConstraint('part.lastModified');
$this->minAmount = new NumberConstraint('part.minamount');
/* We have to use an IntConstraint here because otherwise we get just an empty result list when applying the filter
This seems to be related to the fact, that PDO does not have an float parameter type and using string type does not work in this situation (at least in SQLite)
TODO: Find a better solution here
*/
$this->amountSum = new IntConstraint('amountSum');
$this->lotCount = new IntConstraint('COUNT(partLots)');
$this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location');
$this->lotNeedsRefill = new BooleanConstraint('partLots.needs_refill');
$this->lotUnknownAmount = new BooleanConstraint('partLots.instock_unknown');
$this->lotExpirationDate = new DateTimeConstraint('partLots.expiration_date');
$this->lotDescription = new TextConstraint('partLots.description');
$this->manufacturer = new EntityConstraint($nodesListBuilder, Manufacturer::class, 'part.manufacturer');
$this->manufacturer_product_number = new TextConstraint('part.manufacturer_product_number');
$this->manufacturer_product_url = new TextConstraint('part.manufacturer_product_url');
$this->manufacturing_status = new ChoiceConstraint('part.manufacturing_status');
$this->attachmentsCount = new IntConstraint('COUNT(attachments)');
$this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, 'attachments.attachment_type');
$this->attachmentName = new TextConstraint('attachments.name');
$this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, 'orderdetails.supplier');
$this->orderdetailsCount = new IntConstraint('COUNT(orderdetails)');
$this->obsolete = new BooleanConstraint('orderdetails.obsolete');
$this->parameters = new ArrayCollection();
$this->parametersCount = new IntConstraint('COUNT(parameters)');
}
public function apply(QueryBuilder $queryBuilder): void
{
$this->applyAllChildFilters($queryBuilder);
}
/**
* @return BooleanConstraint|false
*/
public function getFavorite()
{
return $this->favorite;
}
/**
* @return BooleanConstraint
*/
public function getNeedsReview(): BooleanConstraint
{
return $this->needsReview;
}
public function getMass(): NumberConstraint
{
return $this->mass;
}
public function getName(): TextConstraint
{
return $this->name;
}
public function getDescription(): TextConstraint
{
return $this->description;
}
/**
* @return DateTimeConstraint
*/
public function getLastModified(): DateTimeConstraint
{
return $this->lastModified;
}
/**
* @return DateTimeConstraint
*/
public function getAddedDate(): DateTimeConstraint
{
return $this->addedDate;
}
public function getCategory(): EntityConstraint
{
return $this->category;
}
/**
* @return EntityConstraint
*/
public function getFootprint(): EntityConstraint
{
return $this->footprint;
}
/**
* @return EntityConstraint
*/
public function getManufacturer(): EntityConstraint
{
return $this->manufacturer;
}
/**
* @return EntityConstraint
*/
public function getSupplier(): EntityConstraint
{
return $this->supplier;
}
/**
* @return EntityConstraint
*/
public function getStorelocation(): EntityConstraint
{
return $this->storelocation;
}
/**
* @return EntityConstraint
*/
public function getMeasurementUnit(): EntityConstraint
{
return $this->measurementUnit;
}
/**
* @return NumberConstraint
*/
public function getDbId(): NumberConstraint
{
return $this->dbId;
}
/**
* @return TextConstraint
*/
public function getComment(): TextConstraint
{
return $this->comment;
}
/**
* @return NumberConstraint
*/
public function getMinAmount(): NumberConstraint
{
return $this->minAmount;
}
/**
* @return TextConstraint
*/
public function getManufacturerProductUrl(): TextConstraint
{
return $this->manufacturer_product_url;
}
/**
* @return TextConstraint
*/
public function getManufacturerProductNumber(): TextConstraint
{
return $this->manufacturer_product_number;
}
public function getLotCount(): NumberConstraint
{
return $this->lotCount;
}
/**
* @return TagsConstraint
*/
public function getTags(): TagsConstraint
{
return $this->tags;
}
/**
* @return IntConstraint
*/
public function getOrderdetailsCount(): IntConstraint
{
return $this->orderdetailsCount;
}
/**
* @return IntConstraint
*/
public function getAttachmentsCount(): IntConstraint
{
return $this->attachmentsCount;
}
/**
* @return BooleanConstraint
*/
public function getLotNeedsRefill(): BooleanConstraint
{
return $this->lotNeedsRefill;
}
/**
* @return BooleanConstraint
*/
public function getLotUnknownAmount(): BooleanConstraint
{
return $this->lotUnknownAmount;
}
/**
* @return DateTimeConstraint
*/
public function getLotExpirationDate(): DateTimeConstraint
{
return $this->lotExpirationDate;
}
/**
* @return EntityConstraint
*/
public function getAttachmentType(): EntityConstraint
{
return $this->attachmentType;
}
/**
* @return TextConstraint
*/
public function getAttachmentName(): TextConstraint
{
return $this->attachmentName;
}
public function getManufacturingStatus(): ChoiceConstraint
{
return $this->manufacturing_status;
}
public function getAmountSum(): NumberConstraint
{
return $this->amountSum;
}
/**
* @return ArrayCollection
*/
public function getParameters(): ArrayCollection
{
return $this->parameters;
}
public function getParametersCount(): IntConstraint
{
return $this->parametersCount;
}
/**
* @return TextConstraint
*/
public function getLotDescription(): TextConstraint
{
return $this->lotDescription;
}
/**
* @return BooleanConstraint
*/
public function getObsolete(): BooleanConstraint
{
return $this->obsolete;
}
}

View file

@ -0,0 +1,359 @@
<?php
namespace App\DataTables\Filters;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface
{
/** @var string The string to query for */
protected $keyword;
/** @var boolean Whether to use regex for searching */
protected $regex = false;
/** @var bool Use name field for searching */
protected $name = true;
/** @var bool Use category name for searching */
protected $category = true;
/** @var bool Use description for searching */
protected $description = true;
/** @var bool Use tags for searching */
protected $tags = true;
/** @var bool Use storelocation name for searching */
protected $storelocation = true;
/** @var bool Use comment field for searching */
protected $comment = true;
/** @var bool Use ordernr for searching */
protected $ordernr = true;
/** @var bool Use manufacturer product name for searching */
protected $mpn = true;
/** @var bool Use supplier name for searching */
protected $supplier = false;
/** @var bool Use manufacturer name for searching */
protected $manufacturer = false;
/** @var bool Use footprint name for searching */
protected $footprint = false;
public function __construct(string $query)
{
$this->keyword = $query;
}
protected function getFieldsToSearch(): array
{
$fields_to_search = [];
if($this->name) {
$fields_to_search[] = 'part.name';
}
if($this->category) {
$fields_to_search[] = 'category.name';
}
if($this->description) {
$fields_to_search[] = 'part.description';
}
if($this->tags) {
$fields_to_search[] = 'part.tags';
}
if($this->storelocation) {
$fields_to_search[] = 'storelocations.name';
}
if($this->ordernr) {
$fields_to_search[] = 'orderdetails.supplierpartnr';
}
if($this->mpn) {
$fields_to_search[] = 'part.manufacturer_product_url';
}
if($this->supplier) {
$fields_to_search[] = 'suppliers.name';
}
if($this->manufacturer) {
$fields_to_search[] = 'manufacturer.name';
}
if($this->footprint) {
$fields_to_search[] = 'footprint.name';
}
return $fields_to_search;
}
public function apply(QueryBuilder $queryBuilder): void
{
$fields_to_search = $this->getFieldsToSearch();
//If we have nothing to search for, do nothing
if (empty($fields_to_search) || empty($this->keyword)) {
return;
}
//Convert the fields to search to a list of expressions
$expressions = array_map(function (string $field) {
if ($this->regex) {
return sprintf("REGEXP(%s, :search_query) = 1", $field);
}
return sprintf("%s LIKE :search_query", $field);
}, $fields_to_search);
//Add Or concatation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
//For regex we pass the query as is, for like we add % to the start and end as wildcards
if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword);
} else {
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
}
}
/**
* @return string
*/
public function getKeyword(): string
{
return $this->keyword;
}
/**
* @param string $keyword
* @return PartSearchFilter
*/
public function setKeyword(string $keyword): PartSearchFilter
{
$this->keyword = $keyword;
return $this;
}
/**
* @return bool
*/
public function isRegex(): bool
{
return $this->regex;
}
/**
* @param bool $regex
* @return PartSearchFilter
*/
public function setRegex(bool $regex): PartSearchFilter
{
$this->regex = $regex;
return $this;
}
/**
* @return bool
*/
public function isName(): bool
{
return $this->name;
}
/**
* @param bool $name
* @return PartSearchFilter
*/
public function setName(bool $name): PartSearchFilter
{
$this->name = $name;
return $this;
}
/**
* @return bool
*/
public function isCategory(): bool
{
return $this->category;
}
/**
* @param bool $category
* @return PartSearchFilter
*/
public function setCategory(bool $category): PartSearchFilter
{
$this->category = $category;
return $this;
}
/**
* @return bool
*/
public function isDescription(): bool
{
return $this->description;
}
/**
* @param bool $description
* @return PartSearchFilter
*/
public function setDescription(bool $description): PartSearchFilter
{
$this->description = $description;
return $this;
}
/**
* @return bool
*/
public function isTags(): bool
{
return $this->tags;
}
/**
* @param bool $tags
* @return PartSearchFilter
*/
public function setTags(bool $tags): PartSearchFilter
{
$this->tags = $tags;
return $this;
}
/**
* @return bool
*/
public function isStorelocation(): bool
{
return $this->storelocation;
}
/**
* @param bool $storelocation
* @return PartSearchFilter
*/
public function setStorelocation(bool $storelocation): PartSearchFilter
{
$this->storelocation = $storelocation;
return $this;
}
/**
* @return bool
*/
public function isOrdernr(): bool
{
return $this->ordernr;
}
/**
* @param bool $ordernr
* @return PartSearchFilter
*/
public function setOrdernr(bool $ordernr): PartSearchFilter
{
$this->ordernr = $ordernr;
return $this;
}
/**
* @return bool
*/
public function isMpn(): bool
{
return $this->mpn;
}
/**
* @param bool $mpn
* @return PartSearchFilter
*/
public function setMpn(bool $mpn): PartSearchFilter
{
$this->mpn = $mpn;
return $this;
}
/**
* @return bool
*/
public function isSupplier(): bool
{
return $this->supplier;
}
/**
* @param bool $supplier
* @return PartSearchFilter
*/
public function setSupplier(bool $supplier): PartSearchFilter
{
$this->supplier = $supplier;
return $this;
}
/**
* @return bool
*/
public function isManufacturer(): bool
{
return $this->manufacturer;
}
/**
* @param bool $manufacturer
* @return PartSearchFilter
*/
public function setManufacturer(bool $manufacturer): PartSearchFilter
{
$this->manufacturer = $manufacturer;
return $this;
}
/**
* @return bool
*/
public function isFootprint(): bool
{
return $this->footprint;
}
/**
* @param bool $footprint
* @return PartSearchFilter
*/
public function setFootprint(bool $footprint): PartSearchFilter
{
$this->footprint = $footprint;
return $this;
}
/**
* @return bool
*/
public function isComment(): bool
{
return $this->comment;
}
/**
* @param bool $comment
* @return PartSearchFilter
*/
public function setComment(bool $comment): PartSearchFilter
{
$this->comment = $comment;
return $this;
}
}

View file

@ -47,6 +47,8 @@ use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\LogEntryExtraColumn;
use App\DataTables\Column\LogEntryTargetColumn;
use App\DataTables\Column\RevertLogColumn;
use App\DataTables\Filters\AttachmentFilter;
use App\DataTables\Filters\LogFilter;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\TimeTravelInterface;
use App\Entity\LogSystem\AbstractLogEntry;
@ -61,6 +63,7 @@ use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
@ -97,10 +100,12 @@ class LogDataTable implements DataTableTypeInterface
$optionsResolver->setDefaults([
'mode' => 'system_log',
'filter_elements' => [],
'filter' => null,
]);
$optionsResolver->setAllowedTypes('filter_elements', ['array', 'object']);
$optionsResolver->setAllowedTypes('mode', 'string');
$optionsResolver->setAllowedTypes('filter', ['null', LogFilter::class]);
$optionsResolver->setNormalizer('filter_elements', static function (Options $options, $value) {
if (!is_array($value)) {
@ -121,6 +126,7 @@ class LogDataTable implements DataTableTypeInterface
$dataTable->add('symbol', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => static function ($value, AbstractLogEntry $context) {
switch ($context->getLevelString()) {
case LogLevel::DEBUG:
@ -191,8 +197,8 @@ class LogDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('log.level'),
'visible' => 'system_log' === $options['mode'],
'propertyPath' => 'levelString',
'render' => static function (string $value, AbstractLogEntry $context) {
return $value;
'render' => function (string $value, AbstractLogEntry $context) {
return $this->translator->trans('log.level.'.$value);
},
]);
@ -270,9 +276,24 @@ class LogDataTable implements DataTableTypeInterface
'query' => function (QueryBuilder $builder) use ($options): void {
$this->getQuery($builder, $options);
},
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
]);
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
if (!empty($options['filter'])) {
$filter = $options['filter'];
$filter->apply($builder);
}
}
protected function getQuery(QueryBuilder $builder, array $options): void
{
$builder->distinct()->select('log')
@ -280,6 +301,7 @@ class LogDataTable implements DataTableTypeInterface
->from(AbstractLogEntry::class, 'log')
->leftJoin('log.user', 'user');
/* Do this here as we don't want to show up the global count of all log entries in the footer line, with these modes */
if ('last_activity' === $options['mode']) {
$builder->where('log INSTANCE OF '.ElementCreatedLogEntry::class)
->orWhere('log INSTANCE OF '.ElementDeletedLogEntry::class)

View file

@ -47,11 +47,17 @@ use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Column\PartAttachmentsColumn;
use App\DataTables\Column\PrettyBoolColumn;
use App\DataTables\Column\SelectColumn;
use App\DataTables\Column\SIUnitNumberColumn;
use App\DataTables\Column\TagsColumn;
use App\DataTables\Filters\PartFilter;
use App\DataTables\Filters\PartSearchFilter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier;
use App\Services\AmountFormatter;
@ -101,48 +107,12 @@ final class PartsDataTable implements DataTableTypeInterface
public function configureOptions(OptionsResolver $optionsResolver): void
{
$optionsResolver->setDefaults([
'category' => null,
'footprint' => null,
'manufacturer' => null,
'storelocation' => null,
'supplier' => null,
'tag' => null,
'search' => null,
'filter' => null,
'search' => null
]);
$optionsResolver->setAllowedTypes('category', ['null', Category::class]);
$optionsResolver->setAllowedTypes('footprint', ['null', Footprint::class]);
$optionsResolver->setAllowedTypes('manufacturer', ['null', Manufacturer::class]);
$optionsResolver->setAllowedTypes('supplier', ['null', Supplier::class]);
$optionsResolver->setAllowedTypes('tag', ['null', 'string']);
$optionsResolver->setAllowedTypes('search', ['null', 'string']);
//Configure search options
$optionsResolver->setDefault('search_options', static function (OptionsResolver $resolver): void {
$resolver->setDefaults([
'name' => true,
'category' => true,
'description' => true,
'store_location' => true,
'comment' => true,
'ordernr' => true,
'supplier' => false,
'manufacturer' => false,
'footprint' => false,
'tags' => false,
'regex' => false,
]);
$resolver->setAllowedTypes('name', 'bool');
$resolver->setAllowedTypes('category', 'bool');
$resolver->setAllowedTypes('description', 'bool');
$resolver->setAllowedTypes('store_location', 'bool');
$resolver->setAllowedTypes('comment', 'bool');
$resolver->setAllowedTypes('supplier', 'bool');
$resolver->setAllowedTypes('manufacturer', 'bool');
$resolver->setAllowedTypes('footprint', 'bool');
$resolver->setAllowedTypes('tags', 'bool');
$resolver->setAllowedTypes('regex', 'bool');
});
$optionsResolver->setAllowedTypes('filter', [PartFilter::class, 'null']);
$optionsResolver->setAllowedTypes('search', [PartSearchFilter::class, 'null']);
}
public function configure(DataTable $dataTable, array $options): void
@ -152,8 +122,10 @@ final class PartsDataTable implements DataTableTypeInterface
$options = $resolver->resolve($options);
$dataTable
->add('select', SelectColumn::class)
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, Part $context) {
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
if (null === $preview_attachment) {
@ -230,6 +202,7 @@ final class PartsDataTable implements DataTableTypeInterface
return $this->amountFormatter->format($amount, $context->getPartUnit());
},
'orderField' => 'amountSum'
])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
@ -251,18 +224,12 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.lastModified'),
'visible' => false,
])
->add('needs_review', BoolColumn::class, [
->add('needs_review', PrettyBoolColumn::class, [
'label' => $this->translator->trans('part.table.needsReview'),
'trueValue' => $this->translator->trans('true'),
'falseValue' => $this->translator->trans('false'),
'nullValue' => '',
'visible' => false,
])
->add('favorite', BoolColumn::class, [
->add('favorite', PrettyBoolColumn::class, [
'label' => $this->translator->trans('part.table.favorite'),
'trueValue' => $this->translator->trans('true'),
'falseValue' => $this->translator->trans('false'),
'nullValue' => '',
'visible' => false,
])
->add('manufacturing_status', MapColumn::class, [
@ -282,9 +249,10 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.mpn'),
'visible' => false,
])
->add('mass', TextColumn::class, [
->add('mass', SIUnitNumberColumn::class, [
'label' => $this->translator->trans('part.table.mass'),
'visible' => false,
'unit' => 'g'
])
->add('tags', TagsColumn::class, [
'label' => $this->translator->trans('part.table.tags'),
@ -324,6 +292,7 @@ final class PartsDataTable implements DataTableTypeInterface
private function getQuery(QueryBuilder $builder): void
{
$builder->distinct()->select('part')
->addSelect('category')
->addSelect('footprint')
@ -335,6 +304,16 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect('orderdetails')
->addSelect('attachments')
->addSelect('storelocations')
//Calculate amount sum using a subquery, so we can filter and sort by it
->addSelect(
'(
SELECT IFNULL(SUM(partLot.amount), 0.0)
FROM '. PartLot::class. ' partLot
WHERE partLot.part = part.id
AND partLot.instock_unknown = false
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
) AS HIDDEN amountSum'
)
->from(Part::class, 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
@ -346,144 +325,26 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.orderdetails', 'orderdetails')
->leftJoin('orderdetails.supplier', 'suppliers')
->leftJoin('part.attachments', 'attachments')
->leftJoin('part.partUnit', 'partUnit');
->leftJoin('part.partUnit', 'partUnit')
->leftJoin('part.parameters', 'parameters')
->groupBy('part')
;
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
if (isset($options['category'])) {
$category = $options['category'];
$list = $this->treeBuilder->typeToNodesList(Category::class, $category);
$list[] = $category;
$builder->andWhere('part.category IN (:cid)')->setParameter('cid', $list);
//Apply the search criterias first
if ($options['search'] instanceof PartSearchFilter) {
$search = $options['search'];
$search->apply($builder);
}
if (isset($options['footprint'])) {
$category = $options['footprint'];
$list = $this->treeBuilder->typeToNodesList(Footprint::class, $category);
$list[] = $category;
$builder->andWhere('part.footprint IN (:cid)')->setParameter('cid', $list);
//We do the most stuff here in the filter class
if ($options['filter'] instanceof PartFilter) {
$filter = $options['filter'];
$filter->apply($builder);
}
if (isset($options['manufacturer'])) {
$category = $options['manufacturer'];
$list = $this->treeBuilder->typeToNodesList(Manufacturer::class, $category);
$list[] = $category;
$builder->andWhere('part.manufacturer IN (:cid)')->setParameter('cid', $list);
}
if (isset($options['storelocation'])) {
$location = $options['storelocation'];
$list = $this->treeBuilder->typeToNodesList(Storelocation::class, $location);
$list[] = $location;
$builder->andWhere('partLots.storage_location IN (:cid)')->setParameter('cid', $list);
}
if (isset($options['supplier'])) {
$supplier = $options['supplier'];
$list = $this->treeBuilder->typeToNodesList(Supplier::class, $supplier);
$list[] = $supplier;
$builder->andWhere('orderdetails.supplier IN (:cid)')->setParameter('cid', $list);
}
if (isset($options['tag'])) {
$builder->andWhere('part.tags LIKE :tag')->setParameter('tag', $options['tag']);
}
if (!empty($options['search'])) {
if (!$options['search_options']['regex']) {
//Dont show results, if no things are selected
$builder->andWhere('0=1');
$defined = false;
if ($options['search_options']['name']) {
$builder->orWhere('part.name LIKE :search');
$defined = true;
}
if ($options['search_options']['description']) {
$builder->orWhere('part.description LIKE :search');
$defined = true;
}
if ($options['search_options']['comment']) {
$builder->orWhere('part.comment LIKE :search');
$defined = true;
}
if ($options['search_options']['category']) {
$builder->orWhere('category.name LIKE :search');
$defined = true;
}
if ($options['search_options']['manufacturer']) {
$builder->orWhere('manufacturer.name LIKE :search');
$defined = true;
}
if ($options['search_options']['footprint']) {
$builder->orWhere('footprint.name LIKE :search');
$defined = true;
}
if ($options['search_options']['tags']) {
$builder->orWhere('part.tags LIKE :search');
$defined = true;
}
if ($options['search_options']['store_location']) {
$builder->orWhere('storelocations.name LIKE :search');
$defined = true;
}
if ($options['search_options']['supplier']) {
$builder->orWhere('suppliers.name LIKE :search');
$defined = true;
}
if ($defined) {
$builder->setParameter('search', '%'.$options['search'].'%');
}
} else { //Use REGEX
$builder->andWhere('0=1');
$defined = false;
if ($options['search_options']['name']) {
$builder->orWhere('REGEXP(part.name, :search) = 1');
$defined = true;
}
if ($options['search_options']['description']) {
$builder->orWhere('REGEXP(part.description, :search) = 1');
$defined = true;
}
if ($options['search_options']['comment']) {
$builder->orWhere('REGEXP(part.comment, :search) = 1');
$defined = true;
}
if ($options['search_options']['category']) {
$builder->orWhere('REGEXP(category.name, :search) = 1');
$defined = true;
}
if ($options['search_options']['manufacturer']) {
$builder->orWhere('REGEXP(manufacturer.name, :search) = 1');
$defined = true;
}
if ($options['search_options']['footprint']) {
$builder->orWhere('REGEXP(footprint.name, :search) = 1');
$defined = true;
}
if ($options['search_options']['tags']) {
$builder->orWhere('REGEXP(part.tags, :search) = 1');
$defined = true;
}
if ($options['search_options']['store_location']) {
$builder->orWhere('REGEXP(storelocations.name, :search) = 1');
$defined = true;
}
if ($options['search_options']['supplier']) {
$builder->orWhere('REGEXP(suppliers.name, :search) = 1');
$defined = true;
}
if ($defined) {
$builder->setParameter('search', $options['search']);
}
}
}
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Doctrine;
use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
use Doctrine\DBAL\Event\ConnectionEventArgs;
use Doctrine\DBAL\Events;
use Doctrine\DBAL\Platforms\SqlitePlatform;
/**
* This subscriber is used to add the regexp operator to the SQLite platform.
* As a PHP callback is called for every entry to compare it is most likely much slower than using regex on MySQL.
* But as regex is not often used, this should be fine for most use cases, also it is almost impossible to implement a better solution.
*/
class SQLiteRegexExtension implements EventSubscriberInterface
{
public function postConnect(ConnectionEventArgs $eventArgs): void
{
$connection = $eventArgs->getConnection();
//We only execute this on SQLite databases
if ($connection->getDatabasePlatform() instanceof SqlitePlatform) {
$native_connection = $connection->getNativeConnection();
//Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
if($native_connection instanceof \PDO && method_exists($native_connection, 'sqliteCreateFunction' )) {
$native_connection->sqliteCreateFunction('REGEXP', function ($pattern, $value) {
return (false !== mb_ereg($pattern, $value)) ? 1 : 0;
});
}
}
}
public function getSubscribedEvents()
{
return[
Events::postConnect
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Doctrine\SetSQLMode;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
/**
* This command sets the initial command parameter for MySQL connections, so we can set the SQL mode
* We use this to disable the ONLY_FULL_GROUP_BY mode, which is enabled by default in MySQL 5.7.5 and higher and causes problems with our filters
*/
class SetSQLModeMiddlewareDriver extends AbstractDriverMiddleware
{
public function connect(array $params): \Doctrine\DBAL\Driver\Connection
{
//Only set this on MySQL connections, as other databases don't support this parameter
if($this->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
//1002 is \PDO::MYSQL_ATTR_INIT_COMMAND constant value
$params['driverOptions'][1002] = 'SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))';
}
return parent::connect($params);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Doctrine\SetSQLMode;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Middleware;
/**
* This class wraps the Doctrine DBAL driver and wraps it into an Midleware driver so we can change the SQL mode
*/
class SetSQLModeMiddlewareWrapper implements Middleware
{
public function wrap(Driver $driver): Driver
{
return new SetSQLModeMiddlewareDriver($driver);
}
}

View file

@ -447,6 +447,6 @@ abstract class AbstractLogEntry extends AbstractDBElement
}
}
throw new InvalidArgumentException('No target ID for this class is existing!');
throw new InvalidArgumentException('No target ID for this class is existing! (Class: '.$class.')');
}
}

View file

@ -33,7 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert;
use function sprintf;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @ORM\Table("parameters")
* @ORM\InheritanceType("SINGLE_TABLE")
* @ORM\DiscriminatorColumn(name="type", type="smallint")

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class AttachmentTypeParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class CategoryParameter extends AbstractParameter

View file

@ -30,7 +30,7 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* A attachment attached to a category element.
*
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class CurrencyParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class DeviceParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class FootprintParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class GroupParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class ManufacturerParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class MeasurementUnitParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class PartParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class StorelocationParameter extends AbstractParameter

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity()
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class SupplierParameter extends AbstractParameter

View file

@ -44,6 +44,7 @@ namespace App\Entity\Parts\PartTraits;
use App\Entity\PriceInformations\Orderdetail;
use App\Security\Annotations\ColumnSecurity;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
use function count;
use Doctrine\Common\Collections\Collection;
@ -129,14 +130,9 @@ trait OrderTrait
{
//If needed hide the obsolete entries
if ($hide_obsolete) {
$orderdetails = $this->orderdetails;
foreach ($orderdetails as $key => $details) {
if ($details->getObsolete()) {
unset($orderdetails[$key]);
}
}
return $orderdetails;
return $this->orderdetails->filter(function (Orderdetail $orderdetail) {
return ! $orderdetail->getObsolete();
});
}
return $this->orderdetails;

View file

@ -0,0 +1,102 @@
<?php
namespace App\Form\Filters;
use App\DataTables\Filters\AttachmentFilter;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Attachments\CategoryAttachment;
use App\Entity\Attachments\CurrencyAttachment;
use App\Entity\Attachments\DeviceAttachment;
use App\Entity\Attachments\FootprintAttachment;
use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\LabelAttachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\StorelocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Supplier;
use App\Form\AdminPages\FootprintAdminForm;
use App\Form\Filters\Constraints\BooleanConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
use App\Form\Filters\Constraints\InstanceOfConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
use App\Form\Filters\Constraints\UserEntityConstraintType;
use App\Form\Filters\Constraints\TextConstraintType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AttachmentFilterType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => AttachmentFilter::class,
'csrf_protection' => false,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('dbId', NumberConstraintType::class, [
'label' => 'part.filter.dbId',
'min' => 1,
'step' => 1,
]);
$builder->add('name', TextConstraintType::class, [
'label' => 'attachment.edit.name',
]);
$builder->add('targetType', InstanceOfConstraintType::class, [
'label' => 'attachment.table.element_type',
'choices' => [
'part.label' => PartAttachment::class,
'attachment_type.label' => AttachmentTypeAttachment::class,
'category.label' => CategoryAttachment::class,
'currency.label' => CurrencyAttachment::class,
'device.label' => DeviceAttachment::class,
'footprint.label' => FootprintAttachment::class,
'group.label' => GroupAttachment::class,
'label_profile.label' => LabelAttachment::class,
'manufacturer.label' => Manufacturer::class,
'measurement_unit.label' => MeasurementUnit::class,
'storelocation.label' => StorelocationAttachment::class,
'supplier.label' => SupplierAttachment::class,
'user.label' => UserAttachment::class,
]
]);
$builder->add('attachmentType', StructuralEntityConstraintType::class, [
'label' => 'attachment.attachment_type',
'entity_class' => AttachmentType::class
]);
$builder->add('showInTable', BooleanConstraintType::class, [
'label' => 'attachment.edit.show_in_table'
]);
$builder->add('lastModified', DateTimeConstraintType::class, [
'label' => 'lastModified'
]);
$builder->add('addedDate', DateTimeConstraintType::class, [
'label' => 'createdAt'
]);
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',
]);
$builder->add('discard', ResetType::class, [
'label' => 'filter.discard',
]);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\Form\Type\TriStateCheckboxType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BooleanConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => BooleanConstraint::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('value', TriStateCheckboxType::class, [
'label' => $options['label'],
'required' => false,
]);
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ChoiceConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired('choices');
$resolver->setAllowedTypes('choices', 'array');
$resolver->setDefaults([
'compound' => true,
'data_class' => ChoiceConstraint::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [
'' => '',
'filter.choice_constraint.operator.ANY' => 'ANY',
'filter.choice_constraint.operator.NONE' => 'NONE',
];
$builder->add('operator', ChoiceType::class, [
'choices' => $choices,
'required' => false,
]);
$builder->add('value', ChoiceType::class, [
'choices' => $options['choices'],
'required' => false,
'multiple' => true,
'attr' => [
'data-controller' => 'elements--select-multiple',
]
]);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class DateTimeConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => DateTimeConstraint::class,
'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
'value1_options' => [], // Options for the first value input
'value2_options' => [], // Options for the second value input
'input_type' => DateTimeType::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [
'' => '',
'=' => '=',
'!=' => '!=',
'<' => '<',
'>' => '>',
'<=' => '<=',
'>=' => '>=',
'filter.number_constraint.value.operator.BETWEEN' => 'BETWEEN',
];
$builder->add('value1', $options['input_type'], array_merge_recursive([
'label' => 'filter.datetime_constraint.value1',
'attr' => [
'placeholder' => 'filter.datetime_constraint.value1',
],
'required' => false,
'html5' => true,
'widget' => 'single_text',
], $options['value1_options']));
$builder->add('value2', $options['input_type'], array_merge_recursive([
'label' => 'filter.datetime_constraint.value2',
'attr' => [
'placeholder' => 'filter.datetime_constraint.value2',
],
'required' => false,
'html5' => true,
'widget' => 'single_text',
], $options['value2_options']));
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.datetime_constraint.operator',
'choices' => $choices,
'required' => false,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\InstanceOfConstraint;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class InstanceOfConstraintType extends AbstractType
{
protected $em;
public function __construct(EntityManagerInterface $entityManager)
{
$this->em = $entityManager;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', InstanceOfConstraint::class);
}
public function getParent()
{
return ChoiceConstraintType::class;
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\NumberConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NumberConstraintType extends AbstractType
{
protected const CHOICES = [
'' => '',
'=' => '=',
'!=' => '!=',
'<' => '<',
'>' => '>',
'<=' => '<=',
'>=' => '>=',
'filter.number_constraint.value.operator.BETWEEN' => 'BETWEEN',
];
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => NumberConstraint::class,
'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
'min' => null,
'max' => null,
'step' => 'any',
'value1_options' => [], // Options for the first value input
'value2_options' => [], // Options for the second value input
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('value1', NumberType::class, array_merge_recursive([
'label' => 'filter.number_constraint.value1',
'attr' => [
'placeholder' => 'filter.number_constraint.value1',
'max' => $options['max'],
'min' => $options['min'],
'step' => $options['step'],
],
'required' => false,
'html5' => true,
], $options['value1_options']));
$builder->add('value2', NumberType::class, array_merge_recursive([
'label' => 'filter.number_constraint.value2',
'attr' => [
'placeholder' => 'filter.number_constraint.value2',
'max' => $options['max'],
'min' => $options['min'],
'step' => $options['step'],
],
'required' => false,
'html5' => true,
], $options['value2_options']));
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.number_constraint.operator',
'choices' => static::CHOICES,
'required' => false,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use Svg\Tag\Text;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ParameterConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => ParameterConstraint::class,
'empty_data' => new ParameterConstraint(),
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class, [
'required' => false,
]);
$builder->add('unit', SearchType::class, [
'required' => false,
]);
$builder->add('symbol', SearchType::class, [
'required' => false
]);
$builder->add('value_text', TextConstraintType::class, [
//'required' => false,
] );
$builder->add('value', ParameterValueConstraintType::class, [
]);
/*
* I am not quite sure why this is needed, but somehow symfony tries to create a new instance of TextConstraint
* instead of using the existing one for the prototype (or the one from empty data). This fails as the constructor of TextConstraint requires
* arguments.
* Ensure that the data is never null, but use an empty ParameterConstraint instead
*/
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$form = $event->getForm();
$data = $event->getData();
if ($data === null) {
$event->setData(new ParameterConstraint());
}
});
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Form\Filters\Constraints;
class ParameterValueConstraintType extends NumberConstraintType
{
protected const CHOICES = [
'' => '',
'filter.parameter_value_constraint.operator.=' => '=',
'filter.parameter_value_constraint.operator.!=' => '!=',
'filter.parameter_value_constraint.operator.<' => '<',
'filter.parameter_value_constraint.operator.>' => '>',
'filter.parameter_value_constraint.operator.<=' => '<=',
'filter.parameter_value_constraint.operator.>=' => '>=',
'filter.parameter_value_constraint.operator.BETWEEN' => 'BETWEEN',
//Extensions by ParameterValueConstraint
'filter.parameter_value_constraint.operator.IN_RANGE' => 'IN_RANGE',
'filter.parameter_value_constraint.operator.NOT_IN_RANGE' => 'NOT_IN_RANGE',
'filter.parameter_value_constraint.operator.GREATER_THAN_RANGE' => 'GREATER_THAN_RANGE',
'filter.parameter_value_constraint.operator.GREATER_EQUAL_RANGE' => 'GREATER_EQUAL_RANGE',
'filter.parameter_value_constraint.operator.LESS_THAN_RANGE' => 'LESS_THAN_RANGE',
'filter.parameter_value_constraint.operator.LESS_EQUAL_RANGE' => 'LESS_EQUAL_RANGE',
'filter.parameter_value_constraint.operator.RANGE_IN_RANGE' => 'RANGE_IN_RANGE',
'filter.parameter_value_constraint.operator.RANGE_INTERSECT_RANGE' => 'RANGE_INTERSECT_RANGE'
];
public function getParent(): string
{
return NumberConstraintType::class;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\Form\Type\StructuralEntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class StructuralEntityConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => EntityConstraint::class,
'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
]);
$resolver->setRequired('entity_class');
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [
'' => '',
'filter.entity_constraint.operator.EQ' => '=',
'filter.entity_constraint.operator.NEQ' => '!=',
'filter.entity_constraint.operator.INCLUDING_CHILDREN' => 'INCLUDING_CHILDREN',
'filter.entity_constraint.operator.EXCLUDING_CHILDREN' => 'EXCLUDING_CHILDREN',
];
$builder->add('value', StructuralEntityType::class, [
'class' => $options['entity_class'],
'required' => false,
]);
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.text_constraint.operator',
'choices' => $choices,
'required' => false,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TagsConstraintType extends AbstractType
{
protected $urlGenerator;
public function __construct(UrlGeneratorInterface $urlGenerator)
{
$this->urlGenerator = $urlGenerator;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => TagsConstraint::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [
'' => '',
'filter.tags_constraint.operator.ANY' => 'ANY',
'filter.tags_constraint.operator.ALL' => 'ALL',
'filter.tags_constraint.operator.NONE' => 'NONE'
];
$builder->add('value', SearchType::class, [
'attr' => [
'class' => 'tagsinput',
'data-controller' => 'elements--tagsinput',
'data-autocomplete' => $this->urlGenerator->generate('typeahead_tags', ['query' => '__QUERY__']),
],
'required' => false,
'empty_data' => '',
]);
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.text_constraint.operator',
'choices' => $choices,
'required' => false,
]);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\TextConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TextConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => TextConstraint::class,
'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [
'' => '',
'filter.text_constraint.value.operator.EQ' => '=',
'filter.text_constraint.value.operator.NEQ' => '!=',
'filter.text_constraint.value.operator.STARTS' => 'STARTS',
'filter.text_constraint.value.operator.ENDS' => 'ENDS',
'filter.text_constraint.value.operator.CONTAINS' => 'CONTAINS',
'filter.text_constraint.value.operator.LIKE' => 'LIKE',
'filter.text_constraint.value.operator.REGEX' => 'REGEX',
];
$builder->add('value', SearchType::class, [
'attr' => [
'placeholder' => 'filter.text_constraint.value',
],
'required' => false,
'empty_data' => '',
]);
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.text_constraint.operator',
'choices' => $choices,
'required' => false,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\Entity\UserSystem\User;
use App\Form\Type\StructuralEntityType;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserEntityConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => EntityConstraint::class,
'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [
'' => '',
'filter.entity_constraint.operator.EQ' => '=',
'filter.entity_constraint.operator.NEQ' => '!=',
];
$builder->add('value', EntityType::class, [
'class' => User::class,
'required' => false,
'attr' => [
'data-controller' => 'elements--selectpicker',
'data-live-search' => true,
'title' => 'selectpicker.nothing_selected',
]
]);
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.text_constraint.operator',
'choices' => $choices,
'required' => false,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
}

View file

@ -0,0 +1,152 @@
<?php
namespace App\Form\Filters;
use App\DataTables\Filters\LogFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Devices\Device;
use App\Entity\Devices\DevicePart;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\DatabaseUpdatedLogEntry;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\LogSystem\InstockChangedLogEntry;
use App\Entity\LogSystem\SecurityEventLogEntry;
use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\LogSystem\UserLogoutLogEntry;
use App\Entity\LogSystem\UserNotAllowedLogEntry;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Form\Filters\Constraints\ChoiceConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
use App\Form\Filters\Constraints\InstanceOfConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
use App\Form\Filters\Constraints\UserEntityConstraintType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class LogFilterType extends AbstractType
{
protected const LEVEL_CHOICES = [
'log.level.debug' => AbstractLogEntry::LEVEL_DEBUG,
'log.level.info' => AbstractLogEntry::LEVEL_INFO,
'log.level.notice' => AbstractLogEntry::LEVEL_NOTICE,
'log.level.warning' => AbstractLogEntry::LEVEL_WARNING,
'log.level.error' => AbstractLogEntry::LEVEL_ERROR,
'log.level.critical' => AbstractLogEntry::LEVEL_CRITICAL,
'log.level.alert' => AbstractLogEntry::LEVEL_ALERT,
'log.level.emergency' => AbstractLogEntry::LEVEL_EMERGENCY,
];
protected const TARGET_TYPE_CHOICES = [
'log.type.collection_element_deleted' => CollectionElementDeleted::class,
'log.type.database_updated' => DatabaseUpdatedLogEntry::class,
'log.type.element_created' => ElementCreatedLogEntry::class,
'log.type.element_deleted' => ElementDeletedLogEntry::class,
'log.type.element_edited' => ElementEditedLogEntry::class,
'log.type.security' => SecurityEventLogEntry::class,
'log.type.user_login' => UserLoginLogEntry::class,
'log.type.user_logout' => UserLogoutLogEntry::class,
'log.type.user_not_allowed' => UserNotAllowedLogEntry::class,
//Legacy entries
'log.type.instock_changed' => InstockChangedLogEntry::class,
];
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => LogFilter::class,
'csrf_protection' => false,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('dbId', NumberConstraintType::class, [
'label' => 'part.filter.dbId',
'min' => 1,
'step' => 1,
]);
$builder->add('timestamp', DateTimeConstraintType::class, [
'label' => 'log.timestamp',
]);
$builder->add('level', ChoiceConstraintType::class, [
'label' => 'log.level',
'choices' => self::LEVEL_CHOICES,
]);
$builder->add('eventType', InstanceOfConstraintType::class, [
'label' => 'log.type',
'choices' => self::TARGET_TYPE_CHOICES
]);
$builder->add('user', UserEntityConstraintType::class, [
'label' => 'log.user',
]);
$builder->add('targetType', ChoiceConstraintType::class, [
'label' => 'log.target_type',
'choices' => [
'user.label' => AbstractLogEntry::targetTypeClassToID(User::class),
'attachment.label' => AbstractLogEntry::targetTypeClassToID(Attachment::class),
'attachment_type.label' => AbstractLogEntry::targetTypeClassToID(AttachmentType::class),
'category.label' => AbstractLogEntry::targetTypeClassToID(Category::class),
'device.label' => AbstractLogEntry::targetTypeClassToID(Device::class),
'device_part.label' => AbstractLogEntry::targetTypeClassToID(DevicePart::class),
'footprint.label' => AbstractLogEntry::targetTypeClassToID(Footprint::class),
'group.label' => AbstractLogEntry::targetTypeClassToID(Group::class),
'manufacturer.label' => AbstractLogEntry::targetTypeClassToID(Manufacturer::class),
'part.label' => AbstractLogEntry::targetTypeClassToID(Part::class),
'storelocation.label' => AbstractLogEntry::targetTypeClassToID(Storelocation::class),
'supplier.label' => AbstractLogEntry::targetTypeClassToID(Supplier::class),
'part_lot.label' => AbstractLogEntry::targetTypeClassToID(PartLot::class),
'currency.label' => AbstractLogEntry::targetTypeClassToID(Currency::class),
'orderdetail.label' => AbstractLogEntry::targetTypeClassToID(Orderdetail::class),
'pricedetail.label' => AbstractLogEntry::targetTypeClassToID(Pricedetail::class),
'measurement_unit.label' => AbstractLogEntry::targetTypeClassToID(MeasurementUnit::class),
'parameter.label' => AbstractLogEntry::targetTypeClassToID(AbstractParameter::class),
'label_profile.label' => AbstractLogEntry::targetTypeClassToID(LabelProfile::class),
]
]);
$builder->add('targetId', NumberConstraintType::class, [
'label' => 'log.target_id',
'min' => 1,
'step' => 1,
]);
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',
]);
$builder->add('discard', ResetType::class, [
'label' => 'filter.discard',
]);
}
}

View file

@ -0,0 +1,249 @@
<?php
namespace App\Form\Filters;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\PartFilter;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Storelocation;
use App\Form\Filters\Constraints\BooleanConstraintType;
use App\Form\Filters\Constraints\ChoiceConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\ParameterConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
use App\Form\Filters\Constraints\TagsConstraintType;
use App\Form\Filters\Constraints\TextConstraintType;
use Svg\Tag\Text;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PartFilterType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => PartFilter::class,
'csrf_protection' => false,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
/*
* Common tab
*/
$builder->add('name', TextConstraintType::class, [
'label' => 'part.edit.name',
]);
$builder->add('description', TextConstraintType::class, [
'label' => 'part.edit.description',
]);
$builder->add('category', StructuralEntityConstraintType::class, [
'label' => 'part.edit.category',
'entity_class' => Category::class
]);
$builder->add('footprint', StructuralEntityConstraintType::class, [
'label' => 'part.edit.footprint',
'entity_class' => Footprint::class
]);
$builder->add('tags', TagsConstraintType::class, [
'label' => 'part.edit.tags'
]);
$builder->add('comment', TextConstraintType::class, [
'label' => 'part.edit.comment'
]);
/*
* Advanced tab
*/
$builder->add('dbId', NumberConstraintType::class, [
'label' => 'part.filter.dbId',
'min' => 1,
'step' => 1,
]);
$builder->add('favorite', BooleanConstraintType::class, [
'label' => 'part.edit.is_favorite'
]);
$builder->add('needsReview', BooleanConstraintType::class, [
'label' => 'part.edit.needs_review'
]);
$builder->add('mass', NumberConstraintType::class, [
'label' => 'part.edit.mass',
'text_suffix' => 'g',
'min' => 0,
]);
$builder->add('measurementUnit', StructuralEntityConstraintType::class, [
'label' => 'part.edit.partUnit',
'entity_class' => MeasurementUnit::class
]);
$builder->add('lastModified', DateTimeConstraintType::class, [
'label' => 'lastModified'
]);
$builder->add('addedDate', DateTimeConstraintType::class, [
'label' => 'createdAt'
]);
/*
* Manufacturer tab
*/
$builder->add('manufacturer', StructuralEntityConstraintType::class, [
'label' => 'part.edit.manufacturer.label',
'entity_class' => Manufacturer::class
]);
$builder->add('manufacturer_product_url', TextConstraintType::class, [
'label' => 'part.edit.manufacturer_url.label'
]);
$builder->add('manufacturer_product_number', TextConstraintType::class, [
'label' => 'part.edit.mpn'
]);
$status_choices = [
'm_status.unknown' => '',
'm_status.announced' => 'announced',
'm_status.active' => 'active',
'm_status.nrfnd' => 'nrfnd',
'm_status.eol' => 'eol',
'm_status.discontinued' => 'discontinued',
];
$builder->add('manufacturing_status', ChoiceConstraintType::class, [
'label' => 'part.edit.manufacturing_status',
'choices' => $status_choices,
]);
/*
* Purchasee informations
*/
$builder->add('supplier', StructuralEntityConstraintType::class, [
'label' => 'supplier.label',
'entity_class' => Manufacturer::class
]);
$builder->add('orderdetailsCount', NumberConstraintType::class, [
'label' => 'part.filter.orderdetails_count',
'step' => 1,
'min' => 0,
]);
$builder->add('obsolete', BooleanConstraintType::class, [
'label' => 'orderdetails.edit.obsolete'
]);
/*
* Stocks tabs
*/
$builder->add('storelocation', StructuralEntityConstraintType::class, [
'label' => 'storelocation.label',
'entity_class' => Storelocation::class
]);
$builder->add('minAmount', NumberConstraintType::class, [
'label' => 'part.edit.mininstock',
'min' => 0,
]);
$builder->add('lotCount', NumberConstraintType::class, [
'label' => 'part.filter.lot_count',
'min' => 0,
'step' => 1,
]);
$builder->add('amountSum', NumberConstraintType::class, [
'label' => 'part.filter.amount_sum',
'min' => 0,
]);
$builder->add('lotNeedsRefill', BooleanConstraintType::class, [
'label' => 'part.filter.lotNeedsRefill'
]);
$builder->add('lotUnknownAmount', BooleanConstraintType::class, [
'label' => 'part.filter.lotUnknwonAmount'
]);
$builder->add('lotExpirationDate', DateTimeConstraintType::class, [
'label' => 'part.filter.lotExpirationDate',
'input_type' => DateType::class,
]);
$builder->add('lotDescription', TextConstraintType::class, [
'label' => 'part.filter.lotDescription',
]);
/**
* Attachments count
*/
$builder->add('attachmentsCount', NumberConstraintType::class, [
'label' => 'part.filter.attachments_count',
'step' => 1,
'min' => 0,
]);
$builder->add('attachmentType', StructuralEntityConstraintType::class, [
'label' => 'attachment.attachment_type',
'entity_class' => AttachmentType::class
]);
$builder->add('attachmentName', TextConstraintType::class, [
'label' => 'part.filter.attachmentName',
]);
$constraint_prototype = new ParameterConstraint();
$builder->add('parameters', CollectionType::class, [
'label' => false,
'entry_type' => ParameterConstraintType::class,
'allow_delete' => true,
'allow_add' => true,
'reindex_enable' => false,
'prototype_data' => $constraint_prototype,
'empty_data' => $constraint_prototype,
]);
$builder->add('parametersCount', NumberConstraintType::class, [
'label' => 'part.filter.parameters_count',
'step' => 1,
'min' => 0,
]);
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',
]);
$builder->add('discard', ResetType::class, [
'label' => 'filter.discard',
]);
}
}

View file

@ -24,10 +24,23 @@ declare(strict_types=1);
namespace App\Form;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter;
use App\Entity\Parameters\CurrencyParameter;
use App\Entity\Parameters\DeviceParameter;
use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\MeasurementUnit;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ParameterType extends AbstractType
@ -117,6 +130,32 @@ class ParameterType extends AbstractType
]);
}
public function finishView(FormView $view, FormInterface $form, array $options)
{
//By default use part parameters for autocomplete
$view->vars['type'] = 'part';
$map = [
PartParameter::class => 'part',
AttachmentTypeParameter::class => 'attachment_type',
CategoryParameter::class => 'category',
CurrencyParameter::class => 'currency',
DeviceParameter::class => 'device',
FootprintParameter::class => 'footprint',
GroupParameter::class => 'group',
ManufacturerParameter::class => 'manufacturer',
MeasurementUnit::class => 'measurement_unit',
StorelocationParameter::class => 'storelocation',
SupplierParameter::class => 'supplier',
];
if (isset($map[$options['data_class']])) {
$view->vars['type'] = $map[$options['data_class']];
}
parent::finishView($view, $form, $options); // TODO: Change the autogenerated stub
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([

View file

@ -171,10 +171,10 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
case 'true':
return true;
case 'false':
case '':
return false;
case 'indeterminate':
case 'null':
case '':
return null;
default:
throw new InvalidArgumentException('Invalid value encountered!: '.$value);

View file

@ -33,7 +33,7 @@ abstract class AbstractPartsContainingRepository extends StructuralDBElementRepo
* @param object $element the element for which the parts should be determined
* @param array $order_by The order of the parts. Format ['name' => 'ASC']
*
* @return Part
* @return Part[]
*/
abstract public function getParts(object $element, array $order_by = ['name' => 'ASC']): array;

View file

@ -0,0 +1,33 @@
<?php
namespace App\Repository;
class ParameterRepository extends DBElementRepository
{
/**
* Find parameters using a parameter name
* @param string $name The name to search for
* @param bool $exact True, if only exact names should match. False, if the name just needs to be contained in the parameter name
* @param int $max_results
* @return array
*/
public function autocompleteParamName(string $name, bool $exact = false, int $max_results = 50): array
{
$qb = $this->createQueryBuilder('parameter');
$qb->distinct()
->select('parameter.name')
->addSelect('parameter.symbol')
->addSelect('parameter.unit')
->where('parameter.name LIKE :name');
if ($exact) {
$qb->setParameter('name', $name);
} else {
$qb->setParameter('name', '%'.$name.'%');
}
$qb->setMaxResults($max_results);
return $qb->getQuery()->getArrayResult();
}
}

View file

@ -84,10 +84,20 @@ class NodesListBuilder
return $this->cache->get($key, function (ItemInterface $item) use ($class_name, $parent, $secure_class_name) {
// Invalidate when groups, a element with the class or the user changes
$item->tag(['groups', 'tree_list', $this->keyGenerator->generateKey(), $secure_class_name]);
/** @var StructuralDBElementRepository */
$repo = $this->em->getRepository($class_name);
return $repo->toNodesList($parent);
return $this->em->getRepository($class_name)->toNodesList($parent);
});
}
/**
* Returns a flattened list of all (recursive) children elements of the given AbstractStructuralDBElement.
* The value is cached for performance reasons.
*
* @template T of AbstractStructuralDBElement
* @param T $element
* @return T[]
*/
public function getChildrenFlatList(AbstractStructuralDBElement $element): array
{
return $this->typeToNodesList(get_class($element), $element);
}
}

View file

@ -42,9 +42,20 @@ declare(strict_types=1);
namespace App\Twig;
use App\Entity\Attachments\Attachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Devices\Device;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Services\AmountFormatter;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\EntityURLGenerator;
@ -54,6 +65,7 @@ use App\Services\MoneyFormatter;
use App\Services\SIFormatter;
use App\Services\Trees\TreeViewGenerator;
use Brick\Math\BigDecimal;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Extension\AbstractExtension;
@ -76,12 +88,14 @@ class AppExtension extends AbstractExtension
protected $FAIconGenerator;
protected $translator;
protected $objectNormalizer;
public function __construct(EntityURLGenerator $entityURLGenerator, MarkdownParser $markdownParser,
SerializerInterface $serializer, TreeViewGenerator $treeBuilder,
MoneyFormatter $moneyFormatter,
SIFormatter $SIFormatter, AmountFormatter $amountFormatter,
AttachmentURLGenerator $attachmentURLGenerator,
FAIconGenerator $FAIconGenerator, TranslatorInterface $translator)
FAIconGenerator $FAIconGenerator, TranslatorInterface $translator, ObjectNormalizer $objectNormalizer)
{
$this->entityURLGenerator = $entityURLGenerator;
$this->markdownParser = $markdownParser;
@ -93,6 +107,8 @@ class AppExtension extends AbstractExtension
$this->attachmentURLGenerator = $attachmentURLGenerator;
$this->FAIconGenerator = $FAIconGenerator;
$this->translator = $translator;
$this->objectNormalizer = $objectNormalizer;
}
public function getFilters(): array
@ -107,6 +123,8 @@ class AppExtension extends AbstractExtension
new TwigFilter('siFormat', [$this, 'siFormat']),
new TwigFilter('amountFormat', [$this, 'amountFormat']),
new TwigFilter('loginPath', [$this, 'loginPath']),
new TwigFilter('toArray', [$this, 'toArray'])
];
}
@ -116,6 +134,12 @@ class AppExtension extends AbstractExtension
new TwigTest('instanceof', static function ($var, $instance) {
return $var instanceof $instance;
}),
new TwigTest('entity', static function ($var) {
return $var instanceof AbstractDBElement;
}),
new TwigTest('object', static function ($var) {
return is_object($var);
}),
];
}
@ -125,9 +149,31 @@ class AppExtension extends AbstractExtension
new TwigFunction('generateTreeData', [$this, 'treeData']),
new TwigFunction('attachment_thumbnail', [$this->attachmentURLGenerator, 'getThumbnailURL']),
new TwigFunction('ext_to_fa_icon', [$this->FAIconGenerator, 'fileExtensionToFAType']),
new TwigFunction('entity_type', [$this, 'getEntityType']),
];
}
public function getEntityType($entity): ?string
{
$map = [
Part::class => 'part',
Footprint::class => 'footprint',
Storelocation::class => 'storelocation',
Manufacturer::class => 'manufacturer',
Category::class => 'category',
Device::class => 'device',
Attachment::class => 'attachment',
Supplier::class => 'supplier',
User::class => 'user',
Group::class => 'group',
Currency::class => 'currency',
MeasurementUnit::class => 'measurement_unit',
LabelProfile::class => 'label_profile',
];
return $map[get_class($entity)] ?? null;
}
public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string
{
$tree = $this->treeBuilder->getTreeView(get_class($element), null, $type, $element);
@ -135,6 +181,11 @@ class AppExtension extends AbstractExtension
return json_encode($tree, JSON_THROW_ON_ERROR);
}
public function toArray($object): array
{
return $this->objectNormalizer->normalize($object, null);
}
/**
* This function/filter generates an path.
*/

View file

@ -0,0 +1,62 @@
{% block number_constraint_widget %}
<div class="input-group" {{ stimulus_controller('filters/number_constraint') }}>
{{ form_widget(form.operator, {"attr": {
"class": "form-select",
"data-filters--number-constraint-target": "operator",
"data-action": "change->filters--number-constraint#update"
}}) }}
{{ form_widget(form.value1) }}
<span class="input-group-text d-none" {{ stimulus_target('filters/number_constraint', 'thingsToHide') }}>{% trans %}filter.number_constraint.AND{% endtrans %}</span>
{{ form_widget(form.value2, {"attr": {"class": "d-none", "data-filters--number-constraint-target": "thingsToHide"}}) }}
{% if form.vars["text_suffix"] %}
<span class="input-group-text">{{ form.vars["text_suffix"] }}</span>
{% endif %}
</div>
{% endblock %}
{% block text_constraint_widget %}
<div class="input-group">
{{ form_widget(form.operator, {"attr": {"class": "form-select"}}) }}
{{ form_widget(form.value) }}
{% if form.vars["text_suffix"] is defined and form.vars["text_suffix"] %}
<span class="input-group-text">{{ form.vars["text_suffix"] }}</span>
{% endif %}
</div>
{% endblock %}
{% block structural_entity_constraint_widget %}
{{ block('text_constraint_widget') }}
{% endblock %}
{% block user_entity_constraint_widget %}
{{ block('text_constraint_widget') }}
{% endblock %}
{% block date_time_constraint_widget %}
{{ block('number_constraint_widget') }}
{% endblock %}
{% block tags_constraint_widget %}
{{ block('text_constraint_widget') }}
{% endblock %}
{% block choice_constraint_widget %}
{{ block('text_constraint_widget') }}
{% endblock %}
{% block parameter_constraint_widget %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<tr {{ stimulus_controller('pages/parameters_autocomplete', {"url": url('typeahead_parameters', {"query": "__QUERY__", "type": "part"}) }) }} >
<td class="col-sm-2">{{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_text) }}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
{{ form_errors(form) }}
</td>
</tr>
{% endblock %}

View file

@ -3,5 +3,53 @@
{% block title %}{% trans %}log.list.title{% endtrans %}{% endblock %}
{% block content %}
<div class="accordion mb-3" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#searchInfo" disabled>
<i class="fa-solid fa-binoculars fa-fw"></i>
{% trans %}log.list.title{% endtrans %}
</button>
</div>
<div id="searchInfo" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
</div>
</div>
</div>
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#filterFormCollapse" aria-expanded="false" aria-controls="filterFormCollapse"><i class="fa-solid fa-filter fa-fw"></i> {% trans %}filter.title{% endtrans %}</button>
</div>
<div id="filterFormCollapse" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
{{ form_row(filterForm.dbId) }}
{{ form_row(filterForm.timestamp) }}
{{ form_row(filterForm.eventType) }}
{{ form_row(filterForm.user) }}
{{ form_row(filterForm.level) }}
{{ form_row(filterForm.targetType) }}
{{ form_row(filterForm.targetId) }}
{{ form_row(filterForm.submit) }}
{{ form_row(filterForm.discard) }}
<div class="row mb-3">
<div class="col-sm-9 offset-sm-3">
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
</div>
</div>
{{ form_end(filterForm) }}
</div>
</div>
</div>
</div>
{% include "LogSystem/_log_table.html.twig" %}
{% endblock %}

View file

@ -68,13 +68,13 @@
{% block parameter_widget %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<tr>
<td>{{ form_widget(form.name) }}{{ form_errors(form.name) }}</td>
<td>{{ form_widget(form.symbol) }}{{ form_errors(form.symbol) }}</td>
<tr {{ stimulus_controller('pages/parameters_autocomplete', {"url": url('typeahead_parameters', {"query": "__QUERY__", "type": form.vars["type"]})}) }}>
<td>{{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}{{ form_errors(form.name) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.symbol) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }}</td>
<td>{{ form_widget(form.value_typical) }}{{ form_errors(form.value_typical) }}</td>
<td>{{ form_widget(form.value_max) }}{{ form_errors(form.value_max) }}</td>
<td>{{ form_widget(form.unit) }}{{ form_errors(form.unit) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }}</td>
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
<td>

View file

@ -1,7 +1,12 @@
{% if not pictures is empty %}
{# <img src="{{ part.masterPictureAttachment | entityURL('file_view') }}" class="img-fluid img-thumbnail bg-light" alt="Part main image" height="300" width="300"> #}
<div id="pictureCarousel" class="carousel slide mb-2" data-interval="false">
<div id="pictureCarousel" class="carousel slide mb-2" data-bs-interval="false" data-bs-ride="false">
<div class="carousel-indicators">
{% for pic in pictures %}
<button type="button" data-bs-target="#pictureCarousel" data-bs-slide-to="{{ loop.index0 }}" {% if loop.first %}class="active" aria-current="true"{% endif %} aria-label="{{ pic.name }}"></button>
{% endfor %}
</div>
<div class="carousel-inner">
{% for pic in pictures %}
{# @var pic App\Entity\Attachments\Attachment #}
@ -21,14 +26,14 @@
{% endfor %}
</div>
{% if pictures | length > 1 %}
<a class="carousel-control-prev carousel-control" href="#pictureCarousel" role="button" data-slide="prev">
<button type="button" class="carousel-control-prev" data-bs-target="#pictureCarousel" role="button" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">{% trans %}part.info.prev_picture{% endtrans %}</span>
</a>
<a class="carousel-control-next carousel-control" href="#pictureCarousel" role="button" data-slide="next">
</button>
<button type="button" class="carousel-control-next" data-bs-target="#pictureCarousel" role="button" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">{% trans %}part.info.next_picture{% endtrans %}</span>
</a>
</button>
{% endif %}
</div>

View file

@ -0,0 +1,137 @@
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#filterFormCollapse" aria-expanded="false" aria-controls="filterFormCollapse"><i class="fa-solid fa-filter fa-fw"></i> {% trans %}filter.title{% endtrans %}</button>
</div>
<div id="filterFormCollapse" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
<ul class="nav nav-tabs" id="filterTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="filter-common-tab" data-bs-toggle="tab" data-bs-target="#filter-common"><i class="fas fa-id-card fa-fw"></i> {% trans %}part.edit.tab.common{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-manufacturer-tab" data-bs-toggle="tab" data-bs-target="#filter-manufacturer"><i class="fas fa-industry fa-fw"></i> {% trans %}part.edit.tab.manufacturer{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-advanced-tab" data-bs-toggle="tab" data-bs-target="#filter-advanced"><i class="fas fa-shapes fa-fw"></i> {% trans %}part.edit.tab.advanced{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-stocks-tab" data-bs-toggle="tab" data-bs-target="#filter-stocks"><i class="fas fa-boxes fa-fw"></i> {% trans %}part.edit.tab.part_lots{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-attachments-tab" data-bs-toggle="tab" data-bs-target="#filter-attachments"><i class="fas fa-paperclip fa-fw"></i> {% trans %}part.edit.tab.attachments{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-orderdetails-tab" data-bs-toggle="tab" data-bs-target="#filter-orderdetails"><i class="fas fa-shopping-cart fa-fw"></i> {% trans %}part.edit.tab.orderdetails{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-parameters-tab" data-bs-toggle="tab" data-bs-target="#filter-parameters"><i class="fas fa-atlas fa-fw"></i> {% trans %}part.edit.tab.specifications{% endtrans %}</button>
</li>
</ul>
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
<div class="tab-content">
<div class="tab-pane active pt-3" id="filter-common" role="tabpanel" aria-labelledby="filter-common-tab" tabindex="0">
{{ form_row(filterForm.name) }}
{{ form_row(filterForm.description) }}
{{ form_row(filterForm.category) }}
{{ form_row(filterForm.footprint) }}
{{ form_row(filterForm.tags) }}
{{ form_row(filterForm.comment) }}
</div>
<div class="tab-pane pt-3" id="filter-manufacturer" role="tabpanel" aria-labelledby="filter-manufacturer-tab" tabindex="0">
{{ form_row(filterForm.manufacturer) }}
{{ form_row(filterForm.manufacturing_status) }}
{{ form_row(filterForm.manufacturer_product_number) }}
{{ form_row(filterForm.manufacturer_product_url) }}
</div>
<div class="tab-pane pt-3" id="filter-advanced" role="tabpanel" aria-labelledby="filter-advanced-tab" tabindex="0">
{{ form_row(filterForm.favorite) }}
{{ form_row(filterForm.needsReview) }}
{{ form_row(filterForm.measurementUnit) }}
{{ form_row(filterForm.mass) }}
{{ form_row(filterForm.dbId) }}
{{ form_row(filterForm.lastModified) }}
{{ form_row(filterForm.addedDate) }}
</div>
<div class="tab-pane pt-3" id="filter-stocks" role="tabpanel" aria-labelledby="filter-stocks-tab" tabindex="0">
{{ form_row(filterForm.storelocation) }}
{{ form_row(filterForm.minAmount) }}
{{ form_row(filterForm.amountSum) }}
{{ form_row(filterForm.lotCount) }}
{{ form_row(filterForm.lotExpirationDate) }}
{{ form_row(filterForm.lotDescription) }}
{{ form_row(filterForm.lotNeedsRefill) }}
{{ form_row(filterForm.lotUnknownAmount) }}
</div>
<div class="tab-pane pt-3" id="filter-attachments" role="tabpanel" aria-labelledby="filter-attachments-tab" tabindex="0">
{{ form_row(filterForm.attachmentsCount) }}
{{ form_row(filterForm.attachmentType) }}
{{ form_row(filterForm.attachmentName) }}
</div>
<div class="tab-pane pt-3" id="filter-orderdetails" role="tabpanel" aria-labelledby="filter-orderdetails-tab" tabindex="0">
{{ form_row(filterForm.supplier) }}
{{ form_row(filterForm.orderdetailsCount) }}
{{ form_row(filterForm.obsolete) }}
</div>
<div class="tab-pane pt-3" id="filter-parameters" role="tabpanel" aria-labelledby="filter-parameters-tab" tabindex="0">
{% import 'components/collection_type.macro.html.twig' as collection %}
{{ form_row(filterForm.parametersCount) }}
<div {{ collection.controller(filterForm.parameters) }}>
<table class="table table-striped table-sm" id="lots_table" {{ collection.target() }}>
<thead>
<tr>
<th>{% trans %}specifications.property{% endtrans %}</th>
<th>{% trans %}specifications.symbol{% endtrans %}</th>
<th>{% trans %}specifications.value{% endtrans %}</th>
<th>{% trans %}specifications.unit{% endtrans %}</th>
<th>{% trans %}specifications.text{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for param in filterForm.parameters %}
{{ form_widget(param) }}
{% endfor %}
</tbody>
</table>
<button type="button" class="btn btn-success" {{ collection.create_btn() }}>
<i class="fas fa-plus-square fa-fw"></i>
{% trans %}filter.constraint.add{% endtrans %}
</button>
</div>
</div>
</div>
{{ form_row(filterForm.submit) }}
{{ form_row(filterForm.discard) }}
<div class="row mb-3">
<div class="col-sm-9 offset-sm-3">
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
</div>
</div>
{# Retain the query parameters of the search form if it is existing #}
{% if searchFilter is defined %}
{% for property, value in searchFilter|toArray %}
<input type="hidden" name="{{ property }}" data-no-clear="true" value="{{ value }}">
{% endfor %}
{% endif %}
{{ form_end(filterForm) }}
</div>
</div>
</div>

View file

@ -3,131 +3,137 @@
{{ helper.breadcrumb_entity_link(entity) }}
<div class="accordion">
<div class="accordion-item mb-3">
<div class="accordion-header">
<button class="accordion-button" data-bs-toggle="collapse" data-bs-target="#entityInfo">
{% if entity.masterPictureAttachment is not null and attachment_manager.isFileExisting(entity.masterPictureAttachment) %}
<img class="hoverpic ms-0 me-0 d-inline" {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ entity.masterPictureAttachment | entityURL('file_view') }}" src="{{ attachment_thumbnail(entity.masterPictureAttachment, 'thumbnail_sm') }}">
{% endif %}
{{ header_label | trans }}:&nbsp;<b>{{ entity.name }}</b>
</button>
</div>
<div id="entityInfo" class="accordion-collapse collapse">
<div class="accordion-body">
<div class="row">
<div class="col-sm-2">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical">
<a class="nav-link active" id="v-pills-home-tab" data-bs-toggle="pill" href="#v-pills-home" role="tab" aria-controls="v-pills-home" aria-selected="true">
<i class="fas fa-info-circle fa-fw"></i>
{% trans %}entity.info.common.tab{% endtrans %}
</a>
<a class="nav-link" id="v-pills-statistics-tab" data-bs-toggle="pill" href="#v-pills-statistics" role="tab" aria-controls="v-pills-profile" aria-selected="false">
<i class="fas fa-chart-pie fa-fw"></i>
{% trans %}entity.info.statistics.tab{% endtrans %}
</a>
{% if entity.attachments is not empty %}
<a class="nav-link" id="v-pills-attachments-tab" data-bs-toggle="pill" href="#v-pills-attachments" role="tab" aria-controls="v-pills-attachments" aria-selected="false">
<i class="fas fa-paperclip fa-fw"></i>
{% trans %}entity.info.attachments.tab{% endtrans %}
</a>
{% endif %}
{% if entity.parameters is not empty %}
<a class="nav-link" id="v-pills-parameters-tab" data-bs-toggle="pill" href="#v-pills-parameters" role="tab" aria-controls="v-pills-parameters" aria-selected="false">
<i class="fas fa-atlas fa-fw"></i>
{% trans %}entity.info.parameters.tab{% endtrans %}
<div class="accordion mb-4" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#entityInfo">
{% if entity.masterPictureAttachment is not null and attachment_manager.isFileExisting(entity.masterPictureAttachment) %}
<img class="hoverpic ms-0 me-1 d-inline" {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ entity.masterPictureAttachment | entityURL('file_view') }}" src="{{ attachment_thumbnail(entity.masterPictureAttachment, 'thumbnail_sm') }}">
{% else %}
{{ helper.entity_icon(entity, "me-1") }}
{% endif %}
{{ header_label | trans }}:&nbsp;<b>{{ entity.name }}</b>
</button>
</div>
<div id="entityInfo" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
<div class="row">
<div class="col-sm-2">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical">
<a class="nav-link active" id="v-pills-home-tab" data-bs-toggle="pill" href="#v-pills-home" role="tab" aria-controls="v-pills-home" aria-selected="true">
<i class="fas fa-info-circle fa-fw"></i>
{% trans %}entity.info.common.tab{% endtrans %}
</a>
{% endif %}
{% if entity.comment is not empty %}
<a class="nav-link" id="v-pills-comment-tab" data-bs-toggle="pill" href="#v-pills-comment" role="tab">
<i class="fas fa-comment-alt fa-fw"></i>
{% trans %}comment.label{% endtrans %}
<a class="nav-link" id="v-pills-statistics-tab" data-bs-toggle="pill" href="#v-pills-statistics" role="tab" aria-controls="v-pills-profile" aria-selected="false">
<i class="fas fa-chart-pie fa-fw"></i>
{% trans %}entity.info.statistics.tab{% endtrans %}
</a>
{% endif %}
{% if entity.attachments is not empty %}
<a class="nav-link" id="v-pills-attachments-tab" data-bs-toggle="pill" href="#v-pills-attachments" role="tab" aria-controls="v-pills-attachments" aria-selected="false">
<i class="fas fa-paperclip fa-fw"></i>
{% trans %}entity.info.attachments.tab{% endtrans %}
</a>
{% endif %}
{% if entity.parameters is not empty %}
<a class="nav-link" id="v-pills-parameters-tab" data-bs-toggle="pill" href="#v-pills-parameters" role="tab" aria-controls="v-pills-parameters" aria-selected="false">
<i class="fas fa-atlas fa-fw"></i>
{% trans %}entity.info.parameters.tab{% endtrans %}
</a>
{% endif %}
{% if entity.comment is not empty %}
<a class="nav-link" id="v-pills-comment-tab" data-bs-toggle="pill" href="#v-pills-comment" role="tab">
<i class="fas fa-comment-alt fa-fw"></i>
{% trans %}comment.label{% endtrans %}
</a>
{% endif %}
</div>
</div>
</div>
<div class="col-sm-10">
<div class="tab-content" id="v-pills-tabContent">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab">
<div class="row">
<div class="col-sm-9 form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.name{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ entity.name }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parent{% endtrans %}:</label>
<span class="col-sm form-control-static">
<div class="col-sm-10">
<div class="tab-content" id="v-pills-tabContent">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab">
<div class="row">
<div class="col-sm-9 form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.name{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ entity.name }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parent{% endtrans %}:</label>
<span class="col-sm form-control-static">
{% if entity.parent %}
{{ entity.parent.fullPath }}
{% else %}
-
{% endif %}
</span>
</div>
</div>
</div>
<div class="col-sm-3">
{% block quick_links %}{% endblock %}
<div class="col-sm-3">
{% block quick_links %}{% endblock %}
<a class="btn btn-secondary w-100 mb-2" href="{{ entity | entityURL('edit') }}">
<i class="fas fa-edit"></i> {% trans %}entity.edit.btn{% endtrans %}
</a>
<div class="">
<a class="btn btn-secondary w-100 mb-2" href="{{ entity | entityURL('edit') }}">
<i class="fas fa-edit"></i> {% trans %}entity.edit.btn{% endtrans %}
</a>
<div class="">
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
<i class="fas fa-history fa-fw"></i> {{ entity.lastModified | format_datetime("short") }}
</span>
<br>
<span class="text-muted mt-1" title="{% trans %}createdAt{% endtrans %}">
<br>
<span class="text-muted mt-1" title="{% trans %}createdAt{% endtrans %}">
<i class="fas fa-calendar-plus fa-fw"></i> {{ entity.addedDate | format_datetime("short") }}
</span>
</div>
{% if entity is instanceof("App\\Entity\\Parts\\Storelocation") %}
{{ dropdown.profile_dropdown('storelocation', entity.id, true, 'btn-secondary w-100 mt-2') }}
{% endif %}
</div>
{% if entity is instanceof("App\\Entity\\Parts\\Storelocation") %}
{{ dropdown.profile_dropdown('storelocation', entity.id, true, 'btn-secondary w-100 mt-2') }}
{% endif %}
</div>
</div>
<div class="tab-pane fade" id="v-pills-statistics" role="tabpanel" aria-labelledby="v-pills-statistics-tab">
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.children_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ entity.children | length }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parts_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ repo.partsCount(entity) }}</span>
</div>
</div>
</div>
{% if entity.attachments is not empty %}
<div class="tab-pane fade" id="v-pills-attachments" role="tabpanel" aria-labelledby="v-pills-attachments-tab">
{% include "Parts/info/_attachments_info.html.twig" with {"part": entity} %}
</div>
{% endif %}
{% if entity.parameters is not empty %}
<div class="tab-pane fade" id="v-pills-parameters" role="tabpanel" aria-labelledby="v-pills-parameters-tab">
{% for name, parameters in entity.groupedParameters %}
{% if name is not empty %}<h5 class="mt-1">{{ name }}</h5>{% endif %}
{{ helper.parameters_table(parameters) }}
{% endfor %}
</div>
{% endif %}
{% if entity.comment is not empty %}
<div class="tab-pane fade" id="v-pills-comment" role="tabpanel" aria-labelledby="home-tab">
<div class="container-fluid mt-2 latex" data-controller="common--latex">
{{ entity.comment|markdown }}
</div>
</div>
{% endif %}
</div>
<div class="tab-pane fade" id="v-pills-statistics" role="tabpanel" aria-labelledby="v-pills-statistics-tab">
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.children_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ entity.children | length }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parts_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ repo.partsCount(entity) }}</span>
</div>
</div>
</div>
{% if entity.attachments is not empty %}
<div class="tab-pane fade" id="v-pills-attachments" role="tabpanel" aria-labelledby="v-pills-attachments-tab">
{% include "Parts/info/_attachments_info.html.twig" with {"part": entity} %}
</div>
{% endif %}
{% if entity.parameters is not empty %}
<div class="tab-pane fade" id="v-pills-parameters" role="tabpanel" aria-labelledby="v-pills-parameters-tab">
{% for name, parameters in entity.groupedParameters %}
{% if name is not empty %}<h5 class="mt-1">{{ name }}</h5>{% endif %}
{{ helper.parameters_table(parameters) }}
{% endfor %}
</div>
{% endif %}
{% if entity.comment is not empty %}
<div class="tab-pane fade" id="v-pills-comment" role="tabpanel" aria-labelledby="home-tab">
<div class="container-fluid mt-2 latex" data-controller="common--latex">
{{ entity.comment|markdown }}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% if filterForm is defined %}
{% include "Parts/lists/_filter.html.twig" %}
{% endif %}
</div>

View file

@ -1,11 +1,29 @@
{% extends "base.html.twig" %}
{% block title %}
{% trans %}parts_list.all.title{% endtrans %}
{% trans %}parts_list.all.title{% endtrans %}
{% endblock %}
{% block content %}
<div class="accordion mb-3" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#searchInfo" disabled>
<i class="fa-solid fa-globe fa-fw"></i>
{% trans %}parts_list.all.title{% endtrans %}
</button>
</div>
<div id="searchInfo" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
</div>
</div>
</div>
{% include "Parts/lists/_filter.html.twig" %}
</div>
{% include "Parts/lists/_action_bar.html.twig" with {'url_options': {}} %}
{% include "Parts/lists/_parts_list.html.twig" %}

View file

@ -1,11 +1,82 @@
{% extends "base.html.twig" %}
{% block title %}
{% trans %}parts_list.search.title{% endtrans %} {{ keyword }}
{% trans %}parts_list.search.title{% endtrans %}: {{ keyword }}
{% endblock %}
{% block content %}
<div class="accordion mb-3" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#searchInfo">
<i class="fa-solid fa-magnifying-glass fa-fw"></i>
{% trans %}parts_list.search.title{% endtrans %}:&nbsp;<b>{{ keyword }}</b>
</button>
</div>
<div id="searchInfo" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
<h4>{% trans with {"%keyword%": keyword} %}parts_list.search.searching_for{% endtrans %}</h4>
{% trans %}parts_list.search_options.caption{% endtrans %}:
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.name %}checked{% endif %}>
<label class="form-check-label justify-content-start">{% trans %}name.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.category %}checked{% endif %}>
<label class="form-check-label justify-content-start">{% trans %}category.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.description %}checked{% endif %}>
<label class="form-check-label justify-content-start">{% trans %}description.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.mpn %}checked{% endif %}>
<label class="form-check-label justify-content-start">{% trans %}part.edit.mpn{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.tags %}checked{% endif %}>
<label class="form-check-label justify-content-start">{% trans %}tags.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.storelocation %}checked{% endif %}>
<label class="form-check-label justify-content-start">{% trans %}storelocation.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.comment %}checked{% endif %}>
<label class="form-check-label justify-content-start">{% trans %}comment.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.ordernr %}checked{% endif %}>
<label for="search_supplierpartnr" class="form-check-label justify-content-start">{% trans %}ordernumber.label.short{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.supplier %}checked{% endif %}>
<label for="search_supplier" class="form-check-label justify-content-start">{% trans %}supplier.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.manufacturer %}checked{% endif %}>
<label for="search_manufacturer" class="form-check-label justify-content-start">{% trans %}manufacturer.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.footprint %}checked{% endif %}>
<label for="search_footprint" class="form-check-label justify-content-start">{% trans %}footprint.label{% endtrans %}</label>
</div>
<hr>
<div class="form-check">
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.regex %}checked{% endif %}>
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>
</div>
</div>
</div>
</div>
{% include "Parts/lists/_filter.html.twig" %}
</div>
{% include "Parts/lists/_parts_list.html.twig" %}
{% endblock %}

View file

@ -6,6 +6,26 @@
{% block content %}
<div class="accordion mb-3" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#searchInfo" disabled>
<i class="fa-solid fa-tags fa-fw"></i>
{% trans %}parts_list.tags.title{% endtrans %}&nbsp;<span class="badge bg-primary">{{ tag }}</span>
</button>
</div>
<div id="searchInfo" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
</div>
</div>
</div>
{% include "Parts/lists/_filter.html.twig" %}
</div>
{% include "Parts/lists/_action_bar.html.twig" with {'url_options': {}} %}
{% include "Parts/lists/_parts_list.html.twig" %}
{% endblock %}

View file

@ -18,6 +18,10 @@
<input type="checkbox" class="form-check-input" id="search_description" name="description" value="1" checked>
<label for="search_description" class="form-check-label justify-content-start">{% trans %}description.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_mpn" name="mpn" value="1" checked>
<label for="search_description" class="form-check-label justify-content-start">{% trans %}part.edit.mpn{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_tags" name="tags" value="1" checked>
<label for="search_tags" class="form-check-label justify-content-start">{% trans %}tags.label{% endtrans %}</label>
@ -26,8 +30,8 @@
<input type="checkbox" class="form-check-input" id="search_storelocation" name="storelocation" value="1" checked>
<label for="search_storelocation" class="form-check-label justify-content-start">{% trans %}storelocation.label{% endtrans %}</label>
</div>
<div class=" custom-control custom-checkbox">
<input type="checkbox" id="search_comment" class="form-check-input" id="search_comment" name="comment" value="1" checked>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_comment" name="comment" value="1" checked>
<label for="search_comment" class="form-check-label justify-content-start">{% trans %}comment.label{% endtrans %}</label>
</div>
{% if true %}
@ -52,10 +56,7 @@
<label for="search_footprint" class="form-check-label justify-content-start">{% trans %}footprint.label{% endtrans %}</label>
</div>
{% endif %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="disable_pid_input" name="disable_pid_input" value="1">
<label for="disable_pid_input" class="form-check-label justify-content-start">{% trans %}search.deactivateBarcode{% endtrans %}</label>
</div>
<hr>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1">
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>

Some files were not shown because too many files have changed in this diff Show more