From 06d07ac64a9360f6a2ef1808a5a8c96cdbbf49c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:52:25 +0000 Subject: [PATCH 1/2] Initial plan From 9867486fe164b57d9812a82a6900c0c0180688bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 18:07:21 +0000 Subject: [PATCH 2/2] Implement capability consistency fixes and AJAX CRUD for rules Co-authored-by: HusnainRKI <226229722+HusnainRKI@users.noreply.github.com> --- assets/css/admin-rules.css | 479 +++++++++++ assets/js/admin-dashboard.js | 151 ++-- assets/js/admin-rules.js | 805 ++++++++++++------ includes/class-capabilities.php | 40 +- ...tally-element-event-tracker-admin-menu.php | 221 ++++- includes/class-rest.php | 203 ++++- includes/class-rules.php | 13 + 7 files changed, 1581 insertions(+), 331 deletions(-) create mode 100644 assets/css/admin-rules.css diff --git a/assets/css/admin-rules.css b/assets/css/admin-rules.css new file mode 100644 index 0000000..1b61676 --- /dev/null +++ b/assets/css/admin-rules.css @@ -0,0 +1,479 @@ +/* ClickTally Rules Page Styles */ + +/* Rules Page Layout */ +.clicktally-rules-page { + max-width: 1200px; +} + +.clicktally-rules-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 20px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; +} + +.clicktally-rules-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +/* Add/Edit Form Panel */ +.clicktally-form-panel { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + margin-bottom: 20px; + position: sticky; + top: 32px; /* Account for WordPress admin bar */ + z-index: 10; +} + +.clicktally-form-panel.collapsed { + display: none; +} + +.clicktally-form-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background: #f6f7f7; + border-bottom: 1px solid #c3c4c7; + border-radius: 4px 4px 0 0; +} + +.clicktally-form-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #1d2327; +} + +.clicktally-form-toggle { + background: none; + border: none; + color: #646970; + cursor: pointer; + font-size: 18px; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.clicktally-form-toggle:hover { + color: #1d2327; +} + +.clicktally-form-body { + padding: 20px; +} + +/* Form Styling */ +.clicktally-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 20px; +} + +.clicktally-form-row { + margin-bottom: 15px; +} + +.clicktally-form-row.full-width { + grid-column: 1 / -1; +} + +.clicktally-form-row label { + display: block; + margin-bottom: 6px; + font-weight: 600; + color: #1d2327; + font-size: 13px; +} + +.clicktally-form-row .description { + margin-top: 4px; + color: #646970; + font-size: 12px; + line-height: 1.4; +} + +.clicktally-form-row input[type="text"], +.clicktally-form-row input[type="number"], +.clicktally-form-row select, +.clicktally-form-row textarea { + width: 100%; + border: 1px solid #8c8f94; + border-radius: 4px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; +} + +.clicktally-form-row input[type="text"]:focus, +.clicktally-form-row input[type="number"]:focus, +.clicktally-form-row select:focus, +.clicktally-form-row textarea:focus { + border-color: #2271b1; + box-shadow: 0 0 0 1px #2271b1; + outline: none; +} + +.clicktally-input-group { + display: flex; + gap: 8px; + align-items: center; +} + +.clicktally-input-group input { + flex: 1; +} + +.clicktally-input-group .button { + flex-shrink: 0; +} + +/* Button Styling */ +.button.button-primary.clicktally-save { + background: #2271b1; + border-color: #2271b1; + color: #fff; + padding: 8px 16px; + font-weight: 600; +} + +.button.button-primary.clicktally-save:hover, +.button.button-primary.clicktally-save:focus { + background: #135e96; + border-color: #135e96; +} + +.button.button-secondary.clicktally-cancel { + background: #f6f7f7; + border-color: #c3c4c7; + color: #3c434a; + padding: 8px 16px; +} + +.button.button-secondary.clicktally-cancel:hover, +.button.button-secondary.clicktally-cancel:focus { + background: #f0f0f1; + border-color: #8c8f94; +} + +.button.button-link-delete { + color: #d63638; + text-decoration: none; + padding: 4px 8px; + border-radius: 3px; +} + +.button.button-link-delete:hover, +.button.button-link-delete:focus { + background: #fcf0f1; + color: #b32d2e; +} + +/* Form Actions */ +.clicktally-form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 15px; + border-top: 1px solid #f0f0f1; +} + +/* Rules Table */ +.clicktally-rules-list { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + overflow: hidden; +} + +.clicktally-rules-table { + width: 100%; + border-collapse: collapse; + margin: 0; +} + +.clicktally-rules-table thead th { + background: #f6f7f7; + border-bottom: 1px solid #c3c4c7; + padding: 12px 15px; + text-align: left; + font-weight: 600; + font-size: 13px; + color: #50575e; +} + +.clicktally-rules-table tbody td { + border-bottom: 1px solid #f0f0f1; + padding: 12px 15px; + font-size: 13px; + vertical-align: top; +} + +.clicktally-rules-table tbody tr:hover { + background: #f6f7f7; +} + +.clicktally-rules-table tbody tr:last-child td { + border-bottom: none; +} + +/* Table Cell Styling */ +.clicktally-rules-table .event-name { + font-weight: 600; + color: #1d2327; +} + +.clicktally-rules-table .selector-display { + font-family: Consolas, Monaco, "Courier New", monospace; + background: #f6f7f7; + padding: 3px 6px; + border-radius: 3px; + font-size: 12px; + color: #3c434a; + border: 1px solid #dcdcde; +} + +.clicktally-rules-table .event-type { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.clicktally-rules-table .event-type.click { + background: #e8f3fd; + color: #0073aa; +} + +.clicktally-rules-table .event-type.submit { + background: #f0f6fc; + color: #2271b1; +} + +.clicktally-rules-table .event-type.change { + background: #fcf9e8; + color: #9a6700; +} + +.clicktally-rules-table .event-type.view { + background: #f0f6fc; + color: #646970; +} + +/* Status Badges */ +.clicktally-status { + display: inline-block; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.clicktally-status.active { + background: #d7eddb; + color: #00a32a; +} + +.clicktally-status.inactive { + background: #fcf0f1; + color: #d63638; +} + +.clicktally-status.paused { + background: #fcf9e8; + color: #9a6700; +} + +/* Action Buttons in Table */ +.clicktally-table-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.clicktally-table-actions .button { + padding: 4px 8px; + font-size: 12px; + line-height: 1.2; + min-height: auto; +} + +/* Auto Rule Indicator */ +.clicktally-auto-rule-indicator { + color: #646970; + font-size: 11px; + margin-left: 6px; +} + +.clicktally-auto-rule-indicator .dashicons { + font-size: 14px; + vertical-align: middle; +} + +/* Empty State */ +.clicktally-empty-state { + text-align: center; + padding: 60px 20px; + color: #646970; +} + +.clicktally-empty-state .dashicons { + font-size: 64px; + opacity: 0.3; + margin-bottom: 15px; +} + +.clicktally-empty-state h3 { + margin: 0 0 8px 0; + font-size: 18px; + font-weight: 600; + color: #50575e; +} + +.clicktally-empty-state p { + margin: 0 0 20px 0; + font-size: 14px; + line-height: 1.5; +} + +/* Success/Error Messages */ +.clicktally-message { + padding: 12px 16px; + border-radius: 4px; + margin-bottom: 20px; + font-weight: 500; +} + +.clicktally-message.success { + background: #d7eddb; + color: #00a32a; + border-left: 4px solid #00a32a; +} + +.clicktally-message.error { + background: #fcf0f1; + color: #d63638; + border-left: 4px solid #d63638; +} + +.clicktally-message.warning { + background: #fcf9e8; + color: #9a6700; + border-left: 4px solid #dba617; +} + +/* Loading States */ +.clicktally-loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; +} + +.clicktally-spinner { + width: 24px; + height: 24px; + border: 2px solid #f3f3f3; + border-top: 2px solid #2271b1; + border-radius: 50%; + animation: clicktally-spin 1s linear infinite; +} + +@keyframes clicktally-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.clicktally-button-loading { + opacity: 0.6; + pointer-events: none; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .clicktally-form-grid { + grid-template-columns: 1fr; + } + + .clicktally-rules-header { + flex-direction: column; + align-items: stretch; + gap: 15px; + } + + .clicktally-form-actions { + flex-direction: column; + } + + .clicktally-table-actions { + flex-direction: column; + gap: 4px; + } + + .clicktally-rules-table { + font-size: 12px; + } + + .clicktally-rules-table th, + .clicktally-rules-table td { + padding: 8px 10px; + } +} + +/* Dark Mode Support (if theme supports it) */ +@media (prefers-color-scheme: dark) { + .clicktally-form-panel, + .clicktally-rules-list { + background: #1d2327; + border-color: #3c434a; + } + + .clicktally-form-header { + background: #2c3338; + border-color: #3c434a; + } + + .clicktally-form-header h3 { + color: #f0f0f1; + } + + .clicktally-rules-table thead th { + background: #2c3338; + color: #f0f0f1; + border-color: #3c434a; + } + + .clicktally-rules-table tbody td { + color: #f0f0f1; + border-color: #3c434a; + } + + .clicktally-rules-table tbody tr:hover { + background: #2c3338; + } +} \ No newline at end of file diff --git a/assets/js/admin-dashboard.js b/assets/js/admin-dashboard.js index f19afab..f1842e3 100644 --- a/assets/js/admin-dashboard.js +++ b/assets/js/admin-dashboard.js @@ -568,82 +568,67 @@ * Export data as CSV */ function clicktally_element_event_tracker_export_csv(type) { - let data, filename, headers; - - if (type === 'top-elements' && dashboardData.topElements) { - data = dashboardData.topElements; - filename = 'clicktally-top-elements.csv'; - headers = ['Event Name', 'Selector Key', 'Example Page', 'Clicks', 'Percentage']; - } else if (type === 'top-pages' && dashboardData.topPages) { - data = dashboardData.topPages; - filename = 'clicktally-top-pages.csv'; - headers = ['Page', 'Title', 'Clicks', 'Top Event']; + const filters = clicktally_element_event_tracker_get_current_filters(); + const params = new URLSearchParams(filters); + + let url; + if (type === 'top-elements') { + url = ClickTallyElementEventTrackerAdminConfig.restUrlBackcompat + 'export/top-elements?' + params.toString(); + } else if (type === 'top-pages') { + url = ClickTallyElementEventTrackerAdminConfig.restUrlBackcompat + 'export/top-pages?' + params.toString(); } else { - console.error('No data available for export'); + console.error('Unknown export type:', type); return; } - if (!data || data.length === 0) { - alert(ClickTallyElementEventTrackerAdminConfig.i18n.noData); - return; - } + // Create a temporary link to trigger download + const link = document.createElement('a'); + link.href = url; + link.style.display = 'none'; - const csv = clicktally_element_event_tracker_convert_to_csv(data, headers, type); - clicktally_element_event_tracker_download_csv(csv, filename); - } - - /** - * Convert data to CSV format - */ - function clicktally_element_event_tracker_convert_to_csv(data, headers, type) { - let csv = headers.join(',') + '\n'; + // Add nonce header for authentication + link.setAttribute('data-nonce', ClickTallyElementEventTrackerAdminConfig.nonce); - data.forEach(function(item) { - let row = []; - - if (type === 'top-elements') { - row = [ - '"' + (item.event_name || '').replace(/"/g, '""') + '"', - '"' + (item.selector_key || item.selector_preview || '').replace(/"/g, '""') + '"', - '"' + (item.example_page || '').replace(/"/g, '""') + '"', - item.total_clicks || item.clicks || 0, - (item.page_share ? (item.page_share * 100).toFixed(1) : (item.percentage || 0).toFixed(1)) + '%' - ]; - } else if (type === 'top-pages') { - row = [ - '"' + (item.page || item.page_url || '').replace(/"/g, '""') + '"', - '"' + (item.title || '').replace(/"/g, '""') + '"', - item.total_clicks || item.clicks || 0, - '"' + (item.top_event || '').replace(/"/g, '""') + '"' - ]; + // For REST API requests, we need to use fetch to include the nonce header + fetch(url, { + method: 'GET', + headers: { + 'X-WP-Nonce': ClickTallyElementEventTrackerAdminConfig.nonce + } + }) + .then(function(response) { + if (!response.ok) { + throw new Error('Export failed: ' + response.statusText); } + return response.blob(); + }) + .then(function(blob) { + // Create download URL and trigger download + const downloadUrl = URL.createObjectURL(blob); + link.href = downloadUrl; + + // Get filename from content-disposition header or use default + const filename = 'clicktally-' + type + '-' + new Date().toISOString().split('T')[0] + '.csv'; + link.download = filename; - csv += row.join(',') + '\n'; - }); - - return csv; - } - - /** - * Download CSV file - */ - function clicktally_element_event_tracker_download_csv(csv, filename) { - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - - if (link.download !== undefined) { - const url = URL.createObjectURL(blob); - link.setAttribute('href', url); - link.setAttribute('download', filename); - link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); - URL.revokeObjectURL(url); - } + URL.revokeObjectURL(downloadUrl); + + // Show success message + clicktally_element_event_tracker_show_export_success(); + }) + .catch(function(error) { + console.error('Export error:', error); + clicktally_element_event_tracker_show_export_error(); + }); } + /** + * Utility functions + */ * Show error in KPI cards */ function clicktally_element_event_tracker_show_error_in_kpis() { @@ -719,8 +704,48 @@ } /** - * Utility functions + * Show export success message */ + function clicktally_element_event_tracker_show_export_success() { + // Show a temporary success message + const message = document.createElement('div'); + message.className = 'notice notice-success is-dismissible'; + message.innerHTML = '
' + ClickTallyElementEventTrackerAdminConfig.i18n.exportSuccess + '
'; + + const header = document.querySelector('.wrap h1'); + if (header && header.parentNode) { + header.parentNode.insertBefore(message, header.nextSibling); + + // Auto-remove after 3 seconds + setTimeout(function() { + if (message.parentNode) { + message.parentNode.removeChild(message); + } + }, 3000); + } + } + + /** + * Show export error message + */ + function clicktally_element_event_tracker_show_export_error() { + // Show a temporary error message + const message = document.createElement('div'); + message.className = 'notice notice-error is-dismissible'; + message.innerHTML = '' + ClickTallyElementEventTrackerAdminConfig.i18n.exportError + '
'; + + const header = document.querySelector('.wrap h1'); + if (header && header.parentNode) { + header.parentNode.insertBefore(message, header.nextSibling); + + // Auto-remove after 5 seconds + setTimeout(function() { + if (message.parentNode) { + message.parentNode.removeChild(message); + } + }, 5000); + } + } function clicktally_element_event_tracker_format_number(num) { return new Intl.NumberFormat().format(num); } diff --git a/assets/js/admin-rules.js b/assets/js/admin-rules.js index 597b5ff..c5d48c2 100644 --- a/assets/js/admin-rules.js +++ b/assets/js/admin-rules.js @@ -1,176 +1,468 @@ /** - * ClickTally Admin Event Rules JavaScript - * For managing tracking events with event delegation and proper error handling + * ClickTally Rules Admin JavaScript + * Modern AJAX-based CRUD implementation for event tracking rules */ -document.addEventListener('DOMContentLoaded', function() { +// Global namespace for rules admin functionality +window.ClickTallyRulesAdmin = (function() { 'use strict'; - // Use event delegation for better handling of dynamic content - setupEventDelegation(); + // Private variables + let isFormVisible = true; + let currentEditingRuleId = null; + let isSubmitting = false; - // Set up event form handling - const eventForm = document.getElementById('event-form'); - if (eventForm) { - eventForm.addEventListener('submit', handleEventFormSubmit); + // DOM elements (cached for performance) + const elements = {}; + + /** + * Initialize the rules admin interface + */ + function init() { + cacheElements(); + bindEvents(); + updateFormVisibility(); + updateSelectorHelp(); } -}); - -function setupEventDelegation() { - // Use event delegation on document body to handle dynamically added buttons - document.body.addEventListener('click', function(e) { - const target = e.target; - - // Prevent default navigation for buttons - if (target.tagName === 'BUTTON' || target.getAttribute('role') === 'button') { - e.preventDefault(); - } - - // Handle different button actions - if (target.getAttribute('data-action') === 'add-event') { - e.preventDefault(); - openEventModal(); - } else if (target.getAttribute('data-action') === 'edit') { - e.preventDefault(); - const eventId = target.getAttribute('data-rule-id'); - if (eventId) { - editEvent(eventId); - } - } else if (target.getAttribute('data-action') === 'delete') { - e.preventDefault(); - const eventId = target.getAttribute('data-rule-id'); - if (eventId) { - deleteEvent(eventId); - } - } else if (target.getAttribute('data-action') === 'close-modal' || target.classList.contains('clicktally-modal-close')) { - e.preventDefault(); - closeEventModal(); - } else if (target.getAttribute('data-action') === 'dom-picker') { - e.preventDefault(); - openDOMPicker(); - } - }); -} - -function openEventModal(eventId) { - const modal = document.getElementById('event-modal'); - if (modal) { - modal.style.display = 'block'; - if (eventId) { - // Load event data for editing - loadEventData(eventId); + + /** + * Cache DOM elements for better performance + */ + function cacheElements() { + elements.formPanel = document.getElementById('clicktally-form-panel'); + elements.formTitle = document.getElementById('clicktally-form-title'); + elements.formToggle = document.getElementById('clicktally-form-toggle'); + elements.formBody = document.getElementById('clicktally-form-body'); + elements.eventForm = document.getElementById('clicktally-event-form'); + elements.rulesList = document.getElementById('clicktally-rules-list'); + elements.messagesContainer = document.getElementById('clicktally-messages-container'); + elements.addNewBtn = document.getElementById('add-new-event-btn'); + elements.cancelBtn = document.getElementById('cancel-event-btn'); + elements.saveBtn = document.getElementById('save-event-btn'); + elements.selectorType = document.getElementById('selector-type'); + elements.selectorValue = document.getElementById('selector-value'); + elements.selectorPickerBtn = document.getElementById('selector-picker-btn'); + elements.selectorHelpText = document.getElementById('selector-help-text'); + elements.eventName = document.getElementById('event-name'); + elements.eventId = document.getElementById('event-id'); + } + + /** + * Bind event handlers + */ + function bindEvents() { + // Form toggle + if (elements.formToggle) { + elements.formToggle.addEventListener('click', toggleForm); + } + + // Add new event button + if (elements.addNewBtn) { + elements.addNewBtn.addEventListener('click', showAddForm); + } + + // Cancel button + if (elements.cancelBtn) { + elements.cancelBtn.addEventListener('click', cancelEdit); + } + + // Form submission + if (elements.eventForm) { + elements.eventForm.addEventListener('submit', handleFormSubmit); + } + + // Selector type change + if (elements.selectorType) { + elements.selectorType.addEventListener('change', updateSelectorHelp); + } + + // Selector picker + if (elements.selectorPickerBtn) { + elements.selectorPickerBtn.addEventListener('click', openSelectorPicker); + } + + // Event delegation for table actions + if (elements.rulesList) { + elements.rulesList.addEventListener('click', handleTableAction); + } + + // Auto-generate event name from selector + if (elements.selectorValue && elements.eventName) { + elements.selectorValue.addEventListener('input', autoGenerateEventName); + } + } + + /** + * Toggle form visibility + */ + function toggleForm() { + isFormVisible = !isFormVisible; + updateFormVisibility(); + } + + /** + * Update form visibility state + */ + function updateFormVisibility() { + if (!elements.formPanel || !elements.formToggle) return; + + const icon = elements.formToggle.querySelector('.dashicons'); + + if (isFormVisible) { + elements.formBody.style.display = 'block'; + elements.formPanel.classList.remove('collapsed'); + icon.className = 'dashicons dashicons-minus'; + elements.formToggle.title = 'Hide Form'; } else { - // Reset form for new event - const form = document.getElementById('event-form'); - if (form) { - form.reset(); - } - const eventIdField = document.getElementById('event-id'); - if (eventIdField) { - eventIdField.value = ''; - } + elements.formBody.style.display = 'none'; + elements.formPanel.classList.add('collapsed'); + icon.className = 'dashicons dashicons-plus-alt2'; + elements.formToggle.title = 'Show Form'; } } -} - -function closeEventModal() { - const modal = document.getElementById('event-modal'); - if (modal) { - modal.style.display = 'none'; + + /** + * Show form for adding new event + */ + function showAddForm() { + resetForm(); + currentEditingRuleId = null; + elements.formTitle.textContent = 'Add New Tracking Event'; + elements.saveBtn.textContent = 'Save Event'; + + if (!isFormVisible) { + isFormVisible = true; + updateFormVisibility(); + } + + // Focus on event name field + if (elements.eventName) { + elements.eventName.focus(); + } } -} - -function openDOMPicker() { - // Basic DOM picker implementation - // In a real implementation, this would open a modal with an iframe - const selectorValueField = document.getElementById('selector-value'); - if (!selectorValueField) return; + /** + * Cancel editing and hide form + */ + function cancelEdit() { + resetForm(); + currentEditingRuleId = null; + + if (hasExistingRules()) { + isFormVisible = false; + updateFormVisibility(); + } + } - // For now, provide a simple prompt-based approach - const selector = prompt('Enter a CSS selector or element ID/class:\n\nExamples:\n- #my-button (for ID)\n- .my-class (for class)\n- button.primary (for CSS selector)\n- //button[@id="submit"] (for XPath)'); + /** + * Check if there are existing rules + */ + function hasExistingRules() { + const table = elements.rulesList.querySelector('.clicktally-rules-table'); + return table && table.querySelector('tbody tr'); + } - if (selector && selector.trim()) { - selectorValueField.value = selector.trim(); - - // Try to determine selector type automatically - const selectorTypeField = document.getElementById('selector-type'); - if (selectorTypeField) { - if (selector.startsWith('#')) { - selectorTypeField.value = 'id'; - selectorValueField.value = selector.substring(1); // Remove the # - } else if (selector.startsWith('.') && !selector.includes(' ')) { - selectorTypeField.value = 'class'; - selectorValueField.value = selector.substring(1); // Remove the . - } else if (selector.startsWith('//')) { - selectorTypeField.value = 'xpath'; - } else if (selector.includes('[data-')) { - selectorTypeField.value = 'data'; + /** + * Reset form to initial state + */ + function resetForm() { + if (elements.eventForm) { + elements.eventForm.reset(); + } + if (elements.eventId) { + elements.eventId.value = ''; + } + updateSelectorHelp(); + clearMessages(); + } + + /** + * Handle form submission + */ + function handleFormSubmit(e) { + e.preventDefault(); + + if (isSubmitting) { + return; + } + + if (!validateForm()) { + return; + } + + const formData = new FormData(elements.eventForm); + const eventData = { + event_name: formData.get('event_name'), + selector_type: formData.get('selector_type'), + selector_value: formData.get('selector_value'), + event_type: formData.get('event_type'), + label_template: formData.get('label_template') || '', + throttle_ms: parseInt(formData.get('throttle_ms')) || 0, + once_per_view: formData.get('once_per_view') ? true : false + }; + + const isUpdate = currentEditingRuleId !== null; + + setFormLoading(true); + + const requestData = { + action: isUpdate ? 'update' : 'create', + ...eventData + }; + + if (isUpdate) { + requestData.rule_id = currentEditingRuleId; + } + + // Make API request + fetch(clickTallyAdmin.apiUrl + 'rules/manage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': clickTallyAdmin.nonce + }, + body: JSON.stringify(requestData) + }) + .then(response => { + if (!response.ok) { + if (response.status === 403) { + throw new Error('Permission denied. Please check your user capabilities.'); + } else if (response.status === 404) { + throw new Error('REST endpoint not found. Try refreshing the page or contact support.'); + } else { + throw new Error('Request failed with status: ' + response.status); + } + } + return response.json(); + }) + .then(data => { + if (data.success) { + const message = isUpdate ? 'Event updated successfully!' : 'Event created successfully!'; + showMessage(message, 'success'); + + // Refresh the rules list + refreshRulesList(); + + // Reset form and hide it if we have rules + resetForm(); + currentEditingRuleId = null; + + if (hasExistingRules()) { + isFormVisible = false; + updateFormVisibility(); + } } else { - selectorTypeField.value = 'css'; + throw new Error(data.message || 'Unknown error occurred'); } + }) + .catch(error => { + console.error('Form submission error:', error); + showMessage(error.message || 'An error occurred while saving the event.', 'error'); + }) + .finally(() => { + setFormLoading(false); + }); + } + + /** + * Validate form data + */ + function validateForm() { + const eventName = elements.eventName.value.trim(); + const selectorType = elements.selectorType.value; + const selectorValue = elements.selectorValue.value.trim(); + + if (!eventName) { + showMessage('Event name is required.', 'error'); + elements.eventName.focus(); + return false; } - // Auto-generate event name if empty - const eventNameField = document.getElementById('event-name'); - if (eventNameField && !eventNameField.value.trim()) { - let autoName = selector; - if (selector.startsWith('#')) { - autoName = selector.substring(1) + ' Click'; - } else if (selector.startsWith('.')) { - autoName = selector.substring(1).replace(/-/g, ' ') + ' Click'; - } else { - autoName = 'Element Click'; + if (!selectorType) { + showMessage('Selector type is required.', 'error'); + elements.selectorType.focus(); + return false; + } + + if (!selectorValue) { + showMessage('Selector value is required.', 'error'); + elements.selectorValue.focus(); + return false; + } + + return true; + } + + /** + * Set form loading state + */ + function setFormLoading(loading) { + isSubmitting = loading; + + if (elements.saveBtn) { + elements.saveBtn.disabled = loading; + elements.saveBtn.textContent = loading ? 'Saving...' : + (currentEditingRuleId ? 'Update Event' : 'Save Event'); + } + + if (elements.cancelBtn) { + elements.cancelBtn.disabled = loading; + } + + // Disable form inputs + const inputs = elements.eventForm.querySelectorAll('input, select, textarea, button'); + inputs.forEach(input => { + if (input !== elements.saveBtn && input !== elements.cancelBtn) { + input.disabled = loading; } - eventNameField.value = autoName.charAt(0).toUpperCase() + autoName.slice(1); + }); + } + + /** + * Handle table action clicks (edit, delete) + */ + function handleTableAction(e) { + const action = e.target.getAttribute('data-action'); + const ruleId = e.target.getAttribute('data-rule-id'); + + if (!action || !ruleId) { + return; + } + + e.preventDefault(); + + switch (action) { + case 'edit-rule': + editRule(ruleId); + break; + case 'delete-rule': + const eventName = e.target.getAttribute('data-event-name'); + deleteRule(ruleId, eventName); + break; } } -} - -function loadEventData(eventId) { - // For now, we'll implement a simple approach that populates the form - // In a real implementation, this would fetch data via AJAX - // Show loading in modal - const modal = document.getElementById('event-modal'); - if (modal) { - const form = document.getElementById('event-form'); - if (form) { - // Set the event ID - const eventIdField = document.getElementById('event-id'); - if (eventIdField) { - eventIdField.value = eventId; + /** + * Edit a rule + */ + function editRule(ruleId) { + // Find the rule data from the table + const row = document.querySelector(`tr[data-rule-id="${ruleId}"]`); + if (!row) { + showMessage('Rule not found.', 'error'); + return; + } + + // For now, we'll need to fetch the rule data via API + // This is a simplified implementation - in production you'd fetch full rule data + showMessage('Loading rule data...', 'info'); + + fetch(clickTallyAdmin.apiUrl + 'rules/get?id=' + ruleId, { + method: 'GET', + headers: { + 'X-WP-Nonce': clickTallyAdmin.nonce } - - // TODO: Fetch actual event data from server - // For now, just log that we're loading - console.log('Loading event data for ID:', eventId); - - // In a future implementation, we would: - // 1. Make an AJAX request to get the event data - // 2. Populate the form fields with the returned data - // 3. Handle any errors appropriately + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to load rule data'); + } + return response.json(); + }) + .then(ruleData => { + loadRuleIntoForm(ruleData); + }) + .catch(error => { + console.error('Error loading rule:', error); + // Fallback: try to extract basic data from the table + loadBasicRuleDataFromTable(ruleId); + }); + } + + /** + * Load rule data into form (fallback method) + */ + function loadBasicRuleDataFromTable(ruleId) { + const row = document.querySelector(`tr[data-rule-id="${ruleId}"]`); + if (!row) return; + + const eventName = row.querySelector('.event-name strong').textContent.trim(); + const selectorText = row.querySelector('.selector-display').textContent.trim(); + const eventType = row.querySelector('.event-type').textContent.toLowerCase().trim(); + + // Parse selector text (format: "type: value") + const selectorParts = selectorText.split(':', 2); + const selectorType = selectorParts[0].trim(); + const selectorValue = selectorParts[1] ? selectorParts[1].trim() : ''; + + // Populate form + currentEditingRuleId = ruleId; + elements.eventId.value = ruleId; + elements.eventName.value = eventName; + elements.selectorType.value = selectorType; + elements.selectorValue.value = selectorValue; + document.getElementById('event-type').value = eventType; + + // Update form UI + elements.formTitle.textContent = 'Edit Tracking Event'; + elements.saveBtn.textContent = 'Update Event'; + + // Show form + if (!isFormVisible) { + isFormVisible = true; + updateFormVisibility(); } + + clearMessages(); } -} - -function editEvent(eventId) { - openEventModal(eventId); -} - -function deleteEvent(eventId) { - if (confirm(clickTallyAdmin.strings.confirmDelete)) { - // Show loading state - const deleteBtn = document.querySelector(`button[data-rule-id="${eventId}"][data-action="delete"]`); + + /** + * Load rule data into form + */ + function loadRuleIntoForm(ruleData) { + currentEditingRuleId = ruleData.id; + elements.eventId.value = ruleData.id; + elements.eventName.value = ruleData.event_name || ''; + elements.selectorType.value = ruleData.selector_type || 'id'; + elements.selectorValue.value = ruleData.selector_value || ''; + document.getElementById('event-type').value = ruleData.event_type || 'click'; + document.getElementById('label-template').value = ruleData.label_template || ''; + document.getElementById('throttle-ms').value = ruleData.throttle_ms || 0; + document.getElementById('once-per-view').checked = ruleData.once_per_view || false; + + // Update form UI + elements.formTitle.textContent = 'Edit Tracking Event'; + elements.saveBtn.textContent = 'Update Event'; + + // Show form + if (!isFormVisible) { + isFormVisible = true; + updateFormVisibility(); + } + + updateSelectorHelp(); + clearMessages(); + } + + /** + * Delete a rule + */ + function deleteRule(ruleId, eventName) { + const message = eventName ? + `Are you sure you want to delete the event "${eventName}"?` : + 'Are you sure you want to delete this event?'; + + if (!confirm(message + '\n\nThis action cannot be undone.')) { + return; + } + + const deleteBtn = document.querySelector(`button[data-rule-id="${ruleId}"][data-action="delete-rule"]`); if (deleteBtn) { deleteBtn.disabled = true; deleteBtn.textContent = 'Deleting...'; } - // Submit deletion via REST API - const url = clickTallyAdmin.apiUrl + 'rules/manage'; - - fetch(url, { + fetch(clickTallyAdmin.apiUrl + 'rules/manage', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -178,132 +470,167 @@ function deleteEvent(eventId) { }, body: JSON.stringify({ action: 'delete', - rule_id: eventId + rule_id: ruleId }) }) .then(response => { if (!response.ok) { - // Handle different error types if (response.status === 403) { - throw new Error('Permission denied. Check your capabilities.'); - } else if (response.status === 404) { - throw new Error('REST endpoint not found. Try flushing permalinks.'); + throw new Error('Permission denied. You do not have permission to delete rules.'); } else { - throw new Error('Network response was not ok: ' + response.status); + throw new Error('Delete request failed'); } } return response.json(); }) .then(data => { if (data.success) { - // Show success message - alert(clickTallyAdmin.strings.eventDeleted); + showMessage('Event deleted successfully!', 'success'); + refreshRulesList(); - // Refresh the page to show updated list - location.reload(); + // If we were editing this rule, clear the form + if (currentEditingRuleId === ruleId) { + cancelEdit(); + } } else { - throw new Error(data.message || 'Unknown error'); + throw new Error(data.message || 'Delete failed'); } }) .catch(error => { - console.error('Error deleting event:', error); - alert(clickTallyAdmin.strings.errorGeneral); + console.error('Delete error:', error); + showMessage(error.message || 'Failed to delete event.', 'error'); }) .finally(() => { - // Restore button state if (deleteBtn) { deleteBtn.disabled = false; deleteBtn.textContent = 'Delete'; } }); } -} - -function handleEventFormSubmit(e) { - e.preventDefault(); - - const form = document.getElementById('event-form'); - if (!form) return; - // Validate form - const selectorType = form.querySelector('#selector-type').value; - const selectorValue = form.querySelector('#selector-value').value.trim(); - const eventName = form.querySelector('#event-name').value.trim(); + /** + * Refresh the rules list + */ + function refreshRulesList() { + // For now, reload the page to refresh the list + // In a full implementation, you'd fetch and re-render the table via AJAX + setTimeout(() => { + window.location.reload(); + }, 1500); + } - if (!selectorType || !selectorValue || !eventName) { - alert('Please fill in all required fields.'); - return; + /** + * Update selector help text based on selected type + */ + function updateSelectorHelp() { + if (!elements.selectorType || !elements.selectorHelpText) return; + + const selectorType = elements.selectorType.value; + const helpTexts = { + 'id': 'Enter the element ID (without #). Example: signup-button', + 'class': 'Enter the CSS class name (without .). Example: btn-primary', + 'css': 'Enter a CSS selector. Example: .header .nav-menu a', + 'xpath': 'Enter an XPath expression. Example: //button[@id="submit"]', + 'data': 'Enter the data attribute name. Example: action (for data-action)' + }; + + elements.selectorHelpText.textContent = helpTexts[selectorType] || 'Enter the selector value.'; + + // Update placeholder + const placeholders = { + 'id': 'signup-button', + 'class': 'btn-primary', + 'css': '.header .nav-menu a', + 'xpath': '//button[@id="submit"]', + 'data': 'action' + }; + + if (elements.selectorValue) { + elements.selectorValue.placeholder = placeholders[selectorType] || ''; + } } - // Show loading state - const submitBtn = form.querySelector('button[type="submit"]'); - const originalText = submitBtn.textContent; - submitBtn.disabled = true; - submitBtn.textContent = 'Saving...'; + /** + * Auto-generate event name from selector + */ + function autoGenerateEventName() { + if (!elements.eventName || !elements.selectorValue || !elements.selectorType) return; + + // Only auto-generate if event name is empty + if (elements.eventName.value.trim() !== '') return; + + const selectorValue = elements.selectorValue.value.trim(); + const selectorType = elements.selectorType.value; + + if (!selectorValue) return; + + let eventName = ''; + + if (selectorType === 'id') { + eventName = selectorValue.replace(/[-_]/g, ' ') + ' Click'; + } else if (selectorType === 'class') { + eventName = selectorValue.replace(/[-_]/g, ' ') + ' Click'; + } else { + eventName = 'Element Click'; + } + + // Capitalize first letter of each word + eventName = eventName.replace(/\b\w/g, l => l.toUpperCase()); + + elements.eventName.value = eventName; + } - // Prepare data - const eventId = form.querySelector('#event-id').value; - const formData = new FormData(form); - const eventData = { - selector_type: selectorType, - selector_value: selectorValue, - event_name: eventName, - event_type: formData.get('event_type') || 'click', - label_template: formData.get('label_template') || '', - throttle_ms: parseInt(formData.get('throttle_ms')) || 0, - once_per_view: formData.get('once_per_view') ? true : false - }; + /** + * Open selector picker (placeholder for future enhancement) + */ + function openSelectorPicker() { + alert('Element picker feature coming soon!\n\nFor now, please enter selectors manually. Use browser developer tools to inspect elements and copy their selectors.'); + } - // Submit via REST API - const url = clickTallyAdmin.apiUrl + 'rules/manage'; - const isUpdate = eventId && eventId !== ''; + /** + * Show message to user + */ + function showMessage(message, type = 'info') { + if (!elements.messagesContainer) return; + + // Clear existing messages + clearMessages(); + + const messageEl = document.createElement('div'); + messageEl.className = `clicktally-message ${type}`; + messageEl.textContent = message; + + elements.messagesContainer.appendChild(messageEl); + + // Auto-remove after 5 seconds for non-error messages + if (type !== 'error') { + setTimeout(() => { + if (messageEl.parentNode) { + messageEl.parentNode.removeChild(messageEl); + } + }, 5000); + } + + // Scroll to message + messageEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } - fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': clickTallyAdmin.nonce - }, - body: JSON.stringify({ - action: isUpdate ? 'update' : 'create', - rule_id: isUpdate ? eventId : undefined, - ...eventData - }) - }) - .then(response => { - if (!response.ok) { - // Handle different error types - if (response.status === 403) { - throw new Error('Permission denied. Check your capabilities.'); - } else if (response.status === 404) { - throw new Error('REST endpoint not found. Try flushing permalinks.'); - } else { - throw new Error('Network response was not ok: ' + response.status); - } + /** + * Clear all messages + */ + function clearMessages() { + if (elements.messagesContainer) { + elements.messagesContainer.innerHTML = ''; } - return response.json(); - }) - .then(data => { - if (data.success) { - // Show success message - alert(isUpdate ? clickTallyAdmin.strings.eventUpdated : clickTallyAdmin.strings.eventAdded); - - // Close modal - closeEventModal(); - - // Refresh the page to show updated list - location.reload(); - } else { - throw new Error(data.message || 'Unknown error'); - } - }) - .catch(error => { - console.error('Error saving event:', error); - alert(clickTallyAdmin.strings.errorGeneral); - }) - .finally(() => { - // Restore button state - submitBtn.disabled = false; - submitBtn.textContent = originalText; - }); -} \ No newline at end of file + } + + // Public API + return { + init: init, + showAddForm: showAddForm, + editRule: editRule, + deleteRule: deleteRule, + showMessage: showMessage + }; + +})(); \ No newline at end of file diff --git a/includes/class-capabilities.php b/includes/class-capabilities.php index 7e6a4d3..0fbda32 100644 --- a/includes/class-capabilities.php +++ b/includes/class-capabilities.php @@ -46,21 +46,21 @@ public static function register_post_type() { 'publicly_queryable' => false, 'capability_type' => 'post', 'capabilities' => array( - 'edit_post' => 'manage_clicktally', - 'read_post' => 'manage_clicktally', - 'delete_post' => 'manage_clicktally', - 'edit_posts' => 'manage_clicktally', - 'edit_others_posts' => 'manage_clicktally', - 'publish_posts' => 'manage_clicktally', - 'read_private_posts' => 'manage_clicktally', - 'read' => 'manage_clicktally', - 'delete_posts' => 'manage_clicktally', - 'delete_private_posts' => 'manage_clicktally', - 'delete_published_posts' => 'manage_clicktally', - 'delete_others_posts' => 'manage_clicktally', - 'edit_private_posts' => 'manage_clicktally', - 'edit_published_posts' => 'manage_clicktally', - 'create_posts' => 'manage_clicktally' + 'edit_post' => 'manage_clicktally_element_event_tracker', + 'read_post' => 'manage_clicktally_element_event_tracker', + 'delete_post' => 'manage_clicktally_element_event_tracker', + 'edit_posts' => 'manage_clicktally_element_event_tracker', + 'edit_others_posts' => 'manage_clicktally_element_event_tracker', + 'publish_posts' => 'manage_clicktally_element_event_tracker', + 'read_private_posts' => 'manage_clicktally_element_event_tracker', + 'read' => 'manage_clicktally_element_event_tracker', + 'delete_posts' => 'manage_clicktally_element_event_tracker', + 'delete_private_posts' => 'manage_clicktally_element_event_tracker', + 'delete_published_posts' => 'manage_clicktally_element_event_tracker', + 'delete_others_posts' => 'manage_clicktally_element_event_tracker', + 'edit_private_posts' => 'manage_clicktally_element_event_tracker', + 'edit_published_posts' => 'manage_clicktally_element_event_tracker', + 'create_posts' => 'manage_clicktally_element_event_tracker' ), 'supports' => array('title', 'revisions'), 'rewrite' => false, @@ -72,14 +72,14 @@ public static function register_post_type() { * Check if current user can manage ClickTally */ public static function can_manage() { - return current_user_can('manage_clicktally'); + return current_user_can('manage_clicktally_element_event_tracker'); } /** * Check if current user can view stats */ public static function can_view_stats() { - return current_user_can('manage_clicktally'); + return current_user_can('manage_clicktally_element_event_tracker'); } /** @@ -90,7 +90,7 @@ public static function get_management_roles() { $wp_roles = wp_roles(); foreach ($wp_roles->roles as $role_name => $role_info) { - if (isset($role_info['capabilities']['manage_clicktally']) && $role_info['capabilities']['manage_clicktally']) { + if (isset($role_info['capabilities']['manage_clicktally_element_event_tracker']) && $role_info['capabilities']['manage_clicktally_element_event_tracker']) { $roles[] = $role_name; } } @@ -104,7 +104,7 @@ public static function get_management_roles() { public static function add_capability_to_role($role_name) { $role = get_role($role_name); if ($role) { - $role->add_cap('manage_clicktally'); + $role->add_cap('manage_clicktally_element_event_tracker'); } } @@ -114,7 +114,7 @@ public static function add_capability_to_role($role_name) { public static function remove_capability_from_role($role_name) { $role = get_role($role_name); if ($role) { - $role->remove_cap('manage_clicktally'); + $role->remove_cap('manage_clicktally_element_event_tracker'); } } } \ No newline at end of file diff --git a/includes/class-clicktally-element-event-tracker-admin-menu.php b/includes/class-clicktally-element-event-tracker-admin-menu.php index 53c283e..5280c77 100644 --- a/includes/class-clicktally-element-event-tracker-admin-menu.php +++ b/includes/class-clicktally-element-event-tracker-admin-menu.php @@ -138,6 +138,13 @@ public static function clicktally_element_event_tracker_enqueue_admin_scripts($h } elseif ($hook_suffix === 'clicktally_page_clicktally-element-event-tracker-rules') { // Rules page - load React components for Add/Edit rule functionality + wp_enqueue_style( + 'clicktally-element-event-tracker-rules-style', + CLICKTALLY_PLUGIN_URL . 'assets/css/admin-rules.css', + array(), + CLICKTALLY_VERSION . '.1' // Bust cache + ); + wp_enqueue_script( 'clicktally-element-event-tracker-rules-script', CLICKTALLY_PLUGIN_URL . 'assets/js/admin-rules.js', @@ -285,20 +292,218 @@ public static function clicktally_element_event_tracker_render_dashboard_page() } /** - * Render Tracking Rules page (delegates to existing functionality for now) + * Render Tracking Rules page (new AJAX-based implementation) */ public static function clicktally_element_event_tracker_render_rules_page() { if (!current_user_can('manage_clicktally_element_event_tracker')) { wp_die(__('You do not have sufficient permissions to access this page.', 'clicktally')); } - // For now, use existing rules page functionality - if (class_exists('ClickTally_Admin')) { - ClickTally_Admin::render_rules_page(); - } else { - echo '' . esc_html__('Rules functionality will be available soon.', 'clicktally') . '
| + | + | + | + | + |
|---|---|---|---|---|
| + + + + + + + | ++ + + + | ++ + + + | ++ + + + | +
+
+
+
+
+
+
+ |
+