197 lines
6.2 KiB
JavaScript
197 lines
6.2 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Button } from './ui/button.js';
|
||
import api from '../auth/apiClient.js';
|
||
|
||
/* ---------- helpers ---------- */
|
||
const normalize = (s = '') =>
|
||
s
|
||
.toLowerCase()
|
||
.replace(/\s*&\s*/g, ' and ')
|
||
.replace(/[–—]/g, '-') // long dash → hyphen
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
/* ---------- component ---------- */
|
||
const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled = false }) => {
|
||
const [suggestions, setSuggestions] = useState([]); // [{title,soc_code,cip_codes,...}]
|
||
const [searchInput, setSearchInput] = useState('');
|
||
const [selectedObj, setSelectedObj] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const abortRef = useRef(null);
|
||
const lastMouseDownRef = useRef(0);
|
||
const prevValueRef = useRef('');
|
||
|
||
const computedDisabled = externallyDisabled || !!selectedObj;
|
||
const listId = 'career-titles';
|
||
|
||
// Debounced query to backend on input changes
|
||
useEffect(() => {
|
||
if (computedDisabled) return;
|
||
|
||
const q = searchInput.trim();
|
||
// Don’t fetch on empty string (keeps UX identical to your datalist flow)
|
||
if (!q) {
|
||
setSuggestions([]);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
|
||
// cancel previous in-flight
|
||
if (abortRef.current) abortRef.current.abort();
|
||
const ctrl = new AbortController();
|
||
abortRef.current = ctrl;
|
||
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
const { data } = await api.get('/api/careers/search', {
|
||
params: { query: q, limit: 15 },
|
||
signal: ctrl.signal
|
||
});
|
||
|
||
// De-dupe by normalized title (keep first)
|
||
const map = new Map();
|
||
for (const c of Array.isArray(data) ? data : []) {
|
||
if (!c?.title || !c?.soc_code || !c?.cip_codes) continue;
|
||
const key = normalize(c.title);
|
||
if (!map.has(key)) {
|
||
map.set(key, {
|
||
title: c.title,
|
||
soc_code: c.soc_code,
|
||
cip_codes: c.cip_codes,
|
||
limited_data: c.limited_data,
|
||
ratings: c.ratings
|
||
});
|
||
}
|
||
}
|
||
setSuggestions([...map.values()]);
|
||
} catch (err) {
|
||
if (err?.name !== 'CanceledError' && err?.code !== 'ERR_CANCELED') {
|
||
console.error('Career search failed:', err);
|
||
setSuggestions([]);
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, 150); // debounce ~150ms (keeps typing snappy)
|
||
|
||
return () => {
|
||
clearTimeout(timer);
|
||
ctrl.abort();
|
||
};
|
||
}, [searchInput, computedDisabled]);
|
||
|
||
// Handle Enter → commit first startsWith match (then datalist shows exact)
|
||
const handleKeyDown = (e) => {
|
||
if (computedDisabled || e.key !== 'Enter') return;
|
||
|
||
const n = normalize(searchInput);
|
||
const exact = suggestions.find(o => normalize(o.title) === n);
|
||
const firstP = suggestions.find(o => normalize(o.title).startsWith(n));
|
||
const first = exact || firstP || suggestions[0];
|
||
|
||
if (!first) return;
|
||
|
||
const payload = { ...first, cip_code: first.cip_codes };
|
||
setSearchInput(first.title);
|
||
setSelectedObj(payload);
|
||
onCareerSelected?.(payload);
|
||
e.preventDefault();
|
||
};
|
||
|
||
|
||
const reset = () => {
|
||
setSelectedObj(null);
|
||
setSearchInput('');
|
||
setSuggestions([]);
|
||
};
|
||
|
||
return (
|
||
<div className="mb-4">
|
||
<label className="block font-medium mb-1">
|
||
Search for Career <span className="text-red-600">*</span>
|
||
</label>
|
||
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
list={listId}
|
||
value={searchInput}
|
||
required={required}
|
||
disabled={computedDisabled}
|
||
onMouseDown={() => { lastMouseDownRef.current = Date.now(); }}
|
||
onChange={async (e) => {
|
||
const val = e.target.value;
|
||
setSearchInput(val);
|
||
if (computedDisabled) return;
|
||
|
||
const exact = suggestions.find(o => normalize(o.title) === normalize(val));
|
||
|
||
// Heuristic: datalist pick usually replaces many characters at once.
|
||
const prev = prevValueRef.current || '';
|
||
const bigJump = Math.abs(val.length - prev.length) > 1;
|
||
|
||
// Also still catch Chromium’s signal when present.
|
||
const it = e?.nativeEvent?.inputType;
|
||
const replacement = it === 'insertReplacementText';
|
||
|
||
if (exact && (bigJump || replacement)) {
|
||
const payload = { ...exact, cip_code: exact.cip_codes }; // full SOC preserved
|
||
setSelectedObj(payload);
|
||
onCareerSelected?.(payload);
|
||
}
|
||
|
||
// update after processing to get a clean delta next time
|
||
prevValueRef.current = val;
|
||
}}
|
||
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={async () => {
|
||
if (computedDisabled) return;
|
||
const exact = suggestions.find(o => normalize(o.title) === normalize(searchInput));
|
||
if (!exact) return;
|
||
const payload = { ...exact, cip_code: exact.cip_codes };
|
||
setSelectedObj(payload);
|
||
onCareerSelected?.(payload);
|
||
}}
|
||
className={`w-full border rounded p-2 ${computedDisabled ? 'bg-gray-100 cursor-not-allowed opacity-60' : ''}`}
|
||
placeholder="Start typing a career..."
|
||
autoComplete="off"
|
||
/>
|
||
|
||
|
||
{!computedDisabled && (
|
||
<datalist id={listId}>
|
||
{suggestions.map((o) => (
|
||
<option key={`${o.soc_code}:${o.title}`} value={o.title} />
|
||
))}
|
||
</datalist>
|
||
)}
|
||
|
||
{loading && !computedDisabled && (
|
||
<div className="absolute right-2 top-2 text-xs text-gray-500">loading…</div>
|
||
)}
|
||
</div>
|
||
|
||
{!selectedObj && (
|
||
<p className="mt-2 text-sm text-blue-700">
|
||
Please pick from the dropdown when performing search. Our database is very comprehensive but can’t
|
||
accommodate every job title—choose the closest match to what you’re searching for.
|
||
</p>
|
||
)}
|
||
|
||
{selectedObj && !externallyDisabled && (
|
||
<button
|
||
type="button"
|
||
onClick={reset}
|
||
className="text-blue-600 underline text-sm mt-1"
|
||
>
|
||
Change
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CareerSearch;
|