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
- Contact Notes — Full CRUD with author avatars (profile image + initials fallback), author-only edit/delete permissions, inline confirmations
- Sorting — Server-side sorting by name/company/date with client-side reinforcement for company name accuracy
- Company Filter — Dedicated dropdown to filter contacts by company name (separate from text search)
- CSV Bulk Import — 3-step flow: file select, S3 upload via presigned URL, backend import with result summary
- External Sharing — Token-based expiring links with email delivery, revocation, access tracking, and a public viewer page
- Merge Contacts — Combine 2+ contacts into one, consolidating notes, types, clients, and shares
- 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
- Prev/Next Navigation — Arrow buttons and keyboard shortcuts (Left/Right) to navigate between contacts; footer shows "Contact X of Y"
- Multi-Value Emails & Phones — Additional email and phone entries with cycling labels (Mobile/Work/Home/Other), available on both view and create pages
- 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
- 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:
DeleteConfirmationDialogreplaceswindow.confirmwhen navigating away with pending edits - Edit page redirect:
[id]/page.tsxnow 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
CreateAdditionalEntryRowusing 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-xlcontainer - 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)
| Issue | Root Cause | Fix |
|---|---|---|
| Contact note update shows false error alert | handleUpdateNote in NotesSection only checked "talent" and "candidate" result keys, missing "contact" | Added entityType === "contact" ? result.data?.updateContactNote branch |
REMOVE_CONTACT_NOTE mutation field name mismatch | Mutation used deleteContactNote but backend resolver is removeContactNote | Changed to removeContactNote in mutation definition |
UPDATE_CONTACT_NOTE argument name mismatch | Mutation used input: but backend expects updateContactNoteInput: | Fixed argument name in mutation |
Browser alert() and window.confirm() used | Multiple places used native browser dialogs | Replaced with addTWToast for informational alerts and inline "Yes/No" confirmation buttons for destructive actions |
| Additional phone fields lack country code | AdditionalEntryRow and CreateAdditionalEntryRow used plain TWInput for phone entries | Conditionally render PhoneInput (with defaultCountry="SE") when field === "additionalPhones" |
Bug Fixes (v1)
| Issue | Root Cause | Fix |
|---|---|---|
createContactNote mutation fails | Backend expects argument name createContactNoteInput, not input | Changed createContactNote(input: $input) to createContactNote(createContactNoteInput: $input) |
authorId required error on note creation | CreateContactNoteInput requires authorId: String! | Added input.authorId = currentUserId in NotesSection for contact entity type |
contactNotes query unknown argument | Backend contactNotes resolver does not accept contactId argument | Removed standalone query; use ContactNote embedded in GET_CONTACT_BY_ID instead |
ContactNote.Author returns null crash | Backend Author resolver is non-nullable but returns null for some notes | Removed Author field from all ContactNote queries; resolve author info client-side via authorId + members data |
Import dialog crash on result.errors.length | Backend returns errors: null instead of errors: [] | Normalized with ?? [] on all result.errors references and on setter |
| Sort by company name A-Z incorrect order | Backend sorts on a different field than the displayed company name | Added client-side sort reinforcement when sortBy === "COMPANY_NAME" |
invitation.user has no id property | Member.invitation.user type does not include id field | Used member.id (which IS the user ID for real members) instead of invitation.user.id |
| Note author display missing profile image | useMembersData did not propagate profileImageUrl from COMPANY_MEMBERS query | Added profileImageUrl to GqlCompanyMember, Member.invitation.user, and toMemberFromUser |
Backend API Discrepancies (Remaining)
importContactsmay returnerrors: nullinstead oferrors: []. Frontend normalizes with?? [].- Mutation argument names differ from the standard
inputpattern:createContactNoteexpectscreateContactNoteInput,updateContactNoteexpectsinput(the variable is namedupdateContactNoteInput). COMPANY_NAMEsort uses a different field than what the frontend displays. Frontend applies client-side sort as workaround.
Resolved:
— Now queried with fallback to client-side resolutionContactNote.Authorreturns null— Backend now acceptsGET_CONTACT_NOTESstandalone query doesn't workcontactIdargument;Authorfield included withprofileImageUrl
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):
- Added
additionalEmails { value label }andadditionalPhones { value label }to all contact queries (GET_CONTACTS_BY_COMPANY,GET_CONTACT_BY_ID) and mutation return fields (CREATE_CONTACT,UPDATE_CONTACT) CreateContactCard.tsx: sendsadditionalEmailsandadditionalPhones(filtered to non-empty) increateContactmutation input[id]/view/page.tsx: loadsadditionalEmails/additionalPhonesfrom query data on page load; sends them inupdateContactmutation input- 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):
- Removed auto-resolution from
userCompanyRole.companyName,user.companies[0].companyName, etc. - Company field now loads only from
display.companyName(what was previously saved) - Company is an editable
EditableInfoItemin the Organization panel and a click-to-edit field in the hero section companyNameis sent in bothcreateContactandupdateContactmutation inputsCreateContactCard.tsx: added acompanyNametext input field
ContactNote.Author — ✅ INTEGRATED WITH FALLBACK
What was done (frontend):
- Re-enabled
Author { id firstName lastName profileImageUrl }onContactNoteinGET_CONTACT_BY_IDquery - Added
profileImageUrltoAuthorinGET_CONTACT_NOTESstandalone query - Notes section now uses
Authorfrom GraphQL when available, falls back to client-sideuserIdToNamemap viaauthorId
Auto-Save on Enter / Escape to Dismiss — ✅ IMPLEMENTED
What was done (frontend):
- Pressing Enter on any inline edit field dismisses the editor and triggers
handleSave()automatically - Pressing Escape dismisses the editor without saving
EditableInfoItemcomponent now hasonDismiss(Enter → save) andonCancel(Escape → dismiss) callbacksAdditionalEntryRowcomponent hasonSaveandonCancelcallbacks- 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
| File | Purpose |
|---|---|
graphql/operations/contacts/queries.ts | All contact GraphQL queries |
graphql/operations/contacts/mutations.ts | All contact GraphQL mutations |
app/company/connections/contacts/page.tsx | Main contacts list page (modernized toolbar + collapsible filters) |
app/company/connections/contacts/[id]/view/page.tsx | Contact detail page (merged view + edit, hero header, prev/next navigation) |
app/company/connections/contacts/[id]/page.tsx | Redirect to ./view (formerly the edit page) |
app/company/connections/contacts/_components/CreateContactCard.tsx | Create contact page (modern hero + icon-row design) |
app/company/connections/contacts/_components/useContactNavigation.ts | Prev/next navigation hook |
app/company/connections/contacts/_components/ImportContactsDialog.tsx | CSV import dialog |
app/company/connections/contacts/_components/ShareContactDialog.tsx | External sharing dialog (inline revoke confirmation) |
app/company/connections/contacts/_components/MergeContactsDialog.tsx | Merge contacts dialog |
app/(visitor)/shared-contact/[token]/page.tsx | Public shared contact page |
components/reusable/NotesSection.tsx | Reusable notes CRUD component (toast notifications, inline delete confirmation) |
app/(shared)/userprofile/members/hooks/useMemberData.ts | Members data hook (provides author info) |
graphql/apollo/apolloClient.ts | Apollo 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
isEditingboolean toggles between view and edit mode for the entire pageeditingField: string | nulltracks which individual field is currently being edited (click-to-edit pattern)formSnapshotstores 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:
- View mode: Shows icon + label + plain text value (or clickable link for email/phone)
- Click: Sets
editingFieldto the field's key - Edit mode: Renders the
editComponentprop (TWInput, PhoneInput, LocationAutocomplete, etc.) - Enter: Dismisses editor and auto-saves via
onDismisscallback - Escape: Dismisses editor without saving via
onCancelcallback
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. UsesPhoneInputfor 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_CONTACTmutation, on success setisEditing = false(stay on page) - Delete:
DeleteConfirmationDialog→REMOVE_CONTACTmutation → navigate to list
Unsaved Changes Guard
When the user has unsaved edits and tries to navigate:
- Prev/next arrows:
DeleteConfirmationDialogwith "Unsaved Changes" title asks to discard - Browser back/close:
beforeunloadevent 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/ArrowRightnavigate (only when NOT editing a field) - Footer: Shows "Contact 3 of 47" position indicator
Contact ID Change Handling
When navigating to a new contact:
isEditingresets tofalseerrorsclears- Form re-populates via the existing
useEffectwatchingcontactData
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
TWInputwhen 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!)— CallsremoveContactNote, 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:
- Primary:
ContactNote.Authorfrom the GraphQL query (includesid, firstName, lastName, profileImageUrl) - Fallback: If
Authoris null, falls back toauthorIdlookup in auserIdToNamemap built fromuseMembersData() NotesSectionrenders:- Profile image (via
TWImage+resolveAvatarUrl) whenprofileImageUrlis available - Name initials (e.g. "JD") in a blue circle as fallback
- Profile image (via
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 inputupdateInputKey:"contact"→"updateContactNoteInput"in the update inputresultDatacheck:"contact"→result.data?.updateContactNoteauthorId: automatically included for"contact"entity type usingcurrentUserId
UX Improvements (v2)
- Inline delete confirmation: Instead of
window.confirm, shows "Delete? Yes / No" buttons inline on the note card - Toast notifications:
addTWToastreplacesalert()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
SectionHeaderstyling - 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
| Value | Label |
|---|---|
CREATED_AT:DESC | Newest first (default) |
CREATED_AT:ASC | Oldest first |
FIRST_NAME:ASC | First name A-Z |
FIRST_NAME:DESC | First name Z-A |
LAST_NAME:ASC | Last name A-Z |
LAST_NAME:DESC | Last name Z-A |
COMPANY_NAME:ASC | Company 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
- File Selection — Validates
.csvextension and 5 MB max size - Generate Upload URL — Calls
GENERATE_UPLOAD_URLmutation (from@graphql/cv/mutations) - Upload to S3 — Direct
PUTto the presigneduploadUrlwithContent-Type: text/csv - Import — Calls
IMPORT_CONTACTSmutation 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 countsGET_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
- Primary Contact Selection — Radio buttons to choose which contact to keep
- Merge Summary — Shows what will happen: primary kept, secondaries merged and removed
- Consolidation Warning — Notes, contact types, client companies, and external shares are all consolidated
- 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)
| Element | Description |
|---|---|
| Search input | Text search with SearchIcon startContent |
| Filter toggle | FilterIcon button with badge showing active filter count |
| View mode toggle | Grid/List switcher |
| Column selector | Grid column count |
| Field visibility | Toggle card fields |
| Contact count | "X contacts" label |
| Import button | Opens ImportContactsDialog |
| Add button | Opens AddContactDialog |
Collapsible Filter Panel (toggled by filter button)
| Element | Description |
|---|---|
| Type filter | Multi-select: Client, Partner, Supplier, Prospect, Lead |
| Company filter | Single-select dropdown of unique company names |
| Sort dropdown | Single-select with sort field + order |
Selection Bar (appears when items selected)
| Element | Description |
|---|---|
| Selection count | "X selected" badge |
| Clear | Deselect all |
| Share | Opens ShareContactDialog for first selected |
| Merge | Opens MergeContactsDialog (requires 2+ selected) |
| Delete | Delete 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
| Route | Purpose |
|---|---|
/company/connections/contacts | Contacts list page |
/company/connections/contacts/create | Create new contact |
/company/connections/contacts/[id]/view | Contact detail (view + edit) |
/company/connections/contacts/[id] | Redirects to [id]/view |
/shared-contact/[token] | Public shared contact viewer |