Skip to content

Commit

Permalink
Closes #2413 - Building a chrome extension for twenty to store person…
Browse files Browse the repository at this point in the history
…/company data into a workspace. (#3430)

* build: create a new vite project for chrome extension

* feat: configure theme per the frontend codebase for chrome extension

* feat: inject the add to twenty button into linkedin profile page

* feat: create the api key form ui and render it on the options page

* feat: inject the add to twenty button into linkedin company page

* feat: scrape required data from both the user profile and the company profile

* refactor: move modules into options because it is the only page using react for now

* fix: show add to twenty button without having to reload the single page application

* fix: extract domain of the business website instead of scrapping the industry type

* feat: store api key to local storage and open options page when trying to store data without setting a key

* feat: send data to the backend upon click and store it to the database

* fix: open options page upon clicking the extension icon

* fix: update terminology from user to person to match the codebase convention

* fix: adopt chrome extension to monorepo approach using nx and get the development server working

* fix: update vite config for build command to work per the requirement

* feat: add instructions in the readme file to install the extension for local testing

* fix: move server base url to a dotenv file and replace the hard-coded url

* feat: permit user to configure a custom route for the server from the options page

* fix: fetch api key and route from local storage and display on options page to inform users of their choices

* fix: move front base url to dotenv and replace the hard-coded url

* fix: remove the trailing slash from person and company linkedin username

* fix: improve code commenting to explain implementation somewhat better

* ci: introduce a workflow to build chrome extension to ensure it can be published

* fix: format files to display code in a consistent manner per the prettier configuration in codebase

* fix: improve the commenting significantly to explain important and hard-to-understand parts of the code

* fix: remove unused permissions from the manifest file for publishing to the chrome web store

* Add nx

* Fix vale

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
  • Loading branch information
mabdullahabaid and charlesBochet authored Feb 12, 2024
1 parent a15128d commit 1265dc7
Show file tree
Hide file tree
Showing 59 changed files with 2,103 additions and 10 deletions.
66 changes: 66 additions & 0 deletions .github/workflows/ci-chrome-extension.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: CI Chrome Extension
on:
push:
branches:
- main
pull_request:
jobs:
chrome-extension-yarn-install:
runs-on: ci-8-cores
env:
VITE_SERVER_BASE_URL: http://localhost:3000
VITE_FRONT_BASE_URL: http://localhost:3001
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Cache chrome extension node modules
uses: actions/cache@v3
with:
path: packages/twenty-chrome-extension/node_modules
key: chrome-extension-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: chrome-extension-node_modules-
- name: Cache root node modules
uses: actions/cache@v3
with:
path: node_modules
key: root-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: root-node_modules-
- name: Chrome Extension / Install Dependencies
run: yarn
chrome-extension-build:
needs: chrome-extension-yarn-install
runs-on: ubuntu-latest
env:
VITE_SERVER_BASE_URL: http://localhost:3000
VITE_FRONT_BASE_URL: http://localhost:3001
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Cache chrome extension node modules
uses: actions/cache@v3
with:
path: packages/twenty-chrome-extension/node_modules
key: chrome-extension-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: chrome-extension-node_modules-
- name: Cache root node modules
uses: actions/cache@v3
with:
path: node_modules
key: root-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: root-node_modules-
- name: Chrome Extension / Run build
run: yarn nx build twenty-chrome-extension
4 changes: 4 additions & 0 deletions .vscode/twenty.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"name": "ROOT",
"path": "../"
},
{
"name": "packages/twenty-chrome-extension",
"path": "../packages/twenty-chrome-extension"
},
{
"name": "packages/twenty-docker",
"path": "../packages/twenty-docker"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@
"version": "0.2.1",
"workspaces": {
"packages": [
"packages/twenty-chrome-extension",
"packages/twenty-front",
"packages/twenty-docs",
"packages/twenty-server",
Expand Down
2 changes: 2 additions & 0 deletions packages/twenty-chrome-extension/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_SERVER_BASE_URL=http://localhost:3000
VITE_FRONT_BASE_URL=http://localhost:3001
18 changes: 18 additions & 0 deletions packages/twenty-chrome-extension/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
24 changes: 24 additions & 0 deletions packages/twenty-chrome-extension/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
51 changes: 51 additions & 0 deletions packages/twenty-chrome-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Twenty Chrome Extension.

This extension allows you to save `company` and `people` information to your twenty workspace directly from LinkedIn.

To install the extension in development mode with hmr (hot module reload), follow these steps.

- STEP 1: Clone the repository and run `yarn install` in the root directory.
- STEP 2: Once the dependencies installation succeeds, create a file with env variables by executing the following command in the root directory.

```
cp ./packages/twenty-chrome-extension/.env.example ./packages/twenty-chrome-extension/.env
```

- STEP 3: Now, execute the following command in the root directory to start up the development server on Port 3002. This will create a `dist` folder in `twenty-chrome-extension`.

```
yarn nx start twenty-chrome-extension
```

- STEP 4: Open Google Chrome and head to the extensions page by typing `chrome://extensions` in the address bar.

<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/01-img-one.png" width="600" />
</p>

- STEP 5: Turn on the `Developer mode` from the top-right corner and click `Load unpacked`.

<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/02-img-two.png" width="600" />
</p>

- STEP 6: Select the `dist` folder from `twenty-chrome-extension`.

<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/03-img-three.png" width="600" />
</p>

- STEP 7: This opens up the `options` page, where you must enter your API key.

<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/04-img-four.png" width="600" />
</p>

- STEP 8: Reload any LinkedIn page that you opened before installing the extension for seamless experience.
- STEP 9: Visit any individual or company profile on LinkedIn and click the `Add to Twenty` button to test.

<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/05-img-five.png" width="600" />
</p>

To install the extension in production mode without hmr (hot module reload), replace the command in STEP THREE with `yarn nx build twenty-chrome-extension`.
12 changes: 12 additions & 0 deletions packages/twenty-chrome-extension/options.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twenty</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/index.tsx"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions packages/twenty-chrome-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "twenty-chrome-extension",
"description": "",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-chrome-extension node ../../node_modules/nx/bin/nx.js",
"start": "vite",
"build": "tsc && vite build"
},
"dependencies": {
"@types/chrome": "^0.0.256"
},
"devDependencies": {
"@crxjs/vite-plugin": "^1.0.14"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/twenty-chrome-extension/public/vite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 62 additions & 0 deletions packages/twenty-chrome-extension/src/background/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { openOptionsPage } from './utils/openOptionsPage';

console.log('Background Script Works');

// Open options page programmatically in a new tab.
chrome.runtime.onInstalled.addListener(function (details) {
if (details.reason === 'install') {
openOptionsPage();
}
});

// Open options page when extension icon is clicked.
chrome.action.onClicked.addListener(function () {
openOptionsPage();
});

// This listens for an event from other parts of the extension, such as the content script, and performs the required tasks.
// The cases themselves are labelled such that their operations are reflected by their names.
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
switch (message.action) {
case 'getActiveTabUrl': // e.g. "https://linkedin.com/company/twenty/"
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs && tabs[0]) {
const activeTabUrl: string | undefined = tabs[0].url;
sendResponse({ url: activeTabUrl });
}
});
break;
case 'openOptionsPage':
openOptionsPage();
break;
default:
break;
}

