diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js index 49e4d60f..c57f4c9b 100644 --- a/assets/controllers/bulk_import_controller.js +++ b/assets/controllers/bulk_import_controller.js @@ -3,14 +3,16 @@ import { generateCsrfHeaders } from "./csrf_protection_controller" export default class extends Controller { static targets = ["progressBar", "progressText"] - static values = { + static values = { jobId: Number, partId: Number, researchUrl: String, researchAllUrl: String, markCompletedUrl: String, markSkippedUrl: String, - markPendingUrl: String + markPendingUrl: String, + quickApplyUrl: String, + quickApplyAllUrl: String } connect() { @@ -321,6 +323,94 @@ export default class extends Controller { } } + 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 = ' 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) } diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 2d3dd7f6..05a698dd 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -29,10 +29,12 @@ use App\Entity\Parts\Part; use App\Entity\Parts\Supplier; use App\Entity\UserSystem\User; use App\Form\InfoProviderSystem\GlobalFieldMappingType; +use App\Services\EntityMergers\Mergers\PartMerger; use App\Services\InfoProviderSystem\BulkInfoProviderService; use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; +use App\Services\InfoProviderSystem\PartInfoRetriever; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -515,6 +517,171 @@ class BulkInfoProviderImportController extends AbstractController } } + #[Route('/job/{jobId}/part/{partId}/quick-apply', name: 'bulk_info_provider_quick_apply', methods: ['POST'])] + public function quickApply( + int $jobId, + int $partId, + Request $request, + PartInfoRetriever $infoRetriever, + PartMerger $partMerger + ): JsonResponse { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $part = $this->entityManager->getRepository(Part::class)->find($partId); + if (!$part) { + return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]); + } + + $this->denyAccessUnlessGranted('edit', $part); + + // Get provider key/id from request body, or fall back to top search result + $body = json_decode($request->getContent(), true) ?? []; + $providerKey = $body['providerKey'] ?? null; + $providerId = $body['providerId'] ?? null; + + if (!$providerKey || !$providerId) { + $searchResults = $job->getSearchResults($this->entityManager); + foreach ($searchResults->partResults as $partResult) { + if ($partResult->part->getId() === $partId) { + $sorted = $partResult->getResultsSortedByPriority(); + if (!empty($sorted)) { + $providerKey = $sorted[0]->searchResult->provider_key; + $providerId = $sorted[0]->searchResult->provider_id; + } + break; + } + } + } + + if (!$providerKey || !$providerId) { + return $this->createErrorResponse('No search result available for this part', 400, ['part_id' => $partId]); + } + + try { + $dto = $infoRetriever->getDetails($providerKey, $providerId); + $providerPart = $infoRetriever->dtoToPart($dto); + $partMerger->merge($part, $providerPart); + + $this->entityManager->flush(); + + $job->markPartAsCompleted($partId); + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'message' => sprintf('Applied provider data to "%s"', $part->getName()), + 'part_id' => $partId, + 'provider_key' => $providerKey, + 'provider_id' => $providerId, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted(), + ]); + } catch (\Exception $e) { + return $this->createErrorResponse( + 'Quick apply failed: ' . $e->getMessage(), + 500, + ['job_id' => $jobId, 'part_id' => $partId, 'exception' => $e->getMessage()] + ); + } + } + + #[Route('/job/{jobId}/quick-apply-all', name: 'bulk_info_provider_quick_apply_all', methods: ['POST'])] + public function quickApplyAll( + int $jobId, + PartInfoRetriever $infoRetriever, + PartMerger $partMerger + ): JsonResponse { + set_time_limit(600); + + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $searchResults = $job->getSearchResults($this->entityManager); + $applied = 0; + $failed = 0; + $noResults = 0; + $errors = []; + + foreach ($job->getJobParts() as $jobPart) { + if ($jobPart->isCompleted() || $jobPart->isSkipped()) { + continue; + } + + $part = $jobPart->getPart(); + + if (!$this->isGranted('edit', $part)) { + $errors[] = sprintf('No edit permission for "%s"', $part->getName()); + $failed++; + continue; + } + + // Find top search result for this part + $providerKey = null; + $providerId = null; + foreach ($searchResults->partResults as $partResult) { + if ($partResult->part->getId() === $part->getId()) { + $sorted = $partResult->getResultsSortedByPriority(); + if (!empty($sorted)) { + $providerKey = $sorted[0]->searchResult->provider_key; + $providerId = $sorted[0]->searchResult->provider_id; + } + break; + } + } + + if (!$providerKey || !$providerId) { + $noResults++; + continue; + } + + try { + $dto = $infoRetriever->getDetails($providerKey, $providerId); + $providerPart = $infoRetriever->dtoToPart($dto); + $partMerger->merge($part, $providerPart); + $this->entityManager->flush(); + + $job->markPartAsCompleted($part->getId()); + $applied++; + } catch (\Exception $e) { + $this->logger->error('Quick apply failed for part', [ + 'part_id' => $part->getId(), + 'part_name' => $part->getName(), + 'error' => $e->getMessage(), + ]); + $errors[] = sprintf('Failed for "%s": %s', $part->getName(), $e->getMessage()); + $failed++; + } + } + + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'applied' => $applied, + 'failed' => $failed, + 'no_results' => $noResults, + 'errors' => $errors, + 'message' => sprintf('Applied to %d parts, %d failed, %d had no results', $applied, $failed, $noResults), + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted(), + ]); + } + #[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])] public function researchAllParts(int $jobId): JsonResponse { diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 559ca20a..4a12be18 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -20,7 +20,9 @@ 'researchAllUrl': path('bulk_info_provider_research_all', {'jobId': job.id}), 'markCompletedUrl': path('bulk_info_provider_mark_completed', {'jobId': job.id, 'partId': '__PART_ID__'}), 'markSkippedUrl': path('bulk_info_provider_mark_skipped', {'jobId': job.id, 'partId': '__PART_ID__'}), - 'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'}) + 'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'}), + 'quickApplyUrl': path('bulk_info_provider_quick_apply', {'jobId': job.id, 'partId': '__PART_ID__'}), + 'quickApplyAllUrl': path('bulk_info_provider_quick_apply_all', {'jobId': job.id}) }) }}>
@@ -95,6 +97,13 @@ {% trans %}info_providers.bulk_import.research.all_pending{% endtrans %} +
@@ -214,6 +223,16 @@
+ {% if not isCompleted %} + + {% endif %} {% set updateHref = path('info_providers_update_part', {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %} diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php index ec3629fe..f35c5f92 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -589,6 +589,296 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase return $parts; } + public function testQuickApplyWithNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/quick-apply'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertArrayHasKey('error', $response); + } + + public function testQuickApplyWithNonExistentPart(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([])); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/999999/quick-apply'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyWithNoSearchResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + // Empty search results - no provider results for any parts + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: []) + ])); + + $entityManager->persist($job); + $entityManager->flush(); + + // Quick apply without providing providerKey/providerId and no search results available + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([])); + + $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertFalse($response['success']); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyAccessControl(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $admin = $userRepository->findOneBy(['name' => 'admin']); + $readonly = $userRepository->findOneBy(['name' => 'noread']); + + if (!$admin || !$readonly) { + $this->markTestSkipped('Required test users not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + // Create job owned by readonly user + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($readonly); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([])); + + $entityManager->persist($job); + $entityManager->flush(); + + // Admin tries to quick apply on readonly user's job - should fail + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testQuickApplyAllWithNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/quick-apply-all'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertArrayHasKey('error', $response); + } + + public function testQuickApplyAllWithNoResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1, 2]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + // Empty search results for all parts + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: []), + new BulkSearchPartResultsDTO(part: $parts[1], searchResults: [], errors: []), + ])); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all'); + + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['applied']); + $this->assertEquals(2, $response['no_results']); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyAllAccessControl(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $readonly = $userRepository->findOneBy(['name' => 'noread']); + + if (!$readonly) { + $this->markTestSkipped('Required test users not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($readonly); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([])); + + $entityManager->persist($job); + $entityManager->flush(); + + // Admin tries quick apply all on readonly user's job + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testStep2TemplateRenderingWithQuickApplyButtons(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = static::getContainer()->get('doctrine')->getManager(); + $partRepository = $entityManager->getRepository(Part::class); + $part = $partRepository->find(1); + + if (!$part) { + $this->markTestSkipped('Test part with ID 1 not found in fixtures'); + } + + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + $job->addPart($part); + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + + $searchResults = new BulkSearchResponseDTO(partResults: [ + new BulkSearchPartResultsDTO(part: $part, + searchResults: [new BulkSearchPartResultDTO( + searchResult: new SearchResultDTO(provider_key: 'test_provider', provider_id: 'TEST123', name: 'Test Component', description: 'Test description', manufacturer: 'Test Mfg', mpn: 'TEST-MPN', provider_url: 'https://example.com/test', preview_image_url: null), + sourceField: 'mpn', + sourceKeyword: 'TEST-MPN', + )] + ) + ]); + + $job->setSearchResults($searchResults); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId()); + + if ($client->getResponse()->isRedirect()) { + $client->followRedirect(); + } + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + + $content = (string) $client->getResponse()->getContent(); + // Verify quick apply buttons are rendered (Stimulus renders camelCase as kebab-case data attributes) + $this->assertStringContainsString('quick-apply-url-value', $content); + $this->assertStringContainsString('quick-apply-all-url-value', $content); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $jobToRemove = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($jobToRemove) { + $entityManager->remove($jobToRemove); + $entityManager->flush(); + } + } + public function testStep1Form(): void { $client = static::createClient(); diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 180d9e5e..e926b0c6 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11103,6 +11103,30 @@ Please note, that you can not impersonate a disabled user. If you try you will g Update Part + + + info_providers.bulk_import.quick_apply + Quick Apply + + + + + info_providers.bulk_import.quick_apply.tooltip + Apply this provider result to the part without opening the edit form + + + + + info_providers.bulk_import.quick_apply_all + Apply All (Top Results) + + + + + info_providers.bulk_import.quick_apply_all.tooltip + Apply the top-ranked search result to all pending parts without individual review + + info_providers.bulk_import.prefetch_details