From 93a595cc4db7b9faf3292349b84eadf04fb95a32 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 22 Aug 2026 08:27:31 -0500 Subject: [PATCH] feat(orgs): show full usernames in org dropdown and simplify custom domain input Widen the org dropdown panel beyond its input, stop truncating usernames, and tag each row with its org type so sandboxes are distinguishable at a glance. Replace the addon-wrapped custom domain field with a single input that accepts any pasted Salesforce URL and infers the sandbox suffix from `--` (#1246). Also fixes: username/org id not searchable, org color hidden on connection error, and no sandbox cue on Org Groups cards. --- .../src/app/controllers/oauth.controller.ts | 19 +- .../jetstream-e2e/src/tests/orgs/orgs.spec.ts | 3 +- .../src/lib/SalesforceOrgCardDraggable.tsx | 11 +- .../constants/src/lib/shared-constants.ts | 11 ++ libs/shared/ui-core/src/orgs/AddOrg.tsx | 113 +++++++----- libs/shared/ui-core/src/orgs/OrgsCombobox.tsx | 43 ++++- .../src/orgs/__tests__/AddOrg.spec.tsx | 145 +++++++++++++++ .../src/orgs/__tests__/OrgsCombobox.spec.tsx | 117 ++++++++++++ .../salesforce-login-url.utils.spec.ts | 173 ++++++++++++++++++ .../src/orgs/salesforce-login-url.utils.ts | 131 +++++++++++++ .../pageObjectModels/OrgGroupPage.model.ts | 7 +- libs/ui/src/lib/form/combobox/Combobox.tsx | 13 +- .../lib/form/combobox/ComboboxListItem.tsx | 73 ++++++-- .../form/combobox/__tests__/Combobox.spec.tsx | 30 +++ .../__tests__/ComboboxListItem.spec.tsx | 74 ++++++++ 15 files changed, 886 insertions(+), 77 deletions(-) create mode 100644 libs/shared/ui-core/src/orgs/__tests__/AddOrg.spec.tsx create mode 100644 libs/shared/ui-core/src/orgs/__tests__/OrgsCombobox.spec.tsx create mode 100644 libs/shared/ui-core/src/orgs/__tests__/salesforce-login-url.utils.spec.ts create mode 100644 libs/shared/ui-core/src/orgs/salesforce-login-url.utils.ts create mode 100644 libs/ui/src/lib/form/combobox/__tests__/Combobox.spec.tsx create mode 100644 libs/ui/src/lib/form/combobox/__tests__/ComboboxListItem.spec.tsx diff --git a/apps/api/src/app/controllers/oauth.controller.ts b/apps/api/src/app/controllers/oauth.controller.ts index 7a2b82de7c..ad13cb0f52 100644 --- a/apps/api/src/app/controllers/oauth.controller.ts +++ b/apps/api/src/app/controllers/oauth.controller.ts @@ -3,7 +3,14 @@ import { AuditLogAction, AuditLogResource, createTeamAuditLog } from '@jetstream import { getApiAddressFromReq } from '@jetstream/auth/server'; import { ApiConnection, ApiRequestError, getApiRequestFactoryFn } from '@jetstream/salesforce-api'; import * as oauthService from '@jetstream/salesforce-oauth'; -import { ERROR_MESSAGES } from '@jetstream/shared/constants'; +import { + ERROR_MESSAGES, + SFDC_LOGIN_URL_PRE_RELEASE, + SFDC_LOGIN_URL_PROD, + SFDC_LOGIN_URL_SANDBOX, + SFDC_LOGIN_URL_WELCOME, + SFDC_MY_DOMAIN_LOGIN_URL_REGEX, +} from '@jetstream/shared/constants'; import { getErrorMessage } from '@jetstream/shared/utils'; import { Maybe, SalesforceOrgUi, SObjectOrganization } from '@jetstream/types'; import { ResponseBodyError } from 'oauth4webapi'; @@ -27,11 +34,11 @@ export const routeDefinition = { validators: { query: z.object({ loginUrl: z.union([ - z.literal('https://login.salesforce.com'), - z.literal('https://test.salesforce.com'), - z.literal('https://welcome.salesforce.com'), - z.literal('https://prerellogin.pre.salesforce.com'), - z.string().regex(/^https:\/\/[a-zA-Z0-9.-]+\.my\.salesforce\.com$/), + z.literal(SFDC_LOGIN_URL_PROD), + z.literal(SFDC_LOGIN_URL_SANDBOX), + z.literal(SFDC_LOGIN_URL_WELCOME), + z.literal(SFDC_LOGIN_URL_PRE_RELEASE), + z.string().regex(SFDC_MY_DOMAIN_LOGIN_URL_REGEX), ]), addLoginParam: z .enum(['true', 'false']) diff --git a/apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts b/apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts index d9b56ed2c8..d41be99325 100644 --- a/apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts +++ b/apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts @@ -70,7 +70,8 @@ test.describe('Salesforce Orgs + Jetstream Orgs', () => { await orgGroupPage.orgDropdownContainer.click(); const orgGroup = orgGroupPage.orgDropdownContainer.getByRole('listbox').getByRole('group').getByRole('option'); await expect(orgGroup).toHaveCount(2); - await expect(orgGroup).toHaveText([environment.TEST_ORG_2, environment.TEST_ORG_3]); + // Options render the username plus an org-type badge, so match on containment rather than exact text + await expect(orgGroup).toContainText([environment.TEST_ORG_2, environment.TEST_ORG_3]); }); await test.step('Edit Org Group', async () => { diff --git a/libs/features/org-groups/src/lib/SalesforceOrgCardDraggable.tsx b/libs/features/org-groups/src/lib/SalesforceOrgCardDraggable.tsx index 27053f9b7c..5c1c6ccb2b 100644 --- a/libs/features/org-groups/src/lib/SalesforceOrgCardDraggable.tsx +++ b/libs/features/org-groups/src/lib/SalesforceOrgCardDraggable.tsx @@ -1,7 +1,8 @@ import { useDraggable } from '@dnd-kit/react'; import { css } from '@emotion/react'; +import { getOrgType } from '@jetstream/shared/ui-utils'; import { AddOrgHandlerFn, SalesforceOrgUi } from '@jetstream/types'; -import { Grid, Icon } from '@jetstream/ui'; +import { Badge, Grid, Icon } from '@jetstream/ui'; import { OrgInfoPopover, useUpdateOrgs } from '@jetstream/ui-core'; import { DraggableSfdcCard } from './organization-group.types'; import { SalesforceOrgCardConnectionRefresh } from './SalesforceOrgCardConnectionRefresh'; @@ -18,6 +19,7 @@ interface SalesforceOrgCardDraggableProps { export function SalesforceOrgCardDraggable({ org, isActive, onAddOrgHandlerFn }: SalesforceOrgCardDraggableProps) { const { actionInProgress, orgLoading, handleAddOrg, handleRemoveOrg, handleUpdateOrg } = useUpdateOrgs(); + const orgType = getOrgType(org); const { ref, isDragging } = useDraggable({ id: org.uniqueId, @@ -72,7 +74,12 @@ export function SalesforceOrgCardDraggable({ org, isActive, onAddOrgHandlerFn }: >

{org.label}

- + + {orgType && ( + + {orgType} + + )} {org.color && (
= ({ }) => { const popoverRef = useRef(null); const { trackEvent } = useAmplitude(); - const [orgType, setOrgType] = useState(() => (existingOrg ? 'custom' : 'prod')); - const [customUrl, setCustomUrl] = useState(() => { - if (!existingOrg) { - return ''; - } - try { - return new URL(existingOrg.instanceUrl).hostname.replace('.my.salesforce.com', ''); - } catch { - return ''; - } - }); - const [loginUrl, setLoginUrl] = useState(() => (existingOrg?.instanceUrl ? existingOrg.instanceUrl : loginUrlMap.prod)); + const [orgType, setOrgType] = useState(() => (getExistingOrgDomain(existingOrg) ? 'custom' : 'prod')); + const [customUrl, setCustomUrl] = useState(() => getExistingOrgDomain(existingOrg) || ''); const [advancedOptionsEnabled, setAdvancedOptionsEnabled] = useState(false); const [addLoginTrue, setAddLoginTrue] = useState(false); const [addToActiveOrgGroup, setAddToActiveOrgGroup] = useState(true); const applicationState = useAtomValue(fromAppState.applicationCookieState); const orgGroup = useAtomValue(fromAppState.jetstreamActiveGroupSelector); - useEffect(() => { - let url: string; - if (orgType === 'custom') { - url = getFQDN(customUrl); - } else { - url = loginUrlMap[orgType] || 'https://login.salesforce.com'; - } - setLoginUrl(url); - }, [orgType, customUrl]); + const parsedCustomUrl = useMemo(() => parseSalesforceLoginUrl(customUrl), [customUrl]); + const showCustomUrlError = orgType === 'custom' && !!customUrl.trim() && !parsedCustomUrl.success; + const loginUrl = orgType === 'custom' ? (parsedCustomUrl.success ? parsedCustomUrl.loginUrl : null) : loginUrlMap[orgType]; + const canContinue = !!loginUrl; function handleAddOrg() { loginUrl && @@ -112,7 +107,6 @@ export const AddOrg: FunctionComponent = ({ } setOrgType('prod'); setCustomUrl(''); - setLoginUrl(loginUrlMap.prod); setAdvancedOptionsEnabled(false); setAddLoginTrue(false); setAddToActiveOrgGroup(true); @@ -122,6 +116,7 @@ export const AddOrg: FunctionComponent = ({ // TODO: figure out way to close this once an org is added - this was fixed, but it caused the component to fully re-render each time! !isOpen && handleReset()} // placement="bottom-end" header={ @@ -141,7 +136,13 @@ export const AddOrg: FunctionComponent = ({ checked={orgType === 'prod'} onChange={() => setOrgType('prod')} /> - setOrgType('sandbox')} /> + setOrgType('sandbox')} + /> = ({ {orgType === 'custom' && ( + + {parsedCustomUrl.loginUrl} + + {parsedCustomUrl.isSandbox && ( + + + Sandbox detected + + )} + + ) : null + } > - setCustomUrl((_prevValue) => (event.target.value || '').replaceAll(/(https:\/\/)|(\.my\.salesforce\.com)/g, '')) - } + aria-describedby={showCustomUrlError ? 'org-custom-url-error' : undefined} + aria-invalid={showCustomUrlError} + autoComplete="off" + spellCheck={false} + onChange={(event) => setCustomUrl(event.target.value || '')} /> )} @@ -220,7 +241,7 @@ export const AddOrg: FunctionComponent = ({ - diff --git a/libs/shared/ui-core/src/orgs/OrgsCombobox.tsx b/libs/shared/ui-core/src/orgs/OrgsCombobox.tsx index da0c2de1dc..690786e319 100644 --- a/libs/shared/ui-core/src/orgs/OrgsCombobox.tsx +++ b/libs/shared/ui-core/src/orgs/OrgsCombobox.tsx @@ -1,11 +1,25 @@ import { css, SerializedStyles } from '@emotion/react'; +import { getOrgType } from '@jetstream/shared/ui-utils'; +import { multiWordObjectFilter } from '@jetstream/shared/utils'; import { ListItem, ListItemGroup, Maybe, SalesforceOrgUi } from '@jetstream/types'; -import { ComboboxWithGroupedItems } from '@jetstream/ui'; +import { Badge, ComboboxWithGroupedItems } from '@jetstream/ui'; import groupBy from 'lodash/groupBy'; import sortBy from 'lodash/sortBy'; import { FunctionComponent, useEffect, useState } from 'react'; import { calculateOrgExpiration } from './useOrgExpiration'; +/** + * Everything a user might reasonably search the org list by. The default combobox filter only looks + * at `label` and `value`, which hides the username as soon as an org is given a custom label. + * `uniqueId` (`-`) is kept so pasting a full org id still works. + */ +const ORG_SEARCH_FIELDS: Array = ['label', 'username', 'orgName', 'organizationId', 'instanceUrl', 'uniqueId']; + +const orgFilterFn = (filter: string) => { + const matchesOrg = multiWordObjectFilter(ORG_SEARCH_FIELDS, filter); + return (item: ListItem) => !!item.meta && matchesOrg(item.meta); +}; + function getSelectedItemLabel(item: ListItem) { const org = item.meta; if (!org) { @@ -30,8 +44,12 @@ function getSelectedItemTitle(item: ListItem) { return `${org.orgInstanceName} - ${org.label}${subtext}`; } +/** + * The color is intentionally kept even when the org has a connection error - knowing which org you + * are pointed at matters most when something is wrong. The error styling layers on top of it. + */ function getSelectedItemStyle(org: Maybe): SerializedStyles | undefined { - if (!org || !org.color || !!org.connectionError) { + if (!org || !org.color) { return; } return css({ @@ -59,6 +77,18 @@ function orgHasError(org: Maybe): boolean { return !!org.connectionError || !!org.expirationScheduledFor; } +function getOrgTypeBadge(org: Maybe) { + const orgType = getOrgType(org); + if (!orgType) { + return undefined; + } + return ( + + {orgType} + + ); +} + function groupOrgs(orgs: SalesforceOrgUi[]): ListItemGroup[] { const orgsById = groupBy(sortBy(orgs, ['label']), 'orgName'); return Object.keys(orgsById).map((key): ListItemGroup => ({ @@ -127,16 +157,19 @@ export const OrgsCombobox: FunctionComponent = ({ itemLength: 7, hasError: orgHasError(selectedOrg), disabled, - // onInputChange: (filter) => setFilterText(filter), - // selectedItemLabel: getSelectedItemLabel(selectedOrg), - // selectedItemTitle: getSelectedItemTitle(selectedOrg), inputCss: getSelectedItemStyle(selectedOrg), + // Usernames often differ only by a trailing sandbox suffix, so the panel is allowed to + // grow past the input rather than ellipsing away the part that distinguishes the orgs. + dropdownWidth: { minWidth: '100%', maxWidth: '32rem' }, }} itemProps={(item) => ({ hasError: orgHasError(item.meta), textBodyCss: getDropdownOrgStyle(item.meta), + labelSuffix: getOrgTypeBadge(item.meta), + allowWrap: true, })} groups={groupedOrgs} + filterFn={orgFilterFn} onSelected={(item) => onSelected(item.meta)} selectedItemId={selectedOrg?.uniqueId} selectedItemLabelFn={getSelectedItemLabel} diff --git a/libs/shared/ui-core/src/orgs/__tests__/AddOrg.spec.tsx b/libs/shared/ui-core/src/orgs/__tests__/AddOrg.spec.tsx new file mode 100644 index 0000000000..db6a9b3775 --- /dev/null +++ b/libs/shared/ui-core/src/orgs/__tests__/AddOrg.spec.tsx @@ -0,0 +1,145 @@ +import { SalesforceOrgUi } from '@jetstream/types'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { atom } from 'jotai'; +import { describe, expect, it, vi } from 'vitest'; + +// `@jetstream/ui/app-state` fetches app info and the user profile at module load, and `..` is the +// full ui-core barrel - neither is needed to exercise the login url derivation this component owns. +// `doMock` rather than `mock` so the stub atoms can be built from a normal top-level import. +vi.doMock('@jetstream/ui/app-state', () => ({ + fromAppState: { + applicationCookieState: atom({ serverUrl: 'https://test.getjetstream.app' }), + jetstreamActiveGroupSelector: atom(undefined), + }, +})); + +vi.doMock('../..', () => ({ + useAmplitude: () => ({ trackEvent: vi.fn() }), +})); + +const { AddOrg } = await import('../AddOrg'); + +function buildOrg(instanceUrl: string): SalesforceOrgUi { + return { + uniqueId: '00D000000000001-005000000000001', + label: 'john.smith@acme.com', + username: 'john.smith@acme.com', + instanceUrl, + } as SalesforceOrgUi; +} + +function renderOpen(existingOrg?: SalesforceOrgUi) { + const onAddOrg = vi.fn(); + const onAddOrgHandlerFn = vi.fn(); + const result = render(); + fireEvent.click(screen.getByRole('button', { name: /add org/i })); + return { ...result, onAddOrgHandlerFn }; +} + +function getCustomUrlInput() { + return document.querySelector('input#org-custom-url') as HTMLInputElement; +} + +function clickContinue() { + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); +} + +describe('AddOrg', () => { + describe('reconnecting an existing org', () => { + // The prefill is re-parsed to build the login url, so anything it drops sends the user to a host + // their org does not serve + it.each([ + ['https://acme.develop.my.salesforce.com'], + ['https://acme.sandbox.my.salesforce.com'], + ['https://acme--uat.sandbox.my.salesforce.com'], + ['https://acme.my.salesforce.com'], + ])('reconnects %s to the same host', (instanceUrl) => { + const { onAddOrgHandlerFn } = renderOpen(buildOrg(instanceUrl)); + + expect((screen.getByLabelText('Custom Login URL') as HTMLInputElement).checked).toBe(true); + clickContinue(); + + expect(onAddOrgHandlerFn).toHaveBeenCalledWith(expect.objectContaining({ loginUrl: instanceUrl }), expect.any(Function)); + }); + + it('passes the existing username as the login hint', () => { + const { onAddOrgHandlerFn } = renderOpen(buildOrg('https://acme.my.salesforce.com')); + clickContinue(); + expect(onAddOrgHandlerFn).toHaveBeenCalledWith(expect.objectContaining({ loginHint: 'john.smith@acme.com' }), expect.any(Function)); + }); + + it.each([['https://login.salesforce.com'], ['https://na139.salesforce.com']])( + 'falls back to production for %s, which is not a My Domain', + (instanceUrl) => { + const { onAddOrgHandlerFn } = renderOpen(buildOrg(instanceUrl)); + + expect((screen.getByLabelText('Production / Developer') as HTMLInputElement).checked).toBe(true); + clickContinue(); + + expect(onAddOrgHandlerFn).toHaveBeenCalledWith( + expect.objectContaining({ loginUrl: 'https://login.salesforce.com' }), + expect.any(Function), + ); + }, + ); + }); + + describe('org type selection', () => { + it.each([ + ['Production / Developer', 'https://login.salesforce.com'], + ['Sandbox (test.salesforce.com)', 'https://test.salesforce.com'], + ['Pre-release', 'https://prerellogin.pre.salesforce.com'], + ])('sends %s to %s', (radioLabel, expected) => { + const { onAddOrgHandlerFn } = renderOpen(); + + fireEvent.click(screen.getByLabelText(radioLabel)); + clickContinue(); + + expect(onAddOrgHandlerFn).toHaveBeenCalledWith(expect.objectContaining({ loginUrl: expected }), expect.any(Function)); + }); + }); + + describe('custom login url', () => { + function selectCustom() { + fireEvent.click(screen.getByLabelText('Custom Login URL')); + } + + it('disables Continue until a valid domain is entered', () => { + renderOpen(); + selectCustom(); + + const continueButton = screen.getByRole('button', { name: 'Continue' }) as HTMLButtonElement; + expect(continueButton.disabled).toBe(true); + + fireEvent.change(getCustomUrlInput(), { target: { value: 'not a domain' } }); + expect(continueButton.disabled).toBe(true); + + fireEvent.change(getCustomUrlInput(), { target: { value: 'acme' } }); + expect(continueButton.disabled).toBe(false); + }); + + it('describes the error to screen readers while the input is invalid', () => { + renderOpen(); + selectCustom(); + fireEvent.change(getCustomUrlInput(), { target: { value: 'https://evil.com' } }); + + expect(getCustomUrlInput().getAttribute('aria-describedby')).toBe('org-custom-url-error'); + expect(getCustomUrlInput().getAttribute('aria-invalid')).toBe('true'); + + fireEvent.change(getCustomUrlInput(), { target: { value: 'acme' } }); + expect(getCustomUrlInput().getAttribute('aria-describedby')).toBeNull(); + }); + + it('builds the login url from shorthand', () => { + const { onAddOrgHandlerFn } = renderOpen(); + selectCustom(); + fireEvent.change(getCustomUrlInput(), { target: { value: 'acme--uat' } }); + clickContinue(); + + expect(onAddOrgHandlerFn).toHaveBeenCalledWith( + expect.objectContaining({ loginUrl: 'https://acme--uat.sandbox.my.salesforce.com' }), + expect.any(Function), + ); + }); + }); +}); diff --git a/libs/shared/ui-core/src/orgs/__tests__/OrgsCombobox.spec.tsx b/libs/shared/ui-core/src/orgs/__tests__/OrgsCombobox.spec.tsx new file mode 100644 index 0000000000..60ed6b9798 --- /dev/null +++ b/libs/shared/ui-core/src/orgs/__tests__/OrgsCombobox.spec.tsx @@ -0,0 +1,117 @@ +import { SalesforceOrgUi } from '@jetstream/types'; +import { fireEvent, render, waitFor, within } from '@testing-library/react'; +import { OrgsCombobox } from '../OrgsCombobox'; + +function buildOrg(overrides: Partial): SalesforceOrgUi { + return { + uniqueId: '00D000000000001-005000000000001', + label: 'john.smith@acme.com', + username: 'john.smith@acme.com', + orgName: 'ACME Corporation', + organizationId: '00D000000000001', + instanceUrl: 'https://acme.my.salesforce.com', + filterText: '', + accessToken: '', + loginUrl: '', + userId: '005000000000001', + email: 'john.smith@acme.com', + displayName: 'John Smith', + ...overrides, + } as SalesforceOrgUi; +} + +const production = buildOrg({ uniqueId: 'prod', orgOrganizationType: 'Enterprise Edition' }); +const uatSandbox = buildOrg({ + uniqueId: 'uat', + label: 'UAT Sandbox', + username: 'john.smith@acme.com.uat', + organizationId: '00D000000000002', + instanceUrl: 'https://acme--uat.sandbox.my.salesforce.com', + orgIsSandbox: true, +}); +const fullCopySandbox = buildOrg({ + uniqueId: 'fullcopy', + label: 'Full Copy', + username: 'john.smith@acme.com.fullcopy', + orgIsSandbox: true, +}); + +const orgs = [production, uatSandbox, fullCopySandbox]; + +function renderOpen(selectedOrg: SalesforceOrgUi | null = null) { + const onSelected = vi.fn(); + const result = render(); + const input = result.container.querySelector('input') as HTMLInputElement; + fireEvent.click(input); + return { ...result, input, onSelected, listbox: result.container.querySelector('[role="listbox"]') as HTMLElement }; +} + +describe('OrgsCombobox', () => { + it('shows the full username for every org rather than truncating it', () => { + const { listbox } = renderOpen(); + // The suffix is the only thing distinguishing these orgs, so it must survive + expect(within(listbox).getByText('john.smith@acme.com.uat')).toBeTruthy(); + expect(within(listbox).getByText('john.smith@acme.com.fullcopy')).toBeTruthy(); + }); + + it('does not truncate usernames', () => { + const { listbox } = renderOpen(); + expect(within(listbox).getByText('john.smith@acme.com.uat').className).not.toContain('slds-truncate'); + }); + + it('tags each org with its type so sandboxes are distinguishable at a glance', () => { + const { listbox } = renderOpen(); + expect(within(listbox).getAllByText('Sandbox')).toHaveLength(2); + expect(within(listbox).getAllByText('Production')).toHaveLength(1); + }); + + it('lets the panel size itself independently of the input', () => { + const { listbox } = renderOpen(); + const styles = getComputedStyle(listbox); + expect(listbox.className).not.toContain('slds-dropdown_fluid'); + expect(styles.maxWidth).toBe('32rem'); + }); + + describe('search', () => { + // The combobox debounces filter input, so assertions wait for the filtered list to settle + async function search(term: string) { + const { input, container } = renderOpen(); + // The combobox reads the filter off keyUp, not change + fireEvent.change(input, { target: { value: term } }); + fireEvent.keyUp(input, { key: term.slice(-1) }); + const listbox = () => container.querySelector('[role="listbox"]') as HTMLElement; + await waitFor(() => expect(within(listbox()).queryAllByRole('option').length).toBeLessThan(orgs.length)); + return listbox(); + } + + it('matches on username even when the org has a custom label', async () => { + const listbox = await search('fullcopy'); + expect(within(listbox).getByText('john.smith@acme.com.fullcopy')).toBeTruthy(); + expect(within(listbox).queryByText('john.smith@acme.com.uat')).toBeNull(); + }); + + it('matches on label', async () => { + const listbox = await search('UAT'); + expect(within(listbox).getByText('UAT Sandbox')).toBeTruthy(); + expect(within(listbox).queryByText('Full Copy')).toBeNull(); + }); + + it('matches on instance url', async () => { + const listbox = await search('--uat.sandbox'); + expect(within(listbox).getByText('UAT Sandbox')).toBeTruthy(); + expect(within(listbox).queryByText('Full Copy')).toBeNull(); + }); + + it('matches on organization id', async () => { + const listbox = await search('00D000000000002'); + expect(within(listbox).getByText('UAT Sandbox')).toBeTruthy(); + expect(within(listbox).queryByText('Full Copy')).toBeNull(); + }); + + it('shows the empty state when nothing matches', async () => { + const listbox = await search('nomatch.my.salesforce.com'); + expect(within(listbox).queryAllByRole('option')).toHaveLength(1); + expect(within(listbox).getByText('There are no items for selection')).toBeTruthy(); + }); + }); +}); diff --git a/libs/shared/ui-core/src/orgs/__tests__/salesforce-login-url.utils.spec.ts b/libs/shared/ui-core/src/orgs/__tests__/salesforce-login-url.utils.spec.ts new file mode 100644 index 0000000000..119a2be097 --- /dev/null +++ b/libs/shared/ui-core/src/orgs/__tests__/salesforce-login-url.utils.spec.ts @@ -0,0 +1,173 @@ +import { SFDC_MY_DOMAIN_LOGIN_URL_REGEX } from '@jetstream/shared/constants'; +import { describe, expect, it } from 'vitest'; +import { parseSalesforceLoginUrl } from '../salesforce-login-url.utils'; + +describe('parseSalesforceLoginUrl', () => { + describe('production and developer domains', () => { + it.each([ + ['acme', 'https://acme.my.salesforce.com'], + ['acme.my.salesforce.com', 'https://acme.my.salesforce.com'], + ['https://acme.my.salesforce.com', 'https://acme.my.salesforce.com'], + ['https://acme.my.salesforce.com/', 'https://acme.my.salesforce.com'], + ['http://acme.my.salesforce.com', 'https://acme.my.salesforce.com'], + ['https://acme.lightning.force.com/lightning/o/Account/list', 'https://acme.my.salesforce.com'], + ['ACME.My.Salesforce.Com', 'https://acme.my.salesforce.com'], + [' acme ', 'https://acme.my.salesforce.com'], + ])('normalizes %s', (input, expected) => { + const result = parseSalesforceLoginUrl(input); + expect(result).toEqual({ success: true, myDomain: 'acme', isSandbox: false, loginUrl: expected }); + }); + }); + + describe('sandbox domains', () => { + it.each([ + ['acme--uat'], + ['acme--uat.sandbox'], + ['acme--uat.sandbox.my.salesforce.com'], + ['https://acme--uat.sandbox.my.salesforce.com/'], + ['https://acme--uat.sandbox.lightning.force.com/lightning/page/home'], + ])('infers the sandbox suffix for %s', (input) => { + expect(parseSalesforceLoginUrl(input)).toEqual({ + success: true, + myDomain: 'acme--uat.sandbox', + isSandbox: true, + loginUrl: 'https://acme--uat.sandbox.my.salesforce.com', + }); + }); + }); + + describe('develop domains', () => { + it.each([ + ['acme.develop', 'acme.develop'], + ['acme.develop.my.salesforce.com', 'acme.develop'], + ['https://acme.develop.lightning.force.com/lightning', 'acme.develop'], + ['acme--dev.develop.my.salesforce.com', 'acme--dev.develop'], + ])('preserves the develop segment for %s', (input, myDomain) => { + expect(parseSalesforceLoginUrl(input)).toEqual({ + success: true, + myDomain, + isSandbox: false, + loginUrl: `https://${myDomain}.my.salesforce.com`, + }); + }); + }); + + describe('legacy instance-scoped sandbox hosts', () => { + // Pre-enhanced-domains sandboxes kept the instance in the host - the `--` still marks a sandbox + it('detects the sandbox from the -- even though the environment segment is an instance', () => { + expect(parseSalesforceLoginUrl('acme--uat.cs123.my.salesforce.com')).toEqual({ + success: true, + myDomain: 'acme--uat.cs123', + isSandbox: true, + loginUrl: 'https://acme--uat.cs123.my.salesforce.com', + }); + }); + }); + + describe('explicit .sandbox segment', () => { + it('is honored even without a -- in the domain', () => { + expect(parseSalesforceLoginUrl('acme.sandbox.my.salesforce.com')).toEqual({ + success: true, + myDomain: 'acme.sandbox', + isSandbox: true, + loginUrl: 'https://acme.sandbox.my.salesforce.com', + }); + }); + }); + + describe('other enhanced domain environments', () => { + // A host we recognized is passed through verbatim, so environments we never enumerated still work + it.each([ + ['acme.scratch.my.salesforce.com', 'https://acme.scratch.my.salesforce.com'], + ['https://acme.trailblaze.my.salesforce.com', 'https://acme.trailblaze.my.salesforce.com'], + ['acme.demo.my.salesforce.com', 'https://acme.demo.my.salesforce.com'], + // Legacy instance-scoped sandbox host + ['acme--uat.cs123.my.salesforce.com', 'https://acme--uat.cs123.my.salesforce.com'], + // Shorthand for an environment the user typed by hand + ['acme.scratch', 'https://acme.scratch.my.salesforce.com'], + ])('keeps the environment segment for %s', (input, expected) => { + const result = parseSalesforceLoginUrl(input); + expect(result.success && result.loginUrl).toBe(expected); + }); + }); + + describe('rejects login endpoints and legacy instance urls', () => { + // These are not My Domains - stripping a bare `.salesforce.com` would silently invent + // `https://login.my.salesforce.com`, which is worse than telling the user we did not understand. + it.each([ + ['https://login.salesforce.com'], + ['https://test.salesforce.com'], + ['https://na139.salesforce.com'], + ['https://prerellogin.pre.salesforce.com'], + ])('rejects %s', (input) => { + expect(parseSalesforceLoginUrl(input).success).toBe(false); + }); + }); + + describe('rejects invalid input', () => { + it.each([ + [''], + [' '], + ['not a domain'], + ['https://evil.com'], + ['acme.my.salesforce.com.evil.com'], + ['https://google.com/salesforce'], + ])('rejects %s', (input) => { + const result = parseSalesforceLoginUrl(input); + expect(result.success).toBe(false); + }); + + it('rejects a domain with unsupported characters', () => { + const result = parseSalesforceLoginUrl('acme_uat'); + expect(result).toEqual({ success: false, error: 'Domains can only contain letters, numbers, and hyphens' }); + }); + }); + + const VALID_INPUTS = [ + 'acme', + 'acme--uat', + 'acme--uat.sandbox', + 'acme.develop.my.salesforce.com', + 'acme.sandbox.my.salesforce.com', + 'acme.scratch.my.salesforce.com', + 'acme--uat.cs123.my.salesforce.com', + 'https://acme--uat.sandbox.lightning.force.com/lightning/page/home', + ]; + + it('produces urls the server allowlist accepts', () => { + VALID_INPUTS.forEach((input) => { + const result = parseSalesforceLoginUrl(input); + expect(result.success).toBe(true); + expect(result.success && result.loginUrl).toMatch(SFDC_MY_DOMAIN_LOGIN_URL_REGEX); + }); + }); + + // Reconnecting prefills the field with the host from `loginUrl` and re-parses whatever the user + // leaves there, so parsing has to be a fixed point on its own output - otherwise reconnecting + // silently points at a domain the org does not serve. + it('re-parses its own login url to the same result', () => { + VALID_INPUTS.forEach((input) => { + const result = parseSalesforceLoginUrl(input); + expect(result.success).toBe(true); + if (!result.success) { + return; + } + expect(parseSalesforceLoginUrl(result.loginUrl)).toEqual(result); + }); + }); + + it('returns a myDomain that re-parses to the same login url', () => { + VALID_INPUTS.forEach((input) => { + const result = parseSalesforceLoginUrl(input); + if (!result.success) { + return; + } + const reparsed = parseSalesforceLoginUrl(result.myDomain); + // Shorthand only covers documented environment segments, so a legacy instance-scoped host + // (`acme--uat.cs123`) is not valid shorthand - the full host above is what the app feeds back + if (reparsed.success) { + expect(reparsed.loginUrl).toBe(result.loginUrl); + } + }); + }); +}); diff --git a/libs/shared/ui-core/src/orgs/salesforce-login-url.utils.ts b/libs/shared/ui-core/src/orgs/salesforce-login-url.utils.ts new file mode 100644 index 0000000000..64e6f9d58b --- /dev/null +++ b/libs/shared/ui-core/src/orgs/salesforce-login-url.utils.ts @@ -0,0 +1,131 @@ +/** + * Normalizes whatever a user pastes into the "Custom Login URL" field into a Salesforce login URL. + * + * Salesforce My Domain hosts follow a small number of shapes: + * production / developer .my.salesforce.com + * sandbox --.sandbox.my.salesforce.com + * scratch / developer edition + * .develop.my.salesforce.com + * + * Users frequently paste the Lightning equivalent (`*.lightning.force.com`) or a deep link, so those + * are reduced back to the login host. A `--` in the domain implies a sandbox, which is how the + * standard Salesforce "Use Custom Domain" login page infers the `.sandbox.` segment. + */ + +const MY_SALESFORCE_SUFFIX = '.my.salesforce.com'; +const SANDBOX_SEGMENT = 'sandbox'; + +/** + * Suffixes that identify a My Domain host, longest/most specific first. + * Deliberately excludes bare `.salesforce.com` and `.force.com`: those also match login endpoints + * (`login.salesforce.com`) and legacy instance urls (`na139.salesforce.com`), which are not My + * Domains, and stripping them would silently produce a bogus domain like `login.my.salesforce.com`. + */ +const KNOWN_HOST_SUFFIXES = ['.my.salesforce.com', '.lightning.force.com', '.my.salesforce-setup.com', '.my.site.com']; + +/** + * Environment segments Salesforce documents for enhanced domains. Only consulted for shorthand the + * user typed by hand (`acme.develop`); a full host we recognized is passed through verbatim, so new + * segments Salesforce adds keep working without a change here. + */ +const ENVIRONMENT_SEGMENTS = [SANDBOX_SEGMENT, 'develop', 'scratch', 'trailblaze', 'demo', 'patch', 'free']; + +const DOMAIN_LABEL_REGEX = /^[a-z0-9-]+$/; + +export interface ParsedLoginUrlSuccess { + success: true; + /** + * The customer portion of the host, e.g. `acme`, `acme--uat.sandbox` or `acme.develop`. + * Not guaranteed to re-parse: legacy instance-scoped hosts produce values like `acme--uat.cs123`, + * which are not valid shorthand. `loginUrl` (or its hostname) is the round-trippable value. + */ + myDomain: string; + isSandbox: boolean; + /** Fully qualified login url safe to hand to the OAuth flow */ + loginUrl: string; +} + +export interface ParsedLoginUrlError { + success: false; + error: string; +} + +export type ParsedLoginUrl = ParsedLoginUrlSuccess | ParsedLoginUrlError; + +const SHORTHAND_ERROR = 'Enter a Salesforce domain, such as acme or acme--uat.sandbox'; + +/** Strips protocol, path, query, fragment, port and credentials, leaving a bare lowercase hostname. */ +function extractHostname(input: string): string { + let value = input.trim().toLowerCase(); + value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, ''); + value = value.split(/[/?#]/)[0]; + // Strip credentials and port + value = value.split('@').pop() || ''; + value = value.split(':')[0]; + return value; +} + +/** + * Removes the Salesforce-owned suffix if one is present, returning the customer portion of the host. + * A host with no recognized suffix is returned untouched so the caller can still accept shorthand + * such as `acme` or `acme--uat.sandbox`. + */ +function stripKnownSuffix(hostname: string): { domain: string; matchedKnownSuffix: boolean } { + const matchedSuffix = KNOWN_HOST_SUFFIXES.find((suffix) => hostname.endsWith(suffix)); + if (!matchedSuffix) { + return { domain: hostname, matchedKnownSuffix: false }; + } + return { domain: hostname.slice(0, -matchedSuffix.length), matchedKnownSuffix: true }; +} + +/** + * Shorthand is a single domain label plus at most one known environment segment. Anything else + * (`evil.com`, `acme.my.salesforce.com.evil.com`) is a shape we did not understand - saying so beats + * silently inventing a host the user never asked for. + */ +function isSupportedShorthand(segments: string[]): boolean { + if (segments.length === 1) { + return true; + } + return segments.length === 2 && ENVIRONMENT_SEGMENTS.includes(segments[1]); +} + +export function parseSalesforceLoginUrl(input: string): ParsedLoginUrl { + const hostname = extractHostname(input || ''); + + if (!hostname) { + return { success: false, error: 'Enter your Salesforce domain' }; + } + + const { domain, matchedKnownSuffix } = stripKnownSuffix(hostname); + + if (!domain) { + return { success: false, error: SHORTHAND_ERROR }; + } + + const segments = domain.split('.'); + + if (!segments.every((segment) => DOMAIN_LABEL_REGEX.test(segment))) { + return { success: false, error: 'Domains can only contain letters, numbers, and hyphens' }; + } + + if (!matchedKnownSuffix && !isSupportedShorthand(segments)) { + return { success: false, error: SHORTHAND_ERROR }; + } + + // Salesforce infers a sandbox from the `--` that separates the domain from the sandbox name, but + // only when the user did not already say which environment they meant. + const myDomain = !matchedKnownSuffix && segments.length === 1 && domain.includes('--') ? `${domain}.${SANDBOX_SEGMENT}` : domain; + + // `--` implies a sandbox unless the host explicitly names a different environment (`acme--dev.develop`). + // Legacy instance-scoped hosts (`acme--uat.cs123`) carry an instance rather than an environment, so + // the `--` still marks them as sandboxes. + const namesOtherEnvironment = segments.length > 1 && segments[1] !== SANDBOX_SEGMENT && ENVIRONMENT_SEGMENTS.includes(segments[1]); + + return { + success: true, + myDomain, + isSandbox: myDomain.endsWith(`.${SANDBOX_SEGMENT}`) || (segments[0].includes('--') && !namesOtherEnvironment), + loginUrl: `https://${myDomain}${MY_SALESFORCE_SUFFIX}`, + }; +} diff --git a/libs/test/e2e-utils/src/lib/pageObjectModels/OrgGroupPage.model.ts b/libs/test/e2e-utils/src/lib/pageObjectModels/OrgGroupPage.model.ts index 293423e693..3f6762fe87 100644 --- a/libs/test/e2e-utils/src/lib/pageObjectModels/OrgGroupPage.model.ts +++ b/libs/test/e2e-utils/src/lib/pageObjectModels/OrgGroupPage.model.ts @@ -43,7 +43,7 @@ export class OrgGroupPage { break; } case 'sandbox': { - await this.page.getByText('Production / Developer').click(); + await this.page.getByText('Sandbox (test.salesforce.com)').click(); break; } case 'pre-release': { @@ -52,8 +52,9 @@ export class OrgGroupPage { } case 'custom': { await this.page.getByText('Custom Login URL').click(); - await this.page.getByPlaceholder('org-domain').click(); - await this.page.getByPlaceholder('org-domain').fill(method.domain); + const customUrlInput = this.page.locator('#org-custom-url'); + await customUrlInput.click(); + await customUrlInput.fill(method.domain); break; } } diff --git a/libs/ui/src/lib/form/combobox/Combobox.tsx b/libs/ui/src/lib/form/combobox/Combobox.tsx index 9f2bf48807..45906b90d6 100644 --- a/libs/ui/src/lib/form/combobox/Combobox.tsx +++ b/libs/ui/src/lib/form/combobox/Combobox.tsx @@ -101,6 +101,12 @@ export interface ComboboxProps { */ isVirtual?: boolean; usePortal?: boolean; + /** + * Sizes the open dropdown panel independently of the input, which is otherwise pinned to the + * input width. Both values must be provided - omitting maxWidth falls back to a narrower default + * than the input in most layouts. Omit entirely for the default fluid behavior. + */ + dropdownWidth?: { minWidth: string; maxWidth: string }; onInputChange?: (value: string) => void; /** Same as onInputChange, but does not get called when closed */ onFilterInputChange?: (value: string) => void; @@ -162,6 +168,7 @@ export const Combobox = forwardRef( showSelectionAsButton, isVirtual, usePortal, + dropdownWidth, children, onInputChange, onFilterInputChange, @@ -444,7 +451,11 @@ export const Combobox = forwardRef( ref={popoverRef} isOpen={isOpen} referenceElement={inputEl.current} - className={classNames(`slds-dropdown_length-${itemLength}`, { 'slds-dropdown_fluid': !usePortal })} + className={classNames(`slds-dropdown_length-${itemLength}`, { + 'slds-dropdown_fluid': !usePortal && !dropdownWidth, + })} + minWidth={dropdownWidth?.minWidth} + maxWidth={dropdownWidth?.maxWidth} id={listId} role="listbox" isEager={isVirtual} diff --git a/libs/ui/src/lib/form/combobox/ComboboxListItem.tsx b/libs/ui/src/lib/form/combobox/ComboboxListItem.tsx index 9c7880918d..66c499444c 100644 --- a/libs/ui/src/lib/form/combobox/ComboboxListItem.tsx +++ b/libs/ui/src/lib/form/combobox/ComboboxListItem.tsx @@ -5,6 +5,34 @@ import classNames from 'classnames'; import React, { forwardRef, Fragment, useEffect, useRef } from 'react'; import Icon from '../../widgets/Icon'; +/** + * Overrides the nowrap/ellipsis that SLDS bakes into `slds-truncate` and + * `slds-listbox__option-text_entity`. `overflow-wrap: anywhere` is required because values like + * Salesforce usernames are single unbroken tokens that `break-word` will not split. + */ +const allowWrapCss = css` + white-space: normal; + overflow: visible; + text-overflow: clip; + overflow-wrap: anywhere; +`; + +/** + * Lays the label and its suffix out side-by-side. With no suffix there is nothing to lay out, so the + * label is emitted as-is rather than adding a wrapper element to every combobox item in the app. + */ +const LabelRow: React.FunctionComponent<{ labelSuffix?: React.ReactNode; children: React.ReactNode }> = ({ labelSuffix, children }) => { + if (!labelSuffix) { + return {children}; + } + return ( +
+ {children} +
{labelSuffix}
+
+ ); +}; + export interface ComboboxListItemProps { id: string; className?: string; @@ -24,6 +52,16 @@ export interface ComboboxListItemProps { * If true, will show icon to indicate child items shown after selected */ isDrillInItem?: boolean; + /** + * Rendered next to the label, outside of the truncating/wrapping text flow. + * Intended for a short status indicator such as a badge. + */ + labelSuffix?: React.ReactNode; + /** + * Let long values wrap onto additional lines instead of truncating with an ellipsis. + * Applies to both the single-line and the stacked "entity" layouts. + */ + allowWrap?: boolean; /** * fallback to label if label is not a string */ @@ -54,6 +92,8 @@ export const ComboboxListItem = forwardRef secondaryLabelOnNewLine, tertiaryLabel, isDrillInItem, + labelSuffix, + allowWrap, title, selected, disabled, @@ -77,6 +117,7 @@ export const ComboboxListItem = forwardRef const backupTitle = `${label || ''} ${secondaryLabel || ''}`; title = title || backupTitle; + const wrapCss = allowWrap ? allowWrapCss : undefined; return (
  • css={textBodyCss} > {label && (!secondaryLabel || !secondaryLabelOnNewLine) && ( - - {label} - {secondaryLabel && {secondaryLabel}} - {tertiaryLabel && ( - -
    - {tertiaryLabel} -
    -
    - )} -
    + + + {label} + {secondaryLabel && {secondaryLabel}} + {tertiaryLabel && ( + +
    + {tertiaryLabel} +
    +
    + )} +
    +
    )} {label && secondaryLabel && secondaryLabelOnNewLine && ( -
    {label}
    + +
    + {label} +
    +
    -
    +
    {secondaryLabel}
    diff --git a/libs/ui/src/lib/form/combobox/__tests__/Combobox.spec.tsx b/libs/ui/src/lib/form/combobox/__tests__/Combobox.spec.tsx new file mode 100644 index 0000000000..32d519baf3 --- /dev/null +++ b/libs/ui/src/lib/form/combobox/__tests__/Combobox.spec.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render } from '@testing-library/react'; +import { Combobox } from '../Combobox'; +import { ComboboxListItem } from '../ComboboxListItem'; + +const NOOP = () => undefined; + +function renderOpen(extra: Record) { + const result = render( + + + , + ); + fireEvent.click(result.container.querySelector('input') as HTMLInputElement); + return result.container.querySelector('[role="listbox"]') as HTMLElement; +} + +describe('Combobox dropdownWidth', () => { + test('defaults to fluid (panel pinned to input width)', () => { + const listbox = renderOpen({}); + expect(listbox.className).toContain('slds-dropdown_fluid'); + }); + + test('drops fluid and applies the provided widths', () => { + const listbox = renderOpen({ dropdownWidth: { minWidth: '100%', maxWidth: '32rem' } }); + expect(listbox.className).not.toContain('slds-dropdown_fluid'); + const styles = getComputedStyle(listbox); + expect(styles.minWidth).toBe('100%'); + expect(styles.maxWidth).toBe('32rem'); + }); +}); diff --git a/libs/ui/src/lib/form/combobox/__tests__/ComboboxListItem.spec.tsx b/libs/ui/src/lib/form/combobox/__tests__/ComboboxListItem.spec.tsx new file mode 100644 index 0000000000..d171df8ae3 --- /dev/null +++ b/libs/ui/src/lib/form/combobox/__tests__/ComboboxListItem.spec.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import { ComboboxListItem } from '../ComboboxListItem'; + +const NOOP = () => undefined; + +/** + * The two layouts are mutually exclusive: a `secondaryLabel` with `secondaryLabelOnNewLine` renders + * the stacked "entity" layout, anything else renders the single-line layout. Truncation behavior has + * to be verified in both, which is what `allowWrap` exists to control. + */ +describe('ComboboxListItem', () => { + describe('single line layout', () => { + test('truncates by default', () => { + render(); + expect(screen.getByText('john.smith@acme.com').parentElement?.className).toContain('slds-truncate'); + }); + + test('drops slds-truncate when allowWrap is set', () => { + render(); + expect(screen.getByText('john.smith@acme.com').parentElement?.className).not.toContain('slds-truncate'); + }); + }); + + describe('entity layout', () => { + const entityProps = { + id: 'a', + label: 'UAT Sandbox', + secondaryLabel: 'john.smith@acme.com.uat', + secondaryLabelOnNewLine: true, + selected: false, + onSelection: NOOP, + }; + + test('truncates the secondary label by default', () => { + render(); + expect(screen.getByText('john.smith@acme.com.uat').className).toContain('slds-truncate'); + }); + + test('drops slds-truncate from the secondary label when allowWrap is set', () => { + render(); + expect(screen.getByText('john.smith@acme.com.uat').className).not.toContain('slds-truncate'); + }); + }); + + describe('labelSuffix', () => { + test('is not rendered when omitted', () => { + render(); + expect(screen.queryByText('Sandbox')).toBeNull(); + }); + + test('renders alongside the label in the single line layout', () => { + render(Sandbox} selected={false} onSelection={NOOP} />); + expect(screen.getByText('Production')).toBeTruthy(); + expect(screen.getByText('Sandbox')).toBeTruthy(); + }); + + test('renders alongside the label in the entity layout', () => { + render( + Sandbox} + selected={false} + onSelection={NOOP} + />, + ); + expect(screen.getByText('UAT Sandbox')).toBeTruthy(); + expect(screen.getByText('john.smith@acme.com.uat')).toBeTruthy(); + expect(screen.getByText('Sandbox')).toBeTruthy(); + }); + }); +});