return true;
});

// Keep track of the tabs in which the "Add to Twenty" button has already been injected.
// Could be that the content script is executed at "https://linkedin.com/feed/", but is needed at "https://linkedin.com/in/mabdullahabaid/".
// However, since Linkedin is a SPA, the script would not be re-executed when you navigate to "https://linkedin.com/in/mabdullahabaid/" from a user action.
// Therefore, this tracks if the user is on desired route and then re-executes the content script to create the "Add to Twenty" button.
// We use a "Set" to keep track of tab ids because it could be that the "Add to Twenty" button was created at "https://linkedin/com/company/twenty".
// However, when we change to about on the company page, the url becomes "https://www.linkedin.com/company/twenty/about/" and the button is created again.
// This creates a duplicate button, which we want to avoid. So, we instruct the extension to only create the button once for any of the following urls.
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
const injectedTabs: Set<number> = new Set();

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
const isDesiredRoute =
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/);

if (changeInfo.status === 'complete' && tab.active) {
if (isDesiredRoute && !injectedTabs.has(tabId)) {
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
injectedTabs.add(tabId);
} else if (!isDesiredRoute) {
injectedTabs.delete(tabId); // Clear entry if navigated away from LinkedIn company page.
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const openOptionsPage = () => {
chrome.runtime.openOptionsPage();
};

export { openOptionsPage };
57 changes: 57 additions & 0 deletions packages/twenty-chrome-extension/src/contentScript/createButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
function createNewButton(
text: string,
onClickHandler: () => void,
): HTMLButtonElement {
const newButton: HTMLButtonElement = document.createElement('button');
newButton.textContent = text;

// Write universal styles for the button
const buttonStyles = {
border: '1px solid black',
borderRadius: '20px',
backgroundColor: 'black',
color: 'white',
fontSize: '1.5rem',
fontWeight: '600',
padding: '0.45em 1em',
width: '15rem',
height: '32px',
};

// Apply common styles to the button.
Object.assign(newButton.style, buttonStyles);

// Apply common styles to specifc states of a button.
newButton.addEventListener('mouseenter', () => {
const hoverStyles = {
backgroundColor: '#5e5e5e',
borderColor: '#5e5e5e',
};
Object.assign(newButton.style, hoverStyles);
});

newButton.addEventListener('mouseleave', () => {
Object.assign(newButton.style, buttonStyles);
});

// Handle the click event.
newButton.addEventListener('click', async () => {
const { apiKey } = await chrome.storage.local.get('apiKey');

// If an api key is not set, the options page opens up to allow the user to configure an api key.
if (!apiKey) {
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
return;
}

// Update content during the resolution of the request.
newButton.textContent = 'Saving...';

// Call the provided onClickHandler function to handle button click logic
onClickHandler();
});

return newButton;
}

export default createNewButton;
Loading

0 comments on commit 1265dc7

Please sign in to comment.