import { useCallback, useEffect, useMemo, useState } from 'react'; import { getSikshyaApi, getWpApi, SIKSHYA_ENDPOINTS } from '../../api'; import { ButtonPrimary, ButtonSecondary } from './buttons'; import { FieldHint } from './FieldHint'; import { Modal } from './Modal'; import { __, _n, sprintf } from '../../lib/i18n'; export type MultiCourseOption = { id: number; title: string; status: string }; type Props = { value: number[]; onChange: (next: number[]) => void; /** Resolved labels (id -> title); rendered in chips. Optional. */ labels?: Record; /** Excluded ids in addition to current `value`. */ excludeIds?: number[]; placeholder?: string; /** Modal title; defaults to "Select courses". */ title?: string; /** Primary action label in the picker modal (default: "Select"). */ confirmLabel?: string; /** Helper line shown under the field. */ hint?: string; /** Defaults to 20 results per search. */ perPage?: number; /** Disable interaction (viewer mode). */ readOnly?: boolean; /** When set (e.g. `1`), only one course can be selected — same modal UX as multi, radio-like behavior. */ maxSelection?: number; /** Extra classes on the wrapper (e.g. max width in toolbars). */ className?: string; /** * When false and `hint` is empty, do not render the hint row (toolbar fields stay one line). * Default true keeps legacy alignment grids that rely on reserved hint space. */ reserveHintSpace?: boolean; /** Comfortable = default padding; compact matches adjacent text inputs in filter rows. */ density?: 'comfortable' | 'compact'; }; function uniqSorted(ids: number[]) { return Array.from(new Set(ids.filter((n) => Number.isFinite(n) && n > 0))).sort((a, b) => a - b); } /** * Multi-course selector with a modal list (search + checkboxes + Done), matching * the "Applicable To → Specific ..." UX pattern. */ export function MultiCoursePicker({ value, onChange, labels, excludeIds, placeholder = __('Click to select courses…', 'sikshya'), title = __('Select courses', 'sikshya'), confirmLabel, hint, perPage = 20, readOnly = false, maxSelection, className = '', reserveHintSpace = true, density = 'comfortable', }: Props) { const selected = useMemo(() => uniqSorted(value || []), [value]); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [busy, setBusy] = useState(false); const [results, setResults] = useState([]); const [labelMap, setLabelMap] = useState>(() => ({ ...(labels || {}) })); // Draft selection inside the modal; only commits on Done. const [draft, setDraft] = useState(selected); useEffect(() => { if (!labels) return; setLabelMap((prev) => ({ ...prev, ...labels })); }, [labels]); useEffect(() => { // Ensure already-selected course IDs render with titles (not just "Course #123") // even before the user opens the search modal. const missing = selected.filter((id) => !labelMap[id]); if (missing.length === 0) { return; } let cancelled = false; void (async () => { try { const include = missing.slice(0, 100).join(','); const rows = await getWpApi().get>( `/sik_course?context=edit&per_page=100&include=${encodeURIComponent(include)}&_fields=id,title` ); if (cancelled || !Array.isArray(rows)) return; setLabelMap((prev) => { const next = { ...prev }; for (const r of rows) { const t = r?.title?.rendered ? String(r.title.rendered).replace(/<[^>]*>/g, '').trim() : ''; if (r?.id && t) next[r.id] = t; } return next; }); } catch { // Non-fatal: fallback remains "Course #id" } })(); return () => { cancelled = true; }; // We intentionally depend on `selected` and `labelMap` so this re-checks after user adds items. }, [selected, labelMap]); useEffect(() => { if (!open) return; setDraft(selected); // reset search each open so the list starts friendly setQuery(''); setResults([]); }, [open, selected]); const search = useCallback( async (term: string) => { const exclude = Array.from(new Set([...(excludeIds || [])])).filter((n) => n > 0); const excludeSet = new Set(exclude); setBusy(true); let usedSikshya = false; try { const r = await getSikshyaApi().get<{ ok?: boolean; courses?: MultiCourseOption[] }>( SIKSHYA_ENDPOINTS.pro.coursesSearch({ search: term.trim(), exclude, per_page: perPage }) ); if (Array.isArray(r?.courses)) { const rows = r.courses.filter((c) => c?.id && !excludeSet.has(c.id)); setResults(rows); setLabelMap((prev) => { const next = { ...prev }; for (const o of rows) { next[o.id] = o.title; } return next; }); usedSikshya = true; } } catch { // Route may be absent (e.g. prerequisites add-on off) — fall back to WP REST. } if (!usedSikshya) { try { const params = new URLSearchParams({ context: 'edit', per_page: String(perPage), page: '1', _fields: 'id,title,status', }); const q = term.trim(); if (q) { params.set('search', q); } const raw = await getWpApi().get< Array<{ id: number; title?: { rendered?: string }; status?: string }> >(`/sik_course?${params.toString()}`); const mapped = (Array.isArray(raw) ? raw : []) .filter((p) => p?.id && !excludeSet.has(p.id)) .map((p) => ({ id: p.id, title: p.title?.rendered ? String(p.title.rendered).replace(/<[^>]*>/g, '').trim() : sprintf(__('Course #%d', 'sikshya'), p.id), status: typeof p.status === 'string' ? p.status : 'publish', })); setResults(mapped); setLabelMap((prev) => { const next = { ...prev }; for (const o of mapped) { next[o.id] = o.title; } return next; }); } catch { setResults([]); } } setBusy(false); }, [excludeIds, perPage] ); useEffect(() => { if (!open) return; const t = window.setTimeout(() => void search(query), 200); return () => window.clearTimeout(t); }, [open, query, search]); const toggle = (id: number) => { if (max === 1) { setDraft((prev) => (prev[0] === id ? [] : [id])); return; } setDraft((prev) => { const set = new Set(prev); if (set.has(id)) { set.delete(id); } else if (max !== undefined && set.size >= max) { return prev; } else { set.add(id); } return uniqSorted(Array.from(set)); }); }; const fieldSummary = (() => { if (selected.length === 0) return placeholder; if (selected.length === 1) { const id = selected[0]; const t = labelMap[id]; return t ? sprintf(__('%1$s · #%2$d', 'sikshya'), t, id) : sprintf(__('Course #%d', 'sikshya'), id); } return sprintf(_n('%d course selected', '%d courses selected', selected.length, 'sikshya'), selected.length); })(); const max = maxSelection !== undefined && maxSelection > 0 ? maxSelection : undefined; const primaryLabel = confirmLabel ?? (max === 1 ? __('Select course', 'sikshya') : __('Select courses', 'sikshya')); const padY = density === 'compact' ? 'py-2' : 'py-3'; const showChips = selected.length > 0 && max !== 1; return (
{reserveHintSpace || (hint !== undefined && hint !== '') ? {hint} : null} {showChips ? (
    {selected.map((id) => (
  • {labelMap[id] ? sprintf(__('%1$s · #%2$d', 'sikshya'), labelMap[id], id) : sprintf(__('Course #%d', 'sikshya'), id)}
  • ))}
) : null} setOpen(false)} footer={
setOpen(false)}> {__('Close', 'sikshya')}
{max === 1 ? draft.length === 0 ? __('No course', 'sikshya') : __('1 course', 'sikshya') : sprintf(__('%d selected', 'sikshya'), draft.length)} { if (max !== undefined && draft.length > max) { return; } onChange(max === 1 ? draft.slice(0, 1) : draft); setOpen(false); }} > {primaryLabel}
} >
setQuery(e.target.value)} placeholder={__('Search courses…', 'sikshya')} className="block w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />
{busy ? (
{__('Searching…', 'sikshya')}
) : results.length === 0 ? (
{query.trim() ? __('No matching courses.', 'sikshya') : __('Start typing to search courses.', 'sikshya')}
) : (
    {results.map((r) => { const checked = draft.includes(r.id); return (
  • ); })}
)}
); }