create form and modal components, update datatable persistant filtering

This commit is contained in:
Casey 2025-10-30 03:21:41 -05:00
parent b70e08026d
commit 8d9bb81fe2
23 changed files with 3502 additions and 74 deletions

View file

@ -0,0 +1,165 @@
<template lang="html">
<DataTable
:value="data"
:rowsPerPageOptions="[5, 10, 20, 50]"
:paginator="true"
:rows="10"
sortMode="multiple"
removableSort
filterDisplay="row"
v-model:filters="filterRef"
scrollable
scrollHeight="70vh"
v-model:selection="selectedRows"
selectionMode="multiple"
metaKeySelection="true"
dataKey="id"
>
<Column
v-for="col in columns"
:key="col.fieldName"
:field="col.fieldName"
:header="col.label"
:sortable="col.sortable"
>
<template v-if="col.filterable === true" #filter="{ filterModel, filterCallback }">
<InputText
v-model="filterModel.value"
type="text"
@input="handleFilterInput(col.fieldName, filterModel.value, filterCallback)"
:placeholder="`Search ${col.label}...`"
/>
</template>
<template v-if="col.type === 'status'" #body="slotProps">
<Tag
:value="slotProps.data[col.fieldName]"
:severity="getBadgeColor(slotProps.data[col.fieldName])"
/>
</template>
<template v-if="col.type === 'button'" #body="slotProps">
<Button
:label="slotProps.data[col.fieldName]"
size="small"
severity="info"
@click="$emit('rowClick', slotProps)"
/>
</template>
</Column>
</DataTable>
</template>
<script setup>
import { defineProps, computed, onMounted, watch } from "vue";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Tag from "primevue/tag";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import { ref } from "vue";
import { FilterMatchMode } from "@primevue/core";
import { useFiltersStore } from "../../stores/filters";
const filtersStore = useFiltersStore();
const props = defineProps({
columns: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
filters: {
type: Object,
default: () => ({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
}),
},
tableName: {
type: String,
required: true,
},
});
const emit = defineEmits(["rowClick"]);
// Initialize filters in store when component mounts
onMounted(() => {
filtersStore.initializeTableFilters(props.tableName, props.columns);
});
// Get filters from store, with fallback to props.filters
const filterRef = computed({
get() {
const storeFilters = filtersStore.getTableFilters(props.tableName);
// Merge store filters with any additional filters from props
return { ...props.filters, ...storeFilters };
},
set(newFilters) {
// Update store when filters change
Object.keys(newFilters).forEach(key => {
if (key !== 'global' && newFilters[key]) {
const filter = newFilters[key];
filtersStore.updateTableFilter(
props.tableName,
key,
filter.value,
filter.matchMode
);
}
});
}
});
// Watch for filter changes to sync match mode changes
watch(filterRef, (newFilters) => {
Object.keys(newFilters).forEach(key => {
if (key !== 'global' && newFilters[key]) {
const filter = newFilters[key];
const storeFilter = filtersStore.getTableFilters(props.tableName)[key];
// Only update if the match mode has actually changed
if (storeFilter && storeFilter.matchMode !== filter.matchMode) {
filtersStore.updateTableFilter(
props.tableName,
key,
filter.value,
filter.matchMode
);
}
}
});
}, { deep: true });
const selectedRows = ref();
// Handle filter input changes
const handleFilterInput = (fieldName, value, filterCallback) => {
// Get the current filter to preserve the match mode
const currentFilter = filterRef.value[fieldName];
const matchMode = currentFilter?.matchMode || FilterMatchMode.CONTAINS;
// Update the store with both value and match mode
filtersStore.updateTableFilter(props.tableName, fieldName, value, matchMode);
// Call the PrimeVue filter callback
filterCallback();
};
const getBadgeColor = (status) => {
console.log("DEBUG: - getBadgeColor status", status);
switch (status?.toLowerCase()) {
case "completed":
return "success"; // green
case "in progress":
return "warn";
case "not started":
return "danger"; // red
default:
return "info"; // blue fallback
}
};
console.log("DEBUG: - DataTable props.columns", props.columns);
console.log("DEBUG: - DataTable props.data", props.data);
</script>
<style lang="">
</style>

