diff --git a/web/src/containers/Settings/index.tsx b/web/src/containers/Settings/index.tsx index 0032549..9cbb183 100644 --- a/web/src/containers/Settings/index.tsx +++ b/web/src/containers/Settings/index.tsx @@ -9,6 +9,7 @@ import Permission from './permission' import Profile from './profile' import { AppContext, ModeType } from '../../context/appContext' +import PermissionsContextProvider from '../../context/permissionsContext' const StyledTab = styled(Tab)({ background: 'black', @@ -64,7 +65,9 @@ const Settings = () => { - + + + diff --git a/web/src/containers/Settings/internal/components/addPermission.tsx b/web/src/containers/Settings/internal/components/addPermission.tsx new file mode 100644 index 0000000..c9fe546 --- /dev/null +++ b/web/src/containers/Settings/internal/components/addPermission.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { IconButton, Tooltip } from '@mui/material' +import { Add } from '@mui/icons-material' +import { RegisterPermissionPayload } from '../../../../utils/types' +import AddPermissionModal from './addPermissionModal' + +type Props = { + openModal: boolean + setOpenModal: React.Dispatch> + addPermission: ( + permissionsToAdd: RegisterPermissionPayload[], + permissionType: string, + principalType: string, + principal: string, + permissionSetting: string + ) => Promise +} + +const AddPermission = ({ openModal, setOpenModal, addPermission }: Props) => { + return ( + <> + + setOpenModal(true)}> + + + + + + ) +} + +export default AddPermission diff --git a/web/src/containers/Settings/addPermissionModal.tsx b/web/src/containers/Settings/internal/components/addPermissionModal.tsx similarity index 95% rename from web/src/containers/Settings/addPermissionModal.tsx rename to web/src/containers/Settings/internal/components/addPermissionModal.tsx index 945ed46..2996f4e 100644 --- a/web/src/containers/Settings/addPermissionModal.tsx +++ b/web/src/containers/Settings/internal/components/addPermissionModal.tsx @@ -3,31 +3,21 @@ import axios from 'axios' import { Button, Grid, - Dialog, DialogContent, DialogActions, TextField, CircularProgress, Autocomplete } from '@mui/material' -import { styled } from '@mui/material/styles' -import { BootstrapDialogTitle } from '../../components/dialogTitle' +import { BootstrapDialog } from '../../../../components/modal' +import { BootstrapDialogTitle } from '../../../../components/dialogTitle' import { UserResponse, GroupResponse, RegisterPermissionPayload -} from '../../utils/types' - -const BootstrapDialog = styled(Dialog)(({ theme }) => ({ - '& .MuiDialogContent-root': { - padding: theme.spacing(2) - }, - '& .MuiDialogActions-root': { - padding: theme.spacing(1) - } -})) +} from '../../../../utils/types' type AddPermissionModalProps = { open: boolean diff --git a/web/src/containers/Settings/internal/components/displayGroup.tsx b/web/src/containers/Settings/internal/components/displayGroup.tsx new file mode 100644 index 0000000..058ab51 --- /dev/null +++ b/web/src/containers/Settings/internal/components/displayGroup.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { Typography, Popover } from '@mui/material' +import { GroupDetailsResponse } from '../../../../utils/types' + +type DisplayGroupProps = { + group: GroupDetailsResponse +} + +const DisplayGroup = ({ group }: DisplayGroupProps) => { + const [anchorEl, setAnchorEl] = useState(null) + + const handlePopoverOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handlePopoverClose = () => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + + return ( +
+ + {group.name} + + + + Group Members + + {group.users.map((user, index) => ( + + {user.username} + + ))} + +
+ ) +} + +export default DisplayGroup diff --git a/web/src/containers/Settings/internal/components/filterPermissions.tsx b/web/src/containers/Settings/internal/components/filterPermissions.tsx new file mode 100644 index 0000000..c599fd5 --- /dev/null +++ b/web/src/containers/Settings/internal/components/filterPermissions.tsx @@ -0,0 +1,72 @@ +import React, { Dispatch, SetStateAction, useState } from 'react' +import { IconButton, Tooltip } from '@mui/material' +import { FilterList } from '@mui/icons-material' +import { PermissionResponse } from '../../../../utils/types' +import PermissionFilterModal from './permissionFilterModal' +import { PrincipalType } from '../hooks/usePermission' + +type Props = { + open: boolean + handleOpen: Dispatch> + permissions: PermissionResponse[] + applyFilter: ( + pathFilter: string[], + principalFilter: string[], + principalTypeFilter: PrincipalType[], + settingFilter: string[] + ) => void + resetFilter: () => void +} + +const FilterPermissions = ({ + open, + handleOpen, + permissions, + applyFilter, + resetFilter +}: Props) => { + const [pathFilter, setPathFilter] = useState([]) + const [principalFilter, setPrincipalFilter] = useState([]) + const [principalTypeFilter, setPrincipalTypeFilter] = useState< + PrincipalType[] + >([]) + const [settingFilter, setSettingFilter] = useState([]) + const handleApplyFilter = () => { + applyFilter(pathFilter, principalFilter, principalTypeFilter, settingFilter) + } + + const handleResetFilter = () => { + setPathFilter([]) + setPrincipalFilter([]) + setPrincipalFilter([]) + setSettingFilter([]) + resetFilter() + } + + return ( + <> + + handleOpen(true)}> + + + + + + ) +} + +export default FilterPermissions diff --git a/web/src/containers/Settings/permissionFilterModal.tsx b/web/src/containers/Settings/internal/components/permissionFilterModal.tsx similarity index 95% rename from web/src/containers/Settings/permissionFilterModal.tsx rename to web/src/containers/Settings/internal/components/permissionFilterModal.tsx index 7fe64fb..22915e4 100644 --- a/web/src/containers/Settings/permissionFilterModal.tsx +++ b/web/src/containers/Settings/internal/components/permissionFilterModal.tsx @@ -10,9 +10,9 @@ import { import { styled } from '@mui/material/styles' import Autocomplete from '@mui/material/Autocomplete' -import { PermissionResponse } from '../../utils/types' -import { BootstrapDialogTitle } from '../../components/dialogTitle' -import { PrincipalType } from './permission' +import { PermissionResponse } from '../../../../utils/types' +import { BootstrapDialogTitle } from '../../../../components/dialogTitle' +import { PrincipalType } from '../hooks/usePermission' const BootstrapDialog = styled(Dialog)(({ theme }) => ({ '& .MuiDialogContent-root': { diff --git a/web/src/containers/Settings/addPermissionResponseModal.tsx b/web/src/containers/Settings/internal/components/permissionResponseModal.tsx similarity index 94% rename from web/src/containers/Settings/addPermissionResponseModal.tsx rename to web/src/containers/Settings/internal/components/permissionResponseModal.tsx index 4096ebc..0cc61c9 100644 --- a/web/src/containers/Settings/addPermissionResponseModal.tsx +++ b/web/src/containers/Settings/internal/components/permissionResponseModal.tsx @@ -2,9 +2,9 @@ import React from 'react' import { Typography, DialogContent } from '@mui/material' -import { BootstrapDialog } from '../../components/modal' -import { BootstrapDialogTitle } from '../../components/dialogTitle' -import { PermissionResponse } from '../../utils/types' +import { BootstrapDialog } from '../../../../components/modal' +import { BootstrapDialogTitle } from '../../../../components/dialogTitle' +import { PermissionResponse } from '../../../../utils/types' export interface PermissionResponsePayload { permissionType: string diff --git a/web/src/containers/Settings/internal/components/permissionTable.tsx b/web/src/containers/Settings/internal/components/permissionTable.tsx new file mode 100644 index 0000000..550f72f --- /dev/null +++ b/web/src/containers/Settings/internal/components/permissionTable.tsx @@ -0,0 +1,101 @@ +import { useContext } from 'react' + +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, + Tooltip +} from '@mui/material' + +import EditIcon from '@mui/icons-material/Edit' +import DeleteForeverIcon from '@mui/icons-material/DeleteForever' + +import { styled } from '@mui/material/styles' + +import { PermissionResponse } from '../../../../utils/types' + +import { AppContext } from '../../../../context/appContext' +import { displayPrincipal, displayPrincipalType } from '../helper' + +const BootstrapTableCell = styled(TableCell)({ + textAlign: 'left' +}) + +export enum PrincipalType { + User = 'User', + Group = 'Group' +} + +type PermissionTableProps = { + permissions: PermissionResponse[] + handleUpdatePermissionClick: (permission: PermissionResponse) => void + handleDeletePermissionClick: (permission: PermissionResponse) => void +} + +const PermissionTable = ({ + permissions, + handleUpdatePermissionClick, + handleDeletePermissionClick +}: PermissionTableProps) => { + const appContext = useContext(AppContext) + + return ( + + + + + Path + Permission Type + Principal + Principal Type + Setting + {appContext.isAdmin && ( + Action + )} + + + + {permissions.map((permission) => ( + + {permission.path} + {permission.type} + + {displayPrincipal(permission)} + + + {displayPrincipalType(permission)} + + {permission.setting} + {appContext.isAdmin && ( + + + handleUpdatePermissionClick(permission)} + > + + + + + handleDeletePermissionClick(permission)} + > + + + + + )} + + ))} + +
+
+ ) +} + +export default PermissionTable diff --git a/web/src/containers/Settings/updatePermissionModal.tsx b/web/src/containers/Settings/internal/components/updatePermissionModal.tsx similarity index 83% rename from web/src/containers/Settings/updatePermissionModal.tsx rename to web/src/containers/Settings/internal/components/updatePermissionModal.tsx index 55d92de..ed5f6a6 100644 --- a/web/src/containers/Settings/updatePermissionModal.tsx +++ b/web/src/containers/Settings/internal/components/updatePermissionModal.tsx @@ -2,26 +2,17 @@ import React, { useState, Dispatch, SetStateAction, useEffect } from 'react' import { Button, Grid, - Dialog, DialogContent, DialogActions, TextField } from '@mui/material' -import { styled } from '@mui/material/styles' + import Autocomplete from '@mui/material/Autocomplete' -import { BootstrapDialogTitle } from '../../components/dialogTitle' +import { BootstrapDialog } from '../../../../components/modal' +import { BootstrapDialogTitle } from '../../../../components/dialogTitle' -import { PermissionResponse } from '../../utils/types' - -const BootstrapDialog = styled(Dialog)(({ theme }) => ({ - '& .MuiDialogContent-root': { - padding: theme.spacing(2) - }, - '& .MuiDialogActions-root': { - padding: theme.spacing(1) - } -})) +import { PermissionResponse } from '../../../../utils/types' type UpdatePermissionModalProps = { open: boolean diff --git a/web/src/containers/Settings/internal/helper.tsx b/web/src/containers/Settings/internal/helper.tsx new file mode 100644 index 0000000..e6d2519 --- /dev/null +++ b/web/src/containers/Settings/internal/helper.tsx @@ -0,0 +1,13 @@ +import { PermissionResponse } from '../../../utils/types' +import { PrincipalType } from './hooks/usePermission' +import DisplayGroup from './components/displayGroup' + +export const displayPrincipal = (permission: PermissionResponse) => { + if (permission.user) return permission.user.username + if (permission.group) return +} + +export const displayPrincipalType = (permission: PermissionResponse) => { + if (permission.user) return PrincipalType.User + if (permission.group) return PrincipalType.Group +} diff --git a/web/src/containers/Settings/internal/hooks/useAddPermission.tsx b/web/src/containers/Settings/internal/hooks/useAddPermission.tsx new file mode 100644 index 0000000..f2d1761 --- /dev/null +++ b/web/src/containers/Settings/internal/hooks/useAddPermission.tsx @@ -0,0 +1,109 @@ +import axios from 'axios' +import { useState, useContext } from 'react' +import { + PermissionResponse, + RegisterPermissionPayload +} from '../../../../utils/types' +import AddPermission from '../components/addPermission' +import { PermissionsContext } from '../../../../context/permissionsContext' +import { + findExistingPermission, + findUpdatingPermission +} from '../../../../utils/helper' + +const useAddPermission = () => { + const { + permissions, + fetchPermissions, + setIsLoading, + setPermissionResponsePayload, + setOpenPermissionResponseModal + } = useContext(PermissionsContext) + + const [addPermissionModalOpen, setAddPermissionModalOpen] = useState(false) + + const addPermission = async ( + permissionsToAdd: RegisterPermissionPayload[], + permissionType: string, + principalType: string, + principal: string, + permissionSetting: string + ) => { + setAddPermissionModalOpen(false) + setIsLoading(true) + + const newAddedPermissions: PermissionResponse[] = [] + const updatedPermissions: PermissionResponse[] = [] + const errorPaths: string[] = [] + + const existingPermissions: PermissionResponse[] = [] + const updatingPermissions: PermissionResponse[] = [] + const newPermissions: RegisterPermissionPayload[] = [] + + permissionsToAdd.forEach((permission) => { + const existingPermission = findExistingPermission(permissions, permission) + if (existingPermission) { + existingPermissions.push(existingPermission) + return + } + + const updatingPermission = findUpdatingPermission(permissions, permission) + if (updatingPermission) { + updatingPermissions.push(updatingPermission) + return + } + + newPermissions.push(permission) + }) + + for (const permission of newPermissions) { + await axios + .post('/SASjsApi/permission', permission) + .then((res) => { + newAddedPermissions.push(res.data) + }) + .catch((error) => { + errorPaths.push(permission.path) + }) + } + + for (const permission of updatingPermissions) { + await axios + .patch(`/SASjsApi/permission/${permission.permissionId}`, { + setting: permission.setting === 'Grant' ? 'Deny' : 'Grant' + }) + .then((res) => { + updatedPermissions.push(res.data) + }) + .catch((error) => { + errorPaths.push(permission.path) + }) + } + + fetchPermissions() + setIsLoading(false) + setPermissionResponsePayload({ + permissionType, + principalType, + principal, + permissionSetting, + existingPermissions, + updatedPermissions, + newAddedPermissions, + errorPaths + }) + setOpenPermissionResponseModal(true) + } + + const AddPermissionButton = () => ( + + ) + + return { AddPermissionButton, setAddPermissionModalOpen } +} + +export default useAddPermission diff --git a/web/src/containers/Settings/internal/hooks/useDeletePermissionModal.tsx b/web/src/containers/Settings/internal/hooks/useDeletePermissionModal.tsx new file mode 100644 index 0000000..7638c22 --- /dev/null +++ b/web/src/containers/Settings/internal/hooks/useDeletePermissionModal.tsx @@ -0,0 +1,61 @@ +import axios from 'axios' +import { useState, useContext } from 'react' +import { PermissionsContext } from '../../../../context/permissionsContext' +import { AlertSeverityType } from '../../../../components/snackbar' +import DeleteConfirmationModal from '../../../../components/deleteConfirmationModal' + +const useDeletePermissionModal = () => { + const { + selectedPermission, + setSelectedPermission, + fetchPermissions, + setIsLoading, + setSnackbarMessage, + setSnackbarSeverity, + setOpenSnackbar, + setModalTitle, + setModalPayload, + setOpenModal + } = useContext(PermissionsContext) + const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = + useState(false) + + const deletePermission = () => { + setDeleteConfirmationModalOpen(false) + setIsLoading(true) + axios + .delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`) + .then((res: any) => { + fetchPermissions() + setSnackbarMessage('Permission deleted!') + setSnackbarSeverity(AlertSeverityType.Success) + setOpenSnackbar(true) + }) + .catch((err) => { + setModalTitle('Abort') + setModalPayload( + typeof err.response.data === 'object' + ? JSON.stringify(err.response.data) + : err.response.data + ) + setOpenModal(true) + }) + .finally(() => { + setIsLoading(false) + setSelectedPermission(undefined) + }) + } + + const DeletePermissionDialog = () => ( + + ) + + return { DeletePermissionDialog, setDeleteConfirmationModalOpen } +} + +export default useDeletePermissionModal diff --git a/web/src/containers/Settings/internal/hooks/useFilterPermissions.tsx b/web/src/containers/Settings/internal/hooks/useFilterPermissions.tsx new file mode 100644 index 0000000..7ca1f6a --- /dev/null +++ b/web/src/containers/Settings/internal/hooks/useFilterPermissions.tsx @@ -0,0 +1,105 @@ +import { useState, useContext } from 'react' +import { PermissionsContext } from '../../../../context/permissionsContext' +import { PrincipalType } from './usePermission' +import FilterPermissions from '../components/filterPermissions' + +const useFilterPermissions = () => { + const { permissions, setFilteredPermissions, setFilterApplied } = + useContext(PermissionsContext) + + const [filterModalOpen, setFilterModalOpen] = useState(false) + + /** + * first find the permissions w.r.t each filter type + * take intersection of resultant arrays + */ + const applyFilter = ( + pathFilter: string[], + principalFilter: string[], + principalTypeFilter: PrincipalType[], + settingFilter: string[] + ) => { + setFilterModalOpen(false) + + const uriFilteredPermissions = + pathFilter.length > 0 + ? permissions.filter((permission) => + pathFilter.includes(permission.path) + ) + : permissions + + const principalFilteredPermissions = + principalFilter.length > 0 + ? permissions.filter((permission) => { + if (permission.user) { + return principalFilter.includes(permission.user.username) + } + if (permission.group) { + return principalFilter.includes(permission.group.name) + } + return false + }) + : permissions + + const principalTypeFilteredPermissions = + principalTypeFilter.length > 0 + ? permissions.filter((permission) => { + if (permission.user) { + return principalTypeFilter.includes(PrincipalType.User) + } + if (permission.group) { + return principalTypeFilter.includes(PrincipalType.Group) + } + return false + }) + : permissions + + const settingFilteredPermissions = + settingFilter.length > 0 + ? permissions.filter((permission) => + settingFilter.includes(permission.setting) + ) + : permissions + + let filteredArray = uriFilteredPermissions.filter((permission) => + principalFilteredPermissions.some( + (item) => item.permissionId === permission.permissionId + ) + ) + + filteredArray = filteredArray.filter((permission) => + principalTypeFilteredPermissions.some( + (item) => item.permissionId === permission.permissionId + ) + ) + + filteredArray = filteredArray.filter((permission) => + settingFilteredPermissions.some( + (item) => item.permissionId === permission.permissionId + ) + ) + + setFilteredPermissions(filteredArray) + setFilterApplied(true) + } + + const resetFilter = () => { + setFilterModalOpen(false) + setFilterApplied(false) + setFilteredPermissions([]) + } + + const FilterPermissionsButton = () => ( + + ) + + return { FilterPermissionsButton } +} + +export default useFilterPermissions diff --git a/web/src/containers/Settings/internal/hooks/usePermission.ts b/web/src/containers/Settings/internal/hooks/usePermission.ts new file mode 100644 index 0000000..6d85180 --- /dev/null +++ b/web/src/containers/Settings/internal/hooks/usePermission.ts @@ -0,0 +1,71 @@ +import { useContext, useEffect } from 'react' +import { AppContext } from '../../../../context/appContext' +import { PermissionsContext } from '../../../../context/permissionsContext' +import { PermissionResponse } from '../../../../utils/types' +import useAddPermission from './useAddPermission' +import useUpdatePermissionModal from './useUpdatePermissionModal' +import useDeletePermissionModal from './useDeletePermissionModal' +import useFilterPermissions from './useFilterPermissions' + +export enum PrincipalType { + User = 'User', + Group = 'Group' +} + +const usePermission = () => { + const { isAdmin } = useContext(AppContext) + const { + filterApplied, + filteredPermissions, + isLoading, + permissions, + Dialog, + Snackbar, + PermissionResponseDialog, + fetchPermissions, + setSelectedPermission + } = useContext(PermissionsContext) + + const { AddPermissionButton } = useAddPermission() + + const { UpdatePermissionDialog, setUpdatePermissionModalOpen } = + useUpdatePermissionModal() + + const { DeletePermissionDialog, setDeleteConfirmationModalOpen } = + useDeletePermissionModal() + + const { FilterPermissionsButton } = useFilterPermissions() + + useEffect(() => { + if (fetchPermissions) fetchPermissions() + }, [fetchPermissions]) + + const handleUpdatePermissionClick = (permission: PermissionResponse) => { + setSelectedPermission(permission) + setUpdatePermissionModalOpen(true) + } + + const handleDeletePermissionClick = (permission: PermissionResponse) => { + setSelectedPermission(permission) + setDeleteConfirmationModalOpen(true) + } + + return { + filterApplied, + filteredPermissions, + isAdmin, + isLoading, + permissions, + AddPermissionButton, + UpdatePermissionDialog, + DeletePermissionDialog, + FilterPermissionsButton, + handleDeletePermissionClick, + handleUpdatePermissionClick, + PermissionResponseDialog, + Dialog, + Snackbar + } +} + +export default usePermission diff --git a/web/src/containers/Settings/internal/hooks/usePermissionResponseModal.tsx b/web/src/containers/Settings/internal/hooks/usePermissionResponseModal.tsx new file mode 100644 index 0000000..ad2d19b --- /dev/null +++ b/web/src/containers/Settings/internal/hooks/usePermissionResponseModal.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' +import PermissionResponseModal, { + PermissionResponsePayload +} from '../components/permissionResponseModal' + +const usePermissionResponseModal = () => { + const [openPermissionResponseModal, setOpenPermissionResponseModal] = + useState(false) + const [permissionResponsePayload, setPermissionResponsePayload] = + useState({ + permissionType: '', + principalType: '', + principal: '', + permissionSetting: '', + existingPermissions: [], + newAddedPermissions: [], + updatedPermissions: [], + errorPaths: [] + }) + + const PermissionResponseDialog = () => ( + + ) + + return { + PermissionResponseDialog, + setOpenPermissionResponseModal, + setPermissionResponsePayload + } +} + +export default usePermissionResponseModal diff --git a/web/src/containers/Settings/internal/hooks/useUpdatePermissionModal.tsx b/web/src/containers/Settings/internal/hooks/useUpdatePermissionModal.tsx new file mode 100644 index 0000000..0deceee --- /dev/null +++ b/web/src/containers/Settings/internal/hooks/useUpdatePermissionModal.tsx @@ -0,0 +1,63 @@ +import axios from 'axios' +import { useState, useContext } from 'react' +import UpdatePermissionModal from '../components/updatePermissionModal' +import { PermissionsContext } from '../../../../context/permissionsContext' +import { AlertSeverityType } from '../../../../components/snackbar' + +const useUpdatePermissionModal = () => { + const { + selectedPermission, + setSelectedPermission, + fetchPermissions, + setIsLoading, + setSnackbarMessage, + setSnackbarSeverity, + setOpenSnackbar, + setModalTitle, + setModalPayload, + setOpenModal + } = useContext(PermissionsContext) + const [updatePermissionModalOpen, setUpdatePermissionModalOpen] = + useState(false) + + const updatePermission = (setting: string) => { + setUpdatePermissionModalOpen(false) + setIsLoading(true) + axios + .patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, { + setting + }) + .then((res: any) => { + fetchPermissions() + setSnackbarMessage('Permission updated!') + setSnackbarSeverity(AlertSeverityType.Success) + setOpenSnackbar(true) + }) + .catch((err) => { + setModalTitle('Abort') + setModalPayload( + typeof err.response.data === 'object' + ? JSON.stringify(err.response.data) + : err.response.data + ) + setOpenModal(true) + }) + .finally(() => { + setIsLoading(false) + setSelectedPermission(undefined) + }) + } + + const UpdatePermissionDialog = () => ( + + ) + + return { UpdatePermissionDialog, setUpdatePermissionModalOpen } +} + +export default useUpdatePermissionModal diff --git a/web/src/containers/Settings/permission.tsx b/web/src/containers/Settings/permission.tsx index dc9f3af..240afcd 100644 --- a/web/src/containers/Settings/permission.tsx +++ b/web/src/containers/Settings/permission.tsx @@ -1,54 +1,7 @@ -import React, { useState, useEffect, useContext, useCallback } from 'react' -import axios from 'axios' -import { - Box, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Grid, - CircularProgress, - IconButton, - Tooltip, - Typography, - Popover -} from '@mui/material' - -import FilterListIcon from '@mui/icons-material/FilterList' -import AddIcon from '@mui/icons-material/Add' -import EditIcon from '@mui/icons-material/Edit' -import DeleteForeverIcon from '@mui/icons-material/DeleteForever' - +import { Box, Paper, Grid, CircularProgress } from '@mui/material' import { styled } from '@mui/material/styles' - -import Modal from '../../components/modal' -import PermissionFilterModal from './permissionFilterModal' -import AddPermissionModal from './addPermissionModal' -import PermissionResponseModal, { - PermissionResponsePayload -} from './addPermissionResponseModal' -import UpdatePermissionModal from './updatePermissionModal' -import DeleteConfirmationModal from '../../components/deleteConfirmationModal' -import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar' - -import { - GroupDetailsResponse, - PermissionResponse, - RegisterPermissionPayload -} from '../../utils/types' -import { - findExistingPermission, - findUpdatingPermission -} from '../../utils/helper' - -import { AppContext } from '../../context/appContext' - -const BootstrapTableCell = styled(TableCell)({ - textAlign: 'left' -}) +import PermissionTable from './internal/components/permissionTable' +import usePermission from './internal/hooks/usePermission' const BootstrapGridItem = styled(Grid)({ '&.MuiGrid-item': { @@ -56,298 +9,23 @@ const BootstrapGridItem = styled(Grid)({ } }) -export enum PrincipalType { - User = 'User', - Group = 'Group' -} - const Permission = () => { - const appContext = useContext(AppContext) - const [isLoading, setIsLoading] = useState(false) - const [openModal, setOpenModal] = useState(false) - const [modalTitle, setModalTitle] = useState('') - const [modalPayload, setModalPayload] = useState('') - const [openSnackbar, setOpenSnackbar] = useState(false) - const [snackbarMessage, setSnackbarMessage] = useState('') - const [snackbarSeverity, setSnackbarSeverity] = useState( - AlertSeverityType.Success - ) - const [addPermissionModalOpen, setAddPermissionModalOpen] = useState(false) - const [openPermissionResponseModal, setOpenPermissionResponseModal] = - useState(false) - const [permissionResponsePayload, setPermissionResponsePayload] = - useState({ - permissionType: '', - principalType: '', - principal: '', - permissionSetting: '', - existingPermissions: [], - newAddedPermissions: [], - updatedPermissions: [], - errorPaths: [] - }) - - const [updatePermissionModalOpen, setUpdatePermissionModalOpen] = - useState(false) - const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = - useState(false) - const [deleteConfirmationModalMessage, setDeleteConfirmationModalMessage] = - useState('') - const [selectedPermission, setSelectedPermission] = - useState() - const [filterModalOpen, setFilterModalOpen] = useState(false) - const [pathFilter, setPathFilter] = useState([]) - const [principalFilter, setPrincipalFilter] = useState([]) - const [principalTypeFilter, setPrincipalTypeFilter] = useState< - PrincipalType[] - >([]) - const [settingFilter, setSettingFilter] = useState([]) - const [permissions, setPermissions] = useState([]) - const [filteredPermissions, setFilteredPermissions] = useState< - PermissionResponse[] - >([]) - const [filterApplied, setFilterApplied] = useState(false) - - const fetchPermissions = useCallback(() => { - axios - .get(`/SASjsApi/permission`) - .then((res: any) => { - if (res.data?.length > 0) { - setPermissions(res.data) - } - }) - .catch((err) => { - setModalTitle('Abort') - setModalPayload( - typeof err.response.data === 'object' - ? JSON.stringify(err.response.data) - : err.response.data - ) - setOpenModal(true) - }) - }, []) - - useEffect(() => { - fetchPermissions() - }, [fetchPermissions]) - - /** - * first find the permissions w.r.t each filter type - * take intersection of resultant arrays - */ - const applyFilter = () => { - setFilterModalOpen(false) - - const uriFilteredPermissions = - pathFilter.length > 0 - ? permissions.filter((permission) => - pathFilter.includes(permission.path) - ) - : permissions - - const principalFilteredPermissions = - principalFilter.length > 0 - ? permissions.filter((permission) => { - if (permission.user) { - return principalFilter.includes(permission.user.username) - } - if (permission.group) { - return principalFilter.includes(permission.group.name) - } - return false - }) - : permissions - - const principalTypeFilteredPermissions = - principalTypeFilter.length > 0 - ? permissions.filter((permission) => { - if (permission.user) { - return principalTypeFilter.includes(PrincipalType.User) - } - if (permission.group) { - return principalTypeFilter.includes(PrincipalType.Group) - } - return false - }) - : permissions - - const settingFilteredPermissions = - settingFilter.length > 0 - ? permissions.filter((permission) => - settingFilter.includes(permission.setting) - ) - : permissions - - let filteredArray = uriFilteredPermissions.filter((permission) => - principalFilteredPermissions.some( - (item) => item.permissionId === permission.permissionId - ) - ) - - filteredArray = filteredArray.filter((permission) => - principalTypeFilteredPermissions.some( - (item) => item.permissionId === permission.permissionId - ) - ) - - filteredArray = filteredArray.filter((permission) => - settingFilteredPermissions.some( - (item) => item.permissionId === permission.permissionId - ) - ) - - setFilteredPermissions(filteredArray) - setFilterApplied(true) - } - - const resetFilter = () => { - setFilterModalOpen(false) - setPathFilter([]) - setPrincipalFilter([]) - setSettingFilter([]) - setFilteredPermissions([]) - setFilterApplied(false) - } - - const addPermission = async ( - permissionsToAdd: RegisterPermissionPayload[], - permissionType: string, - principalType: string, - principal: string, - permissionSetting: string - ) => { - setAddPermissionModalOpen(false) - setIsLoading(true) - - const newAddedPermissions: PermissionResponse[] = [] - const updatedPermissions: PermissionResponse[] = [] - const errorPaths: string[] = [] - - const existingPermissions: PermissionResponse[] = [] - const updatingPermissions: PermissionResponse[] = [] - const newPermissions: RegisterPermissionPayload[] = [] - - permissionsToAdd.forEach((permission) => { - const existingPermission = findExistingPermission(permissions, permission) - if (existingPermission) { - existingPermissions.push(existingPermission) - return - } - - const updatingPermission = findUpdatingPermission(permissions, permission) - if (updatingPermission) { - updatingPermissions.push(updatingPermission) - return - } - - newPermissions.push(permission) - }) - - for (const permission of newPermissions) { - await axios - .post('/SASjsApi/permission', permission) - .then((res) => { - newAddedPermissions.push(res.data) - }) - .catch((error) => { - errorPaths.push(permission.path) - }) - } - - for (const permission of updatingPermissions) { - await axios - .patch(`/SASjsApi/permission/${permission.permissionId}`, { - setting: permission.setting === 'Grant' ? 'Deny' : 'Grant' - }) - .then((res) => { - updatedPermissions.push(res.data) - }) - .catch((error) => { - errorPaths.push(permission.path) - }) - } - - fetchPermissions() - setIsLoading(false) - setPermissionResponsePayload({ - permissionType, - principalType, - principal, - permissionSetting, - existingPermissions, - updatedPermissions, - newAddedPermissions, - errorPaths - }) - setOpenPermissionResponseModal(true) - } - - const handleUpdatePermissionClick = (permission: PermissionResponse) => { - setSelectedPermission(permission) - setUpdatePermissionModalOpen(true) - } - - const updatePermission = (setting: string) => { - setUpdatePermissionModalOpen(false) - setIsLoading(true) - axios - .patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, { - setting - }) - .then((res: any) => { - fetchPermissions() - setSnackbarMessage('Permission updated!') - setSnackbarSeverity(AlertSeverityType.Success) - setOpenSnackbar(true) - }) - .catch((err) => { - setModalTitle('Abort') - setModalPayload( - typeof err.response.data === 'object' - ? JSON.stringify(err.response.data) - : err.response.data - ) - setOpenModal(true) - }) - .finally(() => { - setIsLoading(false) - setSelectedPermission(undefined) - }) - } - - const handleDeletePermissionClick = (permission: PermissionResponse) => { - setSelectedPermission(permission) - setDeleteConfirmationModalOpen(true) - setDeleteConfirmationModalMessage( - 'Are you sure you want to delete this permission?' - ) - } - - const deletePermission = () => { - setDeleteConfirmationModalOpen(false) - setIsLoading(true) - axios - .delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`) - .then((res: any) => { - fetchPermissions() - setSnackbarMessage('Permission deleted!') - setSnackbarSeverity(AlertSeverityType.Success) - setOpenSnackbar(true) - }) - .catch((err) => { - setModalTitle('Abort') - setModalPayload( - typeof err.response.data === 'object' - ? JSON.stringify(err.response.data) - : err.response.data - ) - setOpenModal(true) - }) - .finally(() => { - setIsLoading(false) - setSelectedPermission(undefined) - }) - } + const { + filterApplied, + filteredPermissions, + isAdmin, + isLoading, + permissions, + AddPermissionButton, + UpdatePermissionDialog, + DeletePermissionDialog, + FilterPermissionsButton, + handleDeletePermissionClick, + handleUpdatePermissionClick, + PermissionResponseDialog, + Dialog, + Snackbar + } = usePermission() return isLoading ? ( { - - setFilterModalOpen(true)}> - - - - {appContext.isAdmin && ( - - setAddPermissionModalOpen(true)}> - - - - )} + + {isAdmin && } @@ -384,192 +48,13 @@ const Permission = () => { /> - - - - - - - + + + + + ) } export default Permission - -type PermissionTableProps = { - permissions: PermissionResponse[] - handleUpdatePermissionClick: (permission: PermissionResponse) => void - handleDeletePermissionClick: (permission: PermissionResponse) => void -} - -const PermissionTable = ({ - permissions, - handleUpdatePermissionClick, - handleDeletePermissionClick -}: PermissionTableProps) => { - const appContext = useContext(AppContext) - - return ( - - - - - Path - Permission Type - Principal - Principal Type - Setting - {appContext.isAdmin && ( - Action - )} - - - - {permissions.map((permission) => ( - - {permission.path} - {permission.type} - - {displayPrincipal(permission)} - - - {displayPrincipalType(permission)} - - {permission.setting} - {appContext.isAdmin && ( - - - handleUpdatePermissionClick(permission)} - > - - - - - handleDeletePermissionClick(permission)} - > - - - - - )} - - ))} - -
-
- ) -} - -const displayPrincipal = (permission: PermissionResponse) => { - if (permission.user) return permission.user.username - if (permission.group) return -} - -type DisplayGroupProps = { - group: GroupDetailsResponse -} - -const DisplayGroup = ({ group }: DisplayGroupProps) => { - const [anchorEl, setAnchorEl] = useState(null) - - const handlePopoverOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget) - } - - const handlePopoverClose = () => { - setAnchorEl(null) - } - - const open = Boolean(anchorEl) - - return ( -
- - {group.name} - - - - Group Members - - {group.users.map((user, index) => ( - - {user.username} - - ))} - -
- ) -} - -const displayPrincipalType = (permission: PermissionResponse) => { - if (permission.user) return PrincipalType.User - if (permission.group) return PrincipalType.Group -} diff --git a/web/src/containers/Studio/editor.tsx b/web/src/containers/Studio/editor.tsx index 5157d35..41ebd47 100644 --- a/web/src/containers/Studio/editor.tsx +++ b/web/src/containers/Studio/editor.tsx @@ -1,55 +1,26 @@ -import React, { - Dispatch, - SetStateAction, - useEffect, - useRef, - useState, - useContext, - useCallback -} from 'react' -import axios from 'axios' +import React, { Dispatch, SetStateAction } from 'react' import { Backdrop, Box, - Button, CircularProgress, - FormControl, - IconButton, - Menu, - MenuItem, Paper, - Select, - SelectChangeEvent, Tab, Tooltip, Typography } from '@mui/material' import { styled } from '@mui/material/styles' -import { - RocketLaunch, - MoreVert, - Save, - SaveAs, - Difference, - Edit -} from '@mui/icons-material' -import Editor, { - MonacoDiffEditor, - DiffEditorDidMount, - EditorDidMount, - monaco -} from 'react-monaco-editor' +import Editor, { MonacoDiffEditor } from 'react-monaco-editor' import { TabContext, TabList, TabPanel } from '@mui/lab' -import { AppContext, RunTimeType } from '../../context/appContext' - import FilePathInputModal from '../../components/filePathInputModal' -import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar' -import Modal from '../../components/modal' +import FileMenu from './internal/components/fileMenu' +import RunMenu from './internal/components/runMenu' -import { usePrompt, useStateWithCallback } from '../../utils/hooks' +import { usePrompt } from '../../utils/hooks' +import { getLanguageFromExtension } from './internal/helper' +import useEditor from './internal/hooks/useEditor' const StyledTabPanel = styled(TabPanel)(() => ({ padding: '10px' @@ -70,267 +41,77 @@ type SASjsEditorProps = { setTab: Dispatch> } -const baseUrl = window.location.origin -const SASJS_LOGS_SEPARATOR = - 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784' - const SASjsEditor = ({ selectedFilePath, setSelectedFilePath, tab, setTab }: SASjsEditorProps) => { - const appContext = useContext(AppContext) - const [isLoading, setIsLoading] = useState(false) - const [openModal, setOpenModal] = useState(false) - const [modalTitle, setModalTitle] = useState('') - const [modalPayload, setModalPayload] = useState('') - const [openSnackbar, setOpenSnackbar] = useState(false) - const [snackbarMessage, setSnackbarMessage] = useState('') - const [snackbarSeverity, setSnackbarSeverity] = useState( - AlertSeverityType.Success - ) - const [prevFileContent, setPrevFileContent] = useStateWithCallback('') - const [fileContent, setFileContent] = useState('') - const [log, setLog] = useState('') - const [ctrlPressed, setCtrlPressed] = useState(false) - const [webout, setWebout] = useState('') - const [runTimes, setRunTimes] = useState([]) - const [selectedRunTime, setSelectedRunTime] = useState('') - const [selectedFileExtension, setSelectedFileExtension] = useState('') - const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false) - const [showDiff, setShowDiff] = useState(false) - - const editorRef = useRef(null as any) - - const handleEditorDidMount: EditorDidMount = (editor) => { - editorRef.current = editor - editor.focus() - editor.addAction({ - // An unique identifier of the contributed action. - id: 'show-difference', - - // A label of the action that will be presented to the user. - label: 'Show Differences', - - // An optional array of keybindings for the action. - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD], - - contextMenuGroupId: 'navigation', - - contextMenuOrder: 1, - - // Method that will be executed when the action is triggered. - // @param editor The editor instance is passed in as a convenience - run: function (ed) { - setShowDiff(true) - } - }) - } - - const handleDiffEditorDidMount: DiffEditorDidMount = (diffEditor) => { - diffEditor.focus() - diffEditor.addCommand(monaco.KeyCode.Escape, function () { - setShowDiff(false) - }) - } + const { + ctrlPressed, + fileContent, + isLoading, + log, + openFilePathInputModal, + prevFileContent, + runTimes, + selectedFileExtension, + selectedRunTime, + showDiff, + webout, + Dialog, + handleChangeRunTime, + handleDiffEditorDidMount, + handleEditorDidMount, + handleFilePathInput, + handleKeyDown, + handleKeyUp, + handleRunBtnClick, + handleTabChange, + saveFile, + setShowDiff, + setOpenFilePathInputModal, + setFileContent, + Snackbar + } = useEditor({ selectedFilePath, setSelectedFilePath, setTab }) usePrompt( 'Changes you made may not be saved.', prevFileContent !== fileContent && !!selectedFilePath ) - const saveFile = useCallback( - (filePath?: string) => { - setIsLoading(true) - - if (filePath) { - filePath = filePath.startsWith('/') ? filePath : `/${filePath}` - } - - const formData = new FormData() - - const stringBlob = new Blob([fileContent], { type: 'text/plain' }) - formData.append('file', stringBlob) - formData.append('filePath', filePath ?? selectedFilePath) - - const axiosPromise = filePath - ? axios.post('/SASjsApi/drive/file', formData) - : axios.patch('/SASjsApi/drive/file', formData) - - axiosPromise - .then(() => { - if (filePath && fileContent === prevFileContent) { - // when fileContent and prevFileContent is same, - // callback function in setPrevFileContent method is not called - // because behind the scene useEffect hook is being used - // for calling callback function, and it's only fired when the - // new value is not equal to old value. - // So, we'll have to explicitly update the selected file path - - setSelectedFilePath(filePath, true) - } else { - setPrevFileContent(fileContent, () => { - if (filePath) { - setSelectedFilePath(filePath, true) - } - }) - } - setSnackbarMessage('File saved!') - setSnackbarSeverity(AlertSeverityType.Success) - setOpenSnackbar(true) - }) - .catch((err) => { - setModalTitle('Abort') - setModalPayload( - typeof err.response.data === 'object' - ? JSON.stringify(err.response.data) - : err.response.data - ) - setOpenModal(true) - }) - .finally(() => { - setIsLoading(false) - }) - }, - [ - fileContent, - prevFileContent, - selectedFilePath, - setPrevFileContent, - setSelectedFilePath - ] + const fileMenu = ( + ) - useEffect(() => { - editorRef.current.addAction({ - // An unique identifier of the contributed action. - id: 'save-file', - - // A label of the action that will be presented to the user. - label: 'Save', - - // An optional array of keybindings for the action. - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], - - // Method that will be executed when the action is triggered. - // @param editor The editor instance is passed in as a convenience - run: () => { - if (!selectedFilePath) return setOpenFilePathInputModal(true) - if (prevFileContent !== fileContent) return saveFile() - } - }) - }, [fileContent, prevFileContent, selectedFilePath, saveFile]) - - useEffect(() => { - setRunTimes(Object.values(appContext.runTimes)) - }, [appContext.runTimes]) - - useEffect(() => { - if (runTimes.length) setSelectedRunTime(runTimes[0]) - }, [runTimes]) - - useEffect(() => { - if (selectedFilePath) { - setIsLoading(true) - setSelectedFileExtension(selectedFilePath.split('.').pop() ?? '') - axios - .get(`/SASjsApi/drive/file?_filePath=${selectedFilePath}`) - .then((res: any) => { - setPrevFileContent(res.data) - setFileContent(res.data) - }) - .catch((err) => { - setModalTitle('Abort') - setModalPayload( - typeof err.response.data === 'object' - ? JSON.stringify(err.response.data) - : err.response.data - ) - setOpenModal(true) - }) - .finally(() => setIsLoading(false)) - } else { - const content = localStorage.getItem('fileContent') ?? '' - setFileContent(content) - } - setLog('') - setWebout('') - setTab('code') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedFilePath]) - - useEffect(() => { - if (fileContent.length && !selectedFilePath) { - localStorage.setItem('fileContent', fileContent) - } - }, [fileContent, selectedFilePath]) - - useEffect(() => { - if (runTimes.includes(selectedFileExtension)) - setSelectedRunTime(selectedFileExtension) - }, [selectedFileExtension, runTimes]) - - const handleTabChange = (_e: any, newValue: string) => { - setTab(newValue) - } - - const getSelection = () => { - const editor = editorRef.current as any - const selection = editor?.getModel().getValueInRange(editor?.getSelection()) - return selection ?? '' - } - - const handleRunBtnClick = () => runCode(getSelection() || fileContent) - - const runCode = (code: string) => { - setIsLoading(true) - axios - .post(`/SASjsApi/code/execute`, { code, runTime: selectedRunTime }) - .then((res: any) => { - setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '') - setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '') - setTab('log') - - // Scroll to bottom of log - const logElement = document.getElementById('log') - if (logElement) logElement.scrollTop = logElement.scrollHeight - }) - .catch((err) => { - setModalTitle('Abort') - setModalPayload( - typeof err.response.data === 'object' - ? JSON.stringify(err.response.data) - : err.response.data - ) - setOpenModal(true) - }) - .finally(() => setIsLoading(false)) - } - - const handleKeyDown = (event: any) => { - if (event.ctrlKey) { - if (event.key === 'v') { - setCtrlPressed(false) - } - - if (event.key === 'Enter') runCode(getSelection() || fileContent) - if (!ctrlPressed) setCtrlPressed(true) - } - } - - const handleKeyUp = (event: any) => { - if (!event.ctrlKey && ctrlPressed) setCtrlPressed(false) - } - - const handleChangeRunTime = (event: SelectChangeEvent) => { - setSelectedRunTime(event.target.value as RunTimeType) - } - - const handleFilePathInput = (filePath: string) => { - setOpenFilePathInputModal(false) - saveFile(filePath) - } + const monacoEditor = showDiff ? ( + setFileContent(val)} + /> + ) : ( + setFileContent(val)} + /> + ) return ( @@ -343,15 +124,7 @@ const SASjsEditor = ({ {selectedFilePath && !runTimes.includes(selectedFileExtension) ? ( - + {fileMenu} - {showDiff ? ( - setFileContent(val)} - /> - ) : ( - setFileContent(val)} - /> - )} + {monacoEditor} ) : ( @@ -419,15 +173,7 @@ const SASjsEditor = ({ handleChangeRunTime={handleChangeRunTime} handleRunBtnClick={handleRunBtnClick} /> - + {fileMenu} - {showDiff ? ( - setFileContent(val)} - /> - ) : ( - setFileContent(val)} - /> - )} + {monacoEditor}

)} - - +

+ void - handleRunBtnClick: () => void -} - -const RunMenu = ({ - selectedFilePath, - fileContent, - prevFileContent, - selectedRunTime, - runTimes, - handleChangeRunTime, - handleRunBtnClick -}: RunMenuProps) => { - const launchProgram = () => { - window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`) - } - - return ( - <> - - - - {selectedFilePath ? ( - - - - - - - - - - ) : ( - - - - - - )} - - ) -} - -type FileMenuProps = { - showDiff: boolean - setShowDiff: React.Dispatch> - prevFileContent: string - currentFileContent: string - selectedFilePath: string - setOpenFilePathInputModal: React.Dispatch> - saveFile: () => void -} - -const FileMenu = ({ - showDiff, - setShowDiff, - prevFileContent, - currentFileContent, - selectedFilePath, - setOpenFilePathInputModal, - saveFile -}: FileMenuProps) => { - const [anchorEl, setAnchorEl] = useState< - (EventTarget & HTMLButtonElement) | null - >(null) - - const handleMenu = ( - event?: React.MouseEvent - ) => { - if (event) setAnchorEl(event.currentTarget) - else setAnchorEl(null) - } - - const handleDiffBtnClick = () => { - setAnchorEl(null) - setShowDiff(!showDiff) - } - - const handleSaveAsBtnClick = () => { - setAnchorEl(null) - setOpenFilePathInputModal(true) - } - - const handleSaveBtnClick = () => { - setAnchorEl(null) - saveFile() - } - - return ( - <> - - - - - - handleMenu()} - > - - - - - - - - - - - - ) -} - -const getLanguage = (extension: string) => { - if (extension === 'js') return 'javascript' - - if (extension === 'ts') return 'typescript' - - if (extension === 'md' || extension === 'mdx') return 'markdown' - - return extension -} diff --git a/web/src/containers/Studio/internal/components/fileMenu.tsx b/web/src/containers/Studio/internal/components/fileMenu.tsx new file mode 100644 index 0000000..d1fe317 --- /dev/null +++ b/web/src/containers/Studio/internal/components/fileMenu.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react' + +import { Button, IconButton, Menu, MenuItem, Tooltip } from '@mui/material' + +import { MoreVert, Save, SaveAs, Difference, Edit } from '@mui/icons-material' + +type FileMenuProps = { + showDiff: boolean + setShowDiff: React.Dispatch> + prevFileContent: string + currentFileContent: string + selectedFilePath: string + setOpenFilePathInputModal: React.Dispatch> + saveFile: () => void +} + +const FileMenu = ({ + showDiff, + setShowDiff, + prevFileContent, + currentFileContent, + selectedFilePath, + setOpenFilePathInputModal, + saveFile +}: FileMenuProps) => { + const [anchorEl, setAnchorEl] = useState< + (EventTarget & HTMLButtonElement) | null + >(null) + + const handleMenu = ( + event?: React.MouseEvent + ) => { + if (event) setAnchorEl(event.currentTarget) + else setAnchorEl(null) + } + + const handleDiffBtnClick = () => { + setAnchorEl(null) + setShowDiff(!showDiff) + } + + const handleSaveAsBtnClick = () => { + setAnchorEl(null) + setOpenFilePathInputModal(true) + } + + const handleSaveBtnClick = () => { + setAnchorEl(null) + saveFile() + } + + return ( + <> + + + + + + handleMenu()} + > + + + + + + + + + + + + ) +} + +export default FileMenu diff --git a/web/src/containers/Studio/internal/components/runMenu.tsx b/web/src/containers/Studio/internal/components/runMenu.tsx new file mode 100644 index 0000000..0ab05e9 --- /dev/null +++ b/web/src/containers/Studio/internal/components/runMenu.tsx @@ -0,0 +1,100 @@ +import { + Box, + Button, + FormControl, + IconButton, + MenuItem, + Select, + SelectChangeEvent, + Tooltip +} from '@mui/material' + +import { RocketLaunch } from '@mui/icons-material' + +type RunMenuProps = { + selectedFilePath: string + fileContent: string + prevFileContent: string + selectedRunTime: string + runTimes: string[] + handleChangeRunTime: (event: SelectChangeEvent) => void + handleRunBtnClick: () => void +} + +const RunMenu = ({ + selectedFilePath, + fileContent, + prevFileContent, + selectedRunTime, + runTimes, + handleChangeRunTime, + handleRunBtnClick +}: RunMenuProps) => { + const launchProgram = () => { + const baseUrl = window.location.origin + window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`) + } + + return ( + <> + + + + {selectedFilePath ? ( + + + + + + + + + + ) : ( + + + + + + )} + + ) +} + +export default RunMenu diff --git a/web/src/containers/Studio/internal/helper.ts b/web/src/containers/Studio/internal/helper.ts new file mode 100644 index 0000000..b368912 --- /dev/null +++ b/web/src/containers/Studio/internal/helper.ts @@ -0,0 +1,14 @@ +export const getLanguageFromExtension = (extension: string) => { + if (extension === 'js') return 'javascript' + + if (extension === 'ts') return 'typescript' + + if (extension === 'md' || extension === 'mdx') return 'markdown' + + return extension +} + +export const getSelection = (editor: any) => { + const selection = editor?.getModel().getValueInRange(editor?.getSelection()) + return selection ?? '' +} diff --git a/web/src/containers/Studio/internal/hooks/useEditor.ts b/web/src/containers/Studio/internal/hooks/useEditor.ts new file mode 100644 index 0000000..7cb8dd3 --- /dev/null +++ b/web/src/containers/Studio/internal/hooks/useEditor.ts @@ -0,0 +1,299 @@ +import axios from 'axios' +import { + Dispatch, + SetStateAction, + useCallback, + useContext, + useEffect, + useRef, + useState +} from 'react' +import { DiffEditorDidMount, EditorDidMount, monaco } from 'react-monaco-editor' +import { SelectChangeEvent } from '@mui/material' +import { getSelection } from '../helper' +import { AppContext, RunTimeType } from '../../../../context/appContext' +import { AlertSeverityType } from '../../../../components/snackbar' +import { + useModal, + useSnackbar, + useStateWithCallback +} from '../../../../utils/hooks' + +const SASJS_LOGS_SEPARATOR = + 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784' + +type UseEditorParams = { + selectedFilePath: string + setSelectedFilePath: (filePath: string, refreshSideBar?: boolean) => void + setTab: Dispatch> +} + +const useEditor = ({ + selectedFilePath, + setSelectedFilePath, + setTab +}: UseEditorParams) => { + const appContext = useContext(AppContext) + const { Dialog, setOpenModal, setModalTitle, setModalPayload } = useModal() + const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } = + useSnackbar() + const [isLoading, setIsLoading] = useState(false) + + const [prevFileContent, setPrevFileContent] = useStateWithCallback('') + const [fileContent, setFileContent] = useState('') + const [log, setLog] = useState('') + const [ctrlPressed, setCtrlPressed] = useState(false) + const [webout, setWebout] = useState('') + const [runTimes, setRunTimes] = useState([]) + const [selectedRunTime, setSelectedRunTime] = useState('') + const [selectedFileExtension, setSelectedFileExtension] = useState('') + const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false) + const [showDiff, setShowDiff] = useState(false) + + const editorRef = useRef(null as any) + + const handleEditorDidMount: EditorDidMount = (editor) => { + editorRef.current = editor + editor.focus() + editor.addAction({ + // An unique identifier of the contributed action. + id: 'show-difference', + + // A label of the action that will be presented to the user. + label: 'Show Differences', + + // An optional array of keybindings for the action. + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD], + + contextMenuGroupId: 'navigation', + + contextMenuOrder: 1, + + // Method that will be executed when the action is triggered. + // @param editor The editor instance is passed in as a convenience + run: function (ed) { + setShowDiff(true) + } + }) + } + + const handleDiffEditorDidMount: DiffEditorDidMount = (diffEditor) => { + diffEditor.focus() + diffEditor.addCommand(monaco.KeyCode.Escape, function () { + setShowDiff(false) + }) + } + + const saveFile = useCallback( + (filePath?: string) => { + setIsLoading(true) + + if (filePath) { + filePath = filePath.startsWith('/') ? filePath : `/${filePath}` + } + + const formData = new FormData() + + const stringBlob = new Blob([fileContent], { type: 'text/plain' }) + formData.append('file', stringBlob) + formData.append('filePath', filePath ?? selectedFilePath) + + const axiosPromise = filePath + ? axios.post('/SASjsApi/drive/file', formData) + : axios.patch('/SASjsApi/drive/file', formData) + + axiosPromise + .then(() => { + if (filePath && fileContent === prevFileContent) { + // when fileContent and prevFileContent is same, + // callback function in setPrevFileContent method is not called + // because behind the scene useEffect hook is being used + // for calling callback function, and it's only fired when the + // new value is not equal to old value. + // So, we'll have to explicitly update the selected file path + + setSelectedFilePath(filePath, true) + } else { + setPrevFileContent(fileContent, () => { + if (filePath) { + setSelectedFilePath(filePath, true) + } + }) + } + setSnackbarMessage('File saved!') + setSnackbarSeverity(AlertSeverityType.Success) + setOpenSnackbar(true) + }) + .catch((err) => { + setModalTitle('Abort') + setModalPayload( + typeof err.response.data === 'object' + ? JSON.stringify(err.response.data) + : err.response.data + ) + setOpenModal(true) + }) + .finally(() => { + setIsLoading(false) + }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [fileContent, prevFileContent, selectedFilePath] + ) + + const handleTabChange = (_e: any, newValue: string) => { + setTab(newValue) + } + + const handleRunBtnClick = () => + runCode(getSelection(editorRef.current as any) || fileContent) + + const runCode = (code: string) => { + setIsLoading(true) + axios + .post(`/SASjsApi/code/execute`, { code, runTime: selectedRunTime }) + .then((res: any) => { + setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '') + setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '') + setTab('log') + + // Scroll to bottom of log + const logElement = document.getElementById('log') + if (logElement) logElement.scrollTop = logElement.scrollHeight + }) + .catch((err) => { + setModalTitle('Abort') + setModalPayload( + typeof err.response.data === 'object' + ? JSON.stringify(err.response.data) + : err.response.data + ) + setOpenModal(true) + }) + .finally(() => setIsLoading(false)) + } + + const handleKeyDown = (event: any) => { + if (event.ctrlKey) { + if (event.key === 'v') { + setCtrlPressed(false) + } + + if (event.key === 'Enter') + runCode(getSelection(editorRef.current as any) || fileContent) + if (!ctrlPressed) setCtrlPressed(true) + } + } + + const handleKeyUp = (event: any) => { + if (!event.ctrlKey && ctrlPressed) setCtrlPressed(false) + } + + const handleChangeRunTime = (event: SelectChangeEvent) => { + setSelectedRunTime(event.target.value as RunTimeType) + } + + const handleFilePathInput = (filePath: string) => { + setOpenFilePathInputModal(false) + saveFile(filePath) + } + + useEffect(() => { + editorRef.current.addAction({ + // An unique identifier of the contributed action. + id: 'save-file', + + // A label of the action that will be presented to the user. + label: 'Save', + + // An optional array of keybindings for the action. + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + + // Method that will be executed when the action is triggered. + // @param editor The editor instance is passed in as a convenience + run: () => { + if (!selectedFilePath) return setOpenFilePathInputModal(true) + if (prevFileContent !== fileContent) return saveFile() + } + }) + }, [fileContent, prevFileContent, selectedFilePath, saveFile]) + + useEffect(() => { + setRunTimes(Object.values(appContext.runTimes)) + }, [appContext.runTimes]) + + useEffect(() => { + if (runTimes.length) setSelectedRunTime(runTimes[0]) + }, [runTimes]) + + useEffect(() => { + if (selectedFilePath) { + setIsLoading(true) + setSelectedFileExtension(selectedFilePath.split('.').pop() ?? '') + axios + .get(`/SASjsApi/drive/file?_filePath=${selectedFilePath}`) + .then((res: any) => { + setPrevFileContent(res.data) + setFileContent(res.data) + }) + .catch((err) => { + setModalTitle('Abort') + setModalPayload( + typeof err.response.data === 'object' + ? JSON.stringify(err.response.data) + : err.response.data + ) + setOpenModal(true) + }) + .finally(() => setIsLoading(false)) + } else { + const content = localStorage.getItem('fileContent') ?? '' + setFileContent(content) + } + setLog('') + setWebout('') + setTab('code') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedFilePath]) + + useEffect(() => { + if (fileContent.length && !selectedFilePath) { + localStorage.setItem('fileContent', fileContent) + } + }, [fileContent, selectedFilePath]) + + useEffect(() => { + if (runTimes.includes(selectedFileExtension)) + setSelectedRunTime(selectedFileExtension) + }, [selectedFileExtension, runTimes]) + + return { + ctrlPressed, + fileContent, + isLoading, + log, + openFilePathInputModal, + prevFileContent, + runTimes, + selectedFileExtension, + selectedRunTime, + showDiff, + webout, + Dialog, + handleChangeRunTime, + handleDiffEditorDidMount, + handleEditorDidMount, + handleFilePathInput, + handleKeyDown, + handleKeyUp, + handleRunBtnClick, + handleTabChange, + saveFile, + setShowDiff, + setOpenFilePathInputModal, + setFileContent, + Snackbar + } +} + +export default useEditor diff --git a/web/src/context/permissionsContext.tsx b/web/src/context/permissionsContext.tsx new file mode 100644 index 0000000..e59f509 --- /dev/null +++ b/web/src/context/permissionsContext.tsx @@ -0,0 +1,120 @@ +import React, { + createContext, + Dispatch, + SetStateAction, + useState, + useCallback, + ReactNode +} from 'react' +import axios from 'axios' +import { PermissionResponse } from '../utils/types' +import { useModal, useSnackbar } from '../utils/hooks' +import { AlertSeverityType } from '../components/snackbar' +import usePermissionResponseModal from '../containers/Settings/internal/hooks/usePermissionResponseModal' +import { PermissionResponsePayload } from '../containers/Settings/internal/components/permissionResponseModal' + +interface PermissionsContextProps { + isLoading: boolean + setIsLoading: Dispatch> + permissions: PermissionResponse[] + setPermissions: Dispatch> + selectedPermission: PermissionResponse | undefined + setSelectedPermission: Dispatch< + React.SetStateAction + > + filteredPermissions: PermissionResponse[] + setFilteredPermissions: Dispatch> + filterApplied: boolean + setFilterApplied: Dispatch> + fetchPermissions: () => void + Dialog: () => JSX.Element + setOpenModal: Dispatch> + setModalTitle: Dispatch> + setModalPayload: Dispatch> + Snackbar: () => JSX.Element + setOpenSnackbar: Dispatch> + setSnackbarMessage: Dispatch> + setSnackbarSeverity: Dispatch> + PermissionResponseDialog: () => JSX.Element + setOpenPermissionResponseModal: Dispatch> + setPermissionResponsePayload: Dispatch< + React.SetStateAction + > +} + +export const PermissionsContext = createContext( + undefined! +) + +const PermissionsContextProvider = (props: { children: ReactNode }) => { + const { children } = props + const { Dialog, setOpenModal, setModalTitle, setModalPayload } = useModal() + const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } = + useSnackbar() + const { + PermissionResponseDialog, + setOpenPermissionResponseModal, + setPermissionResponsePayload + } = usePermissionResponseModal() + const [isLoading, setIsLoading] = useState(false) + const [permissions, setPermissions] = useState([]) + const [selectedPermission, setSelectedPermission] = + useState() + const [filteredPermissions, setFilteredPermissions] = useState< + PermissionResponse[] + >([]) + const [filterApplied, setFilterApplied] = useState(false) + + const fetchPermissions = useCallback(() => { + axios + .get(`/SASjsApi/permission`) + .then((res: any) => { + if (res.data?.length > 0) { + setPermissions(res.data) + } + }) + .catch((err) => { + setModalTitle('Abort') + setModalPayload( + typeof err.response.data === 'object' + ? JSON.stringify(err.response.data) + : err.response.data + ) + setOpenModal(true) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + {children} + + ) +} + +export default PermissionsContextProvider diff --git a/web/src/utils/hooks/index.ts b/web/src/utils/hooks/index.ts index bb694d1..48f7886 100644 --- a/web/src/utils/hooks/index.ts +++ b/web/src/utils/hooks/index.ts @@ -1,2 +1,4 @@ +export * from './useModal' export * from './usePrompt' export * from './useStateWithCallback' +export * from './useSnackbar' diff --git a/web/src/utils/hooks/useModal.tsx b/web/src/utils/hooks/useModal.tsx new file mode 100644 index 0000000..8553e2a --- /dev/null +++ b/web/src/utils/hooks/useModal.tsx @@ -0,0 +1,19 @@ +import { useState } from 'react' +import Modal from '../../components/modal' + +export const useModal = () => { + const [openModal, setOpenModal] = useState(false) + const [modalTitle, setModalTitle] = useState('') + const [modalPayload, setModalPayload] = useState('') + + const Dialog = () => ( + + ) + + return { Dialog, setOpenModal, setModalTitle, setModalPayload } +} diff --git a/web/src/utils/hooks/useSnackbar.tsx b/web/src/utils/hooks/useSnackbar.tsx new file mode 100644 index 0000000..0fbc304 --- /dev/null +++ b/web/src/utils/hooks/useSnackbar.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar' + +export const useSnackbar = () => { + const [openSnackbar, setOpenSnackbar] = useState(false) + const [snackbarMessage, setSnackbarMessage] = useState('') + const [snackbarSeverity, setSnackbarSeverity] = useState( + AlertSeverityType.Success + ) + + const Snackbar = () => ( + + ) + + return { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } +}