feat: implement multi search on search bar

This commit is contained in:
swve 2025-04-06 13:25:30 +02:00
parent ed1ae628cb
commit 0167fecbe8
6 changed files with 413 additions and 73 deletions

View file

@ -2,7 +2,7 @@ import os
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from src.routers import health from src.routers import health
from src.routers import usergroups from src.routers import usergroups
from src.routers import dev, trail, users, auth, orgs, roles from src.routers import dev, trail, users, auth, orgs, roles, search
from src.routers.ai import ai from src.routers.ai import ai
from src.routers.courses import chapters, collections, courses, assignments from src.routers.courses import chapters, collections, courses, assignments
from src.routers.courses.activities import activities, blocks from src.routers.courses.activities import activities, blocks
@ -23,6 +23,7 @@ v1_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"])
v1_router.include_router(roles.router, prefix="/roles", tags=["roles"]) v1_router.include_router(roles.router, prefix="/roles", tags=["roles"])
v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"]) v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"])
v1_router.include_router(courses.router, prefix="/courses", tags=["courses"]) v1_router.include_router(courses.router, prefix="/courses", tags=["courses"])
v1_router.include_router(search.router, prefix="/search", tags=["search"])
v1_router.include_router( v1_router.include_router(
assignments.router, prefix="/assignments", tags=["assignments"] assignments.router, prefix="/assignments", tags=["assignments"]
) )

View file

@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from src.core.events.database import get_db_session
from src.db.users import PublicUser
from src.security.auth import get_current_user
from src.services.search.search import search_across_org, SearchResult
router = APIRouter()
@router.get("/org_slug/{org_slug}", response_model=SearchResult)
async def api_search_across_org(
request: Request,
org_slug: str,
query: str,
page: int = 1,
limit: int = 10,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
) -> SearchResult:
"""
Search across courses, collections and users within an organization
"""
return await search_across_org(
request=request,
current_user=current_user,
org_slug=org_slug,
search_query=query,
db_session=db_session,
page=page,
limit=limit
)

View file

@ -288,10 +288,22 @@ async def get_courses_orgslug(
# Create CourseRead objects with authors # Create CourseRead objects with authors
course_reads = [] course_reads = []
for course in courses: for course in courses:
course_read = CourseRead( course_read = CourseRead.model_validate({
**course.model_dump(), "id": course.id or 0, # Ensure id is never None
authors=course_authors.get(course.course_uuid, []) "org_id": course.org_id,
) "name": course.name,
"description": course.description or "",
"about": course.about or "",
"learnings": course.learnings or "",
"tags": course.tags or "",
"thumbnail_image": course.thumbnail_image or "",
"public": course.public,
"open_to_contributors": course.open_to_contributors,
"course_uuid": course.course_uuid,
"creation_date": course.creation_date,
"update_date": course.update_date,
"authors": course_authors.get(course.course_uuid, [])
})
course_reads.append(course_read) course_reads.append(course_read)
return course_reads return course_reads
@ -380,8 +392,22 @@ async def search_courses(
for resource_author, user in author_results for resource_author, user in author_results
] ]
course_read = CourseRead.model_validate(course) course_read = CourseRead.model_validate({
course_read.authors = authors "id": course.id or 0, # Ensure id is never None
"org_id": course.org_id,
"name": course.name,
"description": course.description or "",
"about": course.about or "",
"learnings": course.learnings or "",
"tags": course.tags or "",
"thumbnail_image": course.thumbnail_image or "",
"public": course.public,
"open_to_contributors": course.open_to_contributors,
"course_uuid": course.course_uuid,
"creation_date": course.creation_date,
"update_date": course.update_date,
"authors": authors
})
course_reads.append(course_read) course_reads.append(course_read)
return course_reads return course_reads
@ -700,22 +726,22 @@ async def get_user_courses(
) )
# Create CourseRead object # Create CourseRead object
course_read = CourseRead( course_read = CourseRead.model_validate({
id=course.id, "id": course.id or 0, # Ensure id is never None
org_id=course.org_id, "org_id": course.org_id,
name=course.name, "name": course.name,
description=course.description, "description": course.description or "",
about=course.about, "about": course.about or "",
learnings=course.learnings, "learnings": course.learnings or "",
tags=course.tags, "tags": course.tags or "",
thumbnail_image=course.thumbnail_image, "thumbnail_image": course.thumbnail_image or "",
public=course.public, "public": course.public,
open_to_contributors=course.open_to_contributors, "open_to_contributors": course.open_to_contributors,
course_uuid=course.course_uuid, "course_uuid": course.course_uuid,
creation_date=course.creation_date, "creation_date": course.creation_date,
update_date=course.update_date, "update_date": course.update_date,
authors=authors_with_role, "authors": authors_with_role
) })
result.append(course_read) result.append(course_read)

