Skip to content

Commit 6624ff6

Browse files
committed
Add basic Regle Frontend Validation
1 parent 6f66595 commit 6624ff6

File tree

12 files changed

+741
-433
lines changed

12 files changed

+741
-433
lines changed

package-lock.json

Lines changed: 485 additions & 409 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sprinkle-account/app/assets/composables/useUserProfileEditApi.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
import { ref } from 'vue'
22
import axios from 'axios'
3+
import { useRegle } from '@regle/core'
34
import { Severity } from '@userfrosting/sprinkle-core/interfaces'
45
import { useAlertsStore, useTranslator } from '@userfrosting/sprinkle-core/stores'
56
import type { ApiResponse, ApiErrorResponse } from '@userfrosting/sprinkle-core/interfaces'
67
import type { ProfileEditRequest } from '../interfaces'
7-
8-
// TODO : Add validation
9-
// 'schema://requests/profile-settings.yaml'
8+
import { useRuleSchemaAdapter } from '@userfrosting/sprinkle-core/composables'
9+
import schemaFile from '../../schema/requests/profile-settings.yaml?raw'
1010

1111
/**
1212
* API Composable
1313
*/
1414
export function useUserProfileEditApi() {
1515
const apiLoading = ref<Boolean>(false)
1616
const apiError = ref<ApiErrorResponse | null>(null)
17+
const formData = ref<ProfileEditRequest>({
18+
first_name: '',
19+
last_name: '',
20+
locale: ''
21+
})
22+
23+
// Load the schema and set up the validator
24+
const { r$ } = useRegle(formData, useRuleSchemaAdapter().adapt(schemaFile))
1725

1826
async function submitProfileEdit(data: ProfileEditRequest) {
1927
apiLoading.value = true
@@ -40,5 +48,5 @@ export function useUserProfileEditApi() {
4048
})
4149
}
4250

43-
return { submitProfileEdit, apiLoading, apiError }
51+
return { submitProfileEdit, apiLoading, apiError, formData, r$ }
4452
}

packages/sprinkle-account/app/assets/tests/stores/useAuthStore.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,9 @@ const testUser: UserDataInterface = {
2626
}
2727

