1212 lines
29 KiB
Vue
1212 lines
29 KiB
Vue
<template>
|
|
<form class="dynamic-form">
|
|
<div class="form-container">
|
|
<div class="form-row">
|
|
<div
|
|
v-for="field in fields"
|
|
:key="field.name"
|
|
:class="getFieldColumnClasses(field)"
|
|
class="form-field"
|
|
>
|
|
<!-- Text Input -->
|
|
<div v-if="field.type === 'text'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<InputText
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:type="field.format || 'text'"
|
|
:placeholder="field.placeholder"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:readonly="field.readonly"
|
|
:invalid="!!getFieldError(field.name)"
|
|
fluid
|
|
:maxlength="field.maxLength"
|
|
:inputmode="field.inputMode"
|
|
:pattern="field.pattern"
|
|
@keydown="handleKeyDown(field, $event)"
|
|
@input="handleTextInput(field, $event)"
|
|
@blur="
|
|
handleFieldBlur(
|
|
field,
|
|
$event.target ? $event.target.value : fieldValues[field.name],
|
|
)
|
|
"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- Number Input -->
|
|
<div v-else-if="field.type === 'number'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<InputNumber
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:placeholder="field.placeholder"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:readonly="field.readonly"
|
|
:min="field.min"
|
|
:max="field.max"
|
|
:step="field.step"
|
|
:invalid="!!getFieldError(field.name)"
|
|
fluid
|
|
@input="handleFieldChange(field, $event.value)"
|
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- Textarea -->
|
|
<div v-else-if="field.type === 'textarea'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<Textarea
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:placeholder="field.placeholder"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:readonly="field.readonly"
|
|
:rows="field.rows || 3"
|
|
:invalid="!!getFieldError(field.name)"
|
|
fluid
|
|
:autoResize="field.autoResize !== false"
|
|
@input="
|
|
handleFieldChange(
|
|
field,
|
|
$event.target ? $event.target.value : $event,
|
|
)
|
|
"
|
|
@blur="
|
|
handleFieldBlur(
|
|
field,
|
|
$event.target ? $event.target.value : fieldValues[field.name],
|
|
)
|
|
"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- Select Dropdown -->
|
|
<div v-else-if="field.type === 'select'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<Select
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:options="field.options"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:placeholder="field.placeholder"
|
|
:invalid="!!getFieldError(field.name)"
|
|
fluid
|
|
appendTo="body"
|
|
@update:model-value="handleFieldChange(field, $event)"
|
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- AutoComplete Input (NEW!) -->
|
|
<div v-else-if="field.type === 'autocomplete'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<AutoComplete
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:suggestions="field.filteredOptions || field.options || []"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:placeholder="field.placeholder"
|
|
:invalid="!!getFieldError(field.name)"
|
|
fluid
|
|
:dropdown="field.dropdown"
|
|
:forceSelection="field.forceSelection"
|
|
appendTo="body"
|
|
@complete="handleAutocompleteSearch(field, $event)"
|
|
@update:model-value="handleFieldChange(field, $event)"
|
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- Checkbox -->
|
|
<div v-else-if="field.type === 'checkbox'" class="field-wrapper">
|
|
<div class="checkbox-container">
|
|
<Checkbox
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:binary="true"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:invalid="!!getFieldError(field.name)"
|
|
@update:model-value="handleFieldChange(field, $event)"
|
|
/>
|
|
<label v-if="field.label" :for="field.name" class="checkbox-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
</div>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- Radio Group -->
|
|
<div v-else-if="field.type === 'radio'" class="field-wrapper">
|
|
<label v-if="field.label" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<div class="radio-group">
|
|
<div
|
|
v-for="option in field.options"
|
|
:key="option.value"
|
|
class="radio-option"
|
|
>
|
|
<RadioButton
|
|
:id="`${field.name}_${option.value}`"
|
|
v-model="fieldValues[field.name]"
|
|
:name="field.name"
|
|
:value="option.value"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:invalid="!!getFieldError(field.name)"
|
|
@update:model-value="handleFieldChange(field, $event)"
|
|
/>
|
|
<label :for="`${field.name}_${option.value}`" class="radio-label">
|
|
{{ option.label }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- Date Input -->
|
|
<div v-else-if="field.type === 'date'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<DatePicker
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:placeholder="field.placeholder"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:readonly="field.readonly"
|
|
:minDate="field.minDate"
|
|
:maxDate="field.maxDate"
|
|
:invalid="!!getFieldError(field.name)"
|
|
fluid
|
|
showIcon
|
|
iconDisplay="input"
|
|
:dateFormat="field.dateFormat || 'dd/mm/yy'"
|
|
@update:model-value="handleFieldChange(field, $event)"
|
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- DateTime Input -->
|
|
<div v-else-if="field.type === 'datetime'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<DatePicker
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
:placeholder="field.placeholder"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:readonly="field.readonly"
|
|
:minDate="field.minDate"
|
|
:maxDate="field.maxDate"
|
|
:invalid="!!getFieldError(field.name)"
|
|
fluid
|
|
showIcon
|
|
iconDisplay="input"
|
|
showTime
|
|
:hourFormat="field.hourFormat || '24'"
|
|
:dateFormat="field.dateFormat || 'dd/mm/yy'"
|
|
@update:model-value="handleFieldChange(field, $event)"
|
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
|
|
<!-- File Input -->
|
|
<div v-else-if="field.type === 'file'" class="field-wrapper">
|
|
<label v-if="field.label" :for="field.name" class="field-label">
|
|
{{ field.label }}
|
|
<span v-if="field.required" class="required">*</span>
|
|
</label>
|
|
<FileUpload
|
|
:id="field.name"
|
|
v-model="fieldValues[field.name]"
|
|
mode="basic"
|
|
:disabled="field.disabled || isFormDisabled"
|
|
:accept="field.accept"
|
|
:multiple="field.multiple"
|
|
:invalidFileTypeMessage="`Invalid file type. Accepted types: ${field.accept || 'any'}`"
|
|
:maxFileSize="field.maxFileSize"
|
|
:invalidFileSizeMessage="`File size exceeds the limit of ${field.maxFileSize} bytes`"
|
|
:chooseLabel="field.chooseLabel || 'Choose File'"
|
|
:showUploadButton="false"
|
|
:showCancelButton="false"
|
|
auto
|
|
@upload="handleFieldChange(field, $event.files)"
|
|
@select="handleFieldChange(field, $event.files)"
|
|
/>
|
|
<small v-if="field.helpText" class="field-help">{{
|
|
field.helpText
|
|
}}</small>
|
|
<Message
|
|
v-if="getFieldError(field.name)"
|
|
severity="error"
|
|
size="small"
|
|
variant="simple"
|
|
>
|
|
{{ getFieldError(field.name) }}
|
|
</Message>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit/Action Buttons -->
|
|
<div v-if="showSubmitButton || showCancelButton" class="form-buttons">
|
|
<Button
|
|
v-if="showSubmitButton"
|
|
type="button"
|
|
:label="submitButtonText"
|
|
:loading="isLoading"
|
|
:disabled="isFormDisabled"
|
|
severity="primary"
|
|
@click="handleSubmit"
|
|
/>
|
|
<Button
|
|
v-if="showCancelButton"
|
|
type="button"
|
|
:label="cancelButtonText"
|
|
:disabled="isLoading"
|
|
severity="secondary"
|
|
variant="outlined"
|
|
@click="handleCancel"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, watch, defineEmits, defineProps } from "vue";
|
|
// PrimeVue Components
|
|
import InputText from "primevue/inputtext";
|
|
import InputNumber from "primevue/inputnumber";
|
|
import Textarea from "primevue/textarea";
|
|
import Select from "primevue/select";
|
|
import AutoComplete from "primevue/autocomplete";
|
|
import Checkbox from "primevue/checkbox";
|
|
import RadioButton from "primevue/radiobutton";
|
|
import DatePicker from "primevue/datepicker";
|
|
import FileUpload from "primevue/fileupload";
|
|
import Button from "primevue/button";
|
|
import Message from "primevue/message";
|
|
import { useLoadingStore } from "../../stores/loading";
|
|
|
|
const loadingStore = useLoadingStore();
|
|
|
|
const props = defineProps({
|
|
fields: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
formData: {
|
|
type: Object,
|
|
required: false,
|
|
default: null,
|
|
},
|
|
onChange: {
|
|
type: Function,
|
|
required: false,
|
|
default: null,
|
|
},
|
|
onSubmit: {
|
|
type: Function,
|
|
required: false,
|
|
default: null,
|
|
},
|
|
showSubmitButton: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
showCancelButton: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
submitButtonText: {
|
|
type: String,
|
|
default: "Submit",
|
|
},
|
|
cancelButtonText: {
|
|
type: String,
|
|
default: "Cancel",
|
|
},
|
|
validateOnChange: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
validateOnBlur: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
validateOnSubmit: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
loading: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
loadingMessage: {
|
|
type: String,
|
|
default: "Processing...",
|
|
},
|
|
disableOnLoading: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
// Auto-connect to global loading store
|
|
useGlobalLoading: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
formName: {
|
|
type: String,
|
|
default: "form",
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(["update:formData", "submit", "cancel", "change", "blur"]);
|
|
|
|
// Internal form state (used when no external formData is provided)
|
|
const internalFormData = reactive({});
|
|
const formErrors = reactive({});
|
|
const isSubmitting = ref(false);
|
|
|
|
// Computed loading and disabled states
|
|
const isLoading = computed(() => {
|
|
if (props.useGlobalLoading) {
|
|
return (
|
|
props.loading ||
|
|
loadingStore.getComponentLoading("form") ||
|
|
loadingStore.getComponentLoading(props.formName) ||
|
|
isSubmitting.value
|
|
);
|
|
}
|
|
return props.loading || isSubmitting.value;
|
|
});
|
|
|
|
const isFormDisabled = computed(() => {
|
|
return props.disableOnLoading && isLoading.value;
|
|
});
|
|
|
|
// Computed property for v-model binding
|
|
const fieldValues = computed({
|
|
get() {
|
|
return props.formData || internalFormData;
|
|
},
|
|
set(newValues) {
|
|
if (props.formData) {
|
|
Object.assign(props.formData, newValues);
|
|
emit("update:formData", props.formData);
|
|
} else {
|
|
Object.assign(internalFormData, newValues);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Initialize form data
|
|
const initializeFormData = () => {
|
|
props.fields.forEach((field) => {
|
|
if (props.formData) {
|
|
// If external formData is provided, ensure all fields exist
|
|
if (!(field.name in props.formData)) {
|
|
props.formData[field.name] = getDefaultValue(field);
|
|
}
|
|
} else {
|
|
// Use internal form data
|
|
internalFormData[field.name] = getDefaultValue(field);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Get default value for a field based on its type
|
|
const getDefaultValue = (field) => {
|
|
switch (field.type) {
|
|
case "checkbox":
|
|
return field.defaultValue !== undefined ? field.defaultValue : false;
|
|
case "number":
|
|
return field.defaultValue !== undefined ? field.defaultValue : "";
|
|
case "select":
|
|
case "radio":
|
|
return field.defaultValue !== undefined ? field.defaultValue : "";
|
|
case "file":
|
|
return null;
|
|
default:
|
|
return field.defaultValue !== undefined ? field.defaultValue : "";
|
|
}
|
|
};
|
|
|
|
// Get the current value for a field
|
|
const getFieldValue = (fieldName) => {
|
|
return fieldValues.value[fieldName];
|
|
};
|
|
|
|
// Get error for a field
|
|
const getFieldError = (fieldName) => {
|
|
return formErrors[fieldName];
|
|
};
|
|
|
|
// Validate a single field
|
|
const validateField = (field, value) => {
|
|
const errors = [];
|
|
|
|
// Required validation (only check if field is required and value is empty)
|
|
if (field.required && (value === "" || value === null || value === undefined)) {
|
|
errors.push(`${field.label} is required`);
|
|
return errors[0]; // Return early if required field is empty
|
|
}
|
|
|
|
// Skip other validations if value is empty and field is not required
|
|
if (!value && !field.required) {
|
|
return null;
|
|
}
|
|
|
|
// Email validation (more comprehensive)
|
|
if (field.format === "email" && value) {
|
|
const emailRegex =
|
|
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
if (!emailRegex.test(value)) {
|
|
errors.push("Please enter a valid email address");
|
|
}
|
|
}
|
|
|
|
// Phone validation (for tel format)
|
|
if (field.format === "tel" && value) {
|
|
// Allow various phone formats
|
|
const phoneRegex =
|
|
/^[\+]?[1-9][\d]{0,15}$|^\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}$|^[0-9]{10}$/;
|
|
if (!phoneRegex.test(value.replace(/[\s\-\(\)\.]/g, ""))) {
|
|
// Use custom validation if provided, otherwise use default
|
|
if (!field.validate) {
|
|
errors.push("Please enter a valid phone number");
|
|
}
|
|
}
|
|
}
|
|
|
|
// URL validation
|
|
if (field.format === "url" && value) {
|
|
try {
|
|
new URL(value);
|
|
} catch {
|
|
errors.push("Please enter a valid URL");
|
|
}
|
|
}
|
|
|
|
// Min/Max length validation for text
|
|
if ((field.type === "text" || field.type === "textarea") && value) {
|
|
if (field.minLength && value.length < field.minLength) {
|
|
errors.push(`${field.label} must be at least ${field.minLength} characters`);
|
|
}
|
|
if (field.maxLength && value.length > field.maxLength) {
|
|
errors.push(`${field.label} must not exceed ${field.maxLength} characters`);
|
|
}
|
|
}
|
|
|
|
// Min/Max for numbers
|
|
if (field.type === "number" && value !== "" && value !== null && value !== undefined) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
if (field.min !== undefined && numValue < field.min) {
|
|
errors.push(`Value must be at least ${field.min}`);
|
|
}
|
|
if (field.max !== undefined && numValue > field.max) {
|
|
errors.push(`Value must not exceed ${field.max}`);
|
|
}
|
|
} else {
|
|
errors.push("Please enter a valid number");
|
|
}
|
|
}
|
|
|
|
// Custom validation (always runs last)
|
|
if (field.validate && typeof field.validate === "function") {
|
|
const customError = field.validate(value);
|
|
if (customError) {
|
|
errors.push(customError);
|
|
}
|
|
}
|
|
|
|
return errors.length > 0 ? errors[0] : null;
|
|
};
|
|
|
|
// Handle keydown events for input restrictions
|
|
const handleKeyDown = (field, event) => {
|
|
// Check if field has numeric-only restriction
|
|
if (field.inputMode === "numeric" || field.pattern === "[0-9]*") {
|
|
const key = event.key;
|
|
|
|
// Allow control keys (backspace, delete, tab, escape, enter, arrows, etc.)
|
|
const allowedKeys = [
|
|
"Backspace",
|
|
"Delete",
|
|
"Tab",
|
|
"Escape",
|
|
"Enter",
|
|
"ArrowLeft",
|
|
"ArrowRight",
|
|
"ArrowUp",
|
|
"ArrowDown",
|
|
"Home",
|
|
"End",
|
|
"PageUp",
|
|
"PageDown",
|
|
];
|
|
|
|
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X, Ctrl+Z
|
|
if (event.ctrlKey || event.metaKey) {
|
|
return;
|
|
}
|
|
|
|
// Allow allowed control keys
|
|
if (allowedKeys.includes(key)) {
|
|
return;
|
|
}
|
|
|
|
// Only allow numeric keys (0-9)
|
|
if (!/^[0-9]$/.test(key)) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// Check max length if specified
|
|
if (field.maxLength && event.target.value.length >= field.maxLength) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle text input with custom formatting
|
|
const handleTextInput = (field, event) => {
|
|
let value = event.target ? event.target.value : event;
|
|
|
|
// Apply custom input formatting if provided
|
|
if (field.onInput && typeof field.onInput === "function") {
|
|
value = field.onInput(value);
|
|
|
|
// Update the input value immediately to reflect formatting
|
|
if (event.target) {
|
|
event.target.value = value;
|
|
}
|
|
}
|
|
|
|
// Call the standard field change handler
|
|
handleFieldChange(field, value);
|
|
};
|
|
|
|
// Handle field value changes
|
|
const handleFieldChange = (field, value) => {
|
|
// Update form data
|
|
if (props.formData) {
|
|
props.formData[field.name] = value;
|
|
emit("update:formData", props.formData);
|
|
} else {
|
|
internalFormData[field.name] = value;
|
|
}
|
|
|
|
// Clear previous error for this field
|
|
delete formErrors[field.name];
|
|
|
|
// Validate field if enabled
|
|
if (props.validateOnChange) {
|
|
const error = validateField(field, value);
|
|
if (error) {
|
|
formErrors[field.name] = error;
|
|
}
|
|
}
|
|
|
|
// Call custom onChange if provided on the field
|
|
if (field.onChangeOverride && typeof field.onChangeOverride === "function") {
|
|
field.onChangeOverride(value, field.name, getCurrentFormData());
|
|
}
|
|
// Call global onChange if provided
|
|
else if (props.onChange && typeof props.onChange === "function") {
|
|
props.onChange(field.name, value, getCurrentFormData());
|
|
}
|
|
|
|
// Emit change event
|
|
emit("change", {
|
|
fieldName: field.name,
|
|
value: value,
|
|
formData: getCurrentFormData(),
|
|
});
|
|
};
|
|
|
|
// Handle field blur events
|
|
const handleFieldBlur = (field, value) => {
|
|
// Validate field on blur if enabled
|
|
if (props.validateOnBlur) {
|
|
const error = validateField(field, value);
|
|
if (error) {
|
|
formErrors[field.name] = error;
|
|
} else {
|
|
// Clear error if validation passes
|
|
delete formErrors[field.name];
|
|
}
|
|
}
|
|
|
|
// Emit blur event
|
|
emit("blur", {
|
|
fieldName: field.name,
|
|
value: value,
|
|
formData: getCurrentFormData(),
|
|
});
|
|
};
|
|
|
|
// Get current form data (either external or internal)
|
|
const getCurrentFormData = () => {
|
|
return props.formData || internalFormData;
|
|
};
|
|
|
|
// Handle autocomplete search
|
|
const handleAutocompleteSearch = (field, event) => {
|
|
if (field.onSearch && typeof field.onSearch === "function") {
|
|
// Custom search function provided
|
|
field.onSearch(event.query, (results) => {
|
|
field.filteredOptions = results;
|
|
});
|
|
} else if (field.options) {
|
|
// Filter existing options
|
|
const query = event.query.toLowerCase();
|
|
field.filteredOptions = field.options.filter((option) => {
|
|
const label =
|
|
typeof option === "string" ? option : option[field.optionLabel || "label"];
|
|
return label && label.toLowerCase().includes(query);
|
|
});
|
|
} else {
|
|
field.filteredOptions = [];
|
|
}
|
|
};
|
|
|
|
// Get field column classes for responsive layout
|
|
const getFieldColumnClasses = (field) => {
|
|
const classes = [];
|
|
|
|
// Base column size (mobile-first)
|
|
const cols = field.cols || 12;
|
|
classes.push(`col-${cols}`);
|
|
|
|
// Small breakpoint (sm)
|
|
if (field.sm && field.sm !== cols) {
|
|
classes.push(`sm-${field.sm}`);
|
|
}
|
|
|
|
// Medium breakpoint (md)
|
|
if (field.md && field.md !== cols) {
|
|
classes.push(`md-${field.md}`);
|
|
}
|
|
|
|
// Large breakpoint (lg)
|
|
if (field.lg && field.lg !== cols) {
|
|
classes.push(`lg-${field.lg}`);
|
|
}
|
|
|
|
return classes.join(" ");
|
|
};
|
|
|
|
// Validate entire form
|
|
const validateForm = () => {
|
|
const errors = {};
|
|
let isValid = true;
|
|
|
|
props.fields.forEach((field) => {
|
|
const value = getFieldValue(field.name);
|
|
const error = validateField(field, value);
|
|
if (error) {
|
|
errors[field.name] = error;
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
Object.assign(formErrors, errors);
|
|
return isValid;
|
|
};
|
|
|
|
// Handle form submission
|
|
const handleSubmit = async () => {
|
|
// Prevent double submission
|
|
if (isSubmitting.value) {
|
|
console.warn("Form: submission already in progress, ignoring duplicate submission");
|
|
return;
|
|
}
|
|
|
|
// Always validate on submit if enabled
|
|
if (props.validateOnSubmit && !validateForm()) {
|
|
console.warn("Form: validation failed on submit");
|
|
return;
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
|
|
try {
|
|
const formData = getCurrentFormData();
|
|
console.log("Form: emitting submit event with data:", formData);
|
|
|
|
// Only emit the submit event - let parent handle the actual submission
|
|
// This prevents the dual submission pathway issue
|
|
emit("submit", formData);
|
|
|
|
// Call onSubmit prop if provided (for backward compatibility)
|
|
if (props.onSubmit && typeof props.onSubmit === "function") {
|
|
await props.onSubmit(formData);
|
|
}
|
|
} catch (error) {
|
|
console.error("Form: submission error:", error);
|
|
// Reset isSubmitting on error so user can retry
|
|
isSubmitting.value = false;
|
|
}
|
|
// Note: Don't reset isSubmitting.value here in finally - let parent control this
|
|
// The parent should call the exposed stopLoading() method when done
|
|
};
|
|
|
|
// Handle cancel action
|
|
const handleCancel = () => {
|
|
emit("cancel");
|
|
};
|
|
|
|
// Initialize form data when component mounts
|
|
initializeFormData();
|
|
|
|
// Watch for changes in fields to reinitialize
|
|
watch(
|
|
() => props.fields,
|
|
() => {
|
|
initializeFormData();
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
// Clear all form errors
|
|
const clearAllErrors = () => {
|
|
Object.keys(formErrors).forEach((key) => delete formErrors[key]);
|
|
};
|
|
|
|
// Expose methods for parent component
|
|
defineExpose({
|
|
validateForm,
|
|
getCurrentFormData,
|
|
resetForm: () => {
|
|
initializeFormData();
|
|
clearAllErrors();
|
|
},
|
|
setFieldError: (fieldName, error) => {
|
|
formErrors[fieldName] = error;
|
|
},
|
|
clearFieldError: (fieldName) => {
|
|
delete formErrors[fieldName];
|
|
},
|
|
clearAllErrors,
|
|
// Manual validation trigger
|
|
validateField: (fieldName) => {
|
|
const field = props.fields.find((f) => f.name === fieldName);
|
|
if (field) {
|
|
const value = getFieldValue(fieldName);
|
|
const error = validateField(field, value);
|
|
if (error) {
|
|
formErrors[fieldName] = error;
|
|
} else {
|
|
delete formErrors[fieldName];
|
|
}
|
|
return !error;
|
|
}
|
|
return true;
|
|
},
|
|
// Loading control methods
|
|
startLoading: (message) =>
|
|
loadingStore.setComponentLoading(props.formName, true, message || props.loadingMessage),
|
|
stopLoading: () => loadingStore.setComponentLoading(props.formName, false),
|
|
isLoading: () => isLoading.value,
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dynamic-form {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.form-container {
|
|
width: 100%;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(12, 1fr);
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* Base column classes (mobile-first) */
|
|
.col-1 {
|
|
grid-column: span 1;
|
|
}
|
|
.col-2 {
|
|
grid-column: span 2;
|
|
}
|
|
.col-3 {
|
|
grid-column: span 3;
|
|
}
|
|
.col-4 {
|
|
grid-column: span 4;
|
|
}
|
|
.col-5 {
|
|
grid-column: span 5;
|
|
}
|
|
.col-6 {
|
|
grid-column: span 6;
|
|
}
|
|
.col-7 {
|
|
grid-column: span 7;
|
|
}
|
|
.col-8 {
|
|
grid-column: span 8;
|
|
}
|
|
.col-9 {
|
|
grid-column: span 9;
|
|
}
|
|
.col-10 {
|
|
grid-column: span 10;
|
|
}
|
|
.col-11 {
|
|
grid-column: span 11;
|
|
}
|
|
.col-12 {
|
|
grid-column: span 12;
|
|
}
|
|
|
|
/* Small breakpoint (576px and up) */
|
|
@media (min-width: 576px) {
|
|
.sm-1 {
|
|
grid-column: span 1;
|
|
}
|
|
.sm-2 {
|
|
grid-column: span 2;
|
|
}
|
|
.sm-3 {
|
|
grid-column: span 3;
|
|
}
|
|
.sm-4 {
|
|
grid-column: span 4;
|
|
}
|
|
.sm-5 {
|
|
grid-column: span 5;
|
|
}
|
|
.sm-6 {
|
|
grid-column: span 6;
|
|
}
|
|
.sm-7 {
|
|
grid-column: span 7;
|
|
}
|
|
.sm-8 {
|
|
grid-column: span 8;
|
|
}
|
|
.sm-9 {
|
|
grid-column: span 9;
|
|
}
|
|
.sm-10 {
|
|
grid-column: span 10;
|
|
}
|
|
.sm-11 {
|
|
grid-column: span 11;
|
|
}
|
|
.sm-12 {
|
|
grid-column: span 12;
|
|
}
|
|
}
|
|
|
|
/* Medium breakpoint (768px and up) */
|
|
@media (min-width: 768px) {
|
|
.md-1 {
|
|
grid-column: span 1;
|
|
}
|
|
.md-2 {
|
|
grid-column: span 2;
|
|
}
|
|
.md-3 {
|
|
grid-column: span 3;
|
|
}
|
|
.md-4 {
|
|
grid-column: span 4;
|
|
}
|
|
.md-5 {
|
|
grid-column: span 5;
|
|
}
|
|
.md-6 {
|
|
grid-column: span 6;
|
|
}
|
|
.md-7 {
|
|
grid-column: span 7;
|
|
}
|
|
.md-8 {
|
|
grid-column: span 8;
|
|
}
|
|
.md-9 {
|
|
grid-column: span 9;
|
|
}
|
|
.md-10 {
|
|
grid-column: span 10;
|
|
}
|
|
.md-11 {
|
|
grid-column: span 11;
|
|
}
|
|
.md-12 {
|
|
grid-column: span 12;
|
|
}
|
|
}
|
|
|
|
/* Large breakpoint (992px and up) */
|
|
@media (min-width: 992px) {
|
|
.lg-1 {
|
|
grid-column: span 1;
|
|
}
|
|
.lg-2 {
|
|
grid-column: span 2;
|
|
}
|
|
.lg-3 {
|
|
grid-column: span 3;
|
|
}
|
|
.lg-4 {
|
|
grid-column: span 4;
|
|
}
|
|
.lg-5 {
|
|
grid-column: span 5;
|
|
}
|
|
.lg-6 {
|
|
grid-column: span 6;
|
|
}
|
|
.lg-7 {
|
|
grid-column: span 7;
|
|
}
|
|
.lg-8 {
|
|
grid-column: span 8;
|
|
}
|
|
.lg-9 {
|
|
grid-column: span 9;
|
|
}
|
|
.lg-10 {
|
|
grid-column: span 10;
|
|
}
|
|
.lg-11 {
|
|
grid-column: span 11;
|
|
}
|
|
.lg-12 {
|
|
grid-column: span 12;
|
|
}
|
|
}
|
|
|
|
/* Mobile responsive - stack all fields on very small screens */
|
|
@media (max-width: 575px) {
|
|
.form-field {
|
|
grid-column: span 12 !important;
|
|
}
|
|
}
|
|
|
|
.field-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.field-label {
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
color: var(--text-color, #374151);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.field-help {
|
|
font-size: 0.75rem;
|
|
color: var(--text-color-secondary, #6b7280);
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.required {
|
|
color: var(--red-500, #ef4444);
|
|
margin-left: 0.125rem;
|
|
}
|
|
|
|
/* Checkbox specific styles */
|
|
.checkbox-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.checkbox-label {
|
|
font-weight: 500;
|
|
font-size: 0.875rem;
|
|
color: var(--text-color, #374151);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Radio group styles */
|
|
.radio-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.radio-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.radio-label {
|
|
font-weight: 500;
|
|
font-size: 0.875rem;
|
|
color: var(--text-color, #374151);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Form buttons */
|
|
.form-buttons {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
margin-top: 2rem;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
/* Tablet responsive - let the responsive classes handle the layout */
|
|
@media (max-width: 767px) {
|
|
/* Fields without md specified should span full width on tablets */
|
|
.form-field:not([class*="md-"]) {
|
|
grid-column: span 12;
|
|
}
|
|
}
|
|
|
|
/* Large mobile responsive */
|
|
@media (max-width: 640px) {
|
|
.form-buttons {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.form-buttons :deep(.p-button) {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|