@userfrosting/sprinkle-core 6.0.0-alpha.5 → 6.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,4 @@
1
- export { useSprunjer } from './sprunjer'
1
+ export { useSprunjer } from './useSprunjer'
2
2
  export { useCsrf } from './useCsrf'
3
+ export { useAxiosInterceptor } from './useAxiosInterceptor'
4
+ export { useRuleSchemaAdapter } from './useRuleSchemaAdapter'
@@ -0,0 +1,30 @@
1
+ import axios from 'axios'
2
+ import { useAlertsStore } from '../stores/useAlertsStore'
3
+ import { Severity } from '../interfaces'
4
+
5
+ /**
6
+ * Axios Error Handler
7
+ *
8
+ * This composable sets up an Axios interceptor to handle errors globally using
9
+ * the Alerts store. It sends the error to the Alerts store, unless it's a 401
10
+ * error.
11
+ *
12
+ * @see https://axios-http.com/docs/interceptors
13
+ */
14
+ export const useAxiosInterceptor = () => {
15
+ axios.interceptors.response.use(
16
+ (response) => response,
17
+ (error) => {
18
+ if (error.response.status !== 401) {
19
+ const alertStore = useAlertsStore()
20
+ alertStore.push({
21
+ title: error.response.data.title ?? error.response?.statusText,
22
+ description: error.response?.data?.description ?? error.message,
23
+ style: Severity.Danger
24
+ })
25
+ }
26
+
27
+ return Promise.reject(error)
28
+ }
29
+ )
30
+ }
@@ -34,6 +34,14 @@ export const useCsrf = () => {
34
34
  axios.defaults.headers.patch[key_value.value] = token.value
35
35
  }
36
36
 
37
+ /**
38
+ * Fetch the CSRF token from the server
39
+ */
40
+ async function fetchCsrfToken() {
41
+ const response = await axios.get('/api/csrf')
42
+ updateFromHeaders(response.headers)
43
+ }
44
+
37
45
  /**
38
46
  * Get the CSRF token name and value keys from config.
39
47
  */
@@ -115,6 +123,7 @@ export const useCsrf = () => {
115
123
  name,
116
124
  token,
117
125
  isEnabled,
118
- updateFromHeaders
126
+ updateFromHeaders,
127
+ fetchCsrfToken
119
128
  }
120
129
  }
@@ -0,0 +1,219 @@
1
+ import { parse as YamlParse } from 'yaml'
2
+ import {
3
+ withMessage,
4
+ required,
5
+ maxLength,
6
+ minLength,
7
+ email,
8
+ integer,
9
+ numeric,
10
+ url,
11
+ oneOf,
12
+ between,
13
+ regex,
14
+ not
15
+ } from '@regle/rules'
16
+ import { useTranslator } from '../stores'
17
+
18
+ export function useRuleSchemaAdapter() {
19
+ /**
20
+ * Parse the YAML schema string into a JavaScript object.
21
+ *
22
+ * @param rawSchema The YAML schema string to parse.
23
+ * @returns RuleSchema The Regle schema object.
24
+ */
25
+ function adapt(rawSchema: string) {
26
+ // The YAML data parsed to a JavaScript object
27
+ const sourceSchema = parse(rawSchema)
28
+
29
+ // The Regle schema object to be returned
30
+ const regleSchema: any = {}
31
+
32
+ // Iterate over each field in the schema
33
+ for (const field in sourceSchema) {
34
+ // Check if the field is a direct property of the sourceSchema object
35
+ if (Object.prototype.hasOwnProperty.call(sourceSchema, field)) {
36
+ // Get the field rules from the source schema
37
+ const schemaFieldRules = sourceSchema[field]?.validators || {}
38
+
39
+ // The returned regle rules for the field
40
+ const regleRules: Record<string, any> = {}
41
+
42
+ // Iterate over each rule in the source schema field rules
43
+ for (const key of Object.keys(schemaFieldRules)) {
44
+ adaptRule(key, schemaFieldRules, regleRules)
45
+ }
46
+
47
+ regleSchema[field] = regleRules
48
+ }
49
+ }
50
+
51
+ return regleSchema
52
+ }
53
+
54
+ /**
55
+ * Parse the YAML schema string into a JavaScript object.
56
+ *
57
+ * @param rawSchema The YAML schema string to parse.
58
+ * @returns The parsed YAML schema as a JavaScript object.
59
+ */
60
+ function parse(rawSchema: string): Record<string, any> {
61
+ return YamlParse(rawSchema)
62
+ }
63
+
64
+ function translateMessage(fieldRulesMeta: { message?: string; [key: string]: any }): string {
65
+ const { translate } = useTranslator()
66
+
67
+ // If there's no message, return an empty string
68
+ if (!fieldRulesMeta.message) {
69
+ return ''
70
+ }
71
+
72
+ // Copy the field rules meta to avoid mutation and remove the message key
73
+ const fieldRulesMetaCopy = { ...fieldRulesMeta }
74
+ delete fieldRulesMetaCopy.message
75
+
76
+ return translate(fieldRulesMeta.message, fieldRulesMetaCopy)
77
+ }
78
+
79
+ function adaptRule(key: string, schemaFieldRules: any, regleRules: Record<string, any>) {
80
+ // Required
81
+ if (key === 'required' && schemaFieldRules.required) {
82
+ const message: string = translateMessage(schemaFieldRules.required)
83
+ regleRules['required'] = message === '' ? required : withMessage(required, message)
84
+ }
85
+
86
+ // Email
87
+ if (key === 'email' && schemaFieldRules.email) {
88
+ const message: string = translateMessage(schemaFieldRules.email)
89
+ regleRules['email'] = withMessage(email, message)
90
+ }
91
+
92
+ // Length
93
+ if (key === 'length' && schemaFieldRules.length) {
94
+ if (schemaFieldRules.length.min !== undefined) {
95
+ const message: string = translateMessage(schemaFieldRules.length)
96
+ regleRules['minLength'] =
97
+ message === ''
98
+ ? minLength(schemaFieldRules.length.min)
99
+ : withMessage(minLength(schemaFieldRules.length.min), message)
100
+ }
101
+ if (schemaFieldRules.length.max !== undefined) {
102
+ const message: string = translateMessage(schemaFieldRules.length)
103
+ regleRules['maxLength'] =
104
+ message === ''
105
+ ? maxLength(schemaFieldRules.length.max)
106
+ : withMessage(maxLength(schemaFieldRules.length.max), message)
107
+ }
108
+ }
109
+
110
+ // TODO : matches
111
+ if (key === 'matches' && schemaFieldRules.matches) {
112
+ console.warn('Validation rule "matches" not implemented yet')
113
+ // console.debug('Matched rule: matches', schemaFieldRules.matches)
114
+ // sameAs: sameAs(() => form.value.password),
115
+ }
116
+
117
+ // TODO : equals
118
+ if (key === 'equals' && schemaFieldRules.equals) {
119
+ console.warn('Validation rule "equals" not implemented yet')
120
+ }
121
+
122
+ // Integer
123
+ if (key === 'integer' && schemaFieldRules.integer) {
124
+ const message: string = translateMessage(schemaFieldRules.integer)
125
+ regleRules['integer'] = message === '' ? integer : withMessage(integer, message)
126
+ }
127
+
128
+ // member_of
129
+ if (key === 'member_of' && schemaFieldRules.member_of) {
130
+ const message: string = translateMessage(schemaFieldRules.member_of)
131
+ regleRules['member_of'] =
132
+ message === ''
133
+ ? oneOf(schemaFieldRules.member_of.values)
134
+ : withMessage(oneOf(schemaFieldRules.member_of.values), message)
135
+ }
136
+
137
+ // no_leading_whitespace
138
+ if (key === 'no_leading_whitespace' && schemaFieldRules.no_leading_whitespace) {
139
+ const message: string = translateMessage(schemaFieldRules.no_leading_whitespace)
140
+ regleRules['no_leading_whitespace'] =
141
+ message === '' ? regex(/^\S.*$/) : withMessage(regex(/^\S.*$/), message)
142
+ }
143
+
144
+ // no_trailing_whitespace
145
+ if (key === 'no_trailing_whitespace' && schemaFieldRules.no_trailing_whitespace) {
146
+ const message: string = translateMessage(schemaFieldRules.no_trailing_whitespace)
147
+ regleRules['no_trailing_whitespace'] =
148
+ message === '' ? regex(/^.*\S$/) : withMessage(regex(/^.*\S$/), message)
149
+ }
150
+
151
+ // TODO : not_equals
152
+ if (key === 'not_equals' && schemaFieldRules.not_equals) {
153
+ console.warn('Validation rule "not_equals" not implemented yet')
154
+ }
155
+
156
+ // TODO : not_matches
157
+ if (key === 'not_matches' && schemaFieldRules.not_matches) {
158
+ console.warn('Validation rule "not_matches" not implemented yet')
159
+ }
160
+
161
+ // not_member_of
162
+ if (key === 'not_member_of' && schemaFieldRules.not_member_of) {
163
+ const message: string = translateMessage(schemaFieldRules.not_member_of)
164
+ regleRules['not_member_of'] =
165
+ message === ''
166
+ ? not(oneOf(schemaFieldRules.not_member_of.values))
167
+ : withMessage(not(oneOf(schemaFieldRules.not_member_of.values)), message)
168
+ }
169
+
170
+ // Numeric
171
+ if (key === 'numeric' && schemaFieldRules.numeric) {
172
+ const message: string = translateMessage(schemaFieldRules.numeric)
173
+ regleRules['numeric'] = message === '' ? numeric : withMessage(numeric, message)
174
+ }
175
+
176
+ // Range
177
+ if (key === 'range' && schemaFieldRules.range) {
178
+ const message: string = translateMessage(schemaFieldRules.range)
179
+ regleRules['range'] =
180
+ message === ''
181
+ ? between(schemaFieldRules.range.min, schemaFieldRules.range.max)
182
+ : withMessage(
183
+ between(schemaFieldRules.range.min, schemaFieldRules.range.max),
184
+ message
185
+ )
186
+ }
187
+
188
+ // Regex
189
+ if (key === 'regex' && schemaFieldRules.regex) {
190
+ const message: string = translateMessage(schemaFieldRules.regex)
191
+ regleRules['regex'] =
192
+ message === ''
193
+ ? regex(new RegExp(schemaFieldRules.regex.regex))
194
+ : withMessage(regex(new RegExp(schemaFieldRules.regex.regex)), message)
195
+ }
196
+
197
+ // TODO : telephone
198
+ if (key === 'telephone' && schemaFieldRules.telephone) {
199
+ console.warn('Validation rule "telephone" not implemented yet')
200
+ }
201
+
202
+ // uri
203
+ if (key === 'uri' && schemaFieldRules.uri) {
204
+ const message: string = translateMessage(schemaFieldRules.uri)
205
+ regleRules['uri'] = message === '' ? url : withMessage(url, message)
206
+ }
207
+
208
+ // Username
209
+ if (key === 'username' && schemaFieldRules.username) {
210
+ const message: string = translateMessage(schemaFieldRules.username)
211
+ regleRules['username'] =
212
+ message === ''
213
+ ? regex(/^([a-z0-9.\-_])+$/i)
214
+ : withMessage(regex(/^([a-z0-9.\-_])+$/i), message)
215
+ }
216
+ }
217
+
218
+ return { adapt, parse, translateMessage }
219
+ }
@@ -33,7 +33,13 @@
33
33
  */
