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(); + }); + }); +});