1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-15 13:04:36 +00:00

Compare commits

...

14 Commits

Author SHA1 Message Date
semantic-release-bot
fb6a556630 chore(release): 0.26.0 [skip ci]
# [0.26.0](https://github.com/sasjs/server/compare/v0.25.1...v0.26.0) (2022-11-13)

### Bug Fixes

* **web:** dispose monaco editor actions in return of useEffect ([acc25cb](acc25cbd68))

### Features

* make access token duration configurable when creating client/secret ([2413c05](2413c05fea))
* make refresh token duration configurable ([abd5c64](abd5c64b4a))
2022-11-13 14:04:03 +00:00
Allan Bowe
9dbd8e16bd Merge pull request #315 from sasjs/issue-307
feat: make access token duration configurable when creating client
2022-11-13 14:00:03 +00:00
fe07c41f5f chore: update header 2022-11-11 15:35:24 +05:00
acc25cbd68 fix(web): dispose monaco editor actions in return of useEffect 2022-11-11 15:27:12 +05:00
4ca61feda6 chore: npm audit fix 2022-11-10 21:05:41 +05:00
abd5c64b4a feat: make refresh token duration configurable 2022-11-10 21:02:20 +05:00
2413c05fea feat: make access token duration configurable when creating client/secret 2022-11-10 19:43:06 +05:00
semantic-release-bot
4c874c2c39 chore(release): 0.25.1 [skip ci]
## [0.25.1](https://github.com/sasjs/server/compare/v0.25.0...v0.25.1) (2022-11-07)

### Bug Fixes

* **web:** use mui treeView instead of custom implementation ([c51b504](c51b50428f))
2022-11-07 15:50:02 +00:00
Allan Bowe
d819d79bc9 Merge pull request #313 from sasjs/tree-view
fix(web): use mui treeView instead of custom implementation
2022-11-07 15:46:14 +00:00
c51b50428f fix(web): use mui treeView instead of custom implementation 2022-11-06 01:14:58 +05:00
semantic-release-bot
e10a0554f0 chore(release): 0.25.0 [skip ci]
# [0.25.0](https://github.com/sasjs/server/compare/v0.24.0...v0.25.0) (2022-11-02)

### Features

* Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server ([1c9d167](1c9d167f86))
2022-11-02 15:24:25 +00:00
Allan Bowe
337e2eb2a0 Merge pull request #311 from sasjs/issue-310
feat: Enable DRIVE_LOCATION setting for deploying multiple instances
2022-11-02 15:19:54 +00:00
66f8e7840b chore: update readme 2022-11-02 20:18:28 +05:00
1c9d167f86 feat: Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server 2022-11-02 20:05:12 +05:00
24 changed files with 257 additions and 139 deletions

View File

