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

feat: cond routingv1.1 #8031

Merged
merged 11 commits into from
Jan 23, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
import { DropdownFieldBase, FormFieldDto, WorkflowType } from '~shared/types'
import { checkIsOptionsMismatched } from '~shared/utils/options-recipients-map-validation'

import { parseCsvFileToCsvString } from '~utils/parseCsvFileToCsvString'
import { parseCsvFile } from '~utils/parseCsvFile'
import { SingleSelect } from '~components/Dropdown'
import Attachment from '~components/Field/Attachment'
import { downloadFile } from '~components/Field/Attachment/utils/downloadFile'
Expand All @@ -49,21 +49,21 @@ export interface ConditionalRoutingConfig {
}

/**
* Converts a CSV file into a string, validating that it has the required csv template file headers.
* Parses the conditional routing CSV file, validating that it has the required csv template file headers.
* @param csvFile - The CSV file to parse
* @returns A promise that resolves to the CSV content as a string
* @throws Error if CSV headers are invalid (must have 'Options' and 'Add emails in this column' columns)
* @throws Error if CSV headers are invalid (must have 'Options' and 'Emails' columns)
*/
const parseCsvTemplateToString = async (csvFile: File) =>
parseCsvFileToCsvString(csvFile, (headerRow) => {
const parseConditionalRoutingTemplateCsv = async (csvFile: File) =>
parseCsvFile(csvFile, (headerRow) => {
return {
isValid:
headerRow &&
headerRow.length === 2 &&
headerRow[0] === 'Options' &&
headerRow[1] === 'Add emails in this column',
headerRow[1] === 'Emails',
invalidReason:
'Your CSV file should only contain 2 columns with the headers "Options" and "Add emails in this column".',
'Your CSV file should only contain 2 columns with the headers "Options" and "Emails".',
}
})

Expand Down Expand Up @@ -151,7 +151,7 @@ export const ConditionalRoutingOption = ({
const handleCsvDownload = () => {
if (!selectedConditionalFieldOptionsToRecipientsMap) return
const csvData = {
fields: ['Options', 'Add emails in this column'],
fields: ['Options', 'Emails'],
data: Object.entries(selectedConditionalFieldOptionsToRecipientsMap).map(
([option, recipients]) => [option, recipients.join(',')],
),
Expand Down Expand Up @@ -181,7 +181,7 @@ export const ConditionalRoutingOption = ({
return conditionalField?.fieldOptions
}
const generateCsvContent = (fieldOptions: string[] | undefined) => {
const headerRow = ['Options', 'Add emails in this column']
const headerRow = ['Options', 'Emails']
const optionsRows = fieldOptions?.map((field) => [field, '']) ?? []
const jsonContent = [headerRow, ...optionsRows]
return Papa.unparse(jsonContent, {
Expand Down Expand Up @@ -220,20 +220,19 @@ export const ConditionalRoutingOption = ({
return
}

const conditionalRoutingCsvString = await parseCsvTemplateToString(
data.csvFile,
).catch(() => {
return null
})
const conditionalRoutingCsvRows =
await parseConditionalRoutingTemplateCsv(data.csvFile).catch(() => {
return null
})

if (!conditionalRoutingCsvString) return
const csvToOptionsToRecipientsMap = (csvString: string) => {
const csvRows = csvString.split('\r\n')
if (!conditionalRoutingCsvRows) return
const csvToOptionsToRecipientsMap = (csvRows: string[][]) => {
return csvRows.reduce((acc, row) => {
const [option, ...recipients] = row.split(',')
const [option, recipients] = row
const recipientsArray = recipients.split(',')
return {
...acc,
[option]: recipients.map((email) => email.trim()),
[option]: recipientsArray.map((email) => email.trim()),
}
}, {})
}
Expand All @@ -242,7 +241,7 @@ export const ConditionalRoutingOption = ({
{
fieldId: conditionalFieldId,
optionsToRecipientsMap: csvToOptionsToRecipientsMap(
conditionalRoutingCsvString,
conditionalRoutingCsvRows,
),
},
{
Expand Down Expand Up @@ -304,25 +303,25 @@ export const ConditionalRoutingOption = ({
): Promise<string | undefined> => {
if (!file) return 'Please upload a CSV file'

let conditionalRoutingCsvString
let conditionalRoutingCsvRows
try {
conditionalRoutingCsvString = await parseCsvTemplateToString(file)
conditionalRoutingCsvRows = await parseConditionalRoutingTemplateCsv(file)
} catch (error) {
if (error instanceof Error) {
return error.message
}
return CONDITIONAL_ROUTING_CSV_PARSE_ERROR_MESSAGE
}

const options = conditionalRoutingCsvString.split('\r\n')
const optionsSet = new Set<string>()

for (const row of options) {
const [option, ...recipients] = row.split(',')
if (recipients.length <= 0 || !recipients[0] || !option) {
for (const csvRow of conditionalRoutingCsvRows) {
const [option, recipients] = csvRow
const recipientsArray = recipients.split(',')
if (recipientsArray.length <= 0 || !recipientsArray[0] || !option) {
return CONDITIONAL_ROUTING_EMAILS_OPTIONS_MISSING_ERROR_MESSAGE
}
if (recipients.some((recipient) => !isEmail(recipient.trim()))) {
if (recipientsArray.some((recipient) => !isEmail(recipient.trim()))) {
return CONDITIONAL_ROUTING_INVALID_CSV_FORMAT_ERROR_MESSAGE
}
optionsSet.add(option)
Expand All @@ -331,7 +330,7 @@ export const ConditionalRoutingOption = ({
const selectedConditionalFieldOptions =
selectedConditionalField?.fieldOptions

if (optionsSet.size < options.length) {
if (optionsSet.size < conditionalRoutingCsvRows.length) {
return CONDITIONAL_ROUTING_DUPLICATE_OPTIONS_ERROR_MESSAGE
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import FormErrorMessage from '~components/FormControl/FormErrorMessage'
import { ModalCloseButton } from '~components/Modal'
import { ProgressIndicator } from '~components/ProgressIndicator/ProgressIndicator'

import CSV_TEMPLATE_EXAMPLE_IMAGE from './conditional-routing-example.png'
import CSV_TEMPLATE_EXAMPLE_GIF from './conditional-routing-example.gif'
import { ConditionalRoutingConfig } from './ConditionalRoutingOption'
import { FieldItem } from './types'

Expand Down Expand Up @@ -117,9 +117,9 @@ const StepOneModalContent = ({
</Stack>
</Box>
<Stack spacing="1rem" alignItems="center">
<Image w="466px" src={CSV_TEMPLATE_EXAMPLE_IMAGE} />
<Image w="466px" src={CSV_TEMPLATE_EXAMPLE_GIF} />
<Text color="secondary.400" textStyle="caption-2">
Your CSV template should look like this
How to set up your CSV
</Text>
</Stack>
</Stack>
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

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

This file is quite huge, standing at 9MB. Did a brief check on our gifs in the repo, they aren't so huge (~300kb).

@alicia-ogp you have a smaller version of the gif? What something along the lines of 1000width instead of 1600width

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { MB } from '~shared/constants'
import { AttachmentSize, BasicField, StorageFormSettings } from '~shared/types'
import { VALID_WHITELIST_FILE_EXTENSIONS } from '~shared/utils/file-validation'

import { parseCsvFileToCsvString } from '~utils/parseCsvFileToCsvString'
import { parseCsvFile } from '~utils/parseCsvFile'
import Attachment from '~components/Field/Attachment'
import { AttachmentFieldSchema } from '~templates/Field'
import { FieldContainer } from '~templates/Field/FieldContainer'
Expand Down Expand Up @@ -101,7 +101,7 @@ export const FormWhitelistAttachmentField = ({
return
}

const csvString = parseCsvFileToCsvString(file, (headerRow) => {
const csvString = parseCsvFile(file, (headerRow) => {
return {
isValid:
headerRow &&
Expand All @@ -111,6 +111,18 @@ export const FormWhitelistAttachmentField = ({
invalidReason:
'Your CSV file should only contain a single column with the header "Respondent".',
}
}).then((csvRows) => {
const whitelistedSubmitterIdsString = csvRows.reduce((acc, row) => {
const trimmedSubmitterId = row[0].trim()
const isSubmitterIdEmpty = !trimmedSubmitterId
if (isSubmitterIdEmpty) {
return acc
}
const isFirst = acc === ''
const delimiter = isFirst ? '' : ','
return acc + delimiter + trimmedSubmitterId
}, '')
return whitelistedSubmitterIdsString
})

mutateFormWhitelistSetting.mutate(csvString, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import Papa from 'papaparse'

export const parseCsvFileToCsvString = (
export const parseCsvFile = (
file: File,
validateHeader?: (headerRow: string[]) => {
isValid: boolean
invalidReason: string
},
): Promise<string> => {
): Promise<string[][]> => {
return new Promise((resolve, reject) => {
Papa.parse(file, {
delimiter: ',',
complete: ({ data }: { data: string[][] }) => {
const hasHeader = !!validateHeader
const headerRow = hasHeader ? data[0] : null
Expand All @@ -20,16 +19,13 @@ export const parseCsvFileToCsvString = (
reject(new Error(invalidReason))
}
}
const csvString = Papa.unparse(contentRows, {
newline: '\r\n',
})
// strip quotes to account for mixed CRLF and LF line endings.
// strip newline/empty spaces at the end of string to account for invisible trailing newlines and empty last rows.
const strippedCsvString = csvString.replaceAll('"', '').trim()
if (!strippedCsvString) {
const nonEmptyContentRows = contentRows
.map((row) => row.map((cell) => cell.trim()))
.filter((row) => !row.every((cell) => cell === ''))
if (nonEmptyContentRows.length === 0) {
reject(new Error('Your CSV file body cannot be empty.'))
}
resolve(strippedCsvString)
resolve(nonEmptyContentRows)
},
error: (error) => {
reject(error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4866,7 +4866,7 @@ describe('admin-form.controller', () => {
cloneDeep(MOCK_BASE_REQ),
{
body: {
whitelistCsvString: `${MOCK_LOWERCASE_NRIC}\r\n${MOCK_VALID_FIN}\r\n${MOCK_VALID_UEN}`,
whitelistCsvString: `${MOCK_LOWERCASE_NRIC},${MOCK_VALID_FIN},${MOCK_VALID_UEN}`,
},
},
)
Expand Down Expand Up @@ -4903,7 +4903,7 @@ describe('admin-form.controller', () => {
cloneDeep(MOCK_BASE_REQ),
{
body: {
whitelistCsvString: `${MOCK_VALID_FIN}\r\n${MOCK_LOWERCASE_NRIC}\r\n${MOCK_LOWERCASE_UEN}`,
whitelistCsvString: `${MOCK_VALID_FIN},${MOCK_LOWERCASE_NRIC},${MOCK_LOWERCASE_UEN}`,
},
},
)
Expand Down Expand Up @@ -4946,7 +4946,7 @@ describe('admin-form.controller', () => {
cloneDeep(MOCK_BASE_REQ),
{
body: {
whitelistCsvString: `${MOCK_VALID_NRIC}\r\n${MOCK_VALID_FIN}\r\n${MOCK_VALID_UEN}`,
whitelistCsvString: `${MOCK_VALID_NRIC},${MOCK_VALID_FIN},${MOCK_VALID_UEN}`,
},
},
)
Expand Down
2 changes: 1 addition & 1 deletion src/app/modules/form/admin-form/admin-form.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1686,7 +1686,7 @@ const _parseWhitelistCsvString = (whitelistCsvString: string | null) => {
if (!whitelistCsvString) {
return null
}
return whitelistCsvString.split('\r\n').map((entry: string) => entry.trim())
return whitelistCsvString.split(',').map((entry: string) => entry.trim())
}

const _handleUpdateWhitelistSetting: ControllerHandler<
Expand Down
Loading