Skip to content

Commit eec344c

Browse files
authored
Merge pull request #1550 from jetstreamapp/feat/web-extension-fixes-2.18.26
Feat/web-extension-fixes-2.18.26
2 parents 7fa4069 + a541fea commit eec344c

File tree

8 files changed

+341
-30
lines changed

8 files changed

+341
-30
lines changed

apps/jetstream-web-extension/src/components/SfdcPageButton.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44
import { css } from '@emotion/react';
55
import { logger } from '@jetstream/shared/client-logger';
66
import { APP_ROUTES } from '@jetstream/shared/ui-router';
7-
import { useInterval } from '@jetstream/shared/ui-utils';
87
import type { Maybe, SalesforceOrgUi } from '@jetstream/types';
98
import { Grid, GridCol, OutsideClickHandler, Tabs } from '@jetstream/ui';
109
import { fromAppState } from '@jetstream/ui/app-state';
1110
import { useAtomValue, useSetAtom } from 'jotai';
12-
import { useEffect, useRef, useState } from 'react';
11+
import { useEffect, useState } from 'react';
1312
import browser from 'webextension-polyfill';
1413
import '../sfdc-styles-shim.scss';
1514
import { chromeStorageOptions, chromeSyncStorage } from '../utils/extension.store';
@@ -19,6 +18,7 @@ import JetstreamLogo from './icons/JetstreamLogo';
1918
import { SfdcPageButtonOrgInfo } from './SfdcPageButtonOrgInfo';
2019
import { SfdcPageButtonRecordSearch } from './SfdcPageButtonRecordSearch';
2120
import { SfdcPageButtonUserSearch } from './SfdcPageButtonUserSearch';
21+
import { SfdcPageRecordQuickViewButton } from './SfdcPageRecordQuickView';
2222
interface PageLink {
2323
link: string;
2424
label: string;
@@ -97,7 +97,6 @@ function getActionLink(sfHost: string, pageLink: PageLink, objectName?: string)
9797
}
9898

9999
export function SfdcPageButton() {
100-
const currentPathname = useRef<string>(location.pathname);
101100
const options = useAtomValue(chromeStorageOptions);
102101
const { authTokens, buttonPosition } = useAtomValue(chromeSyncStorage);
103102
const [isOnSalesforcePage] = useState(
@@ -123,15 +122,27 @@ export function SfdcPageButton() {
123122
}
124123
}, []);
125124

126-
// TODO: figure out if there is a better way to listen for url change events
127-
useInterval(() => {
128-
if (currentPathname.current === location.pathname) {
129-
return;
130-
}
131-
currentPathname.current = location.pathname;
132-
setRecordId(() => getRecordPageRecordId(location.pathname));
133-
setObjectName(() => getRecordPageObject(location.pathname));
134-
}, 5000);
125+
// Listen for URL changes in Salesforce SPA navigation
126+
useEffect(() => {
127+
const handleUrlChange = () => {
128+
setRecordId(getRecordPageRecordId(location.pathname));
129+
setObjectName(getRecordPageObject(location.pathname));
130+
};
131+
132+
// Salesforce Lightning uses its own navigation - listen for URL changes via polling as fallback
133+
// Check every 500ms
134+
let lastPathname = location.pathname;
135+
const pollInterval = setInterval(() => {
136+
if (lastPathname !== location.pathname) {
137+
lastPathname = location.pathname;
138+
handleUrlChange();
139+
}
140+
}, 500);
141+
142+
return () => {
143+
clearInterval(pollInterval);
144+
};
145+
}, []);
135146

