hot-fix: Add ORCID validation and improve dataset editing UX
### Major Features - Add comprehensive ORCID validation with checksum verification - Implement unsaved changes detection and auto-save functionality - Enhanced form component reactivity and state management ### ORCID Implementation - Create custom VineJS ORCID validation rule with MOD-11-2 algorithm - Add ORCID fields to Person model and TablePersons component - Update dataset validators to include ORCID validation - Add descriptive placeholder text for ORCID input fields ### UI/UX Improvements - Add UnsavedChangesWarning component with detailed change tracking - Improve FormCheckRadio and FormCheckRadioGroup reactivity - Enhanced BaseButton with proper disabled state handling - Better error handling and user feedback in file validation ### Data Management - Implement sophisticated change detection for all dataset fields - Add proper handling of array ordering for authors/contributors - Improve license selection with better state management - Enhanced subject/keyword processing with duplicate detection ### Technical Improvements - Optimize search indexing with conditional updates based on modification dates - Update person model column mapping for ORCID - Improve validation error messages and user guidance - Better handling of file uploads and deletion tracking ### Dependencies - Update various npm packages (AWS SDK, Babel, Vite, etc.) - Add baseline-browser-mapping for better browser compatibility ### Bug Fixes - Fix form reactivity issues with checkbox/radio groups - Improve error handling in file validation rules - Better handling of edge cases in change detection
This commit is contained in:
parent
06ed2f3625
commit
8f67839f93
16 changed files with 2657 additions and 1168 deletions
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import { computed, PropType } from 'vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
// import { Link } from '@inertiajs/inertia-vue3';
|
||||
import { getButtonColor } from '@/colors';
|
||||
|
@ -30,8 +30,8 @@ const props = defineProps({
|
|||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
color: {
|
||||
type: String as PropType<'white' | 'contrast' | 'light' | 'success' | 'danger' | 'warning' | 'info' | 'modern'>,
|
||||
default: 'white',
|
||||
},
|
||||
as: {
|
||||
|
@ -45,11 +45,18 @@ const props = defineProps({
|
|||
roundedFull: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const is = computed(() => {
|
||||
if (props.as) {
|
||||
return props.as;
|
||||
}
|
||||
|
||||
// If disabled, always render as button or span to prevent navigation
|
||||
if (props.disabled) {
|
||||
return props.routeName || props.href ? 'span' : 'button';
|
||||
}
|
||||
|
||||
if (props.routeName) {
|
||||
return Link;
|
||||
}
|
||||
|
@ -69,47 +76,105 @@ const computedType = computed(() => {
|
|||
return null;
|
||||
});
|
||||
|
||||
// Only provide href/routeName when not disabled
|
||||
const computedHref = computed(() => {
|
||||
if (props.disabled) return null;
|
||||
return props.routeName || props.href;
|
||||
});
|
||||
|
||||
// Only provide target when not disabled and has href
|
||||
const computedTarget = computed(() => {
|
||||
if (props.disabled || !props.href) return null;
|
||||
return props.target;
|
||||
});
|
||||
|
||||
// Only provide disabled attribute for actual button elements
|
||||
const computedDisabled = computed(() => {
|
||||
if (is.value === 'button') {
|
||||
return props.disabled;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'));
|
||||
|
||||
const componentClass = computed(() => {
|
||||
const base = [
|
||||
'inline-flex',
|
||||
'cursor-pointer',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'whitespace-nowrap',
|
||||
'focus:outline-none',
|
||||
'transition-colors',
|
||||
'focus:ring-2',
|
||||
'duration-150',
|
||||
'border',
|
||||
props.roundedFull ? 'rounded-full' : 'rounded',
|
||||
props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700',
|
||||
getButtonColor(props.color, props.outline, !props.disabled),
|
||||
];
|
||||
|
||||
// Only add focus ring styles when not disabled
|
||||
if (!props.disabled) {
|
||||
base.push('focus:ring-2');
|
||||
base.push(props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700');
|
||||
}
|
||||
|
||||
// Add button colors
|
||||
// Add button colors - handle both string and array returns
|
||||
// const buttonColors = getButtonColor(props.color, props.outline, !props.disabled);
|
||||
base.push(getButtonColor(props.color, props.outline, !props.disabled));
|
||||
// if (Array.isArray(buttonColors)) {
|
||||
// base.push(...buttonColors);
|
||||
// } else {
|
||||
// base.push(buttonColors);
|
||||
// }
|
||||
|
||||
// Add size classes
|
||||
if (props.small) {
|
||||
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1');
|
||||
} else {
|
||||
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3');
|
||||
}
|
||||
|
||||
// Add disabled/enabled specific classes
|
||||
if (props.disabled) {
|
||||
base.push('cursor-not-allowed', props.outline ? 'opacity-50' : 'opacity-70');
|
||||
base.push(
|
||||
'cursor-not-allowed',
|
||||
'opacity-60',
|
||||
'pointer-events-none', // This prevents all interactions
|
||||
);
|
||||
} else {
|
||||
base.push('cursor-pointer');
|
||||
// Add hover effects only when not disabled
|
||||
if (is.value === 'button' || is.value === 'a' || is.value === Link) {
|
||||
base.push('hover:opacity-80');
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
// Handle click events with disabled check
|
||||
const handleClick = (event) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
emit('click', event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="is"
|
||||
:class="componentClass"
|
||||
:href="routeName ? routeName : href"
|
||||
:href="computedHref"
|
||||
:to="props.disabled ? null : props.routeName"
|
||||
:type="computedType"
|
||||
:target="target"
|
||||
:disabled="disabled"
|
||||
:target="computedTarget"
|
||||
:disabled="computedDisabled"
|
||||
:tabindex="props.disabled ? -1 : null"
|
||||
:aria-disabled="props.disabled ? 'true' : null"
|
||||
@click="handleClick"
|
||||
>
|
||||
<BaseIcon v-if="icon" :path="icon" />
|
||||
<span v-if="label" :class="labelClass">{{ label }}</span>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch, ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
|
@ -13,32 +13,138 @@ const props = defineProps<Props>();
|
|||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
|
||||
|
||||
// const computedValue = computed({
|
||||
// get: () => props.modelValue,
|
||||
// set: (value) => {
|
||||
// emit('update:modelValue', props.type === 'radio' ? [value] : value);
|
||||
// },
|
||||
// });
|
||||
const computedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', props.type === 'radio' ? [value] : value);
|
||||
get: () => {
|
||||
if (props.type === 'radio') {
|
||||
// For radio buttons, return boolean indicating if this option is selected
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
return props.modelValue;
|
||||
}
|
||||
return [props.modelValue];
|
||||
} else {
|
||||
// For checkboxes, return boolean indicating if this option is included
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
return props.modelValue.includes(props.inputValue);
|
||||
}
|
||||
return props.modelValue == props.inputValue;
|
||||
}
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
if (props.type === 'radio') {
|
||||
// When radio is selected, emit the new value as array
|
||||
emit('update:modelValue', [value]);
|
||||
} else {
|
||||
// Handle checkboxes
|
||||
let updatedValue = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
|
||||
if (value) {
|
||||
if (!updatedValue.includes(props.inputValue)) {
|
||||
updatedValue.push(props.inputValue);
|
||||
}
|
||||
} else {
|
||||
updatedValue = updatedValue.filter(item => item != props.inputValue);
|
||||
}
|
||||
emit('update:modelValue', updatedValue);
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
|
||||
|
||||
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
|
||||
const isChecked = computed(() => {
|
||||
if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
|
||||
return props.type === 'radio'
|
||||
? computedValue.value[0] === props.inputValue
|
||||
: computedValue.value.includes(props.inputValue);
|
||||
// const isChecked = computed(() => {
|
||||
// if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
|
||||
// return props.type === 'radio'
|
||||
// ? computedValue.value[0] === props.inputValue
|
||||
// : computedValue.value.includes(props.inputValue);
|
||||
// }
|
||||
// return computedValue.value === props.inputValue;
|
||||
// });
|
||||
// const isChecked = computed(() => {
|
||||
// return computedValue.value[0] === props.inputValue;
|
||||
// });
|
||||
// Fix the isChecked computation with proper type handling
|
||||
// const isChecked = computed(() => {
|
||||
// if (props.type === 'radio') {
|
||||
// // Use loose equality to handle string/number conversion
|
||||
// return computedValue.value == props.inputValue;
|
||||
// }
|
||||
// return computedValue.value === true;
|
||||
// });
|
||||
|
||||
// const isChecked = computed(() => {
|
||||
// if (props.type === 'radio') {
|
||||
// if (Array.isArray(props.modelValue)) {
|
||||
// return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
|
||||
// }
|
||||
// return props.modelValue == props.inputValue;
|
||||
// }
|
||||
|
||||
// // For checkboxes
|
||||
// if (Array.isArray(props.modelValue)) {
|
||||
// return props.modelValue.includes(props.inputValue);
|
||||
// }
|
||||
// return props.modelValue == props.inputValue;
|
||||
// });
|
||||
// Use a ref for isChecked and update it with a watcher
|
||||
const isChecked = ref(false);
|
||||
// Calculate initial isChecked value
|
||||
const calculateIsChecked = () => {
|
||||
if (props.type === 'radio') {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
|
||||
}
|
||||
return props.modelValue == props.inputValue;
|
||||
}
|
||||
return computedValue.value === props.inputValue;
|
||||
});
|
||||
|
||||
// For checkboxes
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
return props.modelValue.includes(props.inputValue);
|
||||
}
|
||||
return props.modelValue == props.inputValue;
|
||||
};
|
||||
|
||||
// Set initial value
|
||||
isChecked.value = calculateIsChecked();
|
||||
|
||||
// Watch for changes in modelValue and recalculate isChecked
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
console.log('modelValue changed:', {
|
||||
newValue,
|
||||
inputValue: props.inputValue,
|
||||
type: props.type
|
||||
});
|
||||
isChecked.value = calculateIsChecked();
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// Also watch inputValue in case it changes
|
||||
watch(
|
||||
() => props.inputValue,
|
||||
() => {
|
||||
isChecked.value = calculateIsChecked();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label v-if="type === 'radio'" :class="[type]"
|
||||
<label v-if="type === 'radio'" :class="[type]"
|
||||
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
|
||||
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue"
|
||||
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0"
|
||||
:checked="isChecked" />
|
||||
<input
|
||||
v-model="computedValue"
|
||||
:type="inputType"
|
||||
:name="name"
|
||||
:value="inputValue"
|
||||
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0" />
|
||||
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
|
||||
'border-gray-700': !isChecked,
|
||||
'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked
|
||||
|
|
|
@ -38,32 +38,82 @@ const props = defineProps({
|
|||
},
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const computedValue = computed({
|
||||
// get: () => props.modelValue,
|
||||
get: () => {
|
||||
// const ids = props.modelValue.map((obj) => obj.id);
|
||||
// return ids;
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
if (props.modelValue.every((item) => typeof item === 'number')) {
|
||||
return props.modelValue;
|
||||
} else if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
||||
const ids = props.modelValue.map((obj) => obj.id);
|
||||
return ids;
|
||||
}
|
||||
return props.modelValue;
|
||||
}
|
||||
// return props.modelValue;
|
||||
},
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
// const computedValue = computed({
|
||||
// // get: () => props.modelValue,
|
||||
// get: () => {
|
||||
// // const ids = props.modelValue.map((obj) => obj.id);
|
||||
// // return ids;
|
||||
// if (Array.isArray(props.modelValue)) {
|
||||
// if (props.modelValue.every((item) => typeof item === 'number')) {
|
||||
// return props.modelValue;
|
||||
// } else if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
||||
// const ids = props.modelValue.map((obj) => obj.id);
|
||||
// return ids;
|
||||
// }
|
||||
// return props.modelValue;
|
||||
// }
|
||||
// // return props.modelValue;
|
||||
// },
|
||||
// set: (value) => {
|
||||
// emit('update:modelValue', value);
|
||||
// },
|
||||
// });
|
||||
|
||||
// Define a type guard to check if an object has an 'id' attribute
|
||||
// function hasIdAttribute(obj: any): obj is { id: any } {
|
||||
// return typeof obj === 'object' && 'id' in obj;
|
||||
// }
|
||||
|
||||
const computedValue = computed({
|
||||
get: () => {
|
||||
if (!props.modelValue) return props.modelValue;
|
||||
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
// Handle empty array
|
||||
if (props.modelValue.length === 0) return [];
|
||||
|
||||
// If all items are objects with id property
|
||||
if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
||||
return props.modelValue.map((obj) => {
|
||||
// Ensure we return the correct type based on the options keys
|
||||
const id = obj.id;
|
||||
// Check if options keys are numbers or strings
|
||||
const optionKeys = Object.keys(props.options);
|
||||
if (optionKeys.length > 0) {
|
||||
// If option keys are numeric strings, return number
|
||||
if (optionKeys.every(key => !isNaN(Number(key)))) {
|
||||
return Number(id);
|
||||
}
|
||||
}
|
||||
return String(id);
|
||||
});
|
||||
}
|
||||
|
||||
// If all items are numbers
|
||||
if (props.modelValue.every((item) => typeof item === 'number')) {
|
||||
return props.modelValue;
|
||||
}
|
||||
|
||||
// If all items are strings that represent numbers
|
||||
if (props.modelValue.every((item) => typeof item === 'string' && !isNaN(Number(item)))) {
|
||||
// Convert to numbers if options keys are numeric
|
||||
const optionKeys = Object.keys(props.options);
|
||||
if (optionKeys.length > 0 && optionKeys.every(key => !isNaN(Number(key)))) {
|
||||
return props.modelValue.map(item => Number(item));
|
||||
}
|
||||
return props.modelValue;
|
||||
}
|
||||
|
||||
// Return as-is for other cases
|
||||
return props.modelValue;
|
||||
}
|
||||
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const hasIdAttribute = (obj: any): obj is { id: any } => {
|
||||
return typeof obj === 'object' && 'id' in obj;
|
||||
};
|
||||
|
@ -110,7 +160,7 @@ const inputElClass = computed(() => {
|
|||
</div>
|
||||
<!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
||||
:name="name" :input-value="key" :label="value" :class="componentClass" /> -->
|
||||
<FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
||||
<FormCheckRadio v-for="(value, key) in options" key="`${name}-${key}-${JSON.stringify(computedValue)}`" v-model="computedValue" :type="type"
|
||||
:name="name" :input-value="isNaN(Number(key)) ? key : Number(key)" :label="value" :class="componentClass" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -218,6 +218,7 @@ const perPageOptions = [
|
|||
<th scope="col">Id</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name / Organization</th>
|
||||
<th>Orcid</th>
|
||||
<th>Email</th>
|
||||
<th v-if="showContributorTypes" scope="col" class="text-left p-3">Type</th>
|
||||
<th v-if="canDelete" class="w-20 p-3">Actions</th>
|
||||
|
@ -274,6 +275,18 @@ const perPageOptions = [
|
|||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Orcid -->
|
||||
<td data-label="Orcid">
|
||||
<FormControl
|
||||
v-model="element.identifier_orcid"
|
||||
type="text"
|
||||
:is-read-only="element.status == true"
|
||||
/>
|
||||
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.identifier_orcid`])">
|
||||
{{ errors[`${relation}.${index}.identifier_orcid`].join(', ') }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Email -->
|
||||
<td data-label="Email">
|
||||
<FormControl
|
||||
|
@ -362,6 +375,19 @@ const perPageOptions = [
|
|||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-3">
|
||||
<FormControl
|
||||
:model-value="element.identifier_orcid"
|
||||
@update:model-value="updatePerson(index, 'identifier_orcid', $event)"
|
||||
type="text"
|
||||
:is-read-only="element.status || !canEdit"
|
||||
:error="getFieldError(index, 'identifier_orcid')"
|
||||
/>
|
||||
<div v-if="getFieldError(index, 'identifier_orcid')" class="text-red-400 text-sm">
|
||||
{{ getFieldError(index, 'identifier_orcid') }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-3">
|
||||
<FormControl
|
||||
required
|
||||
|
|
287
resources/js/Components/UnsavedChangesWarning.vue
Normal file
287
resources/js/Components/UnsavedChangesWarning.vue
Normal file
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 transform -translate-y-2"
|
||||
enter-to-class="opacity-100 transform translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 transform translate-y-0"
|
||||
leave-to-class="opacity-0 transform -translate-y-2"
|
||||
>
|
||||
<div v-if="show" class="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg shadow-sm" role="alert" aria-live="polite">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<WarningTriangleIcon class="h-5 w-5 text-amber-500" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-amber-800">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="mt-1 text-sm text-amber-700">
|
||||
<p>{{ message }}</p>
|
||||
|
||||
<!-- Optional detailed list of changes -->
|
||||
<div v-if="showDetails && changesSummary.length > 0" class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="toggleDetails"
|
||||
class="text-amber-800 underline hover:text-amber-900 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 rounded"
|
||||
>
|
||||
{{ detailsVisible ? 'Hide details' : 'Show details' }}
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="detailsVisible" class="mt-2 overflow-hidden">
|
||||
<ul class="text-xs text-amber-600 space-y-1">
|
||||
<li v-for="change in changesSummary" :key="change" class="flex items-center">
|
||||
<div class="w-1 h-1 bg-amber-400 rounded-full mr-2"></div>
|
||||
{{ change }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div v-if="showActions" class="ml-4 flex-shrink-0 flex space-x-2">
|
||||
<button
|
||||
v-if="onSave"
|
||||
type="button"
|
||||
@click.stop="handleSave"
|
||||
:disabled="isSaving"
|
||||
class="bg-amber-100 text-amber-800 px-3 py-1 rounded text-sm font-medium hover:bg-amber-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="!isSaving">Save Now</span>
|
||||
<span v-else class="flex items-center">
|
||||
<LoadingSpinner class="w-3 h-3 mr-1" />
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="onDismiss"
|
||||
type="button"
|
||||
@click="handleDismiss"
|
||||
class="text-amber-600 hover:text-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 rounded p-1"
|
||||
:title="dismissLabel"
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" aria-hidden="true" />
|
||||
<span class="sr-only">{{ dismissLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress indicator for auto-save -->
|
||||
<div v-if="showAutoSaveProgress && autoSaveCountdown > 0" class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs text-amber-600">
|
||||
<span>Auto-save in {{ autoSaveCountdown }}s</span>
|
||||
<button @click="cancelAutoSave" class="underline hover:text-amber-700">Cancel</button>
|
||||
</div>
|
||||
<div class="mt-1 w-full bg-amber-200 rounded-full h-1">
|
||||
<div
|
||||
class="bg-amber-500 h-1 rounded-full transition-all duration-1000 ease-linear"
|
||||
:style="{ width: `${((initialCountdown - autoSaveCountdown) / initialCountdown) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, defineComponent } from 'vue';
|
||||
|
||||
// Icons - you can replace these with your preferred icon library
|
||||
const WarningTriangleIcon = defineComponent({
|
||||
template: `
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
`,
|
||||
});
|
||||
|
||||
const XMarkIcon = defineComponent({
|
||||
template: `
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
`,
|
||||
});
|
||||
|
||||
const LoadingSpinner = defineComponent({
|
||||
template: `
|
||||
<svg class="animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
`,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
// Control visibility
|
||||
show?: boolean;
|
||||
|
||||
// Content
|
||||
title?: string;
|
||||
message?: string;
|
||||
changesSummary?: string[];
|
||||
|
||||
// Behavior
|
||||
showDetails?: boolean;
|
||||
showActions?: boolean;
|
||||
showAutoSaveProgress?: boolean;
|
||||
autoSaveDelay?: number; // seconds
|
||||
|
||||
// Callbacks
|
||||
onSave?: () => Promise<void> | void;
|
||||
onDismiss?: () => void;
|
||||
onAutoSave?: () => Promise<void> | void;
|
||||
|
||||
// Labels
|
||||
dismissLabel?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
show: true,
|
||||
title: 'You have unsaved changes',
|
||||
message: 'Your changes will be lost if you leave this page without saving.',
|
||||
changesSummary: () => [],
|
||||
showDetails: false,
|
||||
showActions: true,
|
||||
showAutoSaveProgress: false,
|
||||
autoSaveDelay: 30,
|
||||
dismissLabel: 'Dismiss warning',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [];
|
||||
dismiss: [];
|
||||
autoSave: [];
|
||||
}>();
|
||||
|
||||
// Local state
|
||||
const detailsVisible = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const autoSaveCountdown = ref(0);
|
||||
const initialCountdown = ref(0);
|
||||
let autoSaveTimer: NodeJS.Timeout | null = null;
|
||||
let countdownTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Methods
|
||||
const toggleDetails = () => {
|
||||
detailsVisible.value = !detailsVisible.value;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving.value) return;
|
||||
|
||||
try {
|
||||
isSaving.value = true;
|
||||
await props.onSave?.();
|
||||
emit('save');
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
// You might want to emit an error event here
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
props.onDismiss?.();
|
||||
emit('dismiss');
|
||||
stopAutoSave();
|
||||
};
|
||||
|
||||
const startAutoSave = () => {
|
||||
if (!props.onAutoSave || autoSaveTimer) return;
|
||||
|
||||
autoSaveCountdown.value = props.autoSaveDelay;
|
||||
initialCountdown.value = props.autoSaveDelay;
|
||||
|
||||
// Countdown timer
|
||||
countdownTimer = setInterval(() => {
|
||||
autoSaveCountdown.value--;
|
||||
|
||||
if (autoSaveCountdown.value <= 0) {
|
||||
executeAutoSave();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const executeAutoSave = async () => {
|
||||
stopAutoSave();
|
||||
|
||||
try {
|
||||
await props.onAutoSave?.();
|
||||
emit('autoSave');
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
// Optionally restart auto-save on failure
|
||||
if (props.show) {
|
||||
startAutoSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelAutoSave = () => {
|
||||
stopAutoSave();
|
||||
};
|
||||
|
||||
const stopAutoSave = () => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = null;
|
||||
}
|
||||
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
|
||||
autoSaveCountdown.value = 0;
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.show,
|
||||
(newShow) => {
|
||||
if (newShow && props.showAutoSaveProgress && props.onAutoSave) {
|
||||
startAutoSave();
|
||||
} else if (!newShow) {
|
||||
stopAutoSave();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (props.show && props.showAutoSaveProgress && props.onAutoSave) {
|
||||
startAutoSave();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoSave();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
.max-h-0 {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.max-h-40 {
|
||||
max-height: 10rem;
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load diff
|
@ -99,5 +99,6 @@ export const getButtonColor = (color: 'white' | 'contrast' | 'light' | 'success'
|
|||
base.push(isOutlined ? colors.outlineHover[color] : colors.bgHover[color]);
|
||||
}
|
||||
|
||||
return base;
|
||||
// return base;
|
||||
return base.join(' '); // Join array into single string
|
||||
};
|
||||
|
|
Loading…
Add table
editor.link_modal.header
Reference in a new issue