Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable restoring records from the recycle bin #1224

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/app/controllers/sf-record.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const routeDefinition = {
validators: {
params: z.object({
sobject: z.string(),
operation: z.string(),
operation: z.enum(['retrieve', 'create', 'update', 'upsert', 'delete', 'undelete']),
}),
body: RecordOperationRequestSchema,
query: z.object({
Expand Down
Binary file modified apps/docs/docs/query/bulk-record-actions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 8 additions & 5 deletions apps/docs/docs/query/query-results.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Here are some examples of things you can do from the Query Results page:
- Click on an Id to navigate to that record in Salesforce.
- Download records so you can make changes to records, then load the data back in to Salesforce.
- Copy records to your clipboard and paste into a spreadsheet.
- Modify records (view, edit, clone, delete).
- Modify records (view, edit, clone, delete, restore from recycle bin).
- Update all of the queried records in bulk, such as updating a picklist field for all records.

<img src={require('./query-results-records.png').default} alt="Query results table" />
Expand Down Expand Up @@ -71,11 +71,14 @@ On the left side of each record, there are four icons.

### Bulk Record Actions

If you select one or more records, a table menu button at the top of the page will be enabled and within the menu you can:
If you select one or more records, the settings icon button at the top of the page will be enabled and within the menu you can:

1. Delete the selected records
2. Turn the selected records into Apex code
3. Open each record in a new tab within Salesforce. (Make sure you have an active session with the Salesforce instance prior to to selecting this action, as you will not be automatically logged in.)
1. Bulk update records based on query results
2. Create a new record
3. Delete the selected records
4. Undelete the selected records
5. Turn the selected records into Apex code
6. Open each record in a new tab within Salesforce. (Make sure you have an active session with the Salesforce instance prior to to selecting this action, as you will not be automatically logged in.)

<img src={require('./bulk-record-actions.png').default} alt="Bulk Record Actions" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const routeDefinition = {
validators: {
params: z.object({
sobject: z.string(),
operation: z.string(),
operation: z.enum(['retrieve', 'create', 'update', 'upsert', 'delete', 'undelete']),
}),
body: RecordOperationRequestSchema,
query: z.object({
Expand Down
6 changes: 2 additions & 4 deletions libs/connected/connected-ui/src/lib/useDescribeMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,8 @@ export function useDescribeMetadata(
setHasLoaded(true);
try {
setLoading(true);
if (hasError) {
setHasError(false);
setErrorMessage(null);
}
setHasError(false);
setErrorMessage(null);
if (clearCache) {
clearCacheForOrg(selectedOrg);
}
Expand Down
46 changes: 44 additions & 2 deletions libs/features/query/src/QueryResults/QueryResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ export const QueryResults: FunctionComponent<QueryResultsProps> = React.memo(()
const skipFrontdoorLogin = useRecoilValue(fromAppState.selectSkipFrontdoorAuth);
const [totalRecordCount, setTotalRecordCount] = useState<number | null>(null);
const bulkDeleteJob = useObservable(
fromJetstreamEvents.getObservable('jobFinished').pipe(filter((ev) => isAsyncJob(ev) && ev.type === 'BulkDelete'))
fromJetstreamEvents
.getObservable('jobFinished')
.pipe(filter((ev) => isAsyncJob(ev) && (ev.type === 'BulkDelete' || ev.type === 'BulkUndelete')))
);
const { notifyUser } = useBrowserNotifications(serverUrl);
const { confirm } = useConfirmation();
Expand Down Expand Up @@ -447,7 +449,9 @@ export const QueryResults: FunctionComponent<QueryResultsProps> = React.memo(()
content: (
<div className="slds-m-around_medium">
<p className="slds-align_absolute-center slds-m-bottom_small">
Are you sure you want to <span className="slds-text-color_destructive slds-p-left_xx-small">delete {label}</span>?
<span>
Are you sure you want to <span className="slds-text-color_destructive slds-p-left_xx-small">delete {label}</span>?
</span>
</p>
<p>
<strong>This record will be deleted from Salesforce.</strong> If you want to recover deleted records you can use the Salesforce
Expand Down Expand Up @@ -477,6 +481,40 @@ export const QueryResults: FunctionComponent<QueryResultsProps> = React.memo(()
});
}

async function handleUndelete(record?: SalesforceRecord) {
const label = record.Name || record.Name || record.Id || getRecordIdFromAttributes(record);
await confirm({
content: (
<div className="slds-m-around_medium">
<p className="slds-align_absolute-center slds-m-bottom_small">
<span>
Are you sure you want to restore the record: <strong>{label}</strong> from the Recycle Bin?
</span>
</p>
</div>
),
header: 'Confirm Restore',
confirmText: 'Restore',
cancelText: 'Cancel',
})
.then(() => {
const jobs: AsyncJobNew[] = [
{
type: 'BulkUndelete',
title: `Restore Record - ${label}`,
org: selectedOrg,
meta: { records: [record] },
},
];
fromJetstreamEvents.emit({ type: 'newJob', payload: jobs });
trackEvent(ANALYTICS_KEYS.query_BulkUndelete, { numRecords: selectedRows.length, source: 'ROW_ACTION' });
})
.catch((ex) => {
logger.info(ex);
// user canceled
});
}

function handleGetAsApex(record: any) {
setGetRecordAsApex({
record: record,
Expand Down Expand Up @@ -620,6 +658,7 @@ export const QueryResults: FunctionComponent<QueryResultsProps> = React.memo(()
filteredRows={filteredRows}
selectedRows={selectedRows}
totalRecordCount={totalRecordCount || 0}
includeDeletedRecords={includeDeletedRecords}
refreshRecords={() => executeQuery(soql, SOURCE_RELOAD, { isTooling })}
onCreateNewRecord={handleCreateNewRecord}
/>
Expand Down Expand Up @@ -770,6 +809,9 @@ export const QueryResults: FunctionComponent<QueryResultsProps> = React.memo(()
onDelete={(record) => {
handleDelete(record);
}}
onUndelete={(record) => {
handleUndelete(record);
}}
onGetAsApex={(record) => {
handleGetAsApex(record);
}}
Expand Down
59 changes: 55 additions & 4 deletions libs/features/query/src/QueryResults/QueryResultsMoreActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const BatchSize = ({ type = 'BULK', onBatchSizeChange }: { type?: 'BATCH' | 'BUL
const [batchSize, setBatchSize] = useState(maxSize);
useNonInitialEffect(() => {
onBatchSizeChange(batchSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchSize]);

return (
Expand Down Expand Up @@ -54,6 +53,7 @@ export interface QueryResultsMoreActionsProps {
filteredRows: any[];
selectedRows: any[];
totalRecordCount: number;
includeDeletedRecords?: boolean;
refreshRecords: () => void;
onCreateNewRecord: () => void;
}
Expand All @@ -67,14 +67,15 @@ export const QueryResultsMoreActions: FunctionComponent<QueryResultsMoreActionsP
filteredRows,
selectedRows,
totalRecordCount,
includeDeletedRecords,
refreshRecords,
onCreateNewRecord,
}) => {
const { trackEvent } = useAmplitude();
const { confirm, setOptions } = useConfirmation();
const [openModal, setOpenModal] = useState<false | 'bulk-update' | 'apex'>(false);

function handleAction(id: 'bulk-delete' | 'get-as-apex' | 'open-in-new-tab' | 'bulk-update' | 'new-record') {
function handleAction(id: 'bulk-delete' | 'bulk-undelete' | 'get-as-apex' | 'open-in-new-tab' | 'bulk-update' | 'new-record') {
logger.log({ id, selectedRows });
switch (id) {
case 'bulk-update': {
Expand All @@ -98,7 +99,7 @@ export const QueryResultsMoreActions: FunctionComponent<QueryResultsMoreActionsP
</p>
<p>
<strong>These records will be deleted from Salesforce.</strong> If you want to recover deleted records you can use the
Salesforce recycle bin.
Salesforce Recycle Bin.
</p>
<BatchSize
type="BATCH"
Expand Down Expand Up @@ -127,6 +128,45 @@ export const QueryResultsMoreActions: FunctionComponent<QueryResultsMoreActionsP
});
break;
}
case 'bulk-undelete': {
if (!selectedRows) {
return;
}
const recordCountText = `${selectedRows.length} ${pluralizeIfMultiple('Record', selectedRows)}`;
confirm({
content: (
<div className="slds-m-around_medium">
<p className="slds-align_absolute-center slds-m-bottom_small">
<span>Are you sure you want to restore {recordCountText}</span>?
</p>
<p>Jetstream will attempt to restore these records from the Salesforce Recycle Bin.</p>
<BatchSize
type="BATCH"
onBatchSizeChange={(batchSize) => {
setOptions((prevValue) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...prevValue!,
submitDisabled: !batchSize || batchSize > MAX_BULK || batchSize < 1,
data: { batchSize },
}));
}}
/>
</div>
),
})
.then(({ batchSize }: { batchSize: number }) => {
const jobs: AsyncJobNew[] = [
{ type: 'BulkUndelete', title: `Restore ${recordCountText}`, org: selectedOrg, meta: { batchSize, records: selectedRows } },
];
fromJetstreamEvents.emit({ type: 'newJob', payload: jobs });
trackEvent(ANALYTICS_KEYS.query_BulkUndelete, { numRecords: selectedRows.length, source: 'HEADER_ACTION' });
})
.catch((ex) => {
logger.info(ex);
// user canceled
});
break;
}
case 'get-as-apex': {
if (!selectedRows) {
return;
Expand Down Expand Up @@ -216,6 +256,15 @@ export const QueryResultsMoreActions: FunctionComponent<QueryResultsMoreActionsP
type: 'utility',
},
},
{
id: 'bulk-undelete',
value: 'Restore records from the Recycle Bin',
disabled: !includeDeletedRecords || disabled || selectedRows.length === 0,
icon: {
icon: 'undelete',
type: 'utility',
},
},
{
id: 'get-as-apex',
value: 'Convert selected records to Apex',
Expand All @@ -235,7 +284,9 @@ export const QueryResultsMoreActions: FunctionComponent<QueryResultsMoreActionsP
},
},
]}
onSelected={(item) => handleAction(item as 'bulk-delete' | 'get-as-apex' | 'open-in-new-tab' | 'bulk-update' | 'new-record')}
onSelected={(item) =>
handleAction(item as 'bulk-delete' | 'bulk-undelete' | 'get-as-apex' | 'open-in-new-tab' | 'bulk-update' | 'new-record')
}
/>

{openModal === 'bulk-update' && sObject && totalRecordCount && parsedQuery && (
Expand Down
2 changes: 2 additions & 0 deletions libs/icon-factory/src/lib/icon-factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import UtilityIcon_Task from './icons/utility/Task';
import UtilityIcon_Text from './icons/utility/Text';
import UtilityIcon_ToggleOff from './icons/utility/ToggleOff';
import UtilityIcon_ToggleOn from './icons/utility/ToggleOn';
import UtilityIcon_Undelete from './icons/utility/Undelete';
import UtilityIcon_Underline from './icons/utility/Underline';
import UtilityIcon_Undo from './icons/utility/Undo';
import UtilityIcon_Up from './icons/utility/Up';
Expand Down Expand Up @@ -336,6 +337,7 @@ const utilityIcons = {
text: UtilityIcon_Text,
toggle_off: UtilityIcon_ToggleOff,
toggle_on: UtilityIcon_ToggleOn,
undelete: UtilityIcon_Undelete,
underline: UtilityIcon_Underline,
undo: UtilityIcon_Undo,
up: UtilityIcon_Up,
Expand Down
23 changes: 21 additions & 2 deletions libs/salesforce-api/src/lib/api-sobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
DescribeSObjectResult,
Maybe,
OperationReturnType,
RecordResult,
SalesforceRecord,
SobjectOperation,
} from '@jetstream/types';
import { ApiConnection } from './connection';
import { SalesforceApi } from './utils';
import { SoapResponse } from './types';
import { correctRecordResultSoapXmlResponse, SalesforceApi } from './utils';

export class ApiSObject extends SalesforceApi {
constructor(connection: ApiConnection) {
Expand All @@ -35,7 +37,7 @@ export class ApiSObject extends SalesforceApi {
records,
}: {
sobject: string;
operation: string;
operation: 'retrieve' | 'create' | 'update' | 'upsert' | 'delete' | 'undelete';
externalId?: Maybe<string>;
allOrNone?: Maybe<boolean>;
ids?: Maybe<string | string[]>;
Expand Down Expand Up @@ -144,6 +146,23 @@ export class ApiSObject extends SalesforceApi {

break;
}
case 'undelete': {
if (!Array.isArray(ids)) {
throw new Error(`The ids property must be included`);
}
operationPromise = this.apiRequest<SoapResponse<'undeleteResponse', RecordResult[]>>(
this.prepareSoapRequestOptions({
type: 'PARTNER',
header: {
AllOrNoneHeader: { allOrNone: allOrNone ? 'true' : 'false' },
},
body: { undelete: { ids } },
})
).then((response) => {
return correctRecordResultSoapXmlResponse(response['ns1:Envelope'].Body.undeleteResponse.result);
});
break;
}
default:
throw new Error(`The operation ${operation} is not valid`);
}
Expand Down
Loading