|
1 | 1 | <template> |
2 | 2 | <form ref="formRef" :class="containerClass" @submit.prevent> |
3 | 3 | <template v-for="(component, index) in internalSchema" :key="component.name || component.id || index"> |
4 | | - <!-- Render action buttons with form data --> |
| 4 | + <!-- Group consecutive actions together (only render on first action in group) --> |
| 5 | + <div v-if="isAction(component) && !isPreviousItemAction(index) && isNextItemAction(index)" class="flex flex-wrap gap-2 items-start"> |
| 6 | + <FormActionButton |
| 7 | + v-bind="component" |
| 8 | + :getFormData="getFormData" |
| 9 | + /> |
| 10 | + <template v-for="(nextComponent, nextIndex) in getConsecutiveActions(index)" :key="nextComponent.name || nextComponent.id || (index + nextIndex + 1)"> |
| 11 | + <FormActionButton |
| 12 | + v-bind="nextComponent" |
| 13 | + :getFormData="getFormData" |
| 14 | + /> |
| 15 | + </template> |
| 16 | + </div> |
| 17 | + |
| 18 | + <!-- Render single action (not part of a group) --> |
5 | 19 | <FormActionButton |
6 | | - v-if="isAction(component)" |
| 20 | + v-else-if="isAction(component) && !isPreviousItemAction(index) && !isNextItemAction(index)" |
7 | 21 | v-bind="component" |
8 | 22 | :getFormData="getFormData" |
9 | 23 | /> |
10 | 24 |
|
11 | 25 | <!-- Render regular form components --> |
12 | 26 | <component |
13 | | - v-else |
| 27 | + v-else-if="!isAction(component)" |
14 | 28 | :is="getComponent(component)" |
15 | 29 | v-bind="getComponentProps(component)" |
16 | 30 | :value="isSchemaComponent(component) ? undefined : internalFormData[component.name]" |
17 | 31 | :modelValue="isSchemaComponent(component) ? internalFormData : undefined" |
| 32 | + :error="getError(component)" |
18 | 33 | @update:model-value="(value) => handleComponentUpdate(component, value)" |
19 | 34 | /> |
20 | 35 | </template> |
21 | 36 | </form> |
22 | 37 | </template> |
23 | 38 |
|
24 | 39 | <script setup lang="ts"> |
25 | | -import { defineAsyncComponent, onMounted, onUnmounted, computed, h, ref, watch, provide, nextTick } from 'vue' |
| 40 | +import { defineAsyncComponent, onMounted, onUnmounted, computed, h, ref, watch, provide, inject, nextTick } from 'vue' |
26 | 41 | import ActionButton from '@laravilt/actions/components/ActionButton.vue' |
27 | 42 |
|
28 | 43 | const formRef = ref<HTMLFormElement | null>(null) |
29 | 44 | const internalFormData = ref<Record<string, any>>({}) |
30 | 45 |
|
| 46 | +// Local validation errors for client-side validation |
| 47 | +const localErrors = ref<Record<string, string>>({}) |
| 48 | +
|
| 49 | +// Inject errors from ErrorProvider (it's a computed ref) |
| 50 | +const injectedErrors = inject<any>('errors', {}) |
| 51 | +
|
| 52 | +// Merged errors: local client-side errors + server-side errors |
| 53 | +const errors = computed(() => { |
| 54 | + const serverErrors = injectedErrors?.value || injectedErrors || {} |
| 55 | + return { ...localErrors.value, ...serverErrors } |
| 56 | +}) |
| 57 | +
|
31 | 58 | // Create a wrapper component for form actions that can collect form data |
32 | 59 | const FormActionButton = { |
33 | 60 | props: { |
@@ -295,20 +322,95 @@ const getFormData = () => { |
295 | 322 | return { ...internalFormData.value } |
296 | 323 | } |
297 | 324 |
|
298 | | -// Validate the form using HTML5 validation |
| 325 | +// Extract required fields from schema recursively |
| 326 | +const extractRequiredFields = (schema: Array<any>): Array<{ name: string; label: string }> => { |
| 327 | + const requiredFields: Array<{ name: string; label: string }> = [] |
| 328 | + const schemaComponentTypes = ['tabs', 'section', 'grid'] |
| 329 | +
|
| 330 | + if (!schema || !Array.isArray(schema)) { |
| 331 | + return requiredFields |
| 332 | + } |
| 333 | +
|
| 334 | + for (const component of schema) { |
| 335 | + // Skip actions |
| 336 | + if (component.hasAction === true || (component.name && !component.component)) { |
| 337 | + continue |
| 338 | + } |
| 339 | +
|
| 340 | + // If it's a tabs component, extract from all tabs |
| 341 | + if (component.component === 'tabs' && component.tabs && Array.isArray(component.tabs)) { |
| 342 | + for (const tab of component.tabs) { |
| 343 | + if (tab.schema && Array.isArray(tab.schema)) { |
| 344 | + requiredFields.push(...extractRequiredFields(tab.schema)) |
| 345 | + } |
| 346 | + } |
| 347 | + continue |
| 348 | + } |
| 349 | +
|
| 350 | + // If it's a schema component (section, grid), recurse into its schema |
| 351 | + if (schemaComponentTypes.includes(component.component) && component.schema && Array.isArray(component.schema)) { |
| 352 | + requiredFields.push(...extractRequiredFields(component.schema)) |
| 353 | + continue |
| 354 | + } |
| 355 | +
|
| 356 | + // If it has a name and is required, add to the list |
| 357 | + if (component.name && component.required) { |
| 358 | + requiredFields.push({ |
| 359 | + name: component.name, |
| 360 | + label: component.label || component.name |
| 361 | + }) |
| 362 | + } |
| 363 | + } |
| 364 | +
|
| 365 | + return requiredFields |
| 366 | +} |
| 367 | +
|
| 368 | +// Check if a value is empty (null, undefined, empty string, empty array) |
| 369 | +const isValueEmpty = (value: any): boolean => { |
| 370 | + if (value === null || value === undefined) return true |
| 371 | + if (typeof value === 'string' && value.trim() === '') return true |
| 372 | + if (Array.isArray(value) && value.length === 0) return true |
| 373 | + return false |
| 374 | +} |
| 375 | +
|
| 376 | +// Validate the form - combines HTML5 validation with custom required field checks |
299 | 377 | const validateForm = () => { |
300 | | - if (!formRef.value) { |
301 | | - return true |
| 378 | + // Clear previous local validation errors |
| 379 | + localErrors.value = {} |
| 380 | +
|
| 381 | + // Get all required fields from schema |
| 382 | + const requiredFields = extractRequiredFields(internalSchema.value) |
| 383 | +
|
| 384 | + // Check each required field |
| 385 | + const newErrors: Record<string, string> = {} |
| 386 | + for (const field of requiredFields) { |
| 387 | + const value = internalFormData.value[field.name] |
| 388 | + if (isValueEmpty(value)) { |
| 389 | + newErrors[field.name] = `${field.label} is required.` |
| 390 | + } |
302 | 391 | } |
303 | 392 |
|
304 | | - const isValid = formRef.value.checkValidity() |
| 393 | + // If there are validation errors, set them and return false |
| 394 | + if (Object.keys(newErrors).length > 0) { |
| 395 | + localErrors.value = newErrors |
| 396 | + return false |
| 397 | + } |
305 | 398 |
|
306 | | - if (!isValid) { |
307 | | - // Trigger validation UI (show error messages) |
308 | | - formRef.value.reportValidity() |
| 399 | + // Also run HTML5 validation for native form elements |
| 400 | + if (formRef.value) { |
| 401 | + const isHtml5Valid = formRef.value.checkValidity() |
| 402 | + if (!isHtml5Valid) { |
| 403 | + formRef.value.reportValidity() |
| 404 | + return false |
| 405 | + } |
309 | 406 | } |
310 | 407 |
|
311 | | - return isValid |
| 408 | + return true |
| 409 | +} |
| 410 | +
|
| 411 | +// Clear local validation errors (useful when modal closes or form resets) |
| 412 | +const clearValidationErrors = () => { |
| 413 | + localErrors.value = {} |
312 | 414 | } |
313 | 415 |
|
314 | 416 | // Function to update schema (for reactive fields) |
@@ -338,18 +440,52 @@ const updateSchema = (newSchema: any[]) => { |
338 | 440 | }) |
339 | 441 | } |
340 | 442 |
|
341 | | -// Provide getFormData, validateForm, updateSchema, and schemaId to all child components |
| 443 | +// Provide getFormData, validateForm, updateSchema, schemaId, and errors to all child components |
342 | 444 | provide('getFormData', getFormData) |
343 | 445 | provide('validateForm', validateForm) |
344 | 446 | provide('updateSchema', updateSchema) |
345 | 447 | provide('schemaId', props.schemaId || null) |
| 448 | +// Override the errors provided by ErrorProvider with our merged errors (local + server) |
| 449 | +provide('errors', errors) |
| 450 | +
|
| 451 | +// Expose getFormData, validateForm, and clearValidationErrors to parent components via template ref |
| 452 | +defineExpose({ |
| 453 | + getFormData, |
| 454 | + validateForm, |
| 455 | + clearValidationErrors |
| 456 | +}) |
346 | 457 |
|
347 | 458 | // Check if an item is an action |
348 | 459 | const isAction = (item: any) => { |
349 | 460 | // Actions have hasAction property or don't have a component property |
350 | 461 | return item.hasAction === true || (item.name && !item.component) |
351 | 462 | } |
352 | 463 |
|
| 464 | +// Check if the next item is an action |
| 465 | +const isNextItemAction = (index: number) => { |
| 466 | + const nextItem = internalSchema.value[index + 1] |
| 467 | + return nextItem && isAction(nextItem) |
| 468 | +} |
| 469 | +
|
| 470 | +// Check if the previous item is an action |
| 471 | +const isPreviousItemAction = (index: number) => { |
| 472 | + const prevItem = internalSchema.value[index - 1] |
| 473 | + return prevItem && isAction(prevItem) |
| 474 | +} |
| 475 | +
|
| 476 | +// Get all consecutive actions starting from the next index |
| 477 | +const getConsecutiveActions = (startIndex: number) => { |
| 478 | + const actions = [] |
| 479 | + let currentIndex = startIndex + 1 |
| 480 | +
|
| 481 | + while (currentIndex < internalSchema.value.length && isAction(internalSchema.value[currentIndex])) { |
| 482 | + actions.push(internalSchema.value[currentIndex]) |
| 483 | + currentIndex++ |
| 484 | + } |
| 485 | +
|
| 486 | + return actions |
| 487 | +} |
| 488 | +
|
353 | 489 | // Check if a component is a schema component (needs entire modelValue, not just a field value) |
354 | 490 | const isSchemaComponent = (component: any) => { |
355 | 491 | const schemaComponents = ['tabs', 'section', 'grid'] |
@@ -416,4 +552,13 @@ const getComponentProps = (component: any) => { |
416 | 552 | const { value, modelValue, ...props } = component |
417 | 553 | return props |
418 | 554 | } |
| 555 | +
|
| 556 | +// Get error message for a component |
| 557 | +const getError = (component: any) => { |
| 558 | + // errors is a computed ref, access its value |
| 559 | + const errorsObj = errors.value |
| 560 | + if (!component.name || !errorsObj) return undefined |
| 561 | + const error = errorsObj[component.name] |
| 562 | + return Array.isArray(error) ? error[0] : error |
| 563 | +} |
419 | 564 | </script> |
0 commit comments