diff --git a/CHANGELOG.md b/CHANGELOG.md index 13725bf..21c871d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- `submissionService.processInjectablesInCustomResource()` + ## [7.0.0] - 2024-08-13 ### Removed diff --git a/src/replaceCustomValues.ts b/src/replaceCustomValues.ts index 8baa4b5..14142ff 100644 --- a/src/replaceCustomValues.ts +++ b/src/replaceCustomValues.ts @@ -694,3 +694,210 @@ export function replaceInjectablesWithSubmissionValues( hadAllInjectablesReplaced, } } + +/** + * Process a resource with injectable element values to turn a single resource + * (could be a single) into multiple resources. e.g. + * `"{ELEMENT:Children|Child_Name} {ELEMENT:Family_Name}"` with the following + * submission data: + * + * ```json + * { + * "Family_Name": "Smith", + * "Children": [ + * { + * "Child_Name": "John" + * }, + * { + * "Child_Name": "Jane" + * } + * ] + * } + * ``` + * + * Would result in the following resources: + * + * - `"John Smith"` + * - `"Jane Smith"` + * + * #### Example + * + * ```js + * const emailAddresses = processInjectablesInCustomResource({ + * resource: '{ELEMENT:People|Email_Address}', + * submission: { + * People: [ + * { + * Email_Address: 'user@oneblink.io', + * }, + * { + * Email_Address: 'admin@oneblink.io', + * }, + * ], + * }, + * formElements: [ + * { + * id: '18dcd3e0-6e2f-462e-803b-e24562d9fa6d', + * type: 'repeatableSet', + * name: 'People', + * label: 'People', + * elements: [ + * { + * id: 'd0902113-3f77-4070-adbd-ca3ae95ce091', + * type: 'email', + * name: 'Email_Address', + * label: 'Email_Address', + * }, + * ], + * }, + * ], + * replaceRootInjectables: (resource, submission, formElements) => { + * const { text } = replaceInjectablesWithElementValues(resource, { + * submission, + * formElements, + * excludeNestedElements: true, + * // other options + * }) + * return text + * }, + * }) + * // emailAddresses === ["user@oneblink.io", "admin@oneblink.io"] + * ``` + * + * @param options + * @returns + */ +export function processInjectablesInCustomResource({ + resource, + submission, + formElements, + replaceRootInjectables, + prepareNestedInjectables = (resource, prepare) => + prepare(String(resource)) as T, +}: { + /** The resource that contains properties that support injection or a string */ + resource: T + /** The form submission data to process */ + submission: SubmissionTypes.S3SubmissionData['submission'] + /** The form elements to process */ + formElements: FormTypes.FormElement[] + /** + * A function to inject values, this allows custom formatters to be used. + * Return `undefined` to prevent the injection from recursively continuing. + * + * @param resource The current resource that contains properties that support + * injection or a string + * @param submission The current form submission data to process (may be an + * entry in a repeatable set) + * @param formElements The current form elements to process (may be the + * elements from a repeatable set) + * @returns + */ + replaceRootInjectables: ( + resource: T, + submission: SubmissionTypes.S3SubmissionData['submission'], + formElements: FormTypes.FormElement[], + ) => + | [injectedText: string, resourceKey: string, newResource: T] + | string + | undefined + /** + * An optional function to replace nested injectables when creating multiple + * resources from repeatable sets. Only required if the `resource` param is + * not a `string`. + * + * @param resource The current resource that contains properties that support + * injection or a string + * @param prepare A function to prepare the resource string(s) for another + * iteration + * @returns + */ + prepareNestedInjectables?: ( + resource: T, + preparer: (resourceText: string) => string, + ) => T +}): Map { + const newResources: Map = new Map() + + const injectorResult = replaceRootInjectables( + resource, + submission, + formElements, + ) + if (!injectorResult) { + return newResources + } + + const [text, resourceKey, newResource] = + typeof injectorResult === 'string' + ? [injectorResult, injectorResult, injectorResult as T] + : injectorResult + + // Find nested form elements + const matches: Map = new Map() + matchElementsTagRegex( + { + text, + excludeNestedElements: false, + }, + ({ elementName }) => { + const [repeatableSetElementName, ...elementNames] = elementName.split('|') + matches.set(repeatableSetElementName, !!elementNames.length) + }, + ) + + if (matches.size) { + matches.forEach((hasNestedFormElements, repeatableSetElementName) => { + if (hasNestedFormElements) { + // Attempt to create a new resource for each entry in the repeatable set. + const entries = submission?.[repeatableSetElementName] + if (Array.isArray(entries)) { + const repeatableSetElement = findFormElement( + formElements, + (formElement) => { + return ( + 'name' in formElement && + formElement.name === repeatableSetElementName + ) + }, + ) + if ( + repeatableSetElement && + 'elements' in repeatableSetElement && + Array.isArray(repeatableSetElement.elements) + ) { + for (const entry of entries) { + const replacedResource = prepareNestedInjectables( + newResource, + (resourceText) => { + return resourceText.replaceAll( + `{ELEMENT:${repeatableSetElementName}|`, + '{ELEMENT:', + ) + }, + ) + const nestedResources = processInjectablesInCustomResource({ + resource: replacedResource, + submission: entry, + formElements: repeatableSetElement.elements, + replaceRootInjectables, + prepareNestedInjectables, + }) + if (nestedResources.size) { + nestedResources.forEach((nestedResource, nestedResourceKey) => { + if (!newResources.has(nestedResourceKey)) { + newResources.set(nestedResourceKey, nestedResource) + } + }) + } + } + } + } + } + }) + } else { + newResources.set(resourceKey, newResource) + } + + return newResources +} diff --git a/tests/__snapshots__/submissionService.test.ts.snap b/tests/__snapshots__/submissionService.test.ts.snap new file mode 100644 index 0000000..4ca5ab4 --- /dev/null +++ b/tests/__snapshots__/submissionService.test.ts.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`processInjectablesInCustomResource should correctly replace nested injectables 1`] = ` +Map { + "John is part of the Smith family" => "John is part of the Smith family", + "Jane is part of the Smith family" => "Jane is part of the Smith family", +} +`; + +exports[`processInjectablesInCustomResource should handle missing element values gracefully 1`] = `Map {}`; + +exports[`processInjectablesInCustomResource should handle multiple replacements correctly 1`] = ` +Map { + "Smith Smith" => "Smith Smith", +} +`; + +exports[`processInjectablesInCustomResource should handle single element without iteration 1`] = ` +Map { + "Smith" => "Smith", +} +`; + +exports[`processInjectablesInCustomResource should replace injectable values correctly 1`] = ` +Map { + "John Smith" => "John Smith", + "Jane Smith" => "Jane Smith", +} +`; + +exports[`processInjectablesInCustomResource should replace injectable values correctly when resource is not a string 1`] = ` +[ + { + "book_title": "The Adventures of Superman", + "favorite_sentence": "It was a bright cold day in April, and the clocks were striking thirteen.", + }, + { + "book_title": "The Adventures of Wonder Woman", + "favorite_sentence": "It was a bright cold day in April, and the clocks were striking thirteen.", + }, + { + "book_title": "The Mysterious Case of Sherlock Holmes", + "favorite_sentence": "Elementary, my dear Watson.", + }, + { + "book_title": "The Mysterious Case of Hercule Poirot", + "favorite_sentence": "Elementary, my dear Watson.", + }, + { + "book_title": "Strange Times in Gotham", + "favorite_sentence": "It was the best of times, it was the worst of times.", + }, + { + "book_title": "Strange Times in Metropolis", + "favorite_sentence": "It was the best of times, it was the worst of times.", + }, + { + "book_title": "The Journey of Superman", + "favorite_sentence": "Not all those who wander are lost.", + }, + { + "book_title": "The Journey of Wonder Woman", + "favorite_sentence": "Not all those who wander are lost.", + }, + { + "book_title": "Brave Nights", + "favorite_sentence": "To be, or not to be, that is the question.", + }, + { + "book_title": "Mysterious Nights", + "favorite_sentence": "To be, or not to be, that is the question.", + }, +] +`; + +exports[`processInjectablesInCustomResource should return an empty array if no injectables are present 1`] = ` +Map { + "No injectables here" => "No injectables here", +} +`; diff --git a/tests/submissionService.test.ts b/tests/submissionService.test.ts index 289e118..8f3d2ff 100644 --- a/tests/submissionService.test.ts +++ b/tests/submissionService.test.ts @@ -1,8 +1,13 @@ -import { FormTypes, ScheduledTasksTypes } from '@oneblink/types' +import { + FormTypes, + ScheduledTasksTypes, + SubmissionTypes, +} from '@oneblink/types' import { replaceInjectablesWithElementValues, replaceInjectablesWithSubmissionValues, getElementSubmissionValue, + processInjectablesInCustomResource, } from '../src/submissionService' describe('replaceInjectablesWithSubmissionValues()', () => { @@ -585,3 +590,313 @@ describe('replaceInjectablesWithSubmissionValues()', () => { }) }) }) + +describe('processInjectablesInCustomResource', () => { + const formElements: FormTypes.FormElement[] = [ + { + id: 'element1', + name: 'Family_Name', + type: 'text', + label: 'Family Name', + readOnly: false, + required: false, + conditionallyShow: false, + requiresAllConditionallyShowPredicates: false, + isElementLookup: false, + isDataLookup: false, + }, + { + id: 'element2', + type: 'repeatableSet', + name: 'Children', + label: 'Children', + conditionallyShow: false, + elements: [ + { + id: 'element3', + name: 'Child_Name', + type: 'text', + label: 'Child Name', + readOnly: false, + required: false, + conditionallyShow: false, + requiresAllConditionallyShowPredicates: false, + isElementLookup: false, + isDataLookup: false, + }, + ], + }, + ] + + const submission: SubmissionTypes.S3SubmissionData['submission'] = { + Family_Name: 'Smith', + Children: [ + { + Child_Name: 'John', + }, + { + Child_Name: 'Jane', + }, + ], + } + + const replaceRootInjectables = ( + text: string, + entry: SubmissionTypes.S3SubmissionData['submission'], + elements: FormTypes.FormElement[], + ) => { + return replaceInjectablesWithElementValues(text, { + userProfile: undefined, + task: undefined, + taskGroup: undefined, + taskGroupInstance: undefined, + formatDateTime: (value) => new Date(value).toString(), + formatDate: (value) => new Date(value).toDateString(), + formatTime: (value) => new Date(value).toTimeString(), + formatNumber: (value) => value.toString(), + formatCurrency: (value) => value.toFixed(2), + submission: entry, + formElements: elements, + excludeNestedElements: true, + }).text + } + + it('should replace injectable values correctly', () => { + const resource = '{ELEMENT:Children|Child_Name} {ELEMENT:Family_Name}' + const result = processInjectablesInCustomResource({ + resource, + submission, + formElements, + replaceRootInjectables, + }) + + expect(result).toMatchSnapshot() + }) + + it('should handle single element without iteration', () => { + const resource = '{ELEMENT:Family_Name}' + const result = processInjectablesInCustomResource({ + resource, + submission, + formElements, + replaceRootInjectables, + }) + + expect(result).toMatchSnapshot() + }) + + it('should handle missing element values gracefully', () => { + const resource = '{ELEMENT:Non_Existing_Element}' + const result = processInjectablesInCustomResource({ + resource, + submission, + formElements, + replaceRootInjectables, + }) + + expect(result).toMatchSnapshot() + }) + + it('should correctly replace nested injectables', () => { + const resource = + '{ELEMENT:Children|Child_Name} is part of the {ELEMENT:Family_Name} family' + const result = processInjectablesInCustomResource({ + resource, + submission, + formElements, + replaceRootInjectables, + }) + + expect(result).toMatchSnapshot() + }) + + it('should return an empty array if no injectables are present', () => { + const resource = 'No injectables here' + const result = processInjectablesInCustomResource({ + resource, + submission, + formElements, + replaceRootInjectables, + }) + + expect(result).toMatchSnapshot() + }) + + it('should handle multiple replacements correctly', () => { + const resource = '{ELEMENT:Family_Name} {ELEMENT:Family_Name}' + const result = processInjectablesInCustomResource({ + resource, + submission, + formElements, + replaceRootInjectables, + }) + + expect(result).toMatchSnapshot() + }) + + it('should replace injectable values correctly when resource is not a string', () => { + type Book = { + book_title: string + favorite_sentence: string + } + const books: Book[] = [ + { + book_title: 'The Adventures of {ELEMENT:heros|name}', + favorite_sentence: + 'It was a bright cold day in April, and the clocks were striking thirteen.', + }, + { + book_title: 'The Mysterious Case of {ELEMENT:detectives|name}', + favorite_sentence: 'Elementary, my dear Watson.', + }, + { + book_title: 'Strange Times in {ELEMENT:cities|name}', + favorite_sentence: + 'It was the best of times, it was the worst of times.', + }, + { + book_title: 'The Journey of {ELEMENT:heros|name}', + favorite_sentence: 'Not all those who wander are lost.', + }, + { + book_title: '{ELEMENT:adjectives|name} Nights', + favorite_sentence: 'To be, or not to be, that is the question.', + }, + ] + + const result = books.reduce((memo, book) => { + const map = processInjectablesInCustomResource({ + resource: book, + submission: { + heros: [{ name: 'Superman' }, { name: 'Wonder Woman' }], + detectives: [{ name: 'Sherlock Holmes' }, { name: 'Hercule Poirot' }], + cities: [{ name: 'Gotham' }, { name: 'Metropolis' }], + adjectives: [{ name: 'Brave' }, { name: 'Mysterious' }], + }, + formElements: [ + { + id: 'element1', + type: 'repeatableSet', + name: 'heros', + label: 'Heros', + conditionallyShow: false, + elements: [ + { + id: 'element2', + name: 'name', + type: 'text', + label: 'Name', + readOnly: false, + required: false, + conditionallyShow: false, + requiresAllConditionallyShowPredicates: false, + isElementLookup: false, + isDataLookup: false, + }, + ], + }, + { + id: 'element3', + type: 'repeatableSet', + name: 'detectives', + label: 'Detectives', + conditionallyShow: false, + elements: [ + { + id: 'element4', + name: 'name', + type: 'text', + label: 'Name', + readOnly: false, + required: false, + conditionallyShow: false, + requiresAllConditionallyShowPredicates: false, + isElementLookup: false, + isDataLookup: false, + }, + ], + }, + { + id: 'element5', + type: 'repeatableSet', + name: 'cities', + label: 'Cities', + conditionallyShow: false, + elements: [ + { + id: 'element6', + name: 'name', + type: 'text', + label: 'Name', + readOnly: false, + required: false, + conditionallyShow: false, + requiresAllConditionallyShowPredicates: false, + isElementLookup: false, + isDataLookup: false, + }, + ], + }, + { + id: 'element7', + type: 'repeatableSet', + name: 'adjectives', + label: 'Adjectives', + conditionallyShow: false, + elements: [ + { + id: 'element8', + name: 'name', + type: 'text', + label: 'Name', + readOnly: false, + required: false, + conditionallyShow: false, + requiresAllConditionallyShowPredicates: false, + isElementLookup: false, + isDataLookup: false, + }, + ], + }, + ], + replaceRootInjectables: (book, entry, elements) => { + const book_title = replaceInjectablesWithElementValues( + book.book_title, + { + userProfile: undefined, + task: undefined, + taskGroup: undefined, + taskGroupInstance: undefined, + formatDateTime: (value) => new Date(value).toString(), + formatDate: (value) => new Date(value).toDateString(), + formatTime: (value) => new Date(value).toTimeString(), + formatNumber: (value) => value.toString(), + formatCurrency: (value) => value.toFixed(2), + submission: entry, + formElements: elements, + excludeNestedElements: true, + }, + ).text + return [ + book_title, + book_title, + { + ...book, + book_title, + } as Book, + ] + }, + prepareNestedInjectables: (book, preparer) => { + return { + ...book, + book_title: preparer(book.book_title), + } + }, + }) + + return [...memo, ...map.values()] + }, []) + + expect(result).toMatchSnapshot() + }) +})