mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
311 lines
14 KiB
React
311 lines
14 KiB
React
/***********************************************************************************************************************************************************************
|
|
* File Name : GroupMultiSelect.jsx
|
|
* Type : Reusable Component
|
|
* Description : Searchable multi-select dropdown for User Groups.
|
|
* - Portal-based dropdown (escapes Card overflow clipping)
|
|
* - First badge + "+N" overflow chip when 2+ selected
|
|
* - "Select all" and "Clear" actions in dropdown header
|
|
*
|
|
* Props:
|
|
* value : number[] — selected group_ids
|
|
* onChange : (ids: number[]) => void
|
|
* groups? : { group_id, name }[] — skip API fetch if provided
|
|
* disabled? : boolean
|
|
* placeholder?: string
|
|
***********************************************************************************************************************************************************************/
|
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import api from '@/utils/api.util';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { cn } from '@/lib/utils';
|
|
import { Check, ChevronsUpDown, X, Users, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
const PAGE_SIZE = 5;
|
|
|
|
export default function GroupMultiSelect({
|
|
value = [],
|
|
onChange,
|
|
groups: groupsProp = null,
|
|
disabled = false,
|
|
placeholder = 'Select groups…',
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [page, setPage] = useState(1);
|
|
const [allGroups, setAllGroups] = useState(groupsProp ?? []);
|
|
const [loadingGroups, setLoadingGroups] = useState(!groupsProp);
|
|
const [dropdownStyle, setDropdownStyle] = useState({});
|
|
|
|
const triggerRef = useRef(null);
|
|
const dropdownRef = useRef(null);
|
|
|
|
// ── Fetch groups if not provided by parent ────────────────────────────────
|
|
useEffect(() => {
|
|
if (groupsProp !== null) {
|
|
setAllGroups(groupsProp);
|
|
setLoadingGroups(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoadingGroups(true);
|
|
api.get('/admin/groups', { params: { limit: 500 } })
|
|
.then((res) => {
|
|
if (!cancelled) {
|
|
const raw = res.data?.data?.data ?? res.data?.data ?? [];
|
|
setAllGroups(Array.isArray(raw) ? raw : []);
|
|
}
|
|
})
|
|
.catch(() => { if (!cancelled) setAllGroups([]); })
|
|
.finally(() => { if (!cancelled) setLoadingGroups(false); });
|
|
return () => { cancelled = true; };
|
|
}, [groupsProp]);
|
|
|
|
// ── Position portal dropdown under trigger ────────────────────────────────
|
|
useLayoutEffect(() => {
|
|
if (!open || !triggerRef.current) return;
|
|
|
|
const reposition = () => {
|
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
setDropdownStyle({
|
|
position: 'fixed',
|
|
top: rect.bottom + 4,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
zIndex: 9999,
|
|
});
|
|
};
|
|
|
|
reposition();
|
|
window.addEventListener('scroll', reposition, true);
|
|
window.addEventListener('resize', reposition);
|
|
return () => {
|
|
window.removeEventListener('scroll', reposition, true);
|
|
window.removeEventListener('resize', reposition);
|
|
};
|
|
}, [open]);
|
|
|
|
// ── Close on outside click ────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e) => {
|
|
if (
|
|
triggerRef.current?.contains(e.target) ||
|
|
dropdownRef.current?.contains(e.target)
|
|
) return;
|
|
setOpen(false);
|
|
setSearch('');
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [open]);
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
const filtered = allGroups.filter((g) =>
|
|
g.name.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
const selectedGroups = allGroups.filter((g) => value.includes(g.group_id));
|
|
const overflowCount = selectedGroups.length - 1;
|
|
|
|
// All currently visible (filtered) IDs — used for select-all scope
|
|
const filteredIds = filtered.map((g) => g.group_id);
|
|
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
|
|
|
|
// ── Pagination ────────────────────────────────────────────────────────────
|
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages);
|
|
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
|
|
|
// Reset to page 1 whenever the search term changes
|
|
const handleSearchChange = (e) => {
|
|
setSearch(e.target.value);
|
|
setPage(1);
|
|
};
|
|
|
|
const toggle = (groupId) =>
|
|
onChange(value.includes(groupId)
|
|
? value.filter((id) => id !== groupId)
|
|
: [...value, groupId]
|
|
);
|
|
|
|
const remove = (e, groupId) => {
|
|
e.stopPropagation();
|
|
onChange(value.filter((id) => id !== groupId));
|
|
};
|
|
|
|
// Select all visible (filtered) groups
|
|
const handleSelectAll = () => {
|
|
const merged = Array.from(new Set([...value, ...filteredIds]));
|
|
onChange(merged);
|
|
};
|
|
|
|
// Clear all selections
|
|
const handleClear = () => onChange([]);
|
|
|
|
// ── Portal dropdown ───────────────────────────────────────────────────────
|
|
const dropdown = open && createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
style={dropdownStyle}
|
|
className="rounded-md border border-border bg-popover shadow-lg"
|
|
>
|
|
{/* Search row */}
|
|
<div className="p-2 border-b border-border">
|
|
<Input
|
|
autoFocus
|
|
value={search}
|
|
onChange={handleSearchChange}
|
|
placeholder="Search groups…"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Select all / Clear row — only when groups are loaded */}
|
|
{!loadingGroups && allGroups.length > 0 && (
|
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/40">
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={allFilteredSelected ? handleClear : handleSelectAll}
|
|
className="text-xs text-primary hover:underline underline-offset-2 font-medium"
|
|
>
|
|
{allFilteredSelected ? 'Deselect all' : 'Select all'}
|
|
{search && ` (${filteredIds.length})`}
|
|
</button>
|
|
{value.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={handleClear}
|
|
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
|
|
>
|
|
Clear ({value.length})
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Options */}
|
|
<ul className="py-1">
|
|
{loadingGroups ? (
|
|
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
|
Loading groups…
|
|
</li>
|
|
) : filtered.length === 0 ? (
|
|
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
|
No groups found.
|
|
</li>
|
|
) : (
|
|
paginated.map((g) => {
|
|
const selected = value.includes(g.group_id);
|
|
return (
|
|
<li
|
|
key={g.group_id}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => toggle(g.group_id)}
|
|
className={cn(
|
|
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none',
|
|
'hover:bg-accent hover:text-accent-foreground',
|
|
selected && 'bg-accent/50'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'h-4 w-4 rounded border flex items-center justify-center shrink-0',
|
|
selected
|
|
? 'bg-primary border-primary text-primary-foreground'
|
|
: 'border-input'
|
|
)}>
|
|
{selected && <Check className="h-3 w-3" />}
|
|
</div>
|
|
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
<span className="truncate">{g.name}</span>
|
|
</li>
|
|
);
|
|
})
|
|
)}
|
|
</ul>
|
|
|
|
{/* Pagination */}
|
|
{!loadingGroups && filtered.length > PAGE_SIZE && (
|
|
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted/40">
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={safePage <= 1}
|
|
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
|
|
>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
Prev
|
|
</button>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{safePage} / {totalPages}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={safePage >= totalPages}
|
|
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* ── Trigger button ───────────────────────────────────────────── */}
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => { setOpen((o) => !o); setSearch(''); setPage(1); }}
|
|
className={cn(
|
|
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
|
|
'flex items-center gap-1.5 text-left',
|
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
open && 'ring-2 ring-ring ring-offset-1'
|
|
)}
|
|
>
|
|
{selectedGroups.length === 0 ? (
|
|
<span className="text-muted-foreground flex-1">{placeholder}</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 flex-1 min-w-0">
|
|
{/* Always show only the first selected group */}
|
|
<Badge variant="secondary" className="gap-1 text-xs pr-1 shrink-0">
|
|
<Users className="h-3 w-3" />
|
|
{selectedGroups[0].name}
|
|
<span
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={(e) => remove(e, selectedGroups[0].group_id)}
|
|
onKeyDown={(e) => e.key === 'Enter' && remove(e, selectedGroups[0].group_id)}
|
|
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5 cursor-pointer"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</span>
|
|
</Badge>
|
|
|
|
{/* +N overflow chip */}
|
|
{overflowCount > 0 && (
|
|
<Badge variant="outline" className="text-xs px-1.5 shrink-0">
|
|
+{overflowCount}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
)}
|
|
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0 ml-auto" />
|
|
</button>
|
|
|
|
{/* ── Portalled dropdown ────────────────────────────────────────── */}
|
|
{dropdown}
|
|
</>
|
|
);
|
|
} |