Skip to main content

Contacts Feature Integration

Frontend integration for the Contacts module: Notes, Sorting, CSV Import, External Sharing, Merge Contacts, Merged View/Edit Page, Prev/Next Navigation, Multi-Value Fields, and Modern UI Redesign.


Release Notes

Features Implemented

  1. Contact Notes — Full CRUD with author avatars (profile image + initials fallback), author-only edit/delete permissions, inline confirmations
  2. Sorting — Server-side sorting by name/company/date with client-side reinforcement for company name accuracy
  3. Company Filter — Dedicated dropdown to filter contacts by company name (separate from text search)
  4. CSV Bulk Import — 3-step flow: file select, S3 upload via presigned URL, backend import with result summary
  5. External Sharing — Token-based expiring links with email delivery, revocation, access tracking, and a public viewer page
  6. Merge Contacts — Combine 2+ contacts into one, consolidating notes, types, clients, and shares
  7. Merged View/Edit Page — Single inline-editable page replaces the separate edit route. Click-to-edit fields with per-field activation, responsible-user permission model, unsaved changes guard
  8. Prev/Next Navigation — Arrow buttons and keyboard shortcuts (Left/Right) to navigate between contacts; footer shows "Contact X of Y"
  9. Multi-Value Emails & Phones — Additional email and phone entries with cycling labels (Mobile/Work/Home/Other), available on both view and create pages
  10. Modern UI Redesign — Hero header with gradient background, icon-row grid layout, section headers with accent dot + gradient line, consistent across view, create, and list pages
  11. Collapsible Filter Panel — Filter icon with active filter count badge; Type, Company, and Sort consolidated into a toggleable panel on the list page

Design Changes (v2)

Contact Detail Page ([id]/view/page.tsx)

  • Hero header: Full-width gradient background with large avatar, name (editable inline), title, contact type chips, and action buttons (Share, Delete, Edit)
  • Click-to-edit fields: Each info row activates on click — renders edit widget inline (TWInput, PhoneInput, LocationAutocomplete, etc.) with save-on-blur/Enter behavior
  • Icon-row grid: Each field row has a colored icon box (32x32), uppercase label, and value — matching the design across all contact pages
  • Section headers: Accent dot + gradient line separator between sections (Contact Information, Company & Role, Notes)
  • Back button: Icon-only arrow on the left side next to "CONTACT PROFILE" label
  • Prev/next arrows: Navigation buttons with counter on the right side of the hero area
  • Unsaved changes guard: DeleteConfirmationDialog replaces window.confirm when navigating away with pending edits
  • Edit page redirect: [id]/page.tsx now redirects to [id]/view (single page handles both modes)

Create Contact Page (_components/CreateContactCard.tsx)

  • Toolbar: Back arrow + "New Contact" heading + status chip
  • Gradient hero: Email search section with gradient background matching view page
  • Hero profile section: Avatar placeholder, name fields, contact type selector
  • FormInfoRow layout: Same icon-row grid as view page (icon box + label + edit widget)
  • SectionHeader component: Accent dot + gradient line, consistent with view page
  • Sticky save bar: Fixed bottom bar with cancel/save buttons
  • Multi-value entries: "+Add email" / "+Add phone" buttons with CreateAdditionalEntryRow using PhoneInput for phones

Contacts List Page (page.tsx)

  • Primary toolbar: Single row with search (SearchIcon startContent), filter toggle (FilterIcon + badge count), view mode, column selector, field visibility, contact count, import, add
  • Collapsible filter panel: Revealed by filter toggle; contains Type, Company, Sort dropdowns in a bg-gray-50/80 rounded-xl container
  • Selection bar: Appears when items are selected; primary-tinted background with share/merge/delete actions
  • Empty states: Gradient circle icon backgrounds with descriptive text

Bug Fixes (v2)

