mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-02 13:29:36 +00:00
Merge e4cd243c7c into 1650ade338
This commit is contained in:
commit
a556fbdffa
158 changed files with 25953 additions and 307 deletions
70
assets/controllers/elements/assembly_select_controller.js
Normal file
70
assets/controllers/elements/assembly_select_controller.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
import "tom-select/dist/css/tom-select.bootstrap5.css";
|
||||
import '../../css/components/tom-select_extensions.css';
|
||||
import TomSelect from "tom-select";
|
||||
import {marked} from "marked";
|
||||
|
||||
export default class extends Controller {
|
||||
_tomSelect;
|
||||
|
||||
connect() {
|
||||
|
||||
let settings = {
|
||||
allowEmptyOption: true,
|
||||
plugins: ['dropdown_input', 'clear_button'],
|
||||
searchField: ["name", "description", "category", "footprint"],
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
preload: "focus",
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
return '<span>' + (data.image ? "<img style='height: 1.5rem; margin-right: 5px;' ' src='" + data.image + "'/>" : "") + escape(data.name) + '</span>';
|
||||
},
|
||||
option: (data, escape) => {
|
||||
if(data.text) {
|
||||
return '<span>' + escape(data.text) + '</span>';
|
||||
}
|
||||
|
||||
let tmp = '<div class="row m-0">' +
|
||||
"<div class='col-2 p-0 d-flex align-items-center' style='max-width: 80px;'>" +
|
||||
(data.image ? "<img class='typeahead-image' src='" + data.image + "'/>" : "") +
|
||||
"</div>" +
|
||||
"<div class='col-10'>" +
|
||||
'<h6 class="m-0">' + escape(data.name) + '</h6>' +
|
||||
(data.description ? '<p class="m-0">' + marked.parseInline(data.description) + '</p>' : "") +
|
||||
(data.category ? '<p class="m-0"><span class="fa-solid fa-tags fa-fw"></span> ' + escape(data.category) : "");
|
||||
|
||||
return tmp + '</p>' +
|
||||
'</div></div>';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (this.element.dataset.autocomplete) {
|
||||
const base_url = this.element.dataset.autocomplete;
|
||||
settings.valueField = "id";
|
||||
settings.load = (query, callback) => {
|
||||
const url = base_url.replace('__QUERY__', encodeURIComponent(query));
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(json => {callback(json);})
|
||||
.catch(() => {
|
||||
callback()
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
this._tomSelect = new TomSelect(this.element, settings);
|
||||
//this._tomSelect.clearOptions();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect();
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -54,10 +54,19 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
initialize() {
|
||||
// The endpoint for searching parts
|
||||
// The endpoint for searching parts or assemblies
|
||||
const base_url = this.element.dataset.autocomplete;
|
||||
// The URL template for the part detail pages
|
||||
const part_detail_uri_template = this.element.dataset.detailUrl;
|
||||
// The URL template for the assembly detail pages
|
||||
const assembly_detail_uri_template = this.element.dataset.assemblyDetailUrl;
|
||||
// The URL template for the project detail pages
|
||||
const project_detail_uri_template = this.element.dataset.projectDetailUrl;
|
||||
|
||||
const hasAssemblyDetailUrl =
|
||||
typeof assembly_detail_uri_template === "string" && assembly_detail_uri_template.length > 0;
|
||||
const hasProjectDetailUrl =
|
||||
typeof project_detail_uri_template === "string" && project_detail_uri_template.length > 0;
|
||||
|
||||
//The URL of the placeholder picture
|
||||
const placeholder_image = this.element.dataset.placeholderImage;
|
||||
|
|
@ -72,6 +81,43 @@ export default class extends Controller {
|
|||
limit: 5,
|
||||
});
|
||||
|
||||
// Cache the last query to avoid fetching the same endpoint twice (parts source + assemblies source)
|
||||
let lastQuery = null;
|
||||
let lastFetchPromise = null;
|
||||
|
||||
const fetchMixedItems = (query) => {
|
||||
if (query === lastQuery && lastFetchPromise) {
|
||||
return lastFetchPromise;
|
||||
}
|
||||
|
||||
lastQuery = query;
|
||||
|
||||
const urlString = base_url.replace('__QUERY__', encodeURIComponent(query));
|
||||
const url = new URL(urlString, window.location.href);
|
||||
if (hasAssemblyDetailUrl || hasProjectDetailUrl) {
|
||||
url.searchParams.set('multidatasources', '1');
|
||||
}
|
||||
|
||||
lastFetchPromise = fetch(url.toString())
|
||||
.then((response) => response.json())
|
||||
.then((items) => {
|
||||
//Iterate over all fields besides the id and highlight them (if present)
|
||||
const fields = ["name", "description", "category", "footprint"];
|
||||
|
||||
items.forEach((item) => {
|
||||
for (const field of fields) {
|
||||
if (item[field] !== undefined && item[field] !== null) {
|
||||
item[field] = that._highlight(item[field], query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
return lastFetchPromise;
|
||||
};
|
||||
|
||||
this._autocomplete = autocomplete({
|
||||
container: this.element,
|
||||
//Place the panel in the navbar, if the element is in navbar mode
|
||||
|
|
@ -102,7 +148,7 @@ export default class extends Controller {
|
|||
},
|
||||
|
||||
// If the form is submitted, forward the term to the form
|
||||
onSubmit({state, event, ...setters}) {
|
||||
onSubmit({ state, event, ...setters }) {
|
||||
//Put the current text into each target input field
|
||||
const input = that.inputTarget;
|
||||
|
||||
|
|
@ -119,31 +165,15 @@ export default class extends Controller {
|
|||
input.form.requestSubmit();
|
||||
},
|
||||
|
||||
|
||||
getSources({ query }) {
|
||||
return [
|
||||
// The parts source
|
||||
const sources = [
|
||||
// Parts source (filtered from mixed endpoint results)
|
||||
{
|
||||
sourceId: 'parts',
|
||||
getItems() {
|
||||
const url = base_url.replace('__QUERY__', encodeURIComponent(query));
|
||||
|
||||
const data = fetch(url)
|
||||
.then((response) => response.json())
|
||||
;
|
||||
|
||||
//Iterate over all fields besides the id and highlight them
|
||||
const fields = ["name", "description", "category", "footprint"];
|
||||
|
||||
data.then((items) => {
|
||||
items.forEach((item) => {
|
||||
for (const field of fields) {
|
||||
item[field] = that._highlight(item[field], query);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
return fetchMixedItems(query).then((items) =>
|
||||
items.filter((item) => item.type !== "assembly")
|
||||
);
|
||||
},
|
||||
getItemUrl({ item }) {
|
||||
return part_detail_uri_template.replace('__ID__', item.id);
|
||||
|
|
@ -151,36 +181,130 @@ export default class extends Controller {
|
|||
templates: {
|
||||
header({ html }) {
|
||||
return html`<span class="aa-SourceHeaderTitle">${trans("part.labelp")}</span>
|
||||
<div class="aa-SourceHeaderLine" />`;
|
||||
<div class="aa-SourceHeaderLine" />`;
|
||||
},
|
||||
item({item, components, html}) {
|
||||
item({ item, components, html }) {
|
||||
const details_url = part_detail_uri_template.replace('__ID__', item.id);
|
||||
|
||||
return html`
|
||||
<a class="aa-ItemLink" href="${details_url}">
|
||||
<div class="aa-ItemContent">
|
||||
<div class="aa-ItemIcon aa-ItemIcon--picture aa-ItemIcon--alignTop">
|
||||
<img src="${item.image !== "" ? item.image : placeholder_image}" alt="${item.name}" width="30" height="30"/>
|
||||
</div>
|
||||
<div class="aa-ItemContentBody">
|
||||
<div class="aa-ItemContentTitle">
|
||||
<b>
|
||||
${components.Highlight({hit: item, attribute: 'name'})}
|
||||
</b>
|
||||
<a class="aa-ItemLink" href="${details_url}">
|
||||
<div class="aa-ItemContent">
|
||||
<div class="aa-ItemIcon aa-ItemIcon--picture aa-ItemIcon--alignTop">
|
||||
<img src="${item.image !== "" ? item.image : placeholder_image}" alt="${item.name}" width="30" height="30"/>
|
||||
</div>
|
||||
<div class="aa-ItemContentDescription">
|
||||
${components.Highlight({hit: item, attribute: 'description'})}
|
||||
${item.category ? html`<p class="m-0"><span class="fa-solid fa-tags fa-fw"></span>${components.Highlight({hit: item, attribute: 'category'})}</p>` : ""}
|
||||
${item.footprint ? html`<p class="m-0"><span class="fa-solid fa-microchip fa-fw"></span>${components.Highlight({hit: item, attribute: 'footprint'})}</p>` : ""}
|
||||
<div class="aa-ItemContentBody">
|
||||
<div class="aa-ItemContentTitle">
|
||||
<b>
|
||||
${components.Highlight({hit: item, attribute: 'name'})}
|
||||
</b>
|
||||
</div>
|
||||
<div class="aa-ItemContentDescription">
|
||||
${components.Highlight({hit: item, attribute: 'description'})}
|
||||
${item.category ? html`<p class="m-0"><span class="fa-solid fa-tags fa-fw"></span>${components.Highlight({hit: item, attribute: 'category'})}</p>` : ""}
|
||||
${item.footprint ? html`<p class="m-0"><span class="fa-solid fa-microchip fa-fw"></span>${components.Highlight({hit: item, attribute: 'footprint'})}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
</a>
|
||||
`;
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (hasAssemblyDetailUrl) {
|
||||
sources.push(
|
||||
// Assemblies source (filtered from the same mixed endpoint results)
|
||||
{
|
||||
sourceId: 'assemblies',
|
||||
getItems() {
|
||||
return fetchMixedItems(query).then((items) =>
|
||||
items.filter((item) => item.type === "assembly")
|
||||
);
|
||||
},
|
||||
getItemUrl({ item }) {
|
||||
return assembly_detail_uri_template.replace('__ID__', item.id);
|
||||
},
|
||||
templates: {
|
||||
header({ html }) {
|
||||
return html`<span class="aa-SourceHeaderTitle">${trans("assembly.labelp")}</span>
|
||||
<div class="aa-SourceHeaderLine" />`;
|
||||
},
|
||||
item({ item, components, html }) {
|
||||
const details_url = assembly_detail_uri_template.replace('__ID__', item.id);
|
||||
|
||||
return html`
|
||||
<a class="aa-ItemLink" href="${details_url}">
|
||||
<div class="aa-ItemContent">
|
||||
<div class="aa-ItemIcon aa-ItemIcon--picture aa-ItemIcon--alignTop">
|
||||
<img src="${item.image !== "" ? item.image : placeholder_image}" alt="${item.name}" width="30" height="30"/>
|
||||
</div>
|
||||
<div class="aa-ItemContentBody">
|
||||
<div class="aa-ItemContentTitle">
|
||||
<b>
|
||||
${components.Highlight({hit: item, attribute: 'name'})}
|
||||
</b>
|
||||
</div>
|
||||
<div class="aa-ItemContentDescription">
|
||||
${components.Highlight({hit: item, attribute: 'description'})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (hasProjectDetailUrl) {
|
||||
sources.push(
|
||||
// Projects source (filtered from the same mixed endpoint results)
|
||||
{
|
||||
sourceId: 'projects',
|
||||
getItems() {
|
||||
return fetchMixedItems(query).then((items) =>
|
||||
items.filter((item) => item.type === "project")
|
||||
);
|
||||
},
|
||||
getItemUrl({ item }) {
|
||||
return project_detail_uri_template.replace('__ID__', item.id);
|
||||
},
|
||||
templates: {
|
||||
header({ html }) {
|
||||
return html`<span class="aa-SourceHeaderTitle">${trans("project.labelp")}</span>
|
||||
<div class="aa-SourceHeaderLine" />`;
|
||||
},
|
||||
item({ item, components, html }) {
|
||||
const details_url = project_detail_uri_template.replace('__ID__', item.id);
|
||||
|
||||
return html`
|
||||
<a class="aa-ItemLink" href="${details_url}">
|
||||
<div class="aa-ItemContent">
|
||||
<div class="aa-ItemIcon aa-ItemIcon--picture aa-ItemIcon--alignTop">
|
||||
<img src="${item.image !== "" ? item.image : placeholder_image}" alt="${item.name}" width="30" height="30"/>
|
||||
</div>
|
||||
<div class="aa-ItemContentBody">
|
||||
<div class="aa-ItemContentTitle">
|
||||
<b>
|
||||
${components.Highlight({hit: item, attribute: 'name'})}
|
||||
</b>
|
||||
</div>
|
||||
<div class="aa-ItemContentDescription">
|
||||
${components.Highlight({hit: item, attribute: 'description'})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return sources;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -192,6 +316,5 @@ export default class extends Controller {
|
|||
this._autocomplete.setIsOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
assets/controllers/elements/toggle_visibility_controller.js
Normal file
62
assets/controllers/elements/toggle_visibility_controller.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static values = {
|
||||
classes: Array
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.displayCheckbox = this.element.querySelector("#display");
|
||||
this.displaySelect = this.element.querySelector("select#display");
|
||||
|
||||
if (this.displayCheckbox) {
|
||||
this.toggleContainers(this.displayCheckbox.checked);
|
||||
|
||||
this.displayCheckbox.addEventListener("change", (event) => {
|
||||
this.toggleContainers(event.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.displaySelect) {
|
||||
this.toggleContainers(this.hasDisplaySelectValue());
|
||||
|
||||
this.displaySelect.addEventListener("change", () => {
|
||||
this.toggleContainers(this.hasDisplaySelectValue());
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a value was selected in the selectbox
|
||||
* @returns {boolean} True when a value has not been selected that is not empty
|
||||
*/
|
||||
hasDisplaySelectValue() {
|
||||
return this.displaySelect && this.displaySelect.value !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides specified containers if the state is active (checkbox checked or select with value).
|
||||
*
|
||||
* @param {boolean} isActive - True when the checkbox is activated or the selectbox has a value.
|
||||
*/
|
||||
toggleContainers(isActive) {
|
||||
if (!Array.isArray(this.classesValue) || this.classesValue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.classesValue.forEach((cssClass) => {
|
||||
const elements = document.querySelectorAll(`.${cssClass}`);
|
||||
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.forEach((element) => {
|
||||
element.style.display = isActive ? "none" : "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export default class extends Controller {
|
|||
|
||||
connect() {
|
||||
//Add event listener to the checkbox
|
||||
this.getCheckbox().addEventListener('change', this.toggleInputLimits.bind(this));
|
||||
this.getCheckbox()?.addEventListener('change', this.toggleInputLimits.bind(this));
|
||||
}
|
||||
|
||||
toggleInputLimits() {
|
||||
|
|
|
|||
157
assets/controllers/pages/statistics_assembly_controller.js
Normal file
157
assets/controllers/pages/statistics_assembly_controller.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
cleanupBomUrl: String,
|
||||
cleanupPreviewUrl: String
|
||||
}
|
||||
|
||||
static targets = ["bomCount", "previewCount", "bomButton", "previewButton"]
|
||||
|
||||
async cleanup(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
const button = event ? event.currentTarget : null;
|
||||
if (button) button.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await this.fetchWithErrorHandling(this.cleanupBomUrlValue, { method: 'POST' });
|
||||
|
||||
if (data.success) {
|
||||
this.showSuccessMessage(data.message);
|
||||
if (this.hasBomCountTarget) {
|
||||
this.bomCountTarget.textContent = data.new_count;
|
||||
}
|
||||
if (data.new_count === 0 && this.hasBomButtonTarget) {
|
||||
this.bomButtonTarget.remove();
|
||||
}
|
||||
} else {
|
||||
this.showErrorMessage(data.message || 'BOM cleanup failed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showErrorMessage(error.message || 'An unexpected error occurred during BOM cleanup');
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupPreview(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
const button = event ? event.currentTarget : null;
|
||||
if (button) button.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await this.fetchWithErrorHandling(this.cleanupPreviewUrlValue, { method: 'POST' });
|
||||
|
||||
if (data.success) {
|
||||
this.showSuccessMessage(data.message);
|
||||
if (this.hasPreviewCountTarget) {
|
||||
this.previewCountTarget.textContent = data.new_count;
|
||||
}
|
||||
if (data.new_count === 0 && this.hasPreviewButtonTarget) {
|
||||
this.previewButtonTarget.remove();
|
||||
}
|
||||
} else {
|
||||
this.showErrorMessage(data.message || 'Preview cleanup failed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showErrorMessage(error.message || 'An unexpected error occurred during Preview cleanup');
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
async fetchWithErrorHandling(url, options = {}, timeout = 30000) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...this.getHeaders(), ...options.headers },
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Server error (${response.status})`;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
if (errorJson && errorJson.message) {
|
||||
errorMessage = errorJson.message;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON response, use status text
|
||||
errorMessage = `${errorMessage}: ${errorText}`;
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timed out. Please try again.')
|
||||
} else if (error.message.includes('Failed to fetch')) {
|
||||
throw new Error('Network error. Please check your connection and try again.')
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showSuccessMessage(message) {
|
||||
this.showToast('success', message)
|
||||
}
|
||||
|
||||
showErrorMessage(message) {
|
||||
this.showToast('error', message)
|
||||
}
|
||||
|
||||
showToast(type, message) {
|
||||
const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle';
|
||||
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
|
||||
const title = type === 'success' ? 'Success' : 'Error';
|
||||
const timeString = new Date().toLocaleString(undefined, {
|
||||
year: '2-digit',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const toastHTML = `
|
||||
<div role="alert" aria-live="assertive" aria-atomic="true" data-delay="5000" data-controller="common--toast" class="toast shadow fade show">
|
||||
<div class="toast-header ${bgClass} text-white">
|
||||
<i class="fas fa-fw ${iconClass} me-2"></i>
|
||||
<strong class="me-auto">${title}</strong>
|
||||
<small class="text-white">${timeString}</small>
|
||||
<button type="button" class="ms-2 mb-1 btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body ${bgClass} text-white">
|
||||
${message}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add toast to body. The common--toast controller will move it to the container.
|
||||
document.body.insertAdjacentHTML('beforeend', toastHTML);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,3 +67,8 @@
|
|||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.assembly-table-image {
|
||||
max-height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@
|
|||
//CHANGED jbtronics: Preserve the get parameters (needed so we can pass additional params to query)
|
||||
$.fn.initDataTables.defaults.url = window.location.origin + window.location.pathname + window.location.search;
|
||||
|
||||
$.fn.dataTable.ext.errMode = function(settings, helpPage, message) {
|
||||
if (message.includes('ColReorder')) {
|
||||
console.warn('ColReorder does not fit the number of columns', message);
|
||||
}
|
||||
};
|
||||
|
||||
var root = this,
|
||||
config = $.extend({}, $.fn.initDataTables.defaults, config),
|
||||
state = ''
|
||||
|
|
@ -105,7 +111,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
root.html(data.template);
|
||||
dt = $('table', root).DataTable(dtOpts);
|
||||
if (config.state !== 'none') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue