@cnamts/synapse 1.1.0 → 1.1.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.
- package/dist/{AutocompleteFilter-DXd4szWO.js → AutocompleteFilter-CGF33skz.js} +1 -1
- package/dist/{DateFilter-BD59Kgwf.js → DateFilter-D7-MsKtx.js} +1 -1
- package/dist/{NumberFilter-BSMZE7uw.js → NumberFilter-bjQPPfsj.js} +1 -1
- package/dist/{PeriodFilter-keUdSSk0.js → PeriodFilter-B3wJpK8-.js} +1 -1
- package/dist/{SelectFilter-Dhvvwazl.js → SelectFilter-BN6DbKAV.js} +1 -1
- package/dist/{TextFilter-CU8FpXz0.js → TextFilter-BffP0J2f.js} +1 -1
- package/dist/{apLightTheme2026-DbS7BPUf.js → apLightTheme2026-C4ygwMHC.js} +11 -11
- package/dist/components/Amelipro/AmeliproAutoCompleteField/AmeliproAutoCompleteField.d.ts +6 -6
- package/dist/components/Amelipro/AmeliproSelect/AmeliproSelect.d.ts +6 -6
- package/dist/components/Amelipro/AmeliproTabs/AmeliproTabs.d.ts +6 -6
- package/dist/components/Captcha/Captcha.d.ts +27 -16
- package/dist/components/Captcha/CaptchaForm.d.ts +29 -3
- package/dist/components/Captcha/types.d.ts +14 -0
- package/dist/components/Captcha/useCaptchaValidation.d.ts +37 -0
- package/dist/components/Customs/Selects/SelectBtnField/SelectBtnField.d.ts +33 -13
- package/dist/components/Customs/Selects/SelectBtnField/composables/useSelectBtnFieldValidation.d.ts +23 -0
- package/dist/components/Customs/Selects/SyAutocomplete/composables/useSyAutocompleteValidation.d.ts +2 -2
- package/dist/components/Customs/Selects/SySelect/composables/useSySelectValidation.d.ts +2 -2
- package/dist/components/Customs/SyCheckBoxGroup/SyCheckBoxGroup.d.ts +17 -48
- package/dist/components/Customs/SyCheckBoxGroup/composables/useSyCheckBoxGroupValidation.d.ts +29 -0
- package/dist/components/Customs/SyCheckBoxGroup/types.d.ts +46 -0
- package/dist/components/Customs/SyCheckbox/SyCheckbox.d.ts +16 -51
- package/dist/components/Customs/SyCheckbox/composables/useSyCheckboxValidation.d.ts +27 -0
- package/dist/components/Customs/SyCheckbox/types.d.ts +49 -0
- package/dist/components/Customs/SyTextField/FieldState.d.ts +5 -0
- package/dist/components/Customs/SyTextField/useSyTextFieldValidation.d.ts +3 -3
- package/dist/components/DialogBox/DialogBox.d.ts +2 -0
- package/dist/components/DialogBox/locales.d.ts +1 -0
- package/dist/components/FilterSideBar/FilterSideBar.d.ts +4 -0
- package/dist/components/LunarCalendar/LunarCalendar.d.ts +43 -14
- package/dist/components/LunarCalendar/types.d.ts +35 -0
- package/dist/components/LunarCalendar/useLunarCalendarValidation.d.ts +11 -12
- package/dist/components/MonthPicker/MonthPicker.d.ts +72 -1747
- package/dist/components/MonthPicker/MonthPickerText/MonthPickerInput.d.ts +21 -1733
- package/dist/components/MonthPicker/MonthPickerText/useTextField.d.ts +5 -0
- package/dist/components/MonthPicker/locales.d.ts +1 -0
- package/dist/components/MonthPicker/types.d.ts +11 -0
- package/dist/components/MonthPicker/useMonthPickerValidation.d.ts +37 -24
- package/dist/components/NirField/NirField.d.ts +6 -4
- package/dist/components/NirField/useNirValidation.d.ts +7 -5
- package/dist/components/PageContainer/PageContainer.d.ts +8 -0
- package/dist/components/PasswordField/PasswordField.d.ts +2 -2
- package/dist/components/PasswordField/usePasswordFieldValidation.d.ts +2 -2
- package/dist/components/PhoneField/PhoneField.d.ts +960 -1938
- package/dist/components/PhoneField/indicatifs.d.ts +715 -8
- package/dist/components/PhoneField/locales.d.ts +7 -0
- package/dist/components/PhoneField/types.d.ts +29 -0
- package/dist/components/PhoneField/usePhoneFieldValidation.d.ts +45 -0
- package/dist/components/PhoneField/usePhoneIndicatifs.d.ts +947 -0
- package/dist/components/SyTextArea/composables/useSyTextAreaValidation.d.ts +2 -2
- package/dist/composables/unifyValidation/documentationValidationProps.d.ts +1 -1
- package/dist/composables/unifyValidation/useValidation.d.ts +4 -5
- package/dist/design-system-v3.js +2 -2
- package/dist/designTokens/tokens/amelipro/apLightTheme.d.ts +10 -10
- package/dist/designTokens/tokens/baseTokens.d.ts +18 -18
- package/dist/designTokens/tokens/cnam/cnamLightTheme.d.ts +10 -10
- package/dist/designTokens/tokens/pa/paLightTheme.d.ts +10 -10
- package/dist/designTokens/tokens/semanticTokens.d.ts +14 -14
- package/dist/{main-D8ryUoS5.js → main-C4wAktOs.js} +13718 -12991
- package/dist/synapse.css +1 -1
- package/dist/vuetifyConfig.js +1 -1
- package/package.json +7 -7
- package/src/assets/compat/_legacy-tokens.scss +91 -0
- package/src/assets/overrides/_utilities.scss +23 -0
- package/src/components/Accordion/Accordion.stories.ts +121 -1
- package/src/components/BackBtn/BackBtn.mdx +1 -1
- package/src/components/BackToTopBtn/BackToTopBtn.mdx +0 -1
- package/src/components/Captcha/Captcha.stories.ts +134 -31
- package/src/components/Captcha/Captcha.vue +95 -28
- package/src/components/Captcha/CaptchaForm.vue +51 -22
- package/src/components/Captcha/tests/Captcha.focus.spec.ts +214 -0
- package/src/components/Captcha/tests/Captcha.spec.ts +233 -24
- package/src/components/Captcha/tests/CaptchaForm.spec.ts +82 -0
- package/src/components/Captcha/tests/__snapshots__/Captcha.spec.ts.snap +16 -42
- package/src/components/Captcha/types.ts +15 -0
- package/src/components/Captcha/useCaptchaValidation.ts +87 -0
- package/src/components/Captcha/validation/validation.stories.ts +1194 -0
- package/src/components/ChipList/ChipList.mdx +0 -1
- package/src/components/CollapsibleList/CollapsibleList.mdx +0 -1
- package/src/components/CookieBanner/CookieBanner.mdx +0 -1
- package/src/components/CopyBtn/CopyBtn.mdx +0 -1
- package/src/components/Customs/Selects/SelectBtnField/SelectBtnField.stories.ts +123 -439
- package/src/components/Customs/Selects/SelectBtnField/SelectBtnField.vue +147 -41
- package/src/components/Customs/Selects/SelectBtnField/Validation/Validation.stories.ts +600 -0
- package/src/components/Customs/Selects/SelectBtnField/composables/useSelectBtnFieldValidation.ts +87 -0
- package/src/components/Customs/Selects/SelectBtnField/tests/SelectBtnField.spec.ts +402 -33
- package/src/components/Customs/Selects/SelectBtnField/tests/__snapshots__/SelectBtnField.spec.ts.snap +52 -38
- package/src/components/Customs/SyCheckBoxGroup/SyCheckBoxGroup.stories.ts +342 -162
- package/src/components/Customs/SyCheckBoxGroup/SyCheckBoxGroup.vue +77 -129
- package/src/components/Customs/SyCheckBoxGroup/Validation/Validation.stories.ts +1008 -0
- package/src/components/Customs/SyCheckBoxGroup/composables/useSyCheckBoxGroupValidation.ts +107 -0
- package/src/components/Customs/SyCheckBoxGroup/tests/SyCheckBoxGroup.spec.ts +180 -7
- package/src/components/Customs/SyCheckBoxGroup/types.ts +49 -0
- package/src/components/Customs/SyCheckbox/SyCheckbox.stories.ts +41 -161
- package/src/components/Customs/SyCheckbox/SyCheckbox.vue +71 -148
- package/src/components/Customs/SyCheckbox/Validation/Validation.stories.ts +654 -0
- package/src/components/Customs/SyCheckbox/composables/useSyCheckboxValidation.ts +105 -0
- package/src/components/Customs/SyCheckbox/tests/SyCheckbox.spec.ts +106 -0
- package/src/components/Customs/SyCheckbox/tests/useSyCheckboxValidation.spec.ts +98 -0
- package/src/components/Customs/SyCheckbox/types.ts +51 -0
- package/src/components/Customs/SyTextField/FieldState.vue +50 -0
- package/src/components/Customs/SyTextField/SyTextField.vue +12 -9
- package/src/components/Customs/SyTextField/useSyTextFieldValidation.ts +2 -11
- package/src/components/DataList/DataList.mdx +0 -1
- package/src/components/DataListGroup/DataListGroup.mdx +0 -1
- package/src/components/DiacriticPicker/DiacriticPicker.mdx +0 -1
- package/src/components/DialogBox/DialogBox.mdx +0 -1
- package/src/components/DialogBox/DialogBox.stories.ts +399 -4
- package/src/components/DialogBox/DialogBox.vue +20 -0
- package/src/components/DialogBox/locales.ts +1 -0
- package/src/components/DialogBox/tests/DialogBox.spec.ts +73 -0
- package/src/components/DialogBox/tests/DialogBox.visual.cy.ts +24 -0
- package/src/components/ErrorPage/ErrorPage.mdx +1 -1
- package/src/components/ExternalLinks/ExternalLinks.mdx +0 -1
- package/src/components/FileList/FileList.mdx +0 -1
- package/src/components/FilterInline/FilterInline.mdx +0 -1
- package/src/components/FilterSideBar/FilterSideBar.mdx +8 -1
- package/src/components/FilterSideBar/FilterSideBar.stories.ts +133 -1
- package/src/components/FilterSideBar/FilterSideBar.vue +19 -2
- package/src/components/FilterSideBar/tests/FilterSideBar.spec.ts +55 -0
- package/src/components/FooterBar/FooterBar.mdx +0 -1
- package/src/components/FranceConnectBtn/FranceConnectBtn.mdx +0 -1
- package/src/components/HeaderBar/HeaderBar.mdx +0 -1
- package/src/components/HeaderLoading/HeaderLoading.mdx +0 -1
- package/src/components/LangBtn/LangBtn.mdx +0 -1
- package/src/components/Logo/Logo.mdx +1 -1
- package/src/components/LunarCalendar/LunarCalendar.mdx +6 -9
- package/src/components/LunarCalendar/LunarCalendar.stories.ts +243 -46
- package/src/components/LunarCalendar/LunarCalendar.vue +61 -26
- package/src/components/LunarCalendar/Validation/Validation.stories.ts +717 -0
- package/src/components/LunarCalendar/tests/LunarCalendar.a11y.spec.ts +1 -1
- package/src/components/LunarCalendar/tests/LunarCalendar.spec.ts +197 -6
- package/src/components/LunarCalendar/tests/useLunarCalendarValidation.spec.ts +287 -0
- package/src/components/LunarCalendar/types.ts +39 -0
- package/src/components/LunarCalendar/useLunarCalendarValidation.ts +115 -39
- package/src/components/MonthPicker/MonthPicker.stories.ts +38 -281
- package/src/components/MonthPicker/MonthPicker.vue +66 -17
- package/src/components/MonthPicker/MonthPickerText/MonthPickerInput.vue +44 -20
- package/src/components/MonthPicker/MonthPickerText/useTextField.ts +5 -0
- package/src/components/MonthPicker/Validation/Validation.stories.ts +1117 -0
- package/src/components/MonthPicker/locales.ts +1 -0
- package/src/components/MonthPicker/tests/MonthPicker.spec.ts +353 -2
- package/src/components/MonthPicker/tests/__snapshots__/MonthPicker.spec.ts.snap +12 -8
- package/src/components/MonthPicker/types.ts +16 -0
- package/src/components/MonthPicker/useMonthPickerValidation.ts +64 -27
- package/src/components/NirField/NirField.mdx +120 -66
- package/src/components/NirField/NirField.stories.ts +216 -0
- package/src/components/NirField/useNirValidation.ts +16 -17
- package/src/components/NotFoundPage/tests/__snapshots__/NotFoundPage.spec.ts.snap +263 -245
- package/src/components/NotificationBar/NotificationBar.mdx +0 -1
- package/src/components/PageContainer/PageContainer.mdx +0 -1
- package/src/components/PageContainer/PageContainer.stories.ts +170 -2
- package/src/components/PageContainer/PageContainer.vue +63 -8
- package/src/components/PageContainer/tests/__snapshots__/PageContainer.spec.ts.snap +19 -11
- package/src/components/PaginatedTable/PaginatedTable.mdx +0 -1
- package/src/components/PeriodField/PeriodField.mdx +0 -1
- package/src/components/PhoneField/PhoneField.mdx +2 -3
- package/src/components/PhoneField/PhoneField.stories.ts +227 -410
- package/src/components/PhoneField/PhoneField.vue +204 -438
- package/src/components/PhoneField/indicatifs.ts +1 -1
- package/src/components/PhoneField/locales.ts +7 -0
- package/src/components/PhoneField/tests/PhoneField.a11y.spec.ts +0 -1
- package/src/components/PhoneField/tests/PhoneField.spec.ts +517 -220
- package/src/components/PhoneField/types.ts +30 -0
- package/src/components/PhoneField/usePhoneFieldValidation.ts +119 -0
- package/src/components/PhoneField/usePhoneIndicatifs.ts +89 -0
- package/src/components/PhoneField/validation/validation.stories.ts +717 -0
- package/src/components/RangeField/RangeField.mdx +0 -1
- package/src/components/RatingPicker/RatingPicker.mdx +0 -1
- package/src/components/SocialMediaLinks/SocialMediaLinks.mdx +0 -1
- package/src/components/StatusPage/StatusPage.vue +1 -0
- package/src/components/StatusPage/tests/__snapshots__/StatusPage.spec.ts.snap +248 -230
- package/src/components/SubHeader/SubHeader.mdx +5 -6
- package/src/components/Tables/common/tests/SyTableFilter.spec.ts +11 -12
- package/src/components/UploadWorkflow/UploadWorkflow.mdx +0 -1
- package/src/components/UserMenuBtn/UserMenuBtn.mdx +0 -1
- package/src/components/UserMenuBtn/UserMenuBtn.stories.ts +177 -0
- package/src/composables/unifyValidation/documentationValidationProps.ts +1 -1
- package/src/composables/unifyValidation/tests/useValidation.spec.ts +13 -1
- package/src/composables/unifyValidation/useValidation.ts +37 -33
- package/src/composantsVuetify/VCard/VCard.mdx +4 -0
- package/src/composantsVuetify/VCard/v-card.stories.ts +93 -1
- package/src/composantsVuetify/VCarousel/VCarousel.mdx +74 -0
- package/src/composantsVuetify/VCarousel/v-carousel.stories.ts +531 -0
- package/src/composantsVuetify/VNavigationDrawer/VNavgationDrawer.mdx +53 -0
- package/src/composantsVuetify/VNavigationDrawer/v-navigation-drawer.stories.ts +310 -0
- package/src/composantsVuetify/VSlideGroup/VSlideGroup.mdx +105 -0
- package/src/composantsVuetify/VSlideGroup/v-slide-group.stories.ts +463 -0
- package/src/designTokens/tokens/baseColors.ts +1 -1
- package/src/designTokens/tokens/baseTokens.ts +18 -18
- package/src/stories/Components/Components.stories.ts +34 -1
- package/src/stories/Demarrer/Releases.stories.ts +16 -2
- package/src/stories/DesignTokens/Arrondis.mdx +1 -1
- package/src/stories/DesignTokens/Correspondances.mdx +219 -0
- package/src/stories/DesignTokens/UtiliserLesTokens.mdx +235 -0
- package/src/stories/DesignTokens/colors.stories.ts +569 -569
- package/src/stories/GuideDuDev/Amelipro.stories.ts +335 -267
- package/dist/components/LunarCalendar/useLunarCalendarRules.d.ts +0 -5
- package/dist/components/PhoneField/tests/types.d.ts +0 -18
- package/src/components/LunarCalendar/tests/useLunarCalendarRules.spec.ts +0 -184
- package/src/components/LunarCalendar/useLunarCalendarRules.ts +0 -96
- package/src/components/PhoneField/tests/types.d.ts +0 -19
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
-
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { flushPromises, mount, VueWrapper } from '@vue/test-utils'
|
|
3
3
|
import Captcha from '../Captcha.vue'
|
|
4
|
+
import type { ComponentPublicInstance } from 'vue/dist/vue.js'
|
|
4
5
|
|
|
5
6
|
describe('Captcha', () => {
|
|
6
7
|
afterEach(() => {
|
|
@@ -24,11 +25,9 @@ describe('Captcha', () => {
|
|
|
24
25
|
})
|
|
25
26
|
|
|
26
27
|
// Wait for the component to fully mount and initialize
|
|
27
|
-
await wrapper.vm.$nextTick()
|
|
28
|
-
await wrapper.vm.$nextTick()
|
|
29
28
|
|
|
30
29
|
// Allow additional time for async initialization
|
|
31
|
-
await
|
|
30
|
+
await flushPromises()
|
|
32
31
|
|
|
33
32
|
expect(fetch).toHaveBeenCalledTimes(1)
|
|
34
33
|
|
|
@@ -64,11 +63,9 @@ describe('Captcha', () => {
|
|
|
64
63
|
})
|
|
65
64
|
|
|
66
65
|
// Wait for the component to fully mount and initialize
|
|
67
|
-
await wrapper.vm.$nextTick()
|
|
68
|
-
await wrapper.vm.$nextTick()
|
|
69
66
|
|
|
70
67
|
// Allow additional time for async initialization
|
|
71
|
-
await
|
|
68
|
+
await flushPromises()
|
|
72
69
|
|
|
73
70
|
expect(fetch).toHaveBeenCalledTimes(1)
|
|
74
71
|
|
|
@@ -156,12 +153,8 @@ describe('Captcha', () => {
|
|
|
156
153
|
},
|
|
157
154
|
})
|
|
158
155
|
|
|
159
|
-
await wrapper.vm.$nextTick()
|
|
160
|
-
await wrapper.vm.$nextTick()
|
|
161
|
-
|
|
162
156
|
// Simulate text input change
|
|
163
|
-
|
|
164
|
-
await (wrapper.vm as any).emitChangeValueEvent('new-text-value')
|
|
157
|
+
await (wrapper.vm as ComponentPublicInstance<typeof Captcha>).emitChangeValueEvent('new-text-value')
|
|
165
158
|
|
|
166
159
|
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
167
160
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new-text-value'])
|
|
@@ -183,9 +176,7 @@ describe('Captcha', () => {
|
|
|
183
176
|
},
|
|
184
177
|
})
|
|
185
178
|
|
|
186
|
-
await
|
|
187
|
-
await wrapper.vm.$nextTick()
|
|
188
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
179
|
+
await flushPromises()
|
|
189
180
|
|
|
190
181
|
expect(fetch).toHaveBeenCalledWith('/captcha/captcha.json', expect.any(Object))
|
|
191
182
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -200,7 +191,7 @@ describe('Captcha', () => {
|
|
|
200
191
|
} as any
|
|
201
192
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
|
202
193
|
|
|
203
|
-
const wrapper = mount(Captcha, {
|
|
194
|
+
const wrapper = mount<typeof Captcha>(Captcha, {
|
|
204
195
|
props: {
|
|
205
196
|
urlCreate: '/captcha/captcha.json',
|
|
206
197
|
urlGetImage: '/captcha/captcha.png',
|
|
@@ -212,11 +203,10 @@ describe('Captcha', () => {
|
|
|
212
203
|
await wrapper.vm.$nextTick()
|
|
213
204
|
|
|
214
205
|
// Change modelValue prop
|
|
215
|
-
await wrapper.setProps({ modelValue: 'updated-value' })
|
|
206
|
+
await (wrapper as VueWrapper<ComponentPublicInstance<typeof Captcha>>).setProps({ modelValue: 'updated-value' })
|
|
216
207
|
await wrapper.vm.$nextTick()
|
|
217
208
|
|
|
218
|
-
|
|
219
|
-
expect((wrapper.vm as any).text).toBe('updated-value')
|
|
209
|
+
expect((wrapper.vm as ComponentPublicInstance<typeof Captcha>).text).toBe('updated-value')
|
|
220
210
|
})
|
|
221
211
|
|
|
222
212
|
it('handles helpDesk prop correctly', async () => {
|
|
@@ -236,9 +226,7 @@ describe('Captcha', () => {
|
|
|
236
226
|
},
|
|
237
227
|
})
|
|
238
228
|
|
|
239
|
-
await
|
|
240
|
-
await wrapper.vm.$nextTick()
|
|
241
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
229
|
+
await flushPromises()
|
|
242
230
|
|
|
243
231
|
expect(wrapper.text()).toContain('1234')
|
|
244
232
|
})
|
|
@@ -260,10 +248,231 @@ describe('Captcha', () => {
|
|
|
260
248
|
},
|
|
261
249
|
})
|
|
262
250
|
|
|
251
|
+
await flushPromises()
|
|
252
|
+
|
|
253
|
+
expect(wrapper.find('.captcha-helpdesk').exists()).toBe(false)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('displays required validation error on blur when captcha text is empty', async () => {
|
|
257
|
+
const response = {
|
|
258
|
+
ok: true,
|
|
259
|
+
json: async () => ({ id: 'captcha-id' }),
|
|
260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
261
|
+
} as any
|
|
262
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
|
263
|
+
|
|
264
|
+
const wrapper = mount(Captcha, {
|
|
265
|
+
props: {
|
|
266
|
+
urlCreate: '/captcha/captcha.json',
|
|
267
|
+
urlGetImage: '/captcha/captcha.png',
|
|
268
|
+
urlGetAudio: '/captcha/captcha.mp3',
|
|
269
|
+
required: true,
|
|
270
|
+
modelValue: '',
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
await flushPromises()
|
|
275
|
+
|
|
276
|
+
const input = wrapper.find('input')
|
|
277
|
+
expect(input.exists()).toBe(true)
|
|
278
|
+
|
|
279
|
+
await input.trigger('focus')
|
|
280
|
+
await input.trigger('blur')
|
|
281
|
+
|
|
282
|
+
expect(wrapper.text()).toContain('est requis')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('applies customRules and displays custom error message on blur', async () => {
|
|
286
|
+
const response = {
|
|
287
|
+
ok: true,
|
|
288
|
+
json: async () => ({ id: 'captcha-id' }),
|
|
289
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
290
|
+
} as any
|
|
291
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
|
292
|
+
|
|
293
|
+
const wrapper = mount(Captcha, {
|
|
294
|
+
props: {
|
|
295
|
+
urlCreate: '/captcha/captcha.json',
|
|
296
|
+
urlGetImage: '/captcha/captcha.png',
|
|
297
|
+
urlGetAudio: '/captcha/captcha.mp3',
|
|
298
|
+
modelValue: 'abc',
|
|
299
|
+
customRules: [{
|
|
300
|
+
type: 'custom',
|
|
301
|
+
options: {
|
|
302
|
+
validate: () => false,
|
|
303
|
+
message: 'Erreur custom captcha',
|
|
304
|
+
},
|
|
305
|
+
}],
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
await flushPromises()
|
|
310
|
+
|
|
311
|
+
const input = wrapper.find('input')
|
|
312
|
+
expect(input.exists()).toBe(true)
|
|
313
|
+
|
|
314
|
+
await input.trigger('focus')
|
|
315
|
+
await input.trigger('blur')
|
|
316
|
+
|
|
317
|
+
expect(wrapper.text()).toContain('Erreur custom captcha')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('applies customWarningRules and displays custom warning message on blur', async () => {
|
|
321
|
+
const response = {
|
|
322
|
+
ok: true,
|
|
323
|
+
json: async () => ({ id: 'captcha-id' }),
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
325
|
+
} as any
|
|
326
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
|
327
|
+
|
|
328
|
+
const wrapper = mount(Captcha, {
|
|
329
|
+
props: {
|
|
330
|
+
urlCreate: '/captcha/captcha.json',
|
|
331
|
+
urlGetImage: '/captcha/captcha.png',
|
|
332
|
+
urlGetAudio: '/captcha/captcha.mp3',
|
|
333
|
+
modelValue: 'abc',
|
|
334
|
+
customWarningRules: [{
|
|
335
|
+
type: 'custom',
|
|
336
|
+
options: {
|
|
337
|
+
validate: () => false,
|
|
338
|
+
isWarning: true,
|
|
339
|
+
warningMessage: 'Warning custom captcha',
|
|
340
|
+
},
|
|
341
|
+
}],
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
|
|
263
345
|
await wrapper.vm.$nextTick()
|
|
264
346
|
await wrapper.vm.$nextTick()
|
|
265
|
-
await
|
|
347
|
+
await flushPromises()
|
|
266
348
|
|
|
267
|
-
|
|
349
|
+
const input = wrapper.find('input')
|
|
350
|
+
expect(input.exists()).toBe(true)
|
|
351
|
+
|
|
352
|
+
await input.trigger('focus')
|
|
353
|
+
await input.trigger('blur')
|
|
354
|
+
await wrapper.vm.$nextTick()
|
|
355
|
+
await wrapper.vm.$nextTick()
|
|
356
|
+
|
|
357
|
+
expect(wrapper.text()).toContain('Warning custom captcha')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('applies customSuccessRules and displays custom success message on blur', async () => {
|
|
361
|
+
const response = {
|
|
362
|
+
ok: true,
|
|
363
|
+
json: async () => ({ id: 'captcha-id' }),
|
|
364
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
365
|
+
} as any
|
|
366
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
|
367
|
+
|
|
368
|
+
const wrapper = mount(Captcha, {
|
|
369
|
+
props: {
|
|
370
|
+
urlCreate: '/captcha/captcha.json',
|
|
371
|
+
urlGetImage: '/captcha/captcha.png',
|
|
372
|
+
urlGetAudio: '/captcha/captcha.mp3',
|
|
373
|
+
modelValue: 'abc',
|
|
374
|
+
showSuccessMessages: true,
|
|
375
|
+
customSuccessRules: [{
|
|
376
|
+
type: 'custom',
|
|
377
|
+
options: {
|
|
378
|
+
validate: () => true,
|
|
379
|
+
successMessage: 'Succès custom captcha',
|
|
380
|
+
},
|
|
381
|
+
}],
|
|
382
|
+
},
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
await wrapper.vm.$nextTick()
|
|
386
|
+
await wrapper.vm.$nextTick()
|
|
387
|
+
await flushPromises()
|
|
388
|
+
|
|
389
|
+
const input = wrapper.find('input')
|
|
390
|
+
expect(input.exists()).toBe(true)
|
|
391
|
+
|
|
392
|
+
await input.trigger('focus')
|
|
393
|
+
await input.trigger('blur')
|
|
394
|
+
await wrapper.vm.$nextTick()
|
|
395
|
+
await wrapper.vm.$nextTick()
|
|
396
|
+
|
|
397
|
+
expect(wrapper.text()).toContain('Succès custom captcha')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('resets the validation when the exposed clearValidation method is called', async () => {
|
|
401
|
+
const response = {
|
|
402
|
+
ok: true,
|
|
403
|
+
json: async () => ({ id: 'captcha-id' }),
|
|
404
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
405
|
+
} as any
|
|
406
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
|
407
|
+
|
|
408
|
+
const wrapper = mount(Captcha, {
|
|
409
|
+
props: {
|
|
410
|
+
urlCreate: '/captcha/captcha.json',
|
|
411
|
+
urlGetImage: '/captcha/captcha.png',
|
|
412
|
+
urlGetAudio: '/captcha/captcha.mp3',
|
|
413
|
+
required: true,
|
|
414
|
+
modelValue: '',
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
await wrapper.vm.$nextTick()
|
|
419
|
+
await wrapper.vm.$nextTick()
|
|
420
|
+
await flushPromises()
|
|
421
|
+
|
|
422
|
+
const input = wrapper.find('input')
|
|
423
|
+
expect(input.exists()).toBe(true)
|
|
424
|
+
|
|
425
|
+
await input.trigger('focus')
|
|
426
|
+
await input.trigger('blur')
|
|
427
|
+
await wrapper.vm.$nextTick()
|
|
428
|
+
await wrapper.vm.$nextTick()
|
|
429
|
+
|
|
430
|
+
expect(wrapper.text()).toContain('est requis')
|
|
431
|
+
|
|
432
|
+
// Call the exposed clearValidation method
|
|
433
|
+
await (wrapper.vm as ComponentPublicInstance<typeof Captcha>).clearValidation()
|
|
434
|
+
|
|
435
|
+
expect(wrapper.text()).not.toContain('est requis')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('resets the field and the validation when the the exposed reset method is called', async () => {
|
|
439
|
+
const response = {
|
|
440
|
+
ok: true,
|
|
441
|
+
json: async () => ({ id: 'captcha-id' }),
|
|
442
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
443
|
+
} as any
|
|
444
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
|
445
|
+
const wrapper = mount(Captcha, {
|
|
446
|
+
props: {
|
|
447
|
+
urlCreate: '/captcha/captcha.json',
|
|
448
|
+
urlGetImage: '/captcha/captcha.png',
|
|
449
|
+
urlGetAudio: '/captcha/captcha.mp3',
|
|
450
|
+
required: true,
|
|
451
|
+
modelValue: '',
|
|
452
|
+
customRules: [{
|
|
453
|
+
type: 'custom',
|
|
454
|
+
options: {
|
|
455
|
+
validate: () => false,
|
|
456
|
+
message: 'Erreur custom captcha',
|
|
457
|
+
},
|
|
458
|
+
}],
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
await flushPromises()
|
|
463
|
+
|
|
464
|
+
const input = wrapper.find('input')
|
|
465
|
+
expect(input.exists()).toBe(true)
|
|
466
|
+
|
|
467
|
+
await input.trigger('focus')
|
|
468
|
+
await input.setValue('abc')
|
|
469
|
+
await input.trigger('blur')
|
|
470
|
+
|
|
471
|
+
expect(wrapper.text()).toContain('Erreur custom captcha')
|
|
472
|
+
|
|
473
|
+
await (wrapper.vm as ComponentPublicInstance<typeof Captcha>).reset()
|
|
474
|
+
|
|
475
|
+
expect(wrapper.text()).not.toContain('Erreur custom captcha')
|
|
476
|
+
expect(input.element.value).toBe('')
|
|
268
477
|
})
|
|
269
478
|
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* eslint-disable vue/one-component-per-file */
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { defineComponent } from 'vue'
|
|
4
|
+
import { mount } from '@vue/test-utils'
|
|
5
|
+
import CaptchaForm from '../CaptchaForm.vue'
|
|
6
|
+
import { locales } from '../locales'
|
|
7
|
+
|
|
8
|
+
const SyTextFieldStub = defineComponent({
|
|
9
|
+
name: 'SyTextField',
|
|
10
|
+
props: {
|
|
11
|
+
errorMessages: { type: Array, default: () => [] },
|
|
12
|
+
hasSuccess: { type: Boolean, default: false },
|
|
13
|
+
readonly: { type: Boolean, default: false },
|
|
14
|
+
isClearable: { type: Boolean, default: false },
|
|
15
|
+
disabled: { type: Boolean, default: false },
|
|
16
|
+
showSuccessMessages: { type: Boolean, default: true },
|
|
17
|
+
label: { type: String, default: '' },
|
|
18
|
+
customRules: { type: Array, default: () => [] },
|
|
19
|
+
},
|
|
20
|
+
emits: ['update:modelValue'],
|
|
21
|
+
template: '<div class="sy-text-field-stub" />',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const VSheetStub = defineComponent({
|
|
25
|
+
name: 'VSheet',
|
|
26
|
+
template: '<div class="v-sheet-stub"><slot /></div>',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('CaptchaForm', () => {
|
|
30
|
+
it('forwards current captcha props to SyTextField', () => {
|
|
31
|
+
const wrapper = mount(CaptchaForm, {
|
|
32
|
+
props: {
|
|
33
|
+
label: 'Captcha label',
|
|
34
|
+
locales,
|
|
35
|
+
state: 'rejected',
|
|
36
|
+
errorMessages: ['error message'],
|
|
37
|
+
success: true,
|
|
38
|
+
},
|
|
39
|
+
global: {
|
|
40
|
+
stubs: {
|
|
41
|
+
VSheet: VSheetStub,
|
|
42
|
+
SyTextField: SyTextFieldStub,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const textField = wrapper.findComponent({ name: 'SyTextField' })
|
|
48
|
+
|
|
49
|
+
expect(textField.exists()).toBe(true)
|
|
50
|
+
expect(textField.props('errorMessages')).toEqual(['error message'])
|
|
51
|
+
expect(textField.props('hasSuccess')).toBe(false)
|
|
52
|
+
expect(textField.props('readonly')).toBe(true)
|
|
53
|
+
expect(textField.props('isClearable')).toBe(false)
|
|
54
|
+
expect(textField.props('disabled')).toBe(true)
|
|
55
|
+
expect(textField.props('showSuccessMessages')).toBe(false)
|
|
56
|
+
expect(textField.props('label')).toBe('Captcha label')
|
|
57
|
+
expect(textField.props('customRules')).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('emits update:modelValue when SyTextField updates the value', async () => {
|
|
61
|
+
const wrapper = mount(CaptchaForm, {
|
|
62
|
+
props: {
|
|
63
|
+
label: 'Captcha label',
|
|
64
|
+
locales,
|
|
65
|
+
errors: [],
|
|
66
|
+
success: false,
|
|
67
|
+
},
|
|
68
|
+
global: {
|
|
69
|
+
stubs: {
|
|
70
|
+
VSheet: VSheetStub,
|
|
71
|
+
SyTextField: SyTextFieldStub,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const textField = wrapper.findComponent({ name: 'SyTextField' })
|
|
77
|
+
await textField.vm.$emit('update:modelValue', 'abc123')
|
|
78
|
+
|
|
79
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
80
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['abc123'])
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -117,22 +117,22 @@ exports[`Captcha > renders correctly in audio mode 1`] = `
|
|
|
117
117
|
v-field-label
|
|
118
118
|
v-label
|
|
119
119
|
"
|
|
120
|
-
for="input-v-
|
|
121
|
-
id="input-v-
|
|
120
|
+
for="input-v-4"
|
|
121
|
+
id="input-v-4-label"
|
|
122
122
|
>
|
|
123
123
|
<!---->
|
|
124
|
-
Caractères de l’
|
|
124
|
+
Caractères de l’image
|
|
125
125
|
</label>
|
|
126
126
|
<!---->
|
|
127
127
|
<input
|
|
128
|
-
aria-
|
|
129
|
-
aria-
|
|
130
|
-
aria-
|
|
128
|
+
aria-label="Caractères de l’image"
|
|
129
|
+
aria-labelledby="input-v-4-label"
|
|
130
|
+
aria-required="false"
|
|
131
131
|
autocomplete="off"
|
|
132
132
|
class="v-field__input"
|
|
133
133
|
direction="horizontal"
|
|
134
|
-
id="input-v-
|
|
135
|
-
title="Caractères de l’
|
|
134
|
+
id="input-v-4"
|
|
135
|
+
title="Caractères de l’image"
|
|
136
136
|
type="text"
|
|
137
137
|
/>
|
|
138
138
|
<!---->
|
|
@@ -158,7 +158,7 @@ exports[`Captcha > renders correctly in audio mode 1`] = `
|
|
|
158
158
|
"
|
|
159
159
|
>
|
|
160
160
|
<!---->
|
|
161
|
-
Caractères de l’
|
|
161
|
+
Caractères de l’image
|
|
162
162
|
</label>
|
|
163
163
|
</div>
|
|
164
164
|
<div class="v-field__outline__end"></div>
|
|
@@ -167,20 +167,7 @@ exports[`Captcha > renders correctly in audio mode 1`] = `
|
|
|
167
167
|
</div>
|
|
168
168
|
</div>
|
|
169
169
|
<!---->
|
|
170
|
-
|
|
171
|
-
<transition-group-stub
|
|
172
|
-
appear="false"
|
|
173
|
-
class="v-messages"
|
|
174
|
-
css="true"
|
|
175
|
-
id="input-v-3-messages"
|
|
176
|
-
name="slide-y-transition"
|
|
177
|
-
persisted="false"
|
|
178
|
-
tag="div"
|
|
179
|
-
>
|
|
180
|
-
<!---->
|
|
181
|
-
</transition-group-stub>
|
|
182
|
-
<!---->
|
|
183
|
-
</div>
|
|
170
|
+
<!---->
|
|
184
171
|
</div>
|
|
185
172
|
<!-- v-if -->
|
|
186
173
|
</div>
|
|
@@ -461,21 +448,21 @@ exports[`Captcha > renders correctly in image mode 1`] = `
|
|
|
461
448
|
v-field-label
|
|
462
449
|
v-label
|
|
463
450
|
"
|
|
464
|
-
for="input-v-
|
|
465
|
-
id="input-v-
|
|
451
|
+
for="input-v-3"
|
|
452
|
+
id="input-v-3-label"
|
|
466
453
|
>
|
|
467
454
|
<!---->
|
|
468
455
|
Caractères de l’image
|
|
469
456
|
</label>
|
|
470
457
|
<!---->
|
|
471
458
|
<input
|
|
472
|
-
aria-describedby="input-v-2-messages"
|
|
473
459
|
aria-label="Caractères de l’image"
|
|
474
|
-
aria-labelledby="input-v-
|
|
460
|
+
aria-labelledby="input-v-3-label"
|
|
461
|
+
aria-required="false"
|
|
475
462
|
autocomplete="off"
|
|
476
463
|
class="v-field__input"
|
|
477
464
|
direction="horizontal"
|
|
478
|
-
id="input-v-
|
|
465
|
+
id="input-v-3"
|
|
479
466
|
title="Caractères de l’image"
|
|
480
467
|
type="text"
|
|
481
468
|
/>
|
|
@@ -511,20 +498,7 @@ exports[`Captcha > renders correctly in image mode 1`] = `
|
|
|
511
498
|
</div>
|
|
512
499
|
</div>
|
|
513
500
|
<!---->
|
|
514
|
-
|
|
515
|
-
<transition-group-stub
|
|
516
|
-
appear="false"
|
|
517
|
-
class="v-messages"
|
|
518
|
-
css="true"
|
|
519
|
-
id="input-v-2-messages"
|
|
520
|
-
name="slide-y-transition"
|
|
521
|
-
persisted="false"
|
|
522
|
-
tag="div"
|
|
523
|
-
>
|
|
524
|
-
<!---->
|
|
525
|
-
</transition-group-stub>
|
|
526
|
-
<!---->
|
|
527
|
-
</div>
|
|
501
|
+
<!---->
|
|
528
502
|
</div>
|
|
529
503
|
<!-- v-if -->
|
|
530
504
|
</div>
|
|
@@ -1,2 +1,17 @@
|
|
|
1
1
|
export type CaptchaType = 'image' | 'audio' | 'choice'
|
|
2
2
|
export type StateType = 'idle' | 'pending' | 'resolved' | 'rejected'
|
|
3
|
+
import type { FieldValidationProps } from '@/composables/unifyValidation/useValidation'
|
|
4
|
+
import type { locales as defaultLocales } from './locales'
|
|
5
|
+
|
|
6
|
+
export type CaptchaProps = FieldValidationProps & {
|
|
7
|
+
modelValue?: string | undefined
|
|
8
|
+
urlCreate: string
|
|
9
|
+
urlGetImage: string
|
|
10
|
+
urlGetAudio: string
|
|
11
|
+
type?: CaptchaType
|
|
12
|
+
tagTitle?: string
|
|
13
|
+
helpDesk?: string | false
|
|
14
|
+
isClearable?: boolean
|
|
15
|
+
locale?: string
|
|
16
|
+
locales?: typeof defaultLocales
|
|
17
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { computed, type Ref } from 'vue'
|
|
2
|
+
import { useValidation, type VuetifyValidationRule } from '@/composables/unifyValidation/useValidation'
|
|
3
|
+
import type { ValidationRule as SyValidationRule } from '@/composables/validation/useValidation'
|
|
4
|
+
import type { locales as defaultLocales } from './locales'
|
|
5
|
+
|
|
6
|
+
export function useCaptchaValidation(params: {
|
|
7
|
+
modelValue: Ref<string | null>
|
|
8
|
+
readonly: Ref<boolean>
|
|
9
|
+
disabled: Ref<boolean>
|
|
10
|
+
required: Ref<boolean>
|
|
11
|
+
isValidateOnBlur: Ref<boolean>
|
|
12
|
+
showSuccessMessages: Ref<boolean>
|
|
13
|
+
disableErrorHandling: Ref<boolean>
|
|
14
|
+
useVuetifyValidation: Ref<boolean>
|
|
15
|
+
label: Ref<string>
|
|
16
|
+
rules: Ref<VuetifyValidationRule[] | undefined>
|
|
17
|
+
customRules: Ref<SyValidationRule[] | undefined>
|
|
18
|
+
customWarningRules: Ref<SyValidationRule[] | undefined>
|
|
19
|
+
customSuccessRules: Ref<SyValidationRule[] | undefined>
|
|
20
|
+
errorMessages: Ref<string[] | undefined | null>
|
|
21
|
+
warningMessages: Ref<string[] | undefined | null>
|
|
22
|
+
successMessages: Ref<string[] | undefined | null>
|
|
23
|
+
hasErrorProp: Ref<boolean>
|
|
24
|
+
hasWarningProp: Ref<boolean>
|
|
25
|
+
hasSuccessProp: Ref<boolean>
|
|
26
|
+
maxErrors: Ref<number | undefined>
|
|
27
|
+
focused: Ref<boolean>
|
|
28
|
+
locales: Ref<typeof defaultLocales>
|
|
29
|
+
},
|
|
30
|
+
) {
|
|
31
|
+
const defaultRules = computed<SyValidationRule[]>(() => params.required
|
|
32
|
+
? [{
|
|
33
|
+
type: 'required',
|
|
34
|
+
options: {
|
|
35
|
+
message: params.locales.value.required,
|
|
36
|
+
fieldIdentifier: params.label.value,
|
|
37
|
+
},
|
|
38
|
+
}]
|
|
39
|
+
: [],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Un captcha rempli n'est pas « valide » pour autant : la réponse doit être vérifiée.
|
|
43
|
+
// On n'autorise donc le succès que s'il existe une source de succès explicite
|
|
44
|
+
// (règle de succès, règle d'erreur portant un successMessage, message de succès injecté,
|
|
45
|
+
// ou état de succès forcé) — sinon le succès « champ rempli = valide » par défaut est ignoré.
|
|
46
|
+
const hasExplicitSuccessSource = computed(() =>
|
|
47
|
+
(params.customSuccessRules.value?.length ?? 0) > 0
|
|
48
|
+
|| (params.customRules.value ?? []).some(rule => !!rule.options?.successMessage)
|
|
49
|
+
|| (params.successMessages.value?.length ?? 0) > 0
|
|
50
|
+
|| params.hasSuccessProp.value,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const { validate, clearValidation, errors, warnings, successes, hasError, hasWarning, hasSuccess } = useValidation({
|
|
54
|
+
modelValue: params.modelValue,
|
|
55
|
+
readonly: params.readonly,
|
|
56
|
+
disabled: params.disabled,
|
|
57
|
+
required: params.required,
|
|
58
|
+
isValidateOnBlur: params.isValidateOnBlur,
|
|
59
|
+
showSuccessMessages: params.showSuccessMessages,
|
|
60
|
+
disableErrorHandling: params.disableErrorHandling,
|
|
61
|
+
useVuetifyValidation: params.useVuetifyValidation,
|
|
62
|
+
label: params.label,
|
|
63
|
+
rules: params.rules ?? [],
|
|
64
|
+
customRules: computed(() => [...defaultRules.value, ...(params.customRules.value ?? [])]),
|
|
65
|
+
customWarningRules: computed(() => params.customWarningRules.value ?? []),
|
|
66
|
+
customSuccessRules: computed(() => params.customSuccessRules.value ?? []),
|
|
67
|
+
errorMessages: params.errorMessages,
|
|
68
|
+
warningMessages: params.warningMessages,
|
|
69
|
+
successMessages: params.successMessages,
|
|
70
|
+
hasErrorProp: params.hasErrorProp,
|
|
71
|
+
hasWarningProp: params.hasWarningProp,
|
|
72
|
+
hasSuccessProp: params.hasSuccessProp,
|
|
73
|
+
maxErrors: computed(() => params.maxErrors.value ?? 1),
|
|
74
|
+
focused: params.focused,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
validate,
|
|
79
|
+
clearValidation,
|
|
80
|
+
errors,
|
|
81
|
+
warnings,
|
|
82
|
+
successes: computed(() => hasExplicitSuccessSource.value ? successes.value : []),
|
|
83
|
+
hasError,
|
|
84
|
+
hasWarning,
|
|
85
|
+
hasSuccess: computed(() => hasExplicitSuccessSource.value && hasSuccess.value),
|
|
86
|
+
}
|
|
87
|
+
}
|