View file

@ -0,0 +1,608 @@
<template>
<v-form @submit.prevent="handleSubmit" class="dynamic-form">
<v-container>
<v-row>
<v-col
v-for="field in fields"
:key="field.name"
:cols="field.cols || 12"
:sm="field.sm || 12"
:md="field.md || 6"
:lg="field.lg || 6"
>
<!-- Text Input -->
<v-text-field
v-if="field.type === 'text'"
v-model="fieldValues[field.name]"
:label="field.label"
:type="field.format || 'text'"
:placeholder="field.placeholder"
:required="field.required"
:disabled="field.disabled"
:readonly="field.readonly"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
variant="outlined"
density="comfortable"
>
<template v-if="field.required" #append-inner>
<span class="text-error">*</span>
</template>
</v-text-field>
<!-- Number Input -->
<v-text-field
v-else-if="field.type === 'number'"
v-model="fieldValues[field.name]"
:label="field.label"
type="number"
:placeholder="field.placeholder"
:required="field.required"
:disabled="field.disabled"
:readonly="field.readonly"
:min="field.min"
:max="field.max"
:step="field.step"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@input="handleFieldChange(field, parseFloat($event.target ? $event.target.value : $event) || $event)"
@blur="handleFieldBlur(field, parseFloat($event.target ? $event.target.value : fieldValues[field.name]) || fieldValues[field.name])"
variant="outlined"
density="comfortable"
>
<template v-if="field.required" #append-inner>
<span class="text-error">*</span>
</template>
</v-text-field>
<!-- Textarea -->
<v-textarea
v-else-if="field.type === 'textarea'"
v-model="fieldValues[field.name]"
:label="field.label"
:placeholder="field.placeholder"
:required="field.required"
:disabled="field.disabled"
:readonly="field.readonly"
:rows="field.rows || 3"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
variant="outlined"
density="comfortable"
>
<template v-if="field.required" #append-inner>
<span class="text-error">*</span>
</template>
</v-textarea>
<!-- Select Dropdown -->
<v-select
v-else-if="field.type === 'select'"
v-model="fieldValues[field.name]"
:label="field.label"
:items="field.options"
item-title="label"
item-value="value"
:required="field.required"
:disabled="field.disabled"
:placeholder="field.placeholder"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@update:model-value="handleFieldChange(field, $event)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
variant="outlined"
density="comfortable"
>
<template v-if="field.required" #append-inner>
<span class="text-error">*</span>
</template>
</v-select>
<!-- Checkbox -->
<v-checkbox
v-else-if="field.type === 'checkbox'"
v-model="fieldValues[field.name]"
:label="field.label"
:required="field.required"
:disabled="field.disabled"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@update:model-value="handleFieldChange(field, $event)"
density="comfortable"
/>
<!-- Radio Group -->
<v-radio-group
v-else-if="field.type === 'radio'"
v-model="fieldValues[field.name]"
:label="field.label"
:required="field.required"
:disabled="field.disabled"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@update:model-value="handleFieldChange(field, $event)"
density="comfortable"
>
<v-radio
v-for="option in field.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</v-radio-group>
<!-- Date Input -->
<v-text-field
v-else-if="field.type === 'date'"
v-model="fieldValues[field.name]"
:label="field.label"
type="date"
:required="field.required"
:disabled="field.disabled"
:readonly="field.readonly"
:min="field.min"
:max="field.max"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
variant="outlined"
density="comfortable"
>
<template v-if="field.required" #append-inner>
<span class="text-error">*</span>
</template>
</v-text-field>
<!-- DateTime Input -->
<v-text-field
v-else-if="field.type === 'datetime'"
v-model="fieldValues[field.name]"
:label="field.label"
type="datetime-local"
:required="field.required"
:disabled="field.disabled"
:readonly="field.readonly"
:min="field.min"
:max="field.max"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
variant="outlined"
density="comfortable"
>
<template v-if="field.required" #append-inner>
<span class="text-error">*</span>
</template>
</v-text-field>
<!-- File Input -->
<v-file-input
v-else-if="field.type === 'file'"
v-model="fieldValues[field.name]"
:label="field.label"
:required="field.required"
:disabled="field.disabled"
:accept="field.accept"
:multiple="field.multiple"
:error-messages="getFieldError(field.name)"
:hint="field.helpText"
persistent-hint
@update:model-value="handleFieldChange(field, $event)"
variant="outlined"
density="comfortable"
prepend-icon="mdi-paperclip"
>
<template v-if="field.required" #append-inner>
<span class="text-error">*</span>
</template>
</v-file-input>
</v-col>
</v-row>
<!-- Submit/Action Buttons -->
<v-row v-if="showSubmitButton || showCancelButton" class="mt-4">
<v-col cols="12">
<div class="d-flex gap-2">
<v-btn
v-if="showSubmitButton"
type="submit"
color="primary"
:loading="isSubmitting"
variant="elevated"
>
{{ submitButtonText }}
</v-btn>
<v-btn
v-if="showCancelButton"
color="secondary"
variant="outlined"
@click="handleCancel"
>
{{ cancelButtonText }}
</v-btn>
</div>
</v-col>
</v-row>
</v-container>
</v-form>
</template>
<script setup>
import { ref, reactive, computed, watch, defineEmits, defineProps } from 'vue';
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
}
});
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 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 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;
};
// 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 () => {
// 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();
if (props.onSubmit && typeof props.onSubmit === 'function') {
await props.onSubmit(formData);
}
emit('submit', formData);
} catch (error) {
console.error('Form submission error:', error);
} finally {
isSubmitting.value = false;
}
};
// 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;
}
});
</script>
<style scoped>
.dynamic-form {
max-width: 1200px;
margin: 0 auto;
}
.gap-2 {
gap: 8px;
}
</style>

