diff --git a/app/[locale]/(user)/search/components/UnifiedListingComponent.tsx b/app/[locale]/(user)/search/components/UnifiedListingComponent.tsx new file mode 100644 index 00000000..036aae95 --- /dev/null +++ b/app/[locale]/(user)/search/components/UnifiedListingComponent.tsx @@ -0,0 +1,698 @@ +'use client' + +import GraphqlPagination from '@/app/[locale]/dashboard/components/GraphqlPagination/graphqlPagination'; +import { useRouter } from 'next/navigation'; +import { + Button, + ButtonGroup, + Card, + Icon, + Pill, + SearchInput, + Select, + Text, + Tray, +} from 'opub-ui'; +import React, { useEffect, useReducer, useRef, useState } from 'react'; + +import BreadCrumbs from '@/components/BreadCrumbs'; +import { Icons } from '@/components/icons'; +import { Loading } from '@/components/loading'; +import { cn, formatDate } from '@/lib/utils'; +import Filter from '../../datasets/components/FIlter/Filter'; +import Styles from '../../datasets/dataset.module.scss'; + +// Helper function to strip markdown and HTML tags for card preview +const stripMarkdown = (markdown: string): string => { + if (!markdown) return ''; + return markdown + .replace(/```[\s\S]*?```/g, '') + .replace(/`([^`]+)`/g, '$1') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/_([^_]+)_/g, '$1') + .replace(/~~([^~]+)~~/g, '$1') + .replace(/^\s*>\s+/gm, '') + .replace(/^(-{3,}|_{3,}|\*{3,})$/gm, '') + .replace(/^\s*[-*+]\s+/gm, '') + .replace(/^\s*\d+\.\s+/gm, '') + .replace(/<[^>]*>/g, '') + .replace(/\n\s*\n/g, '\n') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +}; + +// Interfaces +interface Bucket { + key: string; + doc_count: number; +} + +interface Aggregation { + buckets?: Bucket[]; + [key: string]: any; +} + +interface Aggregations { + [key: string]: Aggregation; +} + +interface FilterOptions { + [key: string]: string[]; +} + +interface QueryParams { + pageSize: number; + currentPage: number; + filters: FilterOptions; + query?: string; + sort?: string; + order?: string; + types?: string; // New: comma-separated list of types to search +} + +type Action = + | { type: 'SET_PAGE_SIZE'; payload: number } + | { type: 'SET_CURRENT_PAGE'; payload: number } + | { type: 'SET_FILTERS'; payload: { category: string; values: string[] } } + | { type: 'REMOVE_FILTER'; payload: { category: string; value: string } } + | { type: 'SET_QUERY'; payload: string } + | { type: 'SET_SORT'; payload: string } + | { type: 'SET_ORDER'; payload: string } + | { type: 'SET_TYPES'; payload: string } + | { type: 'INITIALIZE'; payload: QueryParams }; + +// Initial State +const initialState: QueryParams = { + pageSize: 9, + currentPage: 1, + filters: {}, + query: '', + sort: 'recent', + order: '', + types: 'dataset,usecase,aimodel', // Default: search all types +}; + +// Query Reducer +const queryReducer = (state: QueryParams, action: Action): QueryParams => { + switch (action.type) { + case 'SET_PAGE_SIZE': + return { ...state, pageSize: action.payload, currentPage: 1 }; + case 'SET_CURRENT_PAGE': + return { ...state, currentPage: action.payload }; + case 'SET_FILTERS': + return { + ...state, + filters: { + ...state.filters, + [action.payload.category]: action.payload.values, + }, + currentPage: 1, + }; + case 'REMOVE_FILTER': { + const newFilters = { ...state.filters }; + newFilters[action.payload.category] = newFilters[ + action.payload.category + ].filter((v) => v !== action.payload.value); + return { ...state, filters: newFilters, currentPage: 1 }; + } + case 'SET_QUERY': + return { ...state, query: action.payload, currentPage: 1 }; + case 'SET_SORT': + return { ...state, sort: action.payload }; + case 'SET_ORDER': + return { ...state, order: action.payload }; + case 'SET_TYPES': + return { ...state, types: action.payload, currentPage: 1 }; + case 'INITIALIZE': + return { ...state, ...action.payload }; + default: + return state; + } +}; + +// URL Params Hook +const useUrlParams = ( + queryParams: QueryParams, + setQueryParams: React.Dispatch, + setVariables: (vars: string) => void +) => { + const router = useRouter(); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const sizeParam = urlParams.get('size'); + const pageParam = urlParams.get('page'); + const typesParam = urlParams.get('types'); + const filters: FilterOptions = {}; + + urlParams.forEach((val, key) => { + if (!['size', 'page', 'query', 'types'].includes(key)) { + filters[key] = val.split(','); + } + }); + + const initialParams: QueryParams = { + pageSize: sizeParam ? Number(sizeParam) : 9, + currentPage: pageParam ? Number(pageParam) : 1, + filters, + query: urlParams.get('query') || '', + types: typesParam || 'dataset,usecase,aimodel', + }; + + setQueryParams({ type: 'INITIALIZE', payload: initialParams }); + }, [setQueryParams]); + + useEffect(() => { + const filtersString = Object.entries(queryParams.filters) + .filter(([_key, values]) => values.length > 0) + .map(([key, values]) => `${key}=${values.join(',')}`) + .join('&'); + + const searchParam = queryParams.query + ? `&query=${encodeURIComponent(queryParams.query)}` + : ''; + const sortParam = queryParams.sort + ? `&sort=${encodeURIComponent(queryParams.sort)}` + : ''; + const orderParam = queryParams.order + ? `&order=${encodeURIComponent(queryParams.order)}` + : ''; + const typesParam = queryParams.types + ? `&types=${encodeURIComponent(queryParams.types)}` + : ''; + + const variablesString = `?${filtersString}&size=${queryParams.pageSize}&page=${queryParams.currentPage}${searchParam}${sortParam}${orderParam}${typesParam}`; + setVariables(variablesString); + + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.set('size', queryParams.pageSize.toString()); + currentUrl.searchParams.set('page', queryParams.currentPage.toString()); + + Object.entries(queryParams.filters).forEach(([key, values]) => { + if (values.length > 0) { + currentUrl.searchParams.set(key, values.join(',')); + } else { + currentUrl.searchParams.delete(key); + } + }); + + if (queryParams.query) { + currentUrl.searchParams.set('query', queryParams.query); + } else { + currentUrl.searchParams.delete('query'); + } + if (queryParams.sort) { + currentUrl.searchParams.set('sort', queryParams.sort); + } else { + currentUrl.searchParams.delete('sort'); + } + if (queryParams.order) { + currentUrl.searchParams.set('order', queryParams.order); + } else { + currentUrl.searchParams.delete('order'); + } + if (queryParams.types) { + currentUrl.searchParams.set('types', queryParams.types); + } else { + currentUrl.searchParams.delete('types'); + } + + router.replace(currentUrl.toString()); + }, [queryParams, setVariables, router]); +}; + +// Fetch unified search data +const fetchUnifiedData = async (variables: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/search/unified/${variables}` + ); + const data = await response.json(); + return data; +}; + +// Listing Component Props +interface UnifiedListingProps { + breadcrumbData?: { href: string; label: string }[]; + placeholder: string; + redirectionURL: string; +} + +const UnifiedListingComponent: React.FC = ({ + breadcrumbData, + placeholder, + redirectionURL, +}) => { + const [facets, setFacets] = useState<{ + results: any[]; + total: number; + aggregations: Aggregations; + types_searched: string[]; + } | null>(null); + const [variables, setVariables] = useState(''); + const [open, setOpen] = useState(false); + const [queryParams, setQueryParams] = useReducer(queryReducer, initialState); + const [view, setView] = useState<'collapsed' | 'expanded'>('collapsed'); + + const count = facets?.total ?? 0; + const results = facets?.results ?? []; + + useUrlParams(queryParams, setQueryParams, setVariables); + const latestFetchId = useRef(0); + + useEffect(() => { + if (variables) { + const currentFetchId = ++latestFetchId.current; + + fetchUnifiedData(variables) + .then((res) => { + if (currentFetchId === latestFetchId.current) { + setFacets(res); + } + }) + .catch((err) => { + console.error(err); + }); + } + }, [variables]); + + const [hasMounted, setHasMounted] = useState(false); + + useEffect(() => { + setHasMounted(true); + }, []); + + if (!hasMounted) return ; + + const handlePageChange = (newPage: number) => { + setQueryParams({ type: 'SET_CURRENT_PAGE', payload: newPage }); + }; + + const handlePageSizeChange = (newSize: number) => { + setQueryParams({ type: 'SET_PAGE_SIZE', payload: newSize }); + }; + + const handleFilterChange = (category: string, values: string[]) => { + setQueryParams({ type: 'SET_FILTERS', payload: { category, values } }); + }; + + const handleRemoveFilter = (category: string, value: string) => { + setQueryParams({ type: 'REMOVE_FILTER', payload: { category, value } }); + }; + + const handleSearch = (searchTerm: string) => { + setQueryParams({ type: 'SET_QUERY', payload: searchTerm }); + }; + + const handleSortChange = (sortOption: string) => { + setQueryParams({ type: 'SET_SORT', payload: sortOption }); + }; + + const handleOrderChange = (sortOrder: string) => { + setQueryParams({ type: 'SET_ORDER', payload: sortOrder }); + }; + + const handleTypeFilter = (types: string) => { + setQueryParams({ type: 'SET_TYPES', payload: types }); + }; + + const aggregations: Aggregations = facets?.aggregations || {}; + + const filterOptions = Object.entries(aggregations).reduce( + (acc: Record, [key, _value]) => { + // Skip the 'types' aggregation from filters + if (key === 'types') return acc; + + // Check if _value exists and has buckets array (Elasticsearch format) + if (_value && _value.buckets && Array.isArray(_value.buckets)) { + acc[key] = _value.buckets.map((bucket) => ({ + label: bucket.key, + value: bucket.key, + })); + } + // Handle key-value object format (current backend format) + else if (_value && typeof _value === 'object' && !Array.isArray(_value)) { + acc[key] = Object.entries(_value).map(([label]) => ({ + label: label, + value: label, + })); + } + return acc; + }, + {} + ); + + // Get type counts from aggregations + const typeCounts = aggregations.types || {}; + + // Helper function to get redirect URL based on type + const getRedirectUrl = (item: any) => { + switch (item.type) { + case 'dataset': + return `/datasets/${item.id}`; + case 'usecase': + return `/usecases/${item.id}`; + case 'aimodel': + return `/aimodels/${item.id}`; + default: + return `${redirectionURL}/${item.id}`; + } + }; + + + return ( +
+ {breadcrumbData && } +
+
+
+
+ +
+ +
+ {/* Type Filter Buttons */} +
+ + + + +
+ +
+
+ handleSearch(value)} + onClear={(value) => handleSearch(value)} + /> +
+
+
+ + + + +
+
+ +
+
+