feat: import wizard reliability and UX improvements#115
Conversation
Introduce `CleanupImportsCommand` to remove stale and completed import files based on configurable age thresholds. Register the command and add corresponding tests to ensure behavior consistency.
- Batch JSON custom field resolution to eliminate N+1 queries - Fix chunk pagination bug that skipped rows in large imports (chunkById) - Add concurrent import prevention with Cache::lock and Livewire guards - Add failed rows CSV export/download action - Add UTF-8/international data tests and large dataset (1000-row) tests
Rows without a match_action (null) passed through the skip guard and reached ImportDataStorage::setMultiple() with a null record. Changed the guard from blacklist (isSkip) to whitelist (isCreate || isUpdate).
There was a problem hiding this comment.
Pull request overview
This PR enhances the import wizard's reliability and user experience through performance optimizations, bug fixes, and improved error handling. The changes address N+1 query issues, pagination bugs, null value crashes, and add concurrent import prevention with failed row CSV export functionality.
Changes:
- Batch resolution for JSON custom field queries to eliminate N+1 performance issues during entity linking
- Fixed chunk pagination bug by switching from
chunk()tochunkById()to prevent record skipping - Added null
match_actionguard using whitelist approach instead of blacklist to prevent crashes - Implemented concurrent import prevention using cache locks and Livewire state guards
- Added CSV export functionality for failed import rows with original data and error messages
- Comprehensive test coverage for UTF-8/international data and large datasets (1000+ rows)
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Feature/ImportWizard/Livewire/PreviewStepTest.php | Tests for concurrent import prevention and failed rows download action |
| tests/Feature/ImportWizard/Jobs/ExecuteImportJobTest.php | Tests for batch JSON queries, null match_action handling, UTF-8 data, and large datasets |
| tests/Feature/ImportWizard/Commands/CleanupImportsCommandTest.php | New test file for import cleanup command |
| app-modules/ImportWizard/src/Support/EntityLinkResolver.php | Batched JSON query resolution with chunking to eliminate N+1 queries |
| app-modules/ImportWizard/src/Store/ImportStore.php | Added atomic state transition with locking and failed rows tracking |
| app-modules/ImportWizard/src/Store/ImportRow.php | Added processed column for tracking row processing state |
| app-modules/ImportWizard/src/Livewire/Steps/PreviewStep.php | Concurrent import guards and failed rows CSV download action |
| app-modules/ImportWizard/src/Jobs/ExecuteImportJob.php | Fixed pagination, null guards, transaction wrapping, notifications, and error tracking |
| app-modules/ImportWizard/src/Commands/CleanupImportsCommand.php | New command for cleaning up stale and completed imports |
| app-modules/ImportWizard/src/ImportWizardNewServiceProvider.php | Registered cleanup command |
| app-modules/ImportWizard/resources/views/livewire/steps/preview-step.blade.php | Updated UI to show failed count and download button |
| routes/console.php | Removed schedule registration (moved to bootstrap/app.php) |
| composer.json | Updated custom-fields package constraint from @dev to ^3.0-beta |
| composer.lock | Updated dependencies: relaticle/custom-fields, spatie/laravel-medialibrary, laravel/boost, laravel/roster |
Comments suppressed due to low confidence (1)
app-modules/ImportWizard/src/Support/EntityLinkResolver.php:145
- The removal of
withoutGlobalScopes()on this line may cause issues if the target model class has tenant scoping or other global scopes. The query already filters byteam_id, but if the model has a global scope that applies additional constraints (e.g., soft deletes, multi-tenancy), removing this call could prevent matching existing records during import.
return $modelClass::query()
->where('team_id', $this->teamId)
->whereIn($field, $uniqueValues)
->pluck('id', $field)
->all();
| @@ -181,7 +242,6 @@ private function findExistingRecord(BaseImporter $importer, ?string $matchedId): | |||
| $modelClass = $importer->modelClass(); | |||
|
|
|||
| return $modelClass::query() | |||
There was a problem hiding this comment.
The removal of withoutGlobalScopes() on this line may cause issues if the model has global scopes (e.g., soft deletes, tenant scoping). Since this is used to find existing records for updates during import, failing to bypass global scopes could prevent legitimate matches from being found.
| return $modelClass::query() | |
| return $modelClass::withoutGlobalScopes() |
| "livewire/livewire": "^4.0", | ||
| "prism-php/prism": "^0.98.5", | ||
| "relaticle/custom-fields": "@dev", | ||
| "relaticle/custom-fields": " ^3.0-beta", |
There was a problem hiding this comment.
There are extra spaces before the caret in the version constraint. This should be ^3.0-beta instead of ^3.0-beta to maintain consistent formatting with the rest of the file.
| "relaticle/custom-fields": " ^3.0-beta", | |
| "relaticle/custom-fields": "^3.0-beta", |
| <div class="flex items-center gap-4 text-xs ml-auto shrink-0"> | ||
| <span class="flex items-center gap-1.5"> | ||
| <span class="font-semibold text-success-700 dark:text-success-300">{{ number_format($this->results['created'] ?? 0) }}</span> | ||
| <span class="text-gray-500 dark:text-gray-400">created</span> | ||
| </span> | ||
| <span class="flex items-center gap-1.5"> | ||
| <span class="font-semibold text-primary-700 dark:text-primary-300">{{ number_format($this->results['updated'] ?? 0) }}</span> | ||
| <span class="text-gray-500 dark:text-gray-400">updated</span> | ||
| </span> | ||
| <span class="flex items-center gap-1.5"> | ||
| <span class="font-semibold text-gray-600 dark:text-gray-300">{{ number_format($this->results['skipped'] ?? 0) }}</span> | ||
| <span class="text-gray-500 dark:text-gray-400">skipped</span> | ||
| <span class="font-semibold text-danger-700 dark:text-danger-300">{{ number_format($this->results['failed'] ?? 0) }}</span> | ||
| <span class="text-gray-500 dark:text-gray-400">failed</span> | ||
| </span> | ||
| </div> |
There was a problem hiding this comment.
The UI now displays only "created", "updated", and "failed" counts, but the results array still tracks "skipped" rows (see line 73 of ExecuteImportJob.php). While skipped rows are counted internally, users have no visibility into how many rows were skipped. Consider either displaying the skipped count in the UI or documenting why it's intentionally hidden.
| }); | ||
|
|
||
| $this->markProcessed($row); |
There was a problem hiding this comment.
The markProcessed() call on line 162 occurs outside the database transaction (lines 138-160). If the transaction succeeds but markProcessed() fails, the row will be successfully imported but not marked as processed. On job retry (configured with 3 attempts and backoff), this could lead to duplicate records being created. Consider moving markProcessed() inside the transaction or using a different strategy to ensure atomicity.
| }); | |
| $this->markProcessed($row); | |
| $this->markProcessed($row); | |
| }); |
Summary
EntityLinkResolver::resolveViaJsonColumn()by using chunkedorWhereJsonContainsclauseschunk()with a mutatingWHERE processed=falsecondition skipped every other chunk; switched tochunkById()nullmatch_action passed through the skip guard and hitImportDataStorage::setMultiple()with a null record; changed guard from blacklist (isSkip) to whitelist (isCreate || isUpdate)Cache::lock()atomic status transition inImportStore::transitionToImporting()downloadFailedRowsFilament action on PreviewStep, streams CSV with original data + error columnslow)Test plan
php artisan test --filter=ExecuteImportJob— 37 tests passphp artisan test --filter=PreviewStep— 34 tests passvendor/bin/phpstan analyse— no new errorsvendor/bin/pint --dirty— clean