feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Live mockup (#140)

* Workspaces initial frontend

Signed-off-by: paulovmr <832830+paulovmr@users.noreply.github.com>

* Fixing a CVE and cypress test

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

---------

Signed-off-by: paulovmr <832830+paulovmr@users.noreply.github.com>
Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>
Co-authored-by: Eder Ignatowicz <ignatowicz@gmail.com>
This commit is contained in:
Paulo Rego 2024-12-03 10:47:58 -03:00 committed by GitHub
parent e920dd99de
commit 68d0b91c2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 867 additions and 306 deletions

View File

@ -0,0 +1 @@
package.json

View File

@ -0,0 +1,6 @@
{
"arrowParens": "always",
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}

View File

@ -7,9 +7,21 @@ module.exports = {
path.resolve(relativeDir, 'node_modules/@patternfly/patternfly'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-styles/css'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-core/dist/styles/base.css'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-core/dist/esm/@patternfly/patternfly'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css')
]
path.resolve(
relativeDir,
'node_modules/@patternfly/react-core/dist/esm/@patternfly/patternfly',
),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css',
),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css',
),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css',
),
],
};

View File

@ -19,10 +19,10 @@ module.exports = (env) => {
loader: 'ts-loader',
options: {
transpileOnly: true,
experimentalWatchApi: true
}
}
]
experimentalWatchApi: true,
},
},
],
},
{
test: /\.(svg|ttf|eot|woff|woff2)$/,
@ -31,10 +31,16 @@ module.exports = (env) => {
// if they live under a 'fonts' or 'pficon' directory
include: [
path.resolve(relativeDir, 'node_modules/patternfly/dist/fonts'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-core/dist/styles/assets/fonts'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-core/dist/styles/assets/pficon'),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-core/dist/styles/assets/fonts',
),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-core/dist/styles/assets/pficon',
),
path.resolve(relativeDir, 'node_modules/@patternfly/patternfly/assets/fonts'),
path.resolve(relativeDir, 'node_modules/@patternfly/patternfly/assets/pficon')
path.resolve(relativeDir, 'node_modules/@patternfly/patternfly/assets/pficon'),
],
use: {
loader: 'file-loader',
@ -93,19 +99,22 @@ module.exports = (env) => {
path.resolve(relativeDir, 'node_modules/patternfly'),
path.resolve(relativeDir, 'node_modules/@patternfly/patternfly/assets/images'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-styles/css/assets/images'),
path.resolve(relativeDir, 'node_modules/@patternfly/react-core/dist/styles/assets/images'),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images'
'node_modules/@patternfly/react-core/dist/styles/assets/images',
),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images'
'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images',
),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images'
)
'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images',
),
path.resolve(
relativeDir,
'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images',
),
],
type: 'asset/inline',
use: [
@ -113,10 +122,10 @@ module.exports = (env) => {
options: {
limit: 5000,
outputPath: 'images',
name: '[name].[ext]'
}
}
]
name: '[name].[ext]',
},
},
],
},
{
test: /\.s[ac]ss$/i,
@ -128,35 +137,35 @@ module.exports = (env) => {
// Compiles Sass to CSS
'sass-loader',
],
}
]
},
],
},
output: {
filename: '[name].bundle.js',
path: path.resolve(relativeDir, 'dist'),
publicPath: ASSET_PATH
publicPath: ASSET_PATH,
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(relativeDir, 'src', 'index.html')
template: path.resolve(relativeDir, 'src', 'index.html'),
}),
new Dotenv({
systemvars: true,
silent: true
silent: true,
}),
new CopyPlugin({
patterns: [{ from: './src/images', to: 'images' }]
})
patterns: [{ from: './src/images', to: 'images' }],
}),
],
resolve: {
extensions: ['.js', '.ts', '.tsx', '.jsx'],
plugins: [
new TsconfigPathsPlugin({
configFile: path.resolve(relativeDir, './tsconfig.json')
})
configFile: path.resolve(relativeDir, './tsconfig.json'),
}),
],
symlinks: false,
cacheWithContext: false
}
cacheWithContext: false,
},
};
};
};

View File

@ -26,7 +26,7 @@ module.exports = merge(common('development'), {
},
proxy: [
{
context: ["/api"],
context: ['/api'],
target: {
host: PROXY_HOST,
protocol: PROXY_PROTOCOL,

View File

@ -15,24 +15,24 @@ module.exports = merge(common('production'), {
new TerserJSPlugin({}),
new CssMinimizerPlugin({
minimizerOptions: {
preset: ['default', { mergeLonghand: false }]
}
})
]
preset: ['default', { mergeLonghand: false }],
},
}),
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[name].bundle.css'
})
chunkFilename: '[name].bundle.css',
}),
],
module: {
rules: [
{
test: /\.css$/,
include: [...stylePaths],
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
}
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
});

View File

