Skip to content

Commit

Permalink
feat(mrf): attachments (#7181)
Browse files Browse the repository at this point in the history
* feat: parse attachment content arraybuffer into file

* chore: enable attachment fields

* feat: add download button to attachment

* fix: clearing attachment not working

* fix: force dirty evaluation to true on file obj

* fix: shared typing on decrypted attachments

* chore: remove stray logs

* chore: hide remove attachment if field disabled

* fix: tint on download btn

* feat: save attachment on mrf update

* chore: remove misleading comments

* test: add storybook variant on attachment

* fix: show toast when mutateasync returns with error

* feat: compare attachment content by md5, and allow duplicate filenames

* chore: add sample download file on attachment storybook preview

* refactor: move shared/utils/response-v3 to BE as it contains BE-only code

* refactor: extract types from shared to be types

* refactor: extract normalization out from comparator to caller

* fix: update type imports
  • Loading branch information
KenLSM authored Apr 2, 2024
1 parent 8f02d4a commit e7f425e
Show file tree
Hide file tree
Showing 16 changed files with 270 additions and 112 deletions.
34 changes: 30 additions & 4 deletions frontend/src/components/Field/Attachment/Attachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,17 @@ const IMAGE_UPLOAD_TYPES_TO_COMPRESS = ['image/jpeg', 'image/png']
export interface AttachmentProps extends UseFormControlProps<HTMLElement> {
/**
* Callback to be invoked when the file is attached or removed.
* Do not use undefined to clear the value, use null instead.
*/
onChange: (file?: File) => void
onChange: (file: File | null) => void
/**
* If exists, callback to be invoked when file has errors.
*/
onError?: (errMsg: string) => void
/**
* Current value of the input.
*/
value: File | undefined
value: File | undefined | null
/**
* Name of the input.
*/
Expand All @@ -67,6 +68,16 @@ export interface AttachmentProps extends UseFormControlProps<HTMLElement> {
* Color scheme of the component.
*/
colorScheme?: ThemeColorScheme

/**
* Show attachment download button.
*/
enableDownload?: boolean

/**
* Show attachment removal button
*/
enableRemove?: boolean
}

export const Attachment = forwardRef<AttachmentProps, 'div'>(
Expand All @@ -81,6 +92,8 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
name,
colorScheme,
title,
enableDownload,
enableRemove,
...props
},
ref,
Expand Down Expand Up @@ -164,7 +177,6 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
),
)
}

onChange(acceptedFile)
},
[accept, maxSize, onChange, onError],
Expand Down Expand Up @@ -205,10 +217,21 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
})

const handleRemoveFile = useCallback(() => {
onChange(undefined)
onChange(null)
rootRef.current?.focus()
}, [onChange, rootRef])

const handleDownloadFile = useCallback(() => {
if (value) {
const url = URL.createObjectURL(value)
const a = document.createElement('a')
a.href = url
a.download = value.name
a.click()
URL.revokeObjectURL(url)
}
}, [value])

