dev1/src/components/CareerSearch.js

197 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
// Dont 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 Chromiums 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 cant
accommodate every job titlechoose the closest match to what youre 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;