-
-
Notifications
You must be signed in to change notification settings - Fork 4
Add candidate search dropdown #384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1822,7 +1822,7 @@ function App() { | |
| async function addGigApplication(gigId: string, crmProfile: string) { | ||
| const normalizedProfile = crmProfile.trim() | ||
| if (!normalizedProfile) { | ||
| showToast("Paste a CRM Contact profile first", "warning") | ||
| showToast("Choose a candidate first", "warning") | ||
| return false | ||
| } | ||
| setBusy(`gig:${gigId}:addCandidate`, true) | ||
|
|
@@ -2798,6 +2798,7 @@ function App() { | |
| limit={gigLimit} | ||
| staleDays={staleRecruitingDays} | ||
| canWrite={can("gigs:write")} | ||
| canSearchCandidates={can("people:read")} | ||
| canManageLeads={can("people:read")} | ||
| canIncludeHistorical={can("people:read")} | ||
| crmContactUrl={crmContactUrl} | ||
|
|
@@ -5172,6 +5173,7 @@ function GigsView(props: { | |
| limit: number | ||
| staleDays: number | ||
| canWrite: boolean | ||
| canSearchCandidates: boolean | ||
| canManageLeads: boolean | ||
| canIncludeHistorical: boolean | ||
| crmContactUrl: (contactId?: string) => string | ||
|
|
@@ -5449,9 +5451,11 @@ function GigsView(props: { | |
| <> | ||
| {filterBar} | ||
| <GigDetailPage | ||
| key={props.selectedGig.id} | ||
| gig={props.selectedGig} | ||
| loading={props.loading} | ||
| canWrite={props.canWrite} | ||
| canSearchCandidates={props.canSearchCandidates} | ||
| crmContactUrl={props.crmContactUrl} | ||
| crmAttachmentUrl={props.crmAttachmentUrl} | ||
| staleDays={props.staleDays} | ||
|
|
@@ -6011,10 +6015,15 @@ function candidateDisplayName(application: GigApplication) { | |
| ) | ||
| } | ||
|
|
||
| function personSearchLabel(person: Person) { | ||
| return person.name || person.email_508 || person.email || person.crm_contact_id || "CRM person" | ||
| } | ||
|
|
||
| function GigDetailPage({ | ||
| gig, | ||
| loading, | ||
| canWrite, | ||
| canSearchCandidates, | ||
| crmContactUrl, | ||
| crmAttachmentUrl, | ||
| staleDays, | ||
|
|
@@ -6026,6 +6035,7 @@ function GigDetailPage({ | |
| gig: Gig | ||
| loading: Record<string, boolean> | ||
| canWrite: boolean | ||
| canSearchCandidates: boolean | ||
| crmContactUrl: (contactId?: string) => string | ||
| crmAttachmentUrl: (attachmentId?: string) => string | ||
| staleDays: number | ||
|
|
@@ -6034,16 +6044,76 @@ function GigDetailPage({ | |
| onAddApplication: (gigId: string, crmProfile: string) => Promise<boolean> | ||
| onUpdateApplicationStatus: (gigId: string, applicationId: string, status: string) => void | ||
| }) { | ||
| const [candidateQuery, setCandidateQuery] = useState("") | ||
| const [candidateMatches, setCandidateMatches] = useState<Person[]>([]) | ||
| const [selectedCandidate, setSelectedCandidate] = useState<Person | null>(null) | ||
| const [candidateSearchError, setCandidateSearchError] = useState("") | ||
| const [crmProfile, setCrmProfile] = useState("") | ||
| const applications = Array.isArray(gig.applications) ? gig.applications : [] | ||
| const isRecruiting = gig.status === "recruiting" | ||
| const candidateQueryReady = candidateQuery.trim().length >= 2 | ||
| const selectedCandidateId = selectedCandidate?.crm_contact_id || "" | ||
| const candidateProfile = selectedCandidateId | ||
| ? crmContactUrl(selectedCandidateId) || selectedCandidateId | ||
| : "" | ||
| const profileForAdd = canSearchCandidates ? candidateProfile : crmProfile.trim() | ||
| const threadUrl = | ||
| gig.discord_guild_id && gig.discord_thread_id | ||
| ? `https://discord.com/channels/${encodeURIComponent( | ||
| gig.discord_guild_id, | ||
| )}/${encodeURIComponent(gig.discord_thread_id)}` | ||
| : "" | ||
| const staleAge = staleRecruitingAge(gig, staleDays) | ||
|
|
||
| useEffect(() => { | ||
| if (!canWrite || !canSearchCandidates) return | ||
| const query = candidateQuery.trim() | ||
| if (selectedCandidate && query === personSearchLabel(selectedCandidate)) { | ||
| setCandidateMatches([]) | ||
| setCandidateSearchError("") | ||
| return | ||
| } | ||
| if (query.length < 2) { | ||
| setCandidateMatches([]) | ||
| setCandidateSearchError("") | ||
| return | ||
| } | ||
|
|
||
| const controller = new AbortController() | ||
| const timer = window.setTimeout(() => { | ||
| const params = new URLSearchParams({ limit: "8", query }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the people cache contains Useful? React with 👍 / 👎. |
||
| void requestJson<Person[]>(`/dashboard/api/people?${params.toString()}`, { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Discord Member sessions, Useful? React with 👍 / 👎. |
||
| signal: controller.signal, | ||
| }) | ||
| .then((people) => { | ||
| setCandidateMatches(people.filter((person) => Boolean(person.crm_contact_id))) | ||
|
Comment on lines
+6088
to
+6089
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence: although the updated Useful? React with 👍 / 👎. |
||
| setCandidateSearchError("") | ||
| }) | ||
| .catch((error) => { | ||
| if ( | ||
| controller.signal.aborted || | ||
| (error instanceof DOMException && error.name === "AbortError") | ||
| ) { | ||
| return | ||
| } | ||
| setCandidateMatches([]) | ||
| setCandidateSearchError(messageFromUnknown(error, "Unable to search candidates")) | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, 300) | ||
|
|
||
| return () => { | ||
| controller.abort() | ||
| window.clearTimeout(timer) | ||
| } | ||
| }, [candidateQuery, canSearchCandidates, canWrite, selectedCandidate]) | ||
|
|
||
| function chooseCandidate(person: Person) { | ||
| setSelectedCandidate(person) | ||
| setCandidateQuery(personSearchLabel(person)) | ||
| setCandidateMatches([]) | ||
| setCandidateSearchError("") | ||
| } | ||
|
|
||
| return ( | ||
| <div className="grid gap-5"> | ||
| <Card | ||
|
|
@@ -6160,24 +6230,89 @@ function GigDetailPage({ | |
| className="grid gap-2 border-t p-4 md:grid-cols-[minmax(220px,1fr)_auto]" | ||
| onSubmit={(event) => { | ||
| event.preventDefault() | ||
| void onAddApplication(gig.id, crmProfile).then((added) => { | ||
| if (added) setCrmProfile("") | ||
| void onAddApplication(gig.id, profileForAdd).then((added) => { | ||
| if (added) { | ||
| setCandidateQuery("") | ||
| setCandidateMatches([]) | ||
| setSelectedCandidate(null) | ||
| setCrmProfile("") | ||
| } | ||
| }) | ||
| }} | ||
| > | ||
| <Label className="min-w-0"> | ||
| CRM profile | ||
| <Input | ||
| value={crmProfile} | ||
| onChange={(event) => setCrmProfile(event.target.value)} | ||
| placeholder="https://crm.508.dev/#Contact/view/..." | ||
| aria-label="CRM profile for candidate" | ||
| /> | ||
| </Label> | ||
| {canSearchCandidates ? ( | ||
| <div className="relative min-w-0"> | ||
| <Label> | ||
| Candidate | ||
| <Input | ||
| value={candidateQuery} | ||
| autoComplete="off" | ||
| placeholder="Search by name or email" | ||
| aria-label="Search candidates to add" | ||
| onChange={(event) => { | ||
| setCandidateQuery(event.target.value) | ||
| setCandidateMatches([]) | ||
| setCandidateSearchError("") | ||
| setSelectedCandidate(null) | ||
| }} | ||
| onKeyDown={(event) => { | ||
| if (event.key !== "Enter" || candidateMatches.length !== 1) return | ||
| event.preventDefault() | ||
| chooseCandidate(candidateMatches[0]) | ||
| }} | ||
| /> | ||
| </Label> | ||
| {candidateQueryReady && !selectedCandidate ? ( | ||
| <div className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg"> | ||
| {candidateSearchError ? ( | ||
| <div className="px-3 py-2 text-sm text-destructive"> | ||
| {candidateSearchError} | ||
| </div> | ||
| ) : candidateMatches.length ? ( | ||
| candidateMatches.map((person) => { | ||
| const label = personSearchLabel(person) | ||
| const detail = [person.email_508 || person.email, person.contact_type] | ||
| .filter(Boolean) | ||
| .join(" | ") | ||
| return ( | ||
| <button | ||
| key={person.crm_contact_id} | ||
| type="button" | ||
| className="grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none" | ||
| onClick={() => chooseCandidate(person)} | ||
| > | ||
| <span className="truncate text-sm font-bold">{label}</span> | ||
| {detail ? ( | ||
| <span className="truncate text-xs text-muted-foreground"> | ||
| {detail} | ||
| </span> | ||
| ) : null} | ||
| </button> | ||
| ) | ||
| }) | ||
| ) : ( | ||
| <div className="px-3 py-2 text-sm text-muted-foreground"> | ||
| No candidates match this search | ||
| </div> | ||
| )} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| ) : ( | ||
| <Label className="min-w-0"> | ||
| CRM profile | ||
| <Input | ||
| value={crmProfile} | ||
| onChange={(event) => setCrmProfile(event.target.value)} | ||
| placeholder="https://crm.508.dev/#Contact/view/..." | ||
| aria-label="CRM profile for candidate" | ||
| /> | ||
| </Label> | ||
| )} | ||
| <Button | ||
| type="submit" | ||
| className="self-end" | ||
| disabled={loading[`gig:${gig.id}:addCandidate`] || !crmProfile.trim()} | ||
| disabled={loading[`gig:${gig.id}:addCandidate`] || !profileForAdd} | ||
| > | ||
| <UserPlus /> | ||
| Add candidate | ||
|
|
||
Large diffs are not rendered by default.
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Users with
people:readnow getprofileForAddonly fromselectedCandidate, so the CRM URL input is hidden for admins. When a CRM contact is new or otherwise not yet in the local people cache,/dashboard/api/peoplecannot return it because the lookup selectsFROM people(apps/api/src/five08/backend/api.py:2204), even thoughdashboard_add_gig_application_handlerstill accepts acrm_profileand verifies it directly against Espo (apps/api/src/five08/backend/api.py:7082,apps/api/src/five08/backend/api.py:7099). In that unsynced-contact case an admin can no longer add a valid candidate; keep a manual CRM profile fallback alongside search.Useful? React with 👍 / 👎.