### 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
495 lines
21 KiB
Vue
495 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue';
|
|
import { mdiTrashCan } from '@mdi/js';
|
|
import { mdiDragVariant, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
|
import BaseIcon from '@/Components/BaseIcon.vue';
|
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
|
import BaseButton from '@/Components/BaseButton.vue';
|
|
import { Person } from '@/Dataset';
|
|
import Draggable from 'vuedraggable';
|
|
import FormControl from '@/Components/FormControl.vue';
|
|
|
|
interface Props {
|
|
checkable?: boolean;
|
|
persons?: Person[];
|
|
relation: string;
|
|
contributortypes?: Record<string, string>;
|
|
errors?: Record<string, string[]>;
|
|
isLoading?: boolean;
|
|
canDelete?: boolean;
|
|
canEdit?: boolean;
|
|
canReorder?: boolean;
|
|
}
|
|
|
|
// const props = defineProps({
|
|
// checkable: Boolean,
|
|
// persons: {
|
|
// type: Array<Person>,
|
|
// default: () => [],
|
|
// },
|
|
// relation: {
|
|
// type: String,
|
|
// required: true,
|
|
// },
|
|
// contributortypes: {
|
|
// type: Object,
|
|
// default: () => ({}),
|
|
// },
|
|
// errors: {
|
|
// type: Object,
|
|
// default: () => ({}),
|
|
// },
|
|
// });
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
checkable: false,
|
|
persons: () => [],
|
|
contributortypes: () => ({}),
|
|
errors: () => ({}),
|
|
isLoading: false,
|
|
canDelete: true,
|
|
canEdit: true,
|
|
canReorder: true,
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
'update:persons': [value: Person[]];
|
|
'remove-person': [index: number, person: Person];
|
|
'person-updated': [index: number, person: Person];
|
|
'reorder': [oldIndex: number, newIndex: number];
|
|
}>();
|
|
|
|
// Local state
|
|
const perPage = ref(5);
|
|
const currentPage = ref(0);
|
|
const dragEnabled = ref(props.canReorder);
|
|
|
|
// Computed properties
|
|
const items = computed({
|
|
get() {
|
|
return props.persons;
|
|
},
|
|
// setter
|
|
set(value) {
|
|
// Note: we are using destructuring assignment syntax here.
|
|
|
|
props.persons.length = 0;
|
|
props.persons.push(...value);
|
|
},
|
|
});
|
|
|
|
const itemsPaginated = computed(() => {
|
|
const start = perPage.value * currentPage.value;
|
|
const end = perPage.value * (currentPage.value + 1);
|
|
return items.value.slice(start, end);
|
|
});
|
|
|
|
const numPages = computed(() => Math.ceil(items.value.length / perPage.value));
|
|
const currentPageHuman = computed(() => currentPage.value + 1);
|
|
const hasMultiplePages = computed(() => numPages.value > 1);
|
|
const showContributorTypes = computed(() => Object.keys(props.contributortypes).length > 0);
|
|
|
|
const pagesList = computed(() => {
|
|
const pages: number[] = [];
|
|
const maxVisible = 10;
|
|
|
|
if (numPages.value <= maxVisible) {
|
|
for (let i = 0; i < numPages.value; i++) {
|
|
pages.push(i);
|
|
}
|
|
} else {
|
|
// Smart pagination with ellipsis
|
|
if (currentPage.value <= 2) {
|
|
for (let i = 0; i < 4; i++) pages.push(i);
|
|
pages.push(-1); // Ellipsis marker
|
|
pages.push(numPages.value - 1);
|
|
} else if (currentPage.value >= numPages.value - 3) {
|
|
pages.push(0);
|
|
pages.push(-1);
|
|
for (let i = numPages.value - 4; i < numPages.value; i++) {
|
|
pages.push(i);
|
|
}
|
|
} else {
|
|
pages.push(0);
|
|
pages.push(-1);
|
|
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
|
|
pages.push(i);
|
|
}
|
|
pages.push(-1);
|
|
pages.push(numPages.value - 1);
|
|
}
|
|
}
|
|
|
|
return pages;
|
|
});
|
|
|
|
// const removeAuthor = (key: number) => {
|
|
// items.value.splice(key, 1);
|
|
// };
|
|
// Methods
|
|
const removeAuthor = (index: number) => {
|
|
const actualIndex = perPage.value * currentPage.value + index;
|
|
const person = items.value[actualIndex];
|
|
|
|
if (confirm(`Are you sure you want to remove ${person.first_name || ''} ${person.last_name || person.email}?`)) {
|
|
items.value.splice(actualIndex, 1);
|
|
emit('remove-person', actualIndex, person);
|
|
|
|
// Adjust current page if needed
|
|
if (itemsPaginated.value.length === 0 && currentPage.value > 0) {
|
|
currentPage.value--;
|
|
}
|
|
}
|
|
};
|
|
|
|
const updatePerson = (index: number, field: keyof Person, value: any) => {
|
|
const actualIndex = perPage.value * currentPage.value + index;
|
|
const person = items.value[actualIndex];
|
|
(person as any)[field] = value;
|
|
emit('person-updated', actualIndex, person);
|
|
};
|
|
|
|
const goToPage = (page: number) => {
|
|
if (page >= 0 && page < numPages.value) {
|
|
currentPage.value = page;
|
|
}
|
|
};
|
|
|
|
const getFieldError = (index: number, field: string): string => {
|
|
const actualIndex = perPage.value * currentPage.value + index;
|
|
const errorKey = `${props.relation}.${actualIndex}.${field}`;
|
|
return props.errors[errorKey]?.join(', ') || '';
|
|
};
|
|
|
|
const handleDragEnd = (evt: any) => {
|
|
if (evt.oldIndex !== evt.newIndex) {
|
|
emit('reorder', evt.oldIndex, evt.newIndex);
|
|
}
|
|
};
|
|
|
|
// Watchers
|
|
watch(
|
|
() => props.persons.length,
|
|
() => {
|
|
// Reset to first page if current page is out of bounds
|
|
if (currentPage.value >= numPages.value && numPages.value > 0) {
|
|
currentPage.value = numPages.value - 1;
|
|
}
|
|
},
|
|
);
|
|
|
|
// Pagination helper
|
|
const perPageOptions = [
|
|
{ value: 5, label: '5 per page' },
|
|
{ value: 10, label: '10 per page' },
|
|
{ value: 20, label: '20 per page' },
|
|
{ value: 50, label: '50 per page' },
|
|
];
|
|
</script>
|
|
|
|
<template>
|
|
<div class="card">
|
|
<!-- Table Controls -->
|
|
<div v-if="hasMultiplePages" class="flex justify-between items-center p-3 border-b border-gray-200 dark:border-slate-700">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
Showing {{ currentPage * perPage + 1 }} to
|
|
{{ Math.min((currentPage + 1) * perPage, items.length) }}
|
|
of {{ items.length }} entries
|
|
</span>
|
|
</div>
|
|
<select
|
|
v-model="perPage"
|
|
@change="currentPage = 0"
|
|
class="px-3 py-1 text-sm border rounded-md dark:bg-slate-800 dark:border-slate-600"
|
|
>
|
|
<option v-for="option in perPageOptions" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="border-b border-gray-200 dark:border-slate-700">
|
|
<th v-if="canReorder" class="w-10 p-3" />
|
|
<th scope="col" class="text-left p-3">#</th>
|
|
<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>
|
|
</tr>
|
|
</thead>
|
|
<!-- <tbody> -->
|
|
<!-- <tr v-for="(client, index) in itemsPaginated" :key="client.id"> -->
|
|
<draggable
|
|
v-if="canReorder && !hasMultiplePages"
|
|
tag="tbody"
|
|
v-model="items"
|
|
item-key="id"
|
|
:disabled="!dragEnabled || isLoading"
|
|
@end="handleDragEnd"
|
|
handle=".drag-handle"
|
|
>
|
|
<template #item="{ index, element }">
|
|
<tr class="border-b border-gray-100 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800">
|
|
<td class="p-3">
|
|
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600">
|
|
<BaseIcon :path="mdiDragVariant" />
|
|
</div>
|
|
</td>
|
|
<td class="p-3">{{ index + 1 }}</td>
|
|
<td data-label="Id" class="p-3 text-sm text-gray-600">{{ element.id || '-' }}</td>
|
|
|
|
<!-- First Name - Hidden for Organizational -->
|
|
<td class="p-3" data-label="First Name" v-if="element.name_type !== 'Organizational'">
|
|
<FormControl
|
|
required
|
|
v-model="element.first_name"
|
|
type="text"
|
|
:is-read-only="element.status == true"
|
|
placeholder="[FIRST NAME]"
|
|
/>
|
|
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])">
|
|
{{ errors[`${relation}.${index}.first_name`].join(', ') }}
|
|
</div>
|
|
</td>
|
|
<td v-else></td>
|
|
<!-- Empty cell for organizational entries -->
|
|
|
|
<!-- Last Name / Organization Name -->
|
|
<td :data-label="element.name_type === 'Organizational' ? 'Organization Name' : 'Last Name'">
|
|
<FormControl
|
|
required
|
|
v-model="element.last_name"
|
|
type="text"
|
|
:is-read-only="element.status == true"
|
|
:placeholder="element.name_type === 'Organizational' ? '[ORGANIZATION NAME]' : '[LAST NAME]'"
|
|
/>
|
|
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])">
|
|
{{ errors[`${relation}.${index}.last_name`].join(', ') }}
|
|
</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
|
|
required
|
|
v-model="element.email"
|
|
type="text"
|
|
:is-read-only="element.status == true"
|
|
placeholder="[EMAIL]"
|
|
/>
|
|
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])">
|
|
{{ errors[`${relation}.${index}.email`].join(', ') }}
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Contributor Type -->
|
|
<td v-if="Object.keys(contributortypes).length">
|
|
<FormControl
|
|
required
|
|
v-model="element.pivot_contributor_type"
|
|
type="select"
|
|
:options="contributortypes"
|
|
placeholder="[relation type]"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
|
|
>
|
|
{{ errors[`${relation}.${index}.pivot_contributor_type`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
|
|
<!-- Actions -->
|
|
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
|
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeAuthor(index)" />
|
|
</BaseButtons>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</draggable>
|
|
<!-- </tbody> -->
|
|
<!-- Non-draggable tbody for paginated view -->
|
|
<tbody v-else>
|
|
<tr
|
|
v-for="(element, index) in itemsPaginated"
|
|
:key="element.id || index"
|
|
class="border-b border-gray-100 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800"
|
|
>
|
|
<td v-if="canReorder" class="p-3 text-gray-400">
|
|
<BaseIcon :path="mdiDragVariant" />
|
|
</td>
|
|
<td class="p-3">{{ currentPage * perPage + index + 1 }}</td>
|
|
<td class="p-3 text-sm text-gray-600">{{ element.id || '-' }}</td>
|
|
|
|
<!-- Same field structure as draggable version -->
|
|
<td class="p-3">
|
|
<FormControl
|
|
v-if="element.name_type !== 'Organizational'"
|
|
required
|
|
:model-value="element.first_name"
|
|
@update:model-value="updatePerson(index, 'first_name', $event)"
|
|
type="text"
|
|
:is-read-only="element.status || !canEdit"
|
|
placeholder="[FIRST NAME]"
|
|
:error="getFieldError(index, 'first_name')"
|
|
/>
|
|
<span v-else class="text-gray-400">-</span>
|
|
<div v-if="getFieldError(index, 'first_name')" class="text-red-400 text-sm">
|
|
{{ getFieldError(index, 'first_name') }}
|
|
</div>
|
|
</td>
|
|
|
|
<td class="p-3">
|
|
<FormControl
|
|
required
|
|
:model-value="element.last_name"
|
|
@update:model-value="updatePerson(index, 'last_name', $event)"
|
|
type="text"
|
|
:is-read-only="element.status || !canEdit"
|
|
:placeholder="element.name_type === 'Organizational' ? '[ORGANIZATION NAME]' : '[LAST NAME]'"
|
|
:error="getFieldError(index, 'last_name')"
|
|
/>
|
|
<div v-if="getFieldError(index, 'last_name')" class="text-red-400 text-sm">
|
|
{{ getFieldError(index, 'last_name') }}
|
|
</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
|
|
:model-value="element.email"
|
|
@update:model-value="updatePerson(index, 'email', $event)"
|
|
type="email"
|
|
:is-read-only="element.status || !canEdit"
|
|
placeholder="[EMAIL]"
|
|
:error="getFieldError(index, 'email')"
|
|
/>
|
|
<div v-if="getFieldError(index, 'email')" class="text-red-400 text-sm">
|
|
{{ getFieldError(index, 'email') }}
|
|
</div>
|
|
</td>
|
|
|
|
<td v-if="showContributorTypes" class="p-3">
|
|
<FormControl
|
|
required
|
|
:model-value="element.pivot_contributor_type"
|
|
@update:model-value="updatePerson(index, 'pivot_contributor_type', $event)"
|
|
type="select"
|
|
:options="contributortypes"
|
|
:is-read-only="element.status || !canEdit"
|
|
placeholder="[Select type]"
|
|
:error="getFieldError(index, 'pivot_contributor_type')"
|
|
/>
|
|
<div v-if="getFieldError(index, 'pivot_contributor_type')" class="text-red-400 text-sm">
|
|
{{ getFieldError(index, 'pivot_contributor_type') }}
|
|
</div>
|
|
</td>
|
|
|
|
<td v-if="canDelete" class="p-3">
|
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
|
<BaseButton
|
|
color="danger"
|
|
:icon="mdiTrashCan"
|
|
small
|
|
@click="removeAuthor(index)"
|
|
:disabled="element.status || !canEdit"
|
|
title="Remove person"
|
|
/>
|
|
</BaseButtons>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Empty State -->
|
|
<tr v-if="items.length === 0">
|
|
<td :colspan="canReorder ? 8 : 7" class="text-center p-8 text-gray-500">No persons added yet</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="hasMultiplePages" class="flex justify-between items-center p-3 border-t border-gray-200 dark:border-slate-700">
|
|
<div class="flex gap-1">
|
|
<BaseButton :disabled="currentPage === 0" @click="goToPage(currentPage - 1)" :icon="mdiChevronLeft" small outline />
|
|
|
|
<template v-for="(page, i) in pagesList" :key="i">
|
|
<span v-if="page === -1" class="px-3 py-1">...</span>
|
|
<BaseButton
|
|
v-else
|
|
@click="goToPage(page)"
|
|
:label="String(page + 1)"
|
|
:color="page === currentPage ? 'info' : 'whiteDark'"
|
|
small
|
|
:outline="page !== currentPage"
|
|
/>
|
|
</template>
|
|
|
|
<BaseButton
|
|
:disabled="currentPage >= numPages - 1"
|
|
@click="goToPage(currentPage + 1)"
|
|
:icon="mdiChevronRight"
|
|
small
|
|
outline
|
|
/>
|
|
</div>
|
|
|
|
<span class="text-sm text-gray-600 dark:text-gray-400"> Page {{ currentPageHuman }} of {{ numPages }} </span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="postcss" scoped>
|
|
.drag-handle {
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.card {
|
|
@apply bg-white dark:bg-slate-900 rounded-lg shadow-sm;
|
|
}
|
|
|
|
/* Improve table responsiveness */
|
|
@media (max-width: 768px) {
|
|
table {
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
th,
|
|
td {
|
|
padding: 0.5rem !important;
|
|
}
|
|
}
|
|
</style>
|