fix: "Add to Twenty" button render fix (chrome-extension) (#5048)

fix - #5047
This commit is contained in:
Aditya Pimpalkar
2024-04-19 17:13:53 +01:00
committed by GitHub
parent d145684966
commit 14f97e2e80
13 changed files with 318 additions and 308 deletions

View File

@ -1,4 +1,5 @@
import { openOptionsPage } from '~/background/utils/openOptionsPage'; import { openOptionsPage } from '~/background/utils/openOptionsPage';
import { isDefined } from '~/utils/isDefined';
// Open options page programmatically in a new tab. // Open options page programmatically in a new tab.
chrome.runtime.onInstalled.addListener((details) => { chrome.runtime.onInstalled.addListener((details) => {
@ -16,11 +17,10 @@ chrome.action.onClicked.addListener((tab) => {
// The cases themselves are labelled such that their operations are reflected by their names. // The cases themselves are labelled such that their operations are reflected by their names.
chrome.runtime.onMessage.addListener((message, _, sendResponse) => { chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
switch (message.action) { switch (message.action) {
case 'getActiveTabUrl': // e.g. "https://linkedin.com/company/twenty/" case 'getActiveTab': // e.g. "https://linkedin.com/company/twenty/"
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs && tabs[0]) { if (isDefined(tabs) && isDefined(tabs[0])) {
const activeTabUrl: string | undefined = tabs[0].url; sendResponse({ tab: tabs[0] });
sendResponse({ url: activeTabUrl });
} }
}); });
break; break;
@ -34,27 +34,14 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
return true; 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) => { chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
const isDesiredRoute = const isDesiredRoute =
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) || tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/); tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/);
if (changeInfo.status === 'complete' && tab.active) { if (changeInfo.status === 'complete' && tab.active) {
if (isDesiredRoute && !injectedTabs.has(tabId)) { if (isDefined(isDesiredRoute)) {
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' }); chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
injectedTabs.add(tabId);
} else if (!isDesiredRoute) {
injectedTabs.delete(tabId); // Clear entry if navigated away from LinkedIn company page.
} }
} }
}); });

View File

@ -1,12 +1,17 @@
const createNewButton = ( import { isDefined } from '~/utils/isDefined';
text: string,
onClickHandler: () => void, export const createDefaultButton = (
): HTMLDivElement => { buttonId: string,
onClickHandler?: () => void,
buttonText = '',
) => {
const btn = document.getElementById(buttonId);
if (isDefined(btn)) return btn;
const div = document.createElement('div'); const div = document.createElement('div');
const img = document.createElement('img'); const img = document.createElement('img');
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = text; span.textContent = buttonText;
img.src = img.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII='; 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=';
img.height = 16; img.height = 16;
@ -32,43 +37,38 @@ const createNewButton = (
Object.assign(div.style, divStyles); Object.assign(div.style, divStyles);
// // Apply common styles to the button. // Apply common styles to specifc states of a button.
// Object.assign(buttonDiv.style, buttonDivStyles); div.addEventListener('mouseenter', () => {
const hoverStyles = {
//eslint-disable-next-line @nx/workspace-no-hardcoded-colors
backgroundColor: '#5e5e5e',
//eslint-disable-next-line @nx/workspace-no-hardcoded-colors
borderColor: '#5e5e5e',
};
Object.assign(div.style, hoverStyles);
});
// // Apply common styles to specifc states of a button. div.addEventListener('mouseleave', () => {
// newButton.addEventListener('mouseenter', () => { Object.assign(div.style, divStyles);
// const hoverStyles = { });
// backgroundColor: '#5e5e5e',
// borderColor: '#5e5e5e',
// };
// Object.assign(newButton.style, hoverStyles);
// });
// newButton.addEventListener('mouseleave', () => { div.addEventListener('click', async (e) => {
// Object.assign(newButton.style, buttonStyles); e.preventDefault();
// }); const store = await chrome.storage.local.get();
// Handle the click event.
div.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 an api key is not set, the options page opens up to allow the user to configure an api key.
if (!apiKey) { if (!store.apiKey) {
chrome.runtime.sendMessage({ action: 'openOptionsPage' }); chrome.runtime.sendMessage({ action: 'openOptionsPage' });
return; return;
} }
// Update content during the resolution of the request. onClickHandler?.();
span.textContent = 'Saving...';
// Call the provided onClickHandler function to handle button click logic
onClickHandler();
}); });
div.id = buttonId;
div.appendChild(img); div.appendChild(img);
div.appendChild(span); div.appendChild(span);
return div; return div;
}; };
export default createNewButton;

View File

@ -1,123 +1,117 @@
import createNewButton from '~/contentScript/createButton'; import { createDefaultButton } from '~/contentScript/createButton';
import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink'; import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink';
import extractDomain from '~/contentScript/utils/extractDomain'; import extractDomain from '~/contentScript/utils/extractDomain';
import { createCompany, fetchCompany } from '~/db/company.db'; import { createCompany, fetchCompany } from '~/db/company.db';
import { CompanyInput } from '~/db/types/company.types'; import { CompanyInput } from '~/db/types/company.types';
import { isDefined } from '~/utils/isDefined';
export const checkIfCompanyExists = async () => {
const { tab: activeTab } = await chrome.runtime.sendMessage({
action: 'getActiveTab',
});
const companyURL = extractCompanyLinkedinLink(activeTab.url);
return await fetchCompany({
linkedinLink: {
url: { eq: companyURL },
label: { eq: companyURL },
},
});
};
export const addCompany = 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 companyInputData: CompanyInput = {
name: companyName ?? '',
domainName: domainName,
address: address ?? '',
employees: employees,
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
const { tab: activeTab } = await chrome.runtime.sendMessage({
action: 'getActiveTab',
});
// Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty
const companyURL = extractCompanyLinkedinLink(activeTab.url);
companyInputData.linkedinLink = { url: companyURL, label: companyURL };
const company = await createCompany(companyInputData);
return company;
};
export const insertButtonForCompany = async () => {
const companyButtonDiv = createDefaultButton(
'twenty-company-btn',
async () => {
if (isDefined(companyButtonDiv)) {
const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0];
companyBtnSpan.textContent = 'Saving...';
const company = await addCompany();
if (isDefined(company)) {
companyBtnSpan.textContent = 'Saved';
Object.assign(companyButtonDiv.style, { pointerEvents: 'none' });
} else {
companyBtnSpan.textContent = 'Try again';
}
}
},
);
const insertButtonForCompany = async (): Promise<void> => {
// Select the element in which to create the button.
const parentDiv: HTMLDivElement | null = document.querySelector( const parentDiv: HTMLDivElement | null = document.querySelector(
'.org-top-card-primary-actions__inner', '.org-top-card-primary-actions__inner',
); );
// Create the button with desired callback funciton to execute upon click. if (isDefined(parentDiv)) {
if (parentDiv) { Object.assign(companyButtonDiv.style, {
// Extract company-specific data from the DOM marginLeft: '.8rem',
const companyNameElement = document.querySelector( marginTop: '.4rem',
'.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 companyInputData: CompanyInput = {
name: companyName ?? '',
domainName: domainName,
address: address ?? '',
employees: employees,
};
// 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',
}); });
parentDiv.prepend(companyButtonDiv);
}
// Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0];
const companyURL = extractCompanyLinkedinLink(activeTabUrl); const company = await checkIfCompanyExists();
companyInputData.linkedinLink = { url: companyURL, label: companyURL };
const company = await fetchCompany({ if (isDefined(company)) {
linkedinLink: { companyBtnSpan.textContent = 'Saved';
url: { eq: companyURL }, Object.assign(companyButtonDiv.style, { pointerEvents: 'none' });
label: { eq: companyURL }, } else {
}, companyBtnSpan.textContent = 'Add to Twenty';
});
if (company) {
const savedCompany: HTMLDivElement = createNewButton(
'Saved',
async () => {},
);
// Include the button in the DOM.
parentDiv.prepend(savedCompany);
// Write button specific styles here - common ones can be found in createButton.ts.
const buttonSpecificStyles = {
alignSelf: 'end',
};
Object.assign(savedCompany.style, buttonSpecificStyles);
} else {
const newButtonCompany: HTMLDivElement = createNewButton(
'Add to Twenty',
async () => {
const response = await createCompany(companyInputData);
if (response) {
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

@ -1,127 +1,125 @@
import createNewButton from '~/contentScript/createButton'; import { createDefaultButton } from '~/contentScript/createButton';
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName'; import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
import { createPerson, fetchPerson } from '~/db/person.db'; import { createPerson, fetchPerson } from '~/db/person.db';
import { PersonInput } from '~/db/types/person.types'; import { PersonInput } from '~/db/types/person.types';
import { isDefined } from '~/utils/isDefined';
const insertButtonForPerson = async (): Promise<void> => { export const checkIfPersonExists = async () => {
// Select the element in which to create the button. const { tab: activeTab } = await chrome.runtime.sendMessage({
const parentDiv: HTMLDivElement | null = document.querySelector( action: 'getActiveTab',
'.pv-top-card-v2-ctas', });
let activeTabUrl = '';
if (isDefined(activeTab.url.endsWith('/'))) {
activeTabUrl = activeTab.url.slice(0, -1);
}
const personNameElement = document.querySelector('.text-heading-xlarge');
const personName = personNameElement ? personNameElement.textContent : '';
const { firstName, lastName } = extractFirstAndLastName(String(personName));
const person = await fetchPerson({
name: {
firstName: { eq: firstName },
lastName: { eq: lastName },
},
linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } },
});
return person;
};
export const addPerson = async () => {
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',
); );
// Create the button with desired callback funciton to execute upon click. const firstListItem = document.querySelector(
if (parentDiv) { 'div[data-view-name="profile-component-entity"]',
// Extract person-specific data from the DOM. );
const personNameElement = document.querySelector('.text-heading-xlarge'); const secondDivElement = firstListItem?.querySelector('div:nth-child(2)');
const ariaHiddenSpan = secondDivElement?.querySelector(
'span[aria-hidden="true"]',
);
const separatorElement = document.querySelector( // Get the text content or other necessary data from the DOM elements.
'.pv-text-details__separator', const personName = personNameElement ? personNameElement.textContent : '';
); const personCity = personCityElement
const personCityElement = separatorElement?.previousElementSibling; ? personCityElement.textContent?.trim().replace(/\s+/g, ' ').split(',')[0]
: '';
const profilePicture = profilePictureElement
? profilePictureElement?.getAttribute('src')
: '';
const jobTitle = ariaHiddenSpan ? ariaHiddenSpan.textContent?.trim() : '';
const profilePictureElement = document.querySelector( const { firstName, lastName } = extractFirstAndLastName(String(personName));
'.pv-top-card-profile-picture__image',
// Prepare person data to send to the backend.
const personData: PersonInput = {
name: { firstName, lastName },
city: personCity ?? '',
avatarUrl: profilePicture ?? '',
jobTitle: jobTitle ?? '',
linkedinLink: { url: '', label: '' },
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
const { tab: activeTab } = await chrome.runtime.sendMessage({
action: 'getActiveTab',
});
let activeTabUrl = '';
// Remove last slash from the URL for consistency when saving usernames.
if (isDefined(activeTab.url.endsWith('/'))) {
activeTabUrl = activeTab.url.slice(0, -1);
}
personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };
const person = await createPerson(personData);
return person;
};
export const insertButtonForPerson = async () => {
const personButtonDiv = createDefaultButton('twenty-person-btn', async () => {
if (isDefined(personButtonDiv)) {
const personBtnSpan = personButtonDiv.getElementsByTagName('span')[0];
personBtnSpan.textContent = 'Saving...';
const person = await addPerson();
if (isDefined(person)) {
personBtnSpan.textContent = 'Saved';
Object.assign(personButtonDiv.style, { pointerEvents: 'none' });
} else {
personBtnSpan.textContent = 'Try again';
}
}
});
if (isDefined(personButtonDiv)) {
const parentDiv: HTMLDivElement | null = document.querySelector(
'.pv-top-card-v2-ctas',
); );
const firstListItem = document.querySelector( if (isDefined(parentDiv)) {
'div[data-view-name="profile-component-entity"]', Object.assign(personButtonDiv.style, {
); marginRight: '.8rem',
const secondDivElement = firstListItem?.querySelector('div:nth-child(2)'); });
const ariaHiddenSpan = secondDivElement?.querySelector( parentDiv.prepend(personButtonDiv);
'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: PersonInput = {
name: { firstName, lastName },
city: personCity ?? '',
avatarUrl: profilePicture ?? '',
jobTitle: 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 personBtnSpan = personButtonDiv.getElementsByTagName('span')[0];
const person = await checkIfPersonExists();
const person = await fetchPerson({ if (isDefined(person)) {
name: { personBtnSpan.textContent = 'Saved';
firstName: { eq: firstName }, Object.assign(personButtonDiv.style, { pointerEvents: 'none' });
lastName: { eq: lastName },
},
linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } },
});
if (person) {
const savedPerson: HTMLDivElement = createNewButton(
'Saved',
async () => {},
);
// Include the button in the DOM.
parentDiv.prepend(savedPerson);
// Write button specific styles here - common ones can be found in createButton.ts.
const buttonSpecificStyles = {
marginRight: '0.5em',
};
Object.assign(savedPerson.style, buttonSpecificStyles);
} else { } else {
const newButtonPerson: HTMLDivElement = createNewButton( personBtnSpan.textContent = 'Add to Twenty';
'Add to Twenty',
async () => {
const response = await createPerson(personData);
if (response) {
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

@ -1,10 +1,13 @@
import insertButtonForCompany from '~/contentScript/extractCompanyProfile'; import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
import insertButtonForPerson from '~/contentScript/extractPersonProfile'; import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
// Inject buttons into the DOM when SPA is reloaded on the resource url. // 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/ // e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
await insertButtonForCompany(); // await insertButtonForCompany();
await insertButtonForPerson(); (async () => {
await insertButtonForCompany();
await insertButtonForPerson();
})();
// The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/. // 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. // However, there would never be another reload in a single page application unless triggered manually.

View File

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

View File

@ -6,33 +6,42 @@ import {
import { Company, CompanyFilterInput } from '~/generated/graphql'; import { Company, CompanyFilterInput } from '~/generated/graphql';
import { CREATE_COMPANY } from '~/graphql/company/mutations'; import { CREATE_COMPANY } from '~/graphql/company/mutations';
import { FIND_COMPANY } from '~/graphql/company/queries'; import { FIND_COMPANY } from '~/graphql/company/queries';
import { isDefined } from '~/utils/isDefined';
import { callMutation, callQuery } from '../utils/requestDb'; import { callMutation, callQuery } from '../utils/requestDb';
export const fetchCompany = async ( export const fetchCompany = async (
companyfilerInput: CompanyFilterInput, companyfilerInput: CompanyFilterInput,
): Promise<Company | null> => { ): Promise<Company | null> => {
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, { try {
filter: { const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
...companyfilerInput, filter: {
}, ...companyfilerInput,
}); },
if (data?.companies.edges) { });
return data?.companies.edges.length > 0 if (isDefined(data?.companies.edges)) {
? data?.companies.edges[0].node return data?.companies.edges.length > 0
: null; ? data?.companies.edges[0].node
: null;
}
return null;
} catch (error) {
return null;
} }
return null;
}; };
export const createCompany = async ( export const createCompany = async (
company: CompanyInput, company: CompanyInput,
): Promise<string | null> => { ): Promise<string | null> => {
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, { try {
input: company, const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
}); input: company,
if (data) { });
return data.createCompany.id; if (isDefined(data)) {
return data.createCompany.id;
}
return null;
} catch (error) {
return null;
} }
return null;
}; };

View File

@ -6,31 +6,40 @@ import {
import { Person, PersonFilterInput } from '~/generated/graphql'; import { Person, PersonFilterInput } from '~/generated/graphql';
import { CREATE_PERSON } from '~/graphql/person/mutations'; import { CREATE_PERSON } from '~/graphql/person/mutations';
import { FIND_PERSON } from '~/graphql/person/queries'; import { FIND_PERSON } from '~/graphql/person/queries';
import { isDefined } from '~/utils/isDefined';
import { callMutation, callQuery } from '../utils/requestDb'; import { callMutation, callQuery } from '../utils/requestDb';
export const fetchPerson = async ( export const fetchPerson = async (
personFilterData: PersonFilterInput, personFilterData: PersonFilterInput,
): Promise<Person | null> => { ): Promise<Person | null> => {
const data = await callQuery<FindPersonResponse>(FIND_PERSON, { try {
filter: { const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
...personFilterData, filter: {
}, ...personFilterData,
}); },
if (data?.people.edges) { });
return data?.people.edges.length > 0 ? data?.people.edges[0].node : null; if (isDefined(data?.people.edges)) {
return data?.people.edges.length > 0 ? data?.people.edges[0].node : null;
}
return null;
} catch (error) {
return null;
} }
return null;
}; };
export const createPerson = async ( export const createPerson = async (
person: PersonInput, person: PersonInput,
): Promise<string | null> => { ): Promise<string | null> => {
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, { try {
input: person, const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
}); input: person,
if (data?.createPerson) { });
return data.createPerson.id; if (isDefined(data?.createPerson)) {
return data.createPerson.id;
}
return null;
} catch (error) {
return null;
} }
return null;
}; };

View File

@ -5,6 +5,7 @@ import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/Button'; import { Button } from '@/ui/input/button/Button';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { Toggle } from '@/ui/input/components/Toggle'; import { Toggle } from '@/ui/input/components/Toggle';
import { isDefined } from '~/utils/isDefined';
const StyledContainer = styled.div<{ isToggleOn: boolean }>` const StyledContainer = styled.div<{ isToggleOn: boolean }>`
width: 400px; width: 400px;
@ -71,11 +72,11 @@ export const ApiKeyForm = () => {
const getState = async () => { const getState = async () => {
const localStorage = await chrome.storage.local.get(); const localStorage = await chrome.storage.local.get();
if (localStorage.apiKey) { if (isDefined(localStorage.apiKey)) {
setApiKey(localStorage.apiKey); setApiKey(localStorage.apiKey);
} }
if (localStorage.serverBaseUrl) { if (isDefined(localStorage.serverBaseUrl)) {
setShowSection(true); setShowSection(true);
setRoute(localStorage.serverBaseUrl); setRoute(localStorage.serverBaseUrl);
} }

View File

@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { isDefined } from '~/utils/isDefined';
export type ToggleSize = 'small' | 'medium'; export type ToggleSize = 'small' | 'medium';
type ContainerProps = { type ContainerProps = {
@ -54,7 +56,7 @@ export const Toggle = ({
const handleChange = () => { const handleChange = () => {
setIsOn(!isOn); setIsOn(!isOn);
if (onChange) { if (isDefined(onChange)) {
onChange(!isOn); onChange(!isOn);
} }
}; };

View File

@ -14,7 +14,7 @@ const handleQueryParams = (inputData: { [x: string]: unknown }): string => {
result = result.concat(`${key}: ${quote}${inputData[key]}${quote}, `); result = result.concat(`${key}: ${quote}${inputData[key]}${quote}, `);
} }
}); });
if (result.length) result = result.slice(0, -2); // Remove the last ', ' if (result.length > 0) result = result.slice(0, -2); // Remove the last ', '
return result; return result;
}; };

View File

@ -0,0 +1,4 @@
import { isNull, isUndefined } from '@sniptt/guards';
export const isDefined = <T>(value: T | null | undefined): value is T =>
!isUndefined(value) && !isNull(value);

View File

@ -8,7 +8,7 @@
"**/*.test.ts", "**/*.test.ts",
"**/*.spec.tsx", "**/*.spec.tsx",
"**/*.test.tsx", "**/*.test.tsx",
"jest.config.ts", "jest.config.ts"
], ],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
} }