diff --git a/.changeset/unlucky-camels-give.md b/.changeset/unlucky-camels-give.md new file mode 100644 index 0000000..b76c0d8 --- /dev/null +++ b/.changeset/unlucky-camels-give.md @@ -0,0 +1,6 @@ +--- +'@jhecht/arktype-utils': minor +--- + +Updates formDataToObject to include nested objects + diff --git a/packages/arktype-utils/src/formData.test.ts b/packages/arktype-utils/src/formData.test.ts index 95ab52e..b899e06 100644 --- a/packages/arktype-utils/src/formData.test.ts +++ b/packages/arktype-utils/src/formData.test.ts @@ -1,6 +1,6 @@ -import { expect, describe, it } from 'vitest'; -import { formDataToObject, validateFormData } from './formData.js'; import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { formDataToObject, validateFormData } from './formData.js'; describe('formDataToObject', () => { it('Should parse empty formData object', () => { @@ -79,10 +79,12 @@ describe('formDataToObject', () => { const fd = new FormData(); fd.append('names[]', 'bob'); fd.append('ages[]', '13'); + fd.append('title', 'Fantastic Voyage'); expect(formDataToObject(fd)).toStrictEqual({ names: ['bob'], ages: [13], + title: 'Fantastic Voyage', }); }); diff --git a/packages/arktype-utils/src/formData.ts b/packages/arktype-utils/src/formData.ts index 6c3261e..36c1d9d 100644 --- a/packages/arktype-utils/src/formData.ts +++ b/packages/arktype-utils/src/formData.ts @@ -7,40 +7,119 @@ type FilterFn = (a: EntriesTouple) => boolean; type FormDataObjectEntry = FormDataEntryValue | number | boolean | bigint; +const nameKeyExtractor = /(?[a-zA-z]+[a-zA-Z-]+)\[(?.*)\]/; +const digitCheck = /^\d+$/; + +type MagicObject = { + readonly type: 'array' | 'object'; + add(key: string, value: string | File): void; + toJS(): unknown[] | Record; +}; + +function makeMagicObject(init: EntriesTouple[] = []): MagicObject { + const entries: EntriesTouple[] = ([] as EntriesTouple[]).concat(init); + return { + get type() { + if (entries.every(([k]) => digitCheck.test(k) || k === '')) + return 'array'; + return 'object'; + }, + add(key: string, value: string | File) { + entries.push([key, value]); + }, + toJS() { + // TODO: Figure out how to clean this up + if (this.type === 'array') { + const arr: unknown[] = []; + for (const [k, v] of entries) { + if (k === '') + arr.push(typeof v === 'string' ? stringToJSValue(v) : v); + else arr[Number(k)] = typeof v === 'string' ? stringToJSValue(v) : v; + } + return arr; + } + const ret: Record = {}; + for (const [k, v] of entries) + ret[k] = typeof v === 'string' ? stringToJSValue(v) : v; + return ret; + }, + }; +} + /** * * @param fd the form data object * @param filterFn an optional filtering function to remove some values from the end object - * @param pruneKeyNames if true, then the keynames matching the pattern of `key[]` will be pruned down to just - * `key` in the resulting object * @returns an object mapped from the entries. */ export function formDataToObject( fd: FormData, filterFn: FilterFn = () => true, - pruneKeyNames = true, -): Record { - const ret: Record = {}; - - for (let key of fd.keys()) { - if (filterFn([key, ''])) { - const all = fd.getAll(key); - - if (all.length === 1 && !key.endsWith('[]')) { - // regular stuff - if (typeof all[0] === 'string') ret[key] = stringToJSValue(all[0]); - else if (all[0] instanceof File) ret[key] = all[0]; - } else { - if (pruneKeyNames && /\[.?\]/.test(key)) - key = key.replace(/\[.?\]/, ''); - - ret[key] = all.map(v => - typeof v === 'string' ? stringToJSValue(v) : v, - ); +) { + const ret: Record = {}; + // Map of key name into magic type which converts its entries to either an object + // or an array. + const info = new Map>(); + // eslint-disable-next-line prefer-const + for (let [iterator, value] of fd.entries()) { + // If the key does not match this filter, continue the loop + if (!filterFn([iterator, value])) continue; + // run the iterator against the name key extractor + const matches = iterator.match(nameKeyExtractor); + // If we do not have matches, or the index match is empty, we go here. + if (matches === null || matches[2] === '') { + // Grab all of the values for the current iterator + const all = fd.getAll(iterator); + // If the length of all entries for this iterator is 1 AND the iterator name does not end + // with [] (indicating the user wants this to be an array) we drop in here + if (all.length === 1 && !iterator.endsWith('[]')) { + // set the value on the return object + ret[iterator] = + typeof all[0] === 'string' ? stringToJSValue(all[0]) : all[0]; + // don't need the rest of the loop values here, so we forcibly continue + continue; } + // If the iterator includes an opening [], let's assume we don't want the `[]` to be included + // so we trim it out here + if (iterator.includes('[')) + iterator = iterator.slice(0, iterator.indexOf('[')); + + // Set the iterator value on returned object + ret[iterator] = all.map(v => + typeof v === 'string' ? stringToJSValue(v) : v, + ); + } else { + // If we have matches, there's a bit more processing required + const { groups } = matches; + // Check to ensure our groups are there. It shouldn't be possible to have matches + // without groups in modern JS, but c'est la vie + if (groups === undefined) continue; + // pull out the name and index. we add defaults so TS doesn't yell at us about possibly + // being undefined + const { name = '', index = '' } = groups; + // Grab the magic item from the info map. + let magic = info.get(name); + // If we don't have a magic item, make one and set it + if (!magic) { + magic = makeMagicObject(); + info.set(name, magic); + } + + // If the index is '', it means we were given something like `name[]`, or `age[]` + if (index === '') { + // Get all the values for this iterator + const all = fd.getAll(iterator); + // Loop over + for (const a of all) magic.add('', a as string); + } else magic.add(index, value); } } + // Consolidation of items in the info values. + for (const [key, magic] of info.entries()) { + if (!ret[key]) ret[key] = magic.toJS(); + else console.error(`Key ${key} already exists in object.`); + } return ret; }