@doyosi/laravel
Version:
Complete JavaScript plugins collection for Laravel applications - AJAX, forms, UI components, and more
1,304 lines (1,143 loc) • 76.3 kB
JavaScript
/*!
* @doyosi/laravel-js v1.0.5
* JavaScript plugins for Laravel applications
* (c) 2025 Karyazilim
* Released under MIT License
*/
/**
* AjaxDivBox - Ajax-based grid/list view with card-like div rendering.
*
* @param {Object} options - Configuration options.
* @see constructor for all available options.
*
* @example
* import AjaxDivBox from './AjaxDivBox.js';
* const grid = new AjaxDivBox({
* url: '/api/items',
* container: '#ajax-list',
* templateId: 'box-template',
* filterSelector: '#filters',
* pagination: '#pagination-container'
* });
* grid.init(); // Fetch initial data
* grid.on('rendered', ({ data }) => console.log('Loaded', data.length, 'items'));
*/
class AjaxDivBox {
/**
* @param {Object} options
* @param {string} options.url - API endpoint for data.
* @param {string|Element} options.container - The container to render the items into.
* @param {string} [options.templateId='box-template'] - The ID of the script template for rendering items.
* @param {string} [options.metaKey='meta'] - The key in the API response for pagination metadata.
* @param {string} [options.dataKey='data'] - The key in the API response for the array of items.
* @param {string} [options.fetcher='axios'|'fetch'] - The HTTP client to use. Auto-detects Axios.
* @param {function|null} [options.onBox=null] - A custom function to render an item, overrides templateId.
* @param {string|Element|null} [options.pagination=null] - The container for pagination links.
* @param {string|Element|null} [options.filterSelector=null] - The form or div containing filter inputs.
* @param {string|Element|null} [options.loadingIndicator='.loading-list'] - Selector for the loading element.
* @param {string|Element|null} [options.nothingFoundBlock='.nothing-found-list'] - Element to show when no results are found.
* @param {string|Element|null} [options.errorBlock='.list-render-error'] - Element to show on fetch error.
* @param {function|null} [options.additionalParams=null] - Function that returns an object of extra query parameters.
*/
constructor({
url,
container,
templateId = 'box-template',
metaKey = 'meta',
dataKey = 'data',
fetcher = window.axios ? 'axios' : 'fetch',
onBox = null,
pagination = null,
filterSelector = null,
loadingIndicator = '.loading-list',
nothingFoundBlock = '.nothing-found-list',
errorBlock = '.list-render-error',
additionalParams = null,
}) {
this.url = url;
this.config = { templateId, metaKey, dataKey, fetcher, onBox, additionalParams };
this.filters = {};
this._handlers = {};
this.debounceTimer = null;
// --- UPDATE: Centralized DOM element querying ---
// Query all elements once and store them.
const getElement = (selector) => {
if (selector instanceof HTMLElement) return selector;
if (typeof selector === 'string') return document.querySelector(selector);
return null;
};
this.elements = {
container: getElement(container),
pagination: getElement(pagination),
filters: getElement(filterSelector),
loader: getElement(loadingIndicator),
nothingFound: getElement(nothingFoundBlock),
error: getElement(errorBlock),
};
if (!this.elements.container) {
throw new Error('AjaxDivBox: The main container element is required and was not found.');
}
// --- UPDATE: Event listeners are bound once in the constructor ---
this._bindFilterEvents();
if (this.config.autoInit !== false) {
this.init();
}
}
/**
* Registers an event handler.
* @param {'start'|'rendered'|'error'|'pageChange'} event - The event name.
* @param {Function} fn - The callback function.
* @returns {this}
*/
on(event, fn) {
if (!this._handlers[event]) this._handlers[event] = [];
this._handlers[event].push(fn);
return this;
}
/**
* Emits an event to all registered handlers.
* @private
*/
_emit(event, payload) {
(this._handlers[event] || []).forEach(fn => fn(payload));
}
/**
* Initializes the component by fetching the first page of data.
* This should be called after instantiation.
*/
init() {
if (this.elements.filters) {
this._updateFilters();
}
return this.fetchData(1);
}
/**
* Refreshes the data using the current filters and page.
*/
refresh() {
// UPDATE: More descriptive name than init() for a refresh action.
const currentPage = this.lastMeta?.current_page || 1;
return this.fetchData(currentPage);
}
/**
* Binds change/input events to filter elements.
* @private
*/
_bindFilterEvents() {
if (!this.elements.filters) return;
this.elements.filters.addEventListener('input', e => {
const target = e.target;
if (target.matches('input, select')) {
// UPDATE: Simplified debounce logic.
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this._updateFilters();
this.fetchData(1);
}, 300);
}
});
}
/**
* Reads the current values from the filter inputs.
* @private
*/
_updateFilters() {
this.filters = {};
if (!this.elements.filters) return;
const formData = new FormData(this.elements.filters.tagName === 'FORM' ? this.elements.filters : undefined);
if (this.elements.filters.tagName !== 'FORM') {
this.elements.filters.querySelectorAll('input, select').forEach(input => {
if (input.name) formData.append(input.name, input.value);
});
}
for (const [key, value] of formData.entries()) {
if (value) this.filters[key] = value;
}
}
/**
* Constructs the query string from current filters and additional params.
* @private
*/
_buildQueryString(page = 1) {
let params = { ...this.filters, page };
if (typeof this.config.additionalParams === 'function') {
params = { ...params, ...this.config.additionalParams() };
}
return new URLSearchParams(Object.entries(params).filter(([, v]) => v !== '' && v != null)).toString();
}
/**
* Manages the visibility of elements based on the current state.
* @private
* @param {'loading'|'content'|'error'|'empty'} state - The state to display.
* @param {string} [errorMessage] - An optional error message.
*/
_setState(state, errorMessage = 'An error occurred.') {
const { container, loader, nothingFound, error } = this.elements;
const all = [container, loader, nothingFound, error];
all.forEach(el => el?.classList.add('hidden'));
if (state === 'loading' && loader) loader.classList.remove('hidden');
else if (state === 'content' && container) container.classList.remove('hidden');
else if (state === 'empty' && nothingFound) nothingFound.classList.remove('hidden');
else if (state === 'error' && error) {
error.classList.remove('hidden');
const errorTextField = error.querySelector('.list-render-error-text') || error;
errorTextField.textContent = errorMessage;
}
}
/**
* Fetches data from the API endpoint.
* @param {number} [page=1] - The page number to fetch.
*/
async fetchData(page = 1) {
this._setState('loading');
this._emit("start", { page });
const endpoint = `${this.url.split('?')[0]}?${this._buildQueryString(page)}`;
try {
const response = this.config.fetcher === 'axios'
? (await window.axios.get(endpoint)).data
: await (await fetch(endpoint, { headers: { 'Accept': 'application/json' } })).json();
if (response.ok === false) throw response; // Handle API-level errors
const meta = response[this.config.metaKey] || {};
this.lastMeta = meta; // Cache for refresh
// Check for pre-rendered HTML in the response
if (response.html !== undefined && response.html !== null) {
// If API returns pre-rendered HTML, use it directly
this.elements.container.innerHTML = response.html;
this._renderPagination(meta);
const hasContent = response.html.trim().length > 0;
this._setState(hasContent ? 'content' : 'empty');
this._emit("rendered", { html: response.html, meta, page });
} else {
// Otherwise, use the standard template rendering
const data = response[this.config.dataKey] || [];
this._renderBoxes(data);
this._renderPagination(meta);
this._setState(data.length > 0 ? 'content' : 'empty');
this._emit("rendered", { data, meta, page });
}
} catch (err) {
console.error('AjaxDivBox fetch error:', err);
const message = err?.message || 'Failed to load data.';
this._setState('error', message);
this._emit("error", { error: err, message });
}
}
/**
* Renders the items into the container.
* @private
*/
_renderBoxes(data) {
if (!this.elements.container) return;
this.elements.container.innerHTML = ''; // Clear previous content
const fragment = document.createDocumentFragment();
data.forEach(item => {
const itemHtml = this._renderTemplate(item);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = itemHtml;
// Append all children from the temp div to the fragment
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
});
this.elements.container.appendChild(fragment);
}
/**
* Renders a single item using either the custom onBox function or the template.
* @private
*/
_renderTemplate(item) {
// UPDATE: Prioritize per-item HTML if it exists.
if (item.html !== undefined && item.html !== null) {
return item.html;
}
if (typeof this.config.onBox === 'function') {
return this.config.onBox(item);
}
const tpl = document.getElementById(this.config.templateId);
if (!tpl) {
console.error(`Template with id "${this.config.templateId}" not found.`);
return '';
}
let html = tpl.innerHTML;
// Replace nested object properties (e.g., data.user.name)
html = html.replace(/data\.([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)/g, (match, path) => {
const keys = path.split('.');
let value = item;
for (const key of keys) {
value = value?.[key];
if (value === undefined) break;
}
return value ?? '';
});
// Replace simple properties (e.g., data.name)
html = html.replace(/data\.([a-zA-Z0-9_]+)/g, (_, key) => item[key] ?? '');
return html;
}
/**
* Renders pagination links based on metadata.
* @private
*/
_renderPagination(meta) {
const pagEl = this.elements.pagination;
if (!pagEl) return;
pagEl.innerHTML = '';
if (!meta?.links || meta.last_page <= 1) {
pagEl.classList.add('hidden');
return;
}
pagEl.classList.remove('hidden');
const fragment = document.createDocumentFragment();
meta.links.forEach(link => {
const pageNum = new URL(link.url || '', window.location.origin).searchParams.get('page');
if (link.label.includes('...')) {
const span = document.createElement('span');
span.className = 'btn btn-disabled join-item';
span.textContent = '...';
fragment.appendChild(span);
return;
}
const btn = document.createElement('button');
btn.className = `join-item btn ${link.active ? 'btn-active' : ''}`;
btn.disabled = !link.url || link.active;
btn.innerHTML = link.label.replace(/«|»/g, '');
if (link.url) {
btn.onclick = (e) => {
e.preventDefault();
this._emit("pageChange", { page: parseInt(pageNum), label: link.label });
this.fetchData(pageNum);
};
}
fragment.appendChild(btn);
});
const joinDiv = document.createElement('div');
joinDiv.className = "join";
joinDiv.appendChild(fragment);
pagEl.appendChild(joinDiv);
}
}
class AjaxTable {
constructor({
url,
container,
templateId = 'row-template',
metaKey = 'meta',
dataKey = 'data',
fetcher = window.axios ? 'axios' : 'fetch',
onRow = null,
pagination = null,
filterSelector = null,
loadingIndicator = '.loading-table',
nothingFoundBlock = '.nothing-found-table',
errorBlock = '.table-render-error',
additionalParams = null,
autoInit = true,
debounceTime = 300
}) {
this.url = url;
this.config = { templateId, metaKey, dataKey, fetcher, onRow, additionalParams, debounceTime };
this.filters = {};
this._handlers = {};
this.debounceTimer = null;
const getEl = (sel) => typeof sel === 'string' ? document.querySelector(sel) : sel;
this.elements = {
container: getEl(container),
table: getEl(container)?.querySelector('table'),
tbody: getEl(container)?.querySelector('tbody'),
pagination: getEl(pagination),
filters: getEl(filterSelector),
loader: getEl(loadingIndicator),
nothingFound: getEl(nothingFoundBlock),
error: getEl(errorBlock),
};
if (!this.elements.table || !this.elements.tbody) {
throw new Error('AjaxTable: Table or tbody element not found.');
}
this._bindFilterEvents();
if (autoInit) this.init();
}
on(event, fn) {
if (!this._handlers[event]) this._handlers[event] = [];
this._handlers[event].push(fn);
return this;
}
_emit(event, payload) {
(this._handlers[event] || []).forEach(fn => fn(payload));
}
init() {
if (this.elements.filters) this._updateFilters();
return this.fetchData(1);
}
refresh() {
const currentPage = this.lastMeta?.current_page || 1;
return this.fetchData(currentPage);
}
_bindFilterEvents() {
if (!this.elements.filters) return;
const handler = () => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this._updateFilters();
this.fetchData(1);
}, this.config.debounceTime);
};
this.elements.filters.querySelectorAll('input, select').forEach(input => {
const type = input.tagName.toLowerCase();
if (type === 'input') {
input.addEventListener('input', handler);
} else {
input.addEventListener('change', handler);
}
});
}
_updateFilters() {
this.filters = {};
const formData = new FormData(this.elements.filters.tagName === 'FORM' ? this.elements.filters : undefined);
if (this.elements.filters.tagName !== 'FORM') {
this.elements.filters.querySelectorAll('input, select').forEach(input => {
if (input.name) formData.append(input.name, input.value);
});
}
for (const [key, value] of formData.entries()) {
if (value) this.filters[key] = value;
}
}
_buildQueryString(page = 1) {
let params = { ...this.filters, page };
if (typeof this.config.additionalParams === 'function') {
params = { ...params, ...this.config.additionalParams() };
}
return new URLSearchParams(Object.entries(params).filter(([, v]) => v != null && v !== '')).toString();
}
_setState(state, errorMessage = 'An error occurred.') {
const { container, loader, nothingFound, error } = this.elements;
const all = [container, loader, nothingFound, error];
all.forEach(el => el?.classList.add('hidden'));
if (state === 'loading' && loader) loader.classList.remove('hidden');
else if (state === 'content' && container) container.classList.remove('hidden');
else if (state === 'empty' && nothingFound) nothingFound.classList.remove('hidden');
else if (state === 'error' && error) {
error.classList.remove('hidden');
const errorTextField = error.querySelector('.table-render-error-text') || error;
errorTextField.textContent = errorMessage;
}
}
async fetchData(page = 1) {
this._setState('loading');
this._emit('start', { page });
const pagEl = this.elements.pagination;
if (pagEl) {
pagEl.innerHTML = '';
pagEl.classList.add('hidden');
}
const endpoint = `${this.url.split('?')[0]}?${this._buildQueryString(page)}`;
try {
const response = this.config.fetcher === 'axios'
? (await window.axios.get(endpoint)).data
: await (await fetch(endpoint)).json();
const meta = response[this.config.metaKey] || {};
this.lastMeta = meta;
const data = response[this.config.dataKey] || [];
this._renderRows(data);
this._renderPagination(meta);
this._setState(data.length > 0 ? 'content' : 'empty');
this._emit('rendered', { data, meta, page });
} catch (err) {
console.error('AjaxTable fetch error:', err);
const msg = err?.message || 'Failed to load data.';
this._setState('error', msg);
this._emit('error', { error: err, message: msg });
}
}
_renderRows(data) {
if (!this.elements.tbody) return;
this.elements.tbody.innerHTML = '';
const fragment = document.createDocumentFragment();
data.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = this._renderTemplate(row);
fragment.appendChild(tr.children.length === 1 && tr.children[0].tagName === 'TR'
? tr.children[0]
: tr);
});
this.elements.tbody.appendChild(fragment);
}
_renderTemplate(row) {
if (typeof this.config.onRow === 'function') return this.config.onRow(row);
if (row.html) return row.html;
const tpl = document.getElementById(this.config.templateId);
if (!tpl) return '';
let html = tpl.innerHTML;
html = html.replace(/data\.([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)/g, (_, path) => {
const keys = path.split('.');
let value = row;
for (const key of keys) {
value = value?.[key];
if (value === undefined) break;
}
return value ?? '';
});
html = html.replace(/data\.([a-zA-Z0-9_]+)/g, (_, key) => row[key] ?? '');
return html;
}
_renderPagination(meta) {
const pagEl = this.elements.pagination;
if (!pagEl || !meta?.links || meta.last_page <= 1) {
pagEl?.classList.add('hidden');
return;
}
pagEl.classList.remove('hidden');
pagEl.innerHTML = '';
const fragment = document.createDocumentFragment();
meta.links.forEach(link => {
const pageNum = new URL(link.url || '', window.location.origin).searchParams.get('page');
const btn = document.createElement('button');
btn.className = `join-item btn ${link.active ? 'btn-active' : ''}`;
btn.disabled = !link.url || link.active;
btn.innerHTML = link.label.replace(/«|»/g, '');
if (link.url) {
btn.onclick = e => {
e.preventDefault();
this._emit('pageChange', { page: parseInt(pageNum), label: link.label });
this.fetchData(pageNum);
};
}
fragment.appendChild(link.label.includes('...') ? Object.assign(document.createElement('span'), {
className: 'btn btn-disabled join-item',
textContent: '...'
}) : btn);
});
const joinDiv = document.createElement('div');
joinDiv.className = 'join';
joinDiv.appendChild(fragment);
pagEl.appendChild(joinDiv);
}
}
class CodeInput {
constructor(selector, hiddenName) {
this.inputs = Array.from(document.querySelectorAll(`${selector}[data-id="${hiddenName}"]`));
this.hidden = document.querySelector(`input[type=hidden][name="${hiddenName}"]`);
this._bindEvents();
this._updateHidden();
}
_bindEvents() {
this.inputs.forEach((inpt, idx) => {
inpt.setAttribute('maxlength', 1);
inpt.addEventListener('input', e => this._onInput(e, idx));
inpt.addEventListener('keydown', e => this._onKeyDown(e, idx));
inpt.addEventListener('paste', e => this._onPaste(e, idx));
});
}
_onInput(e, idx) {
const val = e.target.value.replace(/[^0-9]/g, '').charAt(0);
e.target.value = val;
this._updateHidden();
if (val && this.inputs[idx + 1]) {
this.inputs[idx + 1].focus();
}
}
_onKeyDown(e, idx) {
if (e.key === 'Backspace' && !e.target.value && this.inputs[idx - 1]) {
this.inputs[idx - 1].focus();
}
}
_onPaste(e, idx) {
e.preventDefault();
const paste = e.clipboardData.getData('text').trim().replace(/\s+/g, '');
const vals = paste.split('').slice(0, this.inputs.length - idx);
vals.forEach((ch, i) => {
this.inputs[idx + i].value = ch;
});
this.inputs[Math.min(this.inputs.length - 1, idx + vals.length - 1)].focus();
this._updateHidden();
}
_updateHidden() {
const code = this.inputs.map(i => i.value || '').join('');
if (this.hidden) this.hidden.value = code;
}
}
// DeleteContent.js
class DeleteContent {
/**
* @param {string|NodeList|HTMLElement[]} selector Buttons selector, e.g. ".delete-btn" or a NodeList
* @param {Object} options
* @param {string} [options.confirmText] Modal header text
* @param {string} [options.message] Confirmation message
* @param {string} [options.successText] Success modal header
* @param {string} [options.successMessage] Success description
* @param {string} [options.errorText] Error modal header
* @param {string} [options.errorMessage] Error description
* @param {Function} [options.onDelete] Callback after delete: (success, data, button) => {}
*/
constructor(selector, options = {}) {
this.options = Object.assign({
confirmText: "Are you sure?",
message: "This action cannot be undone!",
successText: "Deleted!",
successMessage: "Your item has been successfully deleted.",
errorText: "Error!",
errorMessage: "Delete failed.",
successTimeout: null,
errorTimeout: null,
}, options);
this.buttons = typeof selector === 'string'
? document.querySelectorAll(selector)
: selector;
this._initModals();
this._bindEvents();
}
// Initializes modals (creates them if they don't exist in the DOM)
_initModals() {
// Confirm Modal
if (!document.getElementById('delete_modal')) {
document.body.insertAdjacentHTML('beforeend', `
<dialog id="delete_modal" class="modal">
<div class="modal-box bg-red-800/90 text-white">
<h3 class="font-bold text-lg text-white flex items-center gap-2 justify-center">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
<span id="delete_modal_title">${this.options.confirmText}</span>
</h3>
<p class="py-1 text-sm text-center" id="delete_modal_message">${this.options.message}</p>
<div class="modal-action flex gap-2 justify-center">
<button class="btn btn-sm shadow-none" type="button" id="cancelDeleteBtn">Cancel</button>
<button class="btn btn-sm btn-error shadow-none" type="button" id="confirmDeleteBtn">Delete</button>
</div>
</div>
</dialog>`);
}
// Success Modal
if (!document.getElementById('success_modal')) {
document.body.insertAdjacentHTML('beforeend', `
<dialog id="success_modal" class="modal">
<div class="modal-box bg-green-800/90 text-white">
<div class="flex items-center justify-between gap-1 border-b border-gray-200 py-2">
<h3 class="text-lg font-bold">
<span id="success_modal_title">${this.options.successText}</span>
</h3>
<form method="dialog">
<button class="btn btn-xs btn-ghost rounded-full btn-circle" type="submit"> <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg></button>
</form>
</div>
<p class="py-4" id="success_modal_message">${this.options.successMessage}</p>
</div>
</dialog>`);
}
// Error Modal
if (!document.getElementById('error_modal')) {
document.body.insertAdjacentHTML('beforeend', `
<dialog id="error_modal" class="modal">
<div class="modal-box bg-red-800/90 text-white">
<div class="flex items-center justify-between gap-1 border-b border-gray-200 py-2">
<h3 class="text-lg font-bold">
<span id="error_modal_title">${this.options.errorText}</span>
</h3>
<form method="dialog">
<button class="btn btn-xs btn-ghost rounded-full btn-circle" type="submit"> <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg></button>
</form>
</div>
<p class="py-4" id="error_modal_message">${this.options.errorMessage}</p>
</div>
</dialog>`);
}
// Cache modal elements for later use
this.deleteModal = document.getElementById('delete_modal');
this.successModal = document.getElementById('success_modal');
this.errorModal = document.getElementById('error_modal');
this.confirmBtn = document.getElementById('confirmDeleteBtn');
this.cancelBtn = document.getElementById('cancelDeleteBtn');
}
_bindEvents() {
// For keeping track of which button was clicked
this.currentBtn = null;
// Open the confirmation modal on delete button click
this.buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
this.currentBtn = btn;
document.getElementById('delete_modal_title').textContent = this.options.confirmText;
document.getElementById('delete_modal_message').textContent = this.options.message;
this.confirmBtn.disabled = false;
this.cancelBtn.disabled = false;
this.deleteModal.showModal();
});
});
// Cancel delete
this.cancelBtn.addEventListener('click', () => {
this.deleteModal.close();
this.currentBtn = null;
});
// Confirm delete
this.confirmBtn.addEventListener('click', async () => {
if (!this.currentBtn) return;
const url = this.currentBtn.dataset.href;
// Disable both buttons to prevent multiple clicks
this.confirmBtn.disabled = true;
this.cancelBtn.disabled = true;
try {
let resp;
if (window.axios) {
resp = await axios.delete(url, {
headers: {'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'}
});
if (resp.status === 200 || resp.status === 204) {
this._showSuccess();
this._afterDelete(true, resp.data);
} else throw resp;
} else {
const res = await fetch(url, { method: 'DELETE', headers: {'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'} });
if (res.ok) {
this._showSuccess();
let data = await res.json().catch(() => ({}));
this._afterDelete(true, data);
} else {
throw res;
}
}
} catch (err) {
this._showError(err);
this._afterDelete(false, err);
} finally {
this.deleteModal.close();
this.currentBtn = null;
// Always re-enable buttons when modal re-opens
setTimeout(() => {
this.confirmBtn.disabled = false;
this.cancelBtn.disabled = false;
}, 300);
}
});
}
// Shows the success modal
_showSuccess() {
document.getElementById('success_modal_title').textContent = this.options.successText;
document.getElementById('success_modal_message').textContent = this.options.successMessage;
this.successModal.showModal();
// Only auto-close if timeout is set and > 0
if (this.options.successTimeout && this.options.successTimeout > 0) {
setTimeout(() => {
this.successModal.close();
}, this.options.successTimeout);
}
}
_showError(err) {
document.getElementById('error_modal_title').textContent = this.options.errorText;
let msg = this.options.errorMessage;
if (err && err.response && err.response.data && err.response.data.message)
msg = err.response.data.message;
document.getElementById('error_modal_message').textContent = msg;
this.errorModal.showModal();
// Only auto-close if timeout is set and > 0
if (this.options.errorTimeout && this.options.errorTimeout > 0) {
setTimeout(() => {
this.errorModal.close();
}, this.options.errorTimeout);
}
}
/**
* Hook that fires after delete request completes
* @param {boolean} success Was the delete successful?
* @param {object} data Response data or error
* @param {HTMLElement} button The button that triggered the delete
*/
_afterDelete(success, data) {
if (typeof this.options.onDelete === 'function') {
this.options.onDelete(success, data, this.currentBtn);
}
}
}
/**
* EditContent.js
*
* Easily manage "edit" form workflows for CRUD apps.
* - Fills form via AJAX (fetch, axios supported)
* - Switches form to edit mode, changes button/title, reveals cancel button
* - Handles cancel back to add mode
* - Emits events/hooks for integration
*
* Example usage:
* import EditContent from './EditContent';
* const edit = new EditContent({
* form: '#lineForm',
* editButtonSelector: '.btn-edit-line',
* cancelButtonSelector: '.edit-cancel',
* submitButtonSelector: '#lineButton',
* addTitle: 'Add New Line',
* editTitle: 'Edit Line',
* addButtonText: 'Save',
* editButtonText: 'Edit',
* onEditStart: (data) => {},
* onEditEnd: () => {},
* });
*/
class EditContent {
/**
* @param {Object} opts
* @param {string|HTMLElement} opts.form - Form selector or element
* @param {string} opts.editButtonSelector - Selector for edit buttons
* @param {string} opts.cancelButtonSelector - Selector for cancel button
* @param {string} [opts.submitButtonSelector] - Selector for submit button (default: first [type=submit] in form)
* @param {string} [opts.addTitle] - Default title in add mode
* @param {string} [opts.editTitle] - Title in edit mode
* @param {string} [opts.addButtonText] - Default button text in add mode
* @param {string} [opts.editButtonText] - Button text in edit mode
* @param {Function} [opts.onEditStart] - Hook after edit mode, receives loaded data
* @param {Function} [opts.onEditEnd] - Hook after returning to add mode
*/
constructor(opts = {}) {
// Config/DOM
this.form = typeof opts.form === 'string' ? document.querySelector(opts.form) : opts.form;
this.editButtonSelector = opts.editButtonSelector;
this.cancelButton = document.querySelector(opts.cancelButtonSelector);
this.submitButton = opts.submitButtonSelector
? document.querySelector(opts.submitButtonSelector)
: this.form.querySelector('[type="submit"]');
this.buttonText = this.submitButton?.querySelector('.button-text') || this.submitButton;
this.header = this.form.closest('.pcard')?.querySelector('.pcard-title');
this.addTitle = opts.addTitle || this.header?.dataset.addTitle || this.header?.textContent || 'Add';
this.editTitle = opts.editTitle || this.header?.dataset.editTitle || 'Edit';
this.addButtonText = opts.addButtonText || this.buttonText?.dataset.addTitle || this.buttonText?.textContent || 'Save';
this.editButtonText = opts.editButtonText || this.buttonText?.dataset.editTitle || 'Edit';
this.onEditStart = opts.onEditStart;
this.onEditEnd = opts.onEditEnd;
this.editMode = false;
this._bindEditButtons();
this._bindCancelButton();
}
/** Listen to all edit buttons for click */
_bindEditButtons() {
document.body.addEventListener('click', async (e) => {
const btn = e.target.closest(this.editButtonSelector);
if (!btn) return;
e.preventDefault();
const url = btn.dataset.href;
if (!url) return;
try {
let res, data;
if (window.axios) {
res = await axios.get(url, { headers: { 'Accept': 'application/json' } });
data = res.data.data || res.data;
} else {
res = await fetch(url, { headers: { 'Accept': 'application/json' } });
data = (await res.json()).data || {};
}
this._fillForm(data, {
action: url,
method: 'POST',
title: this.editTitle,
buttonText: this.editButtonText,
});
if (this.cancelButton) this.cancelButton.classList.remove('hidden');
this.editMode = true;
if (typeof this.onEditStart === 'function') this.onEditStart(data);
} catch (err) {
console.error(err);
window.Toast
? new Toast({ message: "Cannot fetch item.", type: "error" })
: alert('Fetch error');
}
});
}
/** Cancel edit, reset form to add mode */
_bindCancelButton() {
if (!this.cancelButton) return;
this.cancelButton.addEventListener('click', () => this.resetToAddMode());
}
/**
* Fill the form with data, and update action/title/button text
*/
_fillForm(data = {}, config = {}) {
['input', 'textarea', 'select'].forEach(tag => {
this.form.querySelectorAll(tag).forEach(input => {
if (!input.name) return;
if (input.type === "file") return;
// Match title[en], description[tr] etc.
let match = input.name.match(/^(\w+)\[(\w+)\]$/);
if (match) {
let [_, parent, locale] = match;
// e.g. parent='title', locale='en' -> look for data.titles?.en
let pluralKey = parent + 's'; // title -> titles
if (data[pluralKey] && data[pluralKey][locale] !== undefined) {
input.value = data[pluralKey][locale];
}
// Fallback to e.g. data.title?.en
else if (data[parent] && typeof data[parent] === 'object' && data[parent][locale] !== undefined) {
input.value = data[parent][locale];
}
} else if (data[input.name] !== undefined) {
console.log(input.name, data[input.name]);
input.value = data[input.name];
}
});
});
if (config.action) this.form.action = config.action;
if (config.method) this.form.setAttribute('data-method', config.method);
if (this.header && config.title) this.header.textContent = config.title;
if (this.buttonText && config.buttonText) this.buttonText.textContent = config.buttonText;
this.form.setAttribute('data-edit-mode', this.editMode ? 'true' : 'false');
}
/** Public: reset form and UI back to add mode */
resetToAddMode(defaults = {}) {
this.form.reset();
this._fillForm(defaults, {
action: this.form.dataset.action,
method: 'POST',
title: this.addTitle,
buttonText: this.addButtonText,
});
if (this.cancelButton) this.cancelButton.classList.add('hidden');
this.editMode = false;
if (typeof this.onEditEnd === 'function') this.onEditEnd();
}
}
class FormSubmit {
constructor(config = {}) {
this.config = Object.assign({
method: 'axios',
httpMethod: 'POST',
formSelector: null,
submitButtonSelector: null,
action: null,
successMessage: 'Operation completed successfully!',
errorMessage: 'An error occurred.',
useToast: typeof Toast !== 'undefined',
disableOnSuccess: false,
redirectUrl: null,
beforeSubmit: null,
afterSubmit: null,
getUrl: null,
buttonOnly: false,
}, config);
this.handlers = { success: [], error: [], beforeSubmit: [], afterSubmit: [] };
this.form = this._getFormElement(this.config.formSelector);
this.submitButton = this._getSubmitButton();
this.isButtonOnly = !this.form || this.config.buttonOnly;
if (!this.isButtonOnly && !this.form) throw new Error('Form element not found.');
if (!this.submitButton) throw new Error('Submit button not found.');
this.originalButtonText = this.submitButton.querySelector('.button-text')?.textContent || this.submitButton.textContent;
this._setupEventListeners();
}
on(event, fn) {
if (this.handlers[event]) this.handlers[event].push(fn);
return this;
}
_emit(event, payload) {
(this.handlers[event] || []).forEach(fn => fn(payload));
}
_getFormElement(sel) {
if (!sel) return null;
if (typeof sel === 'string') return document.querySelector(sel);
if (sel instanceof HTMLFormElement) return sel;
return null;
}
_getSubmitButton() {
const sel = this.config.submitButtonSelector;
if (!sel && this.form) return this.form.querySelector('[type="submit"]');
if (sel instanceof HTMLElement) return sel;
if (typeof sel === 'string') return document.querySelector(sel);
return null;
}
_setupEventListeners() {
this.submitButton.addEventListener('click', e => {
e.preventDefault();
this._clearErrors();
if (this.isButtonOnly) this._handleSubmit();
else this.form.dispatchEvent(new Event('submit', { cancelable: true }));
});
if (this.form && !this.isButtonOnly) {
this.form.addEventListener('submit', e => {
e.preventDefault();
this._handleSubmit();
});
}
}
async _handleSubmit() {
let formData = new FormData(this.isButtonOnly ? undefined : this.form);
if (this.isButtonOnly && this.submitButton.dataset.data) {
try {
const buttonData = JSON.parse(this.submitButton.dataset.data);
Object.entries(buttonData).forEach(([k, v]) => formData.append(k, v));
} catch (e) {
console.warn('Invalid JSON in button data attribute:', e);
}
}
const method = this.config.httpMethod.toUpperCase();
if (method !== 'POST' && !formData.has('_method')) formData.append('_method', method);
const action = typeof this.config.getUrl === 'function'
? this.config.getUrl(this.submitButton)
: (this.config.action || this.form?.action || window.location.href);
try {
if (typeof this.config.beforeSubmit === 'function') {
const result = await this.config.beforeSubmit(formData, this.form, this.submitButton);
if (result instanceof FormData) formData = result;
else if (result === false) return;
}
this._emit('beforeSubmit', { formData, form: this.form, button: this.submitButton });
} catch (error) {
console.error('Error in beforeSubmit hook:', error);
return;
}
this._setLoading(true);
let response, success = false;
try {
if (this.config.method === 'axios' && window.axios) {
const resp = await axios({ url: action, method: method.toLowerCase(), data: formData });
response = resp.data ?? resp;
} else if (this.config.method === 'xhr') {
response = await this._sendWithXHR(action, formData, method);
} else {
response = await this._sendWithFetch(action, formData, method);
}
success = true;
await this._handleSuccess(response);
if (!this.config.disableOnSuccess) this._setLoading(false);
} catch (error) {
response = error;
await this._handleError(error);
this._setLoading(false);
}
try {
if (typeof this.config.afterSubmit === 'function') {
await this.config.afterSubmit(response, success, this.form, this.submitButton);
}
this._emit('afterSubmit', { response, success, form: this.form, button: this.submitButton });
} catch (error) {
console.error('Error in afterSubmit hook:', error);
}
}
async _sendWithFetch(url, fd, method) {
const res = await fetch(url, { method, body: fd, headers: { 'Accept': 'application/json' } });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw { response: { data, status: res.status } };
return data;
}
_sendWithXHR(url, fd, method) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader('Accept', 'application/json');
xhr.onload = () => {
try {
const json = JSON.parse(xhr.responseText);
(xhr.status >= 200 && xhr.status < 300)
? resolve(json)
: reject({ response: { data: json, status: xhr.status } });
} catch (e) {
reject({ message: 'Invalid JSON', error: e });
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(fd);
});
}
_setLoading(loading) {
if (!this.submitButton) return;
const btnText = this.submitButton.querySelector('.button-text');
this.submitButton.disabled = loading;
const loadingText = this.submitButton.dataset.loadingText || 'Loading…';
if (loading) {
if (btnText) btnText.textContent = loadingText;
else this.submitButton.textContent = loadingText;
} else {
const defaultText = btnText?.dataset.default || this.originalButtonText;
if (btnText) btnText.textContent = defaultText;
else this.submitButton.textContent = this.originalButtonText;
}
}
async _handleSuccess(res) {
const msg = res?.message || res?.data?.message || this.config.successMessage;
const redirect = res?.redirect || res?.data?.redirect || this.config.redirectUrl;
this._showMessage(msg, 'success');
this._emit('success', res);
if (redirect) setTimeout(() => window.location.href = redirect, 1000);
}
async _handleError(err) {
console.error(err);
const d = err.response?.data || err;
const msg = d?.message || this.config.errorMessage;
if (d?.errors && this.form) {
Object.entries(d.errors).forEach(([name, messages]) => {
const inputName = name.includes('.') ? name.replace('.', '[') + ']' : name;
const el = this.form.querySelector(`.form-error[data-input="${inputName}"]`);
if (el) {
el.textContent = Array.isArray(messages) ? messages.join(' ') : messages;
el.classList.remove('hidden');
}
});
}
this._showMessage(msg, 'error');
this._emit('error', err);
}
_clearErrors() {
if (!this.form) return;
this.form.querySelectorAll('.form-error[data-input]').forEach(span => {
span.classList.add('hidden');
span.textContent = '';
});
}
_showMessage(msg, type) {
if (this.config.useToast && window.Toast) {
new Toast({ message: msg, type, duration: 4000, position: 'top-center-full' });
} else {
alert(msg);
}
}
/** PUBLIC API **/
reset() {
if (!this.form) {
console.warn('FormSubmit: Cannot reset without form.');
return;
}
this.form.reset();
this._clearErrors();
this._setLoading(false);
}
submit() {
this._handleSubmit();
}
getFormData() {
if (!this.form) {
console.warn('FormSubmit: No form available.');
return new FormData();
}
return new FormData(this.form);
}
setFieldValue(name, value) {
const el = this.form?.querySelector(`[name="${name}"]`);
if (el) el.value = value;
}
getFieldValue(name) {
const el = this.form?.querySelector(`[name="${name}"]`);
return el ? el.value : null;
}
}
// ImageInput.js
// Usage: new ImageInput('#myFileInput') OR new ImageInput(document.querySelector('input[type="file"]'));
/**
* ImageInput - auto updates preview image on file select, restores on ESC/cancel.
* @param {string|HTMLElement} selector - file input selector or element
*/
class ImageInput {
/**
* @param {string|HTMLElement|NodeList} input - Selector, Element, or NodeList of inputs
*/
constructor(input) {
// Allow single or multiple
if (typeof input === "string") {
this.inputs = document.querySelectorAll(input);
} else if (input instanceof HTMLElement) {
this.inputs = [input];
} else if (input instanceof NodeList) {
this.inputs = Array.from(input);
} else {
throw new Error('ImageInput: Invalid input selector/element.');
}
this.inputs.forEach(fileInput => this.init(fileInput));
}
init(fileInput) {
const imgSel = fileInput.closest('.form-group')?.querySelector('[data-img-preview]') ||
(fileInput.dataset.imgPreview && document.querySelector(fileInput.dataset.imgPreview));
if (!imgSel) return;
const img = imgSel;
const originalSrc = img.getAttribute('data-src') || img.src;
// File change: show preview
fileInput.addEventListener('change', (e) => {
if (fileInput.files && fil