Skip to content

Commit

Permalink
Merge pull request #1108 from jetstreamapp/feat/1032-add-user-search
Browse files Browse the repository at this point in the history
Add User Search
  • Loading branch information
paustint authored Dec 9, 2024
2 parents abbd807 + 68e2e78 commit 074d526
Show file tree
Hide file tree
Showing 16 changed files with 451 additions and 32 deletions.
11 changes: 11 additions & 0 deletions apps/docs/docs/assets/icons/people.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions apps/docs/docs/other/other-useful-features.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,21 @@ sidebar_label: Other Useful Features
slug: /other-useful-features
---

import PeopleIcon from '../assets/icons/people.svg';
import RecordLookupIcon from '../assets/icons/record_lookup.svg';

## Searching for Users

If you need to find a user in your Salesforce org, you can use the user search feature in Jetstream.

Click the <PeopleIcon className="icon inline" /> people icon in the top toolbar to open the user search popover.

Once open, you can search for a user by their name, id, email address, or username.

You can then view click the user to view their details or click "View in Salesforce" to open the user in Salesforce.

<img src={require('./user-search.png').default} alt="Search for Users" />

## Viewing, Editing, and Cloning records

Jetstream provides a few different ways for working with records.
Expand Down
Binary file added apps/docs/docs/other/user-search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions libs/icon-factory/src/lib/icon-factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import UtilityIcon_OpenFolder from './icons/utility/OpenFolder';
import UtilityIcon_Page from './icons/utility/Page';
import UtilityIcon_Paste from './icons/utility/Paste';
import UtilityIcon_Pause from './icons/utility/Pause';
import UtilityIcon_People from './icons/utility/People';
import UtilityIcon_Play from './icons/utility/Play';
import UtilityIcon_Preview from './icons/utility/Preview';
import UtilityIcon_ProfileAlt from './icons/utility/ProfileAlt';
Expand Down Expand Up @@ -284,6 +285,7 @@ const utilityIcons = {
page: UtilityIcon_Page,
paste: UtilityIcon_Paste,
pause: UtilityIcon_Pause,
people: UtilityIcon_People,
play: UtilityIcon_Play,
preview: UtilityIcon_Preview,
profile_alt: UtilityIcon_ProfileAlt,
Expand Down
5 changes: 5 additions & 0 deletions libs/shared/constants/src/lib/shared-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ export const ANALYTICS_KEYS = {
record_modal_json: 'record_modal_json',
record_modal_save: 'record_modal_save',
record_modal_clipboard: 'record_modal_clipboard',
/** USER SEARCH MODAL */
user_search_opened: 'user_search_opened',
user_search_view_user: 'user_search_view_user',
user_search_copy_item: 'user_search_copy_item',
user_search_did_search: 'user_search_did_search',
/** AUTOMATION CONTROL */
automation_selection: 'automation_selection',
automation_review: 'automation_review',
Expand Down
19 changes: 3 additions & 16 deletions libs/shared/ui-core/src/app/HeaderNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import Jobs from '../jobs/Jobs';
import OrgsDropdown from '../orgs/OrgsDropdown';
import { SelectedOrgReadOnly } from '../orgs/SelectedOrgReadOnly';
import { RecordSearchPopover } from '../record/RecordSearchPopover';
import { UserSearchPopover } from '../record/UserSearchPopover';
import { applicationCookieState, selectUserPreferenceState } from '../state-management/app-state';
import { HeaderAnnouncementPopover } from './HeaderAnnouncementPopover';
import HeaderDonatePopover from './HeaderDonatePopover';
import HeaderHelpPopover from './HeaderHelpPopover';
import NotificationsRequestModal from './NotificationsRequestModal';
Expand Down Expand Up @@ -82,21 +82,8 @@ export const HeaderNavbar: FunctionComponent<HeaderNavbarProps> = ({ userProfile

const rightHandMenuItems = useMemo(() => {
return isChromeExtension
? [<RecordSearchPopover />, <Jobs />, <HeaderHelpPopover />]
: [
<HeaderAnnouncementPopover>
<p className="">We have launched our new authentication experience</p>
<p className="slds-text-title_caps slds-m-top_x-small">New Features:</p>
<ul className="slds-list_dotted slds-m-vertical_x-small">
<li>Multi-factor authentication via email or authenticator app</li>
<li>Visibility to all active sessions, with option to revoke sessions</li>
</ul>
</HeaderAnnouncementPopover>,
<RecordSearchPopover />,
<Jobs />,
<HeaderHelpPopover />,
<HeaderDonatePopover />,
];
? [<RecordSearchPopover />, <UserSearchPopover />, <Jobs />, <HeaderHelpPopover />]
: [<RecordSearchPopover />, <UserSearchPopover />, <Jobs />, <HeaderHelpPopover />, <HeaderDonatePopover />];
}, [isChromeExtension, userProfile]);

return (
Expand Down
268 changes: 268 additions & 0 deletions libs/shared/ui-core/src/record/UserSearchPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { css } from '@emotion/react';
import { logger } from '@jetstream/shared/client-logger';
import { ANALYTICS_KEYS } from '@jetstream/shared/constants';
import { query } from '@jetstream/shared/data';
import { hasModifierKey, isUKey, useDebounce, useGlobalEventHandler } from '@jetstream/shared/ui-utils';
import { CloneEditView, QueryResults, SalesforceOrgUi } from '@jetstream/types';
import {
CopyToClipboard,
getModifierKey,
Grid,
Icon,
KeyboardShortcut,
List,
Popover,
PopoverRef,
SalesforceLogin,
ScopedNotification,
SearchInput,
} from '@jetstream/ui';
import { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useAmplitude } from '../analytics';
import { applicationCookieState, selectedOrgState, selectSkipFrontdoorAuth } from '../state-management/app-state';
import { getSearchUserSoql } from './record-utils';
import { ViewEditCloneRecord } from './ViewEditCloneRecord';

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';
}

export const UserSearchPopover: FunctionComponent = () => {
const currentSearchRef = useRef<number>(0);
const popoverRef = useRef<PopoverRef>(null);
const [isOpen, setIsOpen] = useState(false);
const [{ defaultApiVersion, serverUrl }] = useRecoilState(applicationCookieState);
const skipFrontDoorAuth = useRecoilValue(selectSkipFrontdoorAuth);
const selectedOrg = useRecoilValue<SalesforceOrgUi>(selectedOrgState);
const { trackEvent } = useAmplitude();

const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [usersResults, setUsersResults] = useState<QueryResults<User>>();

const [searchTerm, setSearchTerm] = useState<string>('');
const searchTermDebounced = useDebounce(searchTerm, 500);

useEffect(() => {
if (!isOpen) {
return;
}
setLoading(true);
const currentSearchValue = currentSearchRef.current;
trackEvent(ANALYTICS_KEYS.user_search_did_search);
query<User>(selectedOrg, getSearchUserSoql(searchTermDebounced))
.then((users) => {
if (currentSearchRef.current === currentSearchValue) {
setUsersResults(users);
setLoading(false);
}
})
.catch((ex) => {
setErrorMessage('An error occurred while fetching users');
logger.warn('[ERROR] Could not fetch users', ex);
});
return () => {
currentSearchRef.current = currentSearchRef.current + 1;
};
}, [isOpen, searchTermDebounced, selectedOrg, trackEvent]);

const [action, setAction] = useState<CloneEditView>('view');

const onKeydown = useCallback((event: KeyboardEvent) => {
if (hasModifierKey(event as any) && isUKey(event as any)) {
event.stopPropagation();
event.preventDefault();
popoverRef.current?.open();
}
}, []);

useGlobalEventHandler('keydown', onKeydown);

function onActionChange(action: CloneEditView) {
setAction(action);
}

function onModalClose() {
setSelectedUser(null);
setAction('view');
}

if (!selectedOrg || !!selectedOrg.connectionError) {
return null;
}

return (
<Fragment>
{selectedUser && (
<ViewEditCloneRecord
apiVersion={defaultApiVersion}
selectedOrg={selectedOrg}
action={action}
sobjectName="User"
recordId={selectedUser.Id}
onClose={onModalClose}
onChangeAction={onActionChange}
/>
)}
<Popover
ref={popoverRef}
size="large"
onChange={(isOpen) => {
setIsOpen(isOpen);
isOpen && trackEvent(ANALYTICS_KEYS.user_search_opened);
}}
header={
<header className="slds-popover__header slds-grid">
<h2 className="slds-text-heading_small">Search Users</h2>
<KeyboardShortcut className="slds-m-left_x-small" keys={[getModifierKey(), 'u']} />
</header>
}
content={
<div className="slds-popover__body slds-p-around_none slds-is-relative">
{errorMessage && (
<div className="slds-m-around-medium">
<ScopedNotification theme="error" className="slds-m-top_medium">
{errorMessage}
</ScopedNotification>
</div>
)}
<Grid verticalAlign="end">
<SearchInput
id="user-search"
className="w-100"
placeholder="Id, Name, Email, or Username"
autoFocus
loading={loading}
value={searchTerm}
onChange={(value) => setSearchTerm(value.trim())}
/>
</Grid>
{!!usersResults && !usersResults?.queryResults.records?.length && (
<p className="slds-text-align_center slds-m-vertical_x-small slds-text-heading_small">No Results</p>
)}
{!!usersResults?.queryResults.records?.length && (
<Fragment>
<h2 className="slds-text-heading_small slds-m-top_small" title="Users">
Users
</h2>
<List
css={css`
max-height: 75vh;
overflow-y: auto;
`}
items={usersResults.queryResults.records}
isActive={(item: User) => item.Id === searchTerm}
onSelected={(key: string) => {
const user = usersResults?.queryResults.records.find(({ Id }) => Id === key);
if (user) {
setSelectedUser(user);
trackEvent(ANALYTICS_KEYS.user_search_view_user);
}
}}
getContent={(user: User) => ({
key: user.Id,
id: user.Id,
heading: getListItemContent({ user, onCopy: (type) => trackEvent(ANALYTICS_KEYS.user_search_copy_item, { type }) }),
children: (
<Grid align="spread">
<SalesforceLogin
serverUrl={serverUrl}
skipFrontDoorAuth={skipFrontDoorAuth}
className="slds-button"
org={selectedOrg}
returnUrl={`/lightning/setup/ManageUsers/page?address=${encodeURIComponent(`/${user.Id}?noredirect=1`)}`}
title="View user in Salesforce"
onClick={(event, url) => event.stopPropagation()}
>
View in Salesforce
</SalesforceLogin>
</Grid>
),
})}
searchTerm={searchTerm}
/>
</Fragment>
)}
</div>
}
buttonProps={{
className:
'slds-button slds-button_icon slds-button_icon-container slds-button_icon-small slds-global-actions__help slds-global-actions__item-action cursor-pointer',
title: 'View Record Details - ctrl/command + k',
disabled: !selectedOrg || !!selectedOrg.connectionError,
}}
>
<Icon type="utility" icon="people" className="slds-button__icon slds-global-header__icon" omitContainer />
</Popover>
</Fragment>
);
};

function getListItemContent({ user, onCopy }: { user: User; onCopy: (type: string) => void }) {
const { Alias, Email, Id, IsActive, Name, Profile, Username, UserType, UserRole } = user;
return (
<div>
<p className="text-bold">
{Name} ({Alias})
</p>
<p>
<span className="text-bold" title={Email}>
Email:
</span>{' '}
<CopyToClipboard content={Email} copied={() => onCopy('email')} />
{Email}
</p>
<p>
<span className="text-bold" title={Username}>
Username:
</span>{' '}
<CopyToClipboard content={Username} copied={() => onCopy('username')} />
{Username}
</p>
{Profile?.Name && (
<p>
<span className="text-bold">Profile: </span>
{Profile.Name}
</p>
)}
{UserRole?.Name && (
<p>
<span className="text-bold">Role: </span>
{UserRole.Name}
</p>
)}
{!IsActive && <p className="slds-text-color_destructive">Inactive</p>}
<p className="slds-text-body_small slds-text-color_weak slds-truncate">
<CopyToClipboard content={Id} copied={() => onCopy('id')} />
{Id}
</p>
{UserType !== 'Standard' && <p>{UserType}</p>}
</div>
);
}
Loading

0 comments on commit 074d526

Please sign in to comment.