feat: check if company/person saved (chrome-extension) (#4280)
* add twenty icon * rest api calls for company * check if company exists * refacto * person/company saved call * gql codegen init * type defs * build fix * DB calls with gql codegen and apollo integration
This commit is contained in:
@ -10,7 +10,7 @@ module.exports = {
|
|||||||
'../../.eslintrc.js',
|
'../../.eslintrc.js',
|
||||||
],
|
],
|
||||||
plugins: ['react-hooks', 'react-refresh'],
|
plugins: ['react-hooks', 'react-refresh'],
|
||||||
ignorePatterns: ['!**/*', 'node_modules', 'dist'],
|
ignorePatterns: ['!**/*', 'node_modules', 'dist', 'src/generated/*.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
'@nx/workspace-effect-components': 'error',
|
'@nx/workspace-effect-components': 'error',
|
||||||
'@nx/workspace-no-hardcoded-colors': 'error',
|
'@nx/workspace-no-hardcoded-colors': 'error',
|
||||||
|
|||||||
24
packages/twenty-chrome-extension/codegen.ts
Normal file
24
packages/twenty-chrome-extension/codegen.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { CodegenConfig } from '@graphql-codegen/cli';
|
||||||
|
|
||||||
|
const config: CodegenConfig = {
|
||||||
|
schema: ['http://localhost:3000/graphql'],
|
||||||
|
overwrite: true,
|
||||||
|
documents: ['./src/**/*.ts', '!src/generated/**/*.*'],
|
||||||
|
generates: {
|
||||||
|
'./src/generated/graphql.tsx': {
|
||||||
|
plugins: [
|
||||||
|
'typescript',
|
||||||
|
'typescript-operations',
|
||||||
|
'typescript-react-apollo',
|
||||||
|
],
|
||||||
|
config: {
|
||||||
|
skipTypename: true,
|
||||||
|
withHooks: true,
|
||||||
|
withHOC: false,
|
||||||
|
withComponent: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@ -10,6 +10,7 @@
|
|||||||
"start": "yarn clean && vite",
|
"start": "yarn clean && vite",
|
||||||
"build": "yarn clean && tsc && vite build",
|
"build": "yarn clean && tsc && vite build",
|
||||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
|
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
|
||||||
|
"graphql:generate": "graphql-codegen",
|
||||||
"fmt": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
"fmt": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
||||||
"fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
|
"fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,42 +1,55 @@
|
|||||||
/* eslint-disable @nx/workspace-no-hardcoded-colors */
|
|
||||||
const createNewButton = (
|
const createNewButton = (
|
||||||
text: string,
|
text: string,
|
||||||
onClickHandler: () => void,
|
onClickHandler: () => void,
|
||||||
): HTMLButtonElement => {
|
): HTMLDivElement => {
|
||||||
const newButton: HTMLButtonElement = document.createElement('button');
|
const div = document.createElement('div');
|
||||||
newButton.textContent = text;
|
const img = document.createElement('img');
|
||||||
|
const span = document.createElement('span');
|
||||||
|
|
||||||
|
span.textContent = text;
|
||||||
|
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=';
|
||||||
|
img.height = 16;
|
||||||
|
img.width = 16;
|
||||||
|
img.alt = 'Twenty logo';
|
||||||
|
|
||||||
// Write universal styles for the button
|
// Write universal styles for the button
|
||||||
const buttonStyles = {
|
const divStyles = {
|
||||||
border: '1px solid black',
|
border: '1px solid black',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
backgroundColor: 'black',
|
backgroundColor: 'black',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontSize: '1.5rem',
|
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
padding: '0.45em 1em',
|
fontSize: '1.5rem',
|
||||||
width: '15rem',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0 1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
height: '32px',
|
height: '32px',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply common styles to the button.
|
Object.assign(div.style, divStyles);
|
||||||
Object.assign(newButton.style, buttonStyles);
|
|
||||||
|
|
||||||
// Apply common styles to specifc states of a button.
|
// // Apply common styles to the button.
|
||||||
newButton.addEventListener('mouseenter', () => {
|
// Object.assign(buttonDiv.style, buttonDivStyles);
|
||||||
const hoverStyles = {
|
|
||||||
backgroundColor: '#5e5e5e',
|
|
||||||
borderColor: '#5e5e5e',
|
|
||||||
};
|
|
||||||
Object.assign(newButton.style, hoverStyles);
|
|
||||||
});
|
|
||||||
|
|
||||||
newButton.addEventListener('mouseleave', () => {
|
// // Apply common styles to specifc states of a button.
|
||||||
Object.assign(newButton.style, buttonStyles);
|
// 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.
|
// Handle the click event.
|
||||||
newButton.addEventListener('click', async () => {
|
div.addEventListener('click', async () => {
|
||||||
const { apiKey } = await chrome.storage.local.get('apiKey');
|
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.
|
||||||
@ -46,13 +59,16 @@ const createNewButton = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update content during the resolution of the request.
|
// Update content during the resolution of the request.
|
||||||
newButton.textContent = 'Saving...';
|
span.textContent = 'Saving...';
|
||||||
|
|
||||||
// Call the provided onClickHandler function to handle button click logic
|
// Call the provided onClickHandler function to handle button click logic
|
||||||
onClickHandler();
|
onClickHandler();
|
||||||
});
|
});
|
||||||
|
|
||||||
return newButton;
|
div.appendChild(img);
|
||||||
|
div.appendChild(span);
|
||||||
|
|
||||||
|
return div;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default createNewButton;
|
export default createNewButton;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import createNewButton from '~/contentScript/createButton';
|
import createNewButton 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 handleQueryParams from '~/utils/handleQueryParams';
|
import { createCompany, fetchCompany } from '~/db/company.db';
|
||||||
import requestDb from '~/utils/requestDb';
|
import { CompanyInput } from '~/db/types/company.types';
|
||||||
|
|
||||||
const insertButtonForCompany = (): void => {
|
const insertButtonForCompany = async (): Promise<void> => {
|
||||||
// Select the element in which to create the button.
|
// 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',
|
||||||
@ -12,94 +12,111 @@ const insertButtonForCompany = (): void => {
|
|||||||
|
|
||||||
// Create the button with desired callback funciton to execute upon click.
|
// Create the button with desired callback funciton to execute upon click.
|
||||||
if (parentDiv) {
|
if (parentDiv) {
|
||||||
const newButtonCompany: HTMLButtonElement = createNewButton(
|
// Extract company-specific data from the DOM
|
||||||
'Add to Twenty',
|
const companyNameElement = document.querySelector(
|
||||||
async () => {
|
'.org-top-card-summary__title',
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
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];
|
||||||
|
|
||||||
// Include the button in the DOM.
|
// Get the text content or other necessary data from the DOM elements
|
||||||
parentDiv.prepend(newButtonCompany);
|
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;
|
||||||
|
|
||||||
// Write button specific styles here - common ones can be found in createButton.ts.
|
// Prepare company data to send to the backend
|
||||||
const buttonSpecificStyles = {
|
const companyInputData: CompanyInput = {
|
||||||
alignSelf: 'end',
|
name: companyName ?? '',
|
||||||
|
domainName: domainName,
|
||||||
|
address: address ?? '',
|
||||||
|
employees: employees,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(newButtonCompany.style, buttonSpecificStyles);
|
// 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);
|
||||||
|
companyInputData.linkedinLink = { url: companyURL, label: companyURL };
|
||||||
|
|
||||||
|
const company = await fetchCompany({
|
||||||
|
linkedinLink: {
|
||||||
|
url: { eq: companyURL },
|
||||||
|
label: { eq: companyURL },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import createNewButton from '~/contentScript/createButton';
|
import createNewButton from '~/contentScript/createButton';
|
||||||
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
|
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
|
||||||
import handleQueryParams from '~/utils/handleQueryParams';
|
import { createPerson, fetchPerson } from '~/db/person.db';
|
||||||
import requestDb from '~/utils/requestDb';
|
import { PersonInput } from '~/db/types/person.types';
|
||||||
|
|
||||||
const insertButtonForPerson = (): void => {
|
const insertButtonForPerson = async (): Promise<void> => {
|
||||||
// Select the element in which to create the button.
|
// Select the element in which to create the button.
|
||||||
const parentDiv: HTMLDivElement | null = document.querySelector(
|
const parentDiv: HTMLDivElement | null = document.querySelector(
|
||||||
'.pv-top-card-v2-ctas',
|
'.pv-top-card-v2-ctas',
|
||||||
@ -11,108 +11,116 @@ const insertButtonForPerson = (): void => {
|
|||||||
|
|
||||||
// Create the button with desired callback funciton to execute upon click.
|
// Create the button with desired callback funciton to execute upon click.
|
||||||
if (parentDiv) {
|
if (parentDiv) {
|
||||||
const newButtonPerson: HTMLButtonElement = createNewButton(
|
// Extract person-specific data from the DOM.
|
||||||
'Add to Twenty',
|
const personNameElement = document.querySelector('.text-heading-xlarge');
|
||||||
async () => {
|
|
||||||
// Extract person-specific data from the DOM.
|
|
||||||
const personNameElement = document.querySelector(
|
|
||||||
'.text-heading-xlarge',
|
|
||||||
);
|
|
||||||
|
|
||||||
const separatorElement = document.querySelector(
|
const separatorElement = document.querySelector(
|
||||||
'.pv-text-details__separator',
|
'.pv-text-details__separator',
|
||||||
);
|
);
|
||||||
const personCityElement = separatorElement?.previousElementSibling;
|
const personCityElement = separatorElement?.previousElementSibling;
|
||||||
|
|
||||||
const profilePictureElement = document.querySelector(
|
const profilePictureElement = document.querySelector(
|
||||||
'.pv-top-card-profile-picture__image',
|
'.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.
|
const firstListItem = document.querySelector(
|
||||||
parentDiv.prepend(newButtonPerson);
|
'div[data-view-name="profile-component-entity"]',
|
||||||
|
);
|
||||||
|
const secondDivElement = firstListItem?.querySelector('div:nth-child(2)');
|
||||||
|
const ariaHiddenSpan = secondDivElement?.querySelector(
|
||||||
|
'span[aria-hidden="true"]',
|
||||||
|
);
|
||||||
|
|
||||||
// Write button specific styles here - common ones can be found in createButton.ts.
|
// Get the text content or other necessary data from the DOM elements.
|
||||||
const buttonSpecificStyles = {
|
const personName = personNameElement ? personNameElement.textContent : '';
|
||||||
marginRight: '0.5em',
|
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: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(newButtonPerson.style, buttonSpecificStyles);
|
// 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 person = await fetchPerson({
|
||||||
|
name: {
|
||||||
|
firstName: { eq: firstName },
|
||||||
|
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 {
|
||||||
|
const newButtonPerson: HTMLDivElement = createNewButton(
|
||||||
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,17 +3,17 @@ 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/
|
||||||
insertButtonForCompany();
|
await insertButtonForCompany();
|
||||||
insertButtonForPerson();
|
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.
|
||||||
// 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.
|
// 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/
|
// 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) => {
|
chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => {
|
||||||
if (message.action === 'executeContentScript') {
|
if (message.action === 'executeContentScript') {
|
||||||
insertButtonForCompany();
|
await insertButtonForCompany();
|
||||||
insertButtonForPerson();
|
await insertButtonForPerson();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.action === 'TOGGLE') {
|
if (message.action === 'TOGGLE') {
|
||||||
|
|||||||
38
packages/twenty-chrome-extension/src/db/company.db.ts
Normal file
38
packages/twenty-chrome-extension/src/db/company.db.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
CompanyInput,
|
||||||
|
CreateCompanyResponse,
|
||||||
|
FindCompanyResponse,
|
||||||
|
} from '~/db/types/company.types';
|
||||||
|
import { Company, CompanyFilterInput } from '~/generated/graphql';
|
||||||
|
import { CREATE_COMPANY } from '~/graphql/company/mutations';
|
||||||
|
import { FIND_COMPANY } from '~/graphql/company/queries';
|
||||||
|
|
||||||
|
import { callMutation, callQuery } from '../utils/requestDb';
|
||||||
|
|
||||||
|
export const fetchCompany = async (
|
||||||
|
companyfilerInput: CompanyFilterInput,
|
||||||
|
): Promise<Company | null> => {
|
||||||
|
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
|
||||||
|
filter: {
|
||||||
|
...companyfilerInput,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data?.companies.edges) {
|
||||||
|
return data?.companies.edges.length > 0
|
||||||
|
? data?.companies.edges[0].node
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCompany = async (
|
||||||
|
company: CompanyInput,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
||||||
|
input: company,
|
||||||
|
});
|
||||||
|
if (data) {
|
||||||
|
return data.createCompany.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
36
packages/twenty-chrome-extension/src/db/person.db.ts
Normal file
36
packages/twenty-chrome-extension/src/db/person.db.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
CreatePersonResponse,
|
||||||
|
FindPersonResponse,
|
||||||
|
PersonInput,
|
||||||
|
} from '~/db/types/person.types';
|
||||||
|
import { Person, PersonFilterInput } from '~/generated/graphql';
|
||||||
|
import { CREATE_PERSON } from '~/graphql/person/mutations';
|
||||||
|
import { FIND_PERSON } from '~/graphql/person/queries';
|
||||||
|
|
||||||
|
import { callMutation, callQuery } from '../utils/requestDb';
|
||||||
|
|
||||||
|
export const fetchPerson = async (
|
||||||
|
personFilterData: PersonFilterInput,
|
||||||
|
): Promise<Person | null> => {
|
||||||
|
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
|
||||||
|
filter: {
|
||||||
|
...personFilterData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data?.people.edges) {
|
||||||
|
return data?.people.edges.length > 0 ? data?.people.edges[0].node : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPerson = async (
|
||||||
|
person: PersonInput,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
||||||
|
input: person,
|
||||||
|
});
|
||||||
|
if (data?.createPerson) {
|
||||||
|
return data.createPerson.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { Company, CompanyConnection } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export type CompanyInput = Pick<
|
||||||
|
Company,
|
||||||
|
'name' | 'domainName' | 'address' | 'employees' | 'linkedinLink'
|
||||||
|
>;
|
||||||
|
export type FindCompanyResponse = {
|
||||||
|
companies: Pick<CompanyConnection, 'edges'>;
|
||||||
|
};
|
||||||
|
export type CreateCompanyResponse = { createCompany: { id: string } };
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { Person, PersonConnection } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export type PersonInput = Pick<
|
||||||
|
Person,
|
||||||
|
'name' | 'city' | 'avatarUrl' | 'jobTitle' | 'linkedinLink'
|
||||||
|
>;
|
||||||
|
export type FindPersonResponse = { people: Pick<PersonConnection, 'edges'> };
|
||||||
|
export type CreatePersonResponse = { createPerson: { id: string } };
|
||||||
5631
packages/twenty-chrome-extension/src/generated/graphql.tsx
Normal file
5631
packages/twenty-chrome-extension/src/generated/graphql.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const CREATE_COMPANY = gql`
|
||||||
|
mutation CreateOneCompany($input: CompanyCreateInput!) {
|
||||||
|
createCompany(data: $input) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const FIND_COMPANY = gql`
|
||||||
|
query FindCompany($filter: CompanyFilterInput!) {
|
||||||
|
companies(filter: $filter) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
linkedinLink {
|
||||||
|
url
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const CREATE_PERSON = gql`
|
||||||
|
mutation CreateOnePerson($input: PersonCreateInput!) {
|
||||||
|
createPerson(data: $input) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const FIND_PERSON = gql`
|
||||||
|
query FindPerson($filter: PersonFilterInput!) {
|
||||||
|
people(filter: $filter) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name {
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
linkedinLink {
|
||||||
|
url
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
18
packages/twenty-chrome-extension/src/utils/apolloClient.ts
Normal file
18
packages/twenty-chrome-extension/src/utils/apolloClient.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ApolloClient, InMemoryCache } from '@apollo/client';
|
||||||
|
|
||||||
|
const getApolloClient = async () => {
|
||||||
|
const { apiKey } = await chrome.storage.local.get('apiKey');
|
||||||
|
const { serverBaseUrl } = await chrome.storage.local.get('serverBaseUrl');
|
||||||
|
|
||||||
|
return new ApolloClient({
|
||||||
|
cache: new InMemoryCache(),
|
||||||
|
uri: `${
|
||||||
|
serverBaseUrl ? serverBaseUrl : import.meta.env.VITE_SERVER_BASE_URL
|
||||||
|
}/graphql`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getApolloClient;
|
||||||
@ -1,31 +1,34 @@
|
|||||||
const requestDb = async (query: string) => {
|
import { OperationVariables } from '@apollo/client';
|
||||||
const { apiKey } = await chrome.storage.local.get('apiKey');
|
import { DocumentNode } from 'graphql';
|
||||||
const { serverBaseUrl } = await chrome.storage.local.get('serverBaseUrl');
|
|
||||||
|
|
||||||
const options = {
|
import getApolloClient from '~/utils/apolloClient';
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ query }),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(
|
export const callQuery = async <T>(
|
||||||
`${
|
query: DocumentNode,
|
||||||
serverBaseUrl ? serverBaseUrl : import.meta.env.VITE_SERVER_BASE_URL
|
variables?: OperationVariables,
|
||||||
}/graphql`,
|
): Promise<T | null> => {
|
||||||
options,
|
const client = await getApolloClient();
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const { data, error } = await client.query<T>({ query, variables });
|
||||||
// TODO: Handle error gracefully and remove the console statement.
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.error(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default requestDb;
|
export const callMutation = async <T>(
|
||||||
|
mutation: DocumentNode,
|
||||||
|
variables?: OperationVariables,
|
||||||
|
): Promise<T | null> => {
|
||||||
|
const client = await getApolloClient();
|
||||||
|
|
||||||
|
const { data, errors } = await client.mutate<T>({ mutation, variables });
|
||||||
|
|
||||||
|
if (errors) throw new Error(errors[0].message);
|
||||||
|
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2022",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export default defineConfig(() => {
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: { chunkFileNames: 'assets/chunk-[hash].js' },
|
output: { chunkFileNames: 'assets/chunk-[hash].js' },
|
||||||
},
|
},
|
||||||
|
target: 'ES2022',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Adding this to fix websocket connection error.
|
// Adding this to fix websocket connection error.
|
||||||
|
|||||||
Reference in New Issue
Block a user