diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png
index df56a69a..a93a6d39 100644
Binary files a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png and b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png differ
diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png
index c0dd6784..0cadb62d 100644
Binary files a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png and b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png differ
diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-user-search.png b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-user-search.png
new file mode 100644
index 00000000..879546ee
Binary files /dev/null and b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-user-search.png differ
diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx b/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx
index ef0aca5d..cf7f55bd 100644
--- a/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx
+++ b/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx
@@ -22,9 +22,9 @@ export function PopupLogin() {
export function PagePopupDisplay() {
return (
);
}
@@ -84,6 +84,18 @@ Once you are logged in, the chrome extension will indicate that you are logged i
Visit any Salesforce page and you will see a Jetstream Icon conveniently located in the right side of the page. Click on the icon to open the Jetstream Chrome Extension.
+
+
+### Using the Page Popup
+
+Open the Jetstream Chrome Extension on any Salesforce page to quickly access the following features:
+
+- Quick access to common features, such as: Query, Load, Automation Control, and Permission Manager
+- View or edit the currently open record _(appears when you visit a record page)_
+- Paste in a record id to view or edit any record in Jetstream
+- Search for any user in your org with the option to open in Salesforce or Jetstream directly
+ - You can also login as the user if you have permission to do so
+
### Options
diff --git a/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx b/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx
index 575d7bf9..8787c0a4 100644
--- a/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx
+++ b/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx
@@ -2,37 +2,19 @@
import { css } from '@emotion/react';
import { logger } from '@jetstream/shared/client-logger';
import { APP_ROUTES } from '@jetstream/shared/ui-router';
+import { useInterval } from '@jetstream/shared/ui-utils';
import type { Maybe } from '@jetstream/types';
-import { Grid, GridCol, OutsideClickHandler } from '@jetstream/ui';
+import { Grid, GridCol, OutsideClickHandler, Tabs } from '@jetstream/ui';
import { fromAppState } from '@jetstream/ui/app-state';
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import '../sfdc-styles-shim.scss';
import { chromeStorageOptions, chromeSyncStorage } from '../utils/extension.store';
import { getRecordPageRecordId, sendMessage } from '../utils/web-extension.utils';
import JetstreamIcon from './icons/JetstreamIcon';
import JetstreamLogo from './icons/JetstreamLogo';
-
-function useInterval(callback: () => void, delay: number | null) {
- const savedCallback = useRef<() => void>();
-
- useEffect(() => {
- savedCallback.current = callback;
- }, [callback]);
-
- useEffect(() => {
- function tick() {
- if (savedCallback.current) {
- savedCallback.current();
- }
- }
-
- if (delay !== null && delay !== undefined) {
- const id = setInterval(tick, delay);
- return () => clearInterval(id);
- }
- }, [delay]);
-}
+import { SfdcPageButtonRecordSearch } from './SfdcPageButtonRecordSearch';
+import { SfdcPageButtonUserSearch } from './SfdcPageButtonUserSearch';
const PAGE_LINKS = [
{
@@ -124,6 +106,33 @@ const ButtonLinkCss = css`
cursor: pointer;
`;
+const ItemColStyles = css`
+ padding-right: var(--lwc-spacingSmall, 0.75rem);
+ padding-left: var(--lwc-spacingSmall, 0.75rem);
+ margin-bottom: var(--lwc-spacingXSmall, 0.5rem);
+ flex: 1 1 auto;
+`;
+
+const HorizontalRule = () => {
+ return (
+
+ );
+};
+
export function SfdcPageButton() {
const options = useRecoilValue(chromeStorageOptions);
const { authTokens, buttonPosition } = useRecoilValue(chromeSyncStorage);
@@ -241,7 +250,7 @@ export function SfdcPageButton() {
position: fixed;
top: clamp(1px, ${buttonPosition.position - 50}px, calc(100vh - 500px));
${buttonPosition.location}: 0;
- width: 250px;
+ width: 300px;
border-radius: var(--lwc-borderRadiusMedium, 0.25rem);
min-height: 2rem;
background-color: var(--slds-g-color-neutral-base-100, var(--lwc-colorBackgroundAlt, rgb(255, 255, 255)));
@@ -277,114 +286,83 @@ export function SfdcPageButton() {
)}
diff --git a/apps/jetstream-web-extension/src/components/SfdcPageButtonRecordSearch.tsx b/apps/jetstream-web-extension/src/components/SfdcPageButtonRecordSearch.tsx
new file mode 100644
index 00000000..4858850e
--- /dev/null
+++ b/apps/jetstream-web-extension/src/components/SfdcPageButtonRecordSearch.tsx
@@ -0,0 +1,57 @@
+import { Grid, Input } from '@jetstream/ui';
+import { useState } from 'react';
+import '../sfdc-styles-shim.scss';
+
+interface SfdcPageButtonRecordSearchProps {
+ sfHost: string;
+}
+
+export function SfdcPageButtonRecordSearch({ sfHost }: SfdcPageButtonRecordSearchProps) {
+ const [recordId, setRecordId] = useState('');
+
+ const isValidRecordId = recordId.length === 15 || recordId.length === 18;
+
+ function handleSubmit(ev: React.FormEvent) {
+ ev.preventDefault();
+ if (!isValidRecordId) {
+ return;
+ }
+ window.open(`${chrome.runtime.getURL('app.html')}?host=${sfHost}&action=VIEW_RECORD&actionValue=${recordId}`, '_blank');
+ }
+
+ function handleEditRecord() {
+ if (!isValidRecordId) {
+ return;
+ }
+ window.open(`${chrome.runtime.getURL('app.html')}?host=${sfHost}&action=EDIT_RECORD&actionValue=${recordId}`, '_blank');
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/jetstream-web-extension/src/components/SfdcPageButtonUserSearch.tsx b/apps/jetstream-web-extension/src/components/SfdcPageButtonUserSearch.tsx
new file mode 100644
index 00000000..38ed1e32
--- /dev/null
+++ b/apps/jetstream-web-extension/src/components/SfdcPageButtonUserSearch.tsx
@@ -0,0 +1,237 @@
+import { css } from '@emotion/react';
+import { useDebounce, useNonInitialEffect } from '@jetstream/shared/ui-utils';
+import type { QueryResults } from '@jetstream/types';
+import { CopyToClipboard, Grid, GridCol, List, ScopedNotification, SearchInput } from '@jetstream/ui';
+import { useEffect, useRef, useState } from 'react';
+import '../sfdc-styles-shim.scss';
+import { OrgAndSessionInfo } from '../utils/extension.types';
+import { sendMessage } from '../utils/web-extension.utils';
+
+interface SfdcPageButtonUserSearchProps {
+ sfHost: string;
+}
+
+interface User {
+ Id: string;
+ Name: string;
+ Alias: string;
+ CreatedDate: string;
+ Email: string;
+ IsActive: boolean;
+ Profile: {
+ Id: string;
+ Name: string;
+ };
+ Username: string;
+ UserRole?: {
+ Id: string;
+ Name: string;
+ };
+ UserType:
+ | 'Standard'
+ | 'PowerPartner'
+ | 'PowerCustomerSuccess'
+ | 'CustomerSuccess'
+ | 'Guest'
+ | 'CspLitePortal'
+ | 'CsnOnly'
+ | 'SelfService';
+}
+
+function getUserSearchSoql(searchTerm: string) {
+ const isPossibleId = /^([0-9a-zA-Z]{16}|[0-9a-zA-Z]{18})$/.test(searchTerm);
+ return [
+ `SELECT Id, Name, Alias, FORMAT(CreatedDate), Email, IsActive, Profile.Id, Profile.Name, Username, UserRole.Id, UserRole.Name, UserType`,
+ `FROM User`,
+ `WHERE Name LIKE '%${searchTerm}%' OR Email LIKE '%${searchTerm}%' OR Username LIKE '%${searchTerm}%'`,
+ isPossibleId ? `OR Id = '${searchTerm}'` : '',
+ `ORDER BY Name LIMIT 50`,
+ ].join(' ');
+}
+
+export function SfdcPageButtonUserSearch({ sfHost }: SfdcPageButtonUserSearchProps) {
+ const currentSearchRef = useRef(0);
+ const [searchTerm, setSearchTerm] = useState('');
+ const searchTermDebounced = useDebounce(searchTerm, 500);
+
+ const [loading, setLoading] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const [usersResults, setUsersResults] = useState>();
+
+ const [org, setOrg] = useState(null);
+ // const [action, setAction] = useState('view' | 'edit');
+
+ useEffect(() => {
+ if (sfHost) {
+ sendMessage({
+ message: 'GET_CURRENT_ORG',
+ data: { sfHost },
+ })
+ .then((data) => setOrg(data))
+ .catch((err) => {
+ console.error(err);
+ setErrorMessage('There was an error initializing the search');
+ });
+ }
+ }, [sfHost]);
+
+ useNonInitialEffect(() => {
+ if (sfHost && searchTermDebounced) {
+ const currentSearchValue = currentSearchRef.current;
+ setLoading(true);
+ setErrorMessage(null);
+ sendMessage({
+ message: 'API_ACTION',
+ data: {
+ method: 'POST',
+ pathname: '/api/query',
+ sfHost,
+ body: { query: getUserSearchSoql(searchTermDebounced) },
+ },
+ })
+ .then(({ data }) => {
+ if (currentSearchRef.current === currentSearchValue) {
+ setUsersResults(data as QueryResults);
+ setLoading(false);
+ }
+ })
+ .catch((err) => {
+ console.error(err);
+ setErrorMessage(`There was an error searching for users. ${err.message}`);
+ })
+ .finally(() => setLoading(false));
+ }
+ return () => {
+ currentSearchRef.current = currentSearchRef.current + 1;
+ };
+ }, [searchTermDebounced, sfHost]);
+
+ return (
+ <>
+ setSearchTerm(value.trim())}
+ />
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+ {!!usersResults && !usersResults.queryResults.totalSize && (
+ No Results
+ )}
+ {!!usersResults?.queryResults?.totalSize && (
+ item.Id === searchTerm}
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ onSelected={(key: string) => {}}
+ getContent={(user: User) => ({
+ key: user.Id,
+ id: user.Id,
+ heading: getListItemContent(sfHost, { user }),
+ children: (
+
+
+
+ Open in Jetstream
+
+
+ {org && (
+
+
+ Attempt Login As User
+
+
+ )}
+
+ ),
+ })}
+ searchTerm={searchTerm}
+ />
+ )}
+ >
+ );
+}
+
+// For some reason, links were not doing anything when clicked
+function handleLinkClick(event: React.MouseEvent) {
+ event.preventDefault();
+ window.open(event.currentTarget.href, '_blank');
+}
+
+function getOpenInJetstreamLink(sfHost: string, recordId: string) {
+ return `${chrome.runtime.getURL('app.html')}?host=${sfHost}&action=VIEW_RECORD&actionValue=${recordId}`;
+}
+
+function getSalesforceUserLink(sfHost: string, recordId: string) {
+ return `https://${sfHost}/lightning/setup/ManageUsers/page?${new URLSearchParams({
+ address: `/${recordId}?noredirect=1`,
+ }).toString()}`;
+}
+
+function getSalesforceProfileLink(sfHost: string, recordId: string) {
+ return `https://${sfHost}/lightning/setup/EnhancedProfiles/page?${new URLSearchParams({
+ address: `/${recordId}?noredirect=1`,
+ }).toString()}`;
+}
+
+function getLoginAsLink(sfHost: string, { org }: OrgAndSessionInfo, recordId: string) {
+ return `https://${sfHost}/servlet/servlet.su?${new URLSearchParams({
+ oid: org.organizationId,
+ suorgadminid: recordId,
+ retURL: window.location.pathname,
+ targetURL: window.location.pathname,
+ }).toString()}`;
+}
+
+function getListItemContent(sfHost: string, { user }: { user: User }) {
+ const { Alias, Email, Id, IsActive, Name, Profile, Username, UserType, UserRole } = user;
+ return (
+
+
+ {Name} ({Alias})
+
+
+ Email:
+ {Email}
+
+
+ Username:
+
+ {Username}
+
+
+ {Profile?.Name && (
+
+ Profile:
+
+ {Profile.Name}
+
+
+ )}
+ {UserRole?.Name && (
+
+ Role:
+ {UserRole.Name}
+
+ )}
+ {!IsActive &&
Inactive
}
+
+
+ {Id}
+
+ {UserType !== 'Standard' &&
{UserType}
}
+
+ );
+}
diff --git a/apps/jetstream-web-extension/src/serviceWorker.ts b/apps/jetstream-web-extension/src/serviceWorker.ts
index 8f2ea0cb..0001f9b0 100644
--- a/apps/jetstream-web-extension/src/serviceWorker.ts
+++ b/apps/jetstream-web-extension/src/serviceWorker.ts
@@ -17,6 +17,7 @@ import { extensionRoutes } from './controllers/extension.routes';
import { environment } from './environments/environment';
import { initApiClient, initApiClientAndOrg } from './utils/api-client';
import {
+ ApiAction,
AUTH_CHECK_INTERVAL_MIN,
AuthTokensStorage,
eventPayload,
@@ -273,6 +274,19 @@ chrome.runtime.onMessage.addListener(
.catch(handleError(sendResponse));
return true; // indicate that sendResponse will be called asynchronously
}
+ case 'API_ACTION': {
+ // Used to call API from the Salesforce page button popover (e.g. user search)
+ handleApiRequestEvent(request.data, sender)
+ .then((data) => handleResponse(data, sendResponse))
+ .catch(handleError(sendResponse));
+ return true; // indicate that sendResponse will be called asynchronously
+ }
+ case 'GET_CURRENT_ORG': {
+ getCurrentOrg(request.data.sfHost, sender)
+ .then((data) => handleResponse(data, sendResponse))
+ .catch(handleError(sendResponse));
+ return true; // indicate that sendResponse will be called asynchronously
+ }
default:
logger.warn(`Unknown message`, request);
return false;
@@ -371,6 +385,29 @@ const doesAuthNeedToBeChecked = (authTokens: AuthTokensStorage['authTokens']): b
return isAfter(new Date(), addMinutes(new Date(authTokens.lastChecked), AUTH_CHECK_INTERVAL_MIN));
};
+async function getCurrentOrg(sfHost: string, sender: chrome.runtime.MessageSender) {
+ // Because we offer loginAs, we need to find the connection based on the session id
+ // we cannot rely just on the host as the session id changes when using loginAs and we don't know the user id
+ // so we don't have a way to associate the session to a specific user unless we were to make API calls to see what user a session belongs to
+ const sessionCookie = await chrome.cookies.get({
+ url: `https://${sfHost}`,
+ name: 'sid',
+ storeId: getCookieStoreId(sender),
+ });
+
+ if (!sessionCookie?.value) {
+ throw new Error(`Session cookie not found for host ${sfHost}`);
+ }
+
+ const connection = Object.values(connections).find(({ sessionInfo }) => sessionInfo.key === sessionCookie.value);
+
+ if (!connection) {
+ throw new Error(`Connection not found for host ${sfHost}`);
+ }
+
+ return connection;
+}
+
/**
* HANDLERS
*/
@@ -511,3 +548,49 @@ async function handleInitOrg(
await setConnection(response.org.uniqueId, { sessionInfo, org: response.org });
return response;
}
+
+/**
+ * Used to make API requests outside of the extension context (e.g. on a Salesforce page)
+ */
+async function handleApiRequestEvent(
+ { method, sfHost, pathname, body, queryParams }: ApiAction['request']['data'],
+ sender: chrome.runtime.MessageSender
+) {
+ const route = extensionRoutes.match(method as Method, pathname);
+ if (!route) {
+ throw new Error('Route not found');
+ }
+ const connection = await getCurrentOrg(sfHost, sender);
+
+ queryParams = queryParams || new URLSearchParams();
+ const { sessionInfo, org } = connection;
+ const apiConnection = initApiClient(sessionInfo);
+
+ const response = await route
+ .handler({
+ event: {
+ request: {
+ url: `https://${sfHost}${pathname}?${queryParams.toString()}`,
+ headers: new Headers({
+ 'content-type': 'application/json',
+ }),
+ json: async () => body,
+ text: async () => body,
+ body,
+ },
+ } as FetchEvent,
+ params: route.params,
+ jetstreamConn: apiConnection,
+ org,
+ })
+ .then((response) => {
+ if (!response.ok) {
+ return response.text().then((message) => {
+ throw new Error(message);
+ });
+ }
+ return response.json();
+ });
+
+ return response;
+}
diff --git a/apps/jetstream-web-extension/src/utils/extension.types.ts b/apps/jetstream-web-extension/src/utils/extension.types.ts
index 8198a31e..14c9ca18 100644
--- a/apps/jetstream-web-extension/src/utils/extension.types.ts
+++ b/apps/jetstream-web-extension/src/utils/extension.types.ts
@@ -43,7 +43,7 @@ export interface ChromeStorageState {
};
}
-export type Message = Logout | VerifyAuth | ToggleExtension | GetSfHost | GetSession | GetPageUrl | InitOrg;
+export type Message = Logout | VerifyAuth | ToggleExtension | GetSfHost | GetSession | GetPageUrl | InitOrg | ApiAction | GetCurrentOrg;
export type MessageRequest = Message['request'];
export interface ResponseError {
@@ -122,6 +122,24 @@ export interface InitOrg {
};
response: { org: SalesforceOrgUi };
}
+
+// Allows calling API routes from Salesforce pages
+export interface ApiAction {
+ request: {
+ message: 'API_ACTION';
+ data: { sfHost: string; method: string; pathname: string; queryParams?: URLSearchParams; body?: any };
+ };
+ response: { data: unknown };
+}
+
+export interface GetCurrentOrg {
+ request: {
+ message: 'GET_CURRENT_ORG';
+ data: { sfHost: string };
+ };
+ response: OrgAndSessionInfo;
+}
+
export interface OrgAndSessionInfo {
org: SalesforceOrgUi;
sessionInfo: SessionInfo;
diff --git a/libs/shared/ui-core/src/data-sync/client-data.db.ts b/libs/shared/ui-core/src/data-sync/client-data.db.ts
index 3e169b80..2f590e97 100644
--- a/libs/shared/ui-core/src/data-sync/client-data.db.ts
+++ b/libs/shared/ui-core/src/data-sync/client-data.db.ts
@@ -1,5 +1,6 @@
import { logger } from '@jetstream/shared/client-logger';
import { INDEXED_DB } from '@jetstream/shared/constants';
+import { delay } from '@jetstream/shared/utils';
import { LoadSavedMappingItem, QueryHistoryItem } from '@jetstream/types';
import { DEXIE_DB_SYNC_NAME, dexieDataSync, dexieDb, hashRecordKey, SyncableTables } from '@jetstream/ui/db';
import 'dexie-observable';
@@ -57,7 +58,8 @@ class DexieInitializer {
initializeDexieSync(DEXIE_DB_SYNC_NAME);
this.hasInitializedSync = true;
}
- // Connect to sync
+ // sometimes the connection does not initialize properly, delaying to ensure it does
+ await delay(1000);
await dexieDataSync.connect();
}