diff --git a/web/src/api/core.ts b/web/src/api/core.ts index 1f8ce808bcd..ae4521d4cb8 100644 --- a/web/src/api/core.ts +++ b/web/src/api/core.ts @@ -4,6 +4,7 @@ import type { SearchRepoInfo, UserInfo, UserType, + SearchOrgInfo, } from '@ossinsight/api'; import { AxiosAdapter, @@ -78,6 +79,12 @@ export async function searchUser ( .then(({ data }) => data); } +export async function searchOrg (keyword: string): Promise { + return await client + .get('/gh/organizations/search', { params: { keyword } }) + .then(({ data }) => data); +} + export async function getCollections (): Promise> { return await client.get('/collections'); } diff --git a/web/src/components/GeneralSearch/index.tsx b/web/src/components/GeneralSearch/index.tsx index 70b69ccf2d4..7d9d96cc3e6 100644 --- a/web/src/components/GeneralSearch/index.tsx +++ b/web/src/components/GeneralSearch/index.tsx @@ -22,8 +22,9 @@ import { Tabs, TextField, Typography, + ListSubheader, } from '@mui/material'; -import { SearchRepoInfo, UserInfo } from '@ossinsight/api'; +import { SearchRepoInfo, UserInfo, SearchOrgInfo } from '@ossinsight/api'; import React, { FC, ForwardedRef, forwardRef, @@ -41,7 +42,7 @@ import KeyboardUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardReturnIcon from '@mui/icons-material/KeyboardReturn'; import { AutocompleteHighlightChangeReason } from '@mui/base/AutocompleteUnstyled/useAutocomplete'; -import { notNullish } from '@site/src/utils/value'; +import { notNullish, isFalsy } from '@site/src/utils/value'; export interface GeneralSearchProps { contrast?: boolean; @@ -50,16 +51,16 @@ export interface GeneralSearchProps { global?: boolean; } -type Option = SearchRepoInfo | UserInfo; +type Option = SearchRepoInfo | UserInfo | SearchOrgInfo; const isOptionEqual = (a: Option, b: Option) => { return a.id === b.id; }; -const getOptionLabel = (option: Option) => (option as SearchRepoInfo).fullName || (option as UserInfo).login; +const getOptionLabel = (option: Option) => (option as SearchRepoInfo).fullName || (option as UserInfo | SearchOrgInfo).login || (option as any).label; const useTabs = () => { - const [type, setType] = useState('user'); + const [type, setType] = useState('all'); const handleTypeChange = useEventCallback((_: any, type: SearchType) => { setType(type); @@ -68,14 +69,22 @@ const useTabs = () => { const tabs = useMemo(() => { return ( + + ); }, [type]); const next = useEventCallback(() => { - setType(type => type === 'user' ? 'repo' : 'user'); + const types: SearchType[] = ['all', 'user', 'repo', 'org']; + const idx = types.findIndex(t => t === type); + if (idx >= types.length - 1) { + setType('all'); + return; + } + setType(types[idx + 1]); }); return [type, tabs, next] as const; @@ -163,6 +172,23 @@ export const renderRepo = (props: ListItemProps, option: Option, highlight: bool ); }; +export const renderSubListItem = (props: ListItemProps, option: any) => { + return ( + <> + + {option?.label} + + + ); +}; + const GeneralSearch: FC = ({ contrast, align = 'left', size, global = false }: GeneralSearchProps) => { const [keyword, setKeyword] = useState(''); const [type, tabs, next] = useTabs(); @@ -183,6 +209,18 @@ const GeneralSearch: FC = ({ contrast, align = 'left', size, case 'user': history.push(`/analyze/${(option as UserInfo).login}`); break; + case 'org': + typeof window !== 'undefined' && (window.location.href = `https://next.ossinsight.io/analyze/${(option as UserInfo).login}`); + break; + case 'all': + if ((option as any).type === 'Repo') { + history.push(`/analyze/${(option as SearchRepoInfo).fullName}`); + } else if ((option as any).type === 'User') { + history.push(`/analyze/${(option as UserInfo).login}`); + } else if ((option as any).type === 'Org') { + typeof window !== 'undefined' && (window.location.href = `https://next.ossinsight.io/analyze/${(option as UserInfo).login}`); + } + break; } }, [type]); @@ -201,11 +239,15 @@ const GeneralSearch: FC = ({ contrast, align = 'left', size, const placeholder = useMemo(() => { if (!open) { - return 'Search a developer or repo'; + return 'Search a developer/repo/org'; } else if (type === 'user') { return 'Enter a GitHub ID'; - } else { + } else if (type === 'repo') { return 'Enter a GitHub Repo Name'; + } else if (type === 'org') { + return 'Enter a GitHub Org Name'; + } else { + return 'Enter a GitHub ID/Repo/Org Name'; } }, [open, type]); @@ -250,6 +292,9 @@ const GeneralSearch: FC = ({ contrast, align = 'left', size, onClose={handleClose} loading={loading} options={list ?? []} + groupBy={(option: any) => { + return option?.type; + }} isOptionEqualToValue={isOptionEqual} getOptionLabel={getOptionLabel} value={option} @@ -258,103 +303,142 @@ const GeneralSearch: FC = ({ contrast, align = 'left', size, onInputChange={handleInputChange} onHighlightChange={handleHighlightChange} forcePopupIcon={false} - sx={useMemo(() => ({ - maxWidth: size === 'large' ? 540 : 300, - flex: 1, - }), [size])} - renderOption={useCallback((props, option) => ( - type === 'repo' - ? renderRepo(props, option, highlight === option) - : renderUser(props, option, highlight === option) - ), [type, highlight])} - renderInput={useCallback(({ InputProps, ...params }) => ( - ({ - backgroundColor: contrast ? '#E9EAEE' : '#3c3c3c', - borderRadius: 2, - color: contrast ? theme.palette.getContrastText('#E9EAEE') : undefined, - '.MuiAutocomplete-input': { - textAlign: align, - }, - '.MuiOutlinedInput-notchedOutline': { - border: 'none', - }, - fontSize: size === 'large' ? 24 : undefined, - py: size === 'large' ? '4px !important' : undefined, - })} - inputRef={inputRef} - InputProps={{ - ...InputProps, - onKeyDown: handleKeyDown, - sx: theme => ({ + sx={useMemo( + () => ({ + maxWidth: size === 'large' ? 540 : 300, + flex: 1, + }), + [size], + )} + renderOption={useCallback( + (props, option) => { + if (!isFalsy(option?.login)) { + return renderUser(props, option, highlight === option); + } + if (!isFalsy(option?.fullName)) { + return renderRepo(props, option, highlight === option); + } + }, + [type, highlight], + )} + renderInput={useCallback( + ({ InputProps, ...params }) => ( + ({ backgroundColor: contrast ? '#E9EAEE' : '#3c3c3c', - color: contrast ? theme.palette.getContrastText('#E9EAEE') : undefined, - }), - startAdornment: ( - - ({ color: contrast ? theme.palette.getContrastText('#E9EAEE') : undefined })} /> - - ), - endAdornment: loading - ? ( - - - - ) - : (global && !open && !option) ? : undefined, - }} - /> - ), [open, global, contrast, align, size, loading, option])} - noOptionsText={( + borderRadius: 2, + color: contrast + ? theme.palette.getContrastText('#E9EAEE') + : undefined, + '.MuiAutocomplete-input': { + textAlign: align, + }, + '.MuiOutlinedInput-notchedOutline': { + border: 'none', + }, + fontSize: size === 'large' ? 24 : undefined, + py: size === 'large' ? '4px !important' : undefined, + })} + inputRef={inputRef} + InputProps={{ + ...InputProps, + onKeyDown: handleKeyDown, + sx: (theme) => ({ + backgroundColor: contrast ? '#E9EAEE' : '#3c3c3c', + color: contrast + ? theme.palette.getContrastText('#E9EAEE') + : undefined, + }), + startAdornment: ( + + ({ + color: contrast + ? theme.palette.getContrastText('#E9EAEE') + : undefined, + })} + /> + + ), + endAdornment: loading + ? ( + + + + ) + : global && !open && !option + ? ( + + ) + : undefined, + }} + /> + ), + [open, global, contrast, align, size, loading, option], + )} + noOptionsText={ {tabs} - - No {type} - + {type !== 'all' && (No {type})} - )} - loadingText={( + } + loadingText={ {tabs} - - - + + + + } + ListboxComponent={useCallback( + forwardRef(function SearchList ( + { children, ...props }: React.HTMLAttributes, + ref: ForwardedRef, + ) { + return ( + + {tabs} + + {children} + + + + + + + } /> + } /> + + + + + + + } /> + + + + + ); + }), + [tabs], )} - ListboxComponent={useCallback(forwardRef(function SearchList ({ children, ...props }: React.HTMLAttributes, ref: ForwardedRef) { - return ( - - {tabs} - - {children} - - - - - - - } /> - } /> - - - - - - - } /> - - - - - ); - }), [tabs])} PopperComponent={CustomPopper} /> ); diff --git a/web/src/components/GeneralSearch/useGeneralSearch.tsx b/web/src/components/GeneralSearch/useGeneralSearch.tsx index 44df92cb1cf..ab8ba79cdcf 100644 --- a/web/src/components/GeneralSearch/useGeneralSearch.tsx +++ b/web/src/components/GeneralSearch/useGeneralSearch.tsx @@ -1,61 +1,124 @@ -import { SearchRepoInfo, UserInfo } from '@ossinsight/api'; +import { SearchOrgInfo, SearchRepoInfo, UserInfo } from '@ossinsight/api'; import useSWR from 'swr'; -import { searchRepo, searchUser } from '../../api/core'; +import { searchRepo, searchUser, searchOrg } from '../../api/core'; import { useDebounced } from '../CompareHeader/useSearchRepo'; import { AsyncData } from '../RemoteCharts/hook'; -export type SearchType = 'repo' | 'user'; +export type SearchType = 'repo' | 'user' | 'org' | 'all'; -export function useGeneralSearch (type: T, keyword: string): AsyncData<(T extends 'repo' ? SearchRepoInfo[] : UserInfo[]) | undefined> { +export function useGeneralSearch ( + type: T, + keyword: string, +): AsyncData<(T extends 'repo' ? SearchRepoInfo[] : UserInfo[]) | undefined> { const searchKey = useDebounced(keyword, 500); + const getRecommend = (type: string) => { + switch (type) { + case 'repo': + return ['recommend-repo-list-1-keyword', 'search:repo']; + case 'user': + return ['recommend-user-list-keyword', 'search:user']; + case 'org': + return ['recommend-org-list-keyword', 'search:org']; + default: + return ['', 'search:all']; + } + }; + const { data, isValidating, error } = useSWR( - searchKey - ? [searchKey, `search:${type}`] - : type === 'repo' - ? ['recommend-repo-list-1-keyword', 'search:repo'] - : ['recommend-user-list-keyword', 'search:user'], { - fetcher: async (keyword: string): Promise => { + searchKey ? [searchKey, `search:${type}`] : getRecommend(type), + { + fetcher: async ( + keyword: string, + ): Promise< + | SearchRepoInfo[] + | UserInfo[] + | SearchOrgInfo[] + | Array + > => { if (type === 'repo') { return await searchRepo(keyword); - } else { + } else if (type === 'user') { return await searchUser(keyword, 'user'); + } else if (type === 'org') { + return await searchOrg(keyword); + } else { + const tmpKeywords = { + repo: keyword || 'recommend-repo-list-1-keyword', + user: keyword || 'recommend-user-list-keyword', + org: keyword || 'recommend-org-list-keyword', + }; + return await Promise.all([ + searchRepo(tmpKeywords.repo), + searchUser(tmpKeywords.user, 'user'), + searchOrg(tmpKeywords.org), + ]).then(([repo, user, org]) => { + return [ + ...repo.map((item) => ({ ...item, type: 'Repo' })), + ...user.map((item) => ({ ...item, type: 'User' })), + ...org.map((item) => ({ ...item, type: 'Org' })), + ]; + }); } }, revalidateOnMount: true, revalidateOnReconnect: false, shouldRetryOnError: false, - }); + }, + ); return { - data: data ?? [] as any, + data: data ?? ([] as any), error, loading: isValidating, }; } -export function useGeneralSearchWithoutDefaults (type: T, keyword: string) { +export function useGeneralSearchWithoutDefaults ( + type: T, + keyword: string, +) { const searchKey = useDebounced(keyword, 500); const { data, isValidating, error } = useSWR( - searchKey - ? [searchKey, `search:${type}`] - : undefined - , { - fetcher: async (keyword: string): Promise => { + searchKey ? [searchKey, `search:${type}`] : undefined, + { + fetcher: async ( + keyword: string, + ): Promise< + | SearchRepoInfo[] + | UserInfo[] + | SearchOrgInfo[] + | Array + > => { if (type === 'repo') { return await searchRepo(keyword); - } else { + } else if (type === 'user') { return await searchUser(keyword, 'user'); + } else if (type === 'org') { + return await searchOrg(keyword); + } else { + return await Promise.all([ + searchRepo(keyword), + searchUser(keyword, 'user'), + searchOrg(keyword), + ]).then(([repo, user, org]) => { + return [ + ...repo.map((item) => ({ ...item, type: 'Repo' })), + ...user.map((item) => ({ ...item, type: 'User' })), + ...org.map((item) => ({ ...item, type: 'Org' })), + ]; + }); } }, revalidateOnMount: true, revalidateOnReconnect: false, shouldRetryOnError: false, - }); + }, + ); return { - data: data ?? [] as any, + data: data ?? ([] as any), error, loading: isValidating, }; diff --git a/web/src/dynamic-pages/analyze-user/index.tsx b/web/src/dynamic-pages/analyze-user/index.tsx index de1041d5df7..654986ebb2a 100644 --- a/web/src/dynamic-pages/analyze-user/index.tsx +++ b/web/src/dynamic-pages/analyze-user/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import CustomPage from '../../theme/CustomPage'; import OverviewSection from './sections/0-Overview'; import BehaviourSection from './sections/1-Behaviour'; @@ -79,6 +79,12 @@ function useAnalyzingUser (): AnalyzeUserContextProps { const { data, isValidating, error } = useUser(login); + useEffect(() => { + if (data?.type === 'Organization') { + location.href = 'https://next.ossinsight.io/' + location.pathname + location.search + location.hash; + } + }, [data]); + return { login, userId: data?.id, diff --git a/web/types/ossinsight.d.ts b/web/types/ossinsight.d.ts index f11d51210c7..c504aef0172 100644 --- a/web/types/ossinsight.d.ts +++ b/web/types/ossinsight.d.ts @@ -24,6 +24,12 @@ declare module '@ossinsight/api' { export interface UserInfo { id: number; login: string; + type: string; + } + + export interface SearchOrgInfo { + id: number; + login: string; } export type Collection = {