mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
refactor: rework form api formating
This commit is contained in:
parent
678638534b
commit
4f59343614
1 changed files with 161 additions and 92 deletions
|
@ -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)) {
|
||||||
|
formated = Object.values(value).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = value
|
return formated
|
||||||
if (typeof value === 'boolean') result = value ? 1 : 0
|
}
|
||||||
if (isObjectLiteral(value) && 'file' in value) {
|
|
||||||
// File has to be deleted
|
type FileReturnType<T extends FileModelValue> = T extends {
|
||||||
if (value.removed) result = ''
|
removed: true
|
||||||
// File has not changed (will not be sent)
|
}
|
||||||
else if (value.current || value.file === null) result = null
|
? ''
|
||||||
else {
|
: T extends {
|
||||||
return getFileContent(value.file, { base64: true }).then((content) => {
|
file: File
|
||||||
return {
|
}
|
||||||
[key]: content.replace(/data:[^;]*;base64,/, ''),
|
? { content: string; filename: string }
|
||||||
[key + '[name]']: value.file.name,
|
: 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a frontend form to its API equivalent to be sent to the server.
|
||||||
|
* This function is async because we need to read files content.
|
||||||
|
*
|
||||||
|
* /!\ FIXME
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function formatForm<
|
||||||
|
T extends Obj<PossibleFormValues>,
|
||||||
|
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(promises).then((resolvedValues) => {
|
||||||
|
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))
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (isObjectLiteral(value) && 'separator' in value) {
|
// Special handling of files which are a bit weird, we inject 2 keys
|
||||||
result = Object.values(value).join('')
|
// in the form, one for the filename and one with its content.
|
||||||
}
|
// TODO: could be improved, with a single key for example as to current
|
||||||
|
// type `{ filename: string; content: string }` and remove the next `reduce`
|
||||||
// Returns a resolved Promise for non async values
|
return entries.reduce(
|
||||||
return Promise.resolve(key ? { [key]: result } : result)
|
(form, [k, v]) => {
|
||||||
|
if (isObjectLiteral(v) && 'filename' in v && 'content' in v) {
|
||||||
|
// @ts-ignore (mess to type)
|
||||||
|
form[k] = v.content
|
||||||
|
// @ts-ignore (mess to type)
|
||||||
|
form[`${String(k)}[name]`] = v.filename
|
||||||
|
}
|
||||||
|
form[k] = v
|
||||||
|
return form
|
||||||
|
},
|
||||||
|
{} as { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
|
||||||
|
)
|
||||||
|
}) as Promise<FormatFormReturnType<R>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type FormatFormReturnType<R> =
|
||||||
* Convinient helper to properly parse a front-end form to its API equivalent.
|
| Partial<{
|
||||||
* This parse each values asynchronously, allow to inject keys into the final form and
|
[k in keyof R as R[k] extends undefined | null ? never : k]: R[k]
|
||||||
* make sure every async values resolves before resolving itself.
|
}>
|
||||||
*
|
| { [k in keyof R as R[k] extends undefined | null ? never : k]: R[k] }
|
||||||
* @param {Object} formData
|
| R
|
||||||
* @return {Object}
|
|
||||||
*/
|
|
||||||
function formatFormDataValues(formData) {
|
|
||||||
const promisedValues = Object.entries(formData).map(([key, value]) => {
|
|
||||||
return formatFormDataValue(value, key)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Promise.all(promisedValues).then((resolvedValues) => {
|
|
||||||
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a form produced by a vue view to be sent to the server.
|
|
||||||
*
|
|
||||||
* @param {Object} formData - An object literal containing form values.
|
|
||||||
* @param {Object} [extraParams] - Optionnal params
|
|
||||||
* @param {Array} [extraParams.extract] - An array of keys that should be extracted from the form.
|
|
||||||
* @param {Boolean} [extraParams.flatten=false] - Flattens or not the passed formData.
|
|
||||||
* @param {Boolean} [extraParams.removeEmpty=true] - Removes "empty" values from the object.
|
|
||||||
* @return {Object} the parsed data to be sent to the server, with extracted values if specified.
|
|
||||||
*/
|
|
||||||
export async function formatFormData(
|
|
||||||
formData,
|
|
||||||
{
|
|
||||||
extract = null,
|
|
||||||
flatten = false,
|
|
||||||
removeEmpty = true,
|
|
||||||
removeNull = false,
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const output = {
|
|
||||||
data: {},
|
|
||||||
extracted: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = await formatFormDataValues(formData)
|
|
||||||
for (const key in values) {
|
|
||||||
const type = extract && extract.includes(key) ? 'extracted' : 'data'
|
|
||||||
const value = values[key]
|
|
||||||
if (removeEmpty && isEmptyValue(value)) {
|
|
||||||
continue
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue