Closes #2413 - Building a chrome extension for twenty to store person/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>
This commit is contained in:
Abdullah
2024-02-12 16:30:23 +05:00
committed by GitHub
parent a15128df36
commit 1265dc74d0
59 changed files with 2103 additions and 10 deletions

View File

@ -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;

View File

@ -0,0 +1,106 @@
import handleQueryParams from '../utils/handleQueryParams';
import requestDb from '../utils/requestDb';
import createNewButton from './createButton';
import extractCompanyLinkedinLink from './utils/extractCompanyLinkedinLink';
import extractDomain from './utils/extractDomain';
function insertButtonForCompany(): void {
// Select the element in which to create the button.
const parentDiv: HTMLDivElement | null = document.querySelector(
'.org-top-card-primary-actions__inner',
);
// Create the button with desired callback funciton to execute upon click.
if (parentDiv) {
const newButtonCompany: HTMLButtonElement = createNewButton(
'Add to Twenty',
async () => {
// Extract company-specific data from the DOM
const companyNameElement = document.querySelector(
'.org-top-card-summary__title',
);
const domainNameElement = document.querySelector(
'.org-top-card-primary-actions__inner a',
);
const addressElement = document.querySelectorAll(
'.org-top-card-summary-info-list__info-item',
)[1];
const employeesNumberElement = document.querySelectorAll(
'.org-top-card-summary-info-list__info-item',
)[3];
// Get the text content or other necessary data from the DOM elements
const companyName = companyNameElement
? companyNameElement.getAttribute('title')
: '';
const domainName = extractDomain(
domainNameElement && domainNameElement.getAttribute('href'),
);
const address = addressElement
? addressElement.textContent?.trim().replace(/\s+/g, ' ')
: '';
const employees = employeesNumberElement
? Number(
employeesNumberElement.textContent
?.trim()
.replace(/\s+/g, ' ')
.split('-')[0],
)
: 0;
// Prepare company data to send to the backend
const companyData = {
name: companyName,
domainName: domainName,
address: address,
employees: employees,
linkedinLink: { url: '', label: '' },
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
const { url: activeTabUrl } = await chrome.runtime.sendMessage({
action: 'getActiveTabUrl',
});
// Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty
const companyURL = extractCompanyLinkedinLink(activeTabUrl);
companyData.linkedinLink = { url: companyURL, label: companyURL };
const query = `mutation CreateOneCompany { createCompany(data:{${handleQueryParams(
companyData,
)}}) {id} }`;
const response = await requestDb(query);
if (response.data) {
newButtonCompany.textContent = 'Saved';
newButtonCompany.setAttribute('disabled', 'true');
// Button specific styles once the button is unclickable after successfully sending data to server.
newButtonCompany.addEventListener('mouseenter', () => {
const hoverStyles = {
backgroundColor: 'black',
borderColor: 'black',
cursor: 'default',
};
Object.assign(newButtonCompany.style, hoverStyles);
});
} else {
newButtonCompany.textContent = 'Try Again';
}
},
);
// Include the button in the DOM.
parentDiv.prepend(newButtonCompany);
// Write button specific styles here - common ones can be found in createButton.ts.
const buttonSpecificStyles = {
alignSelf: 'end',
};
Object.assign(newButtonCompany.style, buttonSpecificStyles);
}
}
export default insertButtonForCompany;

View File

@ -0,0 +1,119 @@
import handleQueryParams from '../utils/handleQueryParams';
import requestDb from '../utils/requestDb';
import createNewButton from './createButton';
import extractFirstAndLastName from './utils/extractFirstAndLastName';
function insertButtonForPerson(): void {
// Select the element in which to create the button.
const parentDiv: HTMLDivElement | null = document.querySelector(
'.pv-top-card-v2-ctas',
);
// Create the button with desired callback funciton to execute upon click.
if (parentDiv) {
const newButtonPerson: HTMLButtonElement = createNewButton(
'Add to Twenty',
async () => {
// Extract person-specific data from the DOM.
const personNameElement = document.querySelector(
'.text-heading-xlarge',
);
const separatorElement = document.querySelector(
'.pv-text-details__separator',
);
const personCityElement = separatorElement?.previousElementSibling;
const profilePictureElement = document.querySelector(
'.pv-top-card-profile-picture__image',
);
const firstListItem = document.querySelector(
'div[data-view-name="profile-component-entity"]',
);
const secondDivElement =
firstListItem?.querySelector('div:nth-child(2)');
const ariaHiddenSpan = secondDivElement?.querySelector(
'span[aria-hidden="true"]',
);
// Get the text content or other necessary data from the DOM elements.
const personName = personNameElement
? personNameElement.textContent
: '';
const personCity = personCityElement
? personCityElement.textContent
?.trim()
.replace(/\s+/g, ' ')
.split(',')[0]
: '';
const profilePicture = profilePictureElement
? profilePictureElement?.getAttribute('src')
: '';
const jobTitle = ariaHiddenSpan
? ariaHiddenSpan.textContent?.trim()
: '';
const { firstName, lastName } = extractFirstAndLastName(
String(personName),
);
// Prepare person data to send to the backend.
const personData = {
name: { firstName, lastName },
city: personCity,
avatarUrl: profilePicture,
jobTitle,
linkedinLink: { url: '', label: '' },
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
let { url: activeTabUrl } = await chrome.runtime.sendMessage({
action: 'getActiveTabUrl',
});
// Remove last slash from the URL for consistency when saving usernames.
if (activeTabUrl.endsWith('/')) {
activeTabUrl = activeTabUrl.slice(0, -1);
}
personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };
const query = `mutation CreateOnePerson { createPerson(data:{${handleQueryParams(
personData,
)}}) {id} }`;
const response = await requestDb(query);
if (response.data) {
newButtonPerson.textContent = 'Saved';
newButtonPerson.setAttribute('disabled', 'true');
// Button specific styles once the button is unclickable after successfully sending data to server.
newButtonPerson.addEventListener('mouseenter', () => {
const hoverStyles = {
backgroundColor: 'black',
borderColor: 'black',
cursor: 'default',
};
Object.assign(newButtonPerson.style, hoverStyles);
});
} else {
newButtonPerson.textContent = 'Try Again';
}
},
);
// Include the button in the DOM.
parentDiv.prepend(newButtonPerson);
// Write button specific styles here - common ones can be found in createButton.ts.
const buttonSpecificStyles = {
marginRight: '0.5em',
};
Object.assign(newButtonPerson.style, buttonSpecificStyles);
}
}
export default insertButtonForPerson;

View File

@ -0,0 +1,20 @@
import insertButtonForPerson from './extractPersonProfile';
import insertButtonForCompany from './extractCompanyProfile';
// Inject buttons into the DOM when SPA is reloaded on the resource url.
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
insertButtonForCompany();
insertButtonForPerson();
// The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/.
// However, there would never be another reload in a single page application unless triggered manually.
// Therefore, if the user navigates to a person or a company page, we must manually re-execute the content script to create the "Add to Twenty" button.
// e.g. create "Add to Twenty" button when a user navigates to https://www.linkedin.com/in/mabdullahabaid/ from https://www.linkedin.com/feed/
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
if (message.action === 'executeContentScript') {
insertButtonForCompany();
insertButtonForPerson();
}
sendResponse('Executing!');
});

View File

@ -0,0 +1,19 @@
// Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page.
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
const extractCompanyLinkedinLink = (activeTabUrl: string) => {
// Regular expression to match the company ID
const regex = /\/company\/([^/]*)/;
// Extract the company ID using the regex
const match = activeTabUrl.match(regex);
if (match && match[1]) {
const companyID = match[1];
const cleanCompanyURL = `https://www.linkedin.com/company/${companyID}`;
return cleanCompanyURL;
}
return '';
};
export default extractCompanyLinkedinLink;

View File

@ -0,0 +1,15 @@
function extractDomain(url: string | null) {
if (!url) return '';
const hostname = new URL(url).hostname;
let domain = hostname.replace('www.', '');
const parts = domain.split('.');
if (parts.length > 2) {
domain = parts.slice(1).join('.');
}
return domain;
}
export default extractDomain;

View File

@ -0,0 +1,9 @@
// Separate first name and last name from a full name.
const extractFirstAndLastName = (fullName: string) => {
const spaceIndex = fullName.lastIndexOf(' ');
const firstName = fullName.substring(0, spaceIndex);
const lastName = fullName.substring(spaceIndex + 1);
return { firstName, lastName };
};
export default extractFirstAndLastName;