View file

@ -0,0 +1,286 @@
<template>
<v-dialog
v-model="localVisible"
:persistent="options.persistent || false"
:fullscreen="options.fullscreen || false"
:max-width="options.maxWidth || '500px'"
:width="options.width"
:height="options.height"
:attach="options.attach"
:transition="options.transition || 'dialog-transition'"
:scrollable="options.scrollable || false"
:retain-focus="options.retainFocus !== false"
:close-on-back="options.closeOnBack !== false"
:close-on-content-click="options.closeOnContentClick || false"
:overlay-color="options.overlayColor"
:overlay-opacity="options.overlayOpacity"
:z-index="options.zIndex"
:class="options.dialogClass"
@click:outside="handleOutsideClick"
@keydown.esc="handleEscapeKey"
>
<v-card
:class="[
'modal-card',
options.cardClass,
{
'elevation-0': options.flat,
'rounded-0': options.noRadius
}
]"
:color="options.cardColor"
:variant="options.cardVariant"
:elevation="options.elevation"
>
<!-- Header Section -->
<v-card-title
v-if="options.showHeader !== false"
:class="[
'modal-header d-flex align-center justify-space-between',
options.headerClass
]"
>
<div class="modal-title">
<slot name="title">
{{ options.title }}
</slot>
</div>
<!-- Close button -->
<v-btn
v-if="options.showCloseButton !== false && !options.persistent"
icon
variant="text"
size="small"
:color="options.closeButtonColor || 'grey'"
@click="closeModal"
class="modal-close-btn"
>
<v-icon>{{ options.closeIcon || 'mdi-close' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider v-if="options.showHeaderDivider && options.showHeader !== false" />
<!-- Content Section -->
<v-card-text
:class="[
'modal-content',
options.contentClass,
{
'pa-0': options.noPadding,
'overflow-y-auto': options.scrollable
}
]"
:style="contentStyle"
>
<slot>
<!-- Default content if no slot provided -->
<div v-if="options.message" v-html="options.message"></div>
</slot>
</v-card-text>
<!-- Actions Section -->
<v-card-actions
v-if="options.showActions !== false || $slots.actions"
:class="[
'modal-actions',
options.actionsClass,
{
'justify-end': options.actionsAlign === 'right',
'justify-center': options.actionsAlign === 'center',
'justify-start': options.actionsAlign === 'left',
'justify-space-between': options.actionsAlign === 'space-between'
}
]"
>
<slot name="actions" :close="closeModal" :options="options">
<!-- Default action buttons -->
<v-btn
v-if="options.showCancelButton !== false && !options.persistent"
:color="options.cancelButtonColor || 'grey'"
:variant="options.cancelButtonVariant || 'text'"
@click="handleCancel"
>
{{ options.cancelButtonText || 'Cancel' }}
</v-btn>
<v-btn
v-if="options.showConfirmButton !== false"
:color="options.confirmButtonColor || 'primary'"
:variant="options.confirmButtonVariant || 'elevated'"
:loading="options.loading"
@click="handleConfirm"
>
{{ options.confirmButtonText || 'Confirm' }}
</v-btn>
</slot>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed, watch } from 'vue'
// Props
const props = defineProps({
// Modal visibility state
visible: {
type: Boolean,
default: false
},
// Options object for configuration
options: {
type: Object,
default: () => ({})
}
})
// Emits
const emit = defineEmits([
'update:visible',
'close',
'confirm',
'cancel',
'outside-click',
'escape-key'
])
// Local visibility state that syncs with parent
const localVisible = computed({
get() {
return props.visible
},
set(value) {
emit('update:visible', value)
}
})
// Computed styles for content area
const contentStyle = computed(() => {
const styles = {}
if (props.options.contentHeight) {
styles.height = props.options.contentHeight
}
if (props.options.contentMaxHeight) {
styles.maxHeight = props.options.contentMaxHeight
}
if (props.options.contentMinHeight) {
styles.minHeight = props.options.contentMinHeight
}
return styles
})
// Methods
const closeModal = () => {
localVisible.value = false
emit('close')
}
const handleConfirm = () => {
emit('confirm')
// Auto-close unless specified not to
if (props.options.autoCloseOnConfirm !== false) {
closeModal()
}
}
const handleCancel = () => {
emit('cancel')
// Auto-close unless specified not to
if (props.options.autoCloseOnCancel !== false) {
closeModal()
}
}
const handleOutsideClick = () => {
emit('outside-click')
// Close on outside click unless persistent or disabled
if (!props.options.persistent && props.options.closeOnOutsideClick !== false) {
closeModal()
}
}
const handleEscapeKey = () => {
emit('escape-key')
// Close on escape key unless persistent or disabled
if (!props.options.persistent && props.options.closeOnEscape !== false) {
closeModal()
}
}
// Watch for external visibility changes
watch(() => props.visible, (newValue) => {
if (newValue && props.options.onOpen) {
props.options.onOpen()
} else if (!newValue && props.options.onClose) {
props.options.onClose()
}
})
</script>
<style scoped>
.modal-card {
position: relative;
}
.modal-header {
background-color: var(--v-theme-surface-variant);
padding: 16px 24px;
}
.modal-title {
font-size: 1.25rem;
font-weight: 500;
flex: 1;
}
.modal-close-btn {
flex-shrink: 0;
}
.modal-content {
position: relative;
}
.modal-actions {
padding: 16px 24px;
gap: 8px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.modal-header {
padding: 12px 16px;
}
.modal-actions {
padding: 12px 16px;
}
.modal-title {
font-size: 1.1rem;
}
}
/* Custom transitions */
.v-dialog--fullscreen .modal-card {
height: 100vh;
border-radius: 0;
}
/* Loading state */
.modal-card.loading {
pointer-events: none;
}
</style>