Skip to content

Commit

Permalink
Async (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
timacdonald authored Aug 14, 2024
1 parent fbf80b0 commit af7b35f
Show file tree
Hide file tree
Showing 19 changed files with 377 additions and 125 deletions.
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
"watch": "npm run watch --workspace=packages/core& npm run watch --workspace=packages/react& npm run watch --workspace=packages/react-inertia& npm run watch --workspace=packages/vue& npm run watch --workspace=packages/vue-inertia& npm run watch --workspace=packages/alpine& wait;",
"build": "npm run build --workspaces",
"link": "npm link --workspaces",
"lint": "eslint --ext .ts --ignore-pattern dist ./packages",
"lint:fix": "eslint --fix --ext .ts --ignore-pattern dist ./packages",
"typeCheck": "npm run typeCheck --workspaces",
"lint": "eslint --ignore-pattern /packages/**/dist/** ./packages/**",
"lint:fix": "eslint --fix --ignore-pattern /packages/**/dist/** ./packages/**",
"test": "npm run test --workspaces --if-present"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.21.0",
"eslint": "^8.14.0"
"@typescript-eslint/eslint-plugin": "^7.10.0",
"@typescript-eslint/parser": "^7.10.0",
"eslint": "^8.56.0"
}
}
1 change: 1 addition & 0 deletions packages/alpine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"scripts": {
"watch": "rm -rf dist && tsc --watch",
"build": "rm -rf dist && tsc",
"typeCheck": "tsc --noEmit",
"prepublishOnly": "npm run build",
"version": "npm pkg set dependencies.laravel-precognition=$npm_package_version"
},
Expand Down
11 changes: 8 additions & 3 deletions packages/alpine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,18 @@ export default function (Alpine: TAlpine) {

return form
},
validate(name) {
validate(name, config) {
if (typeof name === 'object' && !('target' in name)) {
config = name
name = undefined
}

if (typeof name === 'undefined') {
validator.validate()
validator.validate(config)
} else {
name = resolveName(name)

validator.validate(name, get(form.data(), name))
validator.validate(name, get(form.data(), name), config)
}

return form
Expand Down
4 changes: 2 additions & 2 deletions packages/alpine/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Config, NamedInputEvent, SimpleValidationErrors, ValidationErrors } from 'laravel-precognition'
import { Config, NamedInputEvent, SimpleValidationErrors, ValidationConfig, ValidationErrors } from 'laravel-precognition'

export interface Form<Data extends Record<string, unknown>> {
processing: boolean,
Expand All @@ -10,7 +10,7 @@ export interface Form<Data extends Record<string, unknown>> {
hasErrors: boolean,
valid(name: string): boolean,
invalid(name: string): boolean,
validate(name?: string|NamedInputEvent): Data&Form<Data>,
validate(name?: string|NamedInputEvent|ValidationConfig, config?: ValidationConfig): Data&Form<Data>,
setErrors(errors: SimpleValidationErrors|ValidationErrors): Data&Form<Data>
forgetError(name: string|NamedInputEvent): Data&Form<Data>
setValidationTimeout(duration: number): Data&Form<Data>,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"scripts": {
"watch": "rm -rf dist && tsc --watch",
"build": "rm -rf dist && tsc",
"typeCheck": "tsc --noEmit",
"prepublishOnly": "npm run build",
"test": "vitest run"
},
Expand All @@ -36,6 +37,6 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.1.0",
"typescript": "^5.0.0",
"vitest": "^0.31.3"
"vitest": "^1.6.0"
}
}
6 changes: 5 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface Client {

export interface Validator {
touched(): Array<string>,
validate(input?: string|NamedInputEvent, value?: unknown): Validator,
validate(input?: string|NamedInputEvent|ValidationConfig, value?: unknown, config?: ValidationConfig): Validator,
touch(input: string|NamedInputEvent|Array<string>): Validator,
validating(): boolean,
valid(): Array<string>,
Expand Down Expand Up @@ -88,3 +88,7 @@ interface NamedEventTarget extends EventTarget {
export interface NamedInputEvent extends InputEvent {
readonly target: NamedEventTarget;
}

declare module 'axios' {
export function mergeConfig(config1: AxiosRequestConfig, config2: AxiosRequestConfig): AxiosRequestConfig
}
84 changes: 60 additions & 24 deletions packages/core/src/validator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { debounce, isEqual, get, set, omit, merge } from 'lodash-es'
import { ValidationCallback, Config, NamedInputEvent, SimpleValidationErrors, ValidationErrors, Validator as TValidator, ValidatorListeners, ValidationConfig } from './types.js'
import { client, isFile } from './client.js'
import { isAxiosError } from 'axios'
import { isAxiosError, isCancel, mergeConfig } from 'axios'

export const createValidator = (callback: ValidationCallback, initialData: Record<string, unknown> = {}): TValidator => {
/**
Expand Down Expand Up @@ -169,15 +169,33 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor
/**
* Create a debounced validation callback.
*/
const createValidator = () => debounce(() => {
const createValidator = () => debounce((instanceConfig: Config) => {
callback({
get: (url, data = {}, config = {}) => client.get(url, parseData(data), resolveConfig(config, data)),
post: (url, data = {}, config = {}) => client.post(url, parseData(data), resolveConfig(config, data)),
patch: (url, data = {}, config = {}) => client.patch(url, parseData(data), resolveConfig(config, data)),
put: (url, data = {}, config = {}) => client.put(url, parseData(data), resolveConfig(config, data)),
delete: (url, data = {}, config = {}) => client.delete(url, parseData(data), resolveConfig(config, data)),
get: (url, data = {}, globalConfig = {}) => client.get(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
post: (url, data = {}, globalConfig = {}) => client.post(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
patch: (url, data = {}, globalConfig = {}) => client.patch(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
put: (url, data = {}, globalConfig = {}) => client.put(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
delete: (url, data = {}, globalConfig = {}) => client.delete(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
}).catch((error) => {
// Precognition can often cancel in-flight requests. Instead of
// throwing an exception for this expected behaviour, we silently
// discard cancelled request errors to not flood the console with
// expected errors.
if (isCancel(error)) {
return null
}

// Unlike other status codes, 422 responses are expected and
// regularly occur with Precognition requests. We silently ignore
// these so we do not flood the console with expected errors. If
// needed, they can be intercepted by the `onValidationError`
// config option instead.
if (isAxiosError(error) && error.response?.status === 422) {
return null
}

return Promise.reject(error)
})
.catch(error => isAxiosError(error) ? null : Promise.reject(error))
}, debounceTimeoutDuration, { leading: true, trailing: true })

/**
Expand All @@ -188,11 +206,24 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor
/**
* Resolve the configuration.
*/
const resolveConfig = (config: ValidationConfig, data: Record<string, unknown> = {}): Config => {
const resolveConfig = (
globalConfig: ValidationConfig,
instanceConfig: ValidationConfig,
data: Record<string, unknown> = {}
): Config => {
const config: ValidationConfig = {
...globalConfig,
...instanceConfig,
}

const validate = Array.from(config.validate ?? touched)

return {
...config,
...instanceConfig,
// Axios has special rules for merging global and local config. We
// use their merge function here to make sure things like headers
// merge in an expected way.
...mergeConfig(globalConfig, instanceConfig),
validate,
timeout: config.timeout ?? 5000,
onValidationError: (response, axiosError) => {
Expand All @@ -205,8 +236,12 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor
? config.onValidationError(response, axiosError)
: Promise.reject(axiosError)
},
onSuccess: () => {
onSuccess: (response) => {
setValidated([...validated, ...validate]).forEach(listener => listener())

return config.onSuccess
? config.onSuccess(response)
: response
},
onPrecognitionSuccess: (response) => {
[
Expand All @@ -219,11 +254,11 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor
: response
},
onBefore: () => {
const beforeValidationResult = (config.onBeforeValidation ?? ((previous, next) => {
return ! isEqual(previous, next)
}))({ data, touched }, { data: oldData, touched: oldTouched })
const beforeValidationHandler = config.onBeforeValidation ?? ((newRequest, oldRequest) => {
return newRequest.touched.length > 0 && ! isEqual(newRequest, oldRequest)
})

if (beforeValidationResult === false) {
if (beforeValidationHandler({ data, touched }, { data: oldData, touched: oldTouched }) === false) {
return false
}

Expand Down Expand Up @@ -261,9 +296,9 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor
/**
* Validate the given input.
*/
const validate = (name?: string|NamedInputEvent, value?: unknown) => {
const validate = (name?: string|NamedInputEvent, value?: unknown, config?: Config): void => {
if (typeof name === 'undefined') {
validator()
validator(config ?? {})

return
}
Expand All @@ -280,11 +315,7 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor
setTouched([name, ...touched]).forEach(listener => listener())
}

if (touched.length === 0) {
return
}

validator()
validator(config ?? {})
}

/**
Expand All @@ -299,8 +330,13 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor
*/
const form: TValidator = {
touched: () => touched,
validate(input, value) {
validate(input, value)
validate(name, value, config) {
if (typeof name === 'object' && ! ('target' in name)) {
config = name
name = value = undefined
}

validate(name, value, config)

return form
},
Expand Down
40 changes: 20 additions & 20 deletions packages/core/tests/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ it('does not consider 204 response to be success without "Precognition-Success"
},
onSuccess() {
responseSuccess = true
}
},
})

expect(precognitionSucess).toBe(false)
Expand All @@ -229,7 +229,7 @@ it('throws an error if the precognition header is not present on an error respon
expect.assertions(2)

axios.request.mockRejectedValueOnce({ response: { status: 500 } })
axios.isAxiosError.mockReturnValue(true)
axios.isAxiosError.mockReturnValueOnce(true)

await client.get('https://laravel.com').catch((e) => {
expect(e).toBeInstanceOf(Error)
Expand All @@ -249,7 +249,7 @@ it('returns a non-axios error via a rejected promise', async () => {
})
})

it('returns a canceled request error va rejected promise', async () => {
it('returns a cancelled request error va rejected promise', async () => {
expect.assertions(1)

const error = { expected: 'error' }
Expand Down Expand Up @@ -519,29 +519,29 @@ it('overrides the request data with the config data', async () => {
})

await client.get('https://laravel.com', { expected: false }, {
data: { expected: true }
data: { expected: true },
})
expect(config.data).toEqual({ expected: true})
expect(config.data).toEqual({ expected: true })

await client.post('https://laravel.com', { expected: false }, {
data: { expected: true }
data: { expected: true },
})
expect(config.data).toEqual({ expected: true})
expect(config.data).toEqual({ expected: true })

await client.patch('https://laravel.com', { expected: false }, {
data: { expected: true }
data: { expected: true },
})
expect(config.data).toEqual({ expected: true})
expect(config.data).toEqual({ expected: true })

await client.put('https://laravel.com', { expected: false }, {
data: { expected: true }
data: { expected: true },
})
expect(config.data).toEqual({ expected: true})
expect(config.data).toEqual({ expected: true })

await client.delete('https://laravel.com', { expected: false }, {
data: { expected: true }
data: { expected: true },
})
expect(config.data).toEqual({ expected: true})
expect(config.data).toEqual({ expected: true })
})

it('merges request data with config data', async () => {
Expand All @@ -554,28 +554,28 @@ it('merges request data with config data', async () => {
})

await client.get('https://laravel.com', { request: true }, {
data: { config: true }
data: { config: true },
})
expect(config.data).toEqual({ config: true })
expect(config.params).toEqual({ request: true })

await client.post('https://laravel.com', { request: true }, {
data: { config: true }
data: { config: true },
})
expect(config.data).toEqual({ request: true, config: true })

await client.patch('https://laravel.com', { request: true }, {
data: { config: true }
data: { config: true },
})
expect(config.data).toEqual({ request: true, config: true })

await client.put('https://laravel.com', { request: true }, {
data: { config: true }
data: { config: true },
})
expect(config.data).toEqual({ request: true, config: true })

await client.delete('https://laravel.com', { request: true }, {
data: { config: true }
data: { config: true },
})
expect(config.data).toEqual({ config: true })
expect(config.params).toEqual({ request: true })
Expand All @@ -591,13 +591,13 @@ it('merges request data with config params for get and delete requests', async (
})

await client.get('https://laravel.com', { data: true }, {
params: { param: true }
params: { param: true },
})
expect(config.params).toEqual({ data: true, param: true })
expect(config.data).toBeUndefined()

await client.delete('https://laravel.com', { data: true }, {
params: { param: true }
params: { param: true },
})
expect(config.params).toEqual({ data: true, param: true })
expect(config.data).toBeUndefined()
Expand Down
Loading

0 comments on commit af7b35f

Please sign in to comment.