IssueRoot CauseFix
Contact note update shows false error alerthandleUpdateNote in NotesSection only checked "talent" and "candidate" result keys, missing "contact"Added entityType === "contact" ? result.data?.updateContactNote branch
REMOVE_CONTACT_NOTE mutation field name mismatchMutation used deleteContactNote but backend resolver is removeContactNoteChanged to removeContactNote in mutation definition
UPDATE_CONTACT_NOTE argument name mismatchMutation used input: but backend expects updateContactNoteInput:Fixed argument name in mutation
Browser alert() and window.confirm() usedMultiple places used native browser dialogsReplaced with addTWToast for informational alerts and inline "Yes/No" confirmation buttons for destructive actions
Additional phone fields lack country codeAdditionalEntryRow and CreateAdditionalEntryRow used plain TWInput for phone entriesConditionally render PhoneInput (with defaultCountry="SE") when field === "additionalPhones"

Bug Fixes (v1)

IssueRoot CauseFix
createContactNote mutation failsBackend expects argument name createContactNoteInput, not inputChanged createContactNote(input: $input) to createContactNote(createContactNoteInput: $input)
authorId required error on note creationCreateContactNoteInput requires authorId: String!Added input.authorId = currentUserId in NotesSection for contact entity type
contactNotes query unknown argumentBackend contactNotes resolver does not accept contactId argumentRemoved standalone query; use ContactNote embedded in GET_CONTACT_BY_ID instead
ContactNote.Author returns null crashBackend Author resolver is non-nullable but returns null for some notesRemoved Author field from all ContactNote queries; resolve author info client-side via authorId + members data
Import dialog crash on result.errors.lengthBackend returns errors: null instead of errors: []Normalized with ?? [] on all result.errors references and on setter
Sort by company name A-Z incorrect orderBackend sorts on a different field than the displayed company nameAdded client-side sort reinforcement when sortBy === "COMPANY_NAME"
invitation.user has no id propertyMember.invitation.user type does not include id fieldUsed member.id (which IS the user ID for real members) instead of invitation.user.id
Note author display missing profile imageuseMembersData did not propagate profileImageUrl from COMPANY_MEMBERS queryAdded profileImageUrl to GqlCompanyMember, Member.invitation.user, and toMemberFromUser

Backend API Discrepancies (Remaining)

  • importContacts may return errors: null instead of errors: []. Frontend normalizes with ?? [].
  • Mutation argument names differ from the standard input pattern: createContactNote expects createContactNoteInput, updateContactNote expects input (the variable is named updateContactNoteInput).
  • COMPANY_NAME sort uses a different field than what the frontend displays. Frontend applies client-side sort as workaround.

Resolved:

  • ContactNote.Author returns null — Now queried with fallback to client-side resolution
  • GET_CONTACT_NOTES standalone query doesn't work — Backend now accepts contactId argument; Author field included with profileImageUrl

Backend Integration (Completed 2026-03-23)

Multi-Value Emails & Phones — ✅ FULLY INTEGRATED

Backend and frontend are fully wired. Additional emails and phones persist across create/update/load.

What was done (frontend):

  1. Added additionalEmails { value label } and additionalPhones { value label } to all contact queries (GET_CONTACTS_BY_COMPANY, GET_CONTACT_BY_ID) and mutation return fields (CREATE_CONTACT, UPDATE_CONTACT)
  2. CreateContactCard.tsx: sends additionalEmails and additionalPhones (filtered to non-empty) in createContact mutation input
  3. [id]/view/page.tsx: loads additionalEmails / additionalPhones from query data on page load; sends them in updateContact mutation input
  4. Removing an additional entry triggers auto-save immediately

Company Name as Free Text — ✅ FULLY INTEGRATED

Company is now a user-typed free text field, not auto-populated from user.companies or userCompanyRole.

What was done (frontend):

  1. Removed auto-resolution from userCompanyRole.companyName, user.companies[0].companyName, etc.
  2. Company field now loads only from display.companyName (what was previously saved)
  3. Company is an editable EditableInfoItem in the Organization panel and a click-to-edit field in the hero section
  4. companyName is sent in both createContact and updateContact mutation inputs
  5. CreateContactCard.tsx: added a companyName text input field

ContactNote.Author — ✅ INTEGRATED WITH FALLBACK