// Bunch of memoization to avoid unnecessary re-renders.
const processedRootProps = useMemo(() => {
return getRootProps({
Expand Down Expand Up @@ -246,6 +269,9 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
<AttachmentFileInfo
file={value}
handleRemoveFile={handleRemoveFile}
handleDownloadFile={handleDownloadFile}
enableDownload={enableDownload}
enableRemove={enableRemove}
/>
) : (
<AttachmentDropzone
Expand Down
36 changes: 28 additions & 8 deletions frontend/src/components/Field/Attachment/AttachmentFileInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { BiTrash } from 'react-icons/bi'
import { BiDownload, BiTrash } from 'react-icons/bi'
import { Flex, Text, VisuallyHidden } from '@chakra-ui/react'

import IconButton from '~components/IconButton'
Expand All @@ -8,18 +8,26 @@ import { getReadableFileSize } from './utils/getReadableFileSize'

export interface AttachmentFileInfoProps {
file: File
enableDownload?: boolean
enableRemove?: boolean
handleRemoveFile: () => void
handleDownloadFile: () => void
}

export const AttachmentFileInfo = ({
file,
enableDownload = false,
enableRemove = true,
handleRemoveFile,
handleDownloadFile,
}: AttachmentFileInfoProps) => {
const readableFileSize = useMemo(
() => getReadableFileSize(file.size),
[file.size],
)

const showDownloadButton = enableDownload && file

return (
<Flex justify="space-between" bg="primary.100" py="0.875rem" px="1rem">
<VisuallyHidden>
Expand All @@ -37,13 +45,25 @@ export const AttachmentFileInfo = ({
{readableFileSize}
</Text>
</Flex>
<IconButton
variant="clear"
colorScheme="danger"
aria-label="Click to remove file"
icon={<BiTrash />}
onClick={handleRemoveFile}
/>
<Flex>
{enableRemove ? (
<IconButton
variant="clear"
colorScheme="danger"
aria-label="Click to remove file"
icon={<BiTrash />}
onClick={handleRemoveFile}
/>
) : null}
{showDownloadButton ? (
<IconButton
variant="clear"
aria-label="Click to download file"
icon={<BiDownload />}
onClick={handleDownloadFile}
/>
) : null}
</Flex>
</Flex>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -455,8 +455,17 @@ const FieldRow = ({ field, ...rest }: FieldRowProps) => {
return <ImageField schema={field} {...rest} />
case BasicField.Statement:
return <ParagraphField schema={field} {...rest} />
case BasicField.Attachment:
return <AttachmentField schema={field} {...rest} />
case BasicField.Attachment: {
const enableDownload =
rest.responseMode === FormResponseMode.Multirespondent
return (
<AttachmentField
schema={field}
{...rest}
enableDownload={enableDownload}
/>
)
}
case BasicField.Checkbox:
return <CheckboxField schema={field} {...rest} />
case BasicField.Mobile:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Droppable } from 'react-beautiful-dnd'
import { Box } from '@chakra-ui/react'

import { BasicField, FormResponseMode } from '~shared/types'

import { useAdminForm } from '~features/admin-form/common/queries'
import {
BASIC_FIELDS_ORDERED,
CREATE_FIELD_DROP_ID,
Expand All @@ -16,23 +13,14 @@ import { FieldSection } from './FieldSection'

export const BasicFieldPanel = () => {
const { isLoading } = useCreateTabForm()
const { data: form } = useAdminForm()

return (
<Droppable isDropDisabled droppableId={CREATE_FIELD_DROP_ID}>
{(provided) => (
<Box ref={provided.innerRef} {...provided.droppableProps}>
<FieldSection>
{BASIC_FIELDS_ORDERED.map((fieldType, index) => {
let shouldDisableField = isLoading

// Attachment is not supported on MRF
if (
fieldType === BasicField.Attachment &&
form?.responseMode === FormResponseMode.Multirespondent
) {
shouldDisableField = true
}
const shouldDisableField = isLoading

return (
<DraggableBasicFieldListOption
Expand Down
22 changes: 13 additions & 9 deletions frontend/src/features/public-form/PublicFormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -631,15 +631,19 @@ export const PublicFormProvider = ({
previousSubmissionId
? updateMultirespondentSubmissionMutation
: submitMultirespondentFormMutation
).mutateAsync(formData, {
onSuccess: ({ submissionId, timestamp }) => {
trackSubmitForm(form)
setSubmissionData({
id: submissionId,
timestamp,
})
},
})
)
.mutateAsync(formData, {
onSuccess: ({ submissionId, timestamp }) => {
trackSubmitForm(form)
setSubmissionData({
id: submissionId,
timestamp,
})
},
})
.catch(async (error) => {
showErrorToast(error, form)
})
}
},
[
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { memo } from 'react'

import { BasicField } from '~shared/types/field'
import { FormColorTheme } from '~shared/types/form'
import { FormColorTheme, FormResponseMode } from '~shared/types/form'

import {
AttachmentField,
Expand Down Expand Up @@ -50,7 +50,7 @@ interface FieldFactoryProps {

export const FieldFactory = memo(
({ field, ...rest }: FieldFactoryProps) => {
const { myInfoChildrenBirthRecords } = usePublicFormContext()
const { myInfoChildrenBirthRecords, form } = usePublicFormContext()
switch (field.fieldType) {
case BasicField.Section:
return <SectionField schema={field} {...rest} />
Expand Down Expand Up @@ -78,8 +78,17 @@ export const FieldFactory = memo(
return <DateField schema={field} {...rest} />
case BasicField.Uen:
return <UenField schema={field} {...rest} />
case BasicField.Attachment:
return <AttachmentField schema={field} {...rest} />
case BasicField.Attachment: {
const enableDownload =
form?.responseMode === FormResponseMode.Multirespondent
return (
<AttachmentField
schema={field}
{...rest}
enableDownload={enableDownload}
/>
)
}
case BasicField.HomeNo:
return <HomeNoField schema={field} {...rest} />
case BasicField.Mobile: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isEmpty, times } from 'lodash'

import { PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID } from '~shared/constants'
import { CountryRegion } from '~shared/constants/countryRegion'
import { FieldResponsesV3 } from '~shared/types'
import { AttachmentFieldResponseV3, FieldResponsesV3 } from '~shared/types'
import { BasicField, FormFieldDto } from '~shared/types/field'
import {
FormColorTheme,
Expand All @@ -16,6 +16,7 @@ import {
} from '~shared/types/form'
import { centsToDollars } from '~shared/utils/payments'

import bufferToFile from '~utils/bufferToFile'
import InlineMessage from '~components/InlineMessage'
import { FormFieldValue, FormFieldValues } from '~templates/Field'
import { createTableRow } from '~templates/Field/Table/utils/createRow'
Expand Down Expand Up @@ -102,9 +103,14 @@ export const FormFields = ({
}
break
}
case BasicField.Attachment:
//TODO(MRF/FRM-1590): Handling of attachments by respondent 2+
case BasicField.Attachment: {
const attachmentData =
previousResponse.answer as AttachmentFieldResponseV3
const fileData = attachmentData.content.data
const fileName = attachmentData.answer
acc[field._id] = bufferToFile(fileData, fileName)
break
}
default:
acc[field._id] = previousResponse.answer as FormFieldValue
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,17 @@ export const ValidationOptional = Template.bind({})
ValidationOptional.args = {
schema: { ...baseSchema, required: false },
}

export const DownloadEnabled = Template.bind({})
DownloadEnabled.args = {
schema: { ...baseSchema, required: false },
enableDownload: true,
defaultValue: new File(['examplebtyes'], 'example.txt'),
}

export const DownloadEnabledWithDisabledUpload = Template.bind({})
DownloadEnabledWithDisabledUpload.args = {
schema: { ...baseSchema, disabled: true },
enableDownload: true,
defaultValue: new File(['examplebtyes'], 'example.txt'),
}
22 changes: 19 additions & 3 deletions frontend/src/templates/Field/Attachment/AttachmentField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AttachmentFieldInput, AttachmentFieldSchema } from '../types'
export interface AttachmentFieldProps extends BaseFieldProps {
schema: AttachmentFieldSchema
disableRequiredValidation?: boolean
enableDownload?: boolean
}

/**
Expand All @@ -28,6 +29,7 @@ export interface AttachmentFieldProps extends BaseFieldProps {
export const AttachmentField = ({
schema,
disableRequiredValidation,
enableDownload,
colorTheme = FormColorTheme.Blue,
}: AttachmentFieldProps): JSX.Element => {
const fieldName = schema._id
Expand All @@ -52,11 +54,14 @@ export const AttachmentField = ({

const handleFileChange = useCallback(
(onChange: ControllerRenderProps['onChange']) =>
async (file: File | undefined) => {
async (file: File | null) => {
if (schema.disabled) {
return
}
clearErrors(fieldName)
// Case where attachment is cleared.
if (!file) {
onChange(undefined)
onChange(null)
return
}
// Clone file due to bug where attached file may be empty or corrupted if the
Expand All @@ -70,6 +75,15 @@ export const AttachmentField = ({
try {
const buffer = await fileArrayBuffer(file)
const clone = new File([buffer], file.name, { type: file.type })

/**
* Set a custom field to force attachment field to remain dirty.
* React Hook Form is unable to evaluate dirtiness file when comparing File objects https://react-hook-form.com/docs/useformstate#return
* React Hook Form has a custom deepEqual comparator function that checks for key values on the object https://github.com/react-hook-form/react-hook-form/blob/v7.51.1/src/utils/deepEqual.ts
* By introducing a new `__dirtyField` property, so we can set force the evaluation deepEqual to be false, thus remaining dirty.
* */
// @ts-expect-error __dirtyField is not a standard property of File.
clone.__dirtyField = 1
return onChange(clone)
} catch (error) {
setErrorMessage(
Expand All @@ -84,7 +98,7 @@ export const AttachmentField = ({
return onChange(undefined) // Clear attachment and return
}
},
[clearErrors, fieldName, setErrorMessage],
[clearErrors, fieldName, setErrorMessage, schema.disabled],
)

return (
Expand All @@ -101,6 +115,8 @@ export const AttachmentField = ({
onChange={handleFileChange(onChange)}
onError={setErrorMessage}
title={`${schema.questionNumber}. ${schema.title}`}
enableDownload={enableDownload}
enableRemove={!schema.disabled}
/>
)}
name={fieldName}
Expand Down
Loading

0 comments on commit e7f425e

Please sign in to comment.