136147
useEffect(() => {
137148
if (isOnSalesforcePage) {
@@ -170,6 +181,18 @@ export function SfdcPageButton() {
170181
}
171182
}, [isOnSalesforcePage, setSalesforceOrgs, setSelectedOrgId]);
172183

184+
// Close panel on escape key (quick view takes precedence via stopImmediatePropagation)
185+
useEffect(() => {
186+
const handleEscape = (event: KeyboardEvent) => {
187+
if (event.key === 'Escape' && isOpen) {
188+
setIsOpen(false);
189+
}
190+
};
191+
192+
document.addEventListener('keydown', handleEscape);
193+
return () => document.removeEventListener('keydown', handleEscape);
194+
}, [isOpen]);
195+
173196
if (!options.enabled || !authTokens?.loggedIn) {
174197
return null;
175198
}
@@ -277,6 +300,11 @@ export function SfdcPageButton() {
277300

278301
{recordId && (
279302
<>
303+
{objectName && (
304+
<GridCol className="slds-m-bottom_x-small" css={ItemColStyles}>
305+
<SfdcPageRecordQuickViewButton sfHost={sfHost} recordId={recordId} sobject={objectName} />
306+
</GridCol>
307+
)}
280308
<GridCol className="slds-m-bottom_x-small" css={ItemColStyles}>
281309
<a
282310
href={`${browser.runtime.getURL('app.html')}?host=${sfHost}&action=VIEW_RECORD&actionValue=${recordId}`}
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { css } from '@emotion/react';
2+
import { logger } from '@jetstream/shared/client-logger';
3+
import { useDebounce } from '@jetstream/shared/ui-utils';
4+
import { orderObjectsBy } from '@jetstream/shared/utils';
5+
import { CopyToClipboard, Icon, ReadOnlyFormElement, ScopedNotification, SearchInput, Spinner } from '@jetstream/ui';
6+
import { useEffect, useState } from 'react';
7+
import '../sfdc-styles-shim.scss';
8+
import { getApiClientFromHost } from '../utils/extension-generic-api-request.utils';
9+
10+
interface SfdcPageRecordQuickViewProps {
11+
sfHost: string;
12+
recordId: string;
13+
sobject: string;
14+
onClose?: () => void;
15+
}
16+
17+
interface RecordField {
18+
apiName: string;
19+
value: unknown;
20+
displayValue: string;
21+
}
22+
23+
export function SfdcPageRecordQuickViewButton({ sfHost, recordId, sobject, onClose }: SfdcPageRecordQuickViewProps) {
24+
const [isOpen, setIsOpen] = useState(false);
25+
26+
if (!recordId || !sobject) {
27+
return null;
28+
}
29+
30+
return (
31+
<>
32+
{isOpen && <SfdcPageRecordQuickView sfHost={sfHost} recordId={recordId} sobject={sobject} onClose={() => setIsOpen(false)} />}
33+
<button className="slds-button slds-button_neutral slds-button_stretch" onClick={() => setIsOpen(true)}>
34+
Quick View Current Record
35+
</button>
36+
</>
37+
);
38+
}
39+
40+
export function SfdcPageRecordQuickView({ sfHost, recordId, sobject, onClose }: SfdcPageRecordQuickViewProps) {
41+
const [loading, setLoading] = useState(true);
42+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
43+
const [recordData, setRecordData] = useState<RecordField[] | null>(null);
44+
const [searchTerm, setSearchTerm] = useState<string>('');
45+
const searchTermDebounced = useDebounce(searchTerm, 300);
46+
47+
useEffect(() => {
48+
if (!sfHost || !recordId) {
49+
return;
50+
}
51+
52+
let cancelled = false;
53+
54+
const loadRecord = async () => {
55+
setLoading(true);
56+
setErrorMessage(null);
57+
58+
try {
59+
const apiConnection = await getApiClientFromHost(sfHost);
60+
const record = await apiConnection.sobject
61+
.recordOperation({ operation: 'retrieve', ids: [recordId], sobject })
62+
.then((response) => response[0]);
63+
64+
if (cancelled) return;
65+
66+
// Transform record into fields array
67+
const fields: RecordField[] = orderObjectsBy(
68+
Object.entries(record)
69+
.filter(([key]) => key !== 'attributes')
70+
.map(([apiName, value]) => ({
71+
apiName,
72+
value,
73+
displayValue: formatFieldValue(value),
74+
})),
75+
['apiName'],
76+
);
77+
78+
setRecordData(fields);
79+
setLoading(false);
80+
} catch (err) {
81+
if (cancelled) return;
82+
logger.error(err);
83+
setErrorMessage(`Failed to load record: ${err.message}`);
84+
setLoading(false);
85+
}
86+
};
87+
88+
loadRecord();
89+
90+
return () => {
91+
cancelled = true;
92+
};
93+
}, [sfHost, recordId, sobject]);
94+
95+
// Close on escape key
96+
useEffect(() => {
97+
const handleEscape = (event: KeyboardEvent) => {
98+
if (event.key === 'Escape' && onClose) {
99+
event.stopImmediatePropagation(); // Prevent other escape handlers from firing
100+
onClose();
101+
}
102+
};
103+
104+
document.addEventListener('keydown', handleEscape);
105+
return () => document.removeEventListener('keydown', handleEscape);
106+
}, [onClose]);
107+
108+
const filteredFields = recordData?.filter(
109+
(field) =>
110+
!searchTermDebounced ||
111+
field.apiName.toLowerCase().includes(searchTermDebounced.toLowerCase()) ||
112+
field.displayValue.toLowerCase().includes(searchTermDebounced.toLowerCase()),
113+
);
114+
115+
return (
116+
<div
117+
css={css`
118+
position: fixed;
119+
bottom: 0;
120+
right: 0;
121+
width: 50%;
122+
max-height: 90vh;
123+
background: white;
124+
border: 1px solid #dddbda;
125+
border-radius: 0.25rem 0 0 0;
126+
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
127+
display: flex;
128+
flex-direction: column;
129+
z-index: 9999;
130+
131+
@media (max-width: 768px) {
132+
width: 100%;
133+
}
134+
`}
135+
>
136+
{/* Header */}
137+
<div
138+
className="slds-docked-composer__header slds-grid slds-shrink-none"
139+
css={css`
140+
align-items: center;
141+
`}
142+
>
143+
<div className="slds-media slds-media_center slds-no-space">
144+
<div className="slds-media__figure slds-m-right_x-small">
145+
<Icon type="utility" icon="record_alt" className="slds-icon slds-icon_small slds-icon-text-default" />
146+
</div>
147+
<div className="slds-media__body">
148+
<h2 className="slds-truncate">Record View</h2>
149+
</div>
150+
</div>
151+
<span className="slds-text-body_small slds-text-color_weak slds-m-left_x-small">
152+
<CopyToClipboard content={recordId} />
153+
{recordId}
154+
</span>
155+
<div className="slds-col_bump-left slds-shrink-none">
156+
{/* TODO: minimize/expand if we want to */}
157+
<button className="slds-button slds-button_icon slds-button_icon" title="Close" onClick={onClose}>
158+
<Icon type="utility" icon="close" className="slds-icon slds-icon-text-default slds-icon_x-small" />
159+
</button>
160+
</div>
161+
</div>
162+
163+
<div
164+
className="slds-docked-composer__body"
165+
css={css`
166+
flex: 1;
167+
display: flex;
168+
flex-direction: column;
169+
overflow: hidden;
170+
`}
171+
>
172+
{/* Search */}
173+
<div
174+
css={css`
175+
padding: 0.75rem 1rem;
176+
border-bottom: 1px solid #dddbda;
177+
`}
178+
>
179+
<SearchInput
180+
id="field-search"
181+
className="w-100"
182+
placeholder="Filter fields by name or value..."
183+
value={searchTerm}
184+
onChange={(value) => setSearchTerm(value)}
185+
/>
186+
</div>
187+
188+
{/* Content */}
189+
<div
190+
css={css`
191+
flex: 1;
192+
overflow-y: auto;
193+
padding: 0.5rem;
194+
padding-bottom: 3rem;
195+
`}
196+
>
197+
{loading && (
198+
<div
199+
css={css`
200+
display: flex;
201+
justify-content: center;
202+
padding: 2rem;
203+
`}
204+
>
205+
<Spinner />
206+
</div>
207+
)}
208+
209+
{errorMessage && (
210+
<ScopedNotification theme="error" className="slds-m-vertical_medium">
211+
{errorMessage}
212+
</ScopedNotification>
213+
)}
214+
215+
{!loading && filteredFields && filteredFields.length === 0 && (
216+
<p className="slds-text-align_center slds-m-vertical_medium slds-text-color_weak">No fields match your search</p>
217+
)}
218+
219+
{!loading && filteredFields && filteredFields.length > 0 && (
220+
<div
221+
css={css`
222+
display: flex;
223+
flex-direction: column;
224+
gap: 0.5rem;
225+
`}
226+
>
227+
{filteredFields.map((field) => (
228+
<ReadOnlyFormElement
229+
key={field.apiName}
230+
id={field.apiName}
231+
label={field.apiName}
232+
className="slds-p-bottom_x-small"
233+
value={field.displayValue}
234+
bottomBorder
235+
/>
236+
))}
237+
</div>
238+
)}
239+
</div>
240+
</div>
241+
</div>
242+
);
243+
}
244+
245+
// Helper function to format field values for display
246+
function formatFieldValue(value: unknown): string {
247+
if (value === null || value === undefined) {
248+
return '';
249+
}
250+
if (typeof value === 'object') {
251+
return JSON.stringify(value, null, 2);
252+
}
253+
if (typeof value === 'boolean') {
254+
return value ? 'true' : 'false';
255+
}
256+
return String(value);
257+
}

apps/jetstream-web-extension/src/core/AppWrapper.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { AxiosAdapterConfig } from '@jetstream/shared/data';
33
import { AppToast, ConfirmationServiceProvider } from '@jetstream/ui';
44
import { AppLoading, ViewEditCloneRecordWrapper } from '@jetstream/ui-core';
55
import '@salesforce-ux/design-system/assets/styles/salesforce-lightning-design-system.css';
6-
import { useAtomValue } from 'jotai';
6+
import { Provider, useAtomValue } from 'jotai';
77
import { ReactNode, Suspense } from 'react';
88
import { DndProvider } from 'react-dnd';
99
import { HTML5Backend } from 'react-dnd-html5-backend';
@@ -12,7 +12,7 @@ import { MemoryRouter } from 'react-router-dom';
1212
import { environment } from '../environments/environment';
1313
import '../main.scss';
1414
import { browserExtensionAxiosAdapter } from '../utils/extension-axios-adapter';
15-
import { chromeStorageLoading } from '../utils/extension.store';
15+
import { chromeStorageLoading, extensionStateStore } from '../utils/extension.store';
1616
import '../utils/monaco-loader';
1717
import AppInitializer from './AppInitializer';
1818

@@ -22,7 +22,7 @@ if (!environment.production) {
2222

2323
AxiosAdapterConfig.adapter = browserExtensionAxiosAdapter;
2424

25-
export function AppWrapper({ allowWithoutSalesforceOrg, children }: { allowWithoutSalesforceOrg?: boolean; children: ReactNode }) {
25+
function AppWrapperInner({ allowWithoutSalesforceOrg, children }: { allowWithoutSalesforceOrg?: boolean; children: ReactNode }) {
2626
const isLoading = useAtomValue(chromeStorageLoading);
2727
if (isLoading) {
2828
return <AppLoading />;
@@ -44,3 +44,11 @@ export function AppWrapper({ allowWithoutSalesforceOrg, children }: { allowWitho
4444
</ConfirmationServiceProvider>
4545
);
4646
}
47+
48+
export function AppWrapper({ allowWithoutSalesforceOrg, children }: { allowWithoutSalesforceOrg?: boolean; children: ReactNode }) {
49+
return (
50+
<Provider store={extensionStateStore}>
51+
<AppWrapperInner allowWithoutSalesforceOrg={allowWithoutSalesforceOrg}>{children}</AppWrapperInner>
52+
</Provider>
53+
);
54+
}

0 commit comments

Comments
 (0)