2828
// Mock useTranslator load function
29-
const loadTranslator = vi.fn()
3029
vi.mock('@userfrosting/sprinkle-core/stores', () => ({
3130
useTranslator: () => ({
32-
load: loadTranslator
31+
load: vi.fn()
3332
}),
3433
useConfigStore: () => ({
3534
get: vi.fn().mockReturnValue(false)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { useSprunjer } from './useSprunjer'
22
export { useCsrf } from './useCsrf'
33
export { useAxiosInterceptor } from './useAxiosInterceptor'
4+
export { useRuleSchemaAdapter } from './useRuleSchemaAdapter'
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { parse } from 'yaml'
2+
import { withMessage, required, maxLength, minLength, email } from '@regle/rules'
3+
import { useTranslator } from '../stores'
4+
5+
export function useRuleSchemaAdapter() {
6+
/**
7+
* Parse the YAML schema string into a JavaScript object.
8+
*
9+
* @param rawSchema The YAML schema string to parse.
10+
* @returns RuleSchema The Regle schema object.
11+
*/
12+
function adapt(rawSchema: string) {
13+
// The YAML data parsed to a JavaScript object
14+
const sourceSchema = parse(rawSchema)
15+
16+
// The Regle schema object to be returned
17+
const regleSchema: any = {}
18+
19+
// Iterate over each field in the schema
20+
for (const field in sourceSchema) {
21+
// Check if the field is a direct property of the sourceSchema object
22+
if (Object.prototype.hasOwnProperty.call(sourceSchema, field)) {
23+
// Get the field rules from the source schema
24+
const schemaFieldRules = sourceSchema[field]?.validators || {}
25+
26+
// The returned regle rules for the field
27+
const regleRules: Record<string, any> = {}
28+
29+
// Iterate over each rule in the source schema field rules
30+
for (const key of Object.keys(schemaFieldRules)) {
31+
adaptRule(key, schemaFieldRules, regleRules)
32+
}
33+
34+
regleSchema[field] = regleRules
35+
}
36+
}
37+
38+
return regleSchema
39+
}
40+
41+
function translateMessage(fieldRulesMeta: { message?: string; [key: string]: any }): string {
42+
const { translate } = useTranslator()
43+
44+
// If there's no message, return an empty string
45+
if (!fieldRulesMeta.message) {
46+
return ''
47+
}
48+
49+
// Copy the field rules meta to avoid mutation and remove the message key
50+
const fieldRulesMetaCopy = { ...fieldRulesMeta }
51+
delete fieldRulesMetaCopy.message
52+
53+
return translate(fieldRulesMeta.message, fieldRulesMetaCopy)
54+
}
55+
56+
function adaptRule(key: string, schemaFieldRules: any, regleRules: Record<string, any>) {
57+
// Required
58+
if (key === 'required' && schemaFieldRules.required) {
59+
const message: string = translateMessage(schemaFieldRules.required)
60+
regleRules['required'] = message === '' ? required : withMessage(required, message)
61+
}
62+
63+
// Email
64+
if (key === 'email' && schemaFieldRules.email) {
65+
const message: string = translateMessage(schemaFieldRules.email)
66+
regleRules['email'] = withMessage(email, message)
67+
}
68+
69+
// Length
70+
if (key === 'length' && schemaFieldRules.length) {
71+
if (schemaFieldRules.length.min !== undefined) {
72+
const message: string = translateMessage(schemaFieldRules.length)
73+
regleRules['minLength'] =
74+
message === ''
75+
? minLength(schemaFieldRules.length.min)
76+
: withMessage(minLength(schemaFieldRules.length.min), message)
77+
}
78+
if (schemaFieldRules.length.max !== undefined) {
79+
const message: string = translateMessage(schemaFieldRules.length)
80+
regleRules['maxLength'] =
81+
message === ''
82+
? maxLength(schemaFieldRules.length.max)
83+
: withMessage(maxLength(schemaFieldRules.length.max), message)
84+
}
85+
}
86+
87+
// equals : TODO
88+
// integer : TODO
89+
// matches : TODO
90+
// member_of : TODO
91+
// no_leading_whitespace : TODO
92+
// no_trailing_whitespace : TODO
93+
// not_equals : TODO
94+
// not_matches : TODO
95+
// not_member_of : TODO
96+
// numeric : TODO
97+
// range : TODO
98+
// regex : TODO
99+
// telephone : TODO
100+
// uri : TODO
101+
// username : TODO
102+
}
103+
104+
return { adapt }
105+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, test, expect, vi, afterEach } from 'vitest'
2+
import { useRuleSchemaAdapter } from '../../composables/useRuleSchemaAdapter'
3+
import { useRegle } from '@regle/core'
4+
5+
// Mock the translator store
6+
const translateMock = vi.fn((key: string) => key || '')
7+
vi.mock('@userfrosting/sprinkle-core/stores', () => ({
8+
useTranslator: () => ({
9+
translate: translateMock
10+
})
11+
}))
12+
13+
describe('useRuleSchemaAdapter', () => {
14+
afterEach(() => {
15+
vi.clearAllMocks()
16+
})
17+
18+
test('should parse a basic schema', () => {
19+
const yamlInput = `
20+
foo:
21+
validators:
22+
length:
23+
min: 1
24+
max: 132
25+
required: true
26+
`
27+
28+
const { r$ } = useRegle(
29+
{
30+
foo: ''
31+
},
32+
useRuleSchemaAdapter().adapt(yamlInput)
33+
)
34+
35+
expect(r$.foo).toBeDefined()
36+
expect(r$.foo.$rules.required).toBeDefined()
37+
expect(r$.foo.$rules.minLength).toBeDefined()
38+
expect(r$.foo.$rules.maxLength).toBeDefined()
39+
})
40+
41+
test('should parse a schema with a custom message', () => {
42+
const yamlInput = `
43+
name:
44+
validators:
45+
length:
46+
min: 2
47+
max: 20
48+
required: true
49+
email:
50+
validators:
51+
length:
52+
min: 1
53+
max: 30
54+
email: true
55+
`
56+
57+
const { r$ } = useRegle(
58+
{
59+
name: '',
60+
email: ''
61+
},
62+
useRuleSchemaAdapter().adapt(yamlInput)
63+
)
64+
65+
expect(r$.name).toBeDefined()
66+
expect(r$.name.$rules.required).toBeDefined()
67+
expect(r$.name.$rules.minLength).toBeDefined()
68+
expect(r$.name.$rules.maxLength).toBeDefined()
69+
expect(r$.email).toBeDefined()
70+
expect(r$.email.$rules.required).not.toBeDefined()
71+
expect(r$.email.$rules.minLength).toBeDefined()
72+
expect(r$.email.$rules.maxLength).toBeDefined()
73+
})
74+
75+
test('should parse a basic schema', () => {
76+
const yamlInput = `
77+
first_name:
78+
validators:
79+
required:
80+
label: "&FIRST_NAME"
81+
message: VALIDATE.REQUIRED
82+
last_name:
83+
validators:
84+
required: true
85+
`
86+
87+
const { r$ } = useRegle(
88+
{
89+
first_name: '',
90+
last_name: ''
91+
},
92+
useRuleSchemaAdapter().adapt(yamlInput)
93+
)
94+
95+
// Set translator expectations
96+
expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.REQUIRED', {
97+
label: '&FIRST_NAME'
98+
})
99+
100+
expect(r$.first_name.$rules.required.$message).toBe('VALIDATE.REQUIRED') // Custom message
101+
expect(r$.last_name.$rules.required.$message).toBe('This field is required') // Default message
102+
})
103+
})

packages/sprinkle-core/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@
3434
"app/assets/"
3535
],
3636
"dependencies": {
37+
"@regle/core": "^1.6.0",
38+
"@regle/rules": "^1.6.0",
3739
"dot-prop": "^9.0.0",
38-
"luxon": "^3.5.0"
40+
"luxon": "^3.5.0",
41+
"yaml": "^2.8.0"
3942
},
4043
"peerDependencies": {
4144
"axios": "^1.5.0",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script setup lang="ts">
2+
const { errors } = defineProps<{
3+
errors: string[]
4+
}>()
5+
</script>
6+
7+
<template>
8+
<div class="uk-form-label uk-text-danger" v-for="error of errors" :key="error" v-html="error" />
9+
</template>

packages/theme-pink-cupcake/src/components/Pages/Account/FormUserProfile.vue

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
<script setup lang="ts">
2-
import { ref } from 'vue'
32
import { useConfigStore } from '@userfrosting/sprinkle-core/stores'
43
import { useUserProfileEditApi } from '@userfrosting/sprinkle-account/composables'
54
import { useAuthStore } from '@userfrosting/sprinkle-account/stores'
6-
import type { ProfileEditRequest } from '@userfrosting/sprinkle-account/interfaces'
75
86
/**
97
* Variables - Copy the user data to a reactive variable.
@@ -15,20 +13,23 @@ if (user === null) {
1513
throw new Error('User is null.')
1614
}
1715
18-
const formData = ref<ProfileEditRequest>({
19-
first_name: user.first_name,
20-
last_name: user.last_name,
21-
locale: user.locale
22-
})
23-
2416
function getAvailableLocales(): string[] {
2517
return useConfigStore().get('locales.available')
2618
}
2719
2820
/**
2921
* API - Use the profile edit API.
3022
*/
31-
const { submitProfileEdit } = useUserProfileEditApi()
23+
const { submitProfileEdit, r$, formData } = useUserProfileEditApi()
24+
25+
/**
26+
* Initialize form data with user profile information.
27+
*/
28+
formData.value = {
29+
first_name: user.first_name,
30+
last_name: user.last_name,
31+
locale: user.locale
32+
}
3233
3334
/**
3435
* Methods - Submit the form to the API and handle the response.
@@ -47,13 +48,15 @@ const submitForm = () => {
4748
<font-awesome-icon class="fa-form-icon" icon="edit" fixed-width />
4849
<input
4950
class="uk-input"
51+
:class="{ 'uk-form-danger': r$.first_name.$error }"
5052
type="text"
5153
:placeholder="$t('FIRST_NAME')"
5254
aria-label="First Name"
5355
data-test="first_name"
5456
tabindex="1"
5557
autofocus
5658
v-model="formData.first_name" />
59+
<UFFormValidationError :errors="r$.$errors.first_name" />
5760
</div>
5861
</div>
5962

@@ -63,12 +66,14 @@ const submitForm = () => {
6366
<font-awesome-icon class="fa-form-icon" icon="edit" fixed-width />
6467
<input
6568
class="uk-input"
69+
:class="{ 'uk-form-danger': r$.last_name.$error }"
6670
type="text"
6771
:placeholder="$t('LAST_NAME')"
6872
aria-label="Last Name"
6973
data-test="last_name"
7074
tabindex="2"
7175
v-model="formData.last_name" />
76+
<UFFormValidationError :errors="r$.$errors.last_name" />
7277
</div>
7378
</div>
7479

@@ -88,7 +93,9 @@ const submitForm = () => {
8893
:key="key">
8994
{{ value }}
9095
</option>
96+
<option value="spanish">Spanish</option>
9197
</select>
98+
<UFFormValidationError :errors="r$.$errors.locale" />
9299
</div>
93100
</div>
94101

packages/theme-pink-cupcake/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import UFAlert from './UFAlert.vue'
2+
import UFFormValidationError from './Form/UFFormValidationError.vue'
23
import UFLabel from './UFLabel.vue'
34
import UFModal from './Modals/UFModal.vue'
45
import UFModalAlert from './Modals/UFModalAlert.vue'
@@ -38,6 +39,7 @@ import SprunjeTable from './Sprunjer/SprunjeTable.vue'
3839

3940
export {
4041
UFAlert,
42+
UFFormValidationError,
4143
UFLabel,
4244
UFModal,
4345
UFModalAlert,

0 commit comments

Comments
 (0)