34
34
  import { ref, toValue, watchEffect, computed } from 'vue'
35
35
  import axios from 'axios'
36
- import type { AssociativeArray, Sprunjer, SprunjerData, SprunjerResponse } from '../interfaces'
36
+ import type {
37
+ ApiErrorResponse,
38
+ AssociativeArray,
39
+ Sprunjer,
40
+ SprunjerData,
41
+ SprunjerResponse
42
+ } from '../interfaces'
37
43
 
38
44
  export const useSprunjer = (
39
45
  dataUrl: string | (() => string),
@@ -60,6 +66,7 @@ export const useSprunjer = (
60
66
 
61
67
  // State
62
68
  const loading = ref<boolean>(false)
69
+ const error = ref<ApiErrorResponse | null>(null)
63
70
 
64
71
  /**
65
72
  * Api fetch function
@@ -87,8 +94,7 @@ export const useSprunjer = (
87
94
  data.value.filterable = response.data.filterable ?? []
88
95
  })
89
96
  .catch((err) => {
90
- // TODO : User toast alert, or export alert
91
- console.error(err)
97
+ error.value = err.response.data as ApiErrorResponse
92
98
  })
93
99
  .finally(() => {
94
100
  loading.value = false
@@ -169,6 +175,7 @@ export const useSprunjer = (
169
175
  data,
170
176
  fetch,
171
177
  loading,
178
+ error,
172
179
  downloadCsv,
173
180
  totalPages,
174
181
  countFiltered,
@@ -1,6 +1,7 @@
1
1
  import type { App } from 'vue'
2
2
  import { useConfigStore, useTranslator } from './stores'
3
3
  import { useCsrf } from './composables/useCsrf'
4
+ import { useAxiosInterceptor } from './composables'
4
5
 
5
6
  /**
6
7
  * Core Sprinkle initialization recipe.
@@ -11,6 +12,13 @@ import { useCsrf } from './composables/useCsrf'
11
12
  */
12
13
  export default {
13
14
  install: (app: App) => {
15
+ /**
16
+ * Add Axios error handler.
17
+ * Load first to ensure that all axios requests are intercepted.
18
+ * This is important for the config loading and translator loading.
19
+ */
20
+ useAxiosInterceptor()
21
+
14
22
  /**
15
23
  * Load configuration
16
24
  */
@@ -4,5 +4,12 @@
4
4
  * Generic API Response interface.
5
5
  */
6
6
  export interface ApiResponse {
7
- message: string
7
+ title: string
8
+ description?: string
9
+ }
10
+
11
+ export interface ApiErrorResponse {
12
+ title: string
13
+ description: string
14
+ status: number
8
15
  }
@@ -27,4 +27,4 @@ export type { SprunjerRequest, SprunjerResponse } from './sprunjerApi'
27
27
  export type { DictionaryResponse, DictionaryEntries, DictionaryConfig } from './DictionaryApi'
28
28
 
29
29
  // Misc
30
- export type { ApiResponse } from './ApiResponse'
30
+ export type { ApiResponse, ApiErrorResponse } from './ApiResponse'
@@ -4,7 +4,7 @@
4
4
  * Represents the interface for the Sprunjer composable.
5
5
  */
6
6
  import type { Ref, ComputedRef } from 'vue'
7
- import type { AssociativeArray } from '.'
7
+ import type { ApiErrorResponse, AssociativeArray } from '.'
8
8
 
9
9
  export interface Sprunjer {
10
10
  dataUrl: string | (() => string)
@@ -15,6 +15,7 @@ export interface Sprunjer {
15
15
  data: Ref<SprunjerData>
16
16
  fetch: () => void
17
17
  loading: Ref<boolean>
18
+ error: Ref<ApiErrorResponse | null>
18
19
  totalPages: ComputedRef<number>
19
20
  downloadCsv: () => void
20
21
  countFiltered: ComputedRef<number>
@@ -12,7 +12,7 @@ export default [
12
12
  title: 'ERROR.404.TITLE',
13
13
  description: 'ERROR.404.DESCRIPTION'
14
14
  },
15
- component: () => import('../views/404NotFound.vue')
15
+ component: () => import('../views/Page404NotFound.vue')
16
16
  },
17
17
  {
18
18
  path: '/:pathMatch(.*)*',
@@ -21,7 +21,7 @@ export default [
21
21
  title: 'ERROR.401.TITLE',
22
22
  description: 'ERROR.401.DESCRIPTION'
23
23
  },
24
- component: () => import('../views/401Unauthorized.vue')
24
+ component: () => import('../views/Page401Unauthorized.vue')
25
25
  },
26
26
  {
27
27
  path: '/:pathMatch(.*)*',
@@ -30,7 +30,7 @@ export default [
30
30
  title: 'ERROR.403.TITLE',
31
31
  description: 'ERROR.403.DESCRIPTION'
32
32
  },
33
- component: () => import('../views/403Forbidden.vue')
33
+ component: () => import('../views/Page403Forbidden.vue')
34
34
  },
35
35
  {
36
36
  path: '/:pathMatch(.*)*',
@@ -39,6 +39,6 @@ export default [
39
39
  title: 'ERROR.TITLE',
40
40
  description: 'ERROR.DESCRIPTION'
41
41
  },
42
- component: () => import('../views/ErrorPage.vue')
42
+ component: () => import('../views/PageError.vue')
43
43
  }
44
44
  ]
@@ -1,3 +1,4 @@
1
- export { useConfigStore } from './config'
1
+ export { useConfigStore } from './useConfigStore'
2
2
  export { usePageMeta } from './usePageMeta'
3
3
  export { useTranslator } from './useTranslator'
4
+ export { useAlertsStore } from './useAlertsStore'
@@ -0,0 +1,30 @@
1
+ import { defineStore } from 'pinia'
2
+ import type { AlertInterface } from '../interfaces'
3
+
4
+ /**
5
+ * Alerts Store
6
+ *
7
+ * Manages application alerts in a reactive store. Templates can use this
8
+ * store to display alerts to users, including errors, warnings,
9
+ * informational, or success messages. When an alert is added, templates
10
+ * should automatically update the interface to reflect the change.
11
+ */
12
+ export const useAlertsStore = defineStore('alerts', {
13
+ state: () => ({
14
+ alerts: [] as AlertInterface[]
15
+ }),
16
+ actions: {
17
+ push(alert: AlertInterface) {
18
+ this.alerts.push(alert)
19
+ },
20
+ pop() {
21
+ return this.alerts.pop()
22
+ },
23
+ shift() {
24
+ return this.alerts.shift()
25
+ },
26
+ clear() {
27
+ this.alerts = []
28
+ }
29
+ }
30
+ })
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, beforeEach, afterEach, test, vi } from 'vitest'
2
2
  import axios from 'axios'
3
- import { useConfigStore } from '../../stores/config'
3
+ import { useConfigStore } from '../../stores/useConfigStore'
4
4
  import { useCsrf } from '../../composables/useCsrf'
5
5
  import { nextTick } from 'vue'
6
6
 
7
7
  // Mock the config store
8
- vi.mock('../../stores/config')
8
+ vi.mock('../../stores/useConfigStore')
9
9
  const mockUseConfigStore = {
10
10
  get: vi.fn()
11
11
  }
@@ -0,0 +1,564 @@
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 bad schema without errors', () => {
76
+ // N.B.: The schema is missing the 'validators' key
77
+ const yamlInput = `
78
+ foo:
79
+ length:
80
+ min: 1
81
+ max: 132
82
+ required: true
83
+ `
84
+
85
+ const { r$ } = useRegle(
86
+ {
87
+ foo: ''
88
+ },
89
+ useRuleSchemaAdapter().adapt(yamlInput)
90
+ )
91
+
92
+ expect(r$.foo).toBeDefined()
93
+ expect(r$.foo.$rules.required).not.toBeDefined()
94
+ expect(r$.foo.$rules.minLength).not.toBeDefined()
95
+ expect(r$.foo.$rules.maxLength).not.toBeDefined()
96
+ })
97
+
98
+ test('required rule', () => {
99
+ const yamlInput = `
100
+ first_name:
101
+ validators:
102
+ required:
103
+ label: "&FIRST_NAME"
104
+ message: VALIDATE.REQUIRED
105
+ last_name:
106
+ validators:
107
+ required: true
108
+ `
109
+
110
+ const { r$ } = useRegle(
111
+ {
112
+ first_name: '',
113
+ last_name: ''
114
+ },
115
+ useRuleSchemaAdapter().adapt(yamlInput)
116
+ )
117
+
118
+ // Set translator expectations
119
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.REQUIRED', {
120
+ label: '&FIRST_NAME'
121
+ })
122
+
123
+ expect(r$.first_name.$rules.required.$message).toBe('VALIDATE.REQUIRED') // Custom message
124
+ expect(r$.last_name.$rules.required.$message).toBe('This field is required') // Default message
125
+ })
126
+
127
+ test('email rule', () => {
128
+ const yamlInput = `
129
+ email:
130
+ validators:
131
+ email:
132
+ message: VALIDATE.INVALID_EMAIL
133
+ email2:
134
+ validators:
135
+ email: true
136
+ `
137
+
138
+ const { r$ } = useRegle(
139
+ {
140
+ email: '',
141
+ email2: ''
142
+ },
143
+ useRuleSchemaAdapter().adapt(yamlInput)
144
+ )
145
+
146
+ // Set translator expectations
147
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.INVALID_EMAIL', {})
148
+
149
+ expect(r$.email.$rules.email.$message).toBe('VALIDATE.INVALID_EMAIL') // Custom message
150
+ expect(r$.email2.$rules.email.$message).toBe('This field is not valid') // Default message
151
+ })
152
+
153
+ test('length rule', () => {
154
+ const yamlInput = `
155
+ tooShort:
156
+ validators:
157
+ length:
158
+ min: 5
159
+ tooLong:
160
+ validators:
161
+ length:
162
+ max: 2
163
+ tooShortWithMessage:
164
+ validators:
165
+ length:
166
+ min: 5
167
+ message: VALIDATE.LENGTH_RANGE
168
+ tooLongWithMessage:
169
+ validators:
170
+ length:
171
+ max: 2
172
+ message: VALIDATE.LENGTH_RANGE
173
+ `
174
+
175
+ const { r$ } = useRegle(
176
+ {
177
+ tooShort: '1',
178
+ tooLong: '123',
179
+ tooShortWithMessage: '1',
180
+ tooLongWithMessage: '123'
181
+ },
182
+ useRuleSchemaAdapter().adapt(yamlInput)
183
+ )
184
+
185
+ // Set translator expectations
186
+ expect(translateMock).toHaveBeenNthCalledWith(1, 'VALIDATE.LENGTH_RANGE', { min: 5 })
187
+ expect(translateMock).toHaveBeenNthCalledWith(2, 'VALIDATE.LENGTH_RANGE', { max: 2 })
188
+
189
+ expect(r$.tooShort.$silentErrors).toEqual(['The value length should be at least 5']) // Custom message
190
+ expect(r$.tooLong.$silentErrors).toEqual(['The value length should not exceed 2']) // Default message
191
+ expect(r$.tooShortWithMessage.$silentErrors).toEqual(['VALIDATE.LENGTH_RANGE']) // Custom message
192
+ expect(r$.tooLongWithMessage.$silentErrors).toEqual(['VALIDATE.LENGTH_RANGE']) // Default message
193
+ })
194
+
195
+ test('integer rule', () => {
196
+ const yamlInput = `
197
+ foo:
198
+ validators:
199
+ integer: true
200
+ bar:
201
+ validators:
202
+ integer:
203
+ message: VALIDATE.INVALID_INTEGER
204
+ foobar:
205
+ validators:
206
+ integer: true
207
+ `
208
+
209
+ const { r$ } = useRegle(
210
+ {
211
+ foo: 'one',
212
+ bar: 'two',
213
+ foobar: 92
214
+ },
215
+ useRuleSchemaAdapter().adapt(yamlInput)
216
+ )
217
+
218
+ // Set translator expectations
219
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.INVALID_INTEGER', {})
220
+
221
+ expect(r$.foo.$silentErrors).toEqual(['The value must be an integer']) // Custom message
222
+ expect(r$.bar.$silentErrors).toEqual(['VALIDATE.INVALID_INTEGER']) // Default message
223
+ expect(r$.foobar.$silentErrors).toEqual([]) // Valid
224
+ })
225
+
226
+ test('member_of rule', () => {
227
+ const yamlInput = `
228
+ genus:
229
+ validators:
230
+ member_of:
231
+ values:
232
+ - Megascops
233
+ - Bubo
234
+ - Glaucidium
235
+ - Tyto
236
+ - Athene
237
+ message: Sorry, that is not one of the permitted genuses.
238
+ owls:
239
+ validators:
240
+ member_of:
241
+ values:
242
+ - Foo
243
+ - Bar
244
+ valid:
245
+ validators:
246
+ member_of:
247
+ values:
248
+ - Foo
249
+ - Bar
250
+ `
251
+
252
+ const { r$ } = useRegle(
253
+ {
254
+ genus: 'Foo',
255
+ owls: 'Hedwig',
256
+ valid: 'Foo'
257
+ },
258
+ useRuleSchemaAdapter().adapt(yamlInput)
259
+ )
260
+
261
+ // Set translator expectations
262
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith(
263
+ 'Sorry, that is not one of the permitted genuses.',
264
+ {
265
+ values: ['Megascops', 'Bubo', 'Glaucidium', 'Tyto', 'Athene']
266
+ }
267
+ )
268
+
269
+ expect(r$.genus.$silentErrors).toEqual(['Sorry, that is not one of the permitted genuses.']) // Custom message
270
+ expect(r$.owls.$silentErrors).toEqual([
271
+ 'The value should be one of those options: Foo, Bar.'
272
+ ]) // Default message
273
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
274
+ })
275
+
276
+ test('not_member_of rule', () => {
277
+ const yamlInput = `
278
+ genus:
279
+ validators:
280
+ not_member_of:
281
+ values:
282
+ - Megascops
283
+ - Bubo
284
+ - Glaucidium
285
+ - Tyto
286
+ - Athene
287
+ message: VALIDATE.NOT_MEMBER_OF
288
+ owls:
289
+ validators:
290
+ not_member_of:
291
+ values:
292
+ - Foo
293
+ - Bar
294
+ valid:
295
+ validators:
296
+ not_member_of:
297
+ values:
298
+ - Foo
299
+ - Bar
300
+ `
301
+
302
+ const { r$ } = useRegle(
303
+ {
304
+ genus: 'Megascops',
305
+ owls: 'Foo',
306
+ valid: 'Hedwig'
307
+ },
308
+ useRuleSchemaAdapter().adapt(yamlInput)
309
+ )
310
+
311
+ // Set translator expectations
312
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.NOT_MEMBER_OF', {
313
+ values: ['Megascops', 'Bubo', 'Glaucidium', 'Tyto', 'Athene']
314
+ })
315
+
316
+ expect(r$.genus.$silentErrors).toEqual(['VALIDATE.NOT_MEMBER_OF']) // Custom message
317
+ expect(r$.owls.$silentErrors).toEqual(['Error']) // Default message
318
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
319
+ })
320
+
321
+ test('no_leading_whitespace rule', () => {
322
+ const yamlInput = `
323
+ withMessage:
324
+ validators:
325
+ no_leading_whitespace:
326
+ label: "&USERNAME"
327
+ message: VALIDATE.NO_LEAD_WS
328
+ defaultMessage:
329
+ validators:
330
+ no_leading_whitespace: true
331
+ valid:
332
+ validators:
333
+ no_leading_whitespace: true
334
+ `
335
+
336
+ const { r$ } = useRegle(
337
+ {
338
+ withMessage: ' Foo',
339
+ defaultMessage: ' Foo',
340
+ valid: 'Foo'
341
+ },
342
+ useRuleSchemaAdapter().adapt(yamlInput)
343
+ )
344
+
345
+ // Set translator expectations
346
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.NO_LEAD_WS', {
347
+ label: '&USERNAME'
348
+ })
349
+
350
+ expect(r$.withMessage.$silentErrors).toEqual(['VALIDATE.NO_LEAD_WS']) // Custom message
351
+ expect(r$.defaultMessage.$silentErrors).toEqual([
352
+ 'The value does not match the required pattern'
353
+ ]) // Default message
354
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
355
+ })
356
+
357
+ test('no_trailing_whitespace rule', () => {
358
+ const yamlInput = `
359
+ withMessage:
360
+ validators:
361
+ no_trailing_whitespace:
362
+ label: "&USERNAME"
363
+ message: VALIDATE.NO_TRAIL_WS
364
+ defaultMessage:
365
+ validators:
366
+ no_trailing_whitespace: true
367
+ valid:
368
+ validators:
369
+ no_trailing_whitespace: true
370
+ `
371
+
372
+ const { r$ } = useRegle(
373
+ {
374
+ withMessage: 'Foo ',
375
+ defaultMessage: 'Foo ',
376
+ valid: 'Foo'
377
+ },
378
+ useRuleSchemaAdapter().adapt(yamlInput)
379
+ )
380
+
381
+ // Set translator expectations
382
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.NO_TRAIL_WS', {
383
+ label: '&USERNAME'
384
+ })
385
+
386
+ expect(r$.withMessage.$silentErrors).toEqual(['VALIDATE.NO_TRAIL_WS']) // Custom message
387
+ expect(r$.defaultMessage.$silentErrors).toEqual([
388
+ 'The value does not match the required pattern'
389
+ ]) // Default message
390
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
391
+ })
392
+
393
+ test('numeric rule', () => {
394
+ const yamlInput = `
395
+ withMessage:
396
+ validators:
397
+ numeric:
398
+ message: VALIDATE.INVALID_NUMERIC
399
+ defaultMessage:
400
+ validators:
401
+ numeric: true
402
+ valid:
403
+ validators:
404
+ numeric: true
405
+ `
406
+
407
+ const { r$ } = useRegle(
408
+ {
409
+ withMessage: 'Foo',
410
+ defaultMessage: 'Foo',
411
+ valid: '10.2'
412
+ },
413
+ useRuleSchemaAdapter().adapt(yamlInput)
414
+ )
415
+
416
+ // Set translator expectations
417
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.INVALID_NUMERIC', {})
418
+
419
+ expect(r$.withMessage.$silentErrors).toEqual(['VALIDATE.INVALID_NUMERIC']) // Custom message
420
+ expect(r$.defaultMessage.$silentErrors).toEqual(['The value must be numeric']) // Default message
421
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
422
+ })
423
+
424
+ test('range rule', () => {
425
+ const yamlInput = `
426
+ withMessage:
427
+ validators:
428
+ range:
429
+ min: 0
430
+ max: 10
431
+ message: VALIDATE.INVALID_RANGE
432
+ defaultMessage:
433
+ validators:
434
+ range:
435
+ min: 0
436
+ max: 10
437
+ valid:
438
+ validators:
439
+ range:
440
+ min: 0
441
+ max: 10
442
+ `
443
+
444
+ const { r$ } = useRegle(
445
+ {
446
+ withMessage: 92,
447
+ defaultMessage: 92,
448
+ valid: 9
449
+ },
450
+ useRuleSchemaAdapter().adapt(yamlInput)
451
+ )
452
+
453
+ // Set translator expectations
454
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.INVALID_RANGE', {
455
+ min: 0,
456
+ max: 10
457
+ })
458
+
459
+ expect(r$.withMessage.$silentErrors).toEqual(['VALIDATE.INVALID_RANGE']) // Custom message
460
+ expect(r$.defaultMessage.$silentErrors).toEqual(['The value must be between 0 and 10']) // Default message
461
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
462
+ })
463
+
464
+ test('regex rule', () => {
465
+ const yamlInput = `
466
+ withMessage:
467
+ validators:
468
+ regex:
469
+ regex: ^who(o*)$
470
+ message: VALIDATE.INVALID_VALUE
471
+ defaultMessage:
472
+ validators:
473
+ regex:
474
+ regex: ^who(o*)$
475
+ valid:
476
+ validators:
477
+ regex:
478
+ regex: ^who(o*)$
479
+ `
480
+
481
+ const { r$ } = useRegle(
482
+ {
483
+ withMessage: 'hum',
484
+ defaultMessage: 'hello',
485
+ valid: 'whooo'
486
+ },
487
+ useRuleSchemaAdapter().adapt(yamlInput)
488
+ )
489
+ r$.$validate()
490
+
491
+ // Set translator expectations
492
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.INVALID_VALUE', {
493
+ regex: '^who(o*)$'
494
+ })
495
+
496
+ expect(r$.withMessage.$errors).toEqual(['VALIDATE.INVALID_VALUE']) // Custom message
497
+ expect(r$.defaultMessage.$errors).toEqual(['The value does not match the required pattern']) // Default message
498
+ expect(r$.valid.$correct).toEqual(true) // Valid
499
+ })
500
+
501
+ test('uri rule', () => {
502
+ const yamlInput = `
503
+ withMessage:
504
+ validators:
505
+ uri:
506
+ message: VALIDATE.INVALID_URL
507
+ defaultMessage:
508
+ validators:
509
+ uri: true
510
+ valid:
511
+ validators:
512
+ uri: true
513
+ `
514
+
515
+ const { r$ } = useRegle(
516
+ {
517
+ withMessage: 'foo',
518
+ defaultMessage: 'bar@example.com',
519
+ valid: 'http://example.com'
520
+ },
521
+ useRuleSchemaAdapter().adapt(yamlInput)
522
+ )
523
+
524
+ // Set translator expectations
525
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.INVALID_URL', {})
526
+
527
+ expect(r$.withMessage.$silentErrors).toEqual(['VALIDATE.INVALID_URL']) // Custom message
528
+ expect(r$.defaultMessage.$silentErrors).toEqual(['The value is not a valid URL address']) // Default message
529
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
530
+ })
531
+
532
+ test('username rule', () => {
533
+ const yamlInput = `
534
+ withMessage:
535
+ validators:
536
+ username:
537
+ message: VALIDATE.INVALID_USERNAME
538
+ defaultMessage:
539
+ validators:
540
+ username: true
541
+ valid:
542
+ validators:
543
+ username: true
544
+ `
545
+
546
+ const { r$ } = useRegle(
547
+ {
548
+ withMessage: 'My Name',
549
+ defaultMessage: 'bar@example.com',
550
+ valid: 'foo.bar-bax_123'
551
+ },
552
+ useRuleSchemaAdapter().adapt(yamlInput)
553
+ )
554
+
555
+ // Set translator expectations
556
+ expect(translateMock).toHaveBeenCalledExactlyOnceWith('VALIDATE.INVALID_USERNAME', {})
557
+
558
+ expect(r$.withMessage.$silentErrors).toEqual(['VALIDATE.INVALID_USERNAME']) // Custom message
559
+ expect(r$.defaultMessage.$silentErrors).toEqual([
560
+ 'The value does not match the required pattern'
561
+ ]) // Default message
562
+ expect(r$.valid.$silentErrors).toEqual([]) // Valid
563
+ })
564
+ })
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test, vi } from 'vitest'
2
2
  import { createApp } from 'vue'