View file

@ -0,0 +1,118 @@
from typing import List, TypeVar
from fastapi import Request
from sqlmodel import Session, select, or_, text, and_
from sqlalchemy import true as sa_true
from pydantic import BaseModel
from src.db.users import PublicUser, AnonymousUser, UserRead, User
from src.db.courses.courses import Course, CourseRead
from src.db.collections import Collection, CollectionRead
from src.db.collections_courses import CollectionCourse
from src.db.organizations import Organization
from src.services.courses.courses import search_courses
T = TypeVar('T')
class SearchResult(BaseModel):
courses: List[CourseRead]
collections: List[CollectionRead]
users: List[UserRead]
class Config:
arbitrary_types_allowed = True
async def search_across_org(
request: Request,
current_user: PublicUser | AnonymousUser,
org_slug: str,
search_query: str,
db_session: Session,
page: int = 1,
limit: int = 10,
) -> SearchResult:
"""
Search across courses, collections and users within an organization
"""
offset = (page - 1) * limit
# Get organization
org_statement = select(Organization).where(Organization.slug == org_slug)
org = db_session.exec(org_statement).first()
if not org:
return SearchResult(courses=[], collections=[], users=[])
# Search courses using existing search_courses function
courses = await search_courses(request, current_user, org_slug, search_query, db_session, page, limit)
# Search collections
collections_query = (
select(Collection)
.where(Collection.org_id == org.id)
.where(
or_(
text('LOWER("collection".name) LIKE LOWER(:pattern)'),
text('LOWER("collection".description) LIKE LOWER(:pattern)')
)
)
.params(pattern=f"%{search_query}%")
)
# Search users
users_query = (
select(User)
.where(
or_(
text('LOWER("user".username) LIKE LOWER(:pattern) OR ' +
'LOWER("user".first_name) LIKE LOWER(:pattern) OR ' +
'LOWER("user".last_name) LIKE LOWER(:pattern) OR ' +
'LOWER("user".bio) LIKE LOWER(:pattern)')
)
)
.params(pattern=f"%{search_query}%")
)
if isinstance(current_user, AnonymousUser):
# For anonymous users, only show public collections
collections_query = collections_query.where(Collection.public == sa_true())
else:
# For authenticated users, show public collections and those in their org
collections_query = (
collections_query
.where(
or_(
Collection.public == sa_true(),
Collection.org_id == org.id
)
)
)
# Apply pagination to queries
collections = db_session.exec(collections_query.offset(offset).limit(limit)).all()
users = db_session.exec(users_query.offset(offset).limit(limit)).all()
# Convert collections to CollectionRead objects with courses
collection_reads = []
for collection in collections:
# Get courses in collection
statement = (
select(Course)
.select_from(Course)
.join(CollectionCourse, and_(
CollectionCourse.course_id == Course.id,
CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == collection.org_id
))
.distinct()
)
collection_courses = list(db_session.exec(statement).all())
collection_read = CollectionRead(**collection.model_dump(), courses=collection_courses)
collection_reads.append(collection_read)
# Convert users to UserRead objects
user_reads = [UserRead.model_validate(user) for user in users]
return SearchResult(
courses=courses,
collections=collection_reads,
users=user_reads
)

View file

