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'])) : '';
|
$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'])) : '';
|
$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' ] );
|
wp_send_json_error( [ 'message' => 'Invalid request' ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -405,18 +405,49 @@ function SearchableSelect({ postType, value = [], onChange }) {
|
|||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [labels, setLabels] = useState({});
|
||||||
const wrapperRef = useRef(null);
|
const wrapperRef = useRef(null);
|
||||||
|
|
||||||
const selected = Array.isArray(value) ? value : [];
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e) => {
|
if (selected.length === 0) return;
|
||||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
const missing = selected.filter(id => !labels[id]);
|
||||||
setOpen(false);
|
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);
|
loadLabels();
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
}, [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) => {
|
const handleSearch = async (query) => {
|
||||||
@@ -424,14 +455,21 @@ function SearchableSelect({ postType, value = [], onChange }) {
|
|||||||
if (query.length < 2) { setResults([]); return; }
|
if (query.length < 2) { setResults([]); return; }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
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();
|
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) {
|
} catch (e) {
|
||||||
console.error('Search failed:', e);
|
console.error('Search failed:', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -440,7 +478,10 @@ function SearchableSelect({ postType, value = [], onChange }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (item) => {
|
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('');
|
setSearch('');
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -448,33 +489,40 @@ function SearchableSelect({ postType, value = [], onChange }) {
|
|||||||
|
|
||||||
const handleRemove = (val) => onChange(selected.filter(v => v !== val));
|
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 (
|
return (
|
||||||
<div ref={wrapperRef} className="space-y-2">
|
<div ref={wrapperRef} className="space-y-2">
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{selected.map((val) => {
|
{selected.map((val) => (
|
||||||
const item = results.find(r => r.value === val);
|
<Badge key={val} variant="secondary" className="gap-1 pr-1">
|
||||||
return (
|
{getLabel(val)}
|
||||||
<Badge key={val} variant="secondary" className="gap-1 pr-1">
|
<button type="button" className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5" onClick={() => handleRemove(val)}>
|
||||||
{item?.label || `#${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 type="button" className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5" onClick={() => handleRemove(val)}>
|
</button>
|
||||||
<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>
|
</Badge>
|
||||||
</button>
|
))}
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
placeholder={__('Search...', 'formipay')}
|
placeholder={__('Search to add...', 'formipay')}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { handleSearch(e.target.value); setOpen(true); }}
|
onChange={(e) => { handleSearch(e.target.value); setOpen(true); }}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
/>
|
/>
|
||||||
{open && results.length > 0 && (
|
{loading && (
|
||||||
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md overflow-hidden">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
{results.map((item) => (
|
<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
|
<div
|
||||||
key={item.value}
|
key={item.value}
|
||||||
className="px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user