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

Handle attachments web socket notifications on patient flow page #1371

Merged
merged 6 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
18 changes: 14 additions & 4 deletions src/js/apps/patients/sidebar/action-sidebar_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default App.extend(extend({
'change:_owner': this.onChangeOwner,
'destroy': this.onDestroy,
'add:comment': this.onCommentAdded,
'add:attachment': this.onAttachmentAdded,
});

this.showChildView('heading', new HeadingView({ model: this.action }));
Expand Down Expand Up @@ -82,6 +83,11 @@ export default App.extend(extend({

Radio.request('ws', 'add', model);
},
onAttachmentAdded(model) {
this.attachments.add(model);

Radio.request('ws', 'add', model);
},
paulfalgout marked this conversation as resolved.
Show resolved Hide resolved
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Separate from onAddAttachment
Having both onAttachmentAdded and onAddAttachment is potentially confusing. Following the suggestion to rename it clearly differentiates WS vs. manual additions.

onStart(options, activity, comments, attachments) {
this.activityCollection = new Backbone.Collection([...activity.models, ...comments.models]);
this.comments = comments;
Expand Down Expand Up @@ -230,20 +236,24 @@ export default App.extend(extend({
});
attachment.upload(file);

Radio.request('ws', 'add', file);
paulfalgout marked this conversation as resolved.
Show resolved Hide resolved

this.listenTo(attachment, 'upload:failed', () => {
Radio.request('alert', 'show:error', intl.patients.sidebar.actionSidebarApp.uploadError);
});
},
onRemoveAttachment(model) {
model.destroy();

Radio.request('ws', 'unsubscribe', model);
},
addSubscriptions() {
Radio.request('ws', 'add', this.comments.models);
Radio.request('ws', 'add', [...this.comments.models, ...this.attachments.models]);
},
removeSubscriptions() {
// for when sidebar is closed before comments api request is finished
if (!this.comments) return;
// for when the sidebar is closed prior to beforeStart being able to finish
if (!this.isRunning()) return;

Radio.request('ws', 'unsubscribe', this.comments.models);
Radio.request('ws', 'unsubscribe', [...this.comments.models, ...this.attachments.models]);
},
}, SidebarMixin));
4 changes: 3 additions & 1 deletion src/js/base/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import JsonApiMixin from './jsonapi-mixin';

export default Backbone.Model.extend(extend({
destroy(options) {
// isDeleted means the model is already deleted from the db
if (options && options.isDeleted) {
this.stopListening();
this.trigger('destroy', this, this.collection, options);
Expand Down Expand Up @@ -90,8 +91,9 @@ export default Backbone.Model.extend(extend({

return isFunction(messages[category]) ? messages[category] : this[messages[category]];
},
handleMessage({ category, author, payload }) {
handleMessage({ category, resource, author, payload }) {
payload.attributes = extend({}, payload.attributes, { updated_at: dayjs.utc().format() });
payload.resource = resource;
Comment on lines +94 to +96
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Potential mismatch in handleMessage parameters

The implementation in src/js/base/model.js expects an object with { category, resource, author, payload }, but in src/js/services/ws.js, the method is called with the entire data object directly. This could lead to runtime errors if the data structure doesn't match the expected parameters.

  • src/js/services/ws.js: resource.handleMessage(data)
  • src/js/base/model.js: handleMessage({ category, resource, author, payload })
🔗 Analysis chain

Expanded handleMessage signature
Including resource and updating updated_at is helpful for robust message handling. Confirm that external references to handleMessage are updated accordingly.


Let me search for references to handleMessage in the codebase to verify if external calls are aligned with the updated signature.


Let me check the implementation in ws.js to understand how the method is being called.

🏁 Scripts executed

The following scripts were executed for the analysis:

Error: No shell scripts found


Script:

#!/bin/bash
# Search for handleMessage method calls
rg "handleMessage\(" -A 2

# Search for handleMessage method definitions
ast-grep --pattern 'handleMessage($_) { $$$  }'

Length of output: 439


Script:

#!/bin/bash
# Get more context around the handleMessage call in ws.js
rg -B 5 -A 5 "handleMessage\(" src/js/services/ws.js

Length of output: 322

payload.author = author;

const handler = this._getMessageHandler(category);
Expand Down
24 changes: 24 additions & 0 deletions src/js/entities-service/entities/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ const _Model = BaseModel.extend({

this.trigger('add:comment', commentModel);
},
AttachmentAdded({ file, attributes }) {
const attachmentModel = Radio.request('entities', 'files:model', {
id: file.id,
path: attributes.path,
created_at: dayjs.utc().format(),
_action: this.id,
_patient: this.getPatient().id,
_view: attributes.urls.view,
_download: attributes.urls.download,
});

const newFilesRelationship = [...this.get('_files'), { id: file.id, type: 'files' }];
this.set({ _files: newFilesRelationship });

this.trigger('add:attachment', attachmentModel);
},
ResourceDeleted() {
this.destroy({ isDeleted: true });
},
Expand Down Expand Up @@ -234,6 +250,14 @@ const _Model = BaseModel.extend({

return !!size(programAction.get('allowed_uploads'));
},
removeAttachment(resource) {
const files = this.get('_files');
const newFilesRelationship = files.filter(file => {
return file.id !== resource.id;
});

this.set({ _files: newFilesRelationship });
},
Copy link
Contributor Author

@nmajor25 nmajor25 Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The paperclip attachments icon on action list items is shown or hidden based on the length of action.get('_files').

We're currently not updating that when a file is removed manually by a user. And therefore it doesn't work out of the box for the FileRemoved ws notification.

So i added this method to the action entity service that filters out the file from _files by id. And then sets a new _files relationship array for the action.

This allows for the paperclip icon to be hidden after attachment deletion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is how we want to implement this, we would just need to call action.removeAttachment(model) in the action sidebar app when a file is manually deleted by a user.

Copy link
Contributor Author

@nmajor25 nmajor25 Dec 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently in the app, the .paperclip icon isn't shown when an attachment is added and isn't hidden when an attachment is removed manually by a user in the sidebar.

I created a separate story here, that I can tackle in a separate PR.

parseRelationship: _parseRelationship,
});

Expand Down
16 changes: 16 additions & 0 deletions src/js/entities-service/entities/files.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Radio from 'backbone.radio';
import { get, first } from 'underscore';
import Store from 'backbone.store';
import BaseCollection from 'js/base/collection';
Expand All @@ -17,6 +18,21 @@ const _Model = BaseModel.extend({
_progress: 0,
},
type: TYPE,
messages: {
FileReplaced({ attributes }) {
this.set({
path: attributes.path,
_view: attributes.urls.view,
_download: attributes.urls.download,
});
},
FileRemoved({ resource }) {
const action = Radio.request('entities', 'actions:model', this.get('_action'));
action.removeAttachment(resource);

this.destroy({ isDeleted: true });
},
},
urlRoot() {
if (this.isNew()) {
const actionId = this.get('_action');
Expand Down
192 changes: 187 additions & 5 deletions test/integration/patients/patient/patient-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ context('patient flow page', function() {

specify('patient flow action sidebar', function() {
const testCommentId = uuid();
const testFileId = uuid();
const testOtherFileId = uuid();

const testPatient = getPatient({
attributes: {
Expand All @@ -119,25 +121,39 @@ context('patient flow page', function() {
},
});

const testProgramAction = getProgramAction({
attributes: {
allowed_uploads: ['pdf'],
},
});

const testFlowAction = getAction({
attributes: {
name: 'Test Action',
duration: 10,
outreach: 'disabled',
sharing: 'disabled',
updated_at: testTsSubtract(1),
allowed_uploads: ['pdf'],
},
relationships: {
flow: getRelationship(testFlow),
state: getRelationship(stateTodo),
owner: getRelationship(teamNurse),
form: getRelationship(testForm),
patient: getRelationship(testPatient),
'flow': getRelationship(testFlow),
'state': getRelationship(stateTodo),
'owner': getRelationship(teamNurse),
'form': getRelationship(testForm),
'patient': getRelationship(testPatient),
'program-action': getRelationship(testProgramAction),
'files': getRelationship([{ id: testOtherFileId }], 'files'),
},
});

cy
.routesForPatientAction()
.routeSettings(fx => {
fx.data.push({ id: 'upload_attachments', attributes: { value: true } });

return fx;
})
.routeFlow(fx => {
fx.data = mergeJsonApi(testFlow, {
relationships: {
Expand All @@ -150,13 +166,32 @@ context('patient flow page', function() {
.routeFlowActions(fx => {
fx.data = [testFlowAction];

fx.included.push(testProgramAction);

return fx;
})
.routePatientByFlow(fx => {
fx.data = testPatient;

return fx;
})
.routeActionFiles(fx => {
fx.data = [
{
id: testOtherFileId,
attributes: {
path: `patients/${ testPatient.id }/Other_file.pdf`,
created_at: testTsSubtract(1),
},
meta: {
view: `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/view/Other_File.pdf`,
download: `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/download/Other_File.pdf`,
},
},
];

return fx;
})
.routeActionActivity()
.visit(`/flow/${ testFlow.id }/action/${ testFlowAction.id }`)
.wait('@routeFlow')
Expand Down Expand Up @@ -389,6 +424,106 @@ context('patient flow page', function() {
.find('.comment__item')
.should('have.length', 3);

cy.sendWs({
category: 'AttachmentAdded',
resource: {
type: 'patient-actions',
id: testFlowAction.id,
},
payload: {
clinician: {
type: 'clinicians',
id: uuid(),
},
file: {
type: 'files',
id: testFileId,
},
attributes: {
path: `patients/${ testPatient.id }/HRA.pdf`,
bucket: 'bucket_name',
urls: {
view: `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/view/HRA.pdf`,
download: `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/download/HRA.pdf`,
},
},
},
});

cy
.get('[data-attachments-files-region]')
.children()
.as('attachmentItems')
.should('have.length', 2);

cy
.get('@attachmentItems')
.first()
.as('attachmentItem')
.contains('HRA.pdf')
.should('have.attr', 'href')
.and('contain', `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/view/HRA.pdf`);

cy
.get('@attachmentItem')
.contains('Download')
.should('have.attr', 'href')
.and('contain', `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/download/HRA.pdf`);

cy.sendWs({
category: 'FileReplaced',
resource: {
type: 'files',
id: testFileId,
},
payload: {
attributes: {
path: `patients/${ testPatient.id }/HRA_v2.pdf`,
bucket: 'bucket_name',
urls: {
view: `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/view/HRA_v2.pdf`,
download: `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/download/HRA_v2.pdf`,
},
},
},
});

cy
.get('[data-attachments-files-region]')
.children()
.should('have.length', 2);

cy
.get('@attachmentItem')
.contains('HRA_v2.pdf')
.should('have.attr', 'href')
.and('contain', `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/view/HRA_v2.pdf`);

cy
.get('@attachmentItem')
.contains('Download')
.should('have.attr', 'href')
.and('contain', `https://www.bucket_name.s3.amazonaws.com/patients/${ testPatient.id }/download/HRA_v2.pdf`);

cy.sendWs({
category: 'FileRemoved',
resource: {
type: 'files',
id: testFileId,
},
payload: {},
});

cy
.get('[data-attachments-files-region]')
.children()
.should('have.length', 1);

cy
.get('@attachmentItems')
.contains('HRA_v2.pdf')
.should('not.exist');

cy.sendWs({
category: 'ResourceDeleted',
resource: {
Expand Down Expand Up @@ -2492,6 +2627,8 @@ context('patient flow page', function() {
});

specify('socket notifications', function() {
const testSocketFileId = uuid();

const testSocketFlow = getFlow({
attributes: {
name: 'Flow Test',
Expand Down Expand Up @@ -2795,6 +2932,51 @@ context('patient flow page', function() {
.find('[data-state-region]')
.should('contain', 'Done');

cy.sendWs({
category: 'AttachmentAdded',
resource: {
type: 'patient-actions',
id: testSocketAction.id,
},
payload: {
clinician: {
type: 'clinicians',
id: uuid(),
},
file: {
type: 'files',
id: testSocketFileId,
},
attributes: {
path: 'patients/1/HRA.pdf',
bucket: 'bucket_name',
urls: {
view: 'https://www.bucket_name.s3.amazonaws.com/patients/1/view/HRA.pdf',
download: 'https://www.bucket_name.s3.amazonaws.com/patients/1/download/HRA.pdf',
},
},
},
});

cy
.get('.patient-flow__list')
.find('.table-list__item .fa-paperclip')
.should('exist');

cy.sendWs({
category: 'FileRemoved',
resource: {
type: 'files',
id: testSocketFileId,
},
payload: {},
});

cy
.get('.patient-flow__list')
.find('.table-list__item .fa-paperclip')
.should('not.exist');

cy.sendWs({
category: 'ResourceDeleted',
resource: {
Expand Down
Loading