add global loading state, update to use real data for clients table

This commit is contained in:
Casey 2025-11-04 08:33:14 -06:00
parent 2cfe7ed8e6
commit 464c62d1e5
12 changed files with 1075 additions and 194 deletions

View file

@ -14,7 +14,21 @@
selectionMode="multiple"
metaKeySelection="true"
dataKey="id"
:loading="loading"
:loadingIcon="loadingIcon"
>
<template #empty>
<div class="text-center py-6">
<i class="pi pi-info-circle text-4xl text-gray-400 mb-2"></i>
<p class="text-gray-500">{{ emptyMessage || "No data available" }}</p>
</div>
</template>
<template #loading>
<div class="text-center py-6">
<i class="pi pi-spin pi-spinner text-4xl text-blue-500 mb-2"></i>
<p class="text-gray-600">{{ loadingMessage || "Loading data. Please wait..." }}</p>
</div>
</template>
<Column
v-for="col in columns"
:key="col.fieldName"
@ -28,6 +42,7 @@
type="text"
@input="handleFilterInput(col.fieldName, filterModel.value, filterCallback)"
:placeholder="`Search ${col.label}...`"
:disabled="loading"
/>
</template>
<template v-if="col.type === 'status'" #body="slotProps">
@ -57,8 +72,10 @@ import InputText from "primevue/inputtext";
import { ref } from "vue";
import { FilterMatchMode } from "@primevue/core";
import { useFiltersStore } from "../../stores/filters";
import { useLoadingStore } from "../../stores/loading";
const filtersStore = useFiltersStore();
const loadingStore = useLoadingStore();
const props = defineProps({
columns: {
@ -79,10 +96,44 @@ const props = defineProps({
type: String,
required: true,
},
loading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: "",
},
emptyMessage: {
type: String,
default: "",
},
loadingIcon: {
type: String,
default: "pi pi-spinner pi-spin",
},
// Auto-connect to global loading store
useGlobalLoading: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["rowClick"]);
// Computed loading state that considers both prop and global store
const loading = computed(() => {
if (props.useGlobalLoading) {
return (
props.loading ||
loadingStore.getComponentLoading("dataTable") ||
loadingStore.getComponentLoading(props.tableName) ||
loadingStore.isAnyLoading
);
}
return props.loading;
});
// Initialize filters in store when component mounts
onMounted(() => {
filtersStore.initializeTableFilters(props.tableName, props.columns);
@ -97,39 +148,43 @@ const filterRef = computed({
},
set(newFilters) {
// Update store when filters change
Object.keys(newFilters).forEach(key => {
if (key !== 'global' && newFilters[key]) {
Object.keys(newFilters).forEach((key) => {
if (key !== "global" && newFilters[key]) {
const filter = newFilters[key];
filtersStore.updateTableFilter(
props.tableName,
key,
filter.value,
filter.matchMode
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
);
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 });
});
},
{ deep: true },
);
const selectedRows = ref();
@ -138,7 +193,7 @@ 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
@ -160,6 +215,12 @@ const getBadgeColor = (status) => {
};
console.log("DEBUG: - DataTable props.columns", props.columns);
console.log("DEBUG: - DataTable props.data", props.data);
// Expose loading control methods for parent components
defineExpose({
startLoading: (message) => loadingStore.setComponentLoading(props.tableName, true, message),
stopLoading: () => loadingStore.setComponentLoading(props.tableName, false),
isLoading: () => loading.value,
});
</script>
<style lang="">
</style>
<style lang=""></style>

View file

@ -19,16 +19,15 @@
v-model="fieldValues[field.name]"
:type="field.format || 'text'"
:placeholder="field.placeholder"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:invalid="!!getFieldError(field.name)"
:fluid="true"
@input="
handleFieldChange(
field,
$event.target ? $event.target.value : $event,
)
"
fluid
:maxlength="field.maxLength"
:inputmode="field.inputMode"
:pattern="field.pattern"
@keydown="handleKeyDown(field, $event)"
@input="handleTextInput(field, $event)"
@blur="
handleFieldBlur(
field,
@ -59,13 +58,13 @@
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:min="field.min"
:max="field.max"
:step="field.step"
:invalid="!!getFieldError(field.name)"
:fluid="true"
fluid
@input="handleFieldChange(field, $event.value)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
/>
@ -92,11 +91,11 @@
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:rows="field.rows || 3"
:invalid="!!getFieldError(field.name)"
:fluid="true"
fluid
:autoResize="field.autoResize !== false"
@input="
handleFieldChange(
@ -134,14 +133,13 @@
:id="field.name"
v-model="fieldValues[field.name]"
:options="field.options"
:optionLabel="field.optionLabel || 'label'"
:optionValue="field.optionValue || 'value'"
:disabled="field.disabled"
optionLabel="label"
optionValue="value"
:disabled="field.disabled || isFormDisabled"
:placeholder="field.placeholder"
:invalid="!!getFieldError(field.name)"
:fluid="true"
:filter="field.filter !== false"
:showClear="field.showClear !== false"
fluid
appendTo="body"
@update:model-value="handleFieldChange(field, $event)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
/>
@ -168,15 +166,13 @@
:id="field.name"
v-model="fieldValues[field.name]"
:suggestions="field.filteredOptions || field.options || []"
:option-label="field.optionLabel"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:placeholder="field.placeholder"
:invalid="!!getFieldError(field.name)"
:fluid="true"
:dropdown="field.dropdown !== false"
:multiple="field.multiple === true"
:force-selection="field.forceSelection === true"
dropdown-mode="blank"
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])"
@ -201,7 +197,7 @@
:id="field.name"
v-model="fieldValues[field.name]"
:binary="true"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:invalid="!!getFieldError(field.name)"
@update:model-value="handleFieldChange(field, $event)"
/>
@ -240,7 +236,7 @@
v-model="fieldValues[field.name]"
:name="field.name"
:value="option.value"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:invalid="!!getFieldError(field.name)"
@update:model-value="handleFieldChange(field, $event)"
/>
@ -272,13 +268,13 @@
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:minDate="field.minDate"
:maxDate="field.maxDate"
:invalid="!!getFieldError(field.name)"
:fluid="true"
:showIcon="true"
fluid
showIcon
iconDisplay="input"
:dateFormat="field.dateFormat || 'dd/mm/yy'"
@update:model-value="handleFieldChange(field, $event)"
@ -307,15 +303,15 @@
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:minDate="field.minDate"
:maxDate="field.maxDate"
:invalid="!!getFieldError(field.name)"
:fluid="true"
:showIcon="true"
fluid
showIcon
iconDisplay="input"
:showTime="true"
showTime
:hourFormat="field.hourFormat || '24'"
:dateFormat="field.dateFormat || 'dd/mm/yy'"
@update:model-value="handleFieldChange(field, $event)"
@ -344,7 +340,7 @@
:id="field.name"
v-model="fieldValues[field.name]"
mode="basic"
:disabled="field.disabled"
:disabled="field.disabled || isFormDisabled"
:accept="field.accept"
:multiple="field.multiple"
:invalidFileTypeMessage="`Invalid file type. Accepted types: ${field.accept || 'any'}`"
@ -378,13 +374,15 @@
v-if="showSubmitButton"
type="submit"
:label="submitButtonText"
:loading="isSubmitting"
:loading="isLoading"
:disabled="isFormDisabled"
severity="primary"
/>
<Button
v-if="showCancelButton"
type="button"
:label="cancelButtonText"
:disabled="isLoading"
severity="secondary"
variant="outlined"
@click="handleCancel"
@ -408,6 +406,9 @@ 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: {
@ -457,6 +458,27 @@ const props = defineProps({
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"]);
@ -466,6 +488,23 @@ 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() {
@ -605,6 +644,71 @@ const validateField = (field, value) => {
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
@ -693,19 +797,24 @@ const handleAutocompleteSearch = (field, event) => {
const getFieldColumnClasses = (field) => {
const classes = [];
// Default column sizes based on field.cols or defaults
// Base column size (mobile-first)
const cols = field.cols || 12;
const sm = field.sm || 12;
const md = field.md || 6;
const lg = field.lg || 6;
classes.push(`col-${cols}`);
// Convert to CSS Grid or Flexbox classes
// This is a basic implementation - you might want to use a CSS framework
if (cols === 12) classes.push("col-full");
else if (cols === 6) classes.push("col-half");
else if (cols === 4) classes.push("col-third");
else if (cols === 3) classes.push("col-quarter");
else 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(" ");
};
@ -805,6 +914,11 @@ defineExpose({
}
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>
@ -831,19 +945,7 @@ defineExpose({
flex-direction: column;
}
/* Responsive column classes */
.col-full {
grid-column: span 12;
}
.col-half {
grid-column: span 6;
}
.col-third {
grid-column: span 4;
}
.col-quarter {
grid-column: span 3;
}
/* Base column classes (mobile-first) */
.col-1 {
grid-column: span 1;
}
@ -881,14 +983,130 @@ defineExpose({
grid-column: span 12;
}
/* Mobile responsive */
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
/* 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 1 !important;
grid-column: span 12 !important;
}
}
@ -959,12 +1177,11 @@ defineExpose({
justify-content: flex-start;
}
/* Tablet responsive */
@media (max-width: 1024px) {
.col-half,
.col-third,
.col-quarter {
grid-column: span 6;
/* 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;
}
}

View file

@ -0,0 +1,60 @@
<template>
<div
v-if="showOverlay"
class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-[9999] transition-opacity duration-200"
:class="{ 'opacity-100': showOverlay, 'opacity-0 pointer-events-none': !showOverlay }"
>
<div class="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full mx-4 text-center">
<div class="mb-4">
<i class="pi pi-spin pi-spinner text-4xl text-blue-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">Loading</h3>
<p class="text-gray-600">{{ loadingMessage }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useLoadingStore } from "../../stores/loading";
const props = defineProps({
// Show overlay only for global loading, not component-specific
globalOnly: {
type: Boolean,
default: true,
},
// Minimum display time to prevent flashing
minDisplayTime: {
type: Number,
default: 300,
},
});
const loadingStore = useLoadingStore();
const showOverlay = computed(() => {
if (props.globalOnly) {
return loadingStore.isLoading;
}
return loadingStore.isAnyLoading;
});
const loadingMessage = computed(() => {
return loadingStore.loadingMessage;
});
</script>
<style scoped>
/* Additional styling for better visual appearance */
.bg-opacity-30 {
background-color: rgba(0, 0, 0, 0.3);
}
/* Backdrop blur effect for modern browsers */
@supports (backdrop-filter: blur(4px)) {
.fixed.inset-0 {
backdrop-filter: blur(4px);
}
}
</style>