fix: improve SearchableSelect with label resolution for pre-selected items

- Fetch post titles for pre-selected IDs on mount via include[] param
- Cache labels from search results to avoid re-fetching
- Filter already-selected items from dropdown results
- Add loading spinner and no-results message
- Update PHP autocomplete handler to support include[] param
- Fix CSS class conflict warning
This commit is contained in:
dwindown
2026-04-19 15:47:59 +07:00
parent 7ba92022d5
commit 1a10c18c31
6 changed files with 109 additions and 29 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash', 'wp-primitives'), 'version' => 'f5d52a62f3d00d92014a');
<?php return array('dependencies' => array('react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash', 'wp-primitives'), 'version' => '09bb3ab758733bb11e68');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1073,8 +1073,35 @@ class Coupon {
$post_type = isset($_REQUEST['post_type']) ? sanitize_text_field(wp_unslash($_REQUEST['post_type'])) : '';
$search = isset($_REQUEST['search']) ? sanitize_text_field(wp_unslash($_REQUEST['search'])) : '';
$include = isset($_REQUEST['include']) ? array_map('intval', (array) $_REQUEST['include']) : [];
if (empty($post_type) || strlen($search) < 2) {
if (empty($post_type)) {
wp_send_json_error( [ 'message' => 'Invalid request' ] );
}
// Resolve labels for specific IDs (pre-selected items)
if (!empty($include)) {
$query = get_posts([
'post_type' => $post_type,
'post__in' => $include,
'posts_per_page' => -1,
'post_status' => 'any',
]);
$results = [];
if (!empty($query)) {
foreach ($query as $post) {
$results[] = [
'value' => $post->ID,
'label' => $post->post_title,
];
}
}
wp_send_json_success($results);
return;
}
// Search by keyword
if (strlen($search) < 2) {
wp_send_json_error( [ 'message' => 'Invalid request' ] );
}

View File

@@ -405,18 +405,49 @@ function SearchableSelect({ postType, value = [], onChange }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [labels, setLabels] = useState({});
const wrapperRef = useRef(null);
const selected = Array.isArray(value) ? value : [];
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
const nonce = window.formipayAdmin?.nonce || '';
// Resolve labels for pre-selected IDs on mount
useEffect(() => {
const handleClickOutside = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setOpen(false);
if (selected.length === 0) return;
const missing = selected.filter(id => !labels[id]);
if (missing.length === 0) return;
const loadLabels = async () => {
try {
const params = new URLSearchParams({ post_type: postType, _wpnonce: nonce });
missing.forEach(id => params.append('include[]', id));
const res = await fetch(`${ajaxUrl}?action=formipay-autocomplete-search&${params.toString()}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
const result = await res.json();
if (result.success && Array.isArray(result.data)) {
const newLabels = {};
result.data.forEach(item => { newLabels[item.value] = item.label; });
setLabels(prev => ({ ...prev, ...newLabels }));
}
} catch (e) {
// Fall back to showing IDs
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
loadLabels();
}, [selected.join(','), postType]);
// Close dropdown on outside click
useEffect(() => {
const handler = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleSearch = async (query) => {
@@ -424,14 +455,21 @@ function SearchableSelect({ postType, value = [], onChange }) {
if (query.length < 2) { setResults([]); return; }
setLoading(true);
try {
const res = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-autocomplete-search`, {
const res = await fetch(`${ajaxUrl}?action=formipay-autocomplete-search`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ post_type: postType, search: query, _wpnonce: window.formipayAdmin?.nonce || '' }),
body: new URLSearchParams({ post_type: postType, search: query, _wpnonce: nonce }),
});
const result = await res.json();
if (result.success) setResults(result.data || []);
if (result.success) {
const items = result.data || [];
setResults(items);
// Cache labels from search results
const newLabels = {};
items.forEach(item => { newLabels[item.value] = item.label; });
setLabels(prev => ({ ...prev, ...newLabels }));
}
} catch (e) {
console.error('Search failed:', e);
} finally {
@@ -440,7 +478,10 @@ function SearchableSelect({ postType, value = [], onChange }) {
};
const handleSelect = (item) => {
if (!selected.includes(item.value)) onChange([...selected, item.value]);
if (!selected.includes(item.value)) {
onChange([...selected, item.value]);
setLabels(prev => ({ ...prev, [item.value]: item.label }));
}
setSearch('');
setResults([]);
setOpen(false);
@@ -448,33 +489,40 @@ function SearchableSelect({ postType, value = [], onChange }) {
const handleRemove = (val) => onChange(selected.filter(v => v !== val));
const getLabel = (val) => labels[val] || `#${val}`;
// Filter out already-selected items from results
const availableResults = results.filter(r => !selected.includes(r.value));
return (
<div ref={wrapperRef} className="space-y-2">
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map((val) => {
const item = results.find(r => r.value === val);
return (
{selected.map((val) => (
<Badge key={val} variant="secondary" className="gap-1 pr-1">
{item?.label || `#${val}`}
{getLabel(val)}
<button type="button" className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5" onClick={() => handleRemove(val)}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</Badge>
);
})}
))}
</div>
)}
<div className="relative">
<Input
placeholder={__('Search...', 'formipay')}
placeholder={__('Search to add...', 'formipay')}
value={search}
onChange={(e) => { handleSearch(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
/>
{open && results.length > 0 && (
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md overflow-hidden">
{results.map((item) => (
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
)}
{open && availableResults.length > 0 && (
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md overflow-hidden max-h-48 overflow-y-auto">
{availableResults.map((item) => (
<div
key={item.value}
className="px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
@@ -485,6 +533,11 @@ function SearchableSelect({ postType, value = [], onChange }) {
))}
</div>
)}
{open && search.length >= 2 && !loading && availableResults.length === 0 && (
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-md px-3 py-2 text-sm text-muted-foreground">
{__('No results found.', 'formipay')}
</div>
)}
</div>
</div>
);