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:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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' ] );
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user