refactor: rework form api formating

This commit is contained in:
axolotle 2024-07-25 10:12:34 +02:00
parent 678638534b
commit 4f59343614

View file

@ -1,12 +1,15 @@
import { toValue, type MaybeRef } from 'vue'
import { import {
flattenObjectLiteral,
getFileContent, getFileContent,
isEmptyValue, isEmptyValue,
isObjectLiteral, isObjectLiteral,
toEntries,
} from '@/helpers/commons' } from '@/helpers/commons'
import store from '@/store' import store from '@/store'
import type { Translation } from '@/types/commons' import type { ArrInnerType, Obj, Translation } from '@/types/commons'
import type { AdressModelValue } from '@/types/form' import type { AdressModelValue, FileModelValue } from '@/types/form'
import { isAdressModelValue, isFileModelValue } from '@/types/form'
export const DEFAULT_STATUS_ICON = { export const DEFAULT_STATUS_ICON = {
[null]: null, [null]: null,
@ -66,105 +69,171 @@ export function formatAdress(address: string): AdressModelValue {
// FORMAT TO CORE // FORMAT TO CORE
type BasePossibleFormValues =
| FileModelValue
| AdressModelValue
| boolean
| string
| number
| null
| undefined
type PossibleFormValues = BasePossibleFormValues | BasePossibleFormValues[]
/** /**
* Parse a front-end value to its API equivalent. This function returns a Promise or an * Parse a front-end value to its API equivalent.
* Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those * This function is async because we may need to read a file content.
* objects must be merged to define the final sent form.
* *
* Convert Boolean to '1' (true) or '0' (false), * Convert Boolean to '1' (true) or '0' (false),
* Concatenate two parts adresses (subdomain or email for example) into a single string, * Concatenate two parts adresses (subdomain or email for example) into a single string,
* Convert File to its Base64 representation or set its value to '' to ask for a removal. * Convert File to its Base64 representation or set its value to '' to ask for a removal.
* *
* @param {*} value * @param value - Any {@link PossibleFormValues}
* @return {*} * @return Promise that resolves the formated value
*/ */
export function formatFormDataValue(value, key = null) { export async function formatFormValue<T extends PossibleFormValues>(
if (Array.isArray(value)) { value: T,
return Promise.all(value.map((value_) => formatFormDataValue(value_))).then( ): Promise<FormValueReturnType<T>> {
(resolvedValues) => ({ [key]: resolvedValues }), // TODO: couldn't manage proper type checking for this function
// Returned type is ok but it is not type safe since we return `any`
let formated: any = value
if (typeof value === 'boolean') {
formated = value ? 1 : 0
} else if (Array.isArray(value)) {
formated = await Promise.all(value.map((v) => formatFormValue(v)))
} else if (isFileModelValue(value)) {
// File has to be deleted
if (value.removed) formated = ''
// File has not changed (will not be sent)
else if (value.current || value.file === null) formated = null
else {
const filename = value.file.name
formated = await getFileContent(value.file, { base64: true }).then(
(content) => {
return {
content: content.replace(/data:[^;]*;base64,/, ''),
filename,
}
},
) )
} }
} else if (isAdressModelValue(value)) {
let result = value formated = Object.values(value).join('')
if (typeof value === 'boolean') result = value ? 1 : 0
if (isObjectLiteral(value) && 'file' in value) {
// File has to be deleted
if (value.removed) result = ''
// File has not changed (will not be sent)
else if (value.current || value.file === null) result = null
else {
return getFileContent(value.file, { base64: true }).then((content) => {
return {
[key]: content.replace(/data:[^;]*;base64,/, ''),
[key + '[name]']: value.file.name,
}
})
}
} else if (isObjectLiteral(value) && 'separator' in value) {
result = Object.values(value).join('')
} }
// Returns a resolved Promise for non async values return formated
return Promise.resolve(key ? { [key]: result } : result)
} }
type FileReturnType<T extends FileModelValue> = T extends {
removed: true
}
? ''
: T extends {
file: File
}
? { content: string; filename: string }
: null
export type FormValueReturnType<T extends PossibleFormValues> =
T extends boolean
? 0 | 1
: T extends FileModelValue
? FileReturnType<T>
: T extends AdressModelValue
? string
: T extends BasePossibleFormValues[]
? FormValueReturnType<ArrInnerType<T>>[]
: T extends string | number | null | undefined
? T
: never
/** /**
* Convinient helper to properly parse a front-end form to its API equivalent. * Format a frontend form to its API equivalent to be sent to the server.
* This parse each values asynchronously, allow to inject keys into the final form and * This function is async because we need to read files content.
* make sure every async values resolves before resolving itself.
* *
* @param {Object} formData * /!\ FIXME
* @return {Object} * Files type are wrong, they resolves as `{ filename: string; content: string }`
* but in reality they resolves as 2 keys in the returned form. See implementation.
* /!\
*
* @param form - An `Obj` containing form values
* @param removeEmpty - Removes "empty" values (`null | undefined | '' | [] | {}`) from the object
* @param removeNull - Removes `null | undefined` values from the object
* @return API data ready to be sent to the server.
*/ */
function formatFormDataValues(formData) { export function formatForm<
const promisedValues = Object.entries(formData).map(([key, value]) => { T extends Obj<PossibleFormValues>,
return formatFormDataValue(value, key) R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
}) >(
form: MaybeRef<T>,
{ removeEmpty = false },
): Promise<
Partial<{
// TODO: using `Partial` for now since i'm not sure we can infer empty `'' | [] | {}`
[k in keyof R as R[k] extends undefined | null ? never : k]: R[k]
}>
>
export function formatForm<
T extends Obj<PossibleFormValues>,
R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
>(
form: MaybeRef<T>,
{ removeNullish = false },
): Promise<{
[k in keyof R as R[k] extends undefined | null ? never : k]: R[k]
}>
export function formatForm<
T extends Obj<PossibleFormValues>,
R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
>(form: MaybeRef<T>): Promise<R>
export function formatForm<
T extends Obj<PossibleFormValues>,
R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
>(
form: MaybeRef<T>,
{ removeEmpty = false, removeNullish = false } = {},
): Promise<FormatFormReturnType<R>> {
const [keys, promises] = toEntries(toValue(form)).reduce(
(acc, [key, v]) => {
acc[0].push(key)
acc[1].push(formatFormValue(v))
return acc
},
[[] as (keyof T)[], [] as Promise<FormValueReturnType<T[keyof T]>>[]],
)
return Promise.all(promisedValues).then((resolvedValues) => { return Promise.all(promises).then((resolvedValues) => {
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {}) let entries = resolvedValues.map((v, i) => [keys[i], v] as const)
if (removeEmpty || removeNullish) {
entries = entries.filter((entry) => {
return !(
(removeEmpty && isEmptyValue(entry[1])) ||
(removeNullish && [null, undefined].includes(entry[1] as any))
)
}) })
} }
// Special handling of files which are a bit weird, we inject 2 keys
/** // in the form, one for the filename and one with its content.
* Format a form produced by a vue view to be sent to the server. // TODO: could be improved, with a single key for example as to current
* // type `{ filename: string; content: string }` and remove the next `reduce`
* @param {Object} formData - An object literal containing form values. return entries.reduce(
* @param {Object} [extraParams] - Optionnal params (form, [k, v]) => {
* @param {Array} [extraParams.extract] - An array of keys that should be extracted from the form. if (isObjectLiteral(v) && 'filename' in v && 'content' in v) {
* @param {Boolean} [extraParams.flatten=false] - Flattens or not the passed formData. // @ts-ignore (mess to type)
* @param {Boolean} [extraParams.removeEmpty=true] - Removes "empty" values from the object. form[k] = v.content
* @return {Object} the parsed data to be sent to the server, with extracted values if specified. // @ts-ignore (mess to type)
*/ form[`${String(k)}[name]`] = v.filename
export async function formatFormData( }
formData, form[k] = v
{ return form
extract = null, },
flatten = false, {} as { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
removeEmpty = true, )
removeNull = false, }) as Promise<FormatFormReturnType<R>>
} = {},
) {
const output = {
data: {},
extracted: {},
} }
const values = await formatFormDataValues(formData) export type FormatFormReturnType<R> =
for (const key in values) { | Partial<{
const type = extract && extract.includes(key) ? 'extracted' : 'data' [k in keyof R as R[k] extends undefined | null ? never : k]: R[k]
const value = values[key] }>
if (removeEmpty && isEmptyValue(value)) { | { [k in keyof R as R[k] extends undefined | null ? never : k]: R[k] }
continue | R
} else if (removeNull && [null, undefined].includes(value)) {
continue
} else if (flatten && isObjectLiteral(value)) {
flattenObjectLiteral(value, output[type])
} else {
output[type][key] = value
}
}
const { data, extracted } = output
return extract ? { data, ...extracted } : data
}