@ -12,21 +12,19 @@ module.exports = {
coverageDirectory: 'coverage',
// An array of directory names to be searched recursively up from the requiring module's location
moduleDirectories: [
'node_modules',
'<rootDir>/src'
],
moduleDirectories: ['node_modules', '<rootDir>/src'],
// A map from regular expressions to module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(css|less)$': '<rootDir>/__mocks__/styleMock.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js',
'@app/(.*)': '<rootDir>/src/app/$1'
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/fileMock.js',
'@app/(.*)': '<rootDir>/src/app/$1',
},
// A preset that is used as a base for Jest's configuration
preset: 'ts-jest/presets/js-with-ts',
// The test environment that will be used for testing.
testEnvironment: 'jsdom'
testEnvironment: 'jsdom',
};

View File

@ -9,9 +9,10 @@
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
"@patternfly/react-core": "6.0.0-alpha.68",
"@patternfly/react-icons": "6.0.0-alpha.24",
"@patternfly/react-styles": "6.0.0-alpha.24",
"@patternfly/react-core": "^6.0.0",
"@patternfly/react-icons": "^6.0.0",
"@patternfly/react-styles": "^6.0.0",
"@patternfly/react-table": "^6.0.0",
"npm-run-all": "^4.1.5",
"react": "^18",
"react-dom": "^18",
@ -3298,16 +3299,17 @@
}
},
"node_modules/@patternfly/react-core": {
"version": "6.0.0-alpha.68",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.0.0-alpha.68.tgz",
"integrity": "sha512-YhZY4xuDF0WJyDAAzHdvhgYCECs5bY4+QYUbkPe+dGLNLnwVMU3ZwZlLD9ARHRwfXDRV3g+ttS8HRbigbHgtpQ==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.0.0.tgz",
"integrity": "sha512-UKFj9+YzBY+FfEDsLONgOM4N0e8SPV/27/UzNRiJ0gpgqbw2POuXwLpjGSRTTIUuCaLaGGM5PeTSj7mMB73ykw==",
"license": "MIT",
"dependencies": {
"@patternfly/react-icons": "^6.0.0-alpha.24",
"@patternfly/react-styles": "^6.0.0-alpha.24",
"@patternfly/react-tokens": "^6.0.0-alpha.24",
"focus-trap": "7.5.4",
"@patternfly/react-icons": "^6.0.0",
"@patternfly/react-styles": "^6.0.0",
"@patternfly/react-tokens": "^6.0.0",
"focus-trap": "7.6.0",
"react-dropzone": "^14.2.3",
"tslib": "^2.6.2"
"tslib": "^2.7.0"
},
"peerDependencies": {
"react": "^17 || ^18",
@ -3315,23 +3317,44 @@
}
},
"node_modules/@patternfly/react-icons": {
"version": "6.0.0-alpha.24",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.0.0-alpha.24.tgz",
"integrity": "sha512-SRW9sTaHTbMJu+lf7+vWe5fNI1hfquZB6xlJe54D4WAzgzPDTxwPRi6I/KEboKBMZOvU/FYxH2/L5TCYmH51Hw==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.0.0.tgz",
"integrity": "sha512-ZFrsBVKrAp0DZrPOss98OA/EVUL4F0frXhR1uBId9+3ZrRArdKTgYgmQUCeSzMbxnSlxpmm3a2L05XQ36VUVbw==",
"license": "MIT",
"peerDependencies": {
"react": "^17 || ^18",
"react-dom": "^17 || ^18"
}
},
"node_modules/@patternfly/react-styles": {
"version": "6.0.0-alpha.24",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.0.0-alpha.24.tgz",
"integrity": "sha512-9Gh0CbeShaNOJSRy/Y7dEx+Nc9rlDrgjSF9rMtjlFTLfU7HvVavrfuBTGV5NpFJlVBtudJAsDaNRTlC22kKokg=="
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.0.0.tgz",
"integrity": "sha512-fJFMB89sTRGlZTzTLmpRmthgOXqcN078scHMFJ3ttfi2D2btnem5oZrxmQ/gPZkZOxR+9MqwKDB6l3F5x1SqLQ==",
"license": "MIT"
},
"node_modules/@patternfly/react-table": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.0.0.tgz",
"integrity": "sha512-LvWMzjcQZHdFUpK8fjj5EAFrNxqB8/MFd7gUUZu7AgYt6rmS2im4xk6yb7h0K7cAhY085oPeRF9lkYSCgzlRDg==",
"license": "MIT",
"dependencies": {
"@patternfly/react-core": "^6.0.0",
"@patternfly/react-icons": "^6.0.0",
"@patternfly/react-styles": "^6.0.0",
"@patternfly/react-tokens": "^6.0.0",
"lodash": "^4.17.21",
"tslib": "^2.7.0"
},
"peerDependencies": {
"react": "^17 || ^18",
"react-dom": "^17 || ^18"
}
},
"node_modules/@patternfly/react-tokens": {
"version": "6.0.0-alpha.24",
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.0.0-alpha.24.tgz",
"integrity": "sha512-jIVaGxxZD8Wsp2Xbf8z9mrpfYQx4NzlWlUza0IoTuwslEdcxt77Yo3sh0qlyfRBDNx+Q01tdEFXftJ+6OZQ3Gw=="
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.0.0.tgz",
"integrity": "sha512-xd0ynDkiIW2rp8jz4TNvR4Dyaw9kSMkZdsuYcLlFXCVmvX//Mnl4rhBnid/2j2TaqK0NbkyTTPnPY/BU7SfLVQ==",
"license": "MIT"
},
"node_modules/@pkgr/core": {
"version": "0.1.1",
@ -6911,10 +6934,11 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -9714,9 +9738,10 @@
"dev": true
},
"node_modules/focus-trap": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz",
"integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.0.tgz",
"integrity": "sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==",
"license": "MIT",
"dependencies": {
"tabbable": "^6.2.0"
}
@ -13653,8 +13678,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
@ -15035,9 +15059,9 @@
}
},
"node_modules/npm-run-all/node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
"integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
"integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==",
"license": "MIT",
"dependencies": {
"nice-try": "^1.0.4",
@ -18672,7 +18696,8 @@
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tcomb": {
"version": "3.2.29",
@ -19251,9 +19276,10 @@
}
},
"node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",

