diff --git a/package.json b/package.json index ff11e35..b1825c7 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,12 @@ "packages/react-inertia", "packages/vue", "packages/vue-inertia", - "packages/alpine" + "packages/alpine", + "packages/svelte", + "packages/svelte-inertia" ], "scripts": { - "watch": "npx concurrently \"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\" --names=core,react,react-inertia,vue,vue-inertia,alpine", + "watch": "npx concurrently \"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\" \"npm run watch --workspace=packages/svelte\" \"npm run watch --workspace=packages/svelte-inertia\" --names=core,react,react-inertia,vue,vue-inertia,alpine,svelte,svelte-inertia", "build": "npm run build --workspaces", "link": "npm link --workspaces", "typeCheck": "npm run typeCheck --workspaces", diff --git a/packages/svelte-inertia/.gitignore b/packages/svelte-inertia/.gitignore new file mode 100644 index 0000000..c925c21 --- /dev/null +++ b/packages/svelte-inertia/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/svelte-inertia/LICENSE.md b/packages/svelte-inertia/LICENSE.md new file mode 100644 index 0000000..79810c8 --- /dev/null +++ b/packages/svelte-inertia/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/svelte-inertia/README.md b/packages/svelte-inertia/README.md new file mode 100644 index 0000000..0b20012 --- /dev/null +++ b/packages/svelte-inertia/README.md @@ -0,0 +1,31 @@ +# Laravel Precognition + +Test Status +Build Status +Total Downloads +Latest Stable Version +License + +## Introduction + +Laravel Precognition allows you to anticipate the outcome of a future HTTP request. One of the primary use cases of Precognition is the ability to provide "live" validation in your frontend application. + +## Official Documentation + +Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition). + +## Contributing + +Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities. + +## License + +Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md). diff --git a/packages/svelte-inertia/package.json b/packages/svelte-inertia/package.json new file mode 100644 index 0000000..2289a42 --- /dev/null +++ b/packages/svelte-inertia/package.json @@ -0,0 +1,53 @@ +{ + "name": "laravel-precognition-svelte-inertia", + "version": "0.0.1", + "description": "Laravel Precognition (Svelte w/ Inertia).", + "keywords": [ + "laravel", + "precognition", + "svelte", + "inertia" + ], + "homepage": "https://github.com/laravel/precognition", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/laravel/precognition" + }, + "license": "MIT", + "author": "Laravel", + "main": "dist/index.js", + "files": [ + "dist", + "!dist/**/*.test.*", + "!dist/**/*.spec.*" + ], + "svelte": "./dist/index.svelte.js", + "types": "./dist/index.svelte.d.ts", + "exports": { + ".": { + "types": "./dist/index.svelte.d.ts", + "svelte": "./dist/index.svelte.js" + } + }, + "scripts": { + "watch": "rm -rf dist && tsc --watch --preserveWatchOutput", + "build": "rm -rf dist && tsc", + "typeCheck": "tsc --noEmit", + "prepublishOnly": "npm run build", + "version": "npm pkg set dependencies.laravel-precognition=$npm_package_version" + }, + "peerDependencies": { + "@inertiajs/svelte": "^1.0.0 || ^2.0.0", + "svelte": "^5.0.0" + }, + "dependencies": { + "laravel-precognition": "0.7.2", + "laravel-precognition-svelte": "0.0.1", + "lodash-es": "^4.17.21" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "typescript": "^5.0.0" + } +} diff --git a/packages/svelte-inertia/src/index.svelte.ts b/packages/svelte-inertia/src/index.svelte.ts new file mode 100644 index 0000000..e409d87 --- /dev/null +++ b/packages/svelte-inertia/src/index.svelte.ts @@ -0,0 +1,185 @@ +import { FormDataConvertible, VisitOptions } from '@inertiajs/core' +import { useForm as useInertiaForm } from '@inertiajs/svelte' +import { + client, + NamedInputEvent, + RequestMethod, + resolveMethod, + resolveUrl, + SimpleValidationErrors, + toSimpleValidationErrors, + ValidationConfig, + ValidationErrors, +} from 'laravel-precognition' +import { useForm as usePrecognitiveForm } from 'laravel-precognition-svelte' +import { fromStore, get, writable, Writable } from 'svelte/store' +import { Form } from './types' + +export { client } + +export const useForm = >( + method: RequestMethod | (() => RequestMethod), + url: string | (() => string), + inputs: Data, + config: ValidationConfig = {}, +): Writable> => { + /** + * The Inertia form. + */ + const inertiaForm = useInertiaForm(inputs) + + /** + * The React form. + */ + const precognitiveForm = usePrecognitiveForm(method, url, inputs, config) + + /** + * The transform function. + */ + let transformer: (data: Data) => Data = (data) => data + + /** + * Patch the form. + */ + const form = writable({ + ...fromStore(inertiaForm).current, + validating: false, + touched: precognitiveForm.touched, + touch(name: Array | string | NamedInputEvent) { + precognitiveForm.touch(name) + + return get(form) + }, + valid: precognitiveForm.valid, + invalid: precognitiveForm.invalid, + errors: precognitiveForm.errors, + clearErrors(...names: string[]) { + form.update((prev) => { + names.forEach((name) => delete prev.errors[name]) + + return prev + }) + + if (names.length === 0) { + precognitiveForm.setErrors({}) + } else { + names.forEach(precognitiveForm.forgetError) + } + + return get(form) + }, + reset(...names: string[]) { + // inertiaReset(...names) + + precognitiveForm.reset(...names) + }, + setErrors(errors: SimpleValidationErrors | ValidationErrors) { + // @ts-ignore + precognitiveForm.setErrors(errors) + + return get(form) + }, + setError(key: any, value?: any) { + // @ts-ignore + form.update((prev) => ({ + ...prev, + errors: { ...prev.errors, ...(typeof value === 'undefined' ? key : { [key]: value }) }, + })) + + return get(form) + }, + forgetError(name: string | NamedInputEvent) { + precognitiveForm.forgetError(name) + + return get(form) + }, + transform(callback: (data: Data) => Data) { + form.update((prev) => { + prev.transform(callback) + + return prev + }) + + transformer = callback + + return get(form) + }, + validate(name?: keyof Data | NamedInputEvent | ValidationConfig, config?: ValidationConfig) { + precognitiveForm.setData(transformer(get(form).data())) + + if (typeof name === 'object' && !('target' in name)) { + config = name + name = undefined + } + + if (typeof name === 'undefined') { + precognitiveForm.validate(config) + } else { + precognitiveForm.validate(name, config) + } + + return get(form) + }, + setValidationTimeout(duration: number) { + precognitiveForm.setValidationTimeout(duration) + + return get(form) + }, + validateFiles() { + precognitiveForm.validateFiles() + + return get(form) + }, + submit( + submitMethod: RequestMethod | Partial = {}, + submitUrl?: string, + submitOptions?: Partial, + ): void { + if (typeof submitMethod !== 'string') { + submitOptions = submitMethod + submitUrl = resolveUrl(url) + submitMethod = resolveMethod(method) + } + + inertiaForm.update((prev) => ({ + ...prev, + ...get(form).data(), + })) + + get(inertiaForm).submit(submitMethod, submitUrl!, { + ...submitOptions, + onError: (errors: SimpleValidationErrors): any => { + precognitiveForm.validator().setErrors(errors) + + if (submitOptions?.onError) { + return submitOptions.onError(errors) + } + }, + }) + }, + validator: precognitiveForm.validator, + }) + + /** + * Setup event listeners. + */ + form.subscribe((value) => precognitiveForm.setData(value.data())) + + precognitiveForm + .validator() + .on('errorsChanged', () => { + // @ts-ignore + form.update((prev) => ({ + ...prev, + errors: toSimpleValidationErrors(precognitiveForm.validator().errors()), + })) + }) + .on('validatingChanged', () => { + form.update((prev) => ({ + ...prev, + validating: precognitiveForm.validating, + })) + }) + + return form +} diff --git a/packages/svelte-inertia/src/types.ts b/packages/svelte-inertia/src/types.ts new file mode 100644 index 0000000..e990bc8 --- /dev/null +++ b/packages/svelte-inertia/src/types.ts @@ -0,0 +1,49 @@ +import { VisitOptions } from '@inertiajs/core' +import { InertiaForm } from '@inertiajs/svelte' +import { + client, + RequestMethod, + SimpleValidationErrors, + ValidationErrors, + type NamedInputEvent, + type ValidationConfig, +} from 'laravel-precognition' +import { Form as PrecognitiveForm } from 'laravel-precognition-svelte/dist/types' +export { client } + +type RedefinedProperties = + | 'setErrors' + | 'touch' + | 'forgetError' + | 'setValidationTimeout' + | 'submit' + | 'reset' + | 'validateFiles' + | 'setData' + | 'validate' + +export type Form> = Omit, RedefinedProperties> & + Omit, RedefinedProperties> & { + setErrors(errors: SimpleValidationErrors | ValidationErrors): Form + touch(name: Array | string | NamedInputEvent): Form + forgetError(string: keyof Data | NamedInputEvent): Form + setValidationTimeout(duration: number): Form + submit(config?: Partial): void + submit(method: RequestMethod, url: string, options?: Omit): void + reset(...keys: (keyof Partial)[]): void + validateFiles(): Form + validate(name?: (keyof Data | NamedInputEvent) | ValidationConfig, config?: ValidationConfig): Form + valid: (name: keyof Data) => boolean + invalid: (name: keyof Data) => boolean + } + +export type FormDataConvertible = + | Array + | { [key: string]: FormDataConvertible } + | Blob + | FormDataEntryValue + | Date + | boolean + | number + | null + | undefined diff --git a/packages/svelte-inertia/tsconfig.json b/packages/svelte-inertia/tsconfig.json new file mode 100644 index 0000000..1020288 --- /dev/null +++ b/packages/svelte-inertia/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "resolveJsonModule": true, + "strict": true, + "declaration": true, + "esModuleInterop": true + }, + "include": [ + "./src/index.svelte.ts" + ] +} diff --git a/packages/svelte/.gitignore b/packages/svelte/.gitignore new file mode 100644 index 0000000..c925c21 --- /dev/null +++ b/packages/svelte/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/svelte/LICENSE.md b/packages/svelte/LICENSE.md new file mode 100644 index 0000000..79810c8 --- /dev/null +++ b/packages/svelte/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/svelte/README.md b/packages/svelte/README.md new file mode 100644 index 0000000..0b20012 --- /dev/null +++ b/packages/svelte/README.md @@ -0,0 +1,31 @@ +# Laravel Precognition + +Test Status +Build Status +Total Downloads +Latest Stable Version +License + +## Introduction + +Laravel Precognition allows you to anticipate the outcome of a future HTTP request. One of the primary use cases of Precognition is the ability to provide "live" validation in your frontend application. + +## Official Documentation + +Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition). + +## Contributing + +Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities. + +## License + +Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md). diff --git a/packages/svelte/package.json b/packages/svelte/package.json new file mode 100644 index 0000000..866351e --- /dev/null +++ b/packages/svelte/package.json @@ -0,0 +1,50 @@ +{ + "name": "laravel-precognition-svelte", + "version": "0.0.1", + "description": "Laravel Precognition (Svelte).", + "keywords": [ + "laravel", + "precognition", + "svelte" + ], + "homepage": "https://github.com/laravel/precognition", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/laravel/precognition" + }, + "license": "MIT", + "author": "Laravel", + "main": "dist/index.js", + "files": [ + "dist", + "!dist/**/*.test.*", + "!dist/**/*.spec.*" + ], + "svelte": "./dist/index.svelte.js", + "types": "./dist/index.svelte.d.ts", + "exports": { + ".": { + "types": "./dist/index.svelte.d.ts", + "svelte": "./dist/index.svelte.js" + } + }, + "scripts": { + "watch": "rm -rf dist && tsc --watch --preserveWatchOutput", + "build": "rm -rf dist && tsc", + "typeCheck": "tsc --noEmit", + "prepublishOnly": "npm run build", + "version": "npm pkg set dependencies.laravel-precognition=$npm_package_version" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "dependencies": { + "laravel-precognition": "0.7.2", + "lodash-es": "^4.17.21" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "typescript": "^5.0.0" + } +} diff --git a/packages/svelte/src/index.svelte.ts b/packages/svelte/src/index.svelte.ts new file mode 100644 index 0000000..6d5d759 --- /dev/null +++ b/packages/svelte/src/index.svelte.ts @@ -0,0 +1,228 @@ +import { + Config, + RequestMethod, + ValidationConfig, + client, + createValidator, + resolveMethod, + resolveName, + resolveUrl, + toSimpleValidationErrors, +} from 'laravel-precognition' +import { cloneDeep, get, set } from 'lodash-es' +import { Form } from './types.js' + +export { client } + +export const useForm = >( + method: RequestMethod | (() => RequestMethod), + url: string | (() => string), + inputs: Data, + config: ValidationConfig = {}, +): Data & Form => { + /** + * The original data. + */ + const originalData = cloneDeep(inputs) + + /** + * The original input names. + */ + const originalInputs: (keyof Data)[] = Object.keys(originalData) + + /** + * Reactive valid state. + */ + // @ts-ignore + let valid = $state<(keyof Data)[]>([]) + + /** + * Reactive touched state. + */ + // @ts-ignore + let touched = $state<(keyof Partial)[]>([]) + + /** + * Reactive errors. + */ + // @ts-ignore + let errors = $state>({}) + + /** + * Reactive hasErrors. + */ + // @ts-ignore + const hasErrors = $derived(Object.keys(errors).length > 0) + + /** + * Reactive Validating. + */ + // @ts-ignore + let validating = $state(false) + + /** + * Reactive Processing. + */ + // @ts-ignore + let processing = $state(false) + + /** + * Reactive Data state + */ + // @ts-ignore + const data = $state(cloneDeep(originalData)) + + /** + * The validator instance. + */ + const validator = createValidator( + (client) => client[resolveMethod(method)](resolveUrl(url), form.data(), config), + originalData, + ) + .on('validatingChanged', () => { + validating = validator.validating() + }) + .on('validatedChanged', () => { + valid = validator.valid() + }) + .on('touchedChanged', () => { + touched = validator.touched() + }) + .on('errorsChanged', () => { + errors = toSimpleValidationErrors(validator.errors()) as Record + valid = validator.valid() + }) + + /** + * Resolve the config for a form submission. + */ + const resolveSubmitConfig = (config: Config): Config => ({ + ...config, + precognitive: false, + onStart: () => { + processing = true + + config.onStart?.() + }, + onFinish: () => { + processing = false + + config.onFinish?.() + }, + onValidationError: (response: any, error: any) => { + validator.setErrors(response.data.errors) + + return config.onValidationError ? config.onValidationError(response) : Promise.reject(error) + }, + }) + + /** + * Create a new form instance. + */ + const form: Data & Form = { + ...cloneDeep(originalData), + data() { + // @ts-ignore + return $state.snapshot(data) as Data + }, + setData(newData: Data & Record) { + Object.keys(newData).forEach((input) => { + data[input] = newData[input] + }) + return form + }, + touched(name: keyof Data) { + return touched.includes(name) + }, + touch(name: keyof Data & string) { + validator.touch(name) + + return form + }, + validate(name: keyof Data | undefined, config: Config) { + if (typeof name === 'object' && !('target' in name)) { + config = name + name = undefined + } + + if (typeof name === 'undefined') { + validator.validate(config) + } else { + name = resolveName(name as string) + + validator.validate(name, get(data, name), config) + } + + return form + }, + get validating() { + return validating + }, + valid(name: keyof Data) { + return valid.includes(resolveName(name as string)) + }, + invalid(name: keyof Data) { + return typeof form.errors[name] !== 'undefined' + }, + get errors() { + return errors + }, + get hasErrors() { + return hasErrors + }, + setErrors(newErrors: any) { + validator.setErrors(newErrors) + return form + }, + forgetError(name: keyof Data) { + validator.forgetError(name as string) + + return form + }, + reset(...names: (keyof Data & string)[]) { + const original = cloneDeep(originalData) + + if (names.length === 0) { + originalInputs.forEach((name) => (data[name] = original[name])) + } else { + names.forEach((name: keyof Data) => set(data, name, get(original, name))) + } + + validator.reset(...names) + + return form + }, + setValidationTimeout(duration: number) { + validator.setTimeout(duration) + + return form + }, + get processing() { + return processing + }, + async submit(config = {}) { + return client[resolveMethod(method)](resolveUrl(url), form.data(), resolveSubmitConfig(config)) + }, + validateFiles() { + validator.validateFiles() + + return form + }, + validator() { + return validator + }, + } + + ;(Object.keys(data) as Array).forEach((key) => { + Object.defineProperty(form, key, { + get() { + return data[key] + }, + set(value: Data[keyof Data]) { + data[key] = value + }, + }) + }) + + return form as Data & Form +} diff --git a/packages/svelte/src/types.ts b/packages/svelte/src/types.ts new file mode 100644 index 0000000..07a66fc --- /dev/null +++ b/packages/svelte/src/types.ts @@ -0,0 +1,22 @@ +import { client, type Config, type NamedInputEvent, type ValidationConfig, type Validator } from 'laravel-precognition' +export { client } +export interface Form> { + processing: boolean + validating: boolean + errors: Partial> + hasErrors: boolean + touched(name: keyof Data): boolean + touch(name: string | NamedInputEvent | Array): Data & Form + data: () => Data + setData(data: Record): Data & Form + valid(name: keyof Data): boolean + invalid(name: keyof Data): boolean + validate(name?: (keyof Data | NamedInputEvent) | ValidationConfig, config?: ValidationConfig): Data & Form + setErrors(errors: Partial>): Data & Form + forgetError(string: keyof Data | NamedInputEvent): Data & Form + setValidationTimeout(duration: number): Data & Form + submit(config?: Config): Promise + reset(...keys: (keyof Partial)[]): Data & Form + validateFiles(): Data & Form + validator(): Validator +} diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json new file mode 100644 index 0000000..1020288 --- /dev/null +++ b/packages/svelte/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "resolveJsonModule": true, + "strict": true, + "declaration": true, + "esModuleInterop": true + }, + "include": [ + "./src/index.svelte.ts" + ] +}