diff --git a/jsapp/js/components/bigModal/bigModal.es6 b/jsapp/js/components/bigModal/bigModal.es6 index d0f3530d8b..b235f9f0f7 100644 --- a/jsapp/js/components/bigModal/bigModal.es6 +++ b/jsapp/js/components/bigModal/bigModal.es6 @@ -72,6 +72,8 @@ function getSubmissionTitle(props) { * * There are also two other important methods: `hideModal` and `switchModal`. * + * The modal rendering is being handled by `app.jsx`. + * * @prop {object} params - to be passed to the custom modal component */ class BigModal extends React.Component { diff --git a/jsapp/js/components/common/koboSelect.scss b/jsapp/js/components/common/koboSelect.scss index 1625f126db..6a42ec0352 100644 --- a/jsapp/js/components/common/koboSelect.scss +++ b/jsapp/js/components/common/koboSelect.scss @@ -90,10 +90,11 @@ $k-select-menu-padding: 6px; @include mixins.buttonReset; font-weight: 400; position: relative; + color: colors.$kobo-dark-blue; &:hover, &.k-select__option--selected { - color: colors.$kobo-gray-800; + color: colors.$kobo-dark-blue; background-color: colors.$kobo-gray-200; } @@ -114,7 +115,6 @@ $k-select-menu-padding: 6px; justify-content: space-between; width: 100%; height: $k-select-option-height; - color: colors.$kobo-gray-700; padding: 0 #{16px - 2px}; text-align: initial; } @@ -122,6 +122,7 @@ $k-select-menu-padding: 6px; .k-select__menu-message { font-style: italic; margin: 0; + color: colors.$kobo-gray-24; } .k-select__error { diff --git a/jsapp/js/components/formGallery/formGallery.component.scss b/jsapp/js/components/formGallery/formGallery.component.scss index 6b7920f80b..bc7dad9e95 100644 --- a/jsapp/js/components/formGallery/formGallery.component.scss +++ b/jsapp/js/components/formGallery/formGallery.component.scss @@ -1,5 +1,6 @@ @use 'scss/colors'; @use 'scss/breakpoints'; +@use 'scss/mixins'; .gallery { background-color: colors.$kobo-white; @@ -47,12 +48,21 @@ margin-right: 5px; } -.gallery-grid { +.gallery__grid { display: flex; flex-wrap: wrap; gap: 3px; } +.gallery-grid-deleted-attachment { + @include mixins.centerRowFlex; + justify-content: center; + min-width: 100px; + min-height: 100px; + border-radius: 6px; + background-color: colors.$kobo-gray-92; +} + @include breakpoints.breakpoint(mediumAndUp) { .gallery__wrapper { margin: 20px 50px; diff --git a/jsapp/js/components/formGallery/formGallery.component.tsx b/jsapp/js/components/formGallery/formGallery.component.tsx index 637f25542e..539dda3aea 100644 --- a/jsapp/js/components/formGallery/formGallery.component.tsx +++ b/jsapp/js/components/formGallery/formGallery.component.tsx @@ -16,6 +16,7 @@ import { selectImageAttachments, selectShowLoadMore, } from './formGallery.selectors'; +import DeletedAttachment from 'js/components/submissions/deletedAttachment.component'; bem.Gallery = makeBem(null, 'gallery'); bem.Gallery__wrapper = makeBem(bem.Gallery, 'wrapper'); @@ -159,20 +160,30 @@ export default function FormGallery(props: FormGalleryProps) { - {attachments.map((attachment) => ( - - - - ))} + {attachments.map((attachment) => { + if (attachment.is_deleted) { + return ( + + + + ); + } else { + return ( + + + + ) + } + })} {showLoadMore && ( diff --git a/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.module.scss b/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.module.scss index 868cc625ff..25b42ea86f 100644 --- a/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.module.scss +++ b/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.module.scss @@ -1,15 +1,19 @@ @use 'scss/colors'; .mediaWrapper { + border-radius: sizes.$x6; + background-color: colors.$kobo-white; + + &.mediaWrapperDeleted { + padding: 14px; + } + &.mediaWrapperAudio { - background-color: colors.$kobo-white; - border-radius: 6px; // right padding is for the audio player to appear centered padding: 20px #{20px + 12px} 14px 20px; } &.mediaWrapperVideo { - border-radius: 6px; overflow: hidden; } } diff --git a/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.tsx b/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.tsx index 781cc3fa9b..bb3232ee00 100644 --- a/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.tsx +++ b/jsapp/js/components/processing/sidebar/sidebarSubmissionMedia.tsx @@ -1,10 +1,12 @@ import React, {useState} from 'react'; +import cx from 'classnames'; import AudioPlayer from 'js/components/common/audioPlayer'; import singleProcessingStore from 'js/components/processing/singleProcessingStore'; import type {AssetContent} from 'js/dataInterface'; import {QUESTION_TYPES} from 'js/constants'; import {getAttachmentForProcessing} from 'js/components/processing/transcript/transcript.utils'; import styles from './sidebarSubmissionMedia.module.scss'; +import DeletedAttachment from 'js/components/submissions/deletedAttachment.component'; interface SidebarSubmissionMediaProps { assetContent: AssetContent | undefined; @@ -26,15 +28,23 @@ export default function SidebarSubmissionMedia( return null; } + if (attachment.is_deleted) { + return ( + + + + ); + } + switch (store.currentQuestionType) { case QUESTION_TYPES.audio.id: case QUESTION_TYPES['background-audio'].id: return ( void; +} + +/** + * Displays a "…" button that opens a dropdown with some actions available for + * provided attachment. Delete option would display a safety check modal. + */ +export default function AttachmentActionsDropdown( + props: AttachmentActionsDropdownProps +) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeletePending, setIsDeletePending] = useState(false); + + const toggleDeleteModal = () => { + setIsDeleteModalOpen(!isDeleteModalOpen); + }; + + let attachmentTypeName = t('attachment'); + if (props.questionType === QuestionTypeName.audio) { + attachmentTypeName = t('audio recording'); + } else if (props.questionType === QuestionTypeName.video) { + attachmentTypeName = t('video recording'); + } else if (props.questionType === QuestionTypeName.image) { + attachmentTypeName = t('image'); + } else if (props.questionType === MetaQuestionTypeName['background-audio']) { + attachmentTypeName = t('background audio recording'); + } + + function confirmDelete() { + setIsDeletePending(true); + + // TODO: replace the timeout with actual API call that will delete + // the attachment. + console.log('confirmDelete'); + setTimeout(() => { + // TODO: Upon finishing we need to have the submission data being updated + // both here in Submission Modal and in Data Table. + // Validation status changing in Submission Modal works like this: + // 1. Does the call (both Submission Modal and Data Table listens to same call) + // 2. Call finishes and returns a fresh SubmissionResponse + // 3. Upon finishing, Submission Modal updates the submission from props + // 4. Upon finishing, Data Table updates the submission in the list + // We would need to do something similar here. + + // TODO: We would need to confirm from Back-end how would the deleted + // attachment be marked + onAttachmentDeleted(); + }, 2000); + } + + /** + * Stops pending animation, closes confirmation prompt and displays + * a notification. Also let the parent know through prop callback. + */ + function onAttachmentDeleted() { + setIsDeletePending(false); + toggleDeleteModal(); + utils.notify( + t('##Attachment_type## deleted') + .replace('##Attachment_type##', attachmentTypeName) + ); + props.onDeleted(); + } + + function requestDownloadFile() { + utils.downloadUrl(props.attachmentUrl); + } + + const userCanChange = userHasPermForSubmission( + 'change_submissions', + props.asset, + props.submissionData + ); + + const uniqueDropdownName = `attachment-actions-${utils.generateUuid()}`; + + return ( + <> + + } + menuContent={ + + + + {t('Download')} + + + + + {t('Delete')} + + + } + /> + + + + {t('Delete ##attachment_type##').replace('##attachment_type##', attachmentTypeName)} + + + + {t('Are you sure you want to delete this ##attachment_type##?').replace('##attachment_type##', attachmentTypeName)} + + + + + + + + + > + ); +} diff --git a/jsapp/js/components/submissions/audioCell.tsx b/jsapp/js/components/submissions/audioCell.tsx index 2357347639..99374bdaa1 100644 --- a/jsapp/js/components/submissions/audioCell.tsx +++ b/jsapp/js/components/submissions/audioCell.tsx @@ -3,6 +3,7 @@ import bem, {makeBem} from 'js/bem'; import Button from 'js/components/common/button'; import Icon from 'js/components/common/icon'; import MiniAudioPlayer from 'js/components/common/miniAudioPlayer'; +import DeletedAttachment from './deletedAttachment.component'; import {goToProcessing} from 'js/components/processing/routes.utils'; import type {SubmissionAttachment} from 'js/dataInterface'; import './audioCell.scss'; @@ -31,10 +32,20 @@ export default function AudioCell(props: AudioCellProps) { )} - {typeof props.mediaAttachment === 'object' && - props.mediaAttachment?.download_url && ( - - )} + { + typeof props.mediaAttachment === 'object' && + props.mediaAttachment?.download_url && + !props.mediaAttachment?.is_deleted && + ( + + )} + + { + typeof props.mediaAttachment === 'object' && + props.mediaAttachment?.is_deleted && + ( + + )} + {t('Deleted')} + + ); +} diff --git a/jsapp/js/components/submissions/deletedAttachment.module.scss b/jsapp/js/components/submissions/deletedAttachment.module.scss new file mode 100644 index 0000000000..02594e4172 --- /dev/null +++ b/jsapp/js/components/submissions/deletedAttachment.module.scss @@ -0,0 +1,7 @@ +@use 'scss/colors'; + +.deletedAttachment { + font-size: inherit; + font-style: italic; + color: colors.$kobo-gray-55; +} diff --git a/jsapp/js/components/submissions/mediaCell.tsx b/jsapp/js/components/submissions/mediaCell.tsx index 158db48f1b..a752222d64 100644 --- a/jsapp/js/components/submissions/mediaCell.tsx +++ b/jsapp/js/components/submissions/mediaCell.tsx @@ -12,6 +12,7 @@ import {truncateString} from 'js/utils'; import type {SubmissionAttachment} from 'js/dataInterface'; import './mediaCell.scss'; import Icon from 'js/components/common/icon'; +import DeletedAttachment from './deletedAttachment.component'; import type {IconName} from 'jsapp/fonts/k-icons'; import pageState from 'js/pageState.store'; @@ -148,6 +149,10 @@ class MediaCell extends React.Component { ); } + if (this.props.mediaAttachment.is_deleted) { + return (); + } + return ( diff --git a/jsapp/js/components/submissions/submissionDataTable.tsx b/jsapp/js/components/submissions/submissionDataTable.tsx index b11a3222bf..018665c5f9 100644 --- a/jsapp/js/components/submissions/submissionDataTable.tsx +++ b/jsapp/js/components/submissions/submissionDataTable.tsx @@ -29,10 +29,12 @@ import './submissionDataTable.scss'; import type { AssetResponse, SubmissionResponse, + SubmissionAttachment, } from 'jsapp/js/dataInterface'; import AudioPlayer from 'js/components/common/audioPlayer'; import {goToProcessing} from 'js/components/processing/routes.utils'; -import {PROCESSING_QUESTION_TYPES} from 'js/components/processing/processingUtils'; +import AttachmentActionsDropdown from './attachmentActionsDropdown.component'; +import DeletedAttachment from './deletedAttachment.component'; bem.SubmissionDataTable = makeBem(null, 'submission-data-table'); bem.SubmissionDataTable__row = makeBem(bem.SubmissionDataTable, 'row'); @@ -45,6 +47,7 @@ interface SubmissionDataTableProps { submissionData: SubmissionResponse; translationIndex: number; showXMLNames?: boolean; + onAttachmentDeleted: (attachment: SubmissionAttachment) => void; } /** @@ -264,16 +267,24 @@ class SubmissionDataTable extends React.Component { P{pointIndex + 1} - {pointArray[0]} + + {pointArray[0]} + - {pointArray[1]} + + {pointArray[1]} + - {pointArray[2]} + + {pointArray[2]} + - {pointArray[3]} + + {pointArray[3]} + ))} @@ -290,6 +301,10 @@ class SubmissionDataTable extends React.Component { ) { const attachment = getMediaAttachment(this.props.submissionData, filename, xpath); if (attachment && attachment instanceof Object) { + if (attachment.is_deleted) { + return + } + return ( <> {type === QUESTION_TYPES.audio.id && @@ -323,6 +338,17 @@ class SubmissionDataTable extends React.Component { {filename} } + + { + // We're letting know upstream that the attachment was deleted + this.props.onAttachmentDeleted(attachment); + }} + /> > ); // In the case that an attachment is missing, don't crash the page diff --git a/jsapp/js/components/submissions/submissionModal.tsx b/jsapp/js/components/submissions/submissionModal.tsx index debe968d43..c2a95690ee 100644 --- a/jsapp/js/components/submissions/submissionModal.tsx +++ b/jsapp/js/components/submissions/submissionModal.tsx @@ -28,10 +28,16 @@ import type { AssetResponse, SubmissionResponse, ValidationStatusResponse, + SubmissionAttachment, } from 'js/dataInterface'; +import AttachmentActionsDropdown from './attachmentActionsDropdown.component'; import AudioPlayer from 'js/components/common/audioPlayer'; import {getBackgroundAudioQuestionName} from 'js/components/submissions/tableUtils'; -import {getMediaAttachment} from 'js/components/submissions/submissionUtils'; +import { + getMediaAttachment, + markAttachmentAsDeleted, +} from 'js/components/submissions/submissionUtils'; +import DeletedAttachment from './deletedAttachment.component'; import type {SubmissionPageName} from 'js/components/submissions/table.types'; import './submissionModal.scss'; @@ -435,7 +441,8 @@ export default class SubmissionModal extends React.Component< /** * Whether the form has background audio enabled. This means that there is * a possibility that the submission could have a background audio recording. - * If you need to know if recording exist, please use `getBackgroundAudioUrl`. + * If you need to know if recording exist, i.e. if it was being submitted, + * please use `getBackgroundAudioAttachment`. */ hasBackgroundAudioEnabled() { return this.props.asset?.content?.survey?.some( @@ -443,10 +450,8 @@ export default class SubmissionModal extends React.Component< ); } - getBackgroundAudioUrl() { - const backgroundAudioName = getBackgroundAudioQuestionName( - this.props.asset - ); + getBackgroundAudioAttachment(): undefined | SubmissionAttachment { + const backgroundAudioName = getBackgroundAudioQuestionName(this.props.asset); if ( backgroundAudioName && @@ -458,12 +463,12 @@ export default class SubmissionModal extends React.Component< const mediaAttachment = getMediaAttachment( this.state.submission, response, - QuestionTypeName['background-audio'] + backgroundAudioName ); if (typeof mediaAttachment === 'string') { - return mediaAttachment; + return undefined; } else { - return mediaAttachment.download_medium_url || mediaAttachment.download_url; + return mediaAttachment; } } } @@ -471,6 +476,22 @@ export default class SubmissionModal extends React.Component< return undefined; } + handleDeletedAttachment(attachment: SubmissionAttachment) { + // Override the attachment object in memory to mark it as deleted (without + // making an API call for fresh submission data) + if (this.state.submission) { + this.setState({ + submission: markAttachmentAsDeleted(this.state.submission, attachment) + }); + + // Prompt table to refresh submission list + actions.resources.refreshTableSubmissions(); + // TODO: IDEA: instead of doing this for every deleted attachment, mark + // state here as something like `isRefreshTableUponClosingNeeded`, and add + // some `onBeforeClose` functionality to the `bigModal`… + } + } + /** * Displays language and validation status dropdowns. */ @@ -739,6 +760,62 @@ export default class SubmissionModal extends React.Component< ); } + renderBackgroundAudio() { + // For TypeScript + if (!this.state.submission) { + return null; + } + + if (!this.hasBackgroundAudioEnabled()) { + return null; + } + + // Get background audio + const bgAudio = this.getBackgroundAudioAttachment(); + + const isDeleted = Boolean(bgAudio?.is_deleted); + + return ( + + + + {t('Background audio recording')} + + + + + {bgAudio && !isDeleted && + + + + { + this.handleDeletedAttachment(bgAudio); + }} + /> + + } + + {bgAudio && isDeleted && + + + + } + + {!bgAudio && + + {t('N/A')} + + } + + + ); + } + render() { // Until we get all necessary data, we display a spinner if (this.state.isFetchingSubmissionData) { @@ -753,9 +830,6 @@ export default class SubmissionModal extends React.Component< return ; } - // Get background audio - const bgAudioUrl = this.getBackgroundAudioUrl(); - // Each of these `renderX()` functions handle the conditional rendering // by itself return ( @@ -768,35 +842,14 @@ export default class SubmissionModal extends React.Component< {this.renderSubmissionActions()} - {this.hasBackgroundAudioEnabled() && ( - - - - {t('Background audio recording')} - - - - - {bgAudioUrl && - - - - } - - {!bgAudioUrl && - - {t('N/A')} - - } - - - )} + {this.renderBackgroundAudio()} > ); diff --git a/jsapp/js/components/submissions/submissionUtils.ts b/jsapp/js/components/submissions/submissionUtils.ts index 3312cf2b08..968e931cfe 100644 --- a/jsapp/js/components/submissions/submissionUtils.ts +++ b/jsapp/js/components/submissions/submissionUtils.ts @@ -1,4 +1,5 @@ import get from 'lodash.get'; +import clonedeep from 'lodash.clonedeep'; import { getRowName, getTranslatedRowLabel, @@ -772,3 +773,26 @@ export function getQuestionXPath(surveyRows: SurveyRow[], rowName: string) { const flatPaths = getSurveyFlatPaths(surveyRows, true); return flatPaths[rowName]; } + +/** + * In given submission data, it finds provided attachment, sets its `is_deleted` + * flag to `true` and then returns the updated submission data. + */ +export function markAttachmentAsDeleted( + submissionData: SubmissionResponse, + targetAttachment: SubmissionAttachment +): SubmissionResponse { + const data = clonedeep(submissionData); + + data._attachments.forEach((attachment) => { + if ( + attachment.id === targetAttachment.id && + attachment.question_xpath === targetAttachment.question_xpath && + attachment.filename === targetAttachment.filename + ) { + attachment.is_deleted = true; + } + }) + + return data; +} diff --git a/jsapp/js/components/submissions/table.scss b/jsapp/js/components/submissions/table.scss index 5b8a33748b..1a8fcf506b 100644 --- a/jsapp/js/components/submissions/table.scss +++ b/jsapp/js/components/submissions/table.scss @@ -238,6 +238,7 @@ $s-data-table-font: 13px; } // We want to target only the data cells + .rt-td .deletedAttachment:only-child, .rt-td .trimmed-text:only-child { height: 100%; align-content: center; diff --git a/jsapp/js/components/submissions/tableUtils.ts b/jsapp/js/components/submissions/tableUtils.ts index 709a69471f..32c8b8ac37 100644 --- a/jsapp/js/components/submissions/tableUtils.ts +++ b/jsapp/js/components/submissions/tableUtils.ts @@ -169,6 +169,11 @@ export function getColumnHXLTags(survey: SurveyRow[], key: string) { } } +/** + * Finds what name is being used for background audio question. By default it's + * going to be "background-audio", but in case user changes it, we need to use + * this function :shrug:. + */ export function getBackgroundAudioQuestionName( asset: AssetResponse ): string | null { diff --git a/jsapp/js/components/submissions/textModalCell.module.scss b/jsapp/js/components/submissions/textModalCell.module.scss index acbba12f0d..d531b3262e 100644 --- a/jsapp/js/components/submissions/textModalCell.module.scss +++ b/jsapp/js/components/submissions/textModalCell.module.scss @@ -2,6 +2,7 @@ .cell { @include mixins.centerRowFlex; + height: 100%; // To make it nicely aligned in the Data Table } .textContent { diff --git a/jsapp/js/dataInterface.ts b/jsapp/js/dataInterface.ts index c5e3137414..f11196afc6 100644 --- a/jsapp/js/dataInterface.ts +++ b/jsapp/js/dataInterface.ts @@ -157,6 +157,12 @@ export interface SubmissionAttachment { instance: number; xform: number; id: number; + // TODO: this is to be added by Back end, so we need to verify it later + /** + * The flag makes it easier for UI to know that the attachment existed at some + * point, and was deleted - in contrast to never existing. + */ + is_deleted?: boolean; } interface SubmissionSupplementalDetails { @@ -210,6 +216,12 @@ export interface SubmissionResponse { }; _version_: string; _xform_id_string: string; + /** + * If `background-audio` is enabled for this project, submission will have it + * here. The value is just the filename (with extension), and the whole + * attachment object is to be found in `_attachments` array. + */ + 'background-audio'?: string; deviceid?: string; end?: string; 'formhub/uuid': string; diff --git a/jsapp/js/pageState.store.ts b/jsapp/js/pageState.store.ts index 6c3641ddb7..d112a4eb06 100644 --- a/jsapp/js/pageState.store.ts +++ b/jsapp/js/pageState.store.ts @@ -39,6 +39,10 @@ class PageStateStore extends Reflux.Store { this.trigger(_changes); } + /** + * Displays the modal, or updates the data in current modal (useful only if + * you're not changing modal types - see `switchModal` function below). + */ showModal(params: PageStateModalParams) { this.setState({ modal: params @@ -57,7 +61,8 @@ class PageStateStore extends Reflux.Store { */ switchModal(params: PageStateModalParams) { this.hideModal(); - // HACK switch to setState callback after updating to React 16+ + // HACK setState's second parameter callback doesn't exist in Reflux.Store, + // so we can't use it here window.setTimeout(() => { this.showModal(params); }, 0);
{t('Are you sure you want to delete this ##attachment_type##?').replace('##attachment_type##', attachmentTypeName)}