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:
Kaimbacher 2025-09-15 14:07:59 +02:00
parent 06ed2f3625
commit 8f67839f93
16 changed files with 2657 additions and 1168 deletions

View file

@ -42,10 +42,22 @@ export default class DatasetController {
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
})
.preload('authors', (builder) => {
builder.orderBy('pivot_sort_order', 'asc');
builder
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.pivotColumns(['role', 'sort_order'])
.orderBy('pivot_sort_order', 'asc');
})
.preload('contributors', (builder) => {
builder.orderBy('pivot_sort_order', 'asc');
builder
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.pivotColumns(['role', 'sort_order', 'contributor_type'])
.orderBy('pivot_sort_order', 'asc');
})
.preload('subjects')
.preload('coverage')

View file

@ -232,7 +232,7 @@ export default class DatasetController {
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
}),
@ -248,7 +248,7 @@ export default class DatasetController {
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
@ -983,19 +983,6 @@ export default class DatasetController {
const years = Array.from({ length: currentYear - 1990 + 1 }, (_, index) => 1990 + index);
const licenses = await License.query().select('id', 'name_long').where('active', 'true').pluck('name_long', 'id');
// const userHasRoles = user.roles;
// const datasetHasLicenses = await dataset.related('licenses').query().pluck('id');
// const checkeds = dataset.licenses.first().id;
// const doctypes = {
// analysisdata: { label: 'Analysis', value: 'analysisdata' },
// measurementdata: { label: 'Measurements', value: 'measurementdata' },
// monitoring: 'Monitoring',
// remotesensing: 'Remote Sensing',
// gis: 'GIS',
// models: 'Models',
// mixedtype: 'Mixed Type',
// };
return inertia.render('Submitter/Dataset/Edit', {
dataset,
@ -1015,7 +1002,7 @@ export default class DatasetController {
referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })),
relationTypes: Object.entries(RelationTypes).map(([key, value]) => ({ value: key, label: value })),
doctypes: DatasetTypes,
can: {
can: {
edit: await auth.user?.can(['dataset-edit']),
delete: await auth.user?.can(['dataset-delete']),
},
@ -1163,42 +1150,93 @@ export default class DatasetController {
}
}
// Process all subjects/keywords from the request
const subjects = request.input('subjects');
// ============================================
// IMPROVED SUBJECTS PROCESSING
// ============================================
const subjects = request.input('subjects', []);
const currentDatasetSubjectIds = new Set<number>();
for (const subjectData of subjects) {
// Case 1: Subject already exists in the database (has an ID)
let subjectToRelate: Subject;
// Case 1: Subject has an ID (existing subject being updated)
if (subjectData.id) {
// Retrieve the existing subject
const existingSubject = await Subject.findOrFail(subjectData.id);
// Update subject properties from the request data
existingSubject.value = subjectData.value;
existingSubject.type = subjectData.type;
existingSubject.external_key = subjectData.external_key;
// Check if the updated value conflicts with another existing subject
const duplicateSubject = await Subject.query()
.where('value', subjectData.value)
.where('type', subjectData.type)
.where('language', subjectData.language || 'en') // Default language if not provided
.where('id', '!=', subjectData.id) // Exclude the current subject
.first();
// Only save if there are actual changes
if (existingSubject.$isDirty) {
await existingSubject.save();
if (duplicateSubject) {
// A duplicate exists - use the existing duplicate instead
subjectToRelate = duplicateSubject;
// Check if the original subject should be deleted (if it's only used by this dataset)
const originalSubjectUsage = await Subject.query()
.where('id', existingSubject.id)
.withCount('datasets')
.firstOrFail();
if (originalSubjectUsage.$extras.datasets_count <= 1) {
// Only used by this dataset, safe to delete after detaching
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
await existingSubject.useTransaction(trx).delete();
} else {
// Used by other datasets, just detach from this one
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
}
} else {
// No duplicate found, update the existing subject
existingSubject.value = subjectData.value;
existingSubject.type = subjectData.type;
existingSubject.language = subjectData.language;
existingSubject.external_key = subjectData.external_key;
if (existingSubject.$isDirty) {
await existingSubject.useTransaction(trx).save();
}
subjectToRelate = existingSubject;
}
// Note: The relationship between dataset and subject is already established,
// so we don't need to attach it again
}
// Case 2: New subject being added (no ID)
else {
// Check if a subject with the same value and type already exists in the database
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData);
// Use firstOrNew to either find existing or create new subject
subjectToRelate = await Subject.firstOrNew(
{
value: subjectData.value,
type: subjectData.type,
language: subjectData.language || 'en',
},
{
value: subjectData.value,
type: subjectData.type,
language: subjectData.language || 'en',
external_key: subjectData.external_key,
},
);
if (subject.$isNew === true) {
// If it's a completely new subject, create and associate it with the dataset
await dataset.useTransaction(trx).related('subjects').save(subject);
} else {
// If the subject already exists, just create the relationship
await dataset.useTransaction(trx).related('subjects').attach([subject.id]);
if (subjectToRelate.$isNew) {
await subjectToRelate.useTransaction(trx).save();
}
}
// Ensure the relationship exists between dataset and subject
const relationshipExists = await dataset.related('subjects').query().where('subject_id', subjectToRelate.id).first();
if (!relationshipExists) {
await dataset.useTransaction(trx).related('subjects').attach([subjectToRelate.id]);
}
// Track which subjects should remain associated with this dataset
currentDatasetSubjectIds.add(subjectToRelate.id);
}
// Handle explicit deletions
const subjectsToDelete = request.input('subjectsToDelete', []);
for (const subjectData of subjectsToDelete) {
if (subjectData.id) {
@ -1211,16 +1249,16 @@ export default class DatasetController {
.withCount('datasets')
.firstOrFail();
// Check if the subject is used by multiple datasets
if (subject.$extras.datasets_count > 1) {
// If used by multiple datasets, just detach it from the current dataset
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
} else {
// If only used by this dataset, delete the subject completely
// Detach the subject from this dataset
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
// If this was the only dataset using this subject, delete it entirely
if (subject.$extras.datasets_count <= 1) {
await subject.useTransaction(trx).delete();
}
// Remove from current set if it was added earlier
currentDatasetSubjectIds.delete(subjectData.id);
}
}

View file

@ -1,4 +1,4 @@
import { column, SnakeCaseNamingStrategy, computed, manyToMany, afterFetch, afterFind } from '@adonisjs/lucid/orm';
import { column, SnakeCaseNamingStrategy, computed, manyToMany } from '@adonisjs/lucid/orm';
import { DateTime } from 'luxon';
import dayjs from 'dayjs';
import Dataset from './dataset.js';
@ -30,7 +30,7 @@ export default class Person extends BaseModel {
@column({})
public lastName: string;
@column({})
@column({ columnName: 'identifier_orcid' })
public identifierOrcid: string;
@column({})
@ -109,19 +109,20 @@ export default class Person extends BaseModel {
// return json;
// }
@afterFind()
public static async afterFindHook(person: Person) {
if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
person.email = undefined as any;
}
}
@afterFetch()
public static async afterFetchHook(persons: Person[]) {
persons.forEach(person => {
if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
person.email = undefined as any;
}
});
}
// @afterFind()
// public static async afterFindHook(person: Person) {
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
// person.email = undefined as any;
// }
// }
// @afterFetch()
// public static async afterFetchHook(persons: Person[]) {
// persons.forEach(person => {
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
// person.email = undefined as any;
// }
// });
// }
}

View file

@ -69,6 +69,7 @@ export const createDatasetValidator = vine.compile(
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
@ -86,6 +87,7 @@ export const createDatasetValidator = vine.compile(
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.distinct('email')
@ -216,6 +218,7 @@ export const updateDatasetValidator = vine.compile(
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
@ -232,6 +235,7 @@ export const updateDatasetValidator = vine.compile(
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}),
)
@ -307,10 +311,9 @@ export const updateDatasetValidator = vine.compile(
.dependentArrayMinLength({ dependentArray: 'fileInputs', min: 1 }),
fileInputs: vine.array(
vine.object({
label: vine.string().trim().maxLength(100),
//extnames: extensions,
label: vine.string().trim().maxLength(100),
}),
),
).optional(),
}),
);
@ -367,6 +370,7 @@ export const updateEditorDatasetValidator = vine.compile(
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
@ -383,6 +387,7 @@ export const updateEditorDatasetValidator = vine.compile(
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}),
)