diff --git a/src/custom/Modal/index.tsx b/src/custom/Modal/index.tsx index f551c8d5..5f05d172 100644 --- a/src/custom/Modal/index.tsx +++ b/src/custom/Modal/index.tsx @@ -10,8 +10,8 @@ import { CustomTooltip } from '../CustomTooltip'; interface ModalProps extends DialogProps { closeModal: () => void; title: string; - headerIcon: React.ReactNode; - reactNode: React.ReactNode; + headerIcon?: React.ReactNode; + reactNode?: React.ReactNode; } interface ModalFooterProps { diff --git a/src/custom/ShareModal/ShareModal.tsx b/src/custom/ShareModal/ShareModal.tsx new file mode 100644 index 00000000..2993de64 --- /dev/null +++ b/src/custom/ShareModal/ShareModal.tsx @@ -0,0 +1,314 @@ +import { SelectChangeEvent } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { + Avatar, + IconButton, + List, + ListItem, + ListItemAvatar, + ListItemSecondaryAction, + ListItemText, + MenuItem, + Typography +} from '../../base'; +import { ChainIcon, DeleteIcon, LockIcon, PublicIcon } from '../../icons'; +import { useTheme } from '../../theme'; +import { BLACK, WHITE } from '../../theme/colors'; +import { Modal, ModalBody, ModalButtonPrimary, ModalButtonSecondary, ModalFooter } from '../Modal'; +import { UserSearchField } from '../UserSearchField'; +import { + CustomDialogContentText, + CustomListItemText, + CustomSelect, + FormControlWrapper, + IconButtonWrapper, + ListWrapper, + VisibilityIconWrapper +} from './style'; + +const options = { + PUBLIC: 'Anyone with the link can edit', + PRIVATE: 'Only people with access can open with the link' +}; + +const SHARE_MODE = { + PRIVATE: 'private', + PUBLIC: 'public' +}; + +interface User { + id: string; + user_id: string; + first_name: string; + last_name: string; + email: string; + avatar_url?: string; + deleted_at?: { Valid: boolean }; +} + +interface AccessListProps { + accessList: User[]; + ownerData: User; + handleDelete: (email: string) => void; + hostURL?: string | null; +} + +/** + * Custom component to show users list with delete icon and owner tag + */ +const AccessList: React.FC = ({ + accessList, + ownerData, + handleDelete, + hostURL +}: AccessListProps) => { + const openInNewTab = (url: string) => { + window.open(url, '_blank', 'noreferrer'); + }; + + const theme = useTheme(); + + return ( + <> + {accessList.length > 0 && ( + + People with Access + + )} + + + {accessList.map((actorData) => ( + + + { + hostURL && openInNewTab(`${hostURL}/user/${actorData.id}`); + }} + /> + + + + {ownerData.id === actorData.id ? ( +
Owner
+ ) : ( + handleDelete(actorData.email)} + > + + + )} +
+
+ ))} +
+
+ + ); +}; + +interface SelectedResource { + visibility: string; + name: string; + [key: string]: unknown; +} + +interface ShareModalProps { + /** Function to close the share modal */ + handleShareModalClose: () => void; + /** The resource that is selected for sharing.*/ + selectedResource: SelectedResource; + /** The name of the data being shared, like design or filter */ + dataName: string; + /** Data of the user who owns the resource */ + ownerData: User; + /** Function to fetch the list of users who have access to the resource */ + fetchAccessActors: () => Promise; + /** Function to handle the sharing of the resource with specified users and options */ + handleShare: (shareUserData: User[], selectedOption: string | undefined) => void; + /** Optional URL of the host application. Defaults to `null` if not provided */ + hostURL?: string | null; + /** + * Optional URL of the resource. Defaults to empty string if not provided + * Resource URL will be the URL which user will copy with Copy Link Button + */ + resourceURL?: string; + /** Optional flag to disable the visibility selector. Defaults to `false` if not provided */ + isVisibilitySelectorDisabled?: boolean; + /** + * Function to fetch user suggestions based on the input value. + * @param {string} value - The input value for which suggestions are to be fetched. + * @returns {Promise} A promise that resolves to an array of user suggestions. + */ + fetchSuggestions: (value: string) => Promise; + handleCopy: () => void; +} + +/** + * ShareModal component allows sharing a resource with specified users + * and configuring visibility options. + */ +const ShareModal: React.FC = ({ + handleShareModalClose, + selectedResource, + dataName, + ownerData, + fetchAccessActors, + handleShare, + hostURL = null, + handleCopy, + + isVisibilitySelectorDisabled = false, + fetchSuggestions +}: ShareModalProps): JSX.Element => { + const theme = useTheme(); + const [openMenu, setMenu] = useState(false); + const [selectedOption, setOption] = useState(selectedResource?.visibility); + const [shareUserData, setShareUserData] = useState([]); + + const handleDelete = (email: string) => { + setShareUserData((prevData) => prevData.filter((user) => user.email !== email)); + }; + + const handleOptionClick = (event: SelectChangeEvent) => { + const value = event.target.value as string; + setOption(value); + }; + + const handleMenuClose = () => setMenu(false); + + const isShareDisabled = () => { + const existingAccessIds = shareUserData.map((user) => user.id); + const ownerDataId = ownerData?.id; + + if (ownerDataId) { + existingAccessIds.push(ownerDataId); + } + + const hasMismatchedUsers = !shareUserData.every((user) => existingAccessIds.includes(user.id)); + + return ( + shareUserData.length === existingAccessIds.length && + !hasMismatchedUsers && + (selectedOption === selectedResource?.visibility || + shareUserData.length !== existingAccessIds.length) + ); + }; + + useEffect(() => { + const fetchActors = async () => { + const actors = await fetchAccessActors(); + setShareUserData(actors); + }; + fetchActors(); + }, [fetchAccessActors]); + + useEffect(() => { + if (selectedResource) { + setOption(selectedResource?.visibility); + } + }, [selectedResource]); + + return ( +
+ + + + } + fetchSuggestions={fetchSuggestions} + /> + + General Access + + + +
+ + {selectedOption === SHARE_MODE.PUBLIC ? ( + + ) : ( + + )} + +
+ setMenu(true)} + onChange={handleOptionClick} + disabled={isVisibilitySelectorDisabled} + > + {Object.values(SHARE_MODE).map((option) => ( + + {option.charAt(0).toUpperCase() + option.slice(1)} + + ))} + + + {selectedOption === SHARE_MODE.PRIVATE ? options.PRIVATE : options.PUBLIC} + +
+
+
+
+
+ + + + + + + Copy Link + + handleShare(shareUserData, selectedOption)} + > + Share + + +
+
+ ); +}; + +export default ShareModal; diff --git a/src/custom/ShareModal/index.tsx b/src/custom/ShareModal/index.tsx new file mode 100644 index 00000000..47ba3828 --- /dev/null +++ b/src/custom/ShareModal/index.tsx @@ -0,0 +1,3 @@ +import ShareModal from './ShareModal'; + +export { ShareModal }; diff --git a/src/custom/ShareModal/style.tsx b/src/custom/ShareModal/style.tsx new file mode 100644 index 00000000..6139e9e9 --- /dev/null +++ b/src/custom/ShareModal/style.tsx @@ -0,0 +1,70 @@ +import { styled } from '@mui/material'; +import { DialogContentText, FormControl, ListItemText, Select, Typography } from '../../base'; + +export const CustomText = styled(Typography)(() => ({ + fontFamily: 'Qanelas Soft, sans-serif', + '&.MuiTypography-root': { + fontFamily: 'Qanelas Soft, sans-serif' + } +})); + +export const CustomListItemText = styled(ListItemText)(() => ({ + display: 'flex', + justifyContent: 'space-between' +})); + +export const IconButtonWrapper = styled(`div`)(() => ({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginRight: '0.2rem' +})); + +export const VisibilityIconWrapper = styled(`div`)(({ theme }) => ({ + width: '36px', + height: '36px', + background: theme.palette.background.hover, + borderRadius: '20px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginRight: '1rem' +})); + +export const FormControlWrapper = styled(FormControl)(() => ({ + width: '100%' +})); + +export const ListWrapper = styled(`div`)(() => ({ + maxHeight: '16rem', + overflowY: 'auto' +})); + +export const CustomSelect = styled(Select)(() => ({ + width: '6rem', + boxShadow: 'none', + '&:before': { + display: 'none' + }, + '&:after': { + display: 'none' + }, + fontFamily: 'Qanelas Soft, sans-serif', + '&.MuiTypography-root': { + fontFamily: 'Qanelas Soft, sans-serif' + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none' + }, + '& .MuiSelect-select': { + padding: 0 + } +})); + +export const CustomDialogContentText = styled(DialogContentText)(() => ({ + display: 'flex', + justifyContent: 'space-between', + marginTop: '0.2rem', + alignItems: 'center', + alignContent: 'center' +})); diff --git a/src/custom/UserSearchField/UserSearchField.tsx b/src/custom/UserSearchField/UserSearchField.tsx new file mode 100644 index 00000000..1023f55e --- /dev/null +++ b/src/custom/UserSearchField/UserSearchField.tsx @@ -0,0 +1,237 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import CircularProgress from '@mui/material/CircularProgress'; +import React, { useState } from 'react'; +import { Avatar, Box, Chip, Grid, TextField, Tooltip, Typography } from '../../base'; +import { iconSmall } from '../../constants/iconsSizes'; +import { CloseIcon } from '../../icons/Close'; +import { PersonIcon } from '../../icons/Person'; +import { useTheme } from '../../theme'; + +interface User { + id: string; + user_id: string; + first_name: string; + last_name: string; + email: string; + avatar_url?: string; + deleted_at?: { Valid: boolean }; +} + +interface UserSearchFieldProps { + // Array of user objects currently selected. + usersData: User[]; + // Function to update the selected users data. + setUsersData: React.Dispatch>; + // Label for the text field. + label?: string; + // Function to enable or disable the save button. + setDisableSave?: (disabled: boolean) => void; + // Type of search being performed, e.g., 'user', 'admin'. + searchType?: string; + // Boolean indicating whether the search field is disabled. + disabled?: boolean; + // Custom component to change rendering style of users list, if not given + // by default it will show list with avatar and email of selected users + customUsersList?: JSX.Element; + /** + * Function to fetch user suggestions based on the input value. + * @param {string} value - The input value for which suggestions are to be fetched. + * @returns {Promise} A promise that resolves to an array of user suggestions. + */ + fetchSuggestions: (value: string) => Promise; +} + +const UserSearchField: React.FC = ({ + usersData, + setUsersData, + label, + setDisableSave, + disabled = false, + customUsersList, + fetchSuggestions +}: UserSearchFieldProps) => { + const [error, setError] = useState(false); + const [inputValue, setInputValue] = useState(undefined); + const [options, setOptions] = useState([]); + const [open, setOpen] = useState(false); + const [searchUserLoading, setSearchUserLoading] = useState(false); + const [showAllUsers, setShowAllUsers] = useState(false); + const theme = useTheme(); + + const handleDelete = (email: string) => { + setUsersData(usersData.filter((user) => user.email !== email)); + if (setDisableSave) { + setDisableSave(false); + } + }; + + const handleAdd = (_event: React.SyntheticEvent, value: User | null) => { + if (value) { + setUsersData((prevData: User[]): User[] => { + prevData = prevData || []; + const isDuplicate = prevData.some((user) => user.user_id === value.user_id); + const isDeleted = value.deleted_at?.Valid === true; + + if (isDuplicate || isDeleted) { + setError(isDuplicate ? 'User already selected' : 'User does not exist'); + return prevData; + } + + setError(false); + return [...prevData, value]; + }); + setInputValue(undefined); // Clear the input value + setOptions([]); + if (setDisableSave) { + setDisableSave(false); + } + } + }; + + const handleInputChange = (_event: React.SyntheticEvent, value: string) => { + if (value === '') { + setOptions([]); + setOpen(false); + } else { + setSearchUserLoading(true); + fetchSuggestions(value).then((filteredData) => { + setOptions(filteredData); + setSearchUserLoading(false); + }); + setError(false); + setOpen(true); + } + }; + + /** + * Clone customUsersList component to pass necessary props + */ + const clonedComponent = customUsersList + ? React.cloneElement(customUsersList, { + handleDelete: handleDelete + }) + : null; + + const renderChip = (avatarObj: User) => ( + + {avatarObj.avatar_url ? '' : avatarObj.first_name?.charAt(0)} + + } + label={avatarObj.email} + size="small" + onDelete={() => handleDelete(avatarObj.email)} + deleteIcon={ + + + + } + /> + ); + + return ( + <> + x} + options={options} + disableClearable + includeInputInList + filterSelectedOptions + disableListWrap + disabled={disabled} + open={open} + loading={searchUserLoading} + value={inputValue} + getOptionLabel={() => ''} + noOptionsText={searchUserLoading ? 'Loading...' : 'No users found'} + onChange={handleAdd} + onInputChange={handleInputChange} + isOptionEqualToValue={(option, value) => option === value} + clearOnBlur + renderInput={(params) => ( + {searchUserLoading ? : null} + ) + }} + /> + )} + renderOption={(props: React.HTMLAttributes, option) => ( + // @ts-expect-error Props need to be passed to BOX component to make sure styles getting updated + img': { mr: 2, flexShrink: 0 } }} {...props}> + + + + + {option.avatar_url ? '' : } + + + + + {option.deleted_at?.Valid ? ( + + {option.email} (deleted) + + ) : ( + <> + + {option.first_name} {option.last_name} + + + {option.email} + + + )} + + + + )} + /> + + {customUsersList ? ( + clonedComponent + ) : ( + 0 ? '0.5rem' : '' + }} + > + {showAllUsers + ? usersData?.map((avatarObj) => renderChip(avatarObj)) + : usersData?.length > 0 && renderChip(usersData[usersData.length - 1])} + {usersData?.length > 1 && ( + setShowAllUsers(!showAllUsers)} + sx={{ + cursor: 'pointer', + color: theme.palette.text.default, + fontWeight: '600', + '&:hover': { + color: theme.palette.text.brand + } + }} + > + {showAllUsers ? '(hide)' : `(+${usersData.length - 1})`} + + )} + + )} + + ); +}; + +export default UserSearchField; diff --git a/src/custom/UserSearchField/index.tsx b/src/custom/UserSearchField/index.tsx new file mode 100644 index 00000000..c0234808 --- /dev/null +++ b/src/custom/UserSearchField/index.tsx @@ -0,0 +1,3 @@ +import UserSearchField from './UserSearchField'; + +export { UserSearchField }; diff --git a/src/custom/index.tsx b/src/custom/index.tsx index 14e2b14a..e82eb77c 100644 --- a/src/custom/index.tsx +++ b/src/custom/index.tsx @@ -132,4 +132,6 @@ export type { export * from './CatalogDetail'; export * from './Dialog'; +export * from './ShareModal'; +export * from './UserSearchField'; export * from './permissions'; diff --git a/src/icons/Chain/index.tsx b/src/icons/Chain/index.tsx new file mode 100644 index 00000000..043710db --- /dev/null +++ b/src/icons/Chain/index.tsx @@ -0,0 +1 @@ +export { default as ChainIcon } from './ChainIcon'; diff --git a/src/icons/Lock/LockIcon.tsx b/src/icons/Lock/LockIcon.tsx new file mode 100644 index 00000000..27759981 --- /dev/null +++ b/src/icons/Lock/LockIcon.tsx @@ -0,0 +1,22 @@ +import { DEFAULT_HEIGHT, DEFAULT_WIDTH } from '../../constants/constants'; +import { IconProps } from '../types'; + +const LockIcon = ({ + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + fill = '#3C494F', + ...props +}: IconProps): JSX.Element => ( + + + +); + +export default LockIcon; diff --git a/src/icons/Lock/index.tsx b/src/icons/Lock/index.tsx new file mode 100644 index 00000000..a46fa12c --- /dev/null +++ b/src/icons/Lock/index.tsx @@ -0,0 +1 @@ +export { default as LockIcon } from './LockIcon'; diff --git a/src/icons/Person/PersonIcon.tsx b/src/icons/Person/PersonIcon.tsx new file mode 100644 index 00000000..3d6dec76 --- /dev/null +++ b/src/icons/Person/PersonIcon.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react'; +import { IconProps } from '../types'; + +export const PersonIcon: FC = ({ + width, + height, + fill = '#5f6368', + ...props +}: IconProps) => { + return ( + + + + ); +}; + +export default PersonIcon; diff --git a/src/icons/Person/index.ts b/src/icons/Person/index.ts new file mode 100644 index 00000000..c31dfbfe --- /dev/null +++ b/src/icons/Person/index.ts @@ -0,0 +1 @@ +export { default as PersonIcon } from './PersonIcon'; diff --git a/src/icons/Public/PublicIcon.tsx b/src/icons/Public/PublicIcon.tsx new file mode 100644 index 00000000..65bcd83f --- /dev/null +++ b/src/icons/Public/PublicIcon.tsx @@ -0,0 +1,22 @@ +import { DEFAULT_HEIGHT, DEFAULT_WIDTH } from '../../constants/constants'; +import { IconProps } from '../types'; + +const PublicIcon = ({ + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + fill = '#3C494F', + ...props +}: IconProps): JSX.Element => ( + + + +); + +export default PublicIcon; diff --git a/src/icons/Public/index.tsx b/src/icons/Public/index.tsx new file mode 100644 index 00000000..32de4ff7 --- /dev/null +++ b/src/icons/Public/index.tsx @@ -0,0 +1 @@ +export { default as PublicIcon } from './PublicIcon'; diff --git a/src/icons/index.ts b/src/icons/index.ts index e280c899..34263680 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -61,6 +61,7 @@ export * from './Kubernetes'; export * from './Learning'; export * from './LeftAngledArrow'; export * from './LeftArrow'; +export * from './Lock'; export * from './Menu'; export * from './MesheryFilter'; export * from './MesheryOperator'; @@ -68,6 +69,7 @@ export * from './Open'; export * from './PanTool'; export * from './Pattern'; export * from './Pod'; +export * from './Public'; export * from './Publish'; export * from './Question'; export * from './Read';