Skip to content

Feat/import wizard preview and match resolution#113

Merged
ManukMinasyan merged 6 commits into3.xfrom
feat/import-wizard-preview-and-match-resolution
Feb 12, 2026
Merged

Feat/import wizard preview and match resolution#113
ManukMinasyan merged 6 commits into3.xfrom
feat/import-wizard-preview-and-match-resolution

Conversation

@ManukMinasyan
Copy link
Copy Markdown
Contributor

No description provided.

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.
Copilot AI review requested due to automatic review settings February 11, 2026 23:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a MatchResolver service 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().

Comment on lines 54 to 76
@@ -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);
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +202
$results = [];

foreach ($uniqueValues as $value) {
$entityId = (clone $baseQuery)
->whereJsonContains('json_value', $value)
->value('entity_id');

if ($entityId !== null) {
$results[$value] = $entityId;
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +26
@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
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to 51
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();
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@ManukMinasyan ManukMinasyan merged commit 301e5ce into 3.x Feb 12, 2026
6 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants