diff --git a/.eslintrc.json b/.eslintrc.json index 18254681..4e5b57e9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,103 @@ { - "extends": [ "next/core-web-vitals", "prettier" ], - - "rules": { - "semi": "warn" - } + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/strict", + "plugin:sonarjs/recommended", + "plugin:eslint-comments/recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:cypress/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "tsconfig.json", + "ecmaFeatures": { + "jsx": true, + "modules": true + }, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "sonarjs", "jsx-a11y", "github", "cypress", "import"], + "rules": { + "@next/next/no-img-element": "off", + "@typescript-eslint/ban-ts-comment": "warn", + "curly": "error", + "no-console": [ + "warn", + { + "allow": ["warn", "error"] + } + ], + "quotes": [ + "warn", + "single", + { + "avoidEscape": true + } + ], + "prefer-template": "warn", + "react/jsx-curly-brace-presence": [ + "warn", + { + "props": "never" + } + ], + "react/jsx-boolean-value": ["error", "never"], + "react/jsx-no-useless-fragment": "warn", + "react/jsx-fragments": "warn", + "eqeqeq": ["warn", "smart"], + "no-lonely-if": "warn", + "no-multi-assign": "warn", + "@typescript-eslint/no-shadow": "warn", + "no-useless-return": "warn", + "no-useless-rename": "warn", + "one-var-declaration-per-line": "warn", + "prefer-object-spread": "warn", + "no-unreachable-loop": "warn", + "no-template-curly-in-string": "warn", + "default-case-last": "warn", + "no-array-constructor": "warn", + "no-else-return": "warn", + // disabled: too generic to really provide helpful warnings + //'no-negated-condition': 'warn', + "array-callback-return": "warn", + "@typescript-eslint/consistent-type-definitions": ["warn", "type"], + + // check if useful + //"no-secrets/no-secrets": "warn", + + // based on https://github.com/github/eslint-plugin-github/blob/main/lib/configs/react.js + "github/a11y-no-generic-link-text": "error", + + // TODO: why is this one not working? + //"github/role-supports-aria-props": "error", + // disabled for the one above + //"jsx-a11y/role-supports-aria-props": "off" + + // maybe consider https://github.com/brendanmorrell/eslint-plugin-styled-components-a11y + + "import/first": "error", + "import/no-absolute-path": "error", + // disabled: slow linting + /*"import/no-cycle": "error",*/ + "import/no-duplicates": "error", + + "@typescript-eslint/consistent-type-imports": [ + "error", + { + "prefer": "type-imports", + "fixStyle": "inline-type-imports" + } + ], + + "sonarjs/cognitive-complexity": "off", + // typically only triggered on more complex switch cases where introducing a variable only worsens readability + "sonarjs/no-duplicate-string": "off" + }, + "ignorePatterns": [".eslintrc.js"] } diff --git a/.gitignore b/.gitignore index 42a96417..a3ab3f90 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ yarn-error.log* /.idea/ .npmrc -utils/generatedGitInfo.json +lib/generatedGitInfo.json + +.yarn/ +!.yarn/releases diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000..0563839d --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules + +yarnPath: .yarn/releases/yarn-3.6.3.cjs diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 00000000..d545ac3c --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,37 @@ +import { type FC } from 'react'; +import Link from 'next/link'; +import generatedGitInfo from 'lib/generatedGitInfo.json'; + +/** + * Static footer component rendered at the bottom of every page + */ +const Footer: FC = () => ( + +); + +export default Footer; diff --git a/components/Identifiable.ts b/components/Identifiable.ts new file mode 100644 index 00000000..a7af865b --- /dev/null +++ b/components/Identifiable.ts @@ -0,0 +1,5 @@ +type Identifiable = { + readonly id: string; +}; + +export default Identifiable; diff --git a/components/inlineCloseButton.test.tsx b/components/InlineCloseButton.test.tsx similarity index 76% rename from components/inlineCloseButton.test.tsx rename to components/InlineCloseButton.test.tsx index b57af37d..ef624acc 100644 --- a/components/inlineCloseButton.test.tsx +++ b/components/InlineCloseButton.test.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { InlineCloseButton } from 'components/inlineCloseButton'; +import InlineCloseButton from 'components/InlineCloseButton'; import { act, render, screen } from '@testing-library/react'; test('interaction', () => { diff --git a/components/InlineCloseButton.tsx b/components/InlineCloseButton.tsx new file mode 100644 index 00000000..1c2448c6 --- /dev/null +++ b/components/InlineCloseButton.tsx @@ -0,0 +1,26 @@ +import { type FC } from 'react'; +import { X } from 'react-bootstrap-icons'; +import actionable from 'styles/actionable.module.css'; +import clsx from 'clsx'; + +type InlineCloseButtonProps = { onClose: () => void }; + +// TODO: use unstyled button instead of div + +/** + * Small X button representing a close action that is rendered inline + * + * @param props + * @param props.onClose callback to call when button is pressed + */ +const InlineCloseButton: FC = ({ onClose }) => ( +
onClose()} + className={clsx('d-inline-block', actionable['actionable'])} + role="button" + > + +
+); + +export default InlineCloseButton; diff --git a/components/inputWithSuggestions.tsx b/components/InputWithSuggestions.tsx similarity index 60% rename from components/inputWithSuggestions.tsx rename to components/InputWithSuggestions.tsx index d7e8e54e..8d4797af 100644 --- a/components/inputWithSuggestions.tsx +++ b/components/InputWithSuggestions.tsx @@ -1,21 +1,28 @@ -import React, { ReactElement, ReactNode, useState } from 'react'; +import { type FC, type PropsWithChildren, useState } from 'react'; import { Dropdown, FormControl, InputGroup } from 'react-bootstrap'; -import { Suggestion } from './resultSearch/jsonSchema'; +import { type Suggestion } from 'components/result-search/jsonSchema'; import { truthyOrNoneTag } from './utility'; -export function InputWithSuggestions(props: { +type InputWithSuggestionsProps = { setInput: (input: string) => void; suggestions?: Suggestion[]; placeholder?: string; - children?: ReactNode; value?: string; -}): ReactElement { - const [input, setInput] = useState(props.value); +}; - function updateInput(input: string) { - setInput(input); - props.setInput(input); - } +const InputWithSuggestions: FC> = ({ + setInput, + suggestions, + placeholder, + value, + children, +}) => { + const [input, setLocalInput] = useState(value); + + const updateInput = (newInput: string) => { + setLocalInput(newInput); + setInput(newInput); + }; return ( { - updateInput(e.target.value); - }} + onChange={(e) => updateInput(e.target.value)} /> - {props.suggestions !== undefined && props.suggestions.length > 0 && ( + {suggestions !== undefined && suggestions.length > 0 && ( <> - {props.suggestions.map((suggestion) => ( + {suggestions.map((suggestion) => ( {suggestion.field}
@@ -52,7 +57,9 @@ export function InputWithSuggestions(props: { )} {/* TODO: clean up, find alternative for this (this is used in filters) */} - {props.children} + {children}
); -} +}; + +export default InputWithSuggestions; diff --git a/components/JsonHighlight.tsx b/components/JsonHighlight.tsx new file mode 100644 index 00000000..97934217 --- /dev/null +++ b/components/JsonHighlight.tsx @@ -0,0 +1,24 @@ +import { type FC, type PropsWithChildren, useEffect } from 'react'; + +import prism from 'prismjs'; +import 'prismjs/components/prism-json'; + +/** + * Display JSON text with syntax highlighting + * + * @param props + * @param props.children + */ +const JsonHighlight: FC = ({ children }) => { + useEffect(() => { + prism.highlightAll(); + }, []); + + return ( +
+            {children}
+        
+ ); +}; + +export default JsonHighlight; diff --git a/components/JsonPreviewModal.tsx b/components/JsonPreviewModal.tsx new file mode 100644 index 00000000..0ee3ae44 --- /dev/null +++ b/components/JsonPreviewModal.tsx @@ -0,0 +1,41 @@ +import { type FC } from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import JsonHighlight from 'components/JsonHighlight'; +import { type Result } from '@eosc-perf/eosc-perf-client'; + +type JsonPreviewModalProps = { + result: Result | null; + show: boolean; + closeModal: () => void; +}; + +/** + * Modal to view the JSON data of a result + * + * @param props + * @param props.result + * @param props.show + * @param props.closeModal + */ +const JsonPreviewModal: FC = ({ result, show, closeModal }) => { + return ( + + + JSON Data + + + {result !== null && ( + {JSON.stringify(result.json, null, 4)} + )} + {result == null &&
Loading...
} +
+ + + +
+ ); +}; + +export default JsonPreviewModal; diff --git a/components/jsonSelection.tsx b/components/JsonSelection.tsx similarity index 79% rename from components/jsonSelection.tsx rename to components/JsonSelection.tsx index 8c862826..5d09edbe 100644 --- a/components/jsonSelection.tsx +++ b/components/JsonSelection.tsx @@ -1,31 +1,34 @@ -import React, { ChangeEvent, ReactElement, useState } from 'react'; +import { type ChangeEvent, type FC, useState } from 'react'; import { Form, ProgressBar } from 'react-bootstrap'; +type JsonSelectionProps = { + fileContents?: string; + setFileContents: (file?: string) => void; +}; + /** * Form component to select a JSON file for upload + * + * @param props * @param props.fileContents string containing the json file * @param props.setFileContents callback to update the string containing the json - * @constructor */ -export function JsonSelection(props: { - fileContents?: string; - setFileContents: (file?: string) => void; -}): ReactElement { +const JsonSelection: FC = ({ fileContents, setFileContents }) => { const [progress, setProgress] = useState(100.0); function loadFile(file?: File) { if (file === undefined) { - props.setFileContents(undefined); + setFileContents(undefined); return; } const reader = new FileReader(); reader.addEventListener('load', (e) => { - if (e.target && e.target.result) { + if (e.target?.result) { // readAsText guarantees string - props.setFileContents(e.target.result as string); + setFileContents(e.target.result as string); } else { - props.setFileContents(undefined); + setFileContents(undefined); } setProgress(100.0); }); @@ -52,4 +55,6 @@ export function JsonSelection(props: { {/*props.fileContents !== undefined ? props.fileContents :
No file loaded.
*/} ); -} +}; + +export default JsonSelection; diff --git a/components/LoginCheck.tsx b/components/LoginCheck.tsx new file mode 100644 index 00000000..596ddcee --- /dev/null +++ b/components/LoginCheck.tsx @@ -0,0 +1,35 @@ +import { type FC } from 'react'; +import { Alert, Button, Col, Row } from 'react-bootstrap'; +import useUser from 'lib/useUser'; + +const DEFAULT_MESSAGE = + 'You must be logged in to submit data to the platform! Log in using the dropdown in the top right of the page.'; + +type LoginCheckProps = { message?: string }; + +/** + * Warning banner that displays if the user is not logged in. + * @constructor + */ +const LoginCheck: FC = ({ message = DEFAULT_MESSAGE }) => { + const auth = useUser(); + + if (auth.loading || auth.loggedIn) { + return null; + } + + return ( + + + {message} + + + + + + ); +}; + +export default LoginCheck; diff --git a/components/navHeader.tsx b/components/NavHeader.tsx similarity index 95% rename from components/navHeader.tsx rename to components/NavHeader.tsx index 131ee241..e0775156 100644 --- a/components/navHeader.tsx +++ b/components/NavHeader.tsx @@ -1,18 +1,17 @@ -import React, { ReactElement, useContext } from 'react'; -import { UserContext } from 'components/userContext'; +import { type FC } from 'react'; import { Nav, Navbar, NavDropdown } from 'react-bootstrap'; -import logo from '../public/images/eosc-perf-logo.4.svg'; +import logo from 'public/images/eosc-perf-logo.4.svg'; import { useAuth } from 'react-oidc-context'; import { Wrench } from 'react-bootstrap-icons'; import Link from 'next/link'; import Image from 'next/image'; +import useUser from 'lib/useUser'; /** * Navigation header rendered at the top of every page - * @constructor */ -export function NavHeader(): ReactElement { - const auth = useContext(UserContext); +const NavHeader: FC = () => { + const auth = useUser(); const authentication = useAuth(); return ( @@ -119,4 +118,6 @@ export function NavHeader(): ReactElement { ); -} +}; + +export default NavHeader; diff --git a/components/pagination.test.tsx b/components/Paginator.test.tsx similarity index 97% rename from components/pagination.test.tsx rename to components/Paginator.test.tsx index e4ba497e..552ec4d5 100644 --- a/components/pagination.test.tsx +++ b/components/Paginator.test.tsx @@ -1,5 +1,5 @@ import { act, render, screen } from '@testing-library/react'; -import { Paginatable, Paginator } from './pagination'; +import Paginator, { type Paginatable } from './Paginator'; describe('pagination', () => { const pagination: Paginatable = { diff --git a/components/Paginator.tsx b/components/Paginator.tsx new file mode 100644 index 00000000..8b6eefe7 --- /dev/null +++ b/components/Paginator.tsx @@ -0,0 +1,101 @@ +import { type FC } from 'react'; +import { Pagination } from 'react-bootstrap'; + +/** + * Representation of an OpenAPI pagination object + */ +export type Paginatable = { + /** + * Page index of next page + */ + readonly next_num: number; + + /** + * Page index of previous page + */ + readonly prev_num: number; + + /** + * Total number of pages + */ + readonly total: number; + + /** + * Maximum number of items per page + */ + per_page?: number; + + /** + * Whether there is a next page + */ + readonly has_next: boolean; + + /** + * Whether there is a previous page + */ + readonly has_prev: boolean; + + /** + * Total number of pages available + */ + readonly pages: number; + + /** + * The current page + */ + page?: number; +}; + +/** + * Generic for pagination of a specific object type + */ +export type Paginated = { items: Type[] } & Paginatable; + +type PaginatorProps = { + pagination: Paginatable; + navigateTo: (pageIndex: number) => void; +}; + +/** + * Component to navigate between pages of a pagination object + * + * @param props + * @param props.pagination paginatable object to navigate through + * @param props.navigateTo callback to navigate to another page in the pagination + */ +const Paginator: FC = ({ pagination, navigateTo }) => ( + + navigateTo(1)} + data-testid="paginator-first" + /> + navigateTo(pagination.prev_num)} + data-testid="paginator-prev" + /> + {/* TODO: don't show all pages, only nearby 3-5? */} + {[...Array(pagination.pages).keys()].map((n: number) => ( + navigateTo(n + 1)} + key={n + 1} + > + {n + 1} + + ))} + navigateTo(pagination.next_num)} + data-testid="paginator-next" + /> + navigateTo(pagination.pages)} + data-testid="paginator-last" + /> + +); + +export default Paginator; diff --git a/components/queryClientWrapper.tsx b/components/QueryClientWrapper.tsx similarity index 63% rename from components/queryClientWrapper.tsx rename to components/QueryClientWrapper.tsx index b734e1a0..4de22458 100644 --- a/components/queryClientWrapper.tsx +++ b/components/QueryClientWrapper.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import { type FC, type PropsWithChildren } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactQueryDevtools } from 'react-query/devtools'; @@ -10,10 +10,12 @@ const queryClient = new QueryClient({ }, }); -export function QueryClientWrapper(props: { children: ReactNode }) { +const QueryClientWrapper: FC = ({ children }) => { return ( - {props.children} + {children} ); -} +}; + +export default QueryClientWrapper; diff --git a/components/registrationCheck.test.tsx b/components/RegistrationCheck.test.tsx similarity index 90% rename from components/registrationCheck.test.tsx rename to components/RegistrationCheck.test.tsx index f574972b..ffbaf629 100644 --- a/components/registrationCheck.test.tsx +++ b/components/RegistrationCheck.test.tsx @@ -1,7 +1,6 @@ -import React from 'react'; import { render, screen } from '@testing-library/react'; -import { RegistrationCheck } from './registrationCheck'; -import { UserContext, UserInfo } from './userContext'; +import RegistrationCheck from './RegistrationCheck'; +import UserContext, { type UserInfo } from './UserContext'; const legalUser: UserInfo = { token: '__access__token__', diff --git a/components/RegistrationCheck.tsx b/components/RegistrationCheck.tsx new file mode 100644 index 00000000..9510605f --- /dev/null +++ b/components/RegistrationCheck.tsx @@ -0,0 +1,30 @@ +import { type FC } from 'react'; +import { Alert, Button, Col, Row } from 'react-bootstrap'; +import Link from 'next/link'; +import useUser from 'lib/useUser'; + +/** + * Warning banner that displays if the user has not completed registration + */ +const RegistrationCheck: FC = () => { + const auth = useUser(); + + if (auth.loading || !auth.loggedIn || auth.registered) { + return null; + } + + return ( + + + You must register before submitting data to the services on this website! + + + + + + + + ); +}; + +export default RegistrationCheck; diff --git a/components/resultEditModal.tsx b/components/ResultEditModal.tsx similarity index 67% rename from components/resultEditModal.tsx rename to components/ResultEditModal.tsx index c63a902a..1f474117 100644 --- a/components/resultEditModal.tsx +++ b/components/ResultEditModal.tsx @@ -1,28 +1,26 @@ -import React, { useContext, useEffect, useState } from 'react'; +import { type FC, useEffect, useState } from 'react'; import { Button, Modal } from 'react-bootstrap'; import { useMutation } from 'react-query'; -import { UserContext } from 'components/userContext'; -import { JsonHighlight } from 'components/jsonHighlight'; -import TagSelector from './tagSelector'; -import { Result, Tag, TagsIds } from '@eosc-perf/eosc-perf-client'; -import useApi from '../utils/useApi'; +import JsonHighlight from 'components/JsonHighlight'; +import TagSelector from './TagSelector'; +import { type Result, type Tag, type TagsIds } from '@eosc-perf/eosc-perf-client'; +import useApi from 'lib/useApi'; +import useUser from 'lib/useUser'; -export function ResultEditModal({ - result, - show, - closeModal, -}: { +type ResultEditModalProps = { result: Result; show: boolean; closeModal: () => void; -}) { - const auth = useContext(UserContext); +}; + +const ResultEditModal: FC = ({ result, show, closeModal }) => { + const auth = useUser(); const api = useApi(auth.token); const [selectedTags, setSelectedTags] = useState([]); useEffect(() => { - setSelectedTags(result?.tags ?? []); + setSelectedTags(result.tags ?? []); }, [result]); const { mutate } = useMutation((data: TagsIds) => api.results.updateResult(result.id, data), { @@ -31,12 +29,14 @@ export function ResultEditModal({ }, }); - function submitEdit() { + const submitEdit = () => { mutate({ tags_ids: selectedTags.map((tag) => tag.id) }); - } + }; + + // TODO: this is checking for result = null: why? return ( - + Edit result @@ -61,4 +61,6 @@ export function ResultEditModal({ ); -} +}; + +export default ResultEditModal; diff --git a/components/resultReportModal.tsx b/components/ResultReportModal.tsx similarity index 57% rename from components/resultReportModal.tsx rename to components/ResultReportModal.tsx index 9f6bccd4..731660a9 100644 --- a/components/resultReportModal.tsx +++ b/components/ResultReportModal.tsx @@ -1,27 +1,27 @@ -import React, { useContext, useState } from 'react'; +import { type FC, useState } from 'react'; import { Button, Form, Modal } from 'react-bootstrap'; import { useMutation } from 'react-query'; -import { UserContext } from 'components/userContext'; -import { JsonHighlight } from 'components/jsonHighlight'; -import useApi from '../utils/useApi'; -import { CreateClaim, Result } from '@eosc-perf/eosc-perf-client'; +import JsonHighlight from 'components/JsonHighlight'; +import useApi from 'lib/useApi'; +import { type CreateClaim, type Result } from '@eosc-perf/eosc-perf-client'; +import useUser from 'lib/useUser'; -export function ResultReportModal(props: { +type ResultReportModalProps = { result: Result; show: boolean; closeModal: () => void; -}) { - const auth = useContext(UserContext); +}; + +const ResultReportModal: FC = ({ result, show, closeModal }) => { + const auth = useUser(); const api = useApi(auth.token); const [message, setMessage] = useState(''); const { mutate } = useMutation( - (data: CreateClaim) => api.results.claimReport(props.result.id, data), + (data: CreateClaim) => api.results.claimReport(result.id, data), { - onSuccess: () => { - props.closeModal(); - }, + onSuccess: closeModal, } ); @@ -30,7 +30,7 @@ export function ResultReportModal(props: { } return ( - + Report result @@ -45,19 +45,21 @@ export function ResultReportModal(props: { /> - {props.result !== null && ( - {JSON.stringify(props.result.json, null, 4)} + {result !== null && ( + {JSON.stringify(result.json, null, 4)} )} - {props.result == null &&
Loading...
} + {result == null &&
Loading...
} -
); -} +}; + +export default ResultReportModal; diff --git a/components/resultsPerPageSelection.test.tsx b/components/ResultsPerPageSelection.test.tsx similarity index 91% rename from components/resultsPerPageSelection.test.tsx rename to components/ResultsPerPageSelection.test.tsx index 4f35af3e..2b746a75 100644 --- a/components/resultsPerPageSelection.test.tsx +++ b/components/ResultsPerPageSelection.test.tsx @@ -1,4 +1,4 @@ -import { ResultsPerPageSelection } from './resultsPerPageSelection'; +import ResultsPerPageSelection from './ResultsPerPageSelection'; import { render } from '@testing-library/react'; describe('results per page selection', () => { diff --git a/components/resultsPerPageSelection.tsx b/components/ResultsPerPageSelection.tsx similarity index 71% rename from components/resultsPerPageSelection.tsx rename to components/ResultsPerPageSelection.tsx index 67c4938a..160e62de 100644 --- a/components/resultsPerPageSelection.tsx +++ b/components/ResultsPerPageSelection.tsx @@ -1,16 +1,21 @@ -import React, { ChangeEvent } from 'react'; +import { FC } from 'react'; import { Col, Form, Row } from 'react-bootstrap'; +type ResultsPerPageSelectionProps = { + onChange: (resultsPerPage: number) => void; + currentSelection: number; +}; + /** * Dropdown component to select the number of items to display in a page + * @param props * @param props.onChange callback to be called with the new number of items per page * @param props.currentSelection the current number of items displayed per page - * @constructor */ -export function ResultsPerPageSelection(props: { - onChange: (resultsPerPage: number) => void; - currentSelection: number; -}) { +const ResultsPerPageSelection: FC = ({ + onChange, + currentSelection, +}) => { const options = [10, 15, 20, 50, 100]; return ( @@ -18,10 +23,8 @@ export function ResultsPerPageSelection(props: { Results per page: ) => - props.onChange(parseInt(e.target.value)) - } - value={props.currentSelection} + onChange={(e) => onChange(parseInt(e.target.value))} + value={currentSelection} > {options.map((n: number) => (