@@ -1,3 +1,30 @@
# [0.26.0](https://github.com/sasjs/server/compare/v0.25.1...v0.26.0) (2022-11-13)
### Bug Fixes
* **web:** dispose monaco editor actions in return of useEffect ([acc25cb](https://github.com/sasjs/server/commit/acc25cbd686952d3f1c65e57aefcebe1cb859cc7))
### Features
* make access token duration configurable when creating client/secret ([2413c05](https://github.com/sasjs/server/commit/2413c05fea3960f7e5c3c8b7b2f85d61314f08db))
* make refresh token duration configurable ([abd5c64](https://github.com/sasjs/server/commit/abd5c64b4a726e3f17594a98111b6aa269b71fee))
## [0.25.1](https://github.com/sasjs/server/compare/v0.25.0...v0.25.1) (2022-11-07)
### Bug Fixes
* **web:** use mui treeView instead of custom implementation ([c51b504](https://github.com/sasjs/server/commit/c51b50428f32608bc46438e9d7964429b2d595da))
# [0.25.0](https://github.com/sasjs/server/compare/v0.24.0...v0.25.0) (2022-11-02)
### Features
* Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server ([1c9d167](https://github.com/sasjs/server/commit/1c9d167f86bbbb108b96e9bc30efaf8de65d82ff))
# [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28) # [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28)

View File

@@ -93,6 +93,10 @@ R_PATH=/usr/bin/Rscript
SASJS_ROOT=./sasjs_root SASJS_ROOT=./sasjs_root
# This location is for files, sasjs packages and appStreamConfig.json
DRIVE_LOCATION=./sasjs_root/drive
# options: [http|https] default: http # options: [http|https] default: http
PROTOCOL= PROTOCOL=

View File

@@ -30,6 +30,7 @@ PYTHON_PATH=/usr/bin/python
R_PATH=/usr/bin/Rscript R_PATH=/usr/bin/Rscript
SASJS_ROOT=./sasjs_root SASJS_ROOT=./sasjs_root
DRIVE_LOCATION=./sasjs_root/drive
LOG_FORMAT_MORGAN=common LOG_FORMAT_MORGAN=common
LOG_LOCATION=./sasjs_root/logs LOG_LOCATION=./sasjs_root/logs

12
api/package-lock.json generated
View File

@@ -7092,9 +7092,9 @@
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.0.4", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@@ -15592,9 +15592,9 @@
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
}, },
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }

View File

@@ -57,6 +57,16 @@ components:
type: string type: string
description: 'Client Secret' description: 'Client Secret'
example: someRandomCryptoString example: someRandomCryptoString
accessTokenExpiryDays:
type: number
format: double
description: 'Number of days in which access token will expire'
example: 1
refreshTokenExpiryDays:
type: number
format: double
description: 'Number of days in which access token will expire'
example: 30
required: required:
- clientId - clientId
- clientSecret - clientSecret
@@ -679,8 +689,8 @@ paths:
$ref: '#/components/schemas/ClientPayload' $ref: '#/components/schemas/ClientPayload'
examples: examples:
'Example 1': 'Example 1':
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString} value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiryDays: 1, refreshTokenExpiryDays: 30}
summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.' summary: "Admin only task. Create client with the following attributes:\nClientId,\nClientSecret,\naccessTokenExpiryDays (optional),\nrefreshTokenExpiryDays (optional)"
tags: tags:
- Client - Client
security: security:

View File

@@ -11,6 +11,7 @@ import {
ReturnCode, ReturnCode,
setProcessVariables, setProcessVariables,
setupFolders, setupFolders,
setupUserAutoExec,
verifyEnvVariables verifyEnvVariables
} from './utils' } from './utils'
import { import {
@@ -62,8 +63,12 @@ export default setProcessVariables().then(async () => {
// Currently only place we use it is SAS9 Mock - POST /SASLogon/login // Currently only place we use it is SAS9 Mock - POST /SASLogon/login
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
await setupFolders() await setupUserAutoExec()
await copySASjsCore()
if (process.driveLoc === path.join(process.sasjsRoot, 'drive')) {
await setupFolders()
await copySASjsCore()
}
// loading these modules after setting up variables due to // loading these modules after setting up variables due to
// multer's usage of process var process.driveLoc // multer's usage of process var process.driveLoc

View File

@@ -8,6 +8,7 @@ import {
removeTokensInDB, removeTokensInDB,
saveTokensInDB saveTokensInDB
} from '../utils' } from '../utils'
import Client from '../model/Client'
@Route('SASjsApi/auth') @Route('SASjsApi/auth')
@Tags('Auth') @Tags('Auth')
@@ -83,8 +84,17 @@ const token = async (data: any): Promise<TokenResponse> => {
} }
} }
const accessToken = generateAccessToken(userInfo) const client = await Client.findOne({ clientId })
const refreshToken = generateRefreshToken(userInfo) if (!client) throw new Error('Invalid clientId.')
const accessToken = generateAccessToken(
userInfo,
client.accessTokenExpiryDays
)
const refreshToken = generateRefreshToken(
userInfo,
client.refreshTokenExpiryDays
)
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken) await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
@@ -92,8 +102,17 @@ const token = async (data: any): Promise<TokenResponse> => {
} }
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => { const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
const accessToken = generateAccessToken(userInfo) const client = await Client.findOne({ clientId: userInfo.clientId })
const refreshToken = generateRefreshToken(userInfo) if (!client) throw new Error('Invalid clientId.')
const accessToken = generateAccessToken(
userInfo,
client.accessTokenExpiryDays
)
const refreshToken = generateRefreshToken(
userInfo,
client.refreshTokenExpiryDays
)
await saveTokensInDB( await saveTokensInDB(
userInfo.userId, userInfo.userId,

View File

@@ -7,12 +7,18 @@ import Client, { ClientPayload } from '../model/Client'
@Tags('Client') @Tags('Client')
export class ClientController { export class ClientController {
/** /**
* @summary Create client with the following attributes: ClientId, ClientSecret. Admin only task. * @summary Admin only task. Create client with the following attributes:
* ClientId,
* ClientSecret,
* accessTokenExpiryDays (optional),
* refreshTokenExpiryDays (optional)
* *
*/ */
@Example<ClientPayload>({ @Example<ClientPayload>({
clientId: 'someFormattedClientID1234', clientId: 'someFormattedClientID1234',
clientSecret: 'someRandomCryptoString' clientSecret: 'someRandomCryptoString',
accessTokenExpiryDays: 1,
refreshTokenExpiryDays: 30
}) })
@Post('/') @Post('/')
public async createClient( public async createClient(
@@ -22,8 +28,13 @@ export class ClientController {
} }
} }
const createClient = async (data: any): Promise<ClientPayload> => { const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
const { clientId, clientSecret } = data const {
clientId,
clientSecret,
accessTokenExpiryDays,
refreshTokenExpiryDays
} = data
// Checking if client is already in the database // Checking if client is already in the database
const clientExist = await Client.findOne({ clientId }) const clientExist = await Client.findOne({ clientId })
@@ -32,13 +43,16 @@ const createClient = async (data: any): Promise<ClientPayload> => {
// Create a new client // Create a new client
const client = new Client({ const client = new Client({
clientId, clientId,
clientSecret clientSecret,
accessTokenExpiryDays
}) })
const savedClient = await client.save() const savedClient = await client.save()
return { return {
clientId: savedClient.clientId, clientId: savedClient.clientId,
clientSecret: savedClient.clientSecret clientSecret: savedClient.clientSecret,
accessTokenExpiryDays: savedClient.accessTokenExpiryDays,
refreshTokenExpiryDays: savedClient.refreshTokenExpiryDays
} }
} }

View File

@@ -11,6 +11,16 @@ export interface ClientPayload {
* @example "someRandomCryptoString" * @example "someRandomCryptoString"
*/ */
clientSecret: string clientSecret: string
/**
* Number of days in which access token will expire
* @example 1
*/
accessTokenExpiryDays?: number
/**
* Number of days in which access token will expire
* @example 30
*/
refreshTokenExpiryDays?: number
} }
const ClientSchema = new Schema<ClientPayload>({ const ClientSchema = new Schema<ClientPayload>({
@@ -21,6 +31,14 @@ const ClientSchema = new Schema<ClientPayload>({
clientSecret: { clientSecret: {
type: String, type: String,
required: true required: true
},
accessTokenExpiryDays: {
type: Number,
default: 1
},
refreshTokenExpiryDays: {
type: Number,
default: 30
} }
}) })

View File

@@ -5,6 +5,7 @@ declare namespace NodeJS {
pythonLoc?: string pythonLoc?: string
rLoc?: string rLoc?: string
driveLoc: string driveLoc: string
sasjsRoot: string
logsLoc: string logsLoc: string
logsUUID: string logsUUID: string
sessionController?: import('../../controllers/internal').SessionController sessionController?: import('../../controllers/internal').SessionController

View File

@@ -12,7 +12,7 @@ import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
export const copySASjsCore = async () => { export const copySASjsCore = async () => {
if (process.env.NODE_ENV === 'test') return if (process.env.NODE_ENV === 'test') return
console.log('Copying Macros from container to drive(tmp).') console.log('Copying Macros from container to drive.')
const macrosDrivePath = getMacrosFolder() const macrosDrivePath = getMacrosFolder()

View File

@@ -20,22 +20,24 @@ export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server')
export const getDesktopUserAutoExecPath = () => export const getDesktopUserAutoExecPath = () =>
path.join(getSasjsHomeFolder(), 'user-autoexec.sas') path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
export const getSasjsRootFolder = () => process.driveLoc export const getSasjsRootFolder = () => process.sasjsRoot
export const getSasjsDriveFolder = () => process.driveLoc
export const getLogFolder = () => process.logsLoc export const getLogFolder = () => process.logsLoc
export const getAppStreamConfigPath = () => export const getAppStreamConfigPath = () =>
path.join(getSasjsRootFolder(), 'appStreamConfig.json') path.join(getSasjsDriveFolder(), 'appStreamConfig.json')
export const getMacrosFolder = () => export const getMacrosFolder = () =>
path.join(getSasjsRootFolder(), 'sas', 'sasautos') path.join(getSasjsDriveFolder(), 'sas', 'sasautos')
export const getPackagesFolder = () => export const getPackagesFolder = () =>
path.join(getSasjsRootFolder(), 'sas', 'sas_packages') path.join(getSasjsDriveFolder(), 'sas', 'sas_packages')
export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads') export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files') export const getFilesFolder = () => path.join(getSasjsDriveFolder(), 'files')
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts') export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')

View File

@@ -1,7 +1,7 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types' import { InfoJWT } from '../types'
export const generateAccessToken = (data: InfoJWT) => export const generateAccessToken = (data: InfoJWT, expiry?: number) =>
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, { jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
expiresIn: '1day' expiresIn: expiry ? `${expiry}d` : '1d'
}) })

View File

@@ -1,7 +1,7 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types' import { InfoJWT } from '../types'
export const generateRefreshToken = (data: InfoJWT) => export const generateRefreshToken = (data: InfoJWT, expiry?: number) =>
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, { jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
expiresIn: '30 days' expiresIn: expiry ? `${expiry}d` : '30d'
}) })

View File

@@ -26,6 +26,7 @@ export * from './saveTokensInDB'
export * from './seedDB' export * from './seedDB'
export * from './setProcessVariables' export * from './setProcessVariables'
export * from './setupFolders' export * from './setupFolders'
export * from './setupUserAutoExec'
export * from './upload' export * from './upload'
export * from './validation' export * from './validation'
export * from './verifyEnvVariables' export * from './verifyEnvVariables'

View File

@@ -19,7 +19,8 @@ export const setProcessVariables = async () => {
} }
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
process.driveLoc = path.join(process.cwd(), 'sasjs_root') process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
return return
} }
@@ -41,11 +42,19 @@ export const setProcessVariables = async () => {
const { SASJS_ROOT } = process.env const { SASJS_ROOT } = process.env
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd()) const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
await createFolder(absPath) await createFolder(absPath)
process.driveLoc = getRealPath(absPath) process.sasjsRoot = getRealPath(absPath)
const { DRIVE_LOCATION } = process.env
const absDrivePath = getAbsolutePath(
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
process.cwd()
)
await createFolder(absDrivePath)
process.driveLoc = getRealPath(absDrivePath)
const { LOG_LOCATION } = process.env const { LOG_LOCATION } = process.env
const absLogsPath = getAbsolutePath( const absLogsPath = getAbsolutePath(
LOG_LOCATION ?? `sasjs_root${path.sep}logs`, LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
process.cwd() process.cwd()
) )
await createFolder(absLogsPath) await createFolder(absLogsPath)