View File

@ -16,6 +16,8 @@
"build:bundle-analyze": "webpack-bundle-analyzer ./bundle.stats.json",
"build:clean": "rimraf ./dist",
"build:prod": "webpack --config ./config/webpack.prod.js",
"prettier": "prettier --ignore-path .gitignore --write \"**/*{.ts,.tsx,.js,.jsx,.css,.json}\"",
"prettier:check": "prettier --ignore-path .gitignore --check \"**/*{.ts,.tsx,.js,.jsx,.css,.json}\"",
"start:dev": "webpack serve --hot --color --config ./config/webpack.dev.js",
"test:cypress-ci": "npx concurrently -P -k -s first \"npm run cypress:server:build && npm run cypress:server\" \"npx wait-on tcp:127.0.0.1:9001 && npm run cypress:run:mock -- {@}\" -- ",
"test:jest": "jest",
@ -85,9 +87,10 @@
"webpack-merge": "^5.10.0"
},
"dependencies": {
"@patternfly/react-core": "6.0.0-alpha.68",
"@patternfly/react-icons": "6.0.0-alpha.24",
"@patternfly/react-styles": "6.0.0-alpha.24",
"@patternfly/react-core": "^6.0.0",
"@patternfly/react-icons": "^6.0.0",
"@patternfly/react-styles": "^6.0.0",
"@patternfly/react-table": "^6.0.0",
"npm-run-all": "^4.1.5",
"react": "^18",
"react-dom": "^18",

View File

@ -11,112 +11,109 @@ import { beforeRunHook, afterRunHook } from 'cypress-mochawesome-reporter/lib';
import { mergeFiles } from 'junit-report-merger';
import { env, BASE_URL } from '~/__tests__/cypress/cypress/utils/testConfig';
const resultsDir = `${env.CY_RESULTS_DIR || 'results'}/${env.CY_MOCK ? 'mocked' : 'e2e'}`;
export default defineConfig({
experimentalMemoryManagement: true,
// Use relative path as a workaround to https://github.com/cypress-io/cypress/issues/6406
reporter: '../../../node_modules/cypress-multi-reporters',
reporterOptions: {
reporterEnabled: 'cypress-mochawesome-reporter, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: `${resultsDir}/junit/junit-[hash].xml`,
},
cypressMochawesomeReporterReporterOptions: {
charts: true,
embeddedScreenshots: false,
ignoreVideos: false,
inlineAssets: true,
reportDir: resultsDir,
videoOnFailOnly: true,
},
experimentalMemoryManagement: true,
// Use relative path as a workaround to https://github.com/cypress-io/cypress/issues/6406
reporter: '../../../node_modules/cypress-multi-reporters',
reporterOptions: {
reporterEnabled: 'cypress-mochawesome-reporter, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: `${resultsDir}/junit/junit-[hash].xml`,
},
chromeWebSecurity: false,
viewportWidth: 1920,
viewportHeight: 1080,
numTestsKeptInMemory: 1,
video: true,
screenshotsFolder: `${resultsDir}/screenshots`,
videosFolder: `${resultsDir}/videos`,
env: {
MOCK: !!env.CY_MOCK,
coverage: !!env.CY_COVERAGE,
codeCoverage: {
exclude: [path.resolve(__dirname, '../../third_party/**')],
},
resolution: 'high',
cypressMochawesomeReporterReporterOptions: {
charts: true,
embeddedScreenshots: false,
ignoreVideos: false,
inlineAssets: true,
reportDir: resultsDir,
videoOnFailOnly: true,
},
defaultCommandTimeout: 10000,
e2e: {
baseUrl: BASE_URL,
specPattern: env.CY_MOCK
? `cypress/tests/mocked/**/*.cy.ts`
: `cypress/tests/e2e/**/*.cy.ts`,
experimentalInteractiveRunEvents: true,
setupNodeEvents(on, config) {
cypressHighResolution(on, config);
coverage(on, config);
on('task', {
readJSON(filePath: string) {
const absPath = path.resolve(__dirname, filePath);
if (fs.existsSync(absPath)) {
try {
return Promise.resolve(JSON.parse(fs.readFileSync(absPath, 'utf8')));
} catch {
// return default value
}
}
return Promise.resolve({});
},
log(message) {
// eslint-disable-next-line no-console
console.log(message);
return null;
},
error(message) {
// eslint-disable-next-line no-console
console.error(message);
return null;
},
table(message) {
// eslint-disable-next-line no-console
console.table(message);
return null;
},
});
// Delete videos for specs without failing or retried tests
on('after:spec', (_, results) => {
if (results.video) {
// Do we have failures for any retry attempts?
const failures = results.tests.some((test) =>
test.attempts.some((attempt) => attempt.state === 'failed'),
);
if (!failures) {
// delete the video if the spec passed and no tests retried
fs.unlinkSync(results.video);
},
chromeWebSecurity: false,
viewportWidth: 1920,
viewportHeight: 1080,
numTestsKeptInMemory: 1,
video: true,
screenshotsFolder: `${resultsDir}/screenshots`,
videosFolder: `${resultsDir}/videos`,
env: {
MOCK: !!env.CY_MOCK,
coverage: !!env.CY_COVERAGE,
codeCoverage: {
exclude: [path.resolve(__dirname, '../../third_party/**')],
},
resolution: 'high',
},
defaultCommandTimeout: 10000,
e2e: {
baseUrl: BASE_URL,
specPattern: env.CY_MOCK ? `cypress/tests/mocked/**/*.cy.ts` : `cypress/tests/e2e/**/*.cy.ts`,
experimentalInteractiveRunEvents: true,
setupNodeEvents(on, config) {
cypressHighResolution(on, config);
coverage(on, config);
on('task', {
readJSON(filePath: string) {
const absPath = path.resolve(__dirname, filePath);
if (fs.existsSync(absPath)) {
try {
return Promise.resolve(JSON.parse(fs.readFileSync(absPath, 'utf8')));
} catch {
// return default value
}
}
});
on('before:run', async (details) => {
// cypress-mochawesome-reporter
await beforeRunHook(details);
});
on('after:run', async () => {
// cypress-mochawesome-reporter
await afterRunHook();
// merge junit reports into a single report
const outputFile = path.join(__dirname, resultsDir, 'junit-report.xml');
const inputFiles = [`./${resultsDir}/junit/*.xml`];
await mergeFiles(outputFile, inputFiles);
});
return config;
},
return Promise.resolve({});
},
log(message) {
// eslint-disable-next-line no-console
console.log(message);
return null;
},
error(message) {
// eslint-disable-next-line no-console
console.error(message);
return null;
},
table(message) {
// eslint-disable-next-line no-console
console.table(message);
return null;
},
});
// Delete videos for specs without failing or retried tests
on('after:spec', (_, results) => {
if (results.video) {
// Do we have failures for any retry attempts?
const failures = results.tests.some((test) =>
test.attempts.some((attempt) => attempt.state === 'failed'),
);
if (!failures) {
// delete the video if the spec passed and no tests retried
fs.unlinkSync(results.video);
}
}
});
on('before:run', async (details) => {
// cypress-mochawesome-reporter
await beforeRunHook(details);
});
on('after:run', async () => {
// cypress-mochawesome-reporter
await afterRunHook();
// merge junit reports into a single report
const outputFile = path.join(__dirname, resultsDir, 'junit-report.xml');
const inputFiles = [`./${resultsDir}/junit/*.xml`];
await mergeFiles(outputFile, inputFiles);
});
return config;
},
});
},
});

View File

@ -1,11 +1,11 @@
class Home {
visit() {
cy.visit(`/`);
}
findButton() {
return cy.get('button:contains("Primary Action")');
}
visit() {
cy.visit(`/`);
}
findButton() {
return cy.get('button:contains("Create Workspace")');
}
}
export const home = new Home();

View File

@ -1,17 +1,17 @@
class PageNotFound {
visit() {
cy.visit(`/force-not-found-page`, {'failOnStatusCode': false});
this.wait();
}
private wait() {
this.findPage();
cy.testA11y();
}
findPage() {
return cy.get('h1:contains("404 Page not found")');
}
visit() {
cy.visit(`/force-not-found-page`, { failOnStatusCode: false });
this.wait();
}
private wait() {
this.findPage();
cy.testA11y();
}
findPage() {
return cy.get('h1:contains("404 Page not found")');
}
}
export const pageNotfound = new PageNotFound();

View File

@ -1,15 +1,13 @@
import { pageNotfound } from "~/__tests__/cypress/cypress/pages/pageNotFound";
import { home } from "~/__tests__/cypress/cypress/pages/home";
import { pageNotfound } from '~/__tests__/cypress/cypress/pages/pageNotFound';
import { home } from '~/__tests__/cypress/cypress/pages/home';
describe('Application', () => {
it('Page not found should render', () => {
pageNotfound.visit();
});
it('Page not found should render', () => {
pageNotfound.visit()
});
it('Home page should have primary button', () => {
home.visit()
home.findButton();
});
it('Home page should have primary button', () => {
home.visit();
home.findButton();
});
});

View File

@ -9,7 +9,7 @@ import {
MastheadToggle,
Page,
PageToggleButton,
Title
Title,
} from '@patternfly/react-core';
import NavSidebar from './NavSidebar';
import { BarsIcon } from '@patternfly/react-icons';
@ -35,7 +35,7 @@ const App: React.FC = () => {
return (
<Page
mainContainerId='primary-app-container'
mainContainerId="primary-app-container"
masthead={masthead}
isManagedSidebar
sidebar={<NavSidebar />}
@ -45,4 +45,4 @@ const App: React.FC = () => {
);
};
export default App;
export default App;

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { Route, Routes } from 'react-router-dom';
import { NotFound } from './pages/notFound/NotFound';
import { Settings } from './pages/Settings/Settings';
import { Dashboard } from './pages/Dashboard/Dashboard';
import { Workspaces } from './pages/Workspaces/Workspaces';
export const isNavDataGroup = (navItem: NavDataItem): navItem is NavDataGroup =>
'children' in navItem;
@ -52,14 +52,12 @@ const AppRoutes: React.FC = () => {
return (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/" element={<Workspaces />} />
<Route path="*" element={<NotFound />} />
{
// TODO: Remove the linter skip when we implement authentication
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
isAdmin && (
<Route path="/notebookSettings/*" element={<Settings />} />
)
isAdmin && <Route path="/notebookSettings/*" element={<Settings />} />
}
</Routes>
);

View File

@ -1,31 +0,0 @@
import * as React from 'react';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateFooter,
EmptyStateVariant,
PageSection,
Text,
TextContent
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
const Dashboard: React.FunctionComponent = () => (
<PageSection>
<EmptyState variant={EmptyStateVariant.full} titleText="Empty State (Dashboard Module)" icon={CubesIcon}>
<EmptyStateBody>
<TextContent>
<Text component="p">
This represents an the empty state pattern in Patternfly 6. Hopefully it&apos;s simple enough to use but
flexible enough to meet a variety of needs.
</Text>
</TextContent>
</EmptyStateBody><EmptyStateFooter>
<Button variant="primary">Primary Action</Button>
</EmptyStateFooter></EmptyState>
</PageSection>
);
export { Dashboard };

View File

@ -7,24 +7,23 @@ import {
EmptyStateFooter,
EmptyStateVariant,
PageSection,
Text,
TextContent
} from '@patternfly/react-core';
const Settings: React.FunctionComponent = () => (
<PageSection>
<EmptyState variant={EmptyStateVariant.full} titleText="Empty State (Stub Settings Module)" icon={CubesIcon}>
<EmptyState
variant={EmptyStateVariant.full}
titleText="Empty State (Stub Settings Module)"
icon={CubesIcon}
>
<EmptyStateBody>
<TextContent>
<Text component="p">
This represents an the empty state pattern in Patternfly 6. Hopefully it&apos;s simple enough to use but
flexible enough to meet a variety of needs.
</Text>
</TextContent>
</EmptyStateBody><EmptyStateFooter>
<Button variant="primary">Primary Action</Button>
</EmptyStateFooter></EmptyState>
This represents an the empty state pattern in Patternfly 6. Hopefully it&apos;s simple
enough to use but flexible enough to meet a variety of needs.
</EmptyStateBody>
<EmptyStateFooter>
<Button variant="primary">Primary Action</Button>
</EmptyStateFooter>
</EmptyState>
</PageSection>
);

View File

@ -0,0 +1,479 @@
import * as React from 'react';
import {
PageSection,
MenuToggle,
TimestampTooltipVariant,
Timestamp,
Label,
Title,
Popper,
MenuToggleElement,
Menu,
MenuContent,
MenuList,
MenuItem,
Toolbar,
ToolbarContent,
ToolbarToggleGroup,
ToolbarGroup,
ToolbarItem,
ToolbarFilter,
SearchInput,
Button,
PaginationVariant,
Pagination,
} from '@patternfly/react-core';
import {
Table,
Thead,
Tr,
Th,
Tbody,
Td,
ThProps,
CustomActionsToggleProps,
ActionsColumn,
IActions,
} from '@patternfly/react-table';
import { Workspace, WorkspaceState } from '../../../shared/types';
import { FilterIcon } from '@patternfly/react-icons';
export const Workspaces: React.FunctionComponent = () => {
/* Mocked workspaces, to be removed after fetching info from backend */
const workspaces: Workspace[] = [
{
name: 'My Jupyter Notebook',
namespace: 'namespace1',
paused: true,
deferUpdates: true,
kind: 'jupyter-lab',
podTemplate: {
volumes: {
home: '/home',
data: [
{
pvcName: 'data',
mountPath: '/data',
readOnly: false,
},
],
},
},
options: {
imageConfig: 'jupyterlab_scipy_180',
podConfig: 'Small CPU',
},
status: {
activity: {
lastActivity: 0,
lastUpdate: 0,
},
pauseTime: 0,
pendingRestart: false,
podTemplateOptions: {
imageConfig: {
desired: '',
redirectChain: [],
},
},
state: WorkspaceState.Paused,
stateMessage: 'It is paused.',
},
},
{
name: 'My Other Jupyter Notebook',
namespace: 'namespace1',
paused: false,
deferUpdates: false,
kind: 'jupyter-lab',
podTemplate: {
volumes: {
home: '/home',
data: [
{
pvcName: 'data',
mountPath: '/data',
readOnly: false,
},
],
},
},
options: {
imageConfig: 'jupyterlab_scipy_180',
podConfig: 'Large CPU',
},
status: {
activity: {
lastActivity: 0,
lastUpdate: 0,
},
pauseTime: 0,
pendingRestart: false,
podTemplateOptions: {
imageConfig: {
desired: '',
redirectChain: [],
},
},
state: WorkspaceState.Running,
stateMessage: 'It is running.',
},
},
];
// Table columns
const columnNames = {
name: 'Name',
kind: 'Kind',
image: 'Image',
podConfig: 'Pod Config',
state: 'State',
homeVol: 'Home Vol',
dataVol: 'Data Vol',
lastActivity: 'Last Activity',
};
// Filter
const [activeAttributeMenu, setActiveAttributeMenu] = React.useState<string>(columnNames.name);
const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false);
const attributeToggleRef = React.useRef<MenuToggleElement | null>(null);
const attributeMenuRef = React.useRef<HTMLDivElement | null>(null);
const attributeContainerRef = React.useRef<HTMLDivElement | null>(null);
const [searchValue, setSearchValue] = React.useState('');
const searchInput = (
<SearchInput
placeholder="Filter by name"
value={searchValue}
onChange={(_event, value) => onSearchChange(value)}
onClear={() => onSearchChange('')}
/>
);
const handleAttributeMenuKeys = (event: KeyboardEvent) => {
if (!isAttributeMenuOpen) {
return;
}
if (
attributeMenuRef.current?.contains(event.target as Node) ||
attributeToggleRef.current?.contains(event.target as Node)
) {
if (event.key === 'Escape' || event.key === 'Tab') {
setIsAttributeMenuOpen(!isAttributeMenuOpen);
attributeToggleRef.current?.focus();
}
}
};
const handleAttributeClickOutside = (event: MouseEvent) => {
if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) {
setIsAttributeMenuOpen(false);
}
};
React.useEffect(() => {
window.addEventListener('keydown', handleAttributeMenuKeys);
window.addEventListener('click', handleAttributeClickOutside);
return () => {
window.removeEventListener('keydown', handleAttributeMenuKeys);
window.removeEventListener('click', handleAttributeClickOutside);
};
}, [isAttributeMenuOpen, attributeMenuRef]);
const onAttributeToggleClick = (ev: React.MouseEvent) => {
ev.stopPropagation(); // Stop handleClickOutside from handling
setTimeout(() => {
if (attributeMenuRef.current) {
const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
}
}, 0);
setIsAttributeMenuOpen(!isAttributeMenuOpen);
};
const attributeToggle = (
<MenuToggle
ref={attributeToggleRef}
onClick={onAttributeToggleClick}
isExpanded={isAttributeMenuOpen}
icon={<FilterIcon />}
>
{activeAttributeMenu}
</MenuToggle>
);
const attributeMenu = (
<Menu
ref={attributeMenuRef}
onSelect={(_ev, itemId) => {
setActiveAttributeMenu(itemId?.toString());
setIsAttributeMenuOpen(!isAttributeMenuOpen);
}}
>
<MenuContent>
<MenuList>
<MenuItem itemId="Name">Name</MenuItem>
<MenuItem itemId="Kind">Kind</MenuItem>
<MenuItem itemId="Image">Image</MenuItem>
<MenuItem itemId="Pod Config">Pod Config</MenuItem>
<MenuItem itemId="State">State</MenuItem>
<MenuItem itemId="Home Vol">Home Vol</MenuItem>
<MenuItem itemId="Data Vol">Data Vol</MenuItem>
<MenuItem itemId="Last Activity">Last Activity</MenuItem>
</MenuList>
</MenuContent>
</Menu>
);
const attributeDropdown = (
<div ref={attributeContainerRef}>
<Popper
trigger={attributeToggle}
triggerRef={attributeToggleRef}
popper={attributeMenu}
popperRef={attributeMenuRef}
appendTo={attributeContainerRef.current || undefined}
isVisible={isAttributeMenuOpen}
/>
</div>
);
const toolbar = (
<Toolbar
id="attribute-search-filter-toolbar"
clearAllFilters={() => {
setSearchValue('');
}}
>
<ToolbarContent>
<ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="xl">
<ToolbarGroup variant="filter-group">
<ToolbarItem>{attributeDropdown}</ToolbarItem>
<ToolbarFilter
labels={searchValue !== '' ? [searchValue] : ([] as string[])}
deleteLabel={() => setSearchValue('')}
deleteLabelGroup={() => setSearchValue('')}
categoryName={activeAttributeMenu}
>
{searchInput}
</ToolbarFilter>
<Button variant="primary" ouiaId="Primary">
Create Workspace
</Button>
</ToolbarGroup>
</ToolbarToggleGroup>
</ToolbarContent>
</Toolbar>
);
const onSearchChange = (value: string) => {
setSearchValue(value);
};
const onFilter = (workspace: Workspace) => {
// Search name with search value
let searchValueInput: RegExp;
try {
searchValueInput = new RegExp(searchValue, 'i');
} catch (err) {
searchValueInput = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
}
return (
searchValue === '' ||
(activeAttributeMenu === 'Name' && workspace.name.search(searchValueInput) >= 0) ||
(activeAttributeMenu === 'Kind' && workspace.kind.search(searchValueInput) >= 0) ||
(activeAttributeMenu === 'Image' &&
workspace.options.imageConfig.search(searchValueInput) >= 0) ||
(activeAttributeMenu === 'Pod Config' &&
workspace.options.podConfig.search(searchValueInput) >= 0) ||
(activeAttributeMenu === 'State' &&
WorkspaceState[workspace.status.state].search(searchValueInput) >= 0) ||
(activeAttributeMenu === 'Home Vol' &&
workspace.podTemplate.volumes.home.search(searchValueInput) >= 0) ||
(activeAttributeMenu === 'Data Vol' &&
workspace.podTemplate.volumes.data.some(
(dataVol) =>
dataVol.pvcName.search(searchValueInput) >= 0 ||
dataVol.mountPath.search(searchValueInput) >= 0,
))
);
};
const filteredWorkspaces = workspaces.filter(onFilter);
// Column sorting
const [activeSortIndex, setActiveSortIndex] = React.useState<number | null>(null);
const [activeSortDirection, setActiveSortDirection] = React.useState<'asc' | 'desc' | null>(null);
const getSortableRowValues = (workspace: Workspace): (string | number)[] => {
const { name, kind, image, podConfig, state, homeVol, dataVol, lastActivity } = {
name: workspace.name,
kind: workspace.kind,
image: workspace.options.imageConfig,
podConfig: workspace.options.podConfig,
state: WorkspaceState[workspace.status.state],
homeVol: workspace.podTemplate.volumes.home,
dataVol: workspace.podTemplate.volumes.data[0].pvcName || '',
lastActivity: workspace.status.activity.lastActivity,
};
return [name, kind, image, podConfig, state, homeVol, dataVol, lastActivity];
};
let sortedWorkspaces = filteredWorkspaces;
if (activeSortIndex !== null) {
sortedWorkspaces = workspaces.sort((a, b) => {
const aValue = getSortableRowValues(a)[activeSortIndex];
const bValue = getSortableRowValues(b)[activeSortIndex];
if (typeof aValue === 'number') {
// Numeric sort
if (activeSortDirection === 'asc') {
return (aValue as number) - (bValue as number);
}
return (bValue as number) - (aValue as number);
} else {
// String sort
if (activeSortDirection === 'asc') {
return (aValue as string).localeCompare(bValue as string);
}
return (bValue as string).localeCompare(aValue as string);
}
});
}
const getSortParams = (columnIndex: number): ThProps['sort'] => ({
sortBy: {
index: activeSortIndex || 0,
direction: activeSortDirection || 'asc',
defaultDirection: 'asc', // starting sort direction when first sorting a column. Defaults to 'asc'
},
onSort: (_event, index, direction) => {
setActiveSortIndex(index);
setActiveSortDirection(direction);
},
columnIndex,
});
// Actions
const defaultActions = (workspace: Workspace): IActions =>
[
{
title: 'Edit',
onClick: () => console.log(`Clicked on edit, on row ${workspace.name}`),
},
{
title: 'Delete',
onClick: () => console.log(`Clicked on delete, on row ${workspace.name}`),
},
{
isSeparator: true,
},
{
title: 'Start/restart',
onClick: () => console.log(`Clicked on start/restart, on row ${workspace.name}`),
},
{
title: 'Stop',
onClick: () => console.log(`Clicked on stop, on row ${workspace.name}`),
},
] as IActions;
// States
const stateColors: (
| 'blue'
| 'teal'
| 'green'
| 'orange'
| 'purple'
| 'red'
| 'orangered'
| 'grey'
| 'yellow'
)[] = ['green', 'orange', 'yellow', 'blue', 'red', 'purple'];
// Pagination
const [page, setPage] = React.useState(1);
const [perPage, setPerPage] = React.useState(10);
const onSetPage = (
_event: React.MouseEvent | React.KeyboardEvent | MouseEvent,
newPage: number,
) => {
setPage(newPage);
};
const onPerPageSelect = (
_event: React.MouseEvent | React.KeyboardEvent | MouseEvent,
newPerPage: number,
newPage: number,
) => {
setPerPage(newPerPage);
setPage(newPage);
};
return (
<PageSection>
<Title headingLevel="h1">Kubeflow Workspaces</Title>
<p>View your existing workspaces or create new workspaces.</p>
{toolbar}
<Table aria-label="Sortable table" ouiaId="SortableTable">
<Thead>
<Tr>
<Th sort={getSortParams(0)}>{columnNames.name}</Th>
<Th sort={getSortParams(1)}>{columnNames.kind}</Th>
<Th sort={getSortParams(2)}>{columnNames.image}</Th>
<Th sort={getSortParams(3)}>{columnNames.podConfig}</Th>
<Th sort={getSortParams(4)}>{columnNames.state}</Th>
<Th sort={getSortParams(5)}>{columnNames.homeVol}</Th>
<Th sort={getSortParams(6)}>{columnNames.dataVol}</Th>
<Th sort={getSortParams(7)}>{columnNames.lastActivity}</Th>
<Th screenReaderText="Primary action"></Th>
</Tr>
</Thead>
<Tbody>
{sortedWorkspaces.map((workspace, rowIndex) => (
<Tr key={rowIndex}>
<Td dataLabel={columnNames.name}>{workspace.name}</Td>
<Td dataLabel={columnNames.kind}>{workspace.kind}</Td>
<Td dataLabel={columnNames.image}>{workspace.options.imageConfig}</Td>
<Td dataLabel={columnNames.podConfig}>{workspace.options.podConfig}</Td>
<Td dataLabel={columnNames.state}>
<Label color={stateColors[workspace.status.state]}>
{WorkspaceState[workspace.status.state]}
</Label>
</Td>
<Td dataLabel={columnNames.homeVol}>{workspace.podTemplate.volumes.home}</Td>
<Td dataLabel={columnNames.dataVol}>
{workspace.podTemplate.volumes.data[0].pvcName || ''}
</Td>
<Td dataLabel={columnNames.lastActivity}>
<Timestamp
date={new Date(workspace.status.activity.lastActivity)}
tooltip={{ variant: TimestampTooltipVariant.default }}
>
1 hour ago
</Timestamp>
</Td>
<Td isActionCell>
<ActionsColumn items={defaultActions(workspace)} />
</Td>
</Tr>
))}
</Tbody>
</Table>
<Pagination
itemCount={333}
widgetId="bottom-example"
perPage={perPage}
page={page}
variant={PaginationVariant.bottom}
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
/>
</PageSection>
);
};

View File

@ -1,6 +1,12 @@
import * as React from 'react';
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
import { Button, EmptyState, EmptyStateBody, EmptyStateFooter, PageSection } from '@patternfly/react-core';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateFooter,
PageSection,
} from '@patternfly/react-core';
import { useNavigate } from 'react-router-dom';
const NotFound: React.FunctionComponent = () => {
@ -11,9 +17,7 @@ const NotFound: React.FunctionComponent = () => {
navigate('/');
}
return (
<Button onClick={handleClick}>Take me home</Button>
);
return <Button onClick={handleClick}>Take me home</Button>;
}
return (
@ -21,9 +25,11 @@ const NotFound: React.FunctionComponent = () => {
<EmptyState titleText="404 Page not found" variant="full" icon={ExclamationTriangleIcon}>
<EmptyStateBody>
We didn&apos;t find a page that matches the address you navigated to.
</EmptyStateBody><EmptyStateFooter>
<GoHomeBtn />
</EmptyStateFooter></EmptyState>
</EmptyStateBody>
<EmptyStateFooter>
<GoHomeBtn />
</EmptyStateFooter>
</EmptyState>
</PageSection>
);
};

View File

@ -10,5 +10,5 @@ root.render(
<Router>
<App />
</Router>
</React.StrictMode>
</React.StrictMode>,
);

View File

@ -0,0 +1,70 @@
export interface WorkspaceIcon {
url: string;
}
export interface WorkspaceLogo {
url: string;
}
export interface WorkspaceKind {
name: string;
displayName: string;
description: string;
hidden: boolean;
deprecated: boolean;
deprecationWarning: string;
icon: WorkspaceIcon;
logo: WorkspaceLogo;
}
export enum WorkspaceState {
Running,
Terminating,
Paused,
Pending,
Error,
Unknown,
}
export interface WorkspaceStatus {
activity: {
lastActivity: number;
lastUpdate: number;
};
pauseTime: number;
pendingRestart: boolean;
podTemplateOptions: {
imageConfig: {
desired: string;
redirectChain: {
source: string;
target: string;
}[];
};
};
state: WorkspaceState;
stateMessage: string;
}
export interface Workspace {
name: string;
namespace: string;
paused: boolean;
deferUpdates: boolean;
kind: string;
podTemplate: {
volumes: {
home: string;
data: {
pvcName: string;
mountPath: string;
readOnly: boolean;
}[];
};
};
options: {
imageConfig: string;
podConfig: string;
};
status: WorkspaceStatus;
}

View File

@ -1,12 +1,12 @@
declare module "*.png";
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.gif";
declare module "*.svg";
declare module "*.css";
declare module "*.wav";
declare module "*.mp3";
declare module "*.m4a";
declare module "*.rdf";
declare module "*.ttl";
declare module "*.pdf";
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.gif';
declare module '*.svg';
declare module '*.css';
declare module '*.wav';
declare module '*.mp3';
declare module '*.m4a';
declare module '*.rdf';
declare module '*.ttl';
declare module '*.pdf';

View File

@ -5,10 +5,7 @@
"outDir": "dist",
"module": "esnext",
"target": "ES6",
"lib": [
"es6",
"dom"
],
"lib": ["es6", "dom"],
"sourceMap": true,
"jsx": "react",
"moduleResolution": "node",
@ -27,13 +24,6 @@
"importHelpers": true,
"skipLibCheck": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.jsx",
"**/*.js",
],
"exclude": [
"node_modules"
]
"include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js"],
"exclude": ["node_modules"]
}