What was done (frontend):

  1. Re-enabled Author { id firstName lastName profileImageUrl } on ContactNote in GET_CONTACT_BY_ID query
  2. Added profileImageUrl to Author in GET_CONTACT_NOTES standalone query
  3. Notes section now uses Author from GraphQL when available, falls back to client-side userIdToName map via authorId

Auto-Save on Enter / Escape to Dismiss — ✅ IMPLEMENTED

What was done (frontend):

  1. Pressing Enter on any inline edit field dismisses the editor and triggers handleSave() automatically
  2. Pressing Escape dismisses the editor without saving
  3. EditableInfoItem component now has onDismiss (Enter → save) and onCancel (Escape → dismiss) callbacks
  4. AdditionalEntryRow component has onSave and onCancel callbacks
  5. Removing an additional email/phone also triggers auto-save

Delete Icon Always Visible — ✅ IMPLEMENTED

Remove button on additional email/phone entries is now always visible (not hidden behind hover).

Back Button as Icon — ✅ IMPLEMENTED

Back button moved from a text button on the right to an icon-only arrow on the left, next to the "CONTACT PROFILE" label.


File Map

FilePurpose
graphql/operations/contacts/queries.tsAll contact GraphQL queries
graphql/operations/contacts/mutations.tsAll contact GraphQL mutations
app/company/connections/contacts/page.tsxMain contacts list page (modernized toolbar + collapsible filters)
app/company/connections/contacts/[id]/view/page.tsxContact detail page (merged view + edit, hero header, prev/next navigation)
app/company/connections/contacts/[id]/page.tsxRedirect to ./view (formerly the edit page)
app/company/connections/contacts/_components/CreateContactCard.tsxCreate contact page (modern hero + icon-row design)
app/company/connections/contacts/_components/useContactNavigation.tsPrev/next navigation hook
app/company/connections/contacts/_components/ImportContactsDialog.tsxCSV import dialog
app/company/connections/contacts/_components/ShareContactDialog.tsxExternal sharing dialog (inline revoke confirmation)
app/company/connections/contacts/_components/MergeContactsDialog.tsxMerge contacts dialog
app/(visitor)/shared-contact/[token]/page.tsxPublic shared contact page
components/reusable/NotesSection.tsxReusable notes CRUD component (toast notifications, inline delete confirmation)
app/(shared)/userprofile/members/hooks/useMemberData.tsMembers data hook (provides author info)
graphql/apollo/apolloClient.tsApollo client (public route config)

6. Merged View/Edit Page

The contact view and edit pages are merged into a single inline-editable page at [id]/view/page.tsx. The former edit page ([id]/page.tsx) redirects to [id]/view.

Architecture

  • isEditing boolean toggles between view and edit mode for the entire page
  • editingField: string | null tracks which individual field is currently being edited (click-to-edit pattern)
  • formSnapshot stores the form state at the time edit mode was entered, used to revert on cancel

Permission Model

const canEdit = user?.id === contactData?.contact?.responsibleUser?.id
  • Only the responsible user can edit — other users see read-only view with no Edit button
  • Email is always read-only (set at creation, cannot be changed)

Click-to-Edit Pattern

Each info row uses EditableInfoItem (from ProfileViewPrimitives) which:

  1. View mode: Shows icon + label + plain text value (or clickable link for email/phone)
  2. Click: Sets editingField to the field's key
  3. Edit mode: Renders the editComponent prop (TWInput, PhoneInput, LocationAutocomplete, etc.)
  4. Enter: Dismisses editor and auto-saves via onDismiss callback
  5. Escape: Dismisses editor without saving via onCancel callback

Key Components

  • EditableInfoItem — Icon-row with click-to-edit behavior, Enter to save, Escape to cancel (components/reusable/ProfileViewPrimitives.tsx)
  • AdditionalEntryRow — Additional email/phone entry with click-to-edit, label cycling, always-visible remove button. Uses PhoneInput for phone entries. Enter saves, Escape cancels, remove auto-saves.
  • SectionHeader — Accent dot + gradient line section divider

