Skip to content

Commit 383f31b

Browse files
authored
Alpine (#3)
* wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip
1 parent d3ea7ea commit 383f31b

File tree

8 files changed

+342
-4
lines changed

8 files changed

+342
-4
lines changed

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
"packages/react",
66
"packages/react-inertia",
77
"packages/vue",
8-
"packages/vue-inertia"
8+
"packages/vue-inertia",
9+
"packages/alpine"
910
],
1011
"scripts": {
12+
"build": "npm run build --workspaces",
13+
"link": "npm link --workspaces",
1114
"lint": "eslint --ext .ts --ignore-pattern dist ./packages",
1215
"lint:fix": "eslint --fix --ext .ts --ignore-pattern dist ./packages",
13-
"build": "npm run build --workspaces",
14-
"test": "npm run test --workspaces --if-present",
15-
"link": "npm link --workspaces"
16+
"test": "npm run test --workspaces --if-present"
1617
},
1718
"devDependencies": {
1819
"@typescript-eslint/eslint-plugin": "^5.21.0",

packages/alpine/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/dist
2+
/node_modules

packages/alpine/LICENSE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) Taylor Otwell
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

packages/alpine/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Laravel Precognition
2+
3+
<a href="https://github.com/laravel/precognition/actions"><img src="https://github.com/laravel/precognition/workflows/tests/badge.svg" alt="Test Status"></a>
4+
<a href="https://github.com/laravel/precognition/actions"><img src="https://github.com/laravel/precognition/workflows/build/badge.svg" alt="Build Status"></a>
5+
<a href="https://www.npmjs.com/package/laravel-precognition"><img src="https://img.shields.io/npm/dt/laravel-precognition" alt="Total Downloads"></a>
6+
<a href="https://www.npmjs.com/package/laravel-precognition"><img src="https://img.shields.io/npm/v/laravel-precognition" alt="Latest Stable Version"></a>
7+
<a href="https://www.npmjs.com/package/laravel-precognition"><img src="https://img.shields.io/npm/l/laravel-precognition" alt="License"></a>
8+
9+
## Introduction
10+
11+
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.
12+
13+
## Official Documentation
14+
15+
Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition).
16+
17+
## Contributing
18+
19+
Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
20+
21+
## Code of Conduct
22+
23+
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).
24+
25+
## Security Vulnerabilities
26+
27+
Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities.
28+
29+
## License
30+
31+
Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md).

packages/alpine/package.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "laravel-precognition-alpine",
3+
"version": "0.2.1",
4+
"description": "Laravel Precognition (Alpine).",
5+
"keywords": [
6+
"laravel",
7+
"precognition",
8+
"alpine"
9+
],
10+
"homepage": "https://github.com/laravel/precognition",
11+
"type": "module",
12+
"repository": {
13+
"type": "git",
14+
"url": "https://github.com/laravel/precognition"
15+
},
16+
"license": "MIT",
17+
"author": "Laravel",
18+
"main": "dist/index.js",
19+
"files": [
20+
"/dist"
21+
],
22+
"scripts": {
23+
"build": "rm -rf dist && tsc",
24+
"prepublishOnly": "npm run build"
25+
},
26+
"engines": {
27+
"node": ">=14"
28+
},
29+
"peerDependencies": {
30+
"alpinejs": "^3.12.1"
31+
},
32+
"dependencies": {
33+
"laravel-precognition": "^0.2.0",
34+
"lodash.clonedeep": "^4.5.0",
35+
"lodash.get": "^4.4.2",
36+
"lodash.set": "^4.3.2"
37+
},
38+
"devDependencies": {
39+
"@types/alpinejs": "^3.7.1",
40+
"@types/lodash.clonedeep": "^4.5.7",
41+
"@types/lodash.get": "^4.4.7",
42+
"@types/lodash.set": "^4.3.7",
43+
"typescript": "^5.0.0"
44+
}
45+
}

