Merge pull request #155 from wakatime/sebas-new-ts-components

chore: implement new ts components
This commit is contained in:
Juan Sebastian velez Posada
2023-01-16 09:08:43 -05:00
committed by GitHub
20 changed files with 1653 additions and 1445 deletions

2501
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -95,8 +95,8 @@
"jsxhint": "^0.15.1",
"less": "^4.1.3",
"lint-staged": "^13.1.0",
"mocha": "^5.0.0",
"mocha-sinon": "^2.0.0",
"mocha": "^10.2.0",
"mocha-sinon": "^2.1.2",
"node-gyp": "^8.3.0",
"prettier": "^2.8.2",
"prettier-plugin-packagejson": "^2.3.0",
@@ -104,9 +104,9 @@
"remote-redux-devtools": "^0.5.16",
"rimraf": "^3.0.2",
"shelljs": "^0.8.5",
"sinon": "^4.2.2",
"sinon-chai": "^2.8.0",
"sinon-chrome": "^2.2.4",
"sinon": "^15.0.1",
"sinon-chai": "^3.7.0",
"sinon-chrome": "^3.0.1",
"ts-jest": "^29.0.3",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",

View File

@@ -1,12 +1,11 @@
import React from 'react';
import { render } from '@testing-library/react';
import { renderWithProviders } from '../utils/test-utils';
import MainList from './MainList';
type onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
describe('MainList', () => {
let disableLogging: onClick;
let enableLogging: onClick;
let loggedIn: boolean;
let loggingEnabled: boolean;
let logoutUser: onClick;
let totalTimeLoggedToday: string;
@@ -14,17 +13,15 @@ describe('MainList', () => {
disableLogging = jest.fn();
enableLogging = jest.fn();
loggingEnabled = false;
loggedIn = false;
logoutUser = jest.fn();
totalTimeLoggedToday = '1/1/1999';
});
it('should render properly', () => {
const { container } = render(
const { container } = renderWithProviders(
<MainList
disableLogging={disableLogging}
enableLogging={enableLogging}
loggingEnabled={loggingEnabled}
loggedIn={loggedIn}
logoutUser={logoutUser}
totalTimeLoggedToday={totalTimeLoggedToday}
/>,

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { ReduxSelector } from '../types/store';
import { User } from '../types/user';
export interface MainListProps {
disableLogging: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
enableLogging: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
loggedIn: boolean;
loggingEnabled: boolean;
logoutUser: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
totalTimeLoggedToday?: string;
@@ -15,14 +17,17 @@ const openOptionsPage = async (): Promise<void> => {
export default function MainList({
disableLogging,
enableLogging,
loggedIn,
loggingEnabled,
logoutUser,
totalTimeLoggedToday,
}: MainListProps): JSX.Element {
const user: User | undefined = useSelector(
(selector: ReduxSelector) => selector.currentUser.user,
);
return (
<div>
{loggedIn && (
{user && (
<div className="row">
<div className="col-xs-12">
<blockquote>
@@ -34,7 +39,7 @@ export default function MainList({
</div>
</div>
)}
{loggingEnabled && loggedIn && (
{loggingEnabled && user && (
<div className="row">
<div className="col-xs-12">
<p>
@@ -45,7 +50,7 @@ export default function MainList({
</div>
</div>
)}
{!loggingEnabled && loggedIn && (
{!loggingEnabled && user && (
<div className="row">
<div className="col-xs-12">
<p>
@@ -61,7 +66,7 @@ export default function MainList({
<i className="fa fa-fw fa-cogs"></i>
Options
</a>
{loggedIn && (
{user && (
<div>
<a href="#" className="list-group-item" onClick={logoutUser}>
<i className="fa fa-fw fa-sign-out"></i>
@@ -69,7 +74,7 @@ export default function MainList({
</a>
</div>
)}
{!loggedIn && (
{!user && (
<a
target="_blank"
rel="noreferrer"

180
src/components/NavBar.tsx Normal file
View File

@@ -0,0 +1,180 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { configLogout, setApiKey } from '../reducers/configReducer';
import { userLogout } from '../reducers/currentUser';
import { ReduxSelector } from '../types/store';
import { User } from '../types/user';
import config from '../config/config';
import apiKeyInvalid from '../utils/apiKey';
import { fetchUserData } from '../utils/user';
export default function NavBar(): JSX.Element {
const [state, setState] = useState({
apiKey: '',
apiKeyError: '',
loading: false,
});
useEffect(() => {
const fetch = async () => {
const { apiKey } = await browser.storage.sync.get({ apiKey: config.apiKey });
setState({ ...state, apiKey });
};
fetch();
}, []);
const dispatch = useDispatch();
const user: User | undefined = useSelector(
(selector: ReduxSelector) => selector.currentUser.user,
);
const signedInAs = () => {
if (user) {
return (
<p className="navbar-text">
Signed in as <b>{user.full_name}</b>
</p>
);
} else {
return <div />;
}
};
const customRules = () => {
if (user) {
return (
<li>
<a target="_blank" href="https://wakatime.com/settings/rules" rel="noreferrer">
<i className="fa fa-fw fa-filter"></i>
Custom Rules
</a>
</li>
);
} else {
return <div />;
}
};
const dashboard = () => {
if (user) {
return (
<li>
<a target="_blank" href="https://wakatime.com/dashboard" rel="noreferrer">
<i className="fa fa-fw fa-tachometer"></i>
Dashboard
</a>
</li>
);
} else {
return <div />;
}
};
return (
<nav className="navbar navbar-default" role="navigation">
<div className="container-fluid">
<div className="navbar-header">
<button
type="button"
className="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#bs-example-navbar-collapse-1"
>
<span className="sr-only">Toggle navigation</span>
<i className="fa fa-fw fa-cogs"></i>
</button>
<a target="_blank" className="navbar-brand" href="https://wakatime.com" rel="noreferrer">
WakaTime
<img src="graphics/wakatime-logo-48.png" />
</a>
</div>
<div className="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
{signedInAs()}
<ul className="nav navbar-nav">
{customRules()}
{dashboard()}
<li className="dropdown">
<a
href="#"
className="dropdown-toggle"
data-toggle="dropdown"
role="button"
aria-expanded="false"
>
<i className="fa fa-fw fa-info"></i>
About
<span className="caret"></span>
</a>
<ul className="dropdown-menu" role="menu">
<li>
<a
target="_blank"
href="https://github.com/wakatime/chrome-wakatime/issues"
rel="noreferrer"
>
<i className="fa fa-fw fa-bug"></i>
Report an Issue
</a>
</li>
<li>
<a
target="_blank"
href="https://github.com/wakatime/chrome-wakatime"
rel="noreferrer"
>
<i className="fa fa-fw fa-github"></i>
View on GitHub
</a>
</li>
</ul>
</li>
<li>
<div className="container-fluid">
{state.apiKeyError && (
<div className="alert alert-danger" role="alert">
{state.apiKeyError}
</div>
)}
<div className="input-group">
<input
type="text"
className="form-control"
placeholder="API key"
value={state.apiKey}
onChange={(e) => {
const key = e.target.value;
const isApiKeyInvalid = apiKeyInvalid(key);
setState({ ...state, apiKey: key, apiKeyError: isApiKeyInvalid });
}}
/>
<span className="input-group-btn">
<button
className={`btn btn-default ${state.loading ? 'disabled' : ''}`}
disabled={state.loading}
type="button"
data-loading-text="Loading..."
onClick={async () => {
if (state.apiKeyError === '' && state.apiKey !== '') {
setState({ ...state, loading: true });
await browser.storage.sync.set({ apiKey: state.apiKey });
dispatch(configLogout());
dispatch(userLogout());
dispatch(setApiKey(state.apiKey));
await fetchUserData(state.apiKey, dispatch);
setState({ ...state, loading: false });
}
}}
>
Save
</button>
</span>
</div>
</div>
</li>
</ul>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { ApiKeyReducer, ReduxSelector } from '../types/store';
import config from '../config/config';
import { fetchUserData } from '../utils/user';
import changeExtensionState from '../utils/changeExtensionState';
import NavBar from './NavBar';
import MainList from './MainList';
export default function WakaTime(): JSX.Element {
const dispatch = useDispatch();
const defaultState = {
loggingEnabled: config.loggingEnabled,
totalTimeLoggedToday: '0 minutes',
};
const [state, setState] = useState(defaultState);
const {
apiKey: apiKeyFromRedux,
loggingEnabled,
totalTimeLoggedToday,
}: ApiKeyReducer = useSelector((selector: ReduxSelector) => selector.config);
useEffect(() => {
fetchUserData(apiKeyFromRedux, dispatch);
}, []);
const disableLogging = async () => {
setState({
...state,
loggingEnabled: false,
});
await changeExtensionState('notLogging');
await browser.storage.sync.set({
loggingEnabled: false,
});
};
const enableLogging = async () => {
setState({
...state,
loggingEnabled: true,
});
await changeExtensionState('allGood');
await browser.storage.sync.set({
loggingEnabled: true,
});
};
const logoutUser = async () => {
await browser.storage.sync.set({ apiKey: '' });
setState(defaultState);
await changeExtensionState('notSignedIn');
};
return (
<div>
<NavBar />
<div className="container">
<div className="row">
<div className="col-md-12">
<MainList
disableLogging={disableLogging}
enableLogging={enableLogging}
loggingEnabled={loggingEnabled}
totalTimeLoggedToday={totalTimeLoggedToday}
logoutUser={logoutUser}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ describe('wakatime config', () => {
"type": "success",
},
},
"apiKey": "",
"colors": {
"allGood": "",
"lightTheme": "white",

View File

@@ -47,6 +47,10 @@ interface Tooltips {
export interface Config {
alert: Alert;
/**
* API key use to query wakatime api
*/
apiKey: '';
colors: Colors;
/**
* Url from which to detect if the user is logged in
@@ -104,6 +108,8 @@ const config: Config = {
},
},
apiKey: '',
colors: {
allGood: '',
lightTheme: 'white',

View File

@@ -1,7 +1,7 @@
import axios, { AxiosResponse } from 'axios';
import moment from 'moment';
import { Tabs } from 'webextension-polyfill-ts';
import { User } from '../types/user';
import { AxiosUserResponse, User } from '../types/user';
import config from '../config/config';
import { SummariesPayload, GrandTotal } from '../types/summaries';
@@ -10,15 +10,18 @@ class WakaTimeCore {
constructor() {
this.tabsWithDevtoolsOpen = [];
}
setTabsWithDevtoolsOpen(tabs: Tabs.Tab[]): void {
this.tabsWithDevtoolsOpen = tabs;
}
async getTotalTimeLoggedToday(): Promise<GrandTotal> {
async getTotalTimeLoggedToday(api_key = ''): Promise<GrandTotal> {
const today = moment().format('YYYY-MM-DD');
const summariesAxiosPayload: AxiosResponse<SummariesPayload> = await axios.get(
config.summariesApiUrl,
{
data: {
params: {
api_key,
end: today,
start: today,
},
@@ -26,9 +29,22 @@ class WakaTimeCore {
);
return summariesAxiosPayload.data.data[0].grand_total;
}
async checkAuth(): Promise<User> {
const userPayload: AxiosResponse<User> = await axios.get(config.currentUserApiUrl);
return userPayload.data;
async checkAuth(api_key = ''): Promise<User> {
const userPayload: AxiosResponse<AxiosUserResponse> = await axios.get(
config.currentUserApiUrl,
{ params: { api_key } },
);
return userPayload.data.data;
}
async recordHeartbeat(): Promise<void> {
const items = await browser.storage.sync.get({
blacklist: '',
loggingEnabled: config.loggingEnabled,
loggingStyle: config.loggingStyle,
whitelist: '',
});
}
}

View File

@@ -1,13 +1,2 @@
// // Create a connection to the background page
// const backgroundPageConnection = browser.runtime.connect({
// name: 'devtools-page',
// });
// // Send a message to background page with the current active tabId
// backgroundPageConnection.postMessage({
// name: 'init',
// tabId: browser.devtools.inspectedWindow.tabId,
// });
// eslint-disable-next-line @typescript-eslint/no-floating-promises
browser.devtools.panels.create('Wakatime', 'test.png', 'WakatimeDevPanel.html');

View File

@@ -1,10 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import WakaTime from './components/WakaTime';
import createStore from './stores/createStore';
import checkCurrentUser from './utils/checkCurrentUser';
const container = document.getElementById('wakatime');
/* This is a fix for Bootstrap requiring jQuery */
global.jQuery = require('jquery');
require('bootstrap');
const container = document.getElementById('wakatime');
const root = createRoot(container!);
const store = createStore('WakaTime-Options');
checkCurrentUser(store)(30 * 1000);
@@ -12,10 +18,9 @@ const openOptions = async (): Promise<void> => {
await browser.runtime.openOptionsPage();
};
ReactDOM.render(
root.render(
<Provider store={store}>
<h1>POPUP GO HERE</h1>
<WakaTime />
<div onClick={openOptions}>Open options</div>
</Provider>,
container,
);

View File

@@ -0,0 +1,50 @@
import { createSlice } from '@reduxjs/toolkit';
import config from '../config/config';
import { ApiKeyReducer } from '../types/store';
interface SetApiKeyAction {
payload: string;
type: string;
}
interface SetLoggingEnabledAction {
payload: boolean;
type: string;
}
interface SetTotalTimeLoggedTodayAction {
payload: string;
type: string;
}
export const initialConfigState: ApiKeyReducer = {
apiKey: '',
loggingEnabled: config.loggingEnabled,
totalTimeLoggedToday: '0 minutes',
};
const apiKeySlice = createSlice({
initialState: initialConfigState,
name: 'configReducer',
reducers: {
configLogout: (state) => {
state.apiKey = '';
state.loggingEnabled = config.loggingEnabled;
state.totalTimeLoggedToday = '0 minutes';
},
setApiKey: (state, action: SetApiKeyAction) => {
state.apiKey = action.payload;
},
setLoggingEnabled: (state, action: SetLoggingEnabledAction) => {
state.loggingEnabled = action.payload;
},
setTotalTimeLoggedToday: (state, action: SetTotalTimeLoggedTodayAction) => {
state.totalTimeLoggedToday = action.payload;
},
},
});
export const actions = apiKeySlice.actions;
export const { configLogout, setApiKey, setLoggingEnabled, setTotalTimeLoggedToday } =
apiKeySlice.actions;
export default apiKeySlice.reducer;

View File

@@ -1,21 +1,26 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios, { AxiosResponse } from 'axios';
import { User, UserPayload } from '../types/user';
import { CurrentUser, User, UserPayload } from '../types/user';
import config from '../config/config';
interface setUserAction {
payload: User | undefined;
type: string;
}
type NameType = 'currentUser';
export const name: NameType = 'currentUser';
export const fetchCurrentUser = createAsyncThunk<User, undefined>(`[${name}]`, async () => {
const userPayload: AxiosResponse<UserPayload> = await axios.get(config.currentUserApiUrl);
return userPayload.data.data;
});
export const fetchCurrentUser = createAsyncThunk<User, string>(
`[${name}]`,
async (api_key = '') => {
const userPayload: AxiosResponse<UserPayload> = await axios.get(config.currentUserApiUrl, {
params: { api_key },
});
return userPayload.data.data;
},
);
export interface CurrentUser {
error?: unknown;
pending?: boolean;
user?: User;
}
export const initialState: CurrentUser = {};
const currentUser = createSlice({
@@ -30,8 +35,16 @@ const currentUser = createSlice({
},
initialState,
name,
reducers: {},
reducers: {
setUser: (state, action: setUserAction) => {
state.user = action.payload;
},
userLogout: (state) => {
state.user = undefined;
},
},
});
export const actions = currentUser.actions;
export const { setUser, userLogout } = currentUser.actions;
export default currentUser.reducer;

View File

@@ -1,23 +1,25 @@
import { configureStore, Store } from '@reduxjs/toolkit';
import { configureStore, Store, combineReducers } from '@reduxjs/toolkit';
import { logger } from 'redux-logger';
import { reduxBatch } from '@manaflair/redux-batch';
import devToolsEnhancer from 'remote-redux-devtools';
import currentUserReducer, {
initialState as InitalCurrentUser,
CurrentUser,
} from '../reducers/currentUser';
import currentUserReducer, { initialState as InitalCurrentUser } from '../reducers/currentUser';
import configReducer, { initialConfigState } from '../reducers/configReducer';
import isProd from '../utils/isProd';
export interface RootState {
currentUser: CurrentUser;
}
// Create the root reducer separately so we can extract the RootState type
const rootReducer = combineReducers({
config: configReducer,
currentUser: currentUserReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
const preloadedState: RootState = {
config: initialConfigState,
currentUser: InitalCurrentUser,
};
export type RootStore = Store<RootState>;
export default (appName: string): RootStore => {
export default (appName: string): Store<RootState> => {
const enhancers = [];
enhancers.push(reduxBatch);
if (!isProd()) {
@@ -30,9 +32,7 @@ export default (appName: string): RootStore => {
enhancers,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
preloadedState,
reducer: {
currentUser: currentUserReducer,
},
reducer: rootReducer,
});
return store;

12
src/types/store.ts Normal file
View File

@@ -0,0 +1,12 @@
import { CurrentUser } from './user';
export interface ApiKeyReducer {
apiKey: string;
loggingEnabled: boolean;
totalTimeLoggedToday: string;
}
export interface ReduxSelector {
config: ApiKeyReducer;
currentUser: CurrentUser;
}

View File

@@ -4,6 +4,10 @@ export interface UserPayload {
data: User;
}
export interface AxiosUserResponse {
data: User;
}
export interface User {
bio: null;
color_scheme: string;
@@ -42,3 +46,9 @@ export interface User {
weekday_start: number;
writes_only: boolean;
}
export interface CurrentUser {
error?: unknown;
pending?: boolean;
user?: User;
}

10
src/utils/apiKey.ts Normal file
View File

@@ -0,0 +1,10 @@
export default function apiKeyInvalid(key?: string): string {
const err = 'Invalid api key... check https://wakatime.com/settings for your key';
if (!key) return err;
const re = new RegExp(
'^(waka_)?[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$',
'i',
);
if (!re.test(key)) return err;
return '';
}

View File

@@ -1,13 +1,16 @@
import { RootStore } from '../stores/createStore';
import { Store } from '@reduxjs/toolkit';
import { RootState } from '../stores/createStore';
import { fetchCurrentUser } from '../reducers/currentUser';
import { ReduxSelector } from '../types/store';
type unsub = () => void;
export default (store: RootStore) =>
export default (store: Store<RootState>) =>
(time: number): unsub => {
const fetchUser = () => {
const apiKey: string = (store.getState() as ReduxSelector).config.apiKey;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
store.dispatch(fetchCurrentUser());
store.dispatch(fetchCurrentUser(apiKey));
};
fetchUser();
const timeout = setInterval(fetchUser, time);

43
src/utils/test-utils.tsx Normal file
View File

@@ -0,0 +1,43 @@
import React, { PropsWithChildren } from 'react';
import { render } from '@testing-library/react';
import type { RenderOptions } from '@testing-library/react';
import { combineReducers, configureStore, Store } from '@reduxjs/toolkit';
import type { PreloadedState } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { RootState } from '../stores/createStore';
// As a basic setup, import your same slice reducers
import userReducer, { initialState as InitalCurrentUser } from '../reducers/currentUser';
import configReducer, { initialConfigState } from '../reducers/configReducer';
// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: PreloadedState<RootState>;
store?: Store<RootState>;
}
const rootReducer = combineReducers({
config: configReducer,
currentUser: userReducer,
});
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {
config: initialConfigState,
currentUser: InitalCurrentUser,
},
// Automatically create a store instance if no store was passed in
store = configureStore({ preloadedState, reducer: rootReducer }),
...renderOptions
}: ExtendedRenderOptions = {},
): any {
function Wrapper({ children }: PropsWithChildren<Record<string, unknown>>): JSX.Element {
return <Provider store={store}>{children}</Provider>;
}
// Return an object with the store and all of RTL's query functions
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}

45
src/utils/user.ts Normal file
View File

@@ -0,0 +1,45 @@
import { AnyAction, Dispatch } from '@reduxjs/toolkit';
import { setApiKey, setLoggingEnabled, setTotalTimeLoggedToday } from '../reducers/configReducer';
import config from '../config/config';
import WakaTimeCore from '../core/WakaTimeCore';
import { setUser } from '../reducers/currentUser';
import changeExtensionState from './changeExtensionState';
export const fetchUserData = async (
apiKey: string,
dispatch: Dispatch<AnyAction>,
): Promise<void> => {
if (!apiKey) {
const storage = await browser.storage.sync.get({
apiKey: config.apiKey,
});
apiKey = storage.apiKey as string;
dispatch(setApiKey(apiKey));
}
if (!apiKey) {
await changeExtensionState('notSignedIn');
}
try {
const [data, totalTimeLoggedTodayResponse, items] = await Promise.all([
WakaTimeCore.checkAuth(apiKey),
WakaTimeCore.getTotalTimeLoggedToday(apiKey),
browser.storage.sync.get({ loggingEnabled: config.loggingEnabled }),
]);
dispatch(setUser(data));
if (items.loggingEnabled === true) {
await changeExtensionState('allGood');
} else {
await changeExtensionState('notLogging');
}
dispatch(setLoggingEnabled(items.loggingEnabled as boolean));
dispatch(setTotalTimeLoggedToday(totalTimeLoggedTodayResponse.text));
await WakaTimeCore.recordHeartbeat();
} catch (err: unknown) {
await changeExtensionState('notSignedIn');
}
};