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

Submission deletion #1256

Merged
merged 4 commits into from
Feb 27, 2024
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
27 changes: 14 additions & 13 deletions app/components/submission-action-cell/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import swal from 'sweetalert2';
export default class SubmissionActionCell extends Component {
@service currentUser;
@service submissionHandler;
@service flashMessages;

get isPreparer() {
let userId = this.currentUser.user.id;
Expand All @@ -23,32 +24,32 @@ export default class SubmissionActionCell extends Component {
}

/**
* Delete the specified submission record from Fedora.
*
* Note: `ember-fedora-adapter#deleteRecord` behaves like `ember-data#destroyRecord`
* in that the deletion is pushed to the back end automatically, such that a
* subsequent 'save()' will fail.
* Delete the specified submission record. This is done via #destroyRecord
* so is immediately persisted
*
* @param {object} submission model object to be removed
*/
@action
deleteSubmission(submission) {
swal({
// title: 'Are you sure?',
text: 'Are you sure you want to delete this draft submission? This cannot be undone.',
confirmButtonText: 'Delete',
confirmButtonColor: '#DC3545',
showCancelButton: true,
// buttonsStyling: false,
// confirmButtonClass: 'btn btn-danger',
// cancelButtonText: 'No',
// customClass: {
// confirmButton: 'btn btn-danger'
// }
}).then((result) => {
if (result.value) {
this.submissionHandler.deleteSubmission(submission);
this.do_delete(submission);
}
});
}

async do_delete(submission) {
try {
await this.submissionHandler.deleteSubmission(submission);
} catch (e) {
this.flashMessages.danger(
'We encountered an error deleting this draft submission. Please try again later or contact your administrator'
);
}
}
}
15 changes: 11 additions & 4 deletions app/controllers/submissions/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -474,10 +474,17 @@ export default class SubmissionsDetail extends Controller {
if (result.value) {
const ignoreList = this.searchHelper;

await this.submissionHandler.deleteSubmission(submission);
ignoreList.clearIgnore();
ignoreList.ignore(submission.get('id'));
this.transitionToRoute('submissions');
try {
await this.submissionHandler.deleteSubmission(submission);

ignoreList.clearIgnore();
ignoreList.ignore(submission.get('id'));
this.transitionToRoute('submissions');
} catch (e) {
this.flashMessages.danger(
'We encountered an error deleting this draft submission. Please try again later or contact your administrator'
);
}
}
}
}
9 changes: 2 additions & 7 deletions app/routes/submissions/new.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import CheckSessionRoute from '../check-session-route';
import { inject as service } from '@ember/service';
import { set } from '@ember/object';
import { hash } from 'rsvp';
import { fileForSubmissionQuery } from '../../util/paginated-query';

export default class NewRoute extends CheckSessionRoute {
@service('workflow')
Expand Down Expand Up @@ -64,13 +65,7 @@ export default class NewRoute extends CheckSessionRoute {
sort: '+performedDate',
});

files = this.store
.query('file', {
filter: {
file: `submission.id==${newSubmission.get('id')}`,
},
})
.then((files) => [...files.toArray()]);
files = this.store.query('file', fileForSubmissionQuery(newSubmission.id)).then((files) => [...files.toArray()]);

// Also seed workflow.doiInfo with metadata from the Submission
const metadata = newSubmission.get('metadata');
Expand Down
42 changes: 35 additions & 7 deletions app/services/submission-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Service, { inject as service } from '@ember/service';
import ENV from 'pass-ui/config/environment';
import { task } from 'ember-concurrency-decorators';
import { get } from '@ember/object';
import SubmissionModel from '../models/submission';
import { fileForSubmissionQuery, submissionsWithPublicationQuery } from '../util/paginated-query';

/**
* Service to manage submissions.
Expand Down Expand Up @@ -230,18 +232,44 @@ export default class SubmissionHandlerService extends Service {
}

/**
* Simply set submission.submissionStatus to 'cancelled'. This operation is
* distinct from `#cancelSubmission` because this does not create a
* SubmissionEvent
* Delete a draft submission. Any attached files will be deleted and the attached
* Publication will be deleted if it is not referenced by any other submissions.
*
* All other objects associated with the submission (Publication, Files, etc)
* will remain intact.
* - First ensure submission source is pass and submission status is draft
* - Delete Files
* - Delete linked Publication only if no other submissions reference it
* - Delete the Submission only if all of the above succeed
*
* Will reject operation if the specified submission is NOT in 'draft' status
*
* Will pass errors from all of the network interactions:
* - File deletions
* - Publication deletion
* - Submission deletion
*
* @param {Submission} submission submission to delete
* @returns {Promise} that returns once the submission deletion is persisted
*/
async deleteSubmission(submission) {
submission.set('submissionStatus', 'cancelled');
return submission.save();
if (submission.source !== 'pass' || submission.submissionStatus !== 'draft') {
return Promise.reject(`Non-DRAFT submissions cannot be deleted`);
}

// Get submissions for this file
const files = await this.store.query('file', fileForSubmissionQuery(submission.id));
await Promise.all(files.map((file) => file.destroyRecord()));

const publication = await submission.publication;

// Search for Submissions that reference this publication
const submissionId = submission.id;
submission.deleteRecord();
await submission.save();

const subsWithThisPublication = await this.store.query('submission', submissionsWithPublicationQuery(publication));
if (subsWithThisPublication.length === 0) {
publication.deleteRecord();
await publication.save();
}
}
}
20 changes: 20 additions & 0 deletions app/util/paginated-query.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* Centralizing JSON-API queries are formatted using RSQL, which can be a bit awkward to write.
*/

/**
* Paginated query for the submissions/index route
*
Expand Down Expand Up @@ -105,6 +109,22 @@ export function grantsIndexSubmissionQuery(user) {
};
}

export function fileForSubmissionQuery(submissionId) {
return {
filter: {
file: `submission.id==${submissionId}`,
},
};
}

export function submissionsWithPublicationQuery(publication) {
return {
filter: {
submission: `publication.id==${publication.id}`,
},
};
}

function filter(value, ...props) {
if (!value) {
return '';
Expand Down
72 changes: 56 additions & 16 deletions tests/integration/components/submission-action-cell-test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/* eslint-disable ember/no-classic-classes, ember/avoid-leaking-state-in-ember-objects, ember/no-settled-after-test-helper, ember/no-get */
import EmberObject, { get } from '@ember/object';
import EmberObject from '@ember/object';
import { A } from '@ember/array';
import Service from '@ember/service';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { module, test } from 'qunit';
import { render, click, settled } from '@ember/test-helpers';
import { render, click, settled, waitFor } from '@ember/test-helpers';
import { run } from '@ember/runloop';
import Sinon from 'sinon';

module('Integration | Component | submission action cell', (hooks) => {
setupRenderingTest(hooks);
Expand Down Expand Up @@ -61,30 +62,69 @@ module('Integration | Component | submission action cell', (hooks) => {
* supplied submission, then `#save()` should be called
*/
test('should delete and persist submission', async function (assert) {
assert.expect(2);

const record = EmberObject.create({
preparers: A(),
const record = {
preparers: [],
isDraft: true,
save() {
assert.ok(true);
return Promise.resolve();
},
submitter: {
id: 1,
},
});
save: Sinon.fake.resolves(),
submitter: { id: 1 },
};

this.set('record', record);

const submissionHandler = this.owner.lookup('service:submission-handler');
const handlerFake = Sinon.replace(submissionHandler, 'deleteSubmission', Sinon.fake.resolves());
this.owner.register('service:submission-handler', submissionHandler);

await render(hbs`<SubmissionActionCell @record={{this.record}}></SubmissionActionCell>`);
await click('a.delete-button');
await click(document.querySelector('.swal2-confirm'));

await settled();

const status = get(this, 'record.submissionStatus');
assert.strictEqual(status, 'cancelled');
assert.ok(handlerFake.calledOnce, 'Submission handler delete should be called');
});

test('Should show error message on submission deletion error', async function (assert) {
const record = { preparers: [], isDraft: true, save: Sinon.fake.resolves(), submitter: { id: 1 } };
this.record = record;

const submissionHandler = this.owner.lookup('service:submission-handler');
// Make sure submissionHandler#deleteSubmission fails
const handlerFake = Sinon.replace(submissionHandler, 'deleteSubmission', Sinon.fake.throws());
this.owner.register('service:submission-handler', submissionHandler);

// Need to make sure the flash message service is initialized
this.flashMessages = this.owner.lookup('service:flash-messages');
// Need harness for flash messages
// In the real app, this is done at the application level and will always be available
// but needs to be injected for this isolated render test
await render(hbs`
{{#each this.flashMessages.queue as |flash|}}
<div class="flash-message-container">
<FlashMessage @flash={{flash}} as |component flash close|>
<div class="d-flex justify-content-between">
{{flash.message}}
<span role="button" {{on "click" close}}>
x
</span>
</div>
</FlashMessage>
</div>
{{/each}}
<SubmissionActionCell @record={{this.record}}></SubmissionActionCell>
`);

assert.dom('a.delete-button').exists();
assert.dom('a.btn').containsText('Edit');

await click('a.delete-button');
await click(document.querySelector('.swal2-confirm'));
await settled();

await waitFor('.flash-message.alert-danger');
assert.dom('.flash-message.alert-danger').containsText('We encountered an error deleting this draft submission');

assert.ok(handlerFake.calledOnce, 'Submission handler delete should be called');
});

test('Draft submissions should display Edit and Delete buttons', async function (assert) {
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/controllers/submissions/detail-test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import EmberObject from '@ember/object';
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import Sinon from 'sinon';

module('Unit | Controller | submissions/detail', (hooks) => {
setupTest(hooks);
Expand Down Expand Up @@ -38,4 +39,30 @@ module('Unit | Controller | submissions/detail', (hooks) => {

controller.send('deleteSubmission', submission);
});

test('error message shown on submission deletion error', async function (assert) {
const submission = {};

// Mock global SweetAlert. Mocks a user clicking OK on the popup
swal = Sinon.fake.resolves({ value: true });

const controller = this.owner.lookup('controller:submissions/detail');
const transitionFake = Sinon.replace(controller, 'transitionToRoute', Sinon.fake());

controller.submissionHandler = this.owner.lookup('service:submission-handler');
const deleteFake = Sinon.replace(controller.submissionHandler, 'deleteSubmission', Sinon.fake.rejects());

controller.flashMessages = this.owner.lookup('service:flash-messages');
const flashFake = Sinon.replace(controller.flashMessages, 'danger', Sinon.fake());

// Note: using controller.send resolves immediately
// making subsequent assertions evaluate before the controller action fires
// Can't really use Sinon in a nice way unless we call the controller
// method directly
await controller.deleteSubmission(submission);

assert.ok(deleteFake.calledOnce, 'Submission handler delete should be called');
assert.ok(flashFake.calledOnce, 'Flash message should be called');
assert.equal(transitionFake.callCount, 0, 'Transition should not be called');
});
});
Loading
Loading