Edit Mode Behavior

  • Enter edit: Snapshot form state, set isEditing = true
  • Cancel: Restore snapshot, set isEditing = false
  • Save: Run UPDATE_CONTACT mutation, on success set isEditing = false (stay on page)
  • Delete: DeleteConfirmationDialogREMOVE_CONTACT mutation → navigate to list

Unsaved Changes Guard

When the user has unsaved edits and tries to navigate:

  • Prev/next arrows: DeleteConfirmationDialog with "Unsaved Changes" title asks to discard
  • Browser back/close: beforeunload event prevents accidental loss

7. Prev/Next Navigation

Hook: useContactNavigation

File: app/company/connections/contacts/_components/useContactNavigation.ts

export function useContactNavigation(currentContactId: string) {
// Uses GET_CONTACTS_BY_COMPANY with cache-first policy
// Returns: { prevId, nextId, currentIndex, totalCount, goToPrev, goToNext }
}
  • fetchPolicy: "cache-first" — reuses the list page's cached data, falls back to network
  • Default sort: CREATED_AT DESC

UI

  • Arrow buttons: Fixed circular ghost buttons on left/right edges of the hero header
  • Keyboard: ArrowLeft / ArrowRight navigate (only when NOT editing a field)
  • Footer: Shows "Contact 3 of 47" position indicator

Contact ID Change Handling

When navigating to a new contact:

  • isEditing resets to false
  • errors clears
  • Form re-populates via the existing useEffect watching contactData

8. Multi-Value Emails & Phones

Data Model

type AdditionalEntry = {
value: string
label: string // "Mobile" | "Work" | "Home" | "Other" | "Personal"
}

Label Options

  • Phone labels: ["Mobile", "Work", "Home", "Other"]
  • Email labels: ["Work", "Personal", "Other"]
  • Labels cycle on click (click the label chip to advance to the next label)

View Page (AdditionalEntryRow)

  • Click-to-edit behavior matching other fields
  • Phone entries render PhoneInput (with country code support, defaultCountry="SE") when active
  • Email entries render plain TWInput when active
  • Remove button (×) visible on hover
  • Indented under the primary entry (pl-14)

