diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63fb8b74..0963fa8a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,3 +69,18 @@ repos: - --update files: jupyterhub/scopes.py pass_filenames: false + + # run eslint in the jsx directory + # need to pass through 'jsx:install-run' hook in + # top-level package.json to ensure dependencies are installed + # eslint pre-commit hook doesn't really work with eslint 9, + # so use `npm run lint:fix` + - id: jsx-eslint + name: eslint in jsx/ + entry: npm run jsx:install-run lint:fix + pass_filenames: false + language: node + files: "jsx/.*" + # can't run on pre-commit; hangs, for some reason + stages: + - manual diff --git a/jsx/.eslintrc.json b/jsx/.eslintrc.json deleted file mode 100644 index 597e92cb..00000000 --- a/jsx/.eslintrc.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "extends": ["plugin:react/recommended"], - "parserOptions": { - "ecmaVersion": 2018, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - }, - "settings": { - "react": { - "version": "detect" - } - }, - "plugins": ["eslint-plugin-react", "prettier", "unused-imports"], - "env": { - "es6": true, - "browser": true - }, - "rules": { - "semi": "off", - "quotes": "off", - "prettier/prettier": "warn", - "no-unused-vars": "off", - "unused-imports/no-unused-imports": "error", - "unused-imports/no-unused-vars": [ - "warn", - { - "vars": "all", - "varsIgnorePattern": "^regeneratorRuntime|^_", - "args": "after-used", - "argsIgnorePattern": "^_" - } - ] - }, - "overrides": [ - { - "files": ["**/*.test.js", "**/*.test.jsx"], - "env": { - "jest": true - } - } - ] -} diff --git a/jsx/eslint.config.mjs b/jsx/eslint.config.mjs new file mode 100644 index 00000000..d0d7aeb4 --- /dev/null +++ b/jsx/eslint.config.mjs @@ -0,0 +1,77 @@ +import { defineConfig } from "eslint/config"; +import react from "eslint-plugin-react"; +import prettier from "eslint-plugin-prettier"; +import unusedImports from "eslint-plugin-unused-imports"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default defineConfig([ + { + extends: compat.extends("plugin:react/recommended"), + + plugins: { + react, + prettier, + "unused-imports": unusedImports, + }, + + languageOptions: { + globals: { + ...globals.browser, + }, + + ecmaVersion: 2018, + sourceType: "module", + + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + + settings: { + react: { + version: "detect", + }, + }, + + rules: { + semi: "off", + quotes: "off", + "prettier/prettier": "warn", + "no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^regeneratorRuntime|^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + }, + }, + { + files: ["**/*.test.js", "**/*.test.jsx"], + + languageOptions: { + globals: { + ...globals.jest, + }, + }, + }, +]); diff --git a/jsx/package-lock.json b/jsx/package-lock.json index 7c29c386..3285792e 100644 --- a/jsx/package-lock.json +++ b/jsx/package-lock.json @@ -26,6 +26,8 @@ "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", "@babel/preset-react": "^7.26.3", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.23.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", @@ -38,6 +40,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-unused-imports": "^4.1.4", "file-loader": "^6.2.0", + "globals": "^16.0.0", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -851,6 +854,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-classes/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", @@ -1742,6 +1755,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", @@ -6040,11 +6063,16 @@ "dev": true }, "node_modules/globals": { - "version": "11.12.0", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", + "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globalthis": { diff --git a/jsx/package.json b/jsx/package.json index 20f98684..50df077f 100644 --- a/jsx/package.json +++ b/jsx/package.json @@ -52,6 +52,8 @@ "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", "@babel/preset-react": "^7.26.3", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.23.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", @@ -64,6 +66,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-unused-imports": "^4.1.4", "file-loader": "^6.2.0", + "globals": "^16.0.0", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/jsx/src/components/AddUser/AddUser.test.js b/jsx/src/components/AddUser/AddUser.test.js index 217508ac..475a5f7d 100644 --- a/jsx/src/components/AddUser/AddUser.test.js +++ b/jsx/src/components/AddUser/AddUser.test.js @@ -1,7 +1,6 @@ import React, { act } from "react"; import "@testing-library/jest-dom"; import { render, screen, fireEvent } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { Provider, useDispatch, useSelector } from "react-redux"; import { createStore } from "redux"; import { HashRouter } from "react-router"; diff --git a/jsx/src/components/GroupEdit/GroupEdit.jsx b/jsx/src/components/GroupEdit/GroupEdit.jsx index a41759f6..46bd1f58 100644 --- a/jsx/src/components/GroupEdit/GroupEdit.jsx +++ b/jsx/src/components/GroupEdit/GroupEdit.jsx @@ -179,6 +179,7 @@ GroupEdit.propTypes = { removeFromGroup: PropTypes.func, deleteGroup: PropTypes.func, updateGroups: PropTypes.func, + updateProp: PropTypes.func, validateUser: PropTypes.func, }; diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.jsx b/jsx/src/components/ServerDashboard/ServerDashboard.jsx index a603e57e..2ef7e4dc 100644 --- a/jsx/src/components/ServerDashboard/ServerDashboard.jsx +++ b/jsx/src/components/ServerDashboard/ServerDashboard.jsx @@ -3,6 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { debounce } from "lodash"; import PropTypes from "prop-types"; import ErrorAlert from "../../util/error"; +import { User, Server } from "../../util/jhapiUtil"; import { Button, @@ -209,6 +210,15 @@ const ServerDashboard = (props) => { ); }; + ServerButton.propTypes = { + server: Server, + user: User, + action: PropTypes.string, + name: PropTypes.string, + variant: PropTypes.string, + extraClass: PropTypes.string, + }; + const StopServerButton = ({ server, user }) => { if (!server.ready) { return null; @@ -222,6 +232,12 @@ const ServerDashboard = (props) => { extraClass: "stop-button", }); }; + + StopServerButton.propTypes = { + server: Server, + user: User, + }; + const DeleteServerButton = ({ server, user }) => { if (!server.name) { // It's not possible to delete unnamed servers @@ -240,6 +256,11 @@ const ServerDashboard = (props) => { }); }; + DeleteServerButton.propTypes = { + server: Server, + user: User, + }; + const StartServerButton = ({ server, user }) => { if (server.ready) { return null; @@ -254,6 +275,11 @@ const ServerDashboard = (props) => { }); }; + StartServerButton.propTypes = { + server: Server, + user: User, + }; + const SpawnPageButton = ({ server, user }) => { if (server.ready) { return null; @@ -271,6 +297,11 @@ const ServerDashboard = (props) => { ); }; + SpawnPageButton.propTypes = { + server: Server, + user: User, + }; + const AccessServerButton = ({ server }) => { if (!server.ready) { return null; @@ -283,6 +314,9 @@ const ServerDashboard = (props) => { ); }; + AccessServerButton.propTypes = { + server: Server, + }; const EditUserButton = ({ user }) => { return ( @@ -303,10 +337,17 @@ const ServerDashboard = (props) => { ); }; - const ServerRowTable = ({ data }) => { + EditUserButton.propTypes = { + user: User, + }; + + const ServerRowTable = ({ data, exclude }) => { const sortedData = Object.keys(data) .sort() .reduce(function (result, key) { + if (exclude && exclude.includes(key)) { + return result; + } let value = data[key]; switch (key) { case "last_activity": @@ -346,88 +387,101 @@ const ServerDashboard = (props) => { ); }; - const serverRow = (user, server) => { - const { servers, ...userNoServers } = user; + ServerRowTable.propTypes = { + data: Server, + exclude: PropTypes.arrayOf(PropTypes.string), + }; + + const ServerRow = ({ user, server }) => { const serverNameDash = server.name ? `-${server.name}` : ""; const userServerName = user.name + serverNameDash; const open = collapseStates[userServerName] || false; - return [ - - - - {" "} - - - {user.name} - - - {user.admin ? "admin" : ""} - - -

