### 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
287 lines
9.7 KiB
Vue
287 lines
9.7 KiB
Vue
<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>
|