Create Page (CreateAdditionalEntryRow)

  • Always editable (no click-to-edit, since it's a create form)
  • Same PhoneInput/TWInput split based on field type
  • "+Add email" / "+Add phone" buttons below existing entries

Form State

// In both view and create pages:
interface FormState {
// ... other fields ...
additionalEmails: AdditionalEntry[]
additionalPhones: AdditionalEntry[]
}

Handlers

addAdditionalEntry(field: "additionalEmails" | "additionalPhones")
removeAdditionalEntry(field, index)
updateAdditionalEntryValue(field, index, value)
cycleEntryLabel(field, index)

1. Contact Notes

Full CRUD for notes on contacts, using the shared NotesSection component.

GraphQL Operations

Mutations:

  • CREATE_CONTACT_NOTE(input: CreateContactNoteInput!) — Argument name: createContactNoteInput. Input: { contactId, authorId, title, content }
  • UPDATE_CONTACT_NOTE(updateContactNoteInput: UpdateContactNoteInput!) — Input: { id, title, content }
  • REMOVE_CONTACT_NOTE(id: ID!) — Calls removeContactNote, returns { success }

Data Source: Notes come from ContactNote embedded in GET_CONTACT_BY_ID (fields: id, contactId, authorId, title, content, createdAt, updatedAt, Author { id, firstName, lastName, profileImageUrl }).

Author Resolution

Author details are resolved from the GraphQL Author field with a client-side fallback:

  1. Primary: ContactNote.Author from the GraphQL query (includes id, firstName, lastName, profileImageUrl)
  2. Fallback: If Author is null, falls back to authorId lookup in a userIdToName map built from useMembersData()
  3. NotesSection renders:
    • Profile image (via TWImage + resolveAvatarUrl) when profileImageUrl is available
    • Name initials (e.g. "JD") in a blue circle as fallback

NotesSection Integration

The reusable NotesSection component supports "contact" as an entity type:

export type NoteEntityType = "candidate" | "talent" | "contact"

Entity-specific field mappings:

  • entityIdField: "contact""contactId" in the create input
  • updateInputKey: "contact""updateContactNoteInput" in the update input
  • resultData check: "contact"result.data?.updateContactNote
  • authorId: automatically included for "contact" entity type using currentUserId

UX Improvements (v2)

  • Inline delete confirmation: Instead of window.confirm, shows "Delete? Yes / No" buttons inline on the note card
  • Toast notifications: addTWToast replaces alert() for empty note warning and update failure messages
  • Timeline layout: Notes displayed in a timeline with accent dots and connecting line, with hover effects

View Page Integration

<NotesSection
notes={(contactData?.contact?.ContactNote ?? []).map((n) => {
const authorFromQuery = n.Author
const authorFallback = n.authorId ? userIdToName.get(n.authorId) : undefined
return {
id: n.id, title: n.title, content: n.content,
createdAt: n.createdAt, updatedAt: n.updatedAt,
author: authorFromQuery
? { id: authorFromQuery.id, firstName: authorFromQuery.firstName, lastName: authorFromQuery.lastName, profileImageUrl: authorFromQuery.profileImageUrl }
: n.authorId
? { id: n.authorId, firstName: authorFallback?.firstName, lastName: authorFallback?.lastName, profileImageUrl: authorFallback?.profileImageUrl }
: undefined,
}
})}
entityId={contactId}
entityType="contact"
createNoteMutation={CREATE_CONTACT_NOTE}
removeNoteMutation={REMOVE_CONTACT_NOTE}
updateNoteMutation={UPDATE_CONTACT_NOTE}
onNotesUpdated={async () => { await refetchContact() }}
/>

Behavior

  • Notes displayed in a section with SectionHeader styling
  • Author-only edit/delete permissions
  • Refetches contact data (including notes) after any CRUD operation

2. Sorting

Server-side sorting with client-side reinforcement for company name.

Query Parameter

query contactsByCompany($companyId: ID!, $query: ContactsQueryInput) {
contactsByCompany(companyId: $companyId, query: $query) { ... }
}

Important: The type is ContactsQueryInput (plural), not ContactQueryInput.

Sort Options

ValueLabel
CREATED_AT:DESCNewest first (default)
CREATED_AT:ASCOldest first
FIRST_NAME:ASCFirst name A-Z
FIRST_NAME:DESCFirst name Z-A
LAST_NAME:ASCLast name A-Z
LAST_NAME:DESCLast name Z-A
COMPANY_NAME:ASCCompany A-Z

Client-Side Reinforcement

When sortBy === "COMPANY_NAME", a client-side sort is applied after mapping to ensure the displayed company name (which may differ from what the backend sorts on) is in the correct order.

Company Filter Dropdown

A dedicated company filter dropdown (inside the collapsible filter panel) extracts unique company names from loaded contacts and allows filtering to a specific company.

State: companyFilter (string, empty = all companies)


3. CSV Bulk Import

Three-step flow: select CSV file, upload to S3, trigger backend import.

Component: ImportContactsDialog

Location: app/company/connections/contacts/_components/ImportContactsDialog.tsx

Upload Flow

  1. File Selection — Validates .csv extension and 5 MB max size
  2. Generate Upload URL — Calls GENERATE_UPLOAD_URL mutation (from @graphql/cv/mutations)
  3. Upload to S3 — Direct PUT to the presigned uploadUrl with Content-Type: text/csv
  4. Import — Calls IMPORT_CONTACTS mutation with { objectPath, companyId, responsibleUserId }

Import Mutation

mutation ImportContacts($input: ImportContactsInput!) {
importContacts(input: $input) {
totalRows
created
skipped
failed
errors { row email reason }
}
}

Bug fix: Backend may return errors: null instead of errors: []. The component normalizes this with ?? [].


4. External Sharing

Share contacts externally via time-limited, token-based links.

Component: ShareContactDialog

Location: app/company/connections/contacts/_components/ShareContactDialog.tsx

GraphQL Operations

Mutations:

  • CREATE_CONTACT_EXTERNAL_SHARE(input: { contactId, email, expirationHours }) — Returns { id, token, email, expiresAt, isActive }
  • REVOKE_CONTACT_EXTERNAL_SHARE(id: ID!) — Returns { success, message }

Queries:

  • GET_CONTACT_EXTERNAL_SHARES(contactId: ID!) — Returns active/inactive shares with access counts
  • GET_SHARED_CONTACT(token: String!) — Public query for viewing shared contact

Dialog Features

  • Email input with validation + expiration selector (24h, 48h, 7 days, 30 days)
  • Active shares list showing email, expiration date, and access count
  • Inline revoke confirmation: "Revoke? Yes / No" buttons (replaces window.confirm)
  • Toast notifications for success/error

Public Shared Contact Page

Route: /shared-contact/[token] Location: app/(visitor)/shared-contact/[token]/page.tsx

Public page (no auth required) that displays shared contact information using the GET_SHARED_CONTACT query.

Auth Exemption: /shared-contact/ is added to PUBLIC_ROUTES in graphql/apollo/apolloClient.ts and to both isPublicRoute checks.


5. Merge Contacts

Combine 2+ contacts into one, consolidating all related data.

Component: MergeContactsDialog

Location: app/company/connections/contacts/_components/MergeContactsDialog.tsx

GraphQL Mutation

mutation MergeContacts($input: MergeContactsInput!) {
mergeContacts(input: $input) {
id
ContactNote { id }
contactTypes { id name }
clients { id companyName }
}
}

Input: { primaryContactId, secondaryContactIds }

Dialog Features

  1. Primary Contact Selection — Radio buttons to choose which contact to keep
  2. Merge Summary — Shows what will happen: primary kept, secondaries merged and removed
  3. Consolidation Warning — Notes, contact types, client companies, and external shares are all consolidated
  4. Success State — Shows confirmation with count of duplicates removed

Contacts List Page Toolbar Layout

The toolbar in page.tsx uses a modern single-row design with a collapsible filter panel:

Primary Toolbar (always visible)

ElementDescription
Search inputText search with SearchIcon startContent
Filter toggleFilterIcon button with badge showing active filter count
View mode toggleGrid/List switcher
Column selectorGrid column count
Field visibilityToggle card fields
Contact count"X contacts" label
Import buttonOpens ImportContactsDialog
Add buttonOpens AddContactDialog

Collapsible Filter Panel (toggled by filter button)

ElementDescription
Type filterMulti-select: Client, Partner, Supplier, Prospect, Lead
Company filterSingle-select dropdown of unique company names
Sort dropdownSingle-select with sort field + order

Selection Bar (appears when items selected)

ElementDescription
Selection count"X selected" badge
ClearDeselect all
ShareOpens ShareContactDialog for first selected
MergeOpens MergeContactsDialog (requires 2+ selected)
DeleteDelete selected contacts

Data Flow: Contact Company Name

The company name is a free text field typed by the contact creator. It is NOT auto-populated from the user's actual company.

Load: display.companyName ?? display.company ?? "" Save: Sent as companyName in createContact and updateContact mutation inputs

The old auto-resolution chain (userCompanyRole.companyName, user.companies[0].companyName) was removed in the 2026-03-23 integration.


Data Flow: Note Author Info

Author details are resolved from GraphQL with a client-side fallback:

ContactNote.Author { id, firstName, lastName, profileImageUrl }    ← PRIMARY (from GraphQL)
↓ (if null)
ContactNote.authorId → userIdToName.get(authorId) ← FALLBACK (from useMembersData)

NotesSection renders: profile image (TWImage) or initials fallback (blue circle with letters)

Fallback map: member.id is the user's actual ID for real members. Invite-only members are prefixed with invite: and excluded from the author map.


Route Structure

RoutePurpose
/company/connections/contactsContacts list page
/company/connections/contacts/createCreate new contact
/company/connections/contacts/[id]/viewContact detail (view + edit)
/company/connections/contacts/[id]Redirects to [id]/view
/shared-contact/[token]Public shared contact viewer