{server.name}

- - - {server.last_activity ? timeSince(server.last_activity) : "Never"} - - - - - - - - - - , - - + - - - - User - - - - Server - - - - - - , - ]; + + + {" "} + + + {user.name} + + + {user.admin ? "admin" : ""} + + +

{server.name}

+ + + {server.last_activity ? timeSince(server.last_activity) : "Never"} + + + + + + + + + + + + + + + + User + + + + Server + + + + + + + + ); }; - let servers = user_data.flatMap((user) => { - let userServers = Object.values({ + ServerRow.propTypes = { + user: User, + server: Server, + }; + + const serverRows = user_data.flatMap((user) => { + const userServers = Object.values({ + // eslint-disable-next-line react/prop-types "": user.server || {}, + // eslint-disable-next-line react/prop-types ...(user.servers || {}), }); - return userServers.map((server) => [user, server]); + return userServers.map((server) => ServerRow({ user, server })); }); return ( @@ -583,7 +637,7 @@ const ServerDashboard = (props) => { - {servers.flatMap(([user, server]) => serverRow(user, server))} + {serverRows} { }; ServerDashboard.propTypes = { - user_data: PropTypes.array, + user_data: PropTypes.arrayOf(User), updateUsers: PropTypes.func, shutdownHub: PropTypes.func, startServer: PropTypes.func, diff --git a/jsx/src/util/jhapiUtil.js b/jsx/src/util/jhapiUtil.js index 625909ee..57b07a68 100644 --- a/jsx/src/util/jhapiUtil.js +++ b/jsx/src/util/jhapiUtil.js @@ -1,3 +1,5 @@ +import PropTypes from "prop-types"; + const jhdata = window.jhdata || {}; const base_url = jhdata.base_url || "/"; const xsrfToken = jhdata.xsrf_token; @@ -17,3 +19,21 @@ export const jhapiRequest = (endpoint, method, data) => { body: data ? JSON.stringify(data) : null, }); }; + +// need to declare the subset of fields we use, at least +export const Server = PropTypes.shape({ + name: PropTypes.string, + url: PropTypes.string, + active: PropTypes.boolean, + pending: PropTypes.string, + last_activity: PropTypes.string, +}); + +export const User = PropTypes.shape({ + admin: PropTypes.boolean, + name: PropTypes.string, + last_activity: PropTypes.string, + url: PropTypes.string, + server: Server, + servers: PropTypes.objectOf(Server), +}); diff --git a/package.json b/package.json index 8b651bf3..3a0a63a1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "scripts": { "postinstall": "python3 ./bower-lite", "css": "sass --style compressed -I share/jupyterhub/static/components --source-map share/jupyterhub/static/scss/style.scss:share/jupyterhub/static/css/style.min.css", - "build:watch": "npm run css -- --watch" + "build:watch": "npm run css -- --watch", + "jsx:install-run": "npm install --prefix jsx && npm run --prefix jsx", + "jsx:run": "npm run --prefix jsx" }, "devDependencies": { "sass": "^1.74.1"