diff --git a/.eslintrc.json b/.eslintrc.json index 547ea1d..f2da764 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,6 +10,8 @@ "react/prop-types": "off", "comma-dangle": "off", "no-use-before-define": "off", - "@typescript-eslint/no-use-before-define": ["error"] + "@typescript-eslint/no-use-before-define": ["error"], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error"] } } diff --git a/components/AddRule.js b/components/AddRule.js index e798abb..932b1a5 100644 --- a/components/AddRule.js +++ b/components/AddRule.js @@ -27,7 +27,7 @@ const fields = [ } ] -const AddRule = ({ onAddRule }) => { +const AddRule = () => { const formRef = useRef() const [error, setError] = useState(null) const router = useRouter() diff --git a/components/Footer.js b/components/Footer.tsx similarity index 86% rename from components/Footer.js rename to components/Footer.tsx index 18c52c6..7be882a 100644 --- a/components/Footer.js +++ b/components/Footer.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { Flex, Box, Link } from 'ooni-components' import styled from 'styled-components' @@ -16,7 +17,12 @@ const FooterColumn = styled(Flex).attrs({ mx: 3 })`` -const FooterItem = ({ label, link }) => ( +type FooterItemProps = { + label: React.ReactNode, + link: string +} + +const FooterItem: React.FunctionComponent = ({ label, link }) => ( {label} ) diff --git a/components/Layout.js b/components/Layout.tsx similarity index 72% rename from components/Layout.js rename to components/Layout.tsx index 391e873..78231b5 100644 --- a/components/Layout.js +++ b/components/Layout.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import * as React from 'react' import NextHead from 'next/head' import styled from 'styled-components' import { Container, Flex } from 'ooni-components' @@ -11,14 +11,18 @@ const PageWrapper = styled(Flex)` min-height: 100vh; ` -const Layout = ({ title = '', children }) => { +type LayoutProps = { + title: string, + children: React.ReactNode +} +const Layout: React.FunctionComponent = ({ title = '', children }) => { return ( <> {title} - + {children} diff --git a/components/List.js b/components/List.js index 99da9c0..149b984 100644 --- a/components/List.js +++ b/components/List.js @@ -120,7 +120,7 @@ const Button = styled.button` // Dynamic button // * Starts editing a row // * Switches to a two button component to confirm or cancel a row edit operation. -const EditButton = ({ resetRow, onRowUpdate, row: { index, values, state: { isEditing, dirty }, setState } }) => { +const EditButton = ({ resetRow, onRowUpdate, row: { index, values, state: { isEditing, /* dirty */ }, setState } }) => { const onEdit = useCallback(() => { // TODO: Don't edit if another row is still being edited setState(state => ({ ...state, isEditing: true })) diff --git a/components/Loading.tsx b/components/Loading.tsx index fda9b19..403fbe5 100644 --- a/components/Loading.tsx +++ b/components/Loading.tsx @@ -1,7 +1,7 @@ import React from 'react' import styled, { keyframes } from 'styled-components' -const rotate = (dir) => keyframes` +const rotate = (dir: number) => keyframes` from { transform: rotate(0deg); } @@ -19,12 +19,12 @@ const Spinner = styled.div` animation: ${props => rotate(props.$dir)} ${props => 2 / props.speed}s linear infinite; ` -interface LoadingProps { - readonly size?: number; - readonly speed?: number; - readonly dir?: number; +type LoadingProps = { + size?: number; + speed?: number; + dir?: number; } -const Loading: React.FC = ({ size = 64, speed = 1, dir = 1 }) => ( +const Loading: React.FunctionComponent = ({ size = 64, speed = 1, dir = 1 }) => ( - - - +type LoginFormData = { + email_address: string, + nickname: string +} -export const LoginForm = ({ onLogin }) => { - const [submitting, setSubmitting] = useState(false) - const [loginError, setError] = useState(null) +type LoginFormProps = { + onLogin: Function +} - const { handleSubmit, register, formState, reset } = useForm({ +export const LoginForm: React.FunctionComponent = ({ onLogin }) => { + const [submitting, setSubmitting] = useState(false) + const [loginError, setError] = useState(null) + const { handleSubmit, register, formState, reset } = useForm({ mode: 'onTouched', }) const { errors, isValid, isDirty } = formState - const onSubmit = useCallback((data) => { + const onSubmit = useCallback((data: LoginFormData) => { const { email_address, nickname } = data - const registerApi = async (email_address, nickname) => { + const registerApi = async (email_address: string, nickname: string) => { try { await registerUser(email_address, nickname) if (typeof onLogin === 'function') { onLogin() } } catch (e) { - setError(e.message) // Reset form to mark `isDirty` as false reset({}, { keepValues: true }) + console.error(e) + if (e instanceof Error) { + setError(e.message) + } } finally { setSubmitting(false) } @@ -119,4 +125,14 @@ export const LoginForm = ({ onLogin }) => { ) } +type LoginModalProps = { + isShowing: boolean, + hide: Function, + onLogin: Function +} +export const LoginModal: React.FunctionComponent = ({ isShowing, hide, onLogin }) => + + + + export default LoginForm diff --git a/components/lib/api.js b/components/lib/api.js deleted file mode 100644 index 55ddf33..0000000 --- a/components/lib/api.js +++ /dev/null @@ -1,145 +0,0 @@ -import Axios from 'axios' - -export const apiEndpoints = { - ACCOUNT_METADATA: '/api/_/account_metadata', - USER_REGISTER: '/api/v1/user_register', - USER_LOGIN: '/api/v1/user_login', - RULE_LIST: '/api/_/url-priorities/list', - RULE_UPDATE: '/api/_/url-priorities/update', - COUNTRIES_LIST: '/api/_/countries', - // Submissions - SUBMISSION_LIST: '/api/v1/url-submission/test-list', - SUBMISSION_ADD: '/api/v1/url-submission/add-url', - SUBMISSION_UPDATE: '/api/v1/url-submission/update-url', - SUBMISSION_STATE: '/api/v1/url-submission/state', - SUBMISSION_DIFF: '/api/v1/url-submission/diff', - SUBMISSION_SUBMIT: '/api/v1/url-submission/submit' -} - -const axios = Axios.create({ - baseURL: process.env.NEXT_PUBLIC_OONI_API, - withCredentials: true -}) - -export const fetcher = async (url) => { - try { - const res = await axios.get(url) - return res.data.rules ?? res.data - } catch (e) { - const error = new Error(e?.response?.data?.error ?? e.message) - error.info = e?.response?.statusText - error.status = e?.response?.status - throw error - } -} - -export const fetchTestList = async (url, cc) => { - try { - const res = await axios.get(`${url}/${cc}`) - return res.data - } catch (e) { - const error = new Error(e.response?.data?.error ?? e.message) - error.info = e.response.statusText - error.status = e.response.status - throw error - } -} - -export const getAPI = async (endpoint, params = {}, config = {}) => { - return await axios.request({ - method: config.method ?? 'GET', - url: endpoint, - params: params, - ...config - }) - .then(res => res.data) - .catch(e => { - const error = new Error(e?.response?.data?.error ?? e.message) - error.info = e?.response?.statusText - error.status = e?.response?.status - throw error - }) -} - -const postAPI = async (endpoint, params, config) => { - return await getAPI(endpoint, null, { method: 'POST', data: params }) -} - -export const registerUser = async (email, nickname) => { - console.debug('Called registerUser with', email, nickname) - const data = await postAPI(apiEndpoints.USER_REGISTER, { - email_address: email, - nickname: nickname - }) - return data -} - -export const loginUser = async (token) => { - return await getAPI(apiEndpoints.USER_LOGIN, { k: token }) -} - -export const updateRule = (oldEntry, newEntry) => { - console.debug('Called updateRule with old_entry', oldEntry, 'new_entry', newEntry) - return axios.post(apiEndpoints.RULE_UPDATE, { - old_entry: oldEntry, - new_entry: newEntry - }) - .then(res => res.data) -} - -export const deleteRule = (oldEntry) => { - console.debug('Called deleteRule with old_entry', oldEntry) - return updateRule(oldEntry, {}) -} - -export const addURL = async (newEntry, cc, comment) => { - console.debug('Called addURL with new_entry', newEntry) - const data = await postAPI(apiEndpoints.SUBMISSION_UPDATE, { - country_code: cc, - comment: comment, - new_entry: newEntry, - old_entry: {} - }) - return data.updated_entry -} - -export const updateURL = async (cc, comment, oldEntry, newEntry) => { - console.debug('Called updateURL with old_entry', oldEntry, 'new_entry', newEntry) - const data = await postAPI(apiEndpoints.SUBMISSION_UPDATE, { - country_code: cc, - comment: comment, - old_entry: oldEntry, - new_entry: newEntry - }) - return data.updated_entry -} - -export const deleteURL = async (cc, comment, oldEntry) => { - console.debug('Called deleteURL with oldEntry', oldEntry) - const data = await postAPI(apiEndpoints.SUBMISSION_UPDATE, { - country_code: cc, - comment: comment, - old_entry: oldEntry, - new_entry: {} - }) - return data.updated_entry -} - -export const submitChanges = async () => { - console.debug('Called submitChanges') - const data = await postAPI(apiEndpoints.SUBMISSION_SUBMIT) - return data.pr_id -} - -export const customErrorRetry = (error, key, config, revalidate, opts) => { - // This overrides the default exponential backoff algorithm - // Instead it uses the `errorRetryInterval` and `errorRetryCount` configuration to - // limit the retries - const maxRetryCount = config.errorRetryCount - if (maxRetryCount !== undefined && opts.retryCount > maxRetryCount) return - - // Never retry on 4xx errors - if (Math.floor(error.status / 100) === 4) return - - setTimeout(revalidate, config.errorRetryInterval, opts) -} diff --git a/components/lib/api.ts b/components/lib/api.ts new file mode 100644 index 0000000..914aa62 --- /dev/null +++ b/components/lib/api.ts @@ -0,0 +1,188 @@ +import Axios, { AxiosRequestConfig } from 'axios' +import { SWRConfiguration } from 'swr' +import { IApiError, Entry, ListURL, PriorityRuleEntry } from '../types' + +export const apiEndpoints = { + ACCOUNT_METADATA: '/api/_/account_metadata', + USER_REGISTER: '/api/v1/user_register', + USER_LOGIN: '/api/v1/user_login', + USER_LOGOUT: '/api/v1/user_logout', + RULE_LIST: '/api/_/url-priorities/list', + RULE_UPDATE: '/api/_/url-priorities/update', + COUNTRIES_LIST: '/api/_/countries', + // Submissions + SUBMISSION_LIST: '/api/v1/url-submission/test-list', + SUBMISSION_ADD: '/api/v1/url-submission/add-url', + SUBMISSION_UPDATE: '/api/v1/url-submission/update-url', + SUBMISSION_STATE: '/api/v1/url-submission/state', + SUBMISSION_DIFF: '/api/v1/url-submission/diff', + SUBMISSION_SUBMIT: '/api/v1/url-submission/submit' +} + +export enum eApiEndpoints { + ACCOUNT_METADATA = '/api/_/account_metadata', + USER_REGISTER= '/api/v1/user_register', + USER_LOGIN = '/api/v1/user_login', + USER_LOGOUT = '/api/v1/user_logout', + RULE_LIST = '/api/_/url-priorities/list', + RULE_UPDATE = '/api/_/url-priorities/update', + COUNTRIES_LIST = '/api/_/countries', + // Submissions + SUBMISSION_LIST = '/api/v1/url-submission/test-list', + SUBMISSION_ADD = '/api/v1/url-submission/add-url', + SUBMISSION_UPDATE = '/api/v1/url-submission/update-url', + SUBMISSION_STATE = '/api/v1/url-submission/state', + SUBMISSION_DIFF = '/api/v1/url-submission/diff', + SUBMISSION_SUBMIT = '/api/v1/url-submission/submit' +} + +const axios = Axios.create({ + baseURL: process.env.NEXT_PUBLIC_OONI_API, + withCredentials: true +}) + +interface IFetcherError extends Error { + info?: string + status?: string +} + +export const fetcher = async (url: string) => { + try { + const res = await axios.get(url) + return res.data.rules ?? res.data + } catch (e) { + let error: IFetcherError + if (Axios.isAxiosError(e) && e.response) { + error = new Error(e.response?.data?.error) + error.info = e.response.statusText + error.status = String(e.response.status) + } else { + error = new Error((e as Error).message) + error.info = '' + error.status = '' + } + throw error + } +} + +export const fetchTestList = async (url: string, cc: string) => { + try { + const res = await axios.get(`${url}/${cc}`) + return res.data + } catch (e) { + let error: IFetcherError + if (Axios.isAxiosError(e) && e.response) { + error = new Error(e.response?.data?.error) + error.info = e.response.statusText + error.status = String(e.response.status) + } else { + error = new Error((e as Error).message) + error.info = '' + error.status = '' + } + throw error + } +} + +export const getAPI = async (endpoint: eApiEndpoints, params: any = {}, config: AxiosRequestConfig = {}) => { + return await axios.request({ + method: config.method ?? 'GET', + url: endpoint, + params: params, + ...config + }) + .then(res => res.data) + .catch(e => { + const error = new Error(e?.response?.data?.error ?? e.message) as IApiError + error.info = e?.response?.statusText + error.status = e?.response?.status + throw error + }) +} + +const postAPI = async (endpoint: eApiEndpoints, params?: any, config?: AxiosRequestConfig) => { + return await getAPI(endpoint, {}, { ...config, method: 'POST', data: params }) +} + +export const registerUser = async (email: string, nickname: string) => { + console.debug('Called registerUser with', email, nickname) + const data = await postAPI(eApiEndpoints.USER_REGISTER, { + email_address: email, + nickname: nickname + }) + return data +} + +export const loginUser = async (token: string) => { + return await getAPI(eApiEndpoints.USER_LOGIN, { k: token }) +} + +export const logoutUser = async () => { + return await getAPI(eApiEndpoints.ACCOUNT_METADATA) +} + +export const updateRule = (oldEntry: PriorityRuleEntry, newEntry: PriorityRuleEntry | {}) => { + console.debug('Called updateRule with old_entry', oldEntry, 'new_entry', newEntry) + return axios.post(eApiEndpoints.RULE_UPDATE, { + old_entry: oldEntry, + new_entry: newEntry + }) + .then(res => res.data) +} + +export const deleteRule = (oldEntry: PriorityRuleEntry) => { + console.debug('Called deleteRule with old_entry', oldEntry) + return updateRule(oldEntry, {}) +} + +export const addURL = async (newEntry: Entry, cc: string, comment: string) => { + console.debug('Called addURL with new_entry', newEntry) + const data = await postAPI(eApiEndpoints.SUBMISSION_UPDATE, { + country_code: cc, + comment: comment, + new_entry: newEntry, + old_entry: {} + }) + return data.updated_entry +} + +export const updateURL = async (cc: string, comment: string, oldEntry: Entry, newEntry: Entry) => { + console.debug('Called updateURL with old_entry', oldEntry, 'new_entry', newEntry) + const data = await postAPI(eApiEndpoints.SUBMISSION_UPDATE, { + country_code: cc, + comment: comment, + old_entry: oldEntry, + new_entry: newEntry + }) + return data.updated_entry +} + +export const deleteURL = async (cc: string, comment: string, oldEntry: Entry) => { + console.debug('Called deleteURL with oldEntry', oldEntry) + const data = await postAPI(eApiEndpoints.SUBMISSION_UPDATE, { + country_code: cc, + comment: comment, + old_entry: oldEntry, + new_entry: {} + }) + return data.updated_entry +} + +export const submitChanges = async () => { + console.debug('Called submitChanges') + const data = await postAPI(eApiEndpoints.SUBMISSION_SUBMIT) + return data.pr_id +} + +export const customErrorRetry: SWRConfiguration['onErrorRetry'] = (error, key, config, revalidate, opts) => { + // This overrides the default exponential backoff algorithm + // Instead it uses the `errorRetryInterval` and `errorRetryCount` configuration to + // limit the retries + const maxRetryCount = config.errorRetryCount + if (maxRetryCount !== undefined && opts.retryCount > maxRetryCount) return + + // Never retry on 4xx errors + if (Math.floor(error.status / 100) === 4) return + + setTimeout(revalidate, config.errorRetryInterval, opts) +} diff --git a/components/lib/hooks.js b/components/lib/hooks.tsx similarity index 100% rename from components/lib/hooks.js rename to components/lib/hooks.tsx diff --git a/components/lib/notifier.js b/components/lib/notifier.tsx similarity index 89% rename from components/lib/notifier.js rename to components/lib/notifier.tsx index 6c2ae27..7142526 100644 --- a/components/lib/notifier.js +++ b/components/lib/notifier.tsx @@ -6,8 +6,8 @@ const NotifyComponent = () => export const useNotifier = () => { const Notification = React.memo(NotifyComponent) - const error = (message) => { - toast.error((t) => { + const error: typeof toast['error'] = (message) => { + return toast.error((t) => { return ( {message} diff --git a/components/lib/translateErrors.js b/components/lib/translateErrors.ts similarity index 50% rename from components/lib/translateErrors.js rename to components/lib/translateErrors.ts index cf97265..e2e2612 100644 --- a/components/lib/translateErrors.js +++ b/components/lib/translateErrors.ts @@ -4,12 +4,18 @@ ] */ -const commonErrorsMap = [ +type PatternMessagePairs = Array<[RegExp, string]> + +const commonErrorsMap: PatternMessagePairs = [ [/is duplicate$/, 'This URL is already part of this list.'], [/Invalid URL$/, 'The URL is not in a valid format. Here is an example of a valid one: http://ooni.org/'] ] -const errorsMap = { +type ErrorsMapType = { + [key: string]: PatternMessagePairs +} + +const errorsMap: ErrorsMapType = { add: [ ...commonErrorsMap, ], @@ -18,9 +24,9 @@ const errorsMap = { ] } -export const getPrettyErrorMessage = (rawErrorMessage, context) => { +export const getPrettyErrorMessage = (rawErrorMessage: string, context: string): string => { if (context in errorsMap) { - return errorsMap[context].find(([regex, prettyMessage]) => regex.test(rawErrorMessage))?.[1] ?? rawErrorMessage + return errorsMap[context].find(([regex]) => regex.test(rawErrorMessage))?.[1] ?? rawErrorMessage } return rawErrorMessage } diff --git a/components/submit/CategoryList.js b/components/submit/CategoryList.tsx similarity index 68% rename from components/submit/CategoryList.js rename to components/submit/CategoryList.tsx index 5c31eb3..45e8bd1 100644 --- a/components/submit/CategoryList.js +++ b/components/submit/CategoryList.tsx @@ -1,6 +1,9 @@ +import { HTMLAttributes } from 'react' import categories from '../lib/category_codes.json' -const CategoryList = ({ name, defaultValue, ...rest }) => ( +type CategoryListProps = HTMLAttributes & { name: string, required: boolean } + +const CategoryList = ({ name, defaultValue, ...rest }: CategoryListProps) => (
Logout Page
+ ) +} diff --git a/tsconfig.json b/tsconfig.json index 6db37c0..c4ab2fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ ], "allowJs": true, "skipLibCheck": true, - "strict": false, + "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "incremental": true,