diff --git a/apps/api/project.json b/apps/api/project.json index 9bbd3fcf9..12efc2d3d 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -20,7 +20,7 @@ }, "configurations": { "production": { - "optimization": true, + "optimization": false, "extractLicenses": true, "inspect": false, "sourceMap": true, diff --git a/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts index 22db24dc6..c23b06fae 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts @@ -206,7 +206,7 @@ export class QueryPage { } await this.page.getByRole('button', { name: 'Collapse SOQL Query' }).click(); - await this.page.getByRole('button', { name: 'History' }).click(); + await this.page.getByLabel('Query History').click(); // verify query history await expect( @@ -229,7 +229,7 @@ export class QueryPage { buttonName = 'Saved'; role = 'button'; } - await this.page.getByRole('button', { name: 'History' }).click(); + await this.page.getByLabel('Query History').click(); await this.page.getByTestId(`query-history-${query}`).getByRole(role, { name: buttonName }).click(); } diff --git a/apps/jetstream/src/app/components/anonymous-apex/AnonymousApex.tsx b/apps/jetstream/src/app/components/anonymous-apex/AnonymousApex.tsx index 534dcfeaf..1e7617ec6 100644 --- a/apps/jetstream/src/app/components/anonymous-apex/AnonymousApex.tsx +++ b/apps/jetstream/src/app/components/anonymous-apex/AnonymousApex.tsx @@ -14,9 +14,12 @@ import { CopyToClipboard, Grid, Icon, + KeyboardShortcut, SalesforceLogin, Spinner, + Tooltip, ViewDocsLink, + getModifierKey, } from '@jetstream/ui'; import Editor, { OnMount, useMonaco } from '@monaco-editor/react'; import localforage from 'localforage'; @@ -248,10 +251,19 @@ export const AnonymousApex: FunctionComponent = () => { onSelected={(item) => setLogLevel(item.id)} /> - + + + + } + > + + } > diff --git a/apps/jetstream/src/app/components/debug-log-viewer/DebugLogViewerTable.tsx b/apps/jetstream/src/app/components/debug-log-viewer/DebugLogViewerTable.tsx index 4a4e3f8bf..7d8808eeb 100644 --- a/apps/jetstream/src/app/components/debug-log-viewer/DebugLogViewerTable.tsx +++ b/apps/jetstream/src/app/components/debug-log-viewer/DebugLogViewerTable.tsx @@ -22,6 +22,8 @@ export const LogViewedRenderer: FunctionComponent[] = [ { name: '', @@ -101,7 +103,13 @@ export const DebugLogViewerTable: FunctionComponent = return ( - + ); }; diff --git a/apps/jetstream/src/app/components/debug-log-viewer/useDebugLogs.tsx b/apps/jetstream/src/app/components/debug-log-viewer/useDebugLogs.tsx index 20c187116..a4a1a1857 100644 --- a/apps/jetstream/src/app/components/debug-log-viewer/useDebugLogs.tsx +++ b/apps/jetstream/src/app/components/debug-log-viewer/useDebugLogs.tsx @@ -50,7 +50,7 @@ export function useDebugLogs(org: SalesforceOrgUi, { limit, pollInterval, userId setLogs(queryResults.records); } else { setLogs((logs) => - orderBy(Object.values({ ...getMapOf(logs, 'Id'), ...getMapOf(queryResults.records, 'Id') }), ['Id'], ['asc']) + orderBy(Object.values({ ...getMapOf(logs, 'Id'), ...getMapOf(queryResults.records, 'Id') }), ['LastModifiedDate'], ['desc']) ); } setLoading(false); diff --git a/apps/jetstream/src/app/components/deploy/DeployMetadataDeployment.tsx b/apps/jetstream/src/app/components/deploy/DeployMetadataDeployment.tsx index 0bea6c92c..6852d02f5 100644 --- a/apps/jetstream/src/app/components/deploy/DeployMetadataDeployment.tsx +++ b/apps/jetstream/src/app/components/deploy/DeployMetadataDeployment.tsx @@ -297,6 +297,7 @@ export const DeployMetadataDeployment: FunctionComponent (null); const orgsById = useRecoilValue(fromAppState.salesforceOrgsById); + const onKeydown = useCallback( + (event: KeyboardEvent) => { + if (!isOpen && hasModifierKey(event as any) && isHKey(event as any)) { + event.stopPropagation(); + event.preventDefault(); + handleToggleOpen(true, 'keyboardShortcut'); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isOpen] + ); + + useGlobalEventHandler('keydown', onKeydown); + useEffect(() => { if (isOpen) { setIsLoading(true); @@ -77,10 +103,10 @@ export const DeployMetadataHistoryModal = ({ className }: DeployMetadataHistoryM } }, [isOpen]); - function handleToggleOpen(open: boolean) { + function handleToggleOpen(open: boolean, source = 'buttonClick') { setIsOpen(open); if (open) { - trackEvent(ANALYTICS_KEYS.deploy_history_opened); + trackEvent(ANALYTICS_KEYS.deploy_history_opened, source); } } @@ -161,15 +187,23 @@ export const DeployMetadataHistoryModal = ({ className }: DeployMetadataHistoryM return ( - + + {downloadPackageModalState.open && downloadPackageModalState.org && downloadPackageModalState.data && ( ; externalIdValue: string | null; recordIdForUpdate: string | null; dependencies: string[] }, header ) => { - let externalIdValue: string | null = null; const field = dataset.fieldsByName[header.toLowerCase()]; let value = record[header]; const valueIsNull = isNil(value) || (isString(value) && !value); diff --git a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitor.tsx b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitor.tsx index 9ba9dc4cc..124b35fed 100644 --- a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitor.tsx +++ b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitor.tsx @@ -1,16 +1,16 @@ import { css } from '@emotion/react'; import { TITLES } from '@jetstream/shared/constants'; -import { useTitle } from '@jetstream/shared/ui-utils'; +import { useNonInitialEffect, useTitle } from '@jetstream/shared/ui-utils'; import { SplitWrapper as Split } from '@jetstream/splitjs'; -import { ListItem, SalesforceOrgUi } from '@jetstream/types'; +import { ListItem, ListItemGroup, SalesforceOrgUi } from '@jetstream/types'; import { AutoFullHeightContainer } from '@jetstream/ui'; -import type { DescribeGlobalSObjectResult } from 'jsforce'; import { FunctionComponent, useEffect, useRef, useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { applicationCookieState, selectedOrgState } from '../../app-state'; import PlatformEventMonitorFetchEventStatus from './PlatformEventMonitorFetchEventStatus'; import PlatformEventMonitorListenerCard from './PlatformEventMonitorListenerCard'; import PlatformEventMonitorPublisherCard from './PlatformEventMonitorPublisherCard'; +import { PlatformEventObject } from './platform-event-monitor.types'; import { usePlatformEvent } from './usePlatformEvent'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -32,8 +32,9 @@ export const PlatformEventMonitor: FunctionComponent subscribe, unsubscribe, } = usePlatformEvent({ selectedOrg }); - const [platformEventsList, setPlatformEventsList] = useState[]>([]); - const [subscribedPlatformEventsList, setSubscribedPlatformEventsList] = useState[]>([]); + const [platformEventsListSubscriptions, setPlatformEventsListSubscriptions] = useState[]>([]); + const [platformEventsListPublisher, setPlatformEventsListPublisher] = useState[]>([]); + const [subscribedPlatformEventsList, setSubscribedPlatformEventsList] = useState[]>([]); const [picklistKey, setPicklistKey] = useState(1); const [selectedSubscribeEvent, setSelectedSubscribeEvent] = useState(null); const [selectedPublishEvent, setSelectedPublishEvent] = useState(null); @@ -45,30 +46,88 @@ export const PlatformEventMonitor: FunctionComponent }; }, []); + useNonInitialEffect(() => { + setSelectedPublishEvent(null); + }, [selectedOrg]); + useEffect(() => { - const events = platformEvents.map>((event) => ({ - id: event.name, - label: event.label, - secondaryLabel: event.name, - value: event.name, - meta: event, - })); - if (events.length) { - setPlatformEventsList(events); - setSelectedSubscribeEvent(events[0].id); - setSelectedPublishEvent(events[0].id); + const subscriptionEvents: ListItemGroup[] = [ + { + id: 'PLATFORM_EVENT', + label: 'Platform Events (Custom)', + items: platformEvents + .filter((item) => item.type === 'PLATFORM_EVENT') + .map>((event) => ({ + id: event.channel, + label: `${event.label} (${event.name})`, + secondaryLabel: event.channel, + secondaryLabelOnNewLine: true, + value: event.channel, + meta: event, + })), + }, + { + id: 'PLATFORM_EVENT_STANDARD', + label: 'Platform Events (Standard)', + items: platformEvents + .filter((item) => item.type === 'PLATFORM_EVENT_STANDARD') + .map>((event) => ({ + id: event.channel, + label: `${event.label} (${event.name})`, + secondaryLabel: event.channel, + secondaryLabelOnNewLine: true, + value: event.channel, + meta: event, + })), + }, + { + id: 'CHANGE_EVENT', + label: 'Change Data Capture Events', + items: platformEvents + .filter((item) => item.type === 'CHANGE_EVENT') + .map>((event) => ({ + id: event.channel, + label: `${event.label} (${event.name})`, + secondaryLabel: event.channel, + secondaryLabelOnNewLine: true, + value: event.channel, + meta: event, + })), + }, + ]; + + setPlatformEventsListSubscriptions(subscriptionEvents); + setSelectedSubscribeEvent(platformEvents.length ? platformEvents[0].channel : null); + setPicklistKey((prevKey) => prevKey + 1); + + const publisherEvents = platformEvents + .filter((event) => event.type === 'PLATFORM_EVENT') + .map>((event) => ({ + id: event.name, + label: event.label, + secondaryLabel: event.name, + secondaryLabelOnNewLine: true, + value: event.name, + meta: event, + })); + + if (publisherEvents.length) { + setPlatformEventsListPublisher(publisherEvents); + setSelectedPublishEvent(publisherEvents[0].id); setPicklistKey((prevKey) => prevKey + 1); } }, [platformEvents]); useEffect(() => { - setSubscribedPlatformEventsList(platformEventsList.filter((item) => !!messagesByChannel[item.value])); - }, [messagesByChannel]); + setSubscribedPlatformEventsList( + platformEventsListSubscriptions.flatMap((item) => item.items).filter((item) => !!messagesByChannel[item.value]) + ); + }, [messagesByChannel, platformEventsListSubscriptions]); const hasErrorOrNoEvents = !hasPlatformEvents || platformEventFetchError; return ( - + {hasErrorOrNoEvents && ( serverUrl={serverUrl} loadingPlatformEvents={loadingPlatformEvents} picklistKey={picklistKey} - platformEventsList={platformEventsList} + platformEventsList={platformEventsListPublisher} selectedPublishEvent={selectedPublishEvent} onSelectedPublishEvent={setSelectedPublishEvent} publish={publish} diff --git a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorEvents.tsx b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorEvents.tsx index 3c58e1081..e2bffabff 100644 --- a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorEvents.tsx +++ b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorEvents.tsx @@ -1,11 +1,20 @@ import { css } from '@emotion/react'; import { orderStringsBy } from '@jetstream/shared/utils'; -import { AutoFullHeightContainer, ColumnWithFilter, DataTree } from '@jetstream/ui'; +import { AutoFullHeightContainer, ColumnWithFilter, ContextMenuActionData, ContextMenuItem, DataTree } from '@jetstream/ui'; +import copyToClipboard from 'copy-to-clipboard'; import groupBy from 'lodash/groupBy'; -import { FunctionComponent, useEffect, useState } from 'react'; +import { FunctionComponent, useCallback, useEffect, useState } from 'react'; import { RenderCellProps, RowHeightArgs } from 'react-data-grid'; import { MessagesByChannel } from './usePlatformEvent'; +type ContextEventAction = 'COPY_CELL' | 'COPY_EVENT' | 'COPY_EVENT_ALL'; + +const TABLE_CONTEXT_MENU_ITEMS: ContextMenuItem[] = [ + { label: 'Copy cell to clipboard', value: 'COPY_CELL' }, + { label: 'Copy event to clipboard', value: 'COPY_EVENT' }, + { label: 'Copy all events to clipboard', value: 'COPY_EVENT_ALL' }, +]; + export const WrappedTextFormatter: FunctionComponent> = ({ column, row }) => { const value = row[column.key]; return ( @@ -25,19 +34,12 @@ const columns: ColumnWithFilter[] = [ name: 'Event', key: 'event', width: 230, - // rowGroup: true, - // hide: true, - // lockVisible: true, - // lockPosition: true, - // tooltipField: 'event', frozen: true, }, { name: 'Payload', key: 'payload', width: 450, - // wrapText: true, - // autoHeight: true, renderCell: WrappedTextFormatter, cellClass: 'break-all', draggable: true, @@ -46,14 +48,12 @@ const columns: ColumnWithFilter[] = [ name: 'UUID', key: 'uuid', width: 160, - // tooltipField: 'uuid', draggable: true, }, { name: 'Replay Id', key: 'replayId', width: 120, - // tooltipField: 'replayId', draggable: true, }, ]; @@ -61,7 +61,7 @@ const columns: ColumnWithFilter[] = [ const groupedRows = ['event'] as const; function getRowId(data: PlatformEventRow): string { - return data.uuid; + return data.uuid || `${data.replayId}` || JSON.stringify(data); } function getRowHeight({ row, type }: RowHeightArgs) { @@ -108,6 +108,23 @@ export const PlatformEventMonitorEvents: FunctionComponent, data: ContextMenuActionData) => { + switch (item.value) { + case 'COPY_CELL': + copyToClipboard(JSON.stringify(data.row[data.column.key], null, 2), { format: 'text/plain' }); + break; + case 'COPY_EVENT': + copyToClipboard(JSON.stringify(data.row, null, 2), { format: 'text/plain' }); + break; + case 'COPY_EVENT_ALL': + copyToClipboard(JSON.stringify(data.rows, null, 2), { format: 'text/plain' }); + break; + } + }, + [] + ); + return ( setExpandedGroupIds(items)} rowHeight={getRowHeight} + contextMenuItems={TABLE_CONTEXT_MENU_ITEMS} + contextMenuAction={handleContextMenuAction} /> ); diff --git a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorListenerCard.tsx b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorListenerCard.tsx index ddc4ba7bd..559803916 100644 --- a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorListenerCard.tsx +++ b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorListenerCard.tsx @@ -1,16 +1,16 @@ -import { ListItem, Maybe } from '@jetstream/types'; +import { ListItem, ListItemGroup, Maybe } from '@jetstream/types'; import { Card, Grid, Pill, Spinner, ViewDocsLink } from '@jetstream/ui'; -import type { DescribeGlobalSObjectResult } from 'jsforce'; import { FunctionComponent } from 'react'; import PlatformEventMonitorEvents from './PlatformEventMonitorEvents'; import PlatformEventMonitorSubscribe from './PlatformEventMonitorSubscribe'; +import { PlatformEventObject } from './platform-event-monitor.types'; import { MessagesByChannel } from './usePlatformEvent'; export interface PlatformEventMonitorListenerCardListenerCard { loading: boolean; picklistKey: string | number; - platformEventsList: ListItem[]; - subscribedPlatformEventsList: ListItem[]; + platformEventsList: ListItemGroup[]; + subscribedPlatformEventsList: ListItem[]; selectedSubscribeEvent?: Maybe; messagesByChannel: MessagesByChannel; fetchPlatformEvents: (clearCache?: boolean) => void; diff --git a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorPublisherCard.tsx b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorPublisherCard.tsx index 19a61fd57..b15f92416 100644 --- a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorPublisherCard.tsx +++ b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorPublisherCard.tsx @@ -4,15 +4,16 @@ import { useReducerFetchFn } from '@jetstream/shared/ui-utils'; import { ListItem, Maybe, PicklistFieldValues, Record, SalesforceOrgUi } from '@jetstream/types'; import { Card, ComboboxWithItems, Grid, Icon, ScopedNotification, Spinner, Tooltip } from '@jetstream/ui'; import { formatRelative } from 'date-fns'; -import type { DescribeGlobalSObjectResult, DescribeSObjectResult } from 'jsforce'; +import type { DescribeSObjectResult } from 'jsforce'; import { Fragment, FunctionComponent, useCallback, useEffect, useReducer, useRef, useState } from 'react'; +import { PlatformEventObject } from './platform-event-monitor.types'; export interface PlatformEventMonitorPublisherCardProps { selectedOrg: SalesforceOrgUi; serverUrl: string; loadingPlatformEvents: boolean; picklistKey: string | number; - platformEventsList: ListItem[]; + platformEventsList: ListItem[]; selectedPublishEvent: Maybe; onSelectedPublishEvent: (id: string) => void; publish: (platformEventName: string, data: any) => Promise; diff --git a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorSubscribe.tsx b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorSubscribe.tsx index fad4ba9e3..a3296c02d 100644 --- a/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorSubscribe.tsx +++ b/apps/jetstream/src/app/components/platform-event-monitor/PlatformEventMonitorSubscribe.tsx @@ -1,19 +1,19 @@ import { css } from '@emotion/react'; import { isEnterKey } from '@jetstream/shared/ui-utils'; -import { ListItem, Maybe } from '@jetstream/types'; -import { ComboboxWithItems, Grid, Input } from '@jetstream/ui'; -import type { DescribeGlobalSObjectResult } from 'jsforce'; +import { ListItemGroup, Maybe } from '@jetstream/types'; +import { ComboboxWithGroupedItems, Grid, Input } from '@jetstream/ui'; import React, { FunctionComponent, KeyboardEvent, useEffect, useState } from 'react'; +import { PlatformEventObject } from './platform-event-monitor.types'; import { MessagesByChannel } from './usePlatformEvent'; export interface PlatformEventMonitorSubscribeListenerCard { picklistKey: string | number; - platformEventsList: ListItem[]; + platformEventsList: ListItemGroup[]; selectedSubscribeEvent?: Maybe; messagesByChannel: MessagesByChannel; fetchPlatformEvents: (clearCache?: boolean) => void; - subscribe: (platformEventName: string, replayId?: number) => Promise; - unsubscribe: (platformEventName: string) => Promise; + subscribe: (channel: string, replayId?: number) => Promise; + unsubscribe: (channel: string) => Promise; onSelectedSubscribeEvent: (id: string) => void; } @@ -65,12 +65,14 @@ export const PlatformEventMonitorSubscribe: FunctionComponent
- onSelectedSubscribeEvent(item.id)} /> diff --git a/apps/jetstream/src/app/components/platform-event-monitor/platform-event-monitor.types.ts b/apps/jetstream/src/app/components/platform-event-monitor/platform-event-monitor.types.ts new file mode 100644 index 000000000..070e94bd7 --- /dev/null +++ b/apps/jetstream/src/app/components/platform-event-monitor/platform-event-monitor.types.ts @@ -0,0 +1,25 @@ +export interface PlatformEventObject { + name: string; + label: string; + channel: string; + type: 'PLATFORM_EVENT' | 'PLATFORM_EVENT_STANDARD' | 'CHANGE_EVENT'; +} + +export interface EventMessage { + clientId: string; + channel: string; + id: string; + successful: boolean; +} + +export interface EventMessageUnsuccessful extends EventMessage { + subscription?: string; + error?: string; + successful: false; + failure?: { + connectionType: string; + exception?: string; + reason?: string; + transport?: string; + }; +} diff --git a/apps/jetstream/src/app/components/platform-event-monitor/platform-event-monitor.utils.ts b/apps/jetstream/src/app/components/platform-event-monitor/platform-event-monitor.utils.ts index ddef8a26a..17d53c683 100644 --- a/apps/jetstream/src/app/components/platform-event-monitor/platform-event-monitor.utils.ts +++ b/apps/jetstream/src/app/components/platform-event-monitor/platform-event-monitor.utils.ts @@ -1,12 +1,9 @@ import { logger } from '@jetstream/shared/client-logger'; import { HTTP } from '@jetstream/shared/constants'; -import { MapOf, Maybe, SalesforceOrgUi } from '@jetstream/types'; -import { CometD, Message, Extension, SubscriptionHandle } from 'cometd'; +import { MapOf, SalesforceOrgUi } from '@jetstream/types'; +import { CometD, Extension, Message, SubscriptionHandle } from 'cometd'; import isNumber from 'lodash/isNumber'; - -/** - * TODO: a worker might be the best solution here to ensure events are received from background - */ +import { EventMessage, EventMessageUnsuccessful } from './platform-event-monitor.types'; const subscriptions = new Map(); @@ -17,10 +14,12 @@ export function init({ serverUrl, defaultApiVersion, selectedOrg, + onSubscribeError, }: { serverUrl: string; defaultApiVersion: string; selectedOrg: SalesforceOrgUi; + onSubscribeError?: (message: EventMessageUnsuccessful) => void; }) { return new Promise((resolve, reject) => { const cometd = new CometD(); @@ -56,14 +55,17 @@ export function init({ } }); - cometd.addListener('/meta/connect', (message) => { + cometd.addListener('/meta/connect', (message: EventMessage) => { logger.log('[COMETD] connect', message); }); - cometd.addListener('/meta/disconnect', (message) => { + cometd.addListener('/meta/disconnect', (message: EventMessage) => { logger.log('[COMETD] disconnect', message); }); - cometd.addListener('/meta/unsuccessful', (message) => { + // Library appears to have incorrect type for subscription property + cometd.addListener('/meta/unsuccessful', (message: unknown) => { logger.log('[COMETD] unsuccessful', message); + // message.subscription -> not valid + onSubscribeError && onSubscribeError(message as EventMessageUnsuccessful); }); (cometd as any).onListenerException = (exception, subscriptionHandle, isListener, message) => { logger.warn('[COMETD][LISTENER][ERROR]', exception?.message, message, subscriptionHandle); @@ -72,23 +74,21 @@ export function init({ } export function subscribe( - { cometd, platformEventName, replayId }: { cometd: CometD; platformEventName: string; replayId?: number }, + { cometd, channel, replayId }: { cometd: CometD; channel: string; replayId?: number }, callback: (message: T) => void ) { - const channel = `/event/${platformEventName}`; - if (!cometd.isDisconnected()) { const replayExt = cometd.getExtension(CometdReplayExtension.EXT_NAME) as any; if (replayExt) { - replayExt.addChannel(platformEventName, replayId); + replayExt.addChannel(channel, replayId); } - if (subscriptions.has(platformEventName)) { + if (subscriptions.has(channel)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - cometd.unsubscribe(subscriptions.get(platformEventName)!); + cometd.unsubscribe(subscriptions.get(channel)!); } subscriptions.set( - platformEventName, + channel, cometd.subscribe(channel, (message) => { callback(message as any); }) @@ -96,12 +96,12 @@ export function subscribe( } } -export function unsubscribe({ cometd, platformEventName }: { cometd: CometD; platformEventName: string }) { +export function unsubscribe({ cometd, channel }: { cometd: CometD; channel: string }) { if (!cometd.isDisconnected()) { - if (subscriptions.has(platformEventName)) { + if (subscriptions.has(channel)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - cometd.unsubscribe(subscriptions.get(platformEventName)!); - subscriptions.delete(platformEventName); + cometd.unsubscribe(subscriptions.get(channel)!); + subscriptions.delete(channel); } // if no more subscriptions, disconnect everything if (!subscriptions.size) { @@ -135,12 +135,10 @@ class CometdReplayExtension implements Extension { } addChannel(channel: string, replay?: number) { - channel = channel.startsWith('/event') ? channel : `/event/${channel}`; this.replayFromMap[channel] = replay ?? -1; } removeChannel(channel: string) { - channel = channel.startsWith('/event') ? channel : `/event/${channel}`; this.replayFromMap[channel] = undefined; } diff --git a/apps/jetstream/src/app/components/platform-event-monitor/usePlatformEvent.ts b/apps/jetstream/src/app/components/platform-event-monitor/usePlatformEvent.ts index 05e039e6d..cac679447 100644 --- a/apps/jetstream/src/app/components/platform-event-monitor/usePlatformEvent.ts +++ b/apps/jetstream/src/app/components/platform-event-monitor/usePlatformEvent.ts @@ -2,7 +2,6 @@ import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { clearCacheForOrg, describeGlobal, sobjectOperation } from '@jetstream/shared/data'; import { useDebounce, useRollbar } from '@jetstream/shared/ui-utils'; -import { orderObjectsBy } from '@jetstream/shared/utils'; import { MapOf, Maybe, @@ -13,11 +12,12 @@ import { } from '@jetstream/types'; import { fireToast } from '@jetstream/ui'; import { CometD } from 'cometd'; -import type { DescribeGlobalSObjectResult } from 'jsforce'; +import orderBy from 'lodash/orderBy'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useRecoilState } from 'recoil'; import { applicationCookieState } from '../../app-state'; import { useAmplitude } from '../core/analytics'; +import { EventMessageUnsuccessful, PlatformEventObject } from './platform-event-monitor.types'; import * as platformEventUtils from './platform-event-monitor.utils'; export type MessagesByChannel = MapOf<{ replayId?: number; messages: PlatformEventMessagePayload[] }>; @@ -25,7 +25,7 @@ export type MessagesByChannel = MapOf<{ replayId?: number; messages: PlatformEve export function usePlatformEvent({ selectedOrg }: { selectedOrg: SalesforceOrgUi }): { hasPlatformEvents: boolean; platformEventFetchError?: Maybe; - platformEvents: DescribeGlobalSObjectResult[]; + platformEvents: PlatformEventObject[]; messagesByChannel: MessagesByChannel; loadingPlatformEvents: boolean; fetchPlatformEvents: (clearCache?: boolean) => void; @@ -38,7 +38,7 @@ export function usePlatformEvent({ selectedOrg }: { selectedOrg: SalesforceOrgUi const rollbar = useRollbar(); const { trackEvent } = useAmplitude(); const [{ serverUrl, defaultApiVersion }] = useRecoilState(applicationCookieState); - const [platformEvents, setPlatformEvents] = useState([]); + const [platformEvents, setPlatformEvents] = useState([]); const [loadingPlatformEvents, setPlatformLoadingEvents] = useState(false); const [hasPlatformEvents, setHasPlatformEvents] = useState(true); const [platformEventFetchError, setPlatformEventFetchError] = useState>(null); @@ -79,9 +79,19 @@ export function usePlatformEvent({ selectedOrg }: { selectedOrg: SalesforceOrgUi } setPlatformLoadingEvents(true); setPlatformEventFetchError(null); - const platformEvents = orderObjectsBy( - (await describeGlobal(selectedOrg)).data.sobjects.filter((obj) => obj.name.endsWith('__e')), - 'name' + const platformEvents = orderBy( + (await describeGlobal(selectedOrg)).data.sobjects + .filter((obj) => (obj.name.endsWith('__e') || obj.name.endsWith('Event')) && !obj.queryable) + .map(({ name, label }): PlatformEventObject => { + return { + name, + label, + channel: name.endsWith('ChangeEvent') ? `/data/${name}` : `/event/${name}`, + type: name.endsWith('ChangeEvent') ? 'CHANGE_EVENT' : name.endsWith('__e') ? 'PLATFORM_EVENT' : 'PLATFORM_EVENT_STANDARD', + }; + }), + [(obj) => obj.name.endsWith('__e'), (obj) => obj.name.endsWith('ChangeEvent'), 'label'], + ['desc', 'asc', 'asc'] ); if (isMounted.current) { setPlatformEvents(platformEvents); @@ -113,25 +123,49 @@ export function usePlatformEvent({ selectedOrg }: { selectedOrg: SalesforceOrgUi [] ); + const handleSubscribeError = useCallback((message: EventMessageUnsuccessful) => { + logger.warn('[PLATFORM EVENT][ERROR]', message); + if (message.subscription) { + fireToast({ type: 'error', message: `Error subscribing to event: ${message.subscription}. ${message.error}` }); + setMessagesByChannel((item) => { + return Object.keys(item) + .filter((key) => key !== message.subscription) + .reduce((output: MessagesByChannel, key) => { + output[key] = item[key]; + return output; + }, {}); + }); + } else if (message.channel === '/meta/handshake') { + fireToast({ type: 'error', message: `Error subscribing to event: ${message.failure?.reason || 'Unknown reason'}.` }); + } else { + fireToast({ type: 'error', message: `There was an unknown error subscribing to the event` }); + } + }, []); + const subscribe = useCallback( - async (platformEventName: string, replayId?: number) => { + async (channel: string, replayId?: number) => { try { if (selectedOrg) { let requiredInit = false; if (!cometD.current || cometD.current.isDisconnected()) { - cometD.current = await platformEventUtils.init({ defaultApiVersion, selectedOrg, serverUrl }); + cometD.current = await platformEventUtils.init({ + defaultApiVersion, + selectedOrg, + serverUrl, + onSubscribeError: handleSubscribeError, + }); requiredInit = true; } const cometd = cometD.current; - platformEventUtils.subscribe({ cometd, platformEventName, replayId }, onEvent(replayId)); + platformEventUtils.subscribe({ cometd, channel, replayId }, onEvent(replayId)); setMessagesByChannel((item) => { item = { ...item }; - item[platformEventName] = { messages: [], replayId }; + item[channel] = { messages: [], replayId }; return item; }); - trackEvent(ANALYTICS_KEYS.platform_event_subscribed, { requiredInit }); + trackEvent(ANALYTICS_KEYS.platform_event_subscribed, { requiredInit, channel, replayId }); } } catch (ex) { logger.warn('[PLATFORM EVENT][ERROR]', ex.message); @@ -142,15 +176,15 @@ export function usePlatformEvent({ selectedOrg }: { selectedOrg: SalesforceOrgUi ); const unsubscribe = useCallback( - async (platformEventName: string) => { + async (channel: string) => { try { if (cometD.current) { const cometd = cometD.current; - platformEventUtils.unsubscribe({ cometd, platformEventName }); + platformEventUtils.unsubscribe({ cometd, channel }); setMessagesByChannel((item) => { return Object.keys(item) - .filter((key) => key !== platformEventName) + .filter((key) => key !== channel) .reduce((output: MessagesByChannel, key) => { output[key] = item[key]; return output; diff --git a/apps/jetstream/src/app/components/query/QueryBuilder/ExecuteQueryButton.tsx b/apps/jetstream/src/app/components/query/QueryBuilder/ExecuteQueryButton.tsx index f105415b0..62a76cf4d 100644 --- a/apps/jetstream/src/app/components/query/QueryBuilder/ExecuteQueryButton.tsx +++ b/apps/jetstream/src/app/components/query/QueryBuilder/ExecuteQueryButton.tsx @@ -1,7 +1,7 @@ import { Maybe } from '@jetstream/types'; -import { Icon } from '@jetstream/ui'; +import { Icon, KeyboardShortcut, Tooltip, getModifierKey } from '@jetstream/ui'; import type { DescribeGlobalSObjectResult } from 'jsforce'; -import { Fragment, FunctionComponent } from 'react'; +import { FunctionComponent } from 'react'; import { Link } from 'react-router-dom'; interface ExecuteQueryButtonProps { @@ -12,25 +12,33 @@ interface ExecuteQueryButtonProps { export const ExecuteQueryButton: FunctionComponent = ({ soql, isTooling, selectedSObject }) => { return ( - + <> {soql && selectedSObject && ( - + +
+ } > - - Execute - + + + Execute + + )} {!soql && ( )} -
+ ); }; diff --git a/apps/jetstream/src/app/components/query/QueryBuilder/QueryBuilder.tsx b/apps/jetstream/src/app/components/query/QueryBuilder/QueryBuilder.tsx index 2cdb8f38e..ad26fc387 100644 --- a/apps/jetstream/src/app/components/query/QueryBuilder/QueryBuilder.tsx +++ b/apps/jetstream/src/app/components/query/QueryBuilder/QueryBuilder.tsx @@ -310,7 +310,7 @@ export const QueryBuilder: FunctionComponent = () => { - + diff --git a/apps/jetstream/src/app/components/query/QueryHistory/QueryHistory.tsx b/apps/jetstream/src/app/components/query/QueryHistory/QueryHistory.tsx index f1bcc8c6a..7b0bba695 100644 --- a/apps/jetstream/src/app/components/query/QueryHistory/QueryHistory.tsx +++ b/apps/jetstream/src/app/components/query/QueryHistory/QueryHistory.tsx @@ -1,23 +1,44 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { css } from '@emotion/react'; import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; -import { formatNumber, hasModifierKey, isHKey, useGlobalEventHandler, useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import { + formatNumber, + hasModifierKey, + hasShiftModifierKey, + isHKey, + useGlobalEventHandler, + useNonInitialEffect, +} from '@jetstream/shared/ui-utils'; import { multiWordObjectFilter } from '@jetstream/shared/utils'; import { QueryHistoryItem, QueryHistorySelection, SalesforceOrgUi, UpDown } from '@jetstream/types'; -import { EmptyState, Grid, GridCol, Icon, List, Modal, SearchInput, Spinner } from '@jetstream/ui'; +import { + ButtonGroupContainer, + EmptyState, + Grid, + GridCol, + Icon, + KeyboardShortcut, + List, + Modal, + SearchInput, + Spinner, + Tooltip, + getModifierKey, +} from '@jetstream/ui'; import classNames from 'classnames'; import { createRef, forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { useLocation } from 'react-router-dom'; import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'; -import { useAmplitude } from '../../core/analytics'; import ErrorBoundaryFallback from '../../core/ErrorBoundaryFallback'; -import * as fromQueryHistoryState from './query-history.state'; +import { useAmplitude } from '../../core/analytics'; import QueryHistoryEmptyState from './QueryHistoryEmptyState'; import QueryHistoryItemCard from './QueryHistoryItemCard'; import QueryHistoryWhichOrg from './QueryHistoryWhichOrg'; import QueryHistoryWhichType from './QueryHistoryWhichType'; +import * as fromQueryHistoryState from './query-history.state'; const SHOWING_STEP = 25; @@ -60,9 +81,7 @@ export const QueryHistory = forwardRef(({ className, sel useImperativeHandle(ref, () => { return { open: (type: fromQueryHistoryState.QueryHistoryType = 'HISTORY') => { - setIsOpen(true); - setWhichType(type); - setWhichOrg('ALL'); + handleOpenModal(type, 'externalAction'); }, }; }); @@ -72,9 +91,10 @@ export const QueryHistory = forwardRef(({ className, sel if (!isOpen && hasModifierKey(event as any) && isHKey(event as any)) { event.stopPropagation(); event.preventDefault(); - setIsOpen(true); + handleOpenModal(hasShiftModifierKey(event as any) ? 'SAVED' : 'HISTORY', 'keyboardShortcut'); } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [isOpen] ); @@ -131,12 +151,6 @@ export const QueryHistory = forwardRef(({ className, sel } }, [filteredQueryHistory, showingUpTo]); - useEffect(() => { - if (isOpen) { - trackEvent(ANALYTICS_KEYS.query_HistoryModalOpened); - } - }, [isOpen, trackEvent]); - useEffect(() => { if (isOpen) { setIsOpen(false); @@ -162,9 +176,11 @@ export const QueryHistory = forwardRef(({ className, sel // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectObjectsList, filterValue]); - function handleOpenModal() { + function handleOpenModal(type: fromQueryHistoryState.QueryHistoryType = 'HISTORY', source = 'buttonClick') { + setWhichType(type); setIsOpen(true); fromQueryHistoryState.initQueryHistory().then((queryHistory) => setQueryHistorySateMap(queryHistory)); + trackEvent(ANALYTICS_KEYS.query_HistoryModalOpened, { source, type }); } function handleExecute({ created, lastRun, runCount, isTooling, isFavorite }: QueryHistoryItem) { @@ -229,15 +245,48 @@ export const QueryHistory = forwardRef(({ className, sel return ( - + + + View query history + + + } + > + + + + View saved queries + + + } + > + + + {isOpen && ( = ({ cla return ( + + } > diff --git a/libs/api-config/src/lib/api-rollbar-config.ts b/libs/api-config/src/lib/api-rollbar-config.ts index 89de4ed10..4588e05ec 100644 --- a/libs/api-config/src/lib/api-rollbar-config.ts +++ b/libs/api-config/src/lib/api-rollbar-config.ts @@ -9,4 +9,5 @@ export const rollbarServer = new Rollbar({ captureUncaught: true, captureUnhandledRejections: true, enabled: !!ENV.ROLLBAR_SERVER_TOKEN, + nodeSourceMaps: true, }); diff --git a/libs/shared/ui-record-form/src/lib/UiRecordForm.tsx b/libs/shared/ui-record-form/src/lib/UiRecordForm.tsx index e31a5c69a..ad456be41 100644 --- a/libs/shared/ui-record-form/src/lib/UiRecordForm.tsx +++ b/libs/shared/ui-record-form/src/lib/UiRecordForm.tsx @@ -29,7 +29,7 @@ export const UiRecordForm: FunctionComponent = ({ sobjectFields, picklistValues, record, - saveErrors = {}, + saveErrors, disabled = false, onChange, viewRelatedRecord, @@ -66,7 +66,7 @@ export const UiRecordForm: FunctionComponent = ({ ); } if (limitToErrorFields) { - visibleFields = visibleFields.filter((field) => saveErrors[field.name]); + visibleFields = visibleFields.filter((field) => saveErrors?.[field.name]); } if (visibleFields.length) { setVisibleFieldMetadataRows(splitArrayToMaxSize(visibleFields, columnSize)); @@ -144,7 +144,7 @@ export const UiRecordForm: FunctionComponent = ({ disabled={!fieldMetadata || disabled} onChange={setLimitToRequired} /> - {Object.keys(saveErrors).length > 0 && ( + {saveErrors && Object.keys(saveErrors).length > 0 && ( = ({
> context?: TContext; /** Must be stable to avoid constant re-renders */ contextMenuItems?: ContextMenuItem[]; + initialSortColumns?: SortColumn[]; /** Must be stable to avoid constant re-renders */ contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void; getRowKey: (row: T) => string; @@ -39,6 +40,7 @@ export const DataTable = forwardRef>( includeQuickFilter, context, contextMenuItems, + initialSortColumns, contextMenuAction, getRowKey, ignoreRowInSetFilter, @@ -70,6 +72,7 @@ export const DataTable = forwardRef>( quickFilterText, includeQuickFilter, contextMenuItems, + initialSortColumns, ref, contextMenuAction, getRowKey, diff --git a/libs/ui/src/lib/data-table/useDataTable.tsx b/libs/ui/src/lib/data-table/useDataTable.tsx index ac07fb572..09abe5060 100644 --- a/libs/ui/src/lib/data-table/useDataTable.tsx +++ b/libs/ui/src/lib/data-table/useDataTable.tsx @@ -38,6 +38,7 @@ export interface UseDataTableProps { // context?: TContext; /** Must be stable to avoid constant re-renders */ contextMenuItems?: ContextMenuItem[]; + initialSortColumns?: SortColumn[]; ref: any; /** Must be stable to avoid constant re-renders */ contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void; @@ -56,6 +57,7 @@ export function useDataTable({ quickFilterText, includeQuickFilter, contextMenuItems, + initialSortColumns, ref, contextMenuAction, getRowKey, @@ -66,7 +68,7 @@ export function useDataTable({ }: UseDataTableProps) { const [gridId] = useState(() => uniqueId('grid-')); const [columns, setColumns] = useState(_columns || []); - const [sortColumns, setSortColumns] = useState([]); + const [sortColumns, setSortColumns] = useState(() => initialSortColumns || []); const [rowFilterText, setRowFilterText] = useState>({}); const [renderers, setRenderers] = useState>({}); const [columnsOrder, setColumnsOrder] = useState((): readonly number[] => columns.map((_, index) => index)); diff --git a/libs/ui/src/lib/form/dropdown/DropDown.tsx b/libs/ui/src/lib/form/dropdown/DropDown.tsx index 949d5ff8e..a0288e8ae 100644 --- a/libs/ui/src/lib/form/dropdown/DropDown.tsx +++ b/libs/ui/src/lib/form/dropdown/DropDown.tsx @@ -170,6 +170,7 @@ export const DropDown: FunctionComponent = ({