Skip to content

Commit fed3ff4

Browse files
fix: remote-only deletes now supported (#220)
* fix: remote-only deletes now supported * chore: code review I - perf * chore: update messages * chore: fix tests after conflicts changed stubs
1 parent d87671b commit fed3ff4

File tree

5 files changed

+112
-33
lines changed

5 files changed

+112
-33
lines changed

messages/delete.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,7 @@
3737
"If you don’t specify a test level, the default behavior depends on the contents of your deployment package. For more information, see “Running Tests in a Deployment” in the Metadata API Developer Guide."
3838
]
3939
},
40-
"prompt": "This operation will delete the following files on your computer and in your org: \n%s\n\nAre you sure you want to proceed (y/n)?"
40+
"localPrompt": "This operation will delete the following files on your computer and in your org: \n%s",
41+
"remotePrompt": "This operation will delete the following metadata in your org: \n%s",
42+
"areYouSure": "\n\nAre you sure you want to proceed (y/n)?"
4143
}

src/commands/force/source/delete.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import * as fs from 'fs';
99
import { confirm } from 'cli-ux/lib/prompt';
1010
import { flags, FlagsConfig } from '@salesforce/command';
1111
import { Messages } from '@salesforce/core';
12-
import { ComponentSet, RequestStatus, SourceComponent } from '@salesforce/source-deploy-retrieve';
13-
import { Duration, once, env } from '@salesforce/kit';
12+
import { ComponentSet, MetadataComponent, RequestStatus, SourceComponent } from '@salesforce/source-deploy-retrieve';
13+
import { Duration, env, once } from '@salesforce/kit';
1414
import { getString } from '@salesforce/ts-types';
1515
import { DeployCommand } from '../../../deployCommand';
1616
import { ComponentSetBuilder } from '../../../componentSetBuilder';
@@ -72,10 +72,10 @@ export class Delete extends DeployCommand {
7272
};
7373
protected xorFlags = ['metadata', 'sourcepath'];
7474
protected readonly lifecycleEventNames = ['predeploy', 'postdeploy'];
75-
private sourceComponents: SourceComponent[];
7675
private isRest = false;
7776
private deleteResultFormatter: DeleteResultFormatter;
7877
private aborted = false;
78+
private components: MetadataComponent[];
7979

8080
private updateDeployId = once((id) => {
8181
this.displayDeployId(id);
@@ -107,26 +107,31 @@ export class Delete extends DeployCommand {
107107
},
108108
});
109109

110-
this.sourceComponents = this.componentSet.getSourceComponents().toArray();
110+
this.components = this.componentSet.toArray();
111111