@ -1,31 +1,75 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch } from 'lucide-react'; import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch, Users } from 'lucide-react';
import { searchOrgCourses } from '@services/courses/courses'; import { searchOrgContent } from '@services/search/search';
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import Link from 'next/link'; import Link from 'next/link';
import { getCourseThumbnailMediaDirectory } from '@services/media/media'; import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { removeCoursePrefix } from '../Thumbnails/CourseThumbnail'; import { removeCoursePrefix } from '../Thumbnails/CourseThumbnail';
import UserAvatar from '../UserAvatar';
interface User {
username: string;
first_name: string;
last_name: string;
email: string;
avatar_image: string;
bio: string;
details: Record<string, any>;
profile: Record<string, any>;
id: number;
user_uuid: string;
}
interface Author {
user: User;
authorship: string;
authorship_status: string;
creation_date: string;
update_date: string;
}
interface Course { interface Course {
name: string; name: string;
description: string; description: string;
about: string;
learnings: string;
tags: string;
thumbnail_image: string; thumbnail_image: string;
public: boolean;
open_to_contributors: boolean;
id: number;
org_id: number;
authors: Author[];
course_uuid: string; course_uuid: string;
tags?: string[]; creation_date: string;
authors: Array<{ update_date: string;
first_name: string; }
last_name: string;
avatar_image: string; interface Collection {
}>; name: string;
public: boolean;
description: string;
id: number;
courses: string[];
collection_uuid: string;
creation_date: string;
update_date: string;
}
interface SearchResults {
courses: Course[];
collections: Collection[];
users: User[];
} }
interface SearchBarProps { interface SearchBarProps {
orgslug: string; orgslug: string;
className?: string; className?: string;
isMobile?: boolean; isMobile?: boolean;
showSearchSuggestions?: boolean;
} }
const CourseResultsSkeleton = () => ( const CourseResultsSkeleton = () => (
@ -50,10 +94,15 @@ export const SearchBar: React.FC<SearchBarProps> = ({
orgslug, orgslug,
className = '', className = '',
isMobile = false, isMobile = false,
showSearchSuggestions = false,
}) => { }) => {
const org = useOrg() as any; const org = useOrg() as any;
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [courses, setCourses] = useState<Course[]>([]); const [searchResults, setSearchResults] = useState<SearchResults>({
courses: [],
collections: [],
users: []
});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
@ -75,16 +124,16 @@ export const SearchBar: React.FC<SearchBarProps> = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
const fetchCourses = async () => { const fetchResults = async () => {
if (debouncedSearch.trim().length === 0) { if (debouncedSearch.trim().length === 0) {
setCourses([]); setSearchResults({ courses: [], collections: [], users: [] });
setIsLoading(false); setIsLoading(false);
return; return;
} }
setIsLoading(true); setIsLoading(true);
try { try {
const results = await searchOrgCourses( const response = await searchOrgContent(
orgslug, orgslug,
debouncedSearch, debouncedSearch,
1, 1,
@ -92,16 +141,31 @@ export const SearchBar: React.FC<SearchBarProps> = ({
null, null,
session?.data?.tokens?.access_token session?.data?.tokens?.access_token
); );
setCourses(results);
console.log('Search API Response:', response); // Debug log
// Type assertion and safe access
const typedResponse = response.data as any;
// Ensure we have the correct structure and handle potential undefined values
const processedResults: SearchResults = {
courses: Array.isArray(typedResponse?.courses) ? typedResponse.courses : [],
collections: Array.isArray(typedResponse?.collections) ? typedResponse.collections : [],
users: Array.isArray(typedResponse?.users) ? typedResponse.users : []
};
console.log('Processed Results:', processedResults); // Debug log
setSearchResults(processedResults);
} catch (error) { } catch (error) {
console.error('Error searching courses:', error); console.error('Error searching content:', error);
setCourses([]); setSearchResults({ courses: [], collections: [], users: [] });
} }
setIsLoading(false); setIsLoading(false);
setIsInitialLoad(false); setIsInitialLoad(false);
}; };
fetchCourses(); fetchResults();
}, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]); }, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]);
const MemoizedEmptyState = useMemo(() => { const MemoizedEmptyState = useMemo(() => {
@ -160,8 +224,12 @@ export const SearchBar: React.FC<SearchBarProps> = ({
return null; return null;
}, [searchQuery, searchTerms, orgslug]); }, [searchQuery, searchTerms, orgslug]);
const MemoizedCourseResults = useMemo(() => { const MemoizedQuickResults = useMemo(() => {
if (!courses.length) return null; const hasResults = searchResults.courses.length > 0 ||
searchResults.collections.length > 0 ||
searchResults.users.length > 0;
if (!hasResults) return null;
return ( return (
<div className="p-2"> <div className="p-2">
@ -169,40 +237,114 @@ export const SearchBar: React.FC<SearchBarProps> = ({
<TextSearch size={16} /> <TextSearch size={16} />
<span className="font-medium">Quick Results</span> <span className="font-medium">Quick Results</span>
</div> </div>
{courses.map((course) => (
<Link {/* Users Section */}
key={course.course_uuid} {searchResults.users.length > 0 && (
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)} <div className="mb-2">
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors" <div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
> <Users size={12} />
<div className="relative"> <span>Users</span>
{course.thumbnail_image ? ( </div>
<img {searchResults.users.map((user) => (
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)} <Link
alt={course.name} key={user.user_uuid}
className="w-10 h-10 object-cover rounded-lg" href={getUriWithOrg(orgslug, `/user/${user.username}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<UserAvatar
width={40}
avatar_url={user.avatar_image ? getUserAvatarMediaDirectory(user.user_uuid, user.avatar_image) : ''}
predefined_avatar={user.avatar_image ? undefined : 'empty'}
userId={user.id.toString()}
showProfilePopup
rounded="rounded-full"
backgroundColor="bg-gray-100"
/> />
) : ( <div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-black/80 truncate">
{user.first_name} {user.last_name}
</h3>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">User</span>
</div>
<p className="text-xs text-black/50 truncate">@{user.username}</p>
</div>
</Link>
))}
</div>
)}
{/* Courses Section */}
{searchResults.courses.length > 0 && (
<div className="mb-2">
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
<GraduationCap size={12} />
<span>Courses</span>
</div>
{searchResults.courses.map((course) => (
<Link
key={course.course_uuid}
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<div className="relative">
{course.thumbnail_image ? (
<img
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
alt={course.name}
className="w-10 h-10 object-cover rounded-lg"
/>
) : (
<div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center">
<Book size={20} className="text-black/40" />
</div>
)}
<div className="absolute -bottom-1 -right-1 bg-white shadow-sm p-1 rounded-full">
<GraduationCap size={11} className="text-black/60" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-black/80 truncate">{course.name}</h3>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Course</span>
</div>
<p className="text-xs text-black/50 truncate">{course.description}</p>
</div>
</Link>
))}
</div>
)}
{/* Collections Section */}
{searchResults.collections.length > 0 && (
<div className="mb-2">
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
<Book size={12} />
<span>Collections</span>
</div>
{searchResults.collections.map((collection) => (
<Link
key={collection.collection_uuid}
href={getUriWithOrg(orgslug, `/collection/${collection.collection_uuid}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center">
<Book size={20} className="text-black/40" /> <Book size={20} className="text-black/40" />
</div> </div>
)} <div className="flex-1 min-w-0">
<div className="absolute -bottom-1 -right-1 bg-white shadow-sm p-1 rounded-full"> <div className="flex items-center gap-2">
<GraduationCap size={11} className="text-black/60" /> <h3 className="text-sm font-medium text-black/80 truncate">{collection.name}</h3>
</div> <span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Collection</span>
</div> </div>
<div className="flex-1 min-w-0"> <p className="text-xs text-black/50 truncate">{collection.description}</p>
<div className="flex items-center gap-2"> </div>
<h3 className="text-sm font-medium text-black/80 truncate">{course.name}</h3> </Link>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Course</span> ))}
</div> </div>
<p className="text-xs text-black/50 truncate">{course.description}</p> )}
</div>
</Link>
))}
</div> </div>
); );
}, [courses, orgslug, org?.org_uuid]); }, [searchResults, orgslug, org?.org_uuid]);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@ -238,13 +380,16 @@ export const SearchBar: React.FC<SearchBarProps> = ({
MemoizedEmptyState MemoizedEmptyState
) : ( ) : (
<> <>
{MemoizedSearchSuggestions} {showSearchSuggestions && MemoizedSearchSuggestions}
{isLoading ? ( {isLoading ? (
<CourseResultsSkeleton /> <CourseResultsSkeleton />
) : ( ) : (
<> <>
{MemoizedCourseResults} {MemoizedQuickResults}
{(courses.length > 0 || searchQuery.trim()) && ( {((searchResults.courses.length > 0 ||
searchResults.collections.length > 0 ||
searchResults.users.length > 0) ||
searchQuery.trim()) && (
<Link <Link
href={getUriWithOrg(orgslug, `/search?q=${encodeURIComponent(searchQuery)}`)} href={getUriWithOrg(orgslug, `/search?q=${encodeURIComponent(searchQuery)}`)}
className="flex items-center justify-between px-4 py-2.5 text-xs text-black/50 hover:text-black/70 hover:bg-black/[0.02] transition-colors" className="flex items-center justify-between px-4 py-2.5 text-xs text-black/50 hover:text-black/70 hover:bg-black/[0.02] transition-colors"

View file

@ -0,0 +1,19 @@
import { RequestBodyWithAuthHeader } from "@services/utils/ts/requests"
import { getAPIUrl } from "@services/config/config"
import { errorHandling, getResponseMetadata } from "@services/utils/ts/requests"
export async function searchOrgContent(
org_slug: string,
query: string,
page: number = 1,
limit: number = 10,
next: any,
access_token?: any
) {
const result: any = await fetch(
`${getAPIUrl()}search/org_slug/${org_slug}?query=${encodeURIComponent(query)}&page=${page}&limit=${limit}`,
RequestBodyWithAuthHeader('GET', null, next, access_token)
)
const res = await getResponseMetadata(result)
return res
}