3
- import { useConfigStore } from '../stores/config'
3
+ import { useConfigStore } from '../stores/useConfigStore'
4
4
  import plugin from '..'
5
- import * as Config from '../stores/config'
5
+ import * as Config from '../stores/useConfigStore'
6
6
  import * as Translator from '../stores/useTranslator'
7
7
 
8
8
  const mockConfigStore = {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, beforeEach, test, vi } from 'vitest'
2
2
  import { setActivePinia, createPinia } from 'pinia'
3
3
  import axios from 'axios'
4
- import { useConfigStore } from '../../stores/config'
4
+ import { useConfigStore } from '../../stores/useConfigStore'
5
5
 
6
6
  const testConfig = {
7
7
  name: 'Test Config',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@userfrosting/sprinkle-core",
3
- "version": "6.0.0-alpha.5",
3
+ "version": "6.0.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "Core Sprinkle for UserFrosting",
6
6
  "funding": "https://opencollective.com/userfrosting",
@@ -34,8 +34,11 @@
34
34
  "app/assets/"
35
35
  ],
36
36
  "dependencies": {
37
+ "@regle/core": "^1.6.0",
38
+ "@regle/rules": "^1.6.0",
37
39
  "dot-prop": "^9.0.0",
38
- "luxon": "^3.5.0"
40
+ "luxon": "^3.5.0",
41
+ "yaml": "^2.8.0"
39
42
  },
40
43
  "peerDependencies": {
41
44
  "axios": "^1.5.0",
@@ -59,7 +62,7 @@
59
62
  "happy-dom": "^15.11.6",
60
63
  "less": "^4.2.0",
61
64
  "npm-run-all2": "^6.1.2",
62
- "prettier": "^3.2.5",
65
+ "prettier": "^3.6.2",
63
66
  "vite": "^6.2",
64
67
  "vite-plugin-dts": "^4.0.0",
65
68
  "vitest": "^3.1.1",