Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions packages/client/src/components/business/configurations-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import {
relevantDataPicker,
type MakeBoolean,
} from '@/helpers/index.js';
import {
duplicateEntryError,
phraseConflictError,
} from '@/helpers/list-input-validation.js';
import { useGetSortCodes } from '@/hooks/use-get-sort-codes.js';
import { useGetTags } from '@/hooks/use-get-tags.js';
import { useGetTaxCategories } from '@/hooks/use-get-tax-categories.js';
Expand Down Expand Up @@ -640,15 +644,27 @@ function AutoMatchingConfigurationSubSection({ form }: SubSectionProps) {

const addPhrase = () => {
if (newPhrase.trim()) {
const currentPhrases = form.getValues('phrases');
const currentPhrases = form.getValues('phrases') ?? [];
const conflict = phraseConflictError(currentPhrases, newPhrase);
if (conflict) {
form.setError('phrases', { type: 'manual', message: conflict });
return;
}
form.clearErrors('phrases');
form.setValue('phrases', [...currentPhrases, newPhrase.trim()], { shouldDirty: true });
setNewPhrase('');
}
};

const addEmail = () => {
if (newEmail.trim()) {
const currentEmails = form.getValues('emails');
const currentEmails = form.getValues('emails') ?? [];
const conflict = duplicateEntryError(currentEmails, newEmail);
if (conflict) {
form.setError('emails', { type: 'manual', message: conflict });
return;
}
form.clearErrors('emails');
form.setValue('emails', [...currentEmails, newEmail.trim()], { shouldDirty: true });
setNewEmail('');
}
Expand Down Expand Up @@ -774,7 +790,13 @@ function GmailConfigurationSubSection({ form }: SubSectionProps) {

const addLink = () => {
if (newLink.trim()) {
const currentLinks = form.getValues('internalLinks');
const currentLinks = form.getValues('internalLinks') ?? [];
const conflict = duplicateEntryError(currentLinks, newLink);
if (conflict) {
form.setError('internalLinks', { type: 'manual', message: conflict });
return;
}
form.clearErrors('internalLinks');
form.setValue('internalLinks', [...currentLinks, newLink.trim()], { shouldDirty: true });
setNewLink('');
}
Expand Down
26 changes: 24 additions & 2 deletions packages/client/src/components/business/contact-info-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {
} from '@/gql/graphql.js';
import { getFragmentData, type FragmentType } from '@/gql/index.js';
import { dirtyFieldMarker, relevantDataPicker, type MakeBoolean } from '@/helpers/index.js';
import {
duplicateEntryError,
hasNoDuplicateEntries,
} from '@/helpers/list-input-validation.js';
import { useAllCountries } from '@/hooks/use-get-countries.js';
import { useUpdateBusiness } from '@/hooks/use-update-business.js';
import { useUpdateClient } from '@/hooks/use-update-client.js';
Expand Down Expand Up @@ -75,8 +79,14 @@ const contactInfoSchema = z.object({
localAddress: z.string().optional(),
phone: z.string().optional(),
website: z.url('Invalid URL').optional().or(z.literal('')),
generalContacts: z.array(z.email()).optional(),
billingEmails: z.array(z.email()).optional(),
generalContacts: z
.array(z.email())
.refine(hasNoDuplicateEntries, { message: 'Contacts must be unique' })
.optional(),
billingEmails: z
.array(z.email())
.refine(hasNoDuplicateEntries, { message: 'Billing emails must be unique' })
.optional(),
});

type ContactInfoFormValues = z.infer<typeof contactInfoSchema>;
Expand Down Expand Up @@ -184,6 +194,12 @@ export function ContactInfoSection({ data, refetchBusiness }: Props) {

const addGeneralContact = (currentContacts: string[]) => {
if (newContact.trim()) {
const conflict = duplicateEntryError(currentContacts, newContact);
if (conflict) {
form.setError('generalContacts', { type: 'manual', message: conflict });
return;
}
form.clearErrors('generalContacts');
form.setValue('generalContacts', [...currentContacts, newContact.trim()], {
shouldDirty: true,
});
Expand All @@ -201,6 +217,12 @@ export function ContactInfoSection({ data, refetchBusiness }: Props) {

const addBillingEmail = (currentEmails: string[]) => {
if (newBillingEmail.trim()) {
const conflict = duplicateEntryError(currentEmails, newBillingEmail);
if (conflict) {
form.setError('billingEmails', { type: 'manual', message: conflict });
return;
}
form.clearErrors('billingEmails');
form.setValue('billingEmails', [...currentEmails, newBillingEmail.trim()], {
shouldDirty: true,
});
Expand Down
19 changes: 16 additions & 3 deletions packages/client/src/components/clients/modify-client-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@ import {
} from '@/components/ui/select.js';
import { DocumentType } from '@/gql/graphql.js';
import { getDocumentNameFromType } from '@/helpers/index.js';
import {
duplicateEntryError,
hasNoDuplicateEntries,
} from '@/helpers/list-input-validation.js';
import { useInsertClient } from '@/hooks/use-insert-client.js';
import { Badge } from '../ui/badge';

const clientFormSchema = z.object({
businessId: z.uuid().optional(),
emails: z.array(z.email()).optional(),
emails: z
.array(z.email())
.refine(hasNoDuplicateEntries, { message: 'Emails must be unique' })
.optional(),
generatedDocumentType: z.enum(Object.values(DocumentType)),
});

Expand Down Expand Up @@ -79,8 +86,14 @@ export function ModifyClientDialog({ businessId, onDone, showTrigger = true }: P

const addEmail = () => {
if (newEmail.trim()) {
const currentEmails = form.getValues('emails');
form.setValue('emails', [...(currentEmails ?? []), newEmail.trim()], { shouldDirty: true });
const currentEmails = form.getValues('emails') ?? [];
const conflict = duplicateEntryError(currentEmails, newEmail);
if (conflict) {
form.setError('emails', { type: 'manual', message: conflict });
return;
}
form.clearErrors('emails');
form.setValue('emails', [...currentEmails, newEmail.trim()], { shouldDirty: true });
setNewEmail('');
}
};
Expand Down
42 changes: 37 additions & 5 deletions packages/client/src/components/common/modals/insert-business.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ import { Switch } from '@/components/ui/switch.js';
import { Textarea } from '@/components/ui/textarea.js';
import { DocumentType } from '@/gql/graphql.js';
import { pcn874RecordEnum } from '@/helpers/index.js';
import {
duplicateEntryError,
hasNoDuplicateEntries,
hasNoPhraseOverlap,
phraseConflictError,
} from '@/helpers/list-input-validation.js';
import { useAllCountries } from '@/hooks/use-get-countries.js';
import { useGetSortCodes } from '@/hooks/use-get-sort-codes.js';
import { useGetTags } from '@/hooks/use-get-tags.js';
Expand All @@ -57,7 +63,9 @@ const businessFormSchema = z
address: z.string().optional(),
city: z.string().optional(),
zipCode: z.string().optional(),
generalContacts: z.array(z.email()),
generalContacts: z
.array(z.email())
.refine(hasNoDuplicateEntries, { message: 'Contacts must be unique' }),
website: z.url().optional().or(z.literal('')),
phone: z.string().optional(),
taxCategory: z.string().optional(),
Expand All @@ -67,8 +75,14 @@ const businessFormSchema = z
defaultDescription: z.string().optional(),
defaultTags: z.array(z.string()),
isClient: z.boolean().default(false).optional(),
transactionPhrases: z.array(z.string()),
emailAddresses: z.array(z.email()),
transactionPhrases: z
.array(z.string())
.refine(hasNoPhraseOverlap, {
message: 'Phrases must be unique and not overlap one another',
}),
emailAddresses: z
.array(z.email())
.refine(hasNoDuplicateEntries, { message: 'Emails must be unique' }),
})
.refine(
data => {
Expand Down Expand Up @@ -230,6 +244,12 @@ function ContactInformationSection({ form }: SectionProps) {

const addGeneralContact = (currentContacts: string[]) => {
if (newContact.trim()) {
const conflict = duplicateEntryError(currentContacts, newContact);
if (conflict) {
form.setError('generalContacts', { type: 'manual', message: conflict });
return;
}
form.clearErrors('generalContacts');
setValue('generalContacts', [...currentContacts, newContact.trim()], {
shouldDirty: true,
});
Expand Down Expand Up @@ -646,7 +666,13 @@ function AutoMatchingSection({ form }: SectionProps) {

const addPhrase = () => {
if (newPhrase.trim()) {
const currentPhrases = getValues('transactionPhrases');
const currentPhrases = getValues('transactionPhrases') ?? [];
const conflict = phraseConflictError(currentPhrases, newPhrase);
if (conflict) {
form.setError('transactionPhrases', { type: 'manual', message: conflict });
return;
}
form.clearErrors('transactionPhrases');
form.setValue('transactionPhrases', [...currentPhrases, newPhrase.trim()], {
shouldDirty: true,
});
Expand All @@ -656,7 +682,13 @@ function AutoMatchingSection({ form }: SectionProps) {

const addEmail = () => {
if (newEmail.trim()) {
const currentEmails = getValues('emailAddresses');
const currentEmails = getValues('emailAddresses') ?? [];
const conflict = duplicateEntryError(currentEmails, newEmail);
if (conflict) {
form.setError('emailAddresses', { type: 'manual', message: conflict });
return;
}
form.clearErrors('emailAddresses');
setValue('emailAddresses', [...currentEmails, newEmail.trim()], { shouldDirty: true });
setNewEmail('');
}
Expand Down
52 changes: 52 additions & 0 deletions packages/client/src/helpers/list-input-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import {
duplicateEntryError,
hasNoDuplicateEntries,
hasNoPhraseOverlap,
phraseConflictError,
} from './list-input-validation.js';

describe('duplicateEntryError', () => {
it('returns null when the value is new', () => {
expect(duplicateEntryError(['a@x.com'], 'b@x.com')).toBeNull();
});

it('detects case- and whitespace-insensitive duplicates', () => {
expect(duplicateEntryError(['a@x.com'], ' A@X.com ')).toContain('already in the list');
});
});

describe('phraseConflictError', () => {
it('returns null for a distinct phrase', () => {
expect(phraseConflictError(['GOOGLE'], 'AMAZON')).toBeNull();
});

it('flags an exact duplicate', () => {
expect(phraseConflictError(['GOOGLE'], 'google')).toContain('already in the list');
});

it('flags a new phrase already covered by an existing broader one', () => {
expect(phraseConflictError(['GOOGL'], 'GOOGLE')).toContain('overlaps');
});

it('flags a new phrase that would shadow an existing more specific one', () => {
expect(phraseConflictError(['GOOGLE'], 'GOOGL')).toContain('already covered');
});
Comment on lines +28 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Update the test expectations to match the corrected phrase conflict validation logic.

Suggested change
it('flags a new phrase already covered by an existing broader one', () => {
expect(phraseConflictError(['GOOGL'], 'GOOGLE')).toContain('overlaps');
});
it('flags a new phrase that would shadow an existing more specific one', () => {
expect(phraseConflictError(['GOOGLE'], 'GOOGL')).toContain('already covered');
});
it('flags a new phrase already covered by an existing broader one', () => {
expect(phraseConflictError(['GOOGL'], 'GOOGLE')).toContain('already covered');
});
it('flags a new phrase that would shadow an existing more specific one', () => {
expect(phraseConflictError(['GOOGLE'], 'GOOGL')).toContain('broader than');
});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tied to the phraseConflictError / cleanPhrases threads above: the behavior is intentionally keep-the-more-specific-phrase (the shorter substring is the weaker phrase under the whole-word matcher), so these expectations are correct as written. Leaving as-is.


Generated by Claude Code

});

describe('hasNoDuplicateEntries', () => {
it('is true for unique entries and false for case-insensitive duplicates', () => {
expect(hasNoDuplicateEntries(['a@x.com', 'b@x.com'])).toBe(true);
expect(hasNoDuplicateEntries(['a@x.com', 'A@X.com'])).toBe(false);
});
});

describe('hasNoPhraseOverlap', () => {
it('is true for non-overlapping phrases', () => {
expect(hasNoPhraseOverlap(['GOOGLE', 'AMAZON'])).toBe(true);
});

it('is false when one phrase is a substring of another', () => {
expect(hasNoPhraseOverlap(['GOOGL', 'GOOGLE'])).toBe(false);
});
});
69 changes: 69 additions & 0 deletions packages/client/src/helpers/list-input-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Client-side guards for list-of-string form inputs (emails, auto-match
* phrases, …). Mirrors the server-side rules in
* `financial-entities/helpers/list-input-validation.helper.ts` so the user gets
* immediate feedback before the mutation is sent.
*
* All comparisons are done on a normalized (trimmed, lower-cased) form.
*/

export function normalizeEntry(value: string): string {
return value.trim().toLowerCase();
}

/**
* Message to show when `value` would duplicate an existing entry
* (case-insensitive), or `null` when it is safe to add.
*/
export function duplicateEntryError(list: readonly string[], value: string): string | null {
const candidate = normalizeEntry(value);
return list.some(item => normalizeEntry(item) === candidate)
? `"${value.trim()}" is already in the list`
: null;
}

/**
* Message to show when `value` conflicts with an existing phrase — either a
* duplicate or a substring overlap (e.g. "GOOGL" vs "GOOGLE") — or `null` when
* it is safe to add.
*/
export function phraseConflictError(list: readonly string[], value: string): string | null {
const candidate = normalizeEntry(value);
if (candidate === '') {
return null;
}
for (const item of list) {
const existing = normalizeEntry(item);
if (existing === '') {
continue;
}
if (existing === candidate) {
return `"${value.trim()}" is already in the list`;
}
if (existing.includes(candidate)) {
return `"${value.trim()}" is already covered by "${item}"`;
}
if (candidate.includes(existing)) {
return `"${value.trim()}" overlaps with "${item}" — remove the broader phrase first`;
}
}
return null;
}
Comment on lines +30 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The substring overlap logic and error messages are reversed. If the existing phrase is 'AMAZON UK' and the user tries to add 'AMAZON', 'existing.includes(candidate)' is true. The current code tells the user that 'AMAZON' is already covered by 'AMAZON UK', which is false (a transaction with 'AMAZON' won't match 'AMAZON UK'). Conversely, if the existing phrase is 'AMAZON' and they try to add 'AMAZON UK', 'candidate.includes(existing)' is true, and they are told to remove the broader phrase first, which is also incorrect since 'AMAZON UK' is already covered by 'AMAZON'. Swapping the conditions and messages fixes this.

export function phraseConflictError(list: readonly string[], value: string): string | null {
  const candidate = normalizeEntry(value);
  if (candidate === '') {
    return null;
  }
  for (const item of list) {
    const existing = normalizeEntry(item);
    if (existing === '') {
      continue;
    }
    if (existing === candidate) {
      return `"${value.trim()}" is already in the list`;
    }
    if (existing.includes(candidate)) {
      return `"${value.trim()}" is broader than "${item}" — remove the more specific phrase first`;
    }
    if (candidate.includes(existing)) {
      return `"${value.trim()}" is already covered by "${item}"`;
    }
  }
  return null;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Same reasoning as the cleanPhrases thread — these messages are intentionally paired with the server's keep-the-more-specific behavior. Swapping the wording would make the client explanation contradict what the server actually keeps (it would tell the user the longer phrase is "already covered" and to remove it, while the server keeps the longer one). Given the whole-word matcher, the shorter substring is the weaker phrase, so the current pairing is the correct one. Leaving as-is.


Generated by Claude Code


/** True when every entry in the list is unique (case-insensitive). */
export function hasNoDuplicateEntries(list: readonly string[]): boolean {
return new Set(list.map(normalizeEntry)).size === list.length;
}

/** True when no phrase is a duplicate or substring of another. */
export function hasNoPhraseOverlap(list: readonly string[]): boolean {
const normalized = list.map(normalizeEntry).filter(entry => entry !== '');
for (let i = 0; i < normalized.length; i++) {
for (let j = i + 1; j < normalized.length; j++) {
if (normalized[i].includes(normalized[j]) || normalized[j].includes(normalized[i])) {
return false;
}
}
}
return true;
}
Loading
Loading