From 3d9a3e93f899bd56216e1553b40629482c75d142 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Thu, 14 Mar 2024 18:17:12 -0700 Subject: [PATCH 1/3] Add option to delete individual records #778 --- .../query/QueryResults/QueryResults.tsx | 60 +++++++++++++++++-- .../src/lib/data-table/DataTableRenderers.tsx | 38 ++++++++---- .../data-table/SalesforceRecordDataTable.tsx | 7 ++- .../src/lib/data-table/data-table-utils.tsx | 6 +- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx b/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx index 62bafc6f9..85cc748e6 100644 --- a/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx +++ b/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx @@ -15,8 +15,17 @@ import { useNonInitialEffect, useObservable, } from '@jetstream/shared/ui-utils'; -import { getRecordIdFromAttributes, getSObjectNameFromAttributes, splitArrayToMaxSize } from '@jetstream/shared/utils'; -import { AsyncJob, CloneEditView, MapOf, Maybe, Record, SalesforceOrgUi, SobjectCollectionResponse } from '@jetstream/types'; +import { ensureArray, getRecordIdFromAttributes, getSObjectNameFromAttributes, splitArrayToMaxSize } from '@jetstream/shared/utils'; +import { + AsyncJob, + CloneEditView, + MapOf, + Maybe, + RecordResult, + SalesforceOrgUi, + Record as SalesforceRecord, + SobjectCollectionResponse, +} from '@jetstream/types'; import { AutoFullHeightContainer, ButtonGroupContainer, @@ -30,6 +39,8 @@ import { Toolbar, ToolbarItemActions, ToolbarItemGroup, + fireToast, + useConfirmation, } from '@jetstream/ui'; import classNames from 'classnames'; import React, { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; @@ -91,12 +102,12 @@ export const QueryResults: FunctionComponent = React.memo(() const [parsedQuery, setParsedQuery] = useState>(null); const [queryResults, setQueryResults] = useState(null); const [recordCount, setRecordCount] = useState(null); - const [records, setRecords] = useState(null); + const [records, setRecords] = useState(null); const [nextRecordsUrl, setNextRecordsUrl] = useState>(null); const [fields, setFields] = useState(null); const [subqueryFields, setSubqueryFields] = useState>>(null); - const [filteredRows, setFilteredRows] = useState([]); - const [selectedRows, setSelectedRows] = useState([]); + const [filteredRows, setFilteredRows] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const selectedOrg = useRecoilValue(selectedOrgState); @@ -108,6 +119,7 @@ export const QueryResults: FunctionComponent = React.memo(() fromJetstreamEvents.getObservable('jobFinished').pipe(filter((ev: AsyncJob) => ev.type === 'BulkDelete')) ); const { notifyUser } = useBrowserNotifications(serverUrl, window.electron?.isFocused); + const confirm = useConfirmation(); const [cloneEditViewRecord, setCloneEditViewRecord] = useState<{ action: CloneEditView; @@ -404,6 +416,41 @@ export const QueryResults: FunctionComponent = React.memo(() } } + async function handleDelete(record?: SalesforceRecord) { + try { + await confirm({ + content: ( +
+

Are you sure you want to delete this record?

+
+ ), + header: 'Confirm Delete', + confirmText: 'Delete', + cancelText: 'Cancel', + }); + try { + setLoading(true); + const objectName = sobject || getSObjectNameFromAttributes(record); + const id = record.Id || getRecordIdFromAttributes(record); + if (!objectName || !id) { + throw new Error('Invalid object name or id'); + } + const results = ensureArray(await sobjectOperation(selectedOrg, objectName, 'delete', { ids: [id] })); + if (results[0]?.success) { + fireToast({ message: 'Record has been successfully deleted.', type: 'success' }); + executeQuery(soql, SOURCE_RECORD_ACTION, { isTooling }); + } else { + throw new Error(results[0]?.errors?.[0]?.message || 'An unknown error has occurred'); + } + } catch (ex) { + fireToast({ message: `Error deleting record. ${ex.message || ''}`, type: 'error', duration: 30000 }); + setLoading(false); + } + } catch (ex) { + // user cancelled + } + } + function handleGetAsApex(record: any) { setGetRecordAsApex({ record: record, @@ -688,6 +735,9 @@ export const QueryResults: FunctionComponent = React.memo(() handleCloneEditView(record, 'view', source); }} onUpdateRecords={handleUpdateRecords} + onDelete={(record) => { + handleDelete(record); + }} onGetAsApex={(record) => { handleGetAsApex(record); }} diff --git a/libs/ui/src/lib/data-table/DataTableRenderers.tsx b/libs/ui/src/lib/data-table/DataTableRenderers.tsx index 93f4471b1..c2debf866 100644 --- a/libs/ui/src/lib/data-table/DataTableRenderers.tsx +++ b/libs/ui/src/lib/data-table/DataTableRenderers.tsx @@ -23,6 +23,7 @@ import CopyToClipboard from '../widgets/CopyToClipboard'; import Icon from '../widgets/Icon'; import RecordLookupPopover from '../widgets/RecordLookupPopover'; import Spinner from '../widgets/Spinner'; +import Tooltip from '../widgets/Tooltip'; import { DataTableFilterContext, DataTableGenericContext, DataTableSelectedContext } from './data-table-context'; import { dataTableDateFormatter } from './data-table-formatters'; import { @@ -603,18 +604,31 @@ export const ActionRenderer: FunctionComponent<{ row: any }> = ({ row }) => { return ( - - - - + + + + + + + + + + + + + + + ); }; diff --git a/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx b/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx index a64a842c8..dc60f8c2c 100644 --- a/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx +++ b/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx @@ -71,6 +71,7 @@ export interface SalesforceRecordDataTableProps { onClone: (record: any, source: 'ROW_ACTION' | 'RELATED_RECORD_POPOVER') => void; onView: (record: any, source: 'ROW_ACTION' | 'RELATED_RECORD_POPOVER') => void; onUpdateRecords: (records: any[]) => Promise; + onDelete: (record: any) => void; onGetAsApex: (record: any) => void; onSavedRecords: (results: { recordCount: number; failureCount: number }) => void; onReloadQuery: () => void; @@ -99,6 +100,7 @@ export const SalesforceRecordDataTable: FunctionComponent row._saveError).map((row) => row._saveError!)); }, [dirtyRows]); - const handleRowAction = useCallback((row: RowWithKey, action: 'view' | 'edit' | 'clone' | 'apex') => { + const handleRowAction = useCallback((row: RowWithKey, action: 'view' | 'edit' | 'clone' | 'delete' | 'apex') => { const record = row._record; logger.info('row action', record, action); switch (action) { @@ -200,6 +202,9 @@ export const SalesforceRecordDataTable: FunctionComponent Date: Thu, 14 Mar 2024 19:14:36 -0700 Subject: [PATCH 2/3] fix engines --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0d951ce67..59389a6e1 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "version": "3.10.10", "license": "GNU Lesser General Public License v3.0", "engines": { - "node": "20", - "yarn": "1.22.21" + "node": ">=20 <22", + "yarn": ">=1.22.21 <2" }, "scripts": { "init:project": "ts-node ./scripts/init-project.ts", From f4e752094e5cd86fdefaa6e59abedd70ab9f7bb8 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Fri, 15 Mar 2024 07:33:26 -0700 Subject: [PATCH 3/3] Improve record delete and combine query table actions Use bulk API with background job for deleting individual record for a consistent experience Combine table actions into one dropdown, with subheadings, and disable options if there are no selected records resolves #778 --- .../query/QueryResults/QueryResults.tsx | 61 ++++---- .../QueryResults/QueryResultsMoreActions.tsx | 146 ++++++++++-------- libs/icon-factory/src/lib/icon-factory.tsx | 10 ++ .../ConfirmationDialog.tsx | 5 +- .../lib/providers/DialogServiceProvider.tsx | 2 +- 5 files changed, 118 insertions(+), 106 deletions(-) diff --git a/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx b/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx index 85cc748e6..bdc340006 100644 --- a/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx +++ b/apps/jetstream/src/app/components/query/QueryResults/QueryResults.tsx @@ -15,13 +15,13 @@ import { useNonInitialEffect, useObservable, } from '@jetstream/shared/ui-utils'; -import { ensureArray, getRecordIdFromAttributes, getSObjectNameFromAttributes, splitArrayToMaxSize } from '@jetstream/shared/utils'; +import { getRecordIdFromAttributes, getSObjectNameFromAttributes, splitArrayToMaxSize } from '@jetstream/shared/utils'; import { AsyncJob, + AsyncJobNew, CloneEditView, MapOf, Maybe, - RecordResult, SalesforceOrgUi, Record as SalesforceRecord, SobjectCollectionResponse, @@ -39,7 +39,6 @@ import { Toolbar, ToolbarItemActions, ToolbarItemGroup, - fireToast, useConfirmation, } from '@jetstream/ui'; import classNames from 'classnames'; @@ -417,38 +416,32 @@ export const QueryResults: FunctionComponent = React.memo(() } async function handleDelete(record?: SalesforceRecord) { - try { - await confirm({ - content: ( -
-

Are you sure you want to delete this record?

-
- ), - header: 'Confirm Delete', - confirmText: 'Delete', - cancelText: 'Cancel', + const label = record.Name || record.Name || record.Id || getRecordIdFromAttributes(record); + await confirm({ + content: ( +
+

+ Are you sure you want to delete {label}? +

+

+ This record will be deleted from Salesforce. If you want to recover deleted records you can use the Salesforce + recycle bin. +

+
+ ), + header: 'Confirm Delete', + confirmText: 'Delete', + cancelText: 'Cancel', + }) + .then(() => { + const jobs: AsyncJobNew[] = [{ type: 'BulkDelete', title: `Delete Record - ${label}`, org: selectedOrg, meta: [record] }]; + fromJetstreamEvents.emit({ type: 'newJob', payload: jobs }); + trackEvent(ANALYTICS_KEYS.query_BulkDelete, { numRecords: selectedRows.length, source: 'ROW_ACTION' }); + }) + .catch((ex) => { + logger.info(ex); + // user cancelled }); - try { - setLoading(true); - const objectName = sobject || getSObjectNameFromAttributes(record); - const id = record.Id || getRecordIdFromAttributes(record); - if (!objectName || !id) { - throw new Error('Invalid object name or id'); - } - const results = ensureArray(await sobjectOperation(selectedOrg, objectName, 'delete', { ids: [id] })); - if (results[0]?.success) { - fireToast({ message: 'Record has been successfully deleted.', type: 'success' }); - executeQuery(soql, SOURCE_RECORD_ACTION, { isTooling }); - } else { - throw new Error(results[0]?.errors?.[0]?.message || 'An unknown error has occurred'); - } - } catch (ex) { - fireToast({ message: `Error deleting record. ${ex.message || ''}`, type: 'error', duration: 30000 }); - setLoading(false); - } - } catch (ex) { - // user cancelled - } } function handleGetAsApex(record: any) { diff --git a/apps/jetstream/src/app/components/query/QueryResults/QueryResultsMoreActions.tsx b/apps/jetstream/src/app/components/query/QueryResults/QueryResultsMoreActions.tsx index ab07f2f75..e9cc4d532 100644 --- a/apps/jetstream/src/app/components/query/QueryResults/QueryResultsMoreActions.tsx +++ b/apps/jetstream/src/app/components/query/QueryResults/QueryResultsMoreActions.tsx @@ -2,7 +2,7 @@ import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { pluralizeIfMultiple } from '@jetstream/shared/utils'; import { AsyncJobNew, Maybe, SalesforceOrgUi } from '@jetstream/types'; -import { DropDown, Tooltip, getSfdcRetUrl, salesforceLoginAndRedirect, useConfirmation } from '@jetstream/ui'; +import { DropDown, getSfdcRetUrl, salesforceLoginAndRedirect, useConfirmation } from '@jetstream/ui'; import { Fragment, FunctionComponent, useState } from 'react'; import { Query } from 'soql-parser-js'; import { useAmplitude } from '../../core/analytics'; @@ -41,10 +41,21 @@ export const QueryResultsMoreActions: FunctionComponent(false); - function handleBulkRowAction(id: 'bulk-delete' | 'get-as-apex' | 'open-in-new-tab') { + function handleAction(id: 'bulk-delete' | 'get-as-apex' | 'open-in-new-tab' | 'bulk-update' | 'new-record') { logger.log({ id, selectedRows }); switch (id) { + case 'bulk-update': { + setOpenModal('bulk-update'); + break; + } + case 'new-record': { + onCreateNewRecord(); + break; + } case 'bulk-delete': { + if (!selectedRows) { + return; + } const recordCountText = `${selectedRows.length} ${pluralizeIfMultiple('Record', selectedRows)}`; confirm({ content: ( @@ -58,25 +69,29 @@ export const QueryResultsMoreActions: FunctionComponent ), - }).then(() => { - const jobs: AsyncJobNew[] = [ - { - type: 'BulkDelete', - title: `Delete ${recordCountText}`, - org: selectedOrg, - meta: selectedRows, - }, - ]; - fromJetstreamEvents.emit({ type: 'newJob', payload: jobs }); - trackEvent(ANALYTICS_KEYS.query_BulkDelete, { numRecords: selectedRows.length }); - }); + }) + .then(() => { + const jobs: AsyncJobNew[] = [{ type: 'BulkDelete', title: `Delete ${recordCountText}`, org: selectedOrg, meta: selectedRows }]; + fromJetstreamEvents.emit({ type: 'newJob', payload: jobs }); + trackEvent(ANALYTICS_KEYS.query_BulkDelete, { numRecords: selectedRows.length, source: 'HEADER_ACTION' }); + }) + .catch((ex) => { + logger.info(ex); + // user cancelled + }); break; } case 'get-as-apex': { + if (!selectedRows) { + return; + } setOpenModal('apex'); break; } case 'open-in-new-tab': { + if (!selectedRows) { + return; + } (selectedRows.length <= 15 ? Promise.resolve() : confirm({ @@ -110,19 +125,6 @@ export const QueryResultsMoreActions: FunctionComponent - - handleBulkRowAction(item as 'bulk-delete' | 'get-as-apex' | 'open-in-new-tab')} - /> - - handleAction(item as 'bulk-update' | 'new-record')} + onSelected={(item) => handleAction(item as 'bulk-delete' | 'get-as-apex' | 'open-in-new-tab' | 'bulk-update' | 'new-record')} /> {openModal === 'bulk-update' && sObject && totalRecordCount && parsedQuery && ( diff --git a/libs/icon-factory/src/lib/icon-factory.tsx b/libs/icon-factory/src/lib/icon-factory.tsx index 67ab77ab7..684cc91dd 100644 --- a/libs/icon-factory/src/lib/icon-factory.tsx +++ b/libs/icon-factory/src/lib/icon-factory.tsx @@ -35,7 +35,9 @@ import StandardIcon_PortalRolesAndSubordinates from './icons/standard/PortalRole import StandardIcon_ProductConsumed from './icons/standard/ProductConsumed'; import StandardIcon_Record from './icons/standard/Record'; import StandardIcon_RecordCreate from './icons/standard/RecordCreate'; +import StandardIcon_RecordDelete from './icons/standard/RecordDelete'; import StandardIcon_RecordLookup from './icons/standard/RecordLookup'; +import StandardIcon_RecordUpdate from './icons/standard/RecordUpdate'; import StandardIcon_RelatedList from './icons/standard/RelatedList'; import StandardIcon_Settings from './icons/standard/Settings'; import UtilityIcon_Add from './icons/utility/Add'; @@ -102,7 +104,10 @@ import UtilityIcon_Play from './icons/utility/Play'; import UtilityIcon_Preview from './icons/utility/Preview'; import UtilityIcon_PromptEdit from './icons/utility/PromptEdit'; import UtilityIcon_QuotationMarks from './icons/utility/QuotationMarks'; +import UtilityIcon_RecordCreate from './icons/utility/RecordCreate'; +import UtilityIcon_RecordDelete from './icons/utility/RecordDelete'; import UtilityIcon_RecordLookup from './icons/utility/RecordLookup'; +import UtilityIcon_RecordUpdate from './icons/utility/RecordUpdate'; import UtilityIcon_Refresh from './icons/utility/Refresh'; import UtilityIcon_RemoveFormatting from './icons/utility/RemoveFormatting'; import UtilityIcon_Richtextbulletedlist from './icons/utility/Richtextbulletedlist'; @@ -175,7 +180,9 @@ const standardIcons = { portal_roles_and_subordinates: StandardIcon_PortalRolesAndSubordinates, product_consumed: StandardIcon_ProductConsumed, record_create: StandardIcon_RecordCreate, + record_delete: StandardIcon_RecordDelete, record_lookup: StandardIcon_RecordLookup, + record_update: StandardIcon_RecordUpdate, record: StandardIcon_Record, related_list: StandardIcon_RelatedList, settings: StandardIcon_Settings, @@ -261,7 +268,10 @@ const utilityIcons = { preview: UtilityIcon_Preview, prompt_edit: UtilityIcon_PromptEdit, quotation_marks: UtilityIcon_QuotationMarks, + record_create: UtilityIcon_RecordCreate, + record_delete: UtilityIcon_RecordDelete, record_lookup: UtilityIcon_RecordLookup, + record_update: UtilityIcon_RecordUpdate, refresh: UtilityIcon_Refresh, remove_formatting: UtilityIcon_RemoveFormatting, richtextbulletedlist: UtilityIcon_Richtextbulletedlist, diff --git a/libs/ui/src/lib/confirmation-dialog/ConfirmationDialog.tsx b/libs/ui/src/lib/confirmation-dialog/ConfirmationDialog.tsx index 25a80f446..ad44d7e28 100644 --- a/libs/ui/src/lib/confirmation-dialog/ConfirmationDialog.tsx +++ b/libs/ui/src/lib/confirmation-dialog/ConfirmationDialog.tsx @@ -1,7 +1,7 @@ +import { Maybe } from '@jetstream/types'; +import { OverlayProvider } from '@react-aria/overlays'; import React, { Fragment, FunctionComponent } from 'react'; import Modal from '../modal/Modal'; -import { OverlayProvider } from '@react-aria/overlays'; -import { Maybe } from '@jetstream/types'; export interface ConfirmationDialogProps { isOpen: boolean; @@ -15,7 +15,6 @@ export interface ConfirmationDialogProps { } export interface ConfirmationDialogServiceProviderOptions { - rejectOnCancel?: boolean; // if true, then a cancellation will result in a rejected promise header?: Maybe; tagline?: Maybe; content: React.ReactNode; diff --git a/libs/ui/src/lib/providers/DialogServiceProvider.tsx b/libs/ui/src/lib/providers/DialogServiceProvider.tsx index 11ed7ea88..fb047afb4 100644 --- a/libs/ui/src/lib/providers/DialogServiceProvider.tsx +++ b/libs/ui/src/lib/providers/DialogServiceProvider.tsx @@ -28,7 +28,7 @@ export const ConfirmationServiceProvider: FunctionComponent { - if (confirmationState?.rejectOnCancel && awaitingPromiseRef.current) { + if (awaitingPromiseRef.current) { awaitingPromiseRef.current.reject(); } setConfirmationState(null);