Merge pull request #267 from wakatime/feature/enhanced-input-ui

Custom Project Name List
This commit is contained in:
Alan Hamlett
2024-08-30 14:50:00 +02:00
committed by GitHub
11 changed files with 273 additions and 96 deletions

View File

@@ -21,6 +21,7 @@ module.exports = {
'plugin:react/recommended', 'plugin:react/recommended',
'plugin:react/jsx-runtime', 'plugin:react/jsx-runtime',
'plugin:typescript-sort-keys/recommended', 'plugin:typescript-sort-keys/recommended',
'plugin:react-hooks/recommended',
], ],
globals: { globals: {
browser: true, browser: true,

14
package-lock.json generated
View File

@@ -71,6 +71,7 @@
"eslint-plugin-jest-dom": "^4.0.3", "eslint-plugin-jest-dom": "^4.0.3",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-testing-library": "^5.9.1", "eslint-plugin-testing-library": "^5.9.1",
"eslint-plugin-typescript-sort-keys": "^2.1.0", "eslint-plugin-typescript-sort-keys": "^2.1.0",
@@ -9693,10 +9694,11 @@
} }
}, },
"node_modules/eslint-plugin-react-hooks": { "node_modules/eslint-plugin-react-hooks": {
"version": "4.6.0", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
"integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -30036,9 +30038,9 @@
} }
}, },
"eslint-plugin-react-hooks": { "eslint-plugin-react-hooks": {
"version": "4.6.0", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
"integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
"dev": true, "dev": true,
"requires": {} "requires": {}
}, },

View File

@@ -92,6 +92,7 @@
"eslint-plugin-jest-dom": "^4.0.3", "eslint-plugin-jest-dom": "^4.0.3",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-testing-library": "^5.9.1", "eslint-plugin-testing-library": "^5.9.1",
"eslint-plugin-typescript-sort-keys": "^2.1.0", "eslint-plugin-typescript-sort-keys": "^2.1.0",

View File

@@ -0,0 +1,93 @@
import React, { useCallback } from 'react';
import { ProjectName } from '../utils/settings';
type Props = {
handleChange: (sites: ProjectName[]) => void;
helpText: string;
label: string;
projectNamePlaceholder?: string;
sites: ProjectName[];
urlPlaceholder?: string;
};
export default function CustomProjectNameList({
handleChange,
label,
urlPlaceholder,
projectNamePlaceholder,
sites,
}: Props): JSX.Element {
const handleAddNewSite = useCallback(() => {
handleChange([...sites, { projectName: '', url: '' }]);
}, [handleChange, sites]);
const handleUrlChangeForSite = useCallback(
(event: React.ChangeEvent<HTMLInputElement>, index: number) => {
handleChange(
sites.map((item, i) => (i === index ? { ...item, url: event.target.value } : item)),
);
},
[handleChange, sites],
);
const handleOnProjectNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>, index: number) => {
handleChange(
sites.map((item, i) => (i === index ? { ...item, projectName: event.target.value } : item)),
);
},
[handleChange, sites],
);
const handleRemoveSite = useCallback(
(index: number) => {
handleChange(sites.filter((_, i) => i !== index));
},
[handleChange, sites],
);
return (
<div className="form-group mb-4 d-flex flex-column gap-3">
<label htmlFor={`${label}-siteList`} className="control-label">
{label}
</label>
{sites.length > 0 && (
<div className="d-flex flex-column gap-2">
{sites.map((site, i) => (
<div key={i} className="d-flex gap-2">
<div className="flex-fill">
<input
placeholder={urlPlaceholder ?? 'https://google.com'}
className="form-control"
value={site.url}
onChange={(e) => handleUrlChangeForSite(e, i)}
/>
</div>
<div className="flex-fill">
<input
placeholder={projectNamePlaceholder ?? 'Project Name'}
value={site.projectName}
className="form-control"
onChange={(e) => handleOnProjectNameChange(e, i)}
/>
</div>
<button
type="button"
className="btn btn-sm btn-default"
onClick={() => handleRemoveSite(i)}
>
<i className="fa fa-fw fa-times"></i>
</button>
</div>
))}
</div>
)}
<button type="button" onClick={handleAddNewSite} className="btn btn-default col-12">
<i className="fa fa-fw fa-plus me-2"></i>
Add Project Name
</button>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import config, { SuccessOrFailType } from '../config/config'; import config, { SuccessOrFailType } from '../config/config';
import apiKeyInvalid from '../utils/apiKey'; import apiKeyInvalid from '../utils/apiKey';
import { IS_CHROME } from '../utils/operatingSystem'; import { IS_CHROME } from '../utils/operatingSystem';
import { getSettings, saveSettings, Settings } from '../utils/settings'; import { getSettings, ProjectName, saveSettings, Settings } from '../utils/settings';
import { logUserIn } from '../utils/user'; import { logUserIn } from '../utils/user';
import CustomProjectNameList from './CustomProjectNameList';
import SitesList from './SitesList'; import SitesList from './SitesList';
interface State extends Settings { interface State extends Settings {
@@ -19,6 +20,7 @@ export default function Options(): JSX.Element {
allowList: [], allowList: [],
apiKey: '', apiKey: '',
apiUrl: config.apiUrl, apiUrl: config.apiUrl,
customProjectNames: [],
denyList: [], denyList: [],
extensionStatus: 'allGood', extensionStatus: 'allGood',
hostname: '', hostname: '',
@@ -31,37 +33,42 @@ export default function Options(): JSX.Element {
trackSocialMedia: config.trackSocialMedia, trackSocialMedia: config.trackSocialMedia,
}); });
const isApiKeyValid = useMemo(() => apiKeyInvalid(state.apiKey) === '', [state.apiKey]);
const loggingStyleRef = useRef(null); const loggingStyleRef = useRef(null);
const restoreSettings = async (): Promise<void> => { const restoreSettings = useCallback(async () => {
const settings = await getSettings(); const settings = await getSettings();
setState({ setState((oldState) => ({
...state, ...oldState,
...settings, ...settings,
}); }));
}; }, []);
useEffect(() => { useEffect(() => {
void restoreSettings(); void restoreSettings();
}, []); }, [restoreSettings]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (state.loading) return; if (state.loading) return;
setState({ ...state, loading: true }); setState((oldState) => ({ ...oldState, loading: true }));
if (state.apiUrl.endsWith('/')) { if (state.apiUrl.endsWith('/')) {
state.apiUrl = state.apiUrl.slice(0, -1); state.apiUrl = state.apiUrl.slice(0, -1);
} }
await saveSettings({ await saveSettings({
allowList: state.allowList, allowList: state.allowList.filter((item) => !!item.trim()),
apiKey: state.apiKey, apiKey: state.apiKey,
apiUrl: state.apiUrl, apiUrl: state.apiUrl,
denyList: state.denyList, customProjectNames: state.customProjectNames.filter(
(item) => !!item.url.trim() && !!item.projectName.trim(),
),
denyList: state.denyList.filter((item) => !!item.trim()),
extensionStatus: state.extensionStatus, extensionStatus: state.extensionStatus,
hostname: state.hostname, hostname: state.hostname,
loggingEnabled: state.loggingEnabled, loggingEnabled: state.loggingEnabled,
loggingStyle: state.loggingStyle, loggingStyle: state.loggingStyle,
loggingType: state.loggingType, loggingType: state.loggingType,
socialMediaSites: state.socialMediaSites, socialMediaSites: state.socialMediaSites.filter((item) => !!item.trim()),
theme: state.theme, theme: state.theme,
trackSocialMedia: state.trackSocialMedia, trackSocialMedia: state.trackSocialMedia,
}); });
@@ -72,46 +79,56 @@ export default function Options(): JSX.Element {
} }
}; };
const updateDenyListState = (sites: string) => { const updateDenyListState = useCallback((denyList: string[]) => {
setState({ setState((oldState) => ({
...state, ...oldState,
denyList: sites.trim().split('\n'), denyList,
}); }));
}; }, []);
const updateAllowListState = (sites: string) => { const updateAllowListState = useCallback((allowList: string[]) => {
setState({ setState((oldState) => ({
...state, ...oldState,
allowList: sites.trim().split('\n'), allowList,
}); }));
}; }, []);
const updateLoggingStyle = (style: string) => { const updateCustomProjectNamesState = useCallback((customProjectNames: ProjectName[]) => {
setState({ setState((oldState) => ({
...state, ...oldState,
customProjectNames,
}));
}, []);
const updateLoggingStyle = useCallback((style: string) => {
setState((oldState) => ({
...oldState,
loggingStyle: style === 'allow' ? 'allow' : 'deny', loggingStyle: style === 'allow' ? 'allow' : 'deny',
}); }));
}; }, []);
const updateLoggingType = (type: string) => { const updateLoggingType = useCallback((type: string) => {
setState({ setState((oldState) => ({
...state, ...oldState,
loggingType: type === 'url' ? 'url' : 'domain', loggingType: type === 'url' ? 'url' : 'domain',
}); }));
}; }, []);
const updateTheme = (theme: string) => { const updateTheme = useCallback((theme: string) => {
setState({ setState((oldState) => ({
...state, ...oldState,
theme: theme === 'light' ? 'light' : 'dark', theme: theme === 'light' ? 'light' : 'dark',
}); }));
}; }, []);
const toggleSocialMedia = () => { const toggleSocialMedia = useCallback(() => {
setState({ ...state, trackSocialMedia: !state.trackSocialMedia }); setState((oldState) => ({
}; ...oldState,
trackSocialMedia: !oldState.trackSocialMedia,
}));
}, []);
const loggingStyle = function () { const loggingStyle = useCallback(() => {
// TODO: rewrite SitesList to be structured inputs instead of textarea // TODO: rewrite SitesList to be structured inputs instead of textarea
if (state.loggingStyle == 'deny') { if (state.loggingStyle == 'deny') {
@@ -119,7 +136,7 @@ export default function Options(): JSX.Element {
<SitesList <SitesList
handleChange={updateDenyListState} handleChange={updateDenyListState}
label="Exclude" label="Exclude"
sites={state.denyList.join('\n')} sites={state.denyList}
helpText="Sites that you don't want to show in your reports." helpText="Sites that you don't want to show in your reports."
/> />
); );
@@ -128,14 +145,18 @@ export default function Options(): JSX.Element {
<SitesList <SitesList
handleChange={updateAllowListState} handleChange={updateAllowListState}
label="Include" label="Include"
sites={state.allowList.join('\n')} sites={state.allowList}
placeholder="http://google.com&#10;http://myproject.com/MyProject" projectNamePlaceholder="http://google.com&#10;http://myproject.com/MyProject"
helpText="Only track these sites. You can assign URL to project by adding @@YourProject at the end of line." helpText="Only track these sites."
/> />
); );
}; }, [
state.allowList,
const isApiKeyValid = apiKeyInvalid(state.apiKey) === ''; state.denyList,
state.loggingStyle,
updateAllowListState,
updateDenyListState,
]);
return ( return (
<div className="container"> <div className="container">
@@ -168,8 +189,8 @@ export default function Options(): JSX.Element {
value={state.loggingStyle} value={state.loggingStyle}
onChange={(e) => updateLoggingStyle(e.target.value)} onChange={(e) => updateLoggingStyle(e.target.value)}
> >
<option value="denyList">All except excluded sites</option> <option value="deny">All except excluded sites</option>
<option value="allowList">Only allowed sites</option> <option value="allow">Only allowed sites</option>
</select> </select>
</div> </div>
@@ -221,6 +242,13 @@ export default function Options(): JSX.Element {
</span> </span>
</div> </div>
<CustomProjectNameList
sites={state.customProjectNames}
label="Custom Project Names"
handleChange={updateCustomProjectNamesState}
helpText=""
/>
<div className="form-group mb-4"> <div className="form-group mb-4">
<label htmlFor="apiUrl" className="form-label mb-0"> <label htmlFor="apiUrl" className="form-label mb-0">
API Url API Url
@@ -277,16 +305,15 @@ export default function Options(): JSX.Element {
</div> </div>
<div className="modal-body"> <div className="modal-body">
<SitesList <SitesList
handleChange={(sites: string) => { handleChange={(socialMediaSites) => {
setState({ setState((oldState) => ({
...state, ...oldState,
socialMediaSites: sites.split('\n'), socialMediaSites,
}); }));
}} }}
label="Social" label="Social"
sites={state.socialMediaSites.join('\n')} sites={state.socialMediaSites}
helpText="Sites that you don't want to show in your reports." helpText="Sites that you don't want to show in your reports."
rows={5}
/> />
</div> </div>
<div className="modal-footer"> <div className="modal-footer">

View File

@@ -1,47 +1,74 @@
import React from 'react'; import React, { useCallback } from 'react';
type Props = { type Props = {
handleChange: (sites: string) => void; handleChange: (sites: string[]) => void;
helpText: string; helpText: string;
label: string; label: string;
placeholder?: string; projectNamePlaceholder?: string;
rows?: number; sites: string[];
sites: string; urlPlaceholder?: string;
}; };
export default function SitesList({ export default function SitesList({
handleChange, handleChange,
label, label,
placeholder, urlPlaceholder,
rows,
sites, sites,
helpText, helpText,
}: Props): JSX.Element { }: Props): JSX.Element {
const textareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleAddNewSite = useCallback(() => {
handleChange(event.target.value); handleChange([...sites, '']);
}; }, [handleChange, sites]);
const handleUrlChangeForSite = useCallback(
(event: React.ChangeEvent<HTMLInputElement>, index: number) => {
handleChange(sites.map((item, i) => (i === index ? event.target.value : item)));
},
[handleChange, sites],
);
const handleRemoveSite = useCallback(
(index: number) => {
handleChange(sites.filter((_, i) => i !== index));
},
[handleChange, sites],
);
return ( return (
<div className="form-group mb-4"> <div className="form-group mb-4 d-flex flex-column gap-2">
<label htmlFor={`${label}-siteList`} className="col-lg-2 control-label"> <label htmlFor={`${label}-siteList`} className="control-label">
{label} {label}
</label> </label>
<div className="col-lg-10"> {sites.length > 0 && (
<textarea <div className="d-flex flex-column gap-2">
id={`${label}-siteList`} {sites.map((site, i) => (
<div key={i} className="d-flex gap-2">
<div className="flex-fill">
<input
placeholder={urlPlaceholder ?? 'https://google.com'}
className="form-control" className="form-control"
rows={rows ?? 3} value={site}
onChange={textareaChange} onChange={(e) => handleUrlChangeForSite(e, i)}
placeholder={placeholder ?? 'http://google.com'} />
value={sites}
></textarea>
<span className="text-secondary">
{helpText}
<br />
One line per site.
</span>
</div> </div>
<button
type="button"
className="btn btn-sm btn-default"
onClick={() => handleRemoveSite(i)}
>
<i className="fa fa-fw fa-times"></i>
</button>
</div>
))}
</div>
)}
<button type="button" onClick={handleAddNewSite} className="btn btn-default col-12">
<i className="fa fa-fw fa-plus me-2"></i>
Add Site
</button>
<span className="text-secondary">{helpText}</span>
</div> </div>
); );
} }

View File

@@ -21,10 +21,16 @@ export default function WakaTime(): JSX.Element {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
await fetchUserData(apiKeyFromRedux, dispatch); await fetchUserData(apiKeyFromRedux, dispatch);
};
void fetchData();
}, [apiKeyFromRedux, dispatch]);
useEffect(() => {
const init = async () => {
const items = await browser.storage.sync.get({ extensionStatus: '' }); const items = await browser.storage.sync.get({ extensionStatus: '' });
setExtensionStatus(items.extensionStatus as string); setExtensionStatus(items.extensionStatus as string);
}; };
void fetchData(); void init();
}, []); }, []);
const isApiKeyValid = apiKeyInvalid(apiKeyFromRedux) === ''; const isApiKeyValid = apiKeyInvalid(apiKeyFromRedux) === '';

View File

@@ -57,6 +57,7 @@ describe('wakatime config', () => {
"chrome://", "chrome://",
"about:", "about:",
], ],
"queueName": "heartbeatQueue",
"socialMediaSites": [ "socialMediaSites": [
"facebook.com", "facebook.com",
"instagram.com", "instagram.com",

View File

@@ -81,6 +81,14 @@ class WakaTimeCore {
); );
} }
getProjectNameFromList(url: string, settings: Settings) {
const site = settings.customProjectNames.find((pattern) => {
const re = new RegExp(pattern.url);
return re.test(url);
});
return site?.projectName;
}
async handleActivity(tabId: number) { async handleActivity(tabId: number) {
const settings = await getSettings(); const settings = await getSettings();
if (!settings.loggingEnabled) { if (!settings.loggingEnabled) {
@@ -135,14 +143,18 @@ class WakaTimeCore {
).heartbeat; ).heartbeat;
const entity = settings.loggingType === 'domain' ? getDomainFromUrl(url) : url; const entity = settings.loggingType === 'domain' ? getDomainFromUrl(url) : url;
const projectNameFromList = this.getProjectNameFromList(url, settings);
return { return {
branch: heartbeat?.branch ?? '<<LAST_BRANCH>>', branch: heartbeat?.branch ?? '<<LAST_BRANCH>>',
category: heartbeat?.category, category: heartbeat?.category,
entity: heartbeat?.entity ?? entity, entity: heartbeat?.entity ?? entity,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
id: uuid4(), id: uuid4(),
language: heartbeat?.language, language: heartbeat?.language,
plugin: heartbeat?.plugin, plugin: heartbeat?.plugin,
project: heartbeat?.project ?? '<<LAST_PROJECT>>', project: projectNameFromList ?? heartbeat?.project ?? '<<LAST_PROJECT>>',
time: this.getCurrentTime(), time: this.getCurrentTime(),
type: heartbeat?.entityType ?? (settings.loggingType as EntityType), type: heartbeat?.entityType ?? (settings.loggingType as EntityType),
}; };

View File

@@ -1,10 +1,16 @@
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
import config, { ExtensionStatus, LoggingStyle, LoggingType, Theme } from '../config/config'; import config, { ExtensionStatus, LoggingStyle, LoggingType, Theme } from '../config/config';
export interface ProjectName {
projectName: string;
url: string;
}
export interface Settings { export interface Settings {
allowList: string[]; allowList: string[];
apiKey: string; apiKey: string;
apiUrl: string; apiUrl: string;
customProjectNames: ProjectName[];
denyList: string[]; denyList: string[];
extensionStatus: ExtensionStatus; extensionStatus: ExtensionStatus;
hostname: string; hostname: string;
@@ -22,6 +28,7 @@ export const getSettings = async (): Promise<Settings> => {
apiKey: config.apiKey, apiKey: config.apiKey,
apiUrl: config.apiUrl, apiUrl: config.apiUrl,
blacklist: null, blacklist: null,
customProjectNames: [],
denyList: [], denyList: [],
hostname: config.hostname, hostname: config.hostname,
loggingEnabled: config.loggingEnabled, loggingEnabled: config.loggingEnabled,
@@ -48,6 +55,7 @@ export const getSettings = async (): Promise<Settings> => {
await browser.storage.sync.set({ denyList: settings.denyList }); await browser.storage.sync.set({ denyList: settings.denyList });
await browser.storage.sync.remove('blacklist'); await browser.storage.sync.remove('blacklist');
} }
if (typeof settings.socialMediaSites === 'string') { if (typeof settings.socialMediaSites === 'string') {
settings.socialMediaSites = settings.socialMediaSites.trim().split('\n'); settings.socialMediaSites = settings.socialMediaSites.trim().split('\n');
await browser.storage.sync.set({ await browser.storage.sync.set({
@@ -59,6 +67,7 @@ export const getSettings = async (): Promise<Settings> => {
allowList: settings.allowList, allowList: settings.allowList,
apiKey: settings.apiKey, apiKey: settings.apiKey,
apiUrl: settings.apiUrl, apiUrl: settings.apiUrl,
customProjectNames: settings.customProjectNames,
denyList: settings.denyList, denyList: settings.denyList,
extensionStatus: settings.extensionStatus, extensionStatus: settings.extensionStatus,
hostname: settings.hostname, hostname: settings.hostname,

View File

@@ -40,8 +40,6 @@ chrome.runtime.onMessage.addListener(
return; return;
} }
const heartbeat = site.parser(request.url);
sendResponse({ heartbeat: site.parser(request.url) }); sendResponse({ heartbeat: site.parser(request.url) });
} }
}, },