diff --git a/apps/jetstream-e2e/playwright.config.ts b/apps/jetstream-e2e/playwright.config.ts index 8cdb3a743..55ed727f2 100644 --- a/apps/jetstream-e2e/playwright.config.ts +++ b/apps/jetstream-e2e/playwright.config.ts @@ -63,15 +63,5 @@ export default defineConfig({ dependencies: ['setup'], // teardown: 'teardown', }, - - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, ], }); diff --git a/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-128.png b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-128.png new file mode 100644 index 000000000..bceb44b70 Binary files /dev/null and b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-128.png differ diff --git a/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-16.png b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-16.png new file mode 100644 index 000000000..d9c223787 Binary files /dev/null and b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-16.png differ diff --git a/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-24.png b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-24.png new file mode 100644 index 000000000..3d3d8b5c0 Binary files /dev/null and b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-24.png differ diff --git a/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-32.png b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-32.png new file mode 100644 index 000000000..ef1b84d89 Binary files /dev/null and b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-32.png differ diff --git a/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-48.png b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-48.png new file mode 100644 index 000000000..0a530e1a2 Binary files /dev/null and b/apps/jetstream-web-extension/src/assets/icons/jetstream-icon-inverse-48.png differ diff --git a/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx b/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx index f12cbde58..31a8bb48f 100644 --- a/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx +++ b/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx @@ -126,7 +126,7 @@ const ButtonLinkCss = css` export function SfdcPageButton() { const options = useRecoilValue(chromeStorageOptions); - const { authTokens } = useRecoilValue(chromeSyncStorage); + const { authTokens, buttonPosition } = useRecoilValue(chromeSyncStorage); const [isOnSalesforcePage] = useState( () => !!document.querySelector('body.sfdcBody, body.ApexCSIPage, #auraLoadingBox') || location.host.endsWith('visualforce.com') ); @@ -202,33 +202,33 @@ export function SfdcPageButton() { <button data-testid="jetstream-ext-page-button" css={css` - z-index: 1000; - display: block; - position: fixed; - vertical-align: middle; - pointer: cursor; - top: 210px; - right: ${isOpen ? '0px;' : '-5px;'}; - opacity: ${isOpen ? '1;' : '0.25;'}; - width: 25px; - transition: transform 0.3s ease; - transform: ${isOpen ? 'scale(1.5);' : 'scale(1);'} - transform-origin: top right; + z-index: 1000; + display: ${isOpen ? 'none' : 'block'}; + position: fixed; + vertical-align: middle; + pointer: cursor; + top: clamp(1px, ${buttonPosition.position}px, 100vh); + ${buttonPosition.location}: ${isOpen ? '0px' : '-5px'}; + opacity: ${isOpen ? '1' : `${buttonPosition.opacity}`}; + width: ${buttonPosition.inactiveSize}px; + transition: transform 0.3s ease; + transform: ${isOpen ? `scale(${buttonPosition.activeScale})` : 'scale(1)'}; + transform-origin: top ${buttonPosition.location}; - background: none; - border: none; - padding: 0; - margin: 0; - cursor: pointer; - outline: none; - text-align: center; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + outline: none; + text-align: center; - &:hover { - opacity: 1; - right: 0px; - transform: scale(1.5); - } - `} + &:hover { + opacity: 1; + ${buttonPosition.location}: 0px; + transform: scale(${buttonPosition.activeScale}); + } + `} onClick={() => setIsOpen(true)} > <JetstreamIcon /> @@ -239,8 +239,8 @@ export function SfdcPageButton() { z-index: 1000; display: block; position: fixed; - top: 160px; - right: 0; + top: clamp(1px, ${buttonPosition.position - 50}px, calc(100vh - 500px)); + ${buttonPosition.location}: 0; width: 250px; border-radius: var(--lwc-borderRadiusMedium, 0.25rem); min-height: 2rem; diff --git a/apps/jetstream-web-extension/src/core/AppInitializer.tsx b/apps/jetstream-web-extension/src/core/AppInitializer.tsx index ded19ce68..702393c66 100644 --- a/apps/jetstream-web-extension/src/core/AppInitializer.tsx +++ b/apps/jetstream-web-extension/src/core/AppInitializer.tsx @@ -9,8 +9,10 @@ import localforage from 'localforage'; import React, { FunctionComponent, useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { chromeSyncStorage } from '../utils/extension.store'; import { sendMessage } from '../utils/web-extension.utils'; import { GlobalExtensionError } from './GlobalExtensionError'; +import { GlobalExtensionLoggedOut } from './GlobalExtensionLoggedOut'; const args = new URLSearchParams(location.search.slice(1)); const salesforceHost = args.get('host'); @@ -29,6 +31,8 @@ export const AppInitializer: FunctionComponent<AppInitializerProps> = ({ onUserP const location = useLocation(); const userProfile = useRecoilValue<UserProfileUi>(fromAppState.userProfileState); + const { authTokens } = useRecoilValue(chromeSyncStorage); + const setSelectedOrgId = useSetRecoilState(fromAppState.selectedOrgIdState); const setSalesforceOrgs = useSetRecoilState(fromAppState.salesforceOrgsState); const selectedOrg = useRecoilValue(fromAppState.selectedOrgState); @@ -77,6 +81,10 @@ export const AppInitializer: FunctionComponent<AppInitializerProps> = ({ onUserP return <GlobalExtensionError message={fatalError} />; } + if (!authTokens?.loggedIn) { + return <GlobalExtensionLoggedOut />; + } + if (!salesforceHost || !selectedOrg?.uniqueId) { return <AppLoading />; } diff --git a/apps/jetstream-web-extension/src/core/GlobalExtensionLoggedOut.tsx b/apps/jetstream-web-extension/src/core/GlobalExtensionLoggedOut.tsx new file mode 100644 index 000000000..057cb21d0 --- /dev/null +++ b/apps/jetstream-web-extension/src/core/GlobalExtensionLoggedOut.tsx @@ -0,0 +1,37 @@ +import { FeedbackLink, Grid } from '@jetstream/ui'; +import { environment } from '../environments/environment'; + +export const GlobalExtensionLoggedOut = () => { + return ( + <div className="slds-card slds-box"> + <p>This page is only accessible when you are logged in to the Chrome extension. Login to continue.</p> + <a + href={`${environment.serverUrl}/web-extension/init`} + target="_blank" + className="slds-button slds-button_brand slds-m-top_small" + rel="noreferrer" + > + Sign In + </a> + <hr className="slds-m-vertical_medium" /> + <Grid vertical> + <div> + <ol className="slds-list_ordered"> + <li> + Bug reports and feature requests - <FeedbackLink type="GH_ISSUE" /> + </li> + <li className="slds-m-top_x-small"> + <FeedbackLink type="GH_DISCUSSION" /> + </li> + <li className="slds-m-top_x-small"> + Ask a question in the <strong>#vendors-jetstream</strong> Discord channel <FeedbackLink type="DISCORD" /> + </li> + <li className="slds-m-top_x-small"> + You can always email us at <FeedbackLink type="EMAIL" /> + </li> + </ol> + </div> + </Grid> + </div> + ); +}; diff --git a/apps/jetstream-web-extension/src/pages/app/app.html b/apps/jetstream-web-extension/src/pages/app/app.html index cc927fa71..ff03e8bd9 100644 --- a/apps/jetstream-web-extension/src/pages/app/app.html +++ b/apps/jetstream-web-extension/src/pages/app/app.html @@ -3,6 +3,7 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="assets/icons/jetstream-icon-inverse-16.png" type="image/png" /> <title>Jetstream</title> </head> diff --git a/apps/jetstream-web-extension/src/pages/popup/Popup.tsx b/apps/jetstream-web-extension/src/pages/popup/Popup.tsx index 228c89626..9d1d23a43 100644 --- a/apps/jetstream-web-extension/src/pages/popup/Popup.tsx +++ b/apps/jetstream-web-extension/src/pages/popup/Popup.tsx @@ -1,11 +1,14 @@ -import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; -import { CheckboxToggle, FeedbackLink, ScopedNotification } from '@jetstream/ui'; -import { useEffect, useState } from 'react'; +import { css } from '@emotion/react'; +import { useDebounce, useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import { CheckboxToggle, FeedbackLink, RadioButton, RadioGroup, ScopedNotification, Slider } from '@jetstream/ui'; +import { useEffect, useMemo, useState } from 'react'; import { useRecoilValue } from 'recoil'; +import JetstreamIcon from '../../components/icons/JetstreamIcon'; import JetstreamLogo from '../../components/icons/JetstreamLogo'; import { AppWrapperNotJetstreamOwnedPage } from '../../core/AppWrapperNotJetstreamOwnedPage'; import { environment } from '../../environments/environment'; import { chromeStorageOptions, chromeSyncStorage } from '../../utils/extension.store'; +import { ButtonPosition, DEFAULT_BUTTON_POSITION } from '../../utils/extension.types'; import { initAndRenderReact, sendMessage } from '../../utils/web-extension.utils'; initAndRenderReact( @@ -18,10 +21,37 @@ export function Component() { const options = useRecoilValue(chromeStorageOptions); const [enabled, setEnabled] = useState(options.enabled); const [authError, setAuthError] = useState<string | null>(null); - const { authTokens } = useRecoilValue(chromeSyncStorage); + const { authTokens, buttonPosition: _buttonPosition } = useRecoilValue(chromeSyncStorage); + + const [buttonOptionsVisible, setButtonOptionsVisible] = useState(false); + + const [buttonLocation, setButtonLocation] = useState(_buttonPosition.location); + const [buttonPosition, setButtonPosition] = useState(_buttonPosition.position); + const [buttonOpacity, setButtonOpacity] = useState(_buttonPosition.opacity); + const [buttonInactiveSize, setButtonInactiveSize] = useState(_buttonPosition.inactiveSize); + const [buttonActiveScale, setButtonActiveScale] = useState(_buttonPosition.activeScale); + + const currentButtonPosition = useMemo( + (): ButtonPosition => ({ + location: buttonLocation, + position: buttonPosition, + opacity: buttonOpacity, + inactiveSize: buttonInactiveSize, + activeScale: buttonActiveScale, + }), + [buttonActiveScale, buttonInactiveSize, buttonLocation, buttonOpacity, buttonPosition] + ); + + const currentButtonPositionDebounced = useDebounce(currentButtonPosition, 500); const loggedIn = !!authTokens?.loggedIn; + useNonInitialEffect(() => { + chrome.storage.sync.set({ buttonPosition: currentButtonPositionDebounced }).catch((ex) => { + console.warn('Error setting button position', ex); + }); + }, [currentButtonPositionDebounced]); + useEffect(() => { sendMessage({ message: 'VERIFY_AUTH' }).catch((err) => { setAuthError('There was a problem verifying your authentication. Please log in again.'); @@ -44,6 +74,15 @@ export function Component() { }); } + function handleResetButtonOptions() { + setButtonLocation(DEFAULT_BUTTON_POSITION.location); + setButtonPosition(DEFAULT_BUTTON_POSITION.position); + setButtonOpacity(DEFAULT_BUTTON_POSITION.opacity); + setButtonInactiveSize(DEFAULT_BUTTON_POSITION.inactiveSize); + setButtonActiveScale(DEFAULT_BUTTON_POSITION.activeScale); + setButtonOptionsVisible(false); + } + return ( <> <header className="slds-m-bottom_medium"> @@ -71,6 +110,13 @@ export function Component() { /> <hr className="slds-m-vertical_small" /> <p>Visit any Salesforce page to see the floating Jetstream button icon on the right side of the page.</p> + <div + css={css` + width: 25px; + `} + > + <JetstreamIcon /> + </div> <hr className="slds-m-vertical_small" /> <p className="slds-m-bottom_x-small">Feedback or Suggestions?</p> <div> @@ -79,6 +125,78 @@ export function Component() { <div> <FeedbackLink type="EMAIL" label="Send us an email" /> </div> + <hr className="slds-m-vertical_small" /> + <p>Salesforce Button Location</p> + {!buttonOptionsVisible && ( + <button className="slds-button slds-m-top_x-small" onClick={() => setButtonOptionsVisible(true)}> + Show Configuration Options + </button> + )} + {buttonOptionsVisible && ( + <> + <button className="slds-button slds-m-top_x-small slds-m-bottom_small" onClick={() => handleResetButtonOptions()}> + Reset to Default + </button> + <RadioGroup label="Name Type" isButtonGroup className="slds-m-bottom_small"> + <RadioButton + id="sfdc-button-slider-location-left" + name="sfdc-button-slider-location" + label="Left" + value="left" + checked={buttonLocation === 'left'} + onChange={(value) => setButtonLocation(value as 'left')} + /> + <RadioButton + id="sfdc-button-slider-location-rigt" + name="sfdc-button-slider-location" + label="Right" + value="right" + checked={buttonLocation === 'right'} + onChange={(value) => setButtonLocation(value as 'right')} + /> + </RadioGroup> + <Slider + id="sfdc-button-slider-position" + value={`${buttonPosition}`} + label="Vertical Position" + labelHelp="The vertical position of the Jetstream icon on the page." + min={1} + max={window.screen.height - 50 || 1000} + step={10} + onChange={(value) => setButtonPosition(parseInt(value, 10))} + /> + <Slider + id="sfdc-button-slider-opacity" + value={`${buttonOpacity}`} + label="Jetstream Icon Opacity" + labelHelp="Opacity of the Jetstream icon when it is not being hovered over." + min={0.1} + max={1} + step={0.05} + onChange={(value) => setButtonOpacity(Number(value))} + /> + <Slider + id="sfdc-button-slider-inactive-size" + value={`${buttonInactiveSize}`} + label="Jetstream Icon Size" + labelHelp="Size of the Jetstream icon when it is not being hovered over." + min={20} + max={100} + step={5} + onChange={(value) => setButtonInactiveSize(Number(value))} + /> + <Slider + id="sfdc-button-slider-active-scale" + value={`${buttonActiveScale}`} + label="Button Hover Scale" + labelHelp="The scale of the button when you hover over it. 1 is the same size as the non-hovered button." + min={1} + max={5} + step={0.1} + onChange={(value) => setButtonActiveScale(Number(value))} + /> + </> + )} </> )} {(!loggedIn || !authTokens) && ( diff --git a/apps/jetstream-web-extension/src/pages/popup/popup.html b/apps/jetstream-web-extension/src/pages/popup/popup.html index 774ff8f42..7b92be3bf 100644 --- a/apps/jetstream-web-extension/src/pages/popup/popup.html +++ b/apps/jetstream-web-extension/src/pages/popup/popup.html @@ -7,7 +7,7 @@ <style> body { width: 350px; - height: 400px; + min-height: 400px; background-color: white !important; padding: 15px; } diff --git a/apps/jetstream-web-extension/src/utils/extension.store.ts b/apps/jetstream-web-extension/src/utils/extension.store.ts index 75db85b07..0f76e59eb 100644 --- a/apps/jetstream-web-extension/src/utils/extension.store.ts +++ b/apps/jetstream-web-extension/src/utils/extension.store.ts @@ -1,6 +1,6 @@ import { atom, selector } from 'recoil'; import { setRecoil } from 'recoil-nexus'; -import { ChromeStorageState } from './extension.types'; +import { ChromeStorageState, DEFAULT_BUTTON_POSITION } from './extension.types'; chrome.storage.onChanged.addListener((changes, namespace) => { if (namespace === 'local' || namespace === 'sync') { @@ -23,6 +23,7 @@ chrome.storage.onChanged.addListener((changes, namespace) => { newState.sync.authTokens = newState.sync.authTokens ?? null; newState.sync.extIdentifier = newState.sync.extIdentifier ?? null; + newState.sync.buttonPosition = newState.sync.buttonPosition ?? DEFAULT_BUTTON_POSITION; return newState; }); @@ -32,7 +33,7 @@ chrome.storage.onChanged.addListener((changes, namespace) => { async function initAuthState(): Promise<ChromeStorageState> { const [local, sync] = await Promise.all([ chrome.storage.local.get(['options', 'connections']), - chrome.storage.sync.get(['extIdentifier', 'authTokens']), + chrome.storage.sync.get(['extIdentifier', 'authTokens', 'buttonPosition']), ]); return { local: { @@ -46,6 +47,10 @@ async function initAuthState(): Promise<ChromeStorageState> { ...(sync as ChromeStorageState['sync']), authTokens: (sync as ChromeStorageState['sync'])?.authTokens ?? null, extIdentifier: (sync as ChromeStorageState['sync'])?.extIdentifier ?? null, + buttonPosition: { + ...DEFAULT_BUTTON_POSITION, + ...(sync as ChromeStorageState['sync'])?.buttonPosition, + }, }, }; } diff --git a/apps/jetstream-web-extension/src/utils/extension.types.ts b/apps/jetstream-web-extension/src/utils/extension.types.ts index 472fb040e..e60c9ca36 100644 --- a/apps/jetstream-web-extension/src/utils/extension.types.ts +++ b/apps/jetstream-web-extension/src/utils/extension.types.ts @@ -4,6 +4,22 @@ import { z } from 'zod'; export const AUTH_CHECK_INTERVAL_MIN = 5; +export const DEFAULT_BUTTON_POSITION: ButtonPosition = { + location: 'right', + position: 210, + opacity: 0.25, + inactiveSize: 25, + activeScale: 1.5, +}; + +export interface ButtonPosition { + location: 'left' | 'right'; + position: number; + opacity: number; + inactiveSize: number; + activeScale: number; +} + export interface JwtPayload { userId: string; name: string; @@ -19,6 +35,7 @@ export interface ChromeStorageState { sync: { extIdentifier: z.infer<typeof ExtensionIdentifier> | null; authTokens: z.infer<typeof AuthTokens> | null; + buttonPosition: ButtonPosition; }; local: { options: { diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 11dd2ac31..1f3e3b53e 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -63,6 +63,7 @@ export * from './lib/form/radio/RadioGroup'; export * from './lib/form/readonly-form-element/ReadOnlyFormElement'; export * from './lib/form/search-input/SearchInput'; export * from './lib/form/select/Select'; +export * from './lib/form/slider/Slider'; export * from './lib/form/textarea/Textarea'; export * from './lib/form/time-picker/TimePicker'; export * from './lib/google/GoogleSignIn'; diff --git a/libs/ui/src/lib/form/slider/Slider.tsx b/libs/ui/src/lib/form/slider/Slider.tsx index cd2c480f8..521ed8dc9 100644 --- a/libs/ui/src/lib/form/slider/Slider.tsx +++ b/libs/ui/src/lib/form/slider/Slider.tsx @@ -1,3 +1,4 @@ +import { css } from '@emotion/react'; import { SizeXSmallSmallMediumLarge } from '@jetstream/types'; import classNames from 'classnames'; import React, { FunctionComponent, RefObject } from 'react'; @@ -11,8 +12,9 @@ export interface SliderProps { size?: SizeXSmallSmallMediumLarge; value: string; label: string; - rangeLabel: string; + rangeLabel?: string; hideLabel?: boolean; + hideRangLabel?: boolean; labelHelp?: string | JSX.Element | null; helpText?: React.ReactNode | string; disabled?: boolean; @@ -37,6 +39,7 @@ export const Slider: FunctionComponent<SliderProps> = ({ label, rangeLabel, hideLabel, + hideRangLabel, labelHelp, helpText, hasError = false, @@ -52,11 +55,14 @@ export const Slider: FunctionComponent<SliderProps> = ({ }) => { // TODO: value state const sizeClass = size ? `slds-size_${size}` : undefined; + rangeLabel = rangeLabel ?? `${min} - ${max}`; return ( <div className={classNames('slds-form-element', className, { 'slds-has-error': hasError })}> <label className="slds-form-element__label" htmlFor={id}> <span className={classNames('slds-slider-label__label', { 'sr-only': hideLabel || !label })}>{label}</span> - <span className={classNames('slds-slider-label__range', { 'sr-only': hideLabel || !rangeLabel })}>{rangeLabel}</span> + <span className={classNames('slds-slider-label__range', { 'sr-only': hideLabel || hideRangLabel || !rangeLabel })}> + {rangeLabel} + </span> </label> {labelHelp && !hideLabel && <HelpText id={`${id}-label-help-text`} content={labelHelp} />} <div className="slds-form-element__control"> @@ -84,7 +90,13 @@ export const Slider: FunctionComponent<SliderProps> = ({ step={step} onChange={(event) => onChange && onChange(event.target.value)} /> - <span className="slds-slider__value" aria-hidden="true"> + <span + css={css` + min-width: 20px; + `} + className="slds-slider__value" + aria-hidden="true" + > {value} </span> </div>