Feat/import wizard preview and match resolution#113
Conversation
Implement the preview step that shows a full data preview before import, with row-level match resolution (create vs update), relationship linking, and background job execution. Simplify data layer (ColumnData, EntityLink, ImportStore, ImportRow), clean up status banner UI, and add comprehensive tests for preview step, import job, and entity link validation.
Move favicon dispatch logic into a guard method that checks for domain presence before dispatching. Remove boilerplate docblocks and fix trait formatting per coding standards.
Move relationship resolution from synchronous PreviewStep mount into ValidateColumnJob and a new ResolveMatchesJob dispatched during review. Add mappings hash check to skip redundant job dispatch when navigating back and forth without changes. Set Previewing status on continue.
Add Previewing status, URL-based store restoration, and status sync on step navigation so refreshing the page returns to the correct step. Block step indicator clicks during active import.
Replace in-memory iteration of all rows with SQL GROUP BY queries for relationship tabs, summary, and stats. Show unique entity counts instead of total row counts. Fix sticky header opacity on preview table.
Add mutates() declarations to all integration test files and new tests to catch previously undetected mutations in validation, execution, and match resolution jobs.
There was a problem hiding this comment.
Pull request overview
This PR adds an import “preview” experience and background match-resolution so users can see create/update/skip outcomes (including relationship link/create summaries) before starting the actual import, with expanded job/test coverage for the new flow.
Changes:
- Introduces match resolution + execution jobs (
ResolveMatchesJob,ExecuteImportJob) and aMatchResolverservice to determine per-row actions. - Expands the Import Wizard flow to include a functional Preview step with relationship tabs/summaries and progress tracking.
- Refactors entity link matching/validation behavior (new
MatchBehavior) and adds extensive Pest coverage for validation, jobs, and Livewire steps.
Reviewed changes
Copilot reviewed 38 out of 38 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Feature/ImportWizard/Validation/EntityLinkValidatorTest.php | Adds coverage for entity-link validation, batch validation, and column-context helpers. |
| tests/Feature/ImportWizard/Validation/ColumnValidatorTest.php | Adds date/number format validation coverage and edge-case rule handling tests. |
| tests/Feature/ImportWizard/Livewire/UploadStepTest.php | Marks UploadStep as mutating for test tooling. |
| tests/Feature/ImportWizard/Livewire/ReviewStepTest.php | Adds coverage for continue gating, match-resolution batching, and relationship clearing on mount. |
| tests/Feature/ImportWizard/Livewire/PreviewStepTest.php | New PreviewStep feature tests: row actions, relationship tabs/summary, import start/progress, rendering expectations. |
| tests/Feature/ImportWizard/Livewire/MappingStepTest.php | Updates MappingStep to use Filament actions and adds confirmation behavior testing. |
| tests/Feature/ImportWizard/Livewire/ImportWizardTest.php | Adds restore-from-store behavior tests and import-started navigation blocking tests. |
| tests/Feature/ImportWizard/Jobs/ValidateColumnJobTest.php | New tests verifying relationship JSON writing and validation behavior per column. |
| tests/Feature/ImportWizard/Jobs/ResolveMatchesJobTest.php | New tests for match resolution behavior and id/email matching outcomes. |
| tests/Feature/ImportWizard/Jobs/ExecuteImportJobTest.php | New tests for end-to-end import execution, relationship handling, dedupe, and results/status updates. |
| app/Observers/CompanyObserver.php | Moves favicon dispatch logic into saved() and adds conditional domain-based dispatch helper. |
| app/Jobs/FetchFaviconForCompany.php | Refactors constructor/traits formatting and keeps favicon fetch behavior. |
| app-modules/ImportWizard/storage/free-email-domains.json | Adds a free-email domains list used by import/matching logic. |
| app-modules/ImportWizard/src/Support/MatchResolver.php | New service that resets and resolves match actions/matched IDs in SQLite. |
| app-modules/ImportWizard/src/Support/EntityLinkValidator.php | Updates entity-link validation semantics to respect MatchBehavior and adds trimming/empty handling. |
| app-modules/ImportWizard/src/Support/EntityLinkResolver.php | Optimizes batch resolution mapping and adds JSON-value resolution support. |
| app-modules/ImportWizard/src/Store/ImportStore.php | Adds results/meta helpers, meta refresh, and simplifies schema creation logic. |
| app-modules/ImportWizard/src/Store/ImportRow.php | Updates helpers to hide invalid values and adds hasValidationError(). |
| app-modules/ImportWizard/src/Livewire/Steps/ReviewStep.php | Adds mapping-hash re-entry behavior, relationship clearing, match-resolution batching, and continue-to-preview action. |
| app-modules/ImportWizard/src/Livewire/Steps/PreviewStep.php | Implements the functional preview UI: counts, relationship tabs/summary, progress polling, and start import action. |
| app-modules/ImportWizard/src/Livewire/Steps/MappingStep.php | Converts “Continue” into a Filament action with optional confirmation and matchable-field warning UX. |
| app-modules/ImportWizard/src/Livewire/ImportWizard.php | Adds URL-based restore via ?import=..., status/step syncing, and navigation locking after import start. |
| app-modules/ImportWizard/src/Jobs/ValidateColumnJob.php | Extends validation to also write relationship match JSON into import_rows.relationships. |
| app-modules/ImportWizard/src/Jobs/ResolveMatchesJob.php | New queued job wrapper around MatchResolver. |
| app-modules/ImportWizard/src/Jobs/ExecuteImportJob.php | New queued job that performs chunked create/update/skip import execution + relationship storage + progress results. |
| app-modules/ImportWizard/src/Enums/RowMatchAction.php | Adds icon/color helpers for UI rendering. |
| app-modules/ImportWizard/src/Enums/MatchBehavior.php | New enum describing AlwaysCreate vs UpdateOnly behavior. |
| app-modules/ImportWizard/src/Enums/ImportStatus.php | Adds Previewing status. |
| app-modules/ImportWizard/src/Enums/ImportEntityType.php | Adds icon helper used by the UI. |
| app-modules/ImportWizard/src/Data/RelationshipMatch.php | Minor cleanup while keeping relationship match DTO functionality. |
| app-modules/ImportWizard/src/Data/MatchableField.php | Replaces legacy booleans with MatchBehavior and adds helper methods. |
| app-modules/ImportWizard/src/Data/EntityLink.php | Enables canCreate() defaults for certain links and cleans up builder/docs. |
| app-modules/ImportWizard/src/Data/ColumnData.php | Minor safety/clarity improvements for transient fields and entity-link context resolution. |
| app-modules/ImportWizard/resources/views/livewire/steps/review-step.blade.php | Formatting improvements + disables continue button while batches run. |
| app-modules/ImportWizard/resources/views/livewire/steps/preview-step.blade.php | Replaces placeholder with full preview UI: tabs, tables, relationship summaries, progress UI, and action modal. |
| app-modules/ImportWizard/resources/views/livewire/steps/mapping-step.blade.php | Renders Filament action modals and replaces old continue button with action rendering. |
| app-modules/ImportWizard/resources/views/livewire/partials/step-indicator.blade.php | Prevents step navigation when import has started. |
| app-modules/ImportWizard/resources/views/components/field-select.blade.php | Updates matcher UI tag to reflect AlwaysCreate behavior via isAlwaysCreate(). |
| @@ -73,19 +70,14 @@ public function batchValidate(EntityLink $link, MatchableField $matcher, array $ | |||
| $resolved = $this->resolver->batchResolve($link, $matcher, $toValidate); | |||
|
|
|||
| foreach ($toValidate as $value) { | |||
| $results[$value] = isset($resolved[$value]) | |||
| $results[$value] = $resolved[$value] !== null | |||
| ? null | |||
| : $this->buildErrorMessage($link, $matcher, $value); | |||
| } | |||
There was a problem hiding this comment.
batchValidate() trims values and then uses the trimmed strings as the keys in $results (via $toValidate). This breaks callers like ValidateColumnJob::updateValidationErrors() which match rows by the original raw value from json_extract(raw_data, ...)—any leading/trailing whitespace in the CSV will prevent validation errors / relationship matches from being written back. Also, trim($value) will throw a TypeError if $uniqueValues contains non-strings (e.g. numeric values from json_extract). Consider preserving the original value as the map key while normalizing only for lookup (e.g. keep an original => normalized map), and cast to string before trimming.
| $results = []; | ||
|
|
||
| foreach ($uniqueValues as $value) { | ||
| $entityId = (clone $baseQuery) | ||
| ->whereJsonContains('json_value', $value) | ||
| ->value('entity_id'); | ||
|
|
||
| if ($entityId !== null) { | ||
| $results[$value] = $entityId; | ||
| } |
There was a problem hiding this comment.
resolveViaJsonColumn() performs one query per unique value (whereJsonContains inside a loop). For imports with many unique values (e.g. multi-value email/domain custom fields), this becomes an N+1 query pattern and can severely slow down validation/match resolution. Consider rewriting this to a single set-based query (e.g. join against json_each / json_extract where supported, or build a single query with grouped OR conditions / raw SQL) so all matches are resolved in one round-trip.
| $results = []; | |
| foreach ($uniqueValues as $value) { | |
| $entityId = (clone $baseQuery) | |
| ->whereJsonContains('json_value', $value) | |
| ->value('entity_id'); | |
| if ($entityId !== null) { | |
| $results[$value] = $entityId; | |
| } | |
| if ($uniqueValues === []) { | |
| return []; | |
| } | |
| $query = (clone $baseQuery) | |
| ->where(function (Builder $query) use ($uniqueValues): void { | |
| foreach ($uniqueValues as $value) { | |
| $query->orWhereJsonContains('json_value', $value); | |
| } | |
| }); | |
| $rows = $query->get(['entity_id', 'json_value']); | |
| $results = []; | |
| $uniqueValuesLookup = array_flip($uniqueValues); | |
| foreach ($rows as $row) { | |
| $jsonValue = $row->json_value; | |
| if (is_string($jsonValue)) { | |
| $decoded = json_decode($jsonValue, true); | |
| $values = is_array($decoded) ? $decoded : []; | |
| } elseif (is_array($jsonValue)) { | |
| $values = $jsonValue; | |
| } else { | |
| $values = []; | |
| } | |
| foreach ($values as $storedValue) { | |
| if (! is_string($storedValue)) { | |
| continue; | |
| } | |
| if (! isset($uniqueValuesLookup[$storedValue])) { | |
| continue; | |
| } | |
| if (! array_key_exists($storedValue, $results)) { | |
| $results[$storedValue] = $row->entity_id; | |
| } | |
| } |
| @if($this->isCompleted) | ||
| <div class="flex items-center gap-3 px-4 py-3 mb-3 shrink-0 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl"> | ||
| <x-heroicon-o-check-circle class="h-5 w-5 text-success-600 dark:text-success-400 shrink-0" /> | ||
| <p class="text-sm font-medium text-gray-900 dark:text-white">Import Complete</p> | ||
|
|
||
| @if($this->results) | ||
| <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> | ||
| </div> | ||
| @endif |
There was a problem hiding this comment.
The UI shows an “Import Complete” success banner whenever $this->isCompleted is true, but PreviewStep::syncCompletionState() treats both Completed and Failed as completed. This means failed imports will be presented as successful completion. Consider rendering a separate failure state (different copy/icon/colors) when the store status is Failed, or track an isFailed flag separately.
| public function saved(Company $company): void | ||
| { | ||
| dispatch(new \App\Jobs\FetchFaviconForCompany($company))->afterCommit(); | ||
| $company->invalidateAiSummary(); | ||
| $this->dispatchFaviconFetchIfNeeded($company); | ||
| } | ||
|
|
||
| /** | ||
| * Handle the Company "saved" event. | ||
| * Invalidate AI summary when company data changes. | ||
| */ | ||
| public function saved(Company $company): void | ||
| private function dispatchFaviconFetchIfNeeded(Company $company): void | ||
| { | ||
| $company->invalidateAiSummary(); | ||
| $domainField = $company->customFields() | ||
| ->whereBelongsTo($company->team) | ||
| ->where('code', CompanyField::DOMAINS->value) | ||
| ->first(); | ||
|
|
||
| if ($domainField === null) { | ||
| return; | ||
| } | ||
|
|
||
| $company->load('customFieldValues'); | ||
|
|
||
| $domains = $company->getCustomFieldValue($domainField); | ||
| $firstDomain = is_array($domains) ? ($domains[0] ?? null) : $domains; | ||
|
|
||
| if (blank($firstDomain)) { | ||
| return; | ||
| } | ||
|
|
||
| dispatch(new FetchFaviconForCompany($company))->afterCommit(); | ||
| } |
There was a problem hiding this comment.
CompanyObserver::saved() dispatches FetchFaviconForCompany on every save as long as a domain exists, which can enqueue repeated favicon fetches for unrelated updates (and duplicates logic already present in ViewCompany::dispatchFaviconFetchIfNeeded() that only dispatches when domains actually changed). Consider gating dispatch on domain changes (compare old vs new domains), or skip dispatch when the current logo media already matches the current domain.
No description provided.