diff --git a/src/custom/InputSearchField/InputSearchField.tsx b/src/custom/InputSearchField/InputSearchField.tsx new file mode 100644 index 000000000..1557e753c --- /dev/null +++ b/src/custom/InputSearchField/InputSearchField.tsx @@ -0,0 +1,208 @@ +import { Autocomplete } from '@mui/material'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Box, Chip, CircularProgress, Grid, TextField, Tooltip, Typography } from '../../base'; +import { iconLarge, iconSmall } from '../../constants/iconsSizes'; +import { CloseIcon, OrgIcon } from '../../icons'; + +interface Option { + id: string; + name: string; +} + +interface InputSearchFieldProps { + data: Option[]; + setFilterData: (data: Option[]) => void; + label?: string; + fetchSuggestions: (value: string) => void; + isLoading: boolean; + type: string; + disabled?: boolean; + selectedData: Option[]; + searchValue: string; + setSearchValue: (value: string) => void; +} + +const InputSearchField: React.FC = ({ + data, + label, + fetchSuggestions, + setFilterData, + isLoading, + type, + disabled, + selectedData, + searchValue, + setSearchValue +}) => { + const [error, setError] = useState(''); + const [open, setOpen] = useState(false); + const [showAllItems, setShowAllItems] = useState(false); + const [localSelectedData, setLocalSelectedData] = useState(selectedData); + + // Sync local state with prop changes + useEffect(() => { + setLocalSelectedData(selectedData); + }, [selectedData]); + + const handleDelete = useCallback( + (id: string) => { + const newData = localSelectedData.filter((item) => item.id !== id); + setLocalSelectedData(newData); + setFilterData(newData); + }, + [localSelectedData, setFilterData] + ); + + const handleAdd = useCallback( + (_event: React.SyntheticEvent, value: Option | null) => { + if (!value) return; + + // Check for duplicates + const isDuplicate = localSelectedData.some((item) => item.id === value.id); + if (isDuplicate) { + setError(`${type} already selected`); + return; + } + + // Update both local and parent state + const newData = [...localSelectedData, value]; + setLocalSelectedData(newData); + setFilterData(newData); + setError(''); + setSearchValue(''); + setOpen(false); + }, + [localSelectedData, setFilterData, type, setSearchValue] + ); + + const handleInputChange = useCallback( + (_event: React.SyntheticEvent, value: string) => { + setSearchValue(value); + if (value === '') { + setOpen(false); + } else { + const encodedValue = encodeURIComponent(value); + fetchSuggestions(encodedValue); + setError(''); + setOpen(true); + } + }, + [fetchSuggestions, setSearchValue] + ); + + return ( + + searchValue} + isOptionEqualToValue={(option: Option, value: Option) => option.id === value.id} + noOptionsText={isLoading ? 'Loading...' : `No ${type} found`} + loading={isLoading} + open={open} + onClose={() => setOpen(false)} + disabled={disabled} + value={undefined} + inputValue={searchValue} + onChange={handleAdd} + onInputChange={handleInputChange} + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + filterOptions={(x) => x} + disableClearable + includeInputInList + filterSelectedOptions + disableListWrap + clearOnBlur + popupIcon={null} + blurOnSelect + forcePopupIcon={false} + renderInput={(params) => ( + + {isLoading ? : null} + + ) + }} + /> + )} + renderOption={(props, option: Option) => ( +
  • + img': { mr: 2, flexShrink: 0 } }}> + + + + + + + + {option.name} + + + +
  • + )} + /> + + 0 ? '0.5rem' : '' + }} + > + {!showAllItems && localSelectedData?.length > 0 && ( + } + label={localSelectedData[localSelectedData.length - 1]?.name} + size="small" + onDelete={() => handleDelete(localSelectedData[localSelectedData.length - 1]?.id)} + deleteIcon={ + + + + } + /> + )} + {showAllItems && + localSelectedData?.map((obj) => ( + } + label={obj.name} + size="small" + onDelete={() => handleDelete(obj.id)} + deleteIcon={ + + + + } + /> + ))} + {localSelectedData?.length > 1 && ( + setShowAllItems(!showAllItems)} + sx={{ + cursor: 'pointer' + }} + > + {showAllItems ? '(hide)' : `(+${localSelectedData?.length - 1})`} + + )} + +
    + ); +}; + +export default InputSearchField; diff --git a/src/custom/InputSearchField/index.ts b/src/custom/InputSearchField/index.ts new file mode 100644 index 000000000..6ad430f53 --- /dev/null +++ b/src/custom/InputSearchField/index.ts @@ -0,0 +1,3 @@ +import InputSearchField from './InputSearchField'; + +export { InputSearchField }; diff --git a/src/custom/UserSearchField/UserSearchFieldInput.tsx b/src/custom/UserSearchField/UserSearchFieldInput.tsx new file mode 100644 index 000000000..c2f79b076 --- /dev/null +++ b/src/custom/UserSearchField/UserSearchFieldInput.tsx @@ -0,0 +1,320 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { Autocomplete } from '@mui/material'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Avatar, + Box, + Checkbox, + Chip, + CircularProgress, + FormControlLabel, + FormGroup, + Grid, + TextField, + Tooltip, + Typography +} from '../../base'; +import { iconSmall } from '../../constants/iconsSizes'; +import { CloseIcon, PersonIcon } from '../../icons'; + +interface User { + id: string; + first_name: string; + last_name: string; + email: string; + avatar_url?: string; + deleted_at?: { Valid: boolean }; + deleted?: boolean; +} + +interface UserSearchFieldProps { + usersData: User[]; + setUsersData: React.Dispatch>; + label?: string; + setDisableSave?: (disable: boolean) => void; + handleNotifyPref?: () => void; + notifyUpdate?: boolean; + isCreate?: boolean; + searchType?: string; + disabled?: boolean; + currentUserData: User | null; + searchedUsers: User[]; + isUserSearchLoading: boolean; + fetchSearchedUsers: (value: string) => void; + usersSearch: string; + setUsersSearch: React.Dispatch>; +} + +const UserSearchField: React.FC = ({ + usersData, + setUsersData, + label = 'Add User', + setDisableSave, + handleNotifyPref, + notifyUpdate, + isCreate, + searchType, + disabled = false, + currentUserData, + searchedUsers = [], + isUserSearchLoading, + fetchSearchedUsers, + usersSearch, + setUsersSearch +}) => { + const [error, setError] = useState(''); + const [open, setOpen] = useState(false); + const [showAllUsers, setShowAllUsers] = useState(false); + const [hasInitialFocus, setHasInitialFocus] = useState(true); + const [inputValue, setInputValue] = useState(''); + const [localUsersData, setLocalUsersData] = useState(usersData || []); + + useEffect(() => { + setLocalUsersData(usersData || []); + }, [usersData]); + + const displayOptions = useMemo(() => { + if (hasInitialFocus && !usersSearch && currentUserData) { + return [currentUserData]; + } + + const filteredResults = searchedUsers.filter( + (user: User) => + user.id !== currentUserData?.id && + !localUsersData.some((selectedUser) => selectedUser.id === user.id) && + !user.deleted_at?.Valid + ); + + if (!usersSearch && currentUserData) { + return [currentUserData, ...filteredResults]; + } + + return filteredResults; + }, [searchedUsers, currentUserData, usersSearch, hasInitialFocus, localUsersData]); + + const handleDelete = useCallback( + (idToDelete: string, event: React.MouseEvent) => { + event.stopPropagation(); + + const updatedUsers = localUsersData.filter((user) => user.id !== idToDelete); + setLocalUsersData(updatedUsers); + setUsersData(updatedUsers); + + if (setDisableSave) { + setDisableSave(false); + } + }, + [localUsersData, setUsersData, setDisableSave, fetchSearchedUsers, inputValue] + ); + + const handleAdd = useCallback( + (event: React.SyntheticEvent, value: User | null) => { + if (!value) return; + + const isDuplicate = localUsersData.some((user) => user.id === value.id); + const isDeleted = value.deleted_at?.Valid === true; + + if (isDuplicate || isDeleted) { + setError(isDuplicate ? 'User already selected' : 'User does not exist'); + return; + } + setInputValue(''); + setUsersSearch(''); + setError(''); + setOpen(false); + + setLocalUsersData((prev) => [...prev, value]); + setUsersData((prev) => [...prev, value]); + + if (setDisableSave) { + setDisableSave(false); + } + }, + [localUsersData, setUsersData, setDisableSave, setUsersSearch] + ); + + const handleInputChange = useCallback( + (event: React.SyntheticEvent, newValue: string) => { + setInputValue(newValue); + + if (newValue === '') { + setOpen(true); + setUsersSearch(''); + setHasInitialFocus(true); + } else { + const encodedValue = encodeURIComponent(newValue); + fetchSearchedUsers(encodedValue); + setError(''); + setOpen(true); + setHasInitialFocus(false); + } + }, + [fetchSearchedUsers] + ); + + return ( + <> + inputValue} + isOptionEqualToValue={(option, value) => option.id === value.id} + onOpen={() => setOpen(true)} + onClose={() => setOpen(false)} + inputValue={inputValue} + onChange={handleAdd} + onInputChange={handleInputChange} + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + filterOptions={(options, { inputValue }) => { + return options.filter((option: User) => { + const searchStr = inputValue.toLowerCase(); + return ( + option.first_name?.toLowerCase().includes(searchStr) || + option.last_name?.toLowerCase().includes(searchStr) || + option.email?.toLowerCase().includes(searchStr) + ); + }); + }} + loading={isUserSearchLoading} + disabled={disabled} + disableClearable + value={undefined} + selectOnFocus={false} + blurOnSelect={true} + clearOnBlur={true} + popupIcon={null} + forcePopupIcon={false} + noOptionsText={isUserSearchLoading ? 'Loading...' : 'No users found'} + renderInput={(params) => ( + + {isUserSearchLoading ? : null} + + ) + }} + /> + )} + renderOption={(props, option: User) => ( +
  • + img': { mr: 2, flexShrink: 0 } }}> + {' '} + + + + + {option.avatar_url ? '' : } + + + + + {option.deleted ? ( + + {option.email} (deleted) + + ) : ( + <> + + {option.first_name} {option.last_name} + + + {option.email} + + + )} + + + +
  • + )} + /> + + {!isCreate && ( + +
    + + } + label={`Notify ${searchType} of membership change`} + /> +
    +
    + )} + 0 ? '0.5rem' : '' + }} + > + {!showAllUsers && localUsersData?.[0] && ( + + {!localUsersData[0].avatar_url && localUsersData[0].first_name?.[0]} + + } + label={localUsersData[0].email} + onDelete={(e) => handleDelete(localUsersData[0].id, e)} + deleteIcon={ + + + + } + size="small" + /> + )} + + {showAllUsers && + localUsersData?.map((user) => ( + + {!user.avatar_url && user.first_name?.[0]} + + } + label={user.email} + onDelete={(e) => handleDelete(user.id, e)} + deleteIcon={ + + + + } + size="small" + /> + ))} + + {localUsersData?.length > 1 && ( + setShowAllUsers(!showAllUsers)} + sx={{ + cursor: 'pointer' + }} + > + {showAllUsers ? '(hide)' : `(+${localUsersData.length - 1})`} + + )} + + + ); +}; + +export default UserSearchField; diff --git a/src/custom/UserSearchField/index.ts b/src/custom/UserSearchField/index.ts new file mode 100644 index 000000000..7cf9a5ea5 --- /dev/null +++ b/src/custom/UserSearchField/index.ts @@ -0,0 +1,3 @@ +import UserSearchField from './UserSearchFieldInput'; + +export { UserSearchField }; diff --git a/src/custom/index.tsx b/src/custom/index.tsx index 8442e7f30..a0ace46c2 100644 --- a/src/custom/index.tsx +++ b/src/custom/index.tsx @@ -47,6 +47,7 @@ export { CatalogCard } from './CatalogCard'; export { CatalogFilterSidebar } from './CatalogFilterSection'; export type { FilterListType } from './CatalogFilterSection'; export { StyledChartDialog } from './ChartDialog'; +export { InputSearchField } from './InputSearchField'; export { LearningContent } from './LearningContent'; export { NavigationNavbar } from './NavigationNavbar'; export { Note } from './Note'; @@ -56,6 +57,7 @@ export { StyledSearchBar } from './StyledSearchBar'; export { TOC } from './TOCChapter'; export { TOCLearning } from './TOCLearning'; export { Terminal } from './Terminal'; +export { UserSearchField } from './UserSearchField'; export { ActionButton, BookmarkNotification, diff --git a/src/icons/Organization/OrgIcon.tsx b/src/icons/Organization/OrgIcon.tsx new file mode 100644 index 000000000..94ea08d68 --- /dev/null +++ b/src/icons/Organization/OrgIcon.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +interface OrgIconProps { + width?: number; + height?: number; + fill?: string; + secondaryFill?: string; +} + +const OrgIcon: React.FC = ({ + width = 24, + height = 24, + fill = '#F6F8F8', + secondaryFill = '#294957' +}) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default OrgIcon; diff --git a/src/icons/Organization/index.ts b/src/icons/Organization/index.ts new file mode 100644 index 000000000..064cfa999 --- /dev/null +++ b/src/icons/Organization/index.ts @@ -0,0 +1,2 @@ +import OrgIcon from './OrgIcon'; +export { OrgIcon }; diff --git a/src/icons/Person/PersonIcon.tsx b/src/icons/Person/PersonIcon.tsx new file mode 100644 index 000000000..fcb81007c --- /dev/null +++ b/src/icons/Person/PersonIcon.tsx @@ -0,0 +1,21 @@ +const PersonIcon = ({ + width = '24px', + height = '24px', + fill = 'currentColor', + style = {}, + onClick = () => {} +}) => ( + + + +); + +export default PersonIcon; diff --git a/src/icons/Person/index.ts b/src/icons/Person/index.ts new file mode 100644 index 000000000..c31dfbfe5 --- /dev/null +++ b/src/icons/Person/index.ts @@ -0,0 +1 @@ +export { default as PersonIcon } from './PersonIcon'; diff --git a/src/icons/index.ts b/src/icons/index.ts index e280c8992..6a4bc8dfa 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -65,8 +65,10 @@ export * from './Menu'; export * from './MesheryFilter'; export * from './MesheryOperator'; export * from './Open'; +export * from './Organization'; export * from './PanTool'; export * from './Pattern'; +export * from './Person'; export * from './Pod'; export * from './Publish'; export * from './Question';