packages/alpine/src/index.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { Alpine as TAlpine } from 'alpinejs'
2+
import { client, Config, createValidator, RequestMethod, resolveName, toSimpleValidationErrors, ValidationConfig } from 'laravel-precognition'
3+
import cloneDeep from 'lodash.clonedeep'
4+
import get from 'lodash.get'
5+
import set from 'lodash.set'
6+
import { Form } from './types'
7+
8+
export default function (Alpine: TAlpine) {
9+
Alpine.magic('form', (el) => <Data extends Record<string, unknown>>(method: RequestMethod, url: string, inputs: Data, config: ValidationConfig = {}): Data&Form<Data> => {
10+
// @ts-expect-error
11+
method = method.toLowerCase()
12+
13+
syncWithDom(el, method, url)
14+
15+
/**
16+
* The original data.
17+
*/
18+
const originalData = cloneDeep(inputs)
19+
20+
/**
21+
* The original input names.
22+
*/
23+
const originalInputs = Object.keys(originalData)
24+
25+
/**
26+
* Internal reactive state.
27+
*/
28+
const state: {
29+
touched: string[],
30+
valid: string[],
31+
} = Alpine.reactive({
32+
touched: [],
33+
valid: [],
34+
})
35+
36+
/**
37+
* The validator instance.
38+
*/
39+
const validator = createValidator(client => client[method](url, form.data(), config), originalData)
40+
.on('validatingChanged', () => {
41+
form.validating = validator.validating()
42+
})
43+
.on('touchedChanged', () => {
44+
state.valid = validator.valid()
45+
46+
state.touched = validator.touched()
47+
})
48+
.on('errorsChanged', () => {
49+
state.valid = validator.valid()
50+
51+
form.hasErrors = validator.hasErrors()
52+
53+
form.errors = toSimpleValidationErrors(validator.errors())
54+
})
55+
56+
/**
57+
* Resolve the config for a form submission.
58+
*/
59+
const resolveSubmitConfig = (config: Config): Config => ({
60+
...config,
61+
precognitive: false,
62+
onStart: () => {
63+
form.processing = true;
64+
65+
(config.onStart ?? (() => null))()
66+
},
67+
onFinish: () => {
68+
form.processing = false;
69+
70+
(config.onFinish ?? (() => null))()
71+
},
72+
onValidationError: (response, error) => {
73+
validator.setErrors(response.data.errors)
74+
75+
return config.onValidationError
76+
? config.onValidationError(response)
77+
: Promise.reject(error)
78+
},
79+
})
80+
81+
/**
82+
* Create a new form instance.
83+
*/
84+
const createForm = (): Data&Form<Data> => ({
85+
...cloneDeep(inputs),
86+
data() {
87+
return originalInputs.reduce((carry, name) => ({
88+
...carry,
89+
[name]: cloneDeep(form[name]),
90+
}), {}) as Data
91+
},
92+
touched(name) {
93+
return state.touched.includes(name)
94+
},
95+
validate(name) {
96+
name = resolveName(name)
97+
98+
validator.validate(name, get(form.data(), name))
99+
100+
return form
101+
},
102+
validating: false,
103+
valid(name) {
104+
return state.valid.includes(name)
105+
},
106+
invalid(name) {
107+
return typeof form.errors[name] !== 'undefined'
108+
},
109+
errors: {},
110+
hasErrors: false,
111+
setErrors(errors) {
112+
validator.setErrors(errors)
113+
114+
return form
115+
},
116+
reset(...names) {
117+
const original = cloneDeep(originalData)
118+
119+
if (names.length === 0) {
120+
// @ts-expect-error
121+
originalInputs.forEach(name => (form[name] = original[name]))
122+
} else {
123+
names.forEach(name => set(form, name, get(original, name)))
124+
}
125+
126+
validator.reset(...names)
127+
128+
return form
129+
},
130+
setValidationTimeout(duration) {
131+
validator.setTimeout(duration)
132+
133+
return form
134+
},
135+
processing: false,
136+
async submit(config = {}) {
137+
return client[method](url, form.data(), resolveSubmitConfig(config))
138+
},
139+
})
140+
141+
/**
142+
* The form instance.
143+
*/
144+
const form = Alpine.reactive(createForm()) as Data&Form<Data>
145+
146+
return form
147+
})
148+
}
149+
150+
/**
151+
* Sync the DOM form with the Precognitive form.
152+
*/
153+
const syncWithDom = (el: Node, method: RequestMethod, url: string): void => {
154+
if (! (el instanceof Element && el.nodeName === 'FORM')) {
155+
return
156+
}
157+
158+
syncSyntheticMethodInput(el, method)
159+
syncMethodAttribute(el, method)
160+
syncActionAttribute(el, url)
161+
}
162+
163+
/**
164+
* Sync the form's "method" attribute.
165+
*/
166+
const syncMethodAttribute = (el: Element, method: RequestMethod) => {
167+
if (method !== 'get' && ! el.hasAttribute('method')) {
168+
el.setAttribute('method', 'POST')
169+
}
170+
}
171+
172+
/**
173+
* Sync the form's "action" attribute.
174+
*/
175+
const syncActionAttribute = (el: Element, url: string) => {
176+
if (! el.hasAttribute('action')) {
177+
el.setAttribute('action', url)
178+
}
179+
}
180+
181+
/**
182+
* Sync the form's sythentic "method" input.
183+
*/
184+
const syncSyntheticMethodInput = (el: Element, method: RequestMethod) => {
185+
if (['get', 'post'].includes(method)) {
186+
return
187+
}
188+
189+
const existing = el.querySelector('input[type="hidden"][name="_method"]')
190+
191+
if (existing !== null) {
192+
return
193+
}
194+
195+
console.log('here')
196+
197+
const input = el.insertAdjacentElement('afterbegin', document.createElement('input'))
198+
199+
if (input === null) {
200+
return
201+
}
202+
203+
input.setAttribute('type', 'hidden')
204+
input.setAttribute('name', '_method')
205+
input.setAttribute('value', method.toUpperCase())
206+
}

packages/alpine/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Config, NamedInputEvent, SimpleValidationErrors, ValidationErrors, Validator } from 'laravel-precognition'
2+
3+
export interface Form<Data extends Record<string, unknown>> {
4+
processing: boolean,
5+
validating: boolean,
6+
touched(name: string): boolean,
7+
data(): Data,
8+
errors: Record<string, string>,
9+
hasErrors: boolean,
10+
valid(name: string): boolean,
11+
invalid(name: string): boolean,
12+
validate(name: string|NamedInputEvent): Data&Form<Data>,
13+
setErrors(errors: SimpleValidationErrors|ValidationErrors): Data&Form<Data>
14+
setValidationTimeout(duration: number): Data&Form<Data>,
15+
submit(config?: Config): Promise<unknown>,
16+
reset(...keys: string[]): Data&Form<Data>,
17+
}

packages/alpine/tsconfig.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"outDir": "./dist",
4+
"target": "ES2020",
5+
"module": "ES2020",
6+
"moduleResolution": "node",
7+
"resolveJsonModule": true,
8+
"strict": true,
9+
"declaration": true,
10+
"esModuleInterop": true
11+
},
12+
"include": [
13+
"./src/index.ts"
14+
]
15+
}

0 commit comments

Comments
 (0)