112-
if (!this.sourceComponents.length) {
112+
if (!this.components.length) {
113113
// if we didn't find any components to delete, let the user know and exit
114114
this.deleteResultFormatter.displayNoResultsFound();
115115
return;
116116
}
117117

118118
// create a new ComponentSet and mark everything for deletion
119119
const cs = new ComponentSet([]);
120-
this.sourceComponents.map((component) => {
121-
cs.add(component, true);
120+
this.components.map((component) => {
121+
if (component instanceof SourceComponent) {
122+
cs.add(component, true);
123+
} else {
124+
// a remote-only delete
125+
cs.add(new SourceComponent({ name: component.fullName, type: component.type }), true);
126+
}
122127
});
123128
this.componentSet = cs;
124129

125130
this.aborted = !(await this.handlePrompt());
126131
if (this.aborted) return;
127132

128133
// fire predeploy event for the delete
129-
await this.lifecycle.emit('predeploy', this.componentSet.toArray());
134+
await this.lifecycle.emit('predeploy', this.components);
130135
this.isRest = await this.isRestDeploy();
131136
this.ux.log(`*** Deleting with ${this.isRest ? 'REST' : 'SOAP'} API ***`);
132137

@@ -178,7 +183,7 @@ export class Delete extends DeployCommand {
178183

179184
private deleteFilesLocally(): void {
180185
if (!this.getFlag('checkonly') && getString(this.deployResult, 'response.status') === 'Succeeded') {
181-
this.sourceComponents.map((component) => {
186+
this.components.map((component: SourceComponent) => {
182187
// delete the content and/or the xml of the components
183188
if (component.content) {
184189
const stats = fs.lstatSync(component.content);
@@ -188,8 +193,7 @@ export class Delete extends DeployCommand {
188193
fs.unlinkSync(component.content);
189194
}
190195
}
191-
// the xml could've been deleted as part of a bundle type above
192-
if (component.xml && fs.existsSync(component.xml)) {
196+
if (component.xml) {
193197
fs.unlinkSync(component.xml);
194198
}
195199
});
@@ -198,10 +202,33 @@ export class Delete extends DeployCommand {
198202

199203
private async handlePrompt(): Promise<boolean> {
200204
if (!this.getFlag('noprompt')) {
201-
const paths = this.sourceComponents.flatMap((component) => [component.xml, ...component.walkContent()]);
202-
const promptMessage = messages.getMessage('prompt', [[...new Set(paths)].join('\n')]);
205+
const remote: string[] = [];
206+
const local: string[] = [];
207+
const message: string[] = [];
208+
209+
this.components.flatMap((component) => {
210+
if (component instanceof SourceComponent) {
211+
local.push(component.xml, ...component.walkContent());
212+
} else {
213+
// remote only metadata
214+
remote.push(`${component.type.name}:${component.fullName}`);
215+
}
216+
});
217+
218+
if (remote.length) {
219+
message.push(messages.getMessage('remotePrompt', [[...new Set(remote)].join('\n')]));
220+
}
221+
222+
if (local.length) {
223+
if (message.length) {
224+
// add a whitespace between remote and local
225+
message.push('\n');
226+
}
227+
message.push('\n', messages.getMessage('localPrompt', [[...new Set(local)].join('\n')]));
228+
}
203229

204-
return confirm(promptMessage);
230+
message.push(messages.getMessage('areYouSure'));
231+
return confirm(message.join(''));
205232
}
206233
return true;
207234
}

src/formatters/deleteResultFormatter.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
* Licensed under the BSD 3-Clause license.
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7-
import { DeployResult } from '@salesforce/source-deploy-retrieve';
7+
import { DeployMessage, DeployResult, FileResponse } from '@salesforce/source-deploy-retrieve';
88
import { UX } from '@salesforce/command';
99
import { Logger } from '@salesforce/core';
1010
import * as chalk from 'chalk';
1111
import { DeployCommandResult, DeployResultFormatter } from './deployResultFormatter';
12-
import { ResultFormatterOptions } from './resultFormatter';
12+
import { ResultFormatterOptions, toArray } from './resultFormatter';
1313

1414
export class DeleteResultFormatter extends DeployResultFormatter {
1515
public constructor(logger: Logger, ux: UX, options: ResultFormatterOptions, result?: DeployResult) {
@@ -37,13 +37,36 @@ export class DeleteResultFormatter extends DeployResultFormatter {
3737
}
3838

3939
protected displaySuccesses(): void {
40-
if (this.isSuccess() && this.fileResponses?.length) {
41-
const successes = this.fileResponses.filter((f) => f.state !== 'Failed');
42-
if (!successes.length) {
43-
return;
40+
if (this.isSuccess()) {
41+
const successes: Array<FileResponse | DeployMessage> = [];
42+
const fileResponseSuccesses: Map<string, FileResponse> = new Map<string, FileResponse>();
43+
44+
if (this.fileResponses?.length) {
45+
const fileResponses: FileResponse[] = [];
46+
this.fileResponses.map((f: FileResponse) => {
47+
fileResponses.push(f);
48+
fileResponseSuccesses.set(`${f.type}#${f.fullName}`, f);
49+
});
50+
this.sortFileResponses(fileResponses);
51+
this.asRelativePaths(fileResponses);
52+
successes.push(...fileResponses);
53+
}
54+
55+
const deployMessages = toArray(this.result?.response?.details?.componentSuccesses).filter(
56+
(item) => !item.fileName.includes('package.xml')
57+
);
58+
if (deployMessages.length >= successes.length) {
59+
// if there's additional successes in the API response, find the success and add it to the output
60+
deployMessages.map((deployMessage) => {
61+
if (!fileResponseSuccesses.has(`${deployMessage.componentType}#${deployMessage.fullName}`)) {
62+
successes.push(
63+
Object.assign(deployMessage, {
64+
type: deployMessage.componentType,
65+
})
66+
);
67+
}
68+
});
4469
}
45-
this.sortFileResponses(successes);
46-
this.asRelativePaths(successes);
4770

4871
this.ux.log('');
4972
this.ux.styledHeader(chalk.blue('Deleted Source'));

test/commands/source/delete.test.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as fs from 'fs';
99
import { join } from 'path';
1010
import * as sinon from 'sinon';
1111
import { expect } from 'chai';
12-
import { ComponentSet } from '@salesforce/source-deploy-retrieve';
12+
import { ComponentSet, SourceComponent } from '@salesforce/source-deploy-retrieve';
1313
import { Lifecycle, Org, SfdxProject } from '@salesforce/core';
1414
import { fromStub, stubInterface, stubMethod } from '@salesforce/ts-sinon';
1515
import { IConfig } from '@oclif/config';
@@ -80,12 +80,8 @@ describe('force:source:delete', () => {
8080
beforeEach(() => {
8181
resolveProjectConfigStub = sandbox.stub();
8282
buildComponentSetStub = stubMethod(sandbox, ComponentSetBuilder, 'build').resolves({
83-
getSourceComponents: () => {
84-
return {
85-
toArray: () => {
86-
return [exampleSourceComponent];
87-
},
88-
};
83+
toArray: () => {
84+
return [new SourceComponent(exampleSourceComponent)];
8985
},
9086
});
9187
lifecycleEmitStub = sandbox.stub(Lifecycle.prototype, 'emit');
@@ -123,7 +119,8 @@ describe('force:source:delete', () => {
123119
await runDeleteCmd(['--sourcepath', sourcepath[0], '--json', '-r']);
124120
ensureCreateComponentSetArgs({ sourcepath });
125121
ensureHookArgs();
126-
expect(fsUnlink.callCount).to.equal(1);
122+
// deleting the component and its xml
123+
expect(fsUnlink.callCount).to.equal(2);
127124
});
128125

129126
it('should pass along metadata', async () => {

test/nuts/delete.nut.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as os from 'os';
1111
import { expect } from 'chai';
1212
import { execCmd } from '@salesforce/cli-plugins-testkit';
1313
import { SourceTestkit } from '@salesforce/source-testkit';
14+
import { exec } from 'shelljs';
1415

1516
describe('source:delete NUTs', () => {
1617
const executable = path.join(process.cwd(), 'bin', 'run');
@@ -66,9 +67,9 @@ describe('source:delete NUTs', () => {
6667
expect(fs.existsSync(pathToClass)).to.be.false;
6768
});
6869

69-
it('should source:delete all Prompts using the metadata param', () => {
70+
it('should source:delete all Prompts using the sourcepath param', () => {
7071
const response = execCmd<{ deletedSource: [{ filePath: string }] }>(
71-
'force:source:delete --json --noprompt --metadata Prompt',
72+
`force:source:delete --json --noprompt --sourcepath ${path.join('force-app', 'main', 'default', 'prompts')}`,
7273
{
7374
ensureExitCode: 0,
7475
}
@@ -91,6 +92,35 @@ describe('source:delete NUTs', () => {
9192
expect(fs.existsSync(pathToClass)).to.be.false;
9293
});
9394

95+
it('should source:delete a remote-only ApexClass from the org', async () => {
96+
const { apexName, pathToClass } = createApexClass();
97+
const query = () => {
98+
return JSON.parse(
99+
exec(
100+
`sfdx force:data:soql:query -q "SELECT IsNameObsolete FROM SourceMember WHERE MemberType='ApexClass' AND MemberName='${apexName}' LIMIT 1" -t --json`,
101+
{ silent: true }
102+
)
103+
) as { result: { records: Array<{ IsNameObsolete: boolean }> } };
104+
};
105+
106+
let soql = query();
107+
// the ApexClass is present in the org
108+
expect(soql.result.records[0].IsNameObsolete).to.be.false;
109+
await testkit.deleteGlobs(['force-app/main/default/classes/myApexClass.*']);
110+
const response = execCmd<{ deletedSource: [{ filePath: string }] }>(
111+
`force:source:delete --json --noprompt --metadata ApexClass:${apexName}`,
112+
{
113+
ensureExitCode: 0,
114+
}
115+
).jsonOutput.result;
116+
// remote only delete won't have an associated filepath
117+
expect(response.deletedSource).to.have.length(0);
118+
expect(fs.existsSync(pathToClass)).to.be.false;
119+
soql = query();
120+
// the apex class has been deleted in the org
121+
expect(soql.result.records[0].IsNameObsolete).to.be.true;
122+
});
123+
94124
it('should NOT delete local files with --checkonly', () => {
95125
const { apexName, pathToClass } = createApexClass();
96126
const response = execCmd<{ deletedSource: [{ filePath: string }]; deletes: [{ checkOnly: boolean }] }>(

0 commit comments

Comments
 (0)