Change eslint configuration and fix resulting issues (#4423)

* Remove app/service/query-string (unused) and its dependency.

* Fix usage of mixed operators.

* eslint --fix fixes for missing dependencies for react hooks

* Fix: useCallback dependency passed to $http's .catch.

* Satisfy react/no-direct-mutation-state.

* Fix no-mixed-operators violations.

* Move the decision of whether to render Custom chart one level up to make sure hooks are called in the same order.

* Fix: name was undefined. It wasn't detected before because there is such global.

* Simplify eslint config and switch to creat-react-app's eslint base.

* Add prettier config.

* Make sure eslint doesn't conflict with prettier

* A few updates post eslint (#4425)

* Prettier command in package.json
This commit is contained in:
Arik Fraimovich
2019-12-11 12:00:46 +02:00
committed by GitHub
parent 0385b6fb64
commit 1b9b3032ca
31 changed files with 849 additions and 623 deletions

View File

@@ -1,3 +1,4 @@
build/*.js
dist
config/*.js
client/dist

View File

@@ -1,11 +1,10 @@
module.exports = {
root: true,
extends: ["airbnb", "plugin:compat/recommended"],
extends: ["react-app", "plugin:compat/recommended", "prettier"],
plugins: ["jest", "compat", "no-only-tests"],
settings: {
"import/resolver": "webpack"
},
parser: "babel-eslint",
env: {
browser: true,
node: true
@@ -13,54 +12,6 @@ module.exports = {
rules: {
// allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"no-param-reassign": 0,
"no-mixed-operators": 0,
"no-underscore-dangle": 0,
"no-use-before-define": ["error", "nofunc"],
"prefer-destructuring": "off",
"prefer-template": "off",
"no-restricted-properties": "off",
"no-restricted-globals": "off",
"no-multi-assign": "off",
"no-lonely-if": "off",
"consistent-return": "off",
"no-control-regex": "off",
"no-multiple-empty-lines": "warn",
"no-only-tests/no-only-tests": "error",
"operator-linebreak": "off",
"react/destructuring-assignment": "off",
"react/jsx-filename-extension": "off",
"react/jsx-one-expression-per-line": "off",
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"react/jsx-wrap-multilines": "warn",
"react/no-access-state-in-setstate": "warn",
"react/prefer-stateless-function": "warn",
"react/forbid-prop-types": "warn",
"react/prop-types": "warn",
"jsx-a11y/anchor-is-valid": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/label-has-associated-control": [
"warn",
{
controlComponents: true
}
],
"jsx-a11y/label-has-for": "off",
"jsx-a11y/no-static-element-interactions": "off",
"max-len": [
"error",
120,
2,
{
ignoreUrls: true,
ignoreComments: false,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true
}
],
"no-else-return": ["error", { allowElseIf: true }],
"object-curly-newline": ["error", { consistent: true }]
}
};

View File

@@ -86,13 +86,13 @@ function EditParameterSettingsDialog(props) {
// fetch query by id
useEffect(() => {
const { queryId } = props.parameter;
const queryId = props.parameter.queryId;
if (queryId) {
Query.get({ id: queryId }, (query) => {
setInitialQuery(query);
});
}
}, []);
}, [props.parameter.queryId]);
function isFulfilled() {
// name

View File

@@ -2,22 +2,15 @@ import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { react2angular } from 'react2angular';
import { debounce, find } from 'lodash';
import { find } from 'lodash';
import Input from 'antd/lib/input';
import Select from 'antd/lib/select';
import { Query } from '@/services/query';
import notification from '@/services/notification';
import { QueryTagsControl } from '@/components/tags-control/TagsControl';
import useSearchResults from '@/lib/hooks/useSearchResults';
const SEARCH_DEBOUNCE_DURATION = 200;
const { Option } = Select;
class StaleSearchError extends Error {
constructor() {
super('stale search');
}
}
function search(term) {
// get recent
if (!term) {
@@ -34,17 +27,16 @@ function search(term) {
}
export function QuerySelector(props) {
const [searchTerm, setSearchTerm] = useState();
const [searching, setSearching] = useState();
const [searchResults, setSearchResults] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedQuery, setSelectedQuery] = useState();
const [doSearch, searchResults, searching] = useSearchResults(search, { initialResults: [] });
let isStaleSearch = false;
const debouncedSearch = debounce(_search, SEARCH_DEBOUNCE_DURATION);
const placeholder = 'Search a query by name';
const clearIcon = <i className="fa fa-times hide-in-percy" onClick={() => selectQuery(null)} />;
const spinIcon = <i className={cx('fa fa-spinner fa-pulse hide-in-percy', { hidden: !searching })} />;
useEffect(() => { doSearch(searchTerm); }, [doSearch, searchTerm]);
// set selected from prop
useEffect(() => {
if (props.selectedQuery) {
@@ -52,43 +44,6 @@ export function QuerySelector(props) {
}
}, [props.selectedQuery]);
// on search term changed, debounced
useEffect(() => {
// clear results, no search
if (searchTerm === null) {
setSearchResults(null);
return () => {};
}
// search
debouncedSearch(searchTerm);
return () => {
debouncedSearch.cancel();
isStaleSearch = true;
};
}, [searchTerm]);
function _search(term) {
setSearching(true);
search(term)
.then(rejectStale)
.then((results) => {
setSearchResults(results);
setSearching(false);
})
.catch((err) => {
if (!(err instanceof StaleSearchError)) {
setSearching(false);
}
});
}
function rejectStale(results) {
return isStaleSearch
? Promise.reject(new StaleSearchError())
: Promise.resolve(results);
}
function selectQuery(queryId) {
let query = null;
if (queryId) {

View File

@@ -26,7 +26,7 @@ export function TimeAgo({ date, placeholder, autoUpdate }) {
const timer = setInterval(forceUpdate, 30 * 1000);
return () => clearInterval(timer);
}
}, [autoUpdate]);
}, [autoUpdate, forceUpdate]);
return (
<Tooltip title={title}>

View File

@@ -12,7 +12,7 @@ export function Timer({ from }) {
useEffect(() => {
const timer = setInterval(forceUpdate, 1000);
return () => clearInterval(timer);
}, []);
}, [forceUpdate]);
const diff = moment.now() - startTime;
const format = diff > 1000 * 60 * 60 ? 'HH:mm:ss' : 'mm:ss'; // no HH under an hour

View File

@@ -27,7 +27,9 @@ export default function FavoritesDropdown({ fetch, urlTemplate }) {
}, [fetch]);
// fetch items on init
useEffect(() => fetchItems(false), []);
useEffect(() => {
fetchItems(false);
}, [fetchItems]);
// fetch items on click
const onVisibleChange = visible => visible && fetchItems();

View File

@@ -58,22 +58,22 @@ class DynamicForm extends React.Component {
const hasFilledExtraField = some(props.fields, (field) => {
const { extra, initialValue } = field;
return extra && (!isEmpty(initialValue) || isNumber(initialValue) || isBoolean(initialValue) && initialValue);
return extra && (!isEmpty(initialValue) || isNumber(initialValue) || (isBoolean(initialValue) && initialValue));
});
const inProgressActions = {};
props.actions.forEach(action => inProgressActions[action.name] = false);
this.state = {
isSubmitting: false,
inProgressActions: [],
showExtraFields: hasFilledExtraField,
inProgressActions
};
this.actionCallbacks = this.props.actions.reduce((acc, cur) => ({
...acc,
[cur.name]: cur.callback,
}), null);
props.actions.forEach((action) => {
this.state.inProgressActions[action.name] = false;
});
}
setActionInProgress = (actionName, inProgress) => {

View File

@@ -34,7 +34,7 @@ function useGrantees(url) {
const addPermission = useCallback((userId, accessType = 'modify') => $http.post(
url, { access_type: accessType, user_id: userId },
).catch(() => notification.error('Could not grant permission to the user'), [url]));
).catch(() => notification.error('Could not grant permission to the user')), [url]);
const removePermission = useCallback((userId, accessType = 'modify') => $http.delete(
url, { data: { access_type: accessType, user_id: userId } },
@@ -77,7 +77,7 @@ function UserSelect({ onSelect, shouldShowUser }) {
useEffect(() => {
setLoadingUsers(true);
debouncedSearchUsers(searchTerm);
}, [searchTerm]);
}, [debouncedSearchUsers, searchTerm]);
return (
<Select
@@ -117,16 +117,16 @@ function PermissionsEditorDialog({ dialog, author, context, aclUrl }) {
.then(setGrantees)
.catch(() => notification.error('Failed to load grantees list'))
.finally(() => setLoadingGrantees(false));
}, []);
}, [loadGrantees]);
const userHasPermission = useCallback(
user => (user.id === author.id || !!get(find(grantees, { id: user.id }), 'accessType')),
[grantees],
[author.id, grantees],
);
useEffect(() => {
loadUsersWithPermissions();
}, [aclUrl]);
}, [aclUrl, loadUsersWithPermissions]);
return (
<Modal

View File

@@ -71,7 +71,7 @@ class ChangePasswordDialog extends React.Component {
notification.success('Saved.');
this.props.dialog.close({ success: true });
}, (error = {}) => {
notification.error(error.data && error.data.message || 'Failed saving.');
notification.error((error.data && error.data.message) || 'Failed saving.');
this.setState({ updatingPassword: false });
});
} else {

View File

@@ -112,7 +112,7 @@ export default class UserEdit extends React.Component {
successCallback('Saved.');
this.setState({ user: User.convertUserInfo(user) });
}, (error = {}) => {
errorCallback(error.data && error.data.message || 'Failed saving.');
errorCallback((error.data && error.data.message) || 'Failed saving.');
});
};

View File

@@ -11,8 +11,8 @@ function getQueryResultData(queryResult) {
export default function useQueryResult(queryResult) {
const [data, setData] = useState(getQueryResultData(queryResult));
let isCancelled = false;
useEffect(() => {
let isCancelled = false;
if (queryResult) {
queryResult.toPromise()
.then(() => {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce';
export default function useSearchResults(
@@ -7,17 +7,16 @@ export default function useSearchResults(
) {
const [result, setResult] = useState(initialResults);
const [isLoading, setIsLoading] = useState(false);
let currentSearchTerm = null;
let isDestroyed = false;
const currentSearchTerm = useRef(null);
const isDestroyed = useRef(false);
const [doSearch] = useDebouncedCallback((searchTerm) => {
setIsLoading(true);
currentSearchTerm = searchTerm;
currentSearchTerm.current = searchTerm;
fetch(searchTerm)
.catch(() => null)
.then((data) => {
if ((searchTerm === currentSearchTerm) && !isDestroyed) {
if ((searchTerm === currentSearchTerm.current) && !isDestroyed.current) {
setResult(data);
setIsLoading(false);
}
@@ -26,7 +25,7 @@ export default function useSearchResults(
useEffect(() => (
// ignore all requests after component destruction
() => { isDestroyed = true; }
() => { isDestroyed.current = true; }
), []);
return [doSearch, result, isLoading];

View File

@@ -109,7 +109,7 @@ export default class AlertDestinations extends React.Component {
return {
content: (
<div className="destination-wrapper">
<img src={`${IMG_ROOT}/${item.type}.png`} className="destination-icon" alt={name} />
<img src={`${IMG_ROOT}/${item.type}.png`} className="destination-icon" alt={item.name} />
<span className="flex-fill">{item.name}</span>
<ListItemAddon isSelected={isSelected} alreadyInGroup={alreadyInGroup} deselectedIcon="fa-plus" />
</div>

View File

@@ -34,7 +34,7 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
maskClosable: true,
autoFocusButton: null,
});
}, []);
}, [doDelete]);
return (
<Dropdown

View File

@@ -28,7 +28,7 @@ function RearmByDuration({ value, onChange, editMode }) {
break;
}
}
}, []);
}, [value]);
if (!isNumber(count) || !isNumber(durationIdx)) {
return null;

View File

@@ -68,7 +68,7 @@ function FavoriteList({ title, resource, itemUrl, emptyState }) {
resource.favorites().$promise
.then(({ results }) => setItems(results))
.finally(() => setLoading(false));
}, []);
}, [resource]);
return (
<>

View File

@@ -1,11 +0,0 @@
import qs from 'qs';
const parse = queryString => qs.parse(queryString, { allowDots: true, ignoreQueryPrefix: true });
const onlyParameters = ([k]) => k.startsWith('p_');
const removePrefix = ([k, v]) => [k.slice(2), v];
const toObject = (obj, [k, v]) => Object.assign(obj, { [k]: v });
export default () => Object.entries(parse(location.search))
.filter(onlyParameters)
.map(removePrefix)
.reduce(toObject, {});

View File

@@ -84,7 +84,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
originalOptions: options,
};
},
[visualization],
[data, isNew, visualization],
);
const [type, setType] = useState(defaultState.type);

View File

@@ -32,12 +32,12 @@ export function VisualizationRenderer(props) {
// Reset local filters when query results updated
useEffect(() => {
setFilters(combineFilters(data.filters, props.filters));
}, [data]);
}, [data, props.filters]);
// Update local filters when global filters changed
useEffect(() => {
setFilters(combineFilters(filters, props.filters));
}, [props.filters]);
}, [filters, props.filters]);
const filteredData = useMemo(() => ({
columns: data.columns,

View File

@@ -89,7 +89,7 @@ export default function SeriesSettings({ options, data, onOptionsChange }) {
const seriesOptions = [...series];
seriesOptions.splice(newIndex, 0, ...seriesOptions.splice(oldIndex, 1));
onOptionsChange({ seriesOptions: fromPairs(map(seriesOptions, ({ key }, zIndex) => ([key, { zIndex }]))) });
}, [series]);
}, [onOptionsChange, series]);
const updateSeriesOption = useCallback((key, prop, value) => {
onOptionsChange({

View File

@@ -1,17 +1,12 @@
import React, { useState, useEffect, useMemo } from 'react';
import { RendererPropTypes } from '@/visualizations';
import { clientConfig } from '@/services/auth';
import resizeObserver from '@/services/resizeObserver';
import getChartData from '../getChartData';
import { Plotly, prepareCustomChartData, createCustomChartRenderer } from '../plotly';
export default function CustomPlotlyChart({ options, data }) {
if (!clientConfig.allowCustomJSVisualizations) {
return null;
}
const [container, setContainer] = useState(null);
const renderCustomChart = useMemo(
@@ -33,7 +28,7 @@ export default function CustomPlotlyChart({ options, data }) {
});
return unwatch;
}
}, [container, plotlyData]);
}, [container, plotlyData, renderCustomChart]);
// Cleanup when component destroyed
useEffect(() => {

View File

@@ -3,11 +3,12 @@ import { RendererPropTypes } from '@/visualizations';
import PlotlyChart from './PlotlyChart';
import CustomPlotlyChart from './CustomPlotlyChart';
import { clientConfig } from '@/services/auth';
import './renderer.less';
export default function Renderer({ options, ...props }) {
if (options.globalSeriesType === 'custom') {
if (options.globalSeriesType === 'custom' && clientConfig.allowCustomJSVisualizations) {
return <CustomPlotlyChart options={options} {...props} />;
}
return <PlotlyChart options={options} {...props} />;

View File

@@ -66,7 +66,7 @@ function prepareSeries(series, options, additionalOptions) {
{ x: plotlySeries.x[j], y: plotlySeries.y[i] },
);
const zValue = datum && datum.zVal || 0;
const zValue = (datum && datum.zVal) || 0;
item.push(zValue);
if (isFinite(zMax) && options.showDataLabels) {

View File

@@ -28,7 +28,7 @@ export default function BoundsSettings({ options, onOptionsChange }) {
setBounds(newBounds);
onOptionsChangeDebounced({ bounds: newBounds });
}
}, [bounds]);
}, [bounds, onOptionsChangeDebounced]);
return (
<React.Fragment>

View File

@@ -58,7 +58,7 @@ export default function Renderer({ data, options, onOptionsChange }) {
options, // detect changes for all options except bounds, but pass them all!
);
}
}, [map, geoJson, data, optionsWithoutBounds]);
}, [map, geoJson, data, optionsWithoutBounds]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (map) {

View File

@@ -156,7 +156,7 @@ export default function Cornelius({ data, options }) {
const maxRowLength = useMemo(() => min([
max(map(data, d => d.length)) || 0,
options.maxColumns + 1, // each row includes totals, but `maxColumns` is only for stage columns
]), [data]);
]), [data, options.maxColumns]);
if (data.length === 0) {
return null;

View File

@@ -16,6 +16,7 @@ function generateRowKeyPrefix() {
export default function Renderer({ data, options }) {
const funnelData = useMemo(() => prepareData(data.rows, options), [data, options]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const rowKeyPrefix = useMemo(() => generateRowKeyPrefix(), [funnelData]);
const formatValue = useMemo(() => createNumberFormatter(options.numberFormat), [options.numberFormat]);

View File

@@ -0,0 +1,6 @@
module.exports = {
printWidth: 120,
jsxBracketSameLine: true,
tabWidth: 2,
trailingComma: 'es5',
};

1224
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,10 @@
"analyze": "npm run clean && BUNDLE_ANALYZER=on webpack",
"analyze:build": "npm run clean && NODE_ENV=production BUNDLE_ANALYZER=on webpack",
"lint": "npm run lint:base -- --ext .js --ext .jsx ./client",
"lint:fix": "npm run lint:base -- --fix --ext .js --ext .jsx ./client",
"lint:base": "eslint --config ./client/.eslintrc.js --ignore-path ./client/.eslintignore",
"lint:ci": "npm run lint -- --format junit --output-file /tmp/test-results/eslint/results.xml",
"prettier": "prettier --write client/app/**/*.{js,jsx} client/cypress/**/*.js",
"test": "TZ=Africa/Khartoum jest",
"test:watch": "jest --watch",
"cypress:install": "npm install --no-save cypress@~3.6.1 @percy/cypress@^2.2.0 atob@2.1.2",
@@ -25,8 +27,8 @@
"url": "git+https://github.com/getredash/redash.git"
},
"engines": {
"node": "^8.0.0",
"npm": "^5.0.0"
"node": "^12.0.0",
"npm": "^6.0.0"
},
"author": "Redash Contributors",
"license": "BSD-2-Clause",
@@ -72,7 +74,6 @@
"pace-progress": "git+https://github.com/getredash/pace.git",
"plotly.js": "1.41.3",
"prop-types": "^15.6.1",
"qs": "^6.7.0",
"react": "^16.8.3",
"react-ace": "^6.1.0",
"react-dom": "^16.8.3",
@@ -90,7 +91,9 @@
"@babel/plugin-transform-object-assign": "^7.2.0",
"@babel/preset-env": "^7.3.1",
"@babel/preset-react": "^7.0.0",
"babel-eslint": "^8.2.3",
"@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.1.0",
"babel-loader": "^8.0.5",
"babel-plugin-angularjs-annotate": "^0.8.2",
@@ -100,18 +103,20 @@
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.7.1",
"enzyme-to-json": "^3.3.5",
"eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-import-resolver-webpack": "^0.8.3",
"eslint-loader": "^2.1.1",
"eslint-plugin-chai-friendly": "^0.4.1",
"eslint-plugin-compat": "^3.0.1",
"eslint": "^6.7.2",
"eslint-config-prettier": "^6.7.0",
"eslint-config-react-app": "^5.1.0",
"eslint-loader": "^3.0.3",
"eslint-plugin-chai-friendly": "^0.5.0",
"eslint-plugin-compat": "^3.3.0",
"eslint-plugin-cypress": "^2.0.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-flowtype": "^3.13.0",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jest": "^22.2.2",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-no-only-tests": "^2.3.1",
"eslint-plugin-react": "^7.12.3",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-no-only-tests": "^2.4.0",
"eslint-plugin-react": "^7.17.0",
"eslint-plugin-react-hooks": "^1.7.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"husky": "^1.3.1",
@@ -123,6 +128,7 @@
"lint-staged": "^8.1.3",
"mini-css-extract-plugin": "^0.4.4",
"mockdate": "^2.0.2",
"prettier": "^1.19.1",
"raw-loader": "^0.5.1",
"react-test-renderer": "^16.5.2",
"request": "^2.88.0",