mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-10 15:12:12 +00:00
* Add Quick Apply and Apply All buttons to bulk info provider import Adds the ability to apply provider search results to parts directly from the bulk import step 2 page without navigating to individual part edit forms. Includes per-result Quick Apply buttons and an Apply All button for batch operations. * Add navigation buttons and completion banner to bulk import step2 Adds Back to Jobs / Back to Parts buttons at the top of the page and a success banner when the job is completed, so users aren't stuck on the page after applying all parts. * Highlight top search result and remove skip reason prompt - Highlight the recommended/top priority result row with table-success class - Add "Top" badge to the recommended Quick Apply button - Use outline style for non-top Quick Apply buttons to differentiate - Remove the annoying "reason for skipping" prompt popup * Fix 500 error when field mapping has null field or no search results - Skip field mappings with null/empty field values in convertFieldMappingsToDto - Return empty DTO instead of throwing when no search results found - Remove unnecessary try/catch workaround in researchPart * Fix PHPStan error: remove redundant null check on BulkSearchResponseDTO * Improve bulk import UI: split active/history jobs, fix text visibility, add match highlighting - Split manage page into Active Jobs and History sections - Fix source keyword text color (remove text-muted for better visibility) - Add exact match indicators: green check badge when name or MPN matches - Add translation keys for new UI elements * Fix spinning icon, text visibility, auto-priority, and SPN match highlighting - Replace spinning icon with static icon on Active Jobs header - Match highlighting now checks source keyword against name, MPN, AND provider ID (SPN) - Show green "Match" badge in source field column when any field matches 100% - Auto-increment priority when adding new field mapping rows - Fix text-muted visibility issues on table-success background * Fix broken images and improve match highlighting consistency - Hide broken external provider images with onerror fallback - Make source keyword text green when any match is detected - All matched fields (name, MPN, SPN, or any source keyword) show green text * Fix TypeError in LCSCProvider when keyword is numeric string PHP auto-casts numeric string array keys to int. When a search keyword is a pure number (e.g., a part number like "12345"), the foreach loop passes an int to processSearchResponse() which expects string. Cast keyword to string explicitly. * Clean up stale pending jobs and add job ID to display - Auto-delete pending jobs with 0 results (from failed searches/500 errors) - Show job ID (#N) in manage page and step2 to distinguish identical jobs - Move timestamp to subtitle line on manage page for cleaner layout * Fix tests to match updated bulk search behavior (no more RuntimeException) The bulk search service now returns empty response DTOs instead of throwing RuntimeException when no results are found. Updated tests to use assertFalse(hasAnyResults()) instead of catching exceptions. * Add comprehensive test coverage for bulk import controller Covers Quick Apply, Apply All, delete, stop, mark completed/skipped/pending, manage page active/history split, stale job cleanup, research endpoints, and various error paths. Increases patch coverage significantly. * Fix duplicate test method names in bulk import tests * Fix last duplicate test method name (testQuickApplyWithNoSearchResults) * Fixed translation key in translation messages * Moved table rendering logic into macro * fixed visual glitch with button success outline * Use native httpfoundation method to convert json to an array * Show a more user friendly error message, when * Allow to automatically create new manufacturers within quick apply --------- Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
447 lines
No EOL
16 KiB
JavaScript
447 lines
No EOL
16 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import { generateCsrfHeaders } from "./csrf_protection_controller"
|
|
|
|
export default class extends Controller {
|
|
static targets = ["progressBar", "progressText"]
|
|
static values = {
|
|
jobId: Number,
|
|
partId: Number,
|
|
researchUrl: String,
|
|
researchAllUrl: String,
|
|
markCompletedUrl: String,
|
|
markSkippedUrl: String,
|
|
markPendingUrl: String,
|
|
quickApplyUrl: String,
|
|
quickApplyAllUrl: String
|
|
}
|
|
|
|
connect() {
|
|
// Auto-refresh progress if job is in progress
|
|
if (this.hasProgressBarTarget) {
|
|
this.startProgressUpdates()
|
|
}
|
|
|
|
// Restore scroll position after page reload (if any)
|
|
this.restoreScrollPosition()
|
|
}
|
|
|
|
getHeaders() {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
|
|
// Add CSRF headers if available
|
|
const form = document.querySelector('form')
|
|
if (form) {
|
|
const csrfHeaders = generateCsrfHeaders(form)
|
|
Object.assign(headers, csrfHeaders)
|
|
}
|
|
|
|
return headers
|
|
}
|
|
|
|
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()
|
|
throw new Error(`Server error (${response.status}): ${errorText}`)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.progressInterval) {
|
|
clearInterval(this.progressInterval)
|
|
}
|
|
}
|
|
|
|
startProgressUpdates() {
|
|
// Progress updates are handled via page reload for better reliability
|
|
// No need for periodic updates since state changes trigger page refresh
|
|
}
|
|
|
|
restoreScrollPosition() {
|
|
const savedPosition = sessionStorage.getItem('bulkImportScrollPosition')
|
|
if (savedPosition) {
|
|
// Restore scroll position after a small delay to ensure page is fully loaded
|
|
setTimeout(() => {
|
|
window.scrollTo(0, parseInt(savedPosition))
|
|
// Clear the saved position so it doesn't interfere with normal navigation
|
|
sessionStorage.removeItem('bulkImportScrollPosition')
|
|
}, 100)
|
|
}
|
|
}
|
|
|
|
async markCompleted(event) {
|
|
const partId = event.currentTarget.dataset.partId
|
|
|
|
try {
|
|
const url = this.markCompletedUrlValue.replace('__PART_ID__', partId)
|
|
const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
|
|
|
|
if (data.success) {
|
|
this.updateProgressDisplay(data)
|
|
this.markRowAsCompleted(partId)
|
|
|
|
if (data.job_completed) {
|
|
this.showJobCompletedMessage()
|
|
}
|
|
} else {
|
|
this.showErrorMessage(data.error || 'Failed to mark part as completed')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error marking part as completed:', error)
|
|
this.showErrorMessage(error.message || 'Failed to mark part as completed')
|
|
}
|
|
}
|
|
|
|
async markSkipped(event) {
|
|
const partId = event.currentTarget.dataset.partId
|
|
|
|
try {
|
|
const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
|
|
const data = await this.fetchWithErrorHandling(url, {
|
|
method: 'POST'
|
|
})
|
|
|
|
if (data.success) {
|
|
this.updateProgressDisplay(data)
|
|
this.markRowAsSkipped(partId)
|
|
} else {
|
|
this.showErrorMessage(data.error || 'Failed to mark part as skipped')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error marking part as skipped:', error)
|
|
this.showErrorMessage(error.message || 'Failed to mark part as skipped')
|
|
}
|
|
}
|
|
|
|
async markPending(event) {
|
|
const partId = event.currentTarget.dataset.partId
|
|
|
|
try {
|
|
const url = this.markPendingUrlValue.replace('__PART_ID__', partId)
|
|
const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
|
|
|
|
if (data.success) {
|
|
this.updateProgressDisplay(data)
|
|
this.markRowAsPending(partId)
|
|
} else {
|
|
this.showErrorMessage(data.error || 'Failed to mark part as pending')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error marking part as pending:', error)
|
|
this.showErrorMessage(error.message || 'Failed to mark part as pending')
|
|
}
|
|
}
|
|
|
|
updateProgressDisplay(data) {
|
|
if (this.hasProgressBarTarget) {
|
|
this.progressBarTarget.style.width = `${data.progress}%`
|
|
this.progressBarTarget.setAttribute('aria-valuenow', data.progress)
|
|
}
|
|
|
|
if (this.hasProgressTextTarget) {
|
|
this.progressTextTarget.textContent = `${data.completed_count} / ${data.total_count} completed`
|
|
}
|
|
}
|
|
|
|
markRowAsCompleted(partId) {
|
|
// Save scroll position and refresh page to show updated state
|
|
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
|
window.location.reload()
|
|
}
|
|
|
|
markRowAsSkipped(partId) {
|
|
// Save scroll position and refresh page to show updated state
|
|
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
|
window.location.reload()
|
|
}
|
|
|
|
markRowAsPending(partId) {
|
|
// Save scroll position and refresh page to show updated state
|
|
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
|
window.location.reload()
|
|
}
|
|
|
|
showJobCompletedMessage() {
|
|
const alert = document.createElement('div')
|
|
alert.className = 'alert alert-success alert-dismissible fade show'
|
|
alert.innerHTML = `
|
|
<i class="fas fa-check-circle"></i>
|
|
Job completed! All parts have been processed.
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
`
|
|
|
|
const container = document.querySelector('.card-body')
|
|
container.insertBefore(alert, container.firstChild)
|
|
}
|
|
|
|
async researchPart(event) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
const partId = event.currentTarget.dataset.partId
|
|
const spinner = event.currentTarget.querySelector(`[data-research-spinner="${partId}"]`)
|
|
const button = event.currentTarget
|
|
|
|
// Show loading state
|
|
if (spinner) {
|
|
spinner.style.display = 'inline-block'
|
|
}
|
|
button.disabled = true
|
|
|
|
try {
|
|
const url = this.researchUrlValue.replace('__PART_ID__', partId)
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: this.getHeaders(),
|
|
signal: controller.signal
|
|
})
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
throw new Error(`Server error (${response.status}): ${errorText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
this.showSuccessMessage(`Research completed for part. Found ${data.results_count} results.`)
|
|
// Save scroll position and reload to show updated results
|
|
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
|
window.location.reload()
|
|
} else {
|
|
this.showErrorMessage(data.error || 'Research failed')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error researching part:', error)
|
|
|
|
if (error.name === 'AbortError') {
|
|
this.showErrorMessage('Research timed out. Please try again.')
|
|
} else if (error.message.includes('Failed to fetch')) {
|
|
this.showErrorMessage('Network error. Please check your connection and try again.')
|
|
} else {
|
|
this.showErrorMessage(error.message || 'Research failed due to an unexpected error')
|
|
}
|
|
} finally {
|
|
// Hide loading state
|
|
if (spinner) {
|
|
spinner.style.display = 'none'
|
|
}
|
|
button.disabled = false
|
|
}
|
|
}
|
|
|
|
async researchAllParts(event) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
const spinner = document.getElementById('research-all-spinner')
|
|
const button = event.currentTarget
|
|
|
|
// Show loading state
|
|
if (spinner) {
|
|
spinner.style.display = 'inline-block'
|
|
}
|
|
button.disabled = true
|
|
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 120000) // 2 minute timeout for bulk operations
|
|
|
|
const response = await fetch(this.researchAllUrlValue, {
|
|
method: 'POST',
|
|
headers: this.getHeaders(),
|
|
signal: controller.signal
|
|
})
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
throw new Error(`Server error (${response.status}): ${errorText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
this.showSuccessMessage(`Research completed for ${data.researched_count} parts.`)
|
|
// Save scroll position and reload to show updated results
|
|
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
|
window.location.reload()
|
|
} else {
|
|
this.showErrorMessage(data.error || 'Bulk research failed')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error researching all parts:', error)
|
|
|
|
if (error.name === 'AbortError') {
|
|
this.showErrorMessage('Bulk research timed out. This may happen with large batches. Please try again or process smaller batches.')
|
|
} else if (error.message.includes('Failed to fetch')) {
|
|
this.showErrorMessage('Network error. Please check your connection and try again.')
|
|
} else {
|
|
this.showErrorMessage(error.message || 'Bulk research failed due to an unexpected error')
|
|
}
|
|
} finally {
|
|
// Hide loading state
|
|
if (spinner) {
|
|
spinner.style.display = 'none'
|
|
}
|
|
button.disabled = false
|
|
}
|
|
}
|
|
|
|
async quickApply(event) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
const partId = event.currentTarget.dataset.partId
|
|
const providerKey = event.currentTarget.dataset.providerKey
|
|
const providerId = event.currentTarget.dataset.providerId
|
|
const button = event.currentTarget
|
|
const originalHtml = button.innerHTML
|
|
|
|
button.disabled = true
|
|
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Applying...'
|
|
|
|
try {
|
|
const url = this.quickApplyUrlValue.replace('__PART_ID__', partId)
|
|
const data = await this.fetchWithErrorHandling(url, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ providerKey, providerId })
|
|
}, 60000)
|
|
|
|
if (data.success) {
|
|
this.updateProgressDisplay(data)
|
|
this.showSuccessMessage(data.message || 'Part updated successfully')
|
|
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
|
window.location.reload()
|
|
} else {
|
|
this.showErrorMessage(data.error || 'Quick apply failed')
|
|
button.innerHTML = originalHtml
|
|
button.disabled = false
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in quick apply:', error)
|
|
this.showErrorMessage(error.message || 'Quick apply failed')
|
|
button.innerHTML = originalHtml
|
|
button.disabled = false
|
|
}
|
|
}
|
|
|
|
async quickApplyAll(event) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
if (!confirm('This will apply the top search result to all pending parts without individual review. Continue?')) {
|
|
return
|
|
}
|
|
|
|
const button = event.currentTarget
|
|
const spinner = document.getElementById('quick-apply-all-spinner')
|
|
const originalHtml = button.innerHTML
|
|
|
|
button.disabled = true
|
|
if (spinner) {
|
|
spinner.style.display = 'inline-block'
|
|
}
|
|
|
|
try {
|
|
const data = await this.fetchWithErrorHandling(this.quickApplyAllUrlValue, {
|
|
method: 'POST'
|
|
}, 300000)
|
|
|
|
if (data.success) {
|
|
this.updateProgressDisplay(data)
|
|
|
|
let message = data.message || 'Bulk apply completed'
|
|
if (data.errors && data.errors.length > 0) {
|
|
message += '\nErrors:\n' + data.errors.join('\n')
|
|
}
|
|
|
|
this.showSuccessMessage(message)
|
|
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
|
window.location.reload()
|
|
} else {
|
|
this.showErrorMessage(data.error || 'Bulk apply failed')
|
|
button.innerHTML = originalHtml
|
|
button.disabled = false
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in quick apply all:', error)
|
|
this.showErrorMessage(error.message || 'Bulk apply failed')
|
|
button.innerHTML = originalHtml
|
|
button.disabled = false
|
|
} finally {
|
|
if (spinner) {
|
|
spinner.style.display = 'none'
|
|
}
|
|
}
|
|
}
|
|
|
|
showSuccessMessage(message) {
|
|
this.showToast('success', message)
|
|
}
|
|
|
|
showErrorMessage(message) {
|
|
this.showToast('error', message)
|
|
}
|
|
|
|
showToast(type, message) {
|
|
// Create a simple alert that doesn't disrupt layout
|
|
const alertId = 'alert-' + Date.now()
|
|
const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle'
|
|
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger'
|
|
|
|
const alertHTML = `
|
|
<div class="alert ${alertClass} alert-dismissible fade show position-fixed"
|
|
style="top: 20px; right: 20px; z-index: 9999; max-width: 400px;"
|
|
id="${alertId}">
|
|
<i class="fas ${iconClass} me-2"></i>
|
|
${message}
|
|
<button type="button" class="btn-close" onclick="this.parentElement.remove()" aria-label="Close"></button>
|
|
</div>
|
|
`
|
|
|
|
// Add alert to body
|
|
document.body.insertAdjacentHTML('beforeend', alertHTML)
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => {
|
|
const alertElement = document.getElementById(alertId)
|
|
if (alertElement) {
|
|
alertElement.remove()
|
|
}
|
|
}, 5000)
|
|
}
|
|
} |