jsx: update and address eslint

add script to top-level package.json to run eslint in subdir
This commit is contained in:
Min RK
2025-03-26 12:02:04 +01:00
parent 742de1311e
commit cd79f17d90
10 changed files with 276 additions and 124 deletions

View File

@@ -69,3 +69,15 @@ 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/.*"

View File

@@ -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
}
}
]
}

77
jsx/eslint.config.mjs Normal file
View File

@@ -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,
},
},
},
]);

32
jsx/package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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";

View File

@@ -179,6 +179,7 @@ GroupEdit.propTypes = {
removeFromGroup: PropTypes.func,
deleteGroup: PropTypes.func,
updateGroups: PropTypes.func,
updateProp: PropTypes.func,
validateUser: PropTypes.func,
};

View File

@@ -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) => {
</a>
);
};
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 [
<tr
key={`${userServerName}-row`}
data-testid={`user-row-${userServerName}`}
className="user-row"
>
<td data-testid="user-row-name">
<span>
<Button
onClick={() =>
setCollapseStates({
...collapseStates,
[userServerName]: !open,
})
}
aria-controls={`${userServerName}-collapse`}
aria-expanded={open}
data-testid={`${userServerName}-collapse-button`}
variant={open ? "secondary" : "primary"}
size="sm"
>
<span className="fa fa-caret-down"></span>
</Button>{" "}
</span>
<span data-testid={`user-name-div-${userServerName}`}>
{user.name}
</span>
</td>
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
<td data-testid="user-row-server">
<p className="text-secondary">{server.name}</p>
</td>
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity" className="actions">
<StartServerButton server={server} user={user} />
<StopServerButton server={server} user={user} />
<DeleteServerButton server={server} user={user} />
<AccessServerButton server={server} />
<SpawnPageButton server={server} user={user} />
<EditUserButton user={user} />
</td>
</tr>,
<tr key={`${userServerName}-detail`}>
<td
colSpan={6}
style={{ padding: 0 }}
data-testid={`${userServerName}-td`}
return (
<Fragment key={`${userServerName}-row`}>
<tr
key={`${userServerName}-row`}
data-testid={`user-row-${userServerName}`}
className="user-row"
>
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
<CardGroup
id={`${userServerName}-card-group`}
style={{ width: "100%", margin: "0 auto", float: "none" }}
>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>User</Card.Title>
<ServerRowTable data={userNoServers} />
</Card>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>Server</Card.Title>
<ServerRowTable data={server} />
</Card>
</CardGroup>
</Collapse>
</td>
</tr>,
];
<td data-testid="user-row-name">
<span>
<Button
onClick={() =>
setCollapseStates({
...collapseStates,
[userServerName]: !open,
})
}
aria-controls={`${userServerName}-collapse`}
aria-expanded={open}
data-testid={`${userServerName}-collapse-button`}
variant={open ? "secondary" : "primary"}
size="sm"
>
<span className="fa fa-caret-down"></span>
</Button>{" "}
</span>
<span data-testid={`user-name-div-${userServerName}`}>
{user.name}
</span>
</td>
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
<td data-testid="user-row-server">
<p className="text-secondary">{server.name}</p>
</td>
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity" className="actions">
<StartServerButton server={server} user={user} />
<StopServerButton server={server} user={user} />
<DeleteServerButton server={server} user={user} />
<AccessServerButton server={server} />
<SpawnPageButton server={server} user={user} />
<EditUserButton user={user} />
</td>
</tr>
<tr key={`${userServerName}-detail`}>
<td
colSpan={6}
style={{ padding: 0 }}
data-testid={`${userServerName}-td`}
>
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
<CardGroup
id={`${userServerName}-card-group`}
style={{ width: "100%", margin: "0 auto", float: "none" }}
>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>User</Card.Title>
<ServerRowTable data={user} exclude={["server", "servers"]} />
</Card>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>Server</Card.Title>
<ServerRowTable data={server} />
</Card>
</CardGroup>
</Collapse>
</td>
</tr>
</Fragment>
);
};
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) => {
</Button>
</td>
</tr>
{servers.flatMap(([user, server]) => serverRow(user, server))}
{serverRows}
</tbody>
</table>
<PaginationFooter
@@ -607,7 +661,7 @@ const ServerDashboard = (props) => {
};
ServerDashboard.propTypes = {
user_data: PropTypes.array,
user_data: PropTypes.arrayOf(User),
updateUsers: PropTypes.func,
shutdownHub: PropTypes.func,
startServer: PropTypes.func,

View File

@@ -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),
});

View File

@@ -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"