View File

@@ -1,19 +1,7 @@
import { createFile, createFolder, fileExists } from '@sasjs/utils' import { createFolder } from '@sasjs/utils'
import { import { getFilesFolder, getPackagesFolder } from './file'
getDesktopUserAutoExecPath,
getFilesFolder,
getPackagesFolder
} from './file'
import { ModeType } from './verifyEnvVariables'
export const setupFolders = async () => { export const setupFolders = async () => {
const drivePath = getFilesFolder() await createFolder(getFilesFolder())
await createFolder(drivePath)
await createFolder(getPackagesFolder()) await createFolder(getPackagesFolder())
if (process.env.MODE === ModeType.Desktop) {
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
await createFile(getDesktopUserAutoExecPath(), '')
}
}
} }

View File

@@ -0,0 +1,11 @@
import { createFile, fileExists } from '@sasjs/utils'
import { getDesktopUserAutoExecPath } from './file'
import { ModeType } from './verifyEnvVariables'
export const setupUserAutoExec = async () => {
if (process.env.MODE === ModeType.Desktop) {
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
await createFile(getDesktopUserAutoExecPath(), '')
}
}
}

View File

@@ -88,7 +88,9 @@ export const updateUserValidation = (
export const registerClientValidation = (data: any): Joi.ValidationResult => export const registerClientValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
clientId: Joi.string().required(), clientId: Joi.string().required(),
clientSecret: Joi.string().required() clientSecret: Joi.string().required(),
accessTokenExpiryDays: Joi.number(),
refreshTokenExpiryDays: Joi.number()
}).validate(data) }).validate(data)
export const registerPermissionValidation = (data: any): Joi.ValidationResult => export const registerPermissionValidation = (data: any): Joi.ValidationResult =>

View File

@@ -31,14 +31,24 @@ const DeleteConfirmationModal = ({
message, message,
_delete _delete
}: DeleteConfirmationModalProps) => { }: DeleteConfirmationModalProps) => {
const handleDeleteClick = (event: React.MouseEvent) => {
event.stopPropagation()
_delete()
}
const handleClose = (event: any) => {
event.stopPropagation()
setOpen(false)
}
return ( return (
<BootstrapDialog onClose={() => setOpen(false)} open={open}> <BootstrapDialog onClose={handleClose} open={open}>
<DialogContent dividers> <DialogContent dividers>
<Typography gutterBottom>{message}</Typography> <Typography gutterBottom>{message}</Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button> <Button onClick={handleClose}>Cancel</Button>
<Button color="error" onClick={() => _delete()}> <Button color="error" onClick={handleDeleteClick}>
Delete Delete
</Button> </Button>
</DialogActions> </DialogActions>

View File

@@ -69,8 +69,18 @@ const NameInputModal = ({
action(name) action(name)
} }
const handleActionClick = (event: React.MouseEvent) => {
event.stopPropagation()
action(name)
}
const handleClose = (event: any) => {
event.stopPropagation()
setOpen(false)
}
return ( return (
<BootstrapDialog fullWidth onClose={() => setOpen(false)} open={open}> <BootstrapDialog fullWidth onClose={handleClose} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}> <BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
{title} {title}
</BootstrapDialogTitle> </BootstrapDialogTitle>
@@ -91,12 +101,12 @@ const NameInputModal = ({
</form> </form>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button variant="contained" onClick={() => setOpen(false)}> <Button variant="contained" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
onClick={() => action(name)} onClick={handleActionClick}
disabled={hasError || !name} disabled={hasError || !name}
> >
{actionLabel} {actionLabel}

View File

@@ -1,67 +1,79 @@
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { Menu, MenuItem } from '@mui/material' import { Menu, MenuItem, Typography } from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ChevronRightIcon from '@mui/icons-material/ChevronRight' import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import MuiTreeView from '@mui/lab/TreeView'
import MuiTreeItem from '@mui/lab/TreeItem'
import DeleteConfirmationModal from './deleteConfirmationModal' import DeleteConfirmationModal from './deleteConfirmationModal'
import NameInputModal from './nameInputModal' import NameInputModal from './nameInputModal'
import { TreeNode } from '../utils/types' import { TreeNode } from '../utils/types'
type Props = { interface Props {
node: TreeNode node: TreeNode
selectedFilePath: string
handleSelect: (filePath: string) => void handleSelect: (filePath: string) => void
deleteNode: (path: string, isFolder: boolean) => void deleteNode: (path: string, isFolder: boolean) => void
addFile: (path: string) => void addFile: (path: string) => void
addFolder: (path: string) => void addFolder: (path: string) => void
rename: (oldPath: string, newPath: string) => void rename: (oldPath: string, newPath: string) => void
}
interface TreeViewProps extends Props {
defaultExpanded?: string[] defaultExpanded?: string[]
} }
const TreeView = ({ const TreeView = ({
node, node,
selectedFilePath,
handleSelect, handleSelect,
deleteNode, deleteNode,
addFile, addFile,
addFolder, addFolder,
rename, rename,
defaultExpanded defaultExpanded
}: Props) => { }: TreeViewProps) => {
return ( const renderTree = (nodes: TreeNode) => (
<ul <MuiTreeItem
style={{ key={nodes.relativePath}
listStyle: 'none', nodeId={nodes.relativePath}
padding: '0.25rem 0.85rem', label={
width: 'max-content' <TreeItemWithContextMenu
}} node={nodes}
handleSelect={handleSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
/>
}
> >
<TreeViewNode {Array.isArray(nodes.children)
node={node} ? nodes.children.map((node) => renderTree(node))
selectedFilePath={selectedFilePath} : null}
handleSelect={handleSelect} </MuiTreeItem>
deleteNode={deleteNode} )
addFile={addFile}
addFolder={addFolder} return (
rename={rename} <MuiTreeView
defaultExpanded={defaultExpanded} defaultCollapseIcon={<ExpandMoreIcon />}
/> defaultExpandIcon={<ChevronRightIcon />}
</ul> defaultExpanded={defaultExpanded}
sx={{ flexGrow: 1, maxWidth: 400, overflowY: 'auto' }}
>
{renderTree(node)}
</MuiTreeView>
) )
} }
export default TreeView export default TreeView
const TreeViewNode = ({ const TreeItemWithContextMenu = ({
node, node,
selectedFilePath,
handleSelect, handleSelect,
deleteNode, deleteNode,
addFile, addFile,
addFolder, addFolder,
rename, rename
defaultExpanded
}: Props) => { }: Props) => {
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
useState(false) useState(false)
@@ -72,18 +84,19 @@ const TreeViewNode = ({
const [nameInputModalTitle, setNameInputModalTitle] = useState('') const [nameInputModalTitle, setNameInputModalTitle] = useState('')
const [nameInputModalActionLabel, setNameInputModalActionLabel] = useState('') const [nameInputModalActionLabel, setNameInputModalActionLabel] = useState('')
const [nameInputModalForFolder, setNameInputModalForFolder] = useState(false) const [nameInputModalForFolder, setNameInputModalForFolder] = useState(false)
const [childVisible, setChildVisibility] = useState(false)
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
mouseX: number mouseX: number
mouseY: number mouseY: number
} | null>(null) } | null>(null)
const launchProgram = () => { const launchProgram = (event: React.MouseEvent) => {
event.stopPropagation()
const baseUrl = window.location.origin const baseUrl = window.location.origin
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}`) window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}`)
} }
const launchProgramWithDebug = () => { const launchProgramWithDebug = (event: React.MouseEvent) => {
event.stopPropagation()
const baseUrl = window.location.origin const baseUrl = window.location.origin
window.open( window.open(
`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}&_debug=131` `${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}&_debug=131`
@@ -103,25 +116,18 @@ const TreeViewNode = ({
) )
} }
const hasChild = node.children.length ? true : false const handleClose = (event: any) => {
event.stopPropagation()
const handleItemClick = () => { setContextMenu(null)
if (node.children.length) { }
setChildVisibility((v) => !v)
return
}
const handleItemClick = (event: React.MouseEvent) => {
if (node.children.length) return
handleSelect(node.relativePath) handleSelect(node.relativePath)
} }
useEffect(() => { const handleDeleteItemClick = (event: React.MouseEvent) => {
if (defaultExpanded && defaultExpanded[0] === node.relativePath) { event.stopPropagation()
setChildVisibility(true)
defaultExpanded.shift()
}
}, [defaultExpanded, node.relativePath])
const handleDeleteItemClick = () => {
setContextMenu(null) setContextMenu(null)
setDeleteConfirmationModalOpen(true) setDeleteConfirmationModalOpen(true)
setDeleteConfirmationModalMessage( setDeleteConfirmationModalMessage(
@@ -136,7 +142,8 @@ const TreeViewNode = ({
deleteNode(node.relativePath, node.isFolder) deleteNode(node.relativePath, node.isFolder)
} }
const handleNewFolderItemClick = () => { const handleNewFolderItemClick = (event: React.MouseEvent) => {
event.stopPropagation()
setContextMenu(null) setContextMenu(null)
setNameInputModalOpen(true) setNameInputModalOpen(true)
setNameInputModalTitle('Add Folder') setNameInputModalTitle('Add Folder')
@@ -145,7 +152,8 @@ const TreeViewNode = ({
setDefaultInputModalName('') setDefaultInputModalName('')
} }
const handleNewFileItemClick = () => { const handleNewFileItemClick = (event: React.MouseEvent) => {
event.stopPropagation()
setContextMenu(null) setContextMenu(null)
setNameInputModalOpen(true) setNameInputModalOpen(true)
setNameInputModalTitle('Add File') setNameInputModalTitle('Add File')
@@ -161,7 +169,8 @@ const TreeViewNode = ({
else addFile(path) else addFile(path)
} }
const handleRenameItemClick = () => { const handleRenameItemClick = (event: React.MouseEvent) => {
event.stopPropagation()
setContextMenu(null) setContextMenu(null)
setNameInputModalOpen(true) setNameInputModalOpen(true)
setNameInputModalTitle('Rename') setNameInputModalTitle('Rename')
@@ -181,34 +190,7 @@ const TreeViewNode = ({
return ( return (
<div onContextMenu={handleContextMenu} style={{ cursor: 'context-menu' }}> <div onContextMenu={handleContextMenu} style={{ cursor: 'context-menu' }}>
<li style={{ display: 'list-item' }}> <Typography onClick={handleItemClick}>{node.name}</Typography>
<div
className={`tree-item-label ${
selectedFilePath === node.relativePath ? 'selected' : ''
}`}
onClick={() => handleItemClick()}
>
{hasChild &&
(childVisible ? <ExpandMoreIcon /> : <ChevronRightIcon />)}
<div>{node.name}</div>
</div>
{hasChild &&
childVisible &&
node.children.map((child, index) => (
<TreeView
key={node.relativePath + '-' + index}
node={child}
selectedFilePath={selectedFilePath}
handleSelect={handleSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
defaultExpanded={defaultExpanded}
/>
))}
</li>
<DeleteConfirmationModal <DeleteConfirmationModal
open={deleteConfirmationModalOpen} open={deleteConfirmationModalOpen}
setOpen={setDeleteConfirmationModalOpen} setOpen={setDeleteConfirmationModalOpen}
@@ -228,7 +210,7 @@ const TreeViewNode = ({
/> />
<Menu <Menu
open={contextMenu !== null} open={contextMenu !== null}
onClose={() => setContextMenu(null)} onClose={handleClose}
anchorReference="anchorPosition" anchorReference="anchorPosition"
anchorPosition={ anchorPosition={
contextMenu !== null contextMenu !== null

View File

@@ -49,7 +49,7 @@ const useEditor = ({
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false) const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
const [showDiff, setShowDiff] = useState(false) const [showDiff, setShowDiff] = useState(false)
const editorRef = useRef(null as any) const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
const handleEditorDidMount: EditorDidMount = (editor) => { const handleEditorDidMount: EditorDidMount = (editor) => {
editorRef.current = editor editorRef.current = editor
@@ -199,7 +199,7 @@ const useEditor = ({
} }
useEffect(() => { useEffect(() => {
editorRef.current.addAction({ const saveFileAction = editorRef.current?.addAction({
// An unique identifier of the contributed action. // An unique identifier of the contributed action.
id: 'save-file', id: 'save-file',
@@ -209,6 +209,8 @@ const useEditor = ({
// An optional array of keybindings for the action. // An optional array of keybindings for the action.
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
contextMenuGroupId: '9_cutcopypaste',
// Method that will be executed when the action is triggered. // Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience // @param editor The editor instance is passed in as a convenience
run: () => { run: () => {
@@ -217,7 +219,7 @@ const useEditor = ({
} }
}) })
editorRef.current.addAction({ const runCodeAction = editorRef.current?.addAction({
// An unique identifier of the contributed action. // An unique identifier of the contributed action.
id: 'run-code', id: 'run-code',
@@ -229,14 +231,17 @@ const useEditor = ({
contextMenuGroupId: 'navigation', contextMenuGroupId: 'navigation',
contextMenuOrder: 1,
// Method that will be executed when the action is triggered. // Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience // @param editor The editor instance is passed in as a convenience
run: function () { run: function () {
runCode(getSelection(editorRef.current as any) || fileContent) runCode(getSelection(editorRef.current as any) || fileContent)
} }
}) })
return () => {
saveFileAction?.dispose()
runCodeAction?.dispose()
}
}, [fileContent, prevFileContent, selectedFilePath, saveFile, runCode]) }, [fileContent, prevFileContent, selectedFilePath, saveFile, runCode])
useEffect(() => { useEffect(() => {

View File

@@ -180,7 +180,6 @@ const SideBar = ({
{directoryData && ( {directoryData && (
<TreeView <TreeView
node={directoryData} node={directoryData}
selectedFilePath={selectedFilePath}
handleSelect={handleFileSelect} handleSelect={handleFileSelect}
deleteNode={deleteNode} deleteNode={deleteNode}
addFile={addFile} addFile={addFile}