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

@ -13,7 +13,8 @@ export default defineConfig({
commands: [ commands: [
() => import('@adonisjs/core/commands'), () => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'), () => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/mail/commands')], () => import('@adonisjs/mail/commands')
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Preloads | Preloads
@ -36,6 +37,7 @@ export default defineConfig({
() => import('#start/rules/referenceValidation'), () => import('#start/rules/referenceValidation'),
() => import('#start/rules/valid_mimetype'), () => import('#start/rules/valid_mimetype'),
() => import('#start/rules/array_contains_types'), () => import('#start/rules/array_contains_types'),
() => import('#start/rules/orcid'),
], ],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -42,10 +42,22 @@ export default class DatasetController {
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']); builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
}) })
.preload('authors', (builder) => { .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) => { .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('subjects')
.preload('coverage') .preload('coverage')

View file

@ -983,19 +983,6 @@ export default class DatasetController {
const years = Array.from({ length: currentYear - 1990 + 1 }, (_, index) => 1990 + index); 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 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', { return inertia.render('Submitter/Dataset/Edit', {
dataset, dataset,
@ -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) { 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) { if (subjectData.id) {
// Retrieve the existing subject
const existingSubject = await Subject.findOrFail(subjectData.id); const existingSubject = await Subject.findOrFail(subjectData.id);
// Update subject properties from the request data // 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();
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.value = subjectData.value;
existingSubject.type = subjectData.type; existingSubject.type = subjectData.type;
existingSubject.language = subjectData.language;
existingSubject.external_key = subjectData.external_key; existingSubject.external_key = subjectData.external_key;
// Only save if there are actual changes
if (existingSubject.$isDirty) { if (existingSubject.$isDirty) {
await existingSubject.save(); await existingSubject.useTransaction(trx).save();
} }
// Note: The relationship between dataset and subject is already established, subjectToRelate = existingSubject;
// so we don't need to attach it again }
} }
// Case 2: New subject being added (no ID) // Case 2: New subject being added (no ID)
else { else {
// Check if a subject with the same value and type already exists in the database // Use firstOrNew to either find existing or create new subject
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData); 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 (subjectToRelate.$isNew) {
// If it's a completely new subject, create and associate it with the dataset await subjectToRelate.useTransaction(trx).save();
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]);
}
} }
} }
// 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', []); const subjectsToDelete = request.input('subjectsToDelete', []);
for (const subjectData of subjectsToDelete) { for (const subjectData of subjectsToDelete) {
if (subjectData.id) { if (subjectData.id) {
@ -1211,16 +1249,16 @@ export default class DatasetController {
.withCount('datasets') .withCount('datasets')
.firstOrFail(); .firstOrFail();
// Check if the subject is used by multiple datasets // Detach the subject from this dataset
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]); await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
} else {
// If only used by this dataset, delete the subject completely
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(); 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 { DateTime } from 'luxon';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import Dataset from './dataset.js'; import Dataset from './dataset.js';
@ -30,7 +30,7 @@ export default class Person extends BaseModel {
@column({}) @column({})
public lastName: string; public lastName: string;
@column({}) @column({ columnName: 'identifier_orcid' })
public identifierOrcid: string; public identifierOrcid: string;
@column({}) @column({})
@ -109,19 +109,20 @@ export default class Person extends BaseModel {
// return json; // 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() // @afterFind()
public static async afterFetchHook(persons: Person[]) { // public static async afterFindHook(person: Person) {
persons.forEach(person => { // if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') { // person.email = undefined as any;
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' }), .isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'), first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255), last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}), }),
) )
.minLength(1) .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'), first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255), last_name: vine.string().trim().minLength(3).maxLength(255),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)), pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}), }),
) )
.distinct('email') .distinct('email')
@ -216,6 +218,7 @@ export const updateDatasetValidator = vine.compile(
.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'), first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255), last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}), }),
) )
.minLength(1) .minLength(1)
@ -232,6 +235,7 @@ export const updateDatasetValidator = vine.compile(
.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'), first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255), 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)), pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}), }),
) )
@ -308,9 +312,8 @@ export const updateDatasetValidator = vine.compile(
fileInputs: vine.array( fileInputs: vine.array(
vine.object({ vine.object({
label: vine.string().trim().maxLength(100), label: vine.string().trim().maxLength(100),
//extnames: extensions,
}), }),
), ).optional(),
}), }),
); );
@ -367,6 +370,7 @@ export const updateEditorDatasetValidator = vine.compile(
.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'), first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255), last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}), }),
) )
.minLength(1) .minLength(1)
@ -383,6 +387,7 @@ export const updateEditorDatasetValidator = vine.compile(
.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'), first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255), 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)), pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}), }),
) )

View file

@ -12,10 +12,8 @@ import { getDomain } from '#app/utils/utility-functions';
import { BaseCommand, flags } from '@adonisjs/core/ace'; import { BaseCommand, flags } from '@adonisjs/core/ace';
import { CommandOptions } from '@adonisjs/core/types/ace'; import { CommandOptions } from '@adonisjs/core/types/ace';
import env from '#start/env'; import env from '#start/env';
// import db from '@adonisjs/lucid/services/db';
// import { default as Dataset } from '#models/dataset';
import logger from '@adonisjs/core/services/logger'; import logger from '@adonisjs/core/services/logger';
import { DateTime } from 'luxon';
const opensearchNode = env.get('OPENSEARCH_HOST', 'localhost'); const opensearchNode = env.get('OPENSEARCH_HOST', 'localhost');
const client = new Client({ node: `${opensearchNode}` }); // replace with your OpenSearch endpoint const client = new Client({ node: `${opensearchNode}` }); // replace with your OpenSearch endpoint
@ -30,11 +28,10 @@ export default class IndexDatasets extends BaseCommand {
public publish_id: number; public publish_id: number;
public static options: CommandOptions = { public static options: CommandOptions = {
startApp: true, startApp: true, // Ensures the IoC container is ready to use
staysAlive: false, staysAlive: false, // Command exits after running
}; };
async run() { async run() {
logger.debug('Hello world!'); logger.debug('Hello world!');
// const { default: Dataset } = await import('#models/dataset'); // const { default: Dataset } = await import('#models/dataset');
@ -44,10 +41,12 @@ export default class IndexDatasets extends BaseCommand {
const index_name = 'tethys-records'; const index_name = 'tethys-records';
for (var dataset of datasets) { for (var dataset of datasets) {
// Logger.info(`File publish_id ${dataset.publish_id}`); const shouldUpdate = await this.shouldUpdateDataset(dataset, index_name);
// const jsonString = await this.getJsonString(dataset, proc); if (shouldUpdate) {
// console.log(jsonString);
await this.indexDocument(dataset, index_name, proc); await this.indexDocument(dataset, index_name, proc);
} else {
logger.info(`Dataset with publish_id ${dataset.publish_id} is up to date, skipping indexing`);
}
} }
} }
@ -65,6 +64,46 @@ export default class IndexDatasets extends BaseCommand {
return await query.exec(); return await query.exec();
} }
private async shouldUpdateDataset(dataset: Dataset, index_name: string): Promise<boolean> {
try {
// Check if publish_id exists before proceeding
if (!dataset.publish_id) {
// Return true to update since document doesn't exist in OpenSearch yet
return true;
}
// Get the existing document from OpenSearch
const response = await client.get({
index: index_name,
id: dataset.publish_id?.toString(),
});
const existingDoc = response.body._source;
// Compare server_date_modified
if (existingDoc && existingDoc.server_date_modified) {
// Convert Unix timestamp (seconds) to milliseconds for DateTime.fromMillis()
const existingModified = DateTime.fromMillis(Number(existingDoc.server_date_modified) * 1000);
const currentModified = dataset.server_date_modified;
// Only update if the dataset has been modified more recently
if (currentModified <= existingModified) {
return false;
}
}
return true;
} catch (error) {
// If document doesn't exist or other error, we should index it
if (error.statusCode === 404) {
logger.info(`Dataset with publish_id ${dataset.publish_id} not found in index, will create new document`);
return true;
}
logger.warn(`Error checking existing document for publish_id ${dataset.publish_id}: ${error.message}`);
return true; // Index anyway if we can't determine the status
}
}
private async indexDocument(dataset: Dataset, index_name: string, proc: Buffer): Promise<void> { private async indexDocument(dataset: Dataset, index_name: string, proc: Buffer): Promise<void> {
try { try {
const doc = await this.getJsonString(dataset, proc); const doc = await this.getJsonString(dataset, proc);
@ -78,7 +117,8 @@ export default class IndexDatasets extends BaseCommand {
}); });
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`); logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
} catch (error) { } catch (error) {
logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.`); logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.
Error: ${error.message}`);
} }
} }

1541
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<script setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed, PropType } from 'vue';
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
// import { Link } from '@inertiajs/inertia-vue3'; // import { Link } from '@inertiajs/inertia-vue3';
import { getButtonColor } from '@/colors'; import { getButtonColor } from '@/colors';
@ -31,7 +31,7 @@ const props = defineProps({
default: null, default: null,
}, },
color: { color: {
type: String, type: String as PropType<'white' | 'contrast' | 'light' | 'success' | 'danger' | 'warning' | 'info' | 'modern'>,
default: 'white', default: 'white',
}, },
as: { as: {
@ -45,11 +45,18 @@ const props = defineProps({
roundedFull: Boolean, roundedFull: Boolean,
}); });
const emit = defineEmits(['click']);
const is = computed(() => { const is = computed(() => {
if (props.as) { if (props.as) {
return 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) { if (props.routeName) {
return Link; return Link;
} }
@ -69,47 +76,105 @@ const computedType = computed(() => {
return null; 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 labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'));
const componentClass = computed(() => { const componentClass = computed(() => {
const base = [ const base = [
'inline-flex', 'inline-flex',
'cursor-pointer',
'justify-center', 'justify-center',
'items-center', 'items-center',
'whitespace-nowrap', 'whitespace-nowrap',
'focus:outline-none', 'focus:outline-none',
'transition-colors', 'transition-colors',
'focus:ring-2',
'duration-150', 'duration-150',
'border', 'border',
props.roundedFull ? 'rounded-full' : 'rounded', 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) { if (props.small) {
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1'); base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1');
} else { } else {
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3'); base.push('py-2', props.roundedFull ? 'px-6' : 'px-3');
} }
// Add disabled/enabled specific classes
if (props.disabled) { 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; return base;
}); });
// Handle click events with disabled check
const handleClick = (event) => {
if (props.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
emit('click', event);
};
</script> </script>
<template> <template>
<component <component
:is="is" :is="is"
:class="componentClass" :class="componentClass"
:href="routeName ? routeName : href" :href="computedHref"
:to="props.disabled ? null : props.routeName"
:type="computedType" :type="computedType"
:target="target" :target="computedTarget"
:disabled="disabled" :disabled="computedDisabled"
:tabindex="props.disabled ? -1 : null"
:aria-disabled="props.disabled ? 'true' : null"
@click="handleClick"
> >
<BaseIcon v-if="icon" :path="icon" /> <BaseIcon v-if="icon" :path="icon" />
<span v-if="label" :class="labelClass">{{ label }}</span> <span v-if="label" :class="labelClass">{{ label }}</span>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, watch, ref } from 'vue';
interface Props { interface Props {
name: string; name: string;
@ -13,32 +13,138 @@ const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>(); 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({ const computedValue = computed({
get: () => props.modelValue, get: () => {
set: (value) => { if (props.type === 'radio') {
emit('update:modelValue', props.type === 'radio' ? [value] : value); // 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')); const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue // Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
const isChecked = computed(() => { // const isChecked = computed(() => {
if (Array.isArray(computedValue.value) && computedValue.value.length > 0) { // if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
return props.type === 'radio' // return props.type === 'radio'
? computedValue.value[0] === props.inputValue // ? computedValue.value[0] === props.inputValue
: computedValue.value.includes(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 computedValue.value === 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;
};
// 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> </script>
<template> <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"> 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" <input
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0" v-model="computedValue"
:checked="isChecked" /> :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="{ <span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
'border-gray-700': !isChecked, 'border-gray-700': !isChecked,
'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked 'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked

View file

@ -38,32 +38,82 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const computedValue = computed({ // const computedValue = computed({
// get: () => props.modelValue, // // get: () => props.modelValue,
get: () => { // 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); // const ids = props.modelValue.map((obj) => obj.id);
// return ids; // 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; // return props.modelValue;
}, // }
set: (value) => { // // return props.modelValue;
emit('update:modelValue', value); // },
}, // set: (value) => {
}); // emit('update:modelValue', value);
// },
// });
// Define a type guard to check if an object has an 'id' attribute // Define a type guard to check if an object has an 'id' attribute
// function hasIdAttribute(obj: any): obj is { id: any } { // function hasIdAttribute(obj: any): obj is { id: any } {
// return typeof obj === 'object' && 'id' in obj; // 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 } => { const hasIdAttribute = (obj: any): obj is { id: any } => {
return typeof obj === 'object' && 'id' in obj; return typeof obj === 'object' && 'id' in obj;
}; };
@ -110,7 +160,7 @@ const inputElClass = computed(() => {
</div> </div>
<!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type" <!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
:name="name" :input-value="key" :label="value" :class="componentClass" /> --> :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" /> :name="name" :input-value="isNaN(Number(key)) ? key : Number(key)" :label="value" :class="componentClass" />
</div> </div>
</template> </template>

View file

@ -218,6 +218,7 @@ const perPageOptions = [
<th scope="col">Id</th> <th scope="col">Id</th>
<th>First Name</th> <th>First Name</th>
<th>Last Name / Organization</th> <th>Last Name / Organization</th>
<th>Orcid</th>
<th>Email</th> <th>Email</th>
<th v-if="showContributorTypes" scope="col" class="text-left p-3">Type</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> <th v-if="canDelete" class="w-20 p-3">Actions</th>
@ -274,6 +275,18 @@ const perPageOptions = [
</div> </div>
</td> </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 --> <!-- Email -->
<td data-label="Email"> <td data-label="Email">
<FormControl <FormControl
@ -362,6 +375,19 @@ const perPageOptions = [
</div> </div>
</td> </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"> <td class="p-3">
<FormControl <FormControl
required required

View 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

View file

@ -99,5 +99,6 @@ export const getButtonColor = (color: 'white' | 'contrast' | 'light' | 'success'
base.push(isOutlined ? colors.outlineHover[color] : colors.bgHover[color]); base.push(isOutlined ? colors.outlineHover[color] : colors.bgHover[color]);
} }
return base; // return base;
return base.join(' '); // Join array into single string
}; };

View file

@ -2,7 +2,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Preloaded File - node ace make:preload rules/dependentArrayMinLength | Preloaded File - node ace make:preload rules/dependentArrayMinLength
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
|*/ */
import { FieldContext } from '@vinejs/vine/types'; import { FieldContext } from '@vinejs/vine/types';
import vine, { VineArray } from '@vinejs/vine'; import vine, { VineArray } from '@vinejs/vine';
@ -17,39 +17,75 @@ type Options = {
}; };
async function dependentArrayMinLength(value: unknown, options: Options, field: FieldContext) { async function dependentArrayMinLength(value: unknown, options: Options, field: FieldContext) {
const fileInputs = field.data[options.dependentArray]; // Access the dependent array const dependentArrayValue = field.data[options.dependentArray];
const isArrayValue = Array.isArray(value);
const isArrayFileInputs = Array.isArray(fileInputs);
if (isArrayValue && isArrayFileInputs) { // Both values can be null/undefined or arrays, but not other types
if (value.length >= options.min) { const isMainValueValid = value === null || value === undefined || Array.isArray(value);
return true; // Valid if the main array length meets the minimum const isDependentValueValid = dependentArrayValue === null || dependentArrayValue === undefined || Array.isArray(dependentArrayValue);
} else if (value.length === 0 && fileInputs.length >= options.min) {
return true; // Valid if the main array is empty and the dependent array meets the minimum if (!isMainValueValid || !isDependentValueValid) {
} else {
field.report( field.report(
`At least {{ min }} item for {{field}} field must be defined`, `Invalid file data format. Please contact support if this error persists.`,
'array.dependentArrayMinLength', 'array.dependentArrayMinLength',
field, field,
options, options,
); );
return false;
} }
} else {
// Report if either value or dependentArray is not an array // Convert null/undefined to empty arrays for length checking
const mainArray = Array.isArray(value) ? value : [];
const dependentArray = Array.isArray(dependentArrayValue) ? dependentArrayValue : [];
// Calculate total count across both arrays
const totalCount = mainArray.length + dependentArray.length;
// Check if minimum requirement is met
if (totalCount >= options.min) {
return true;
}
// Special case: if dependent array has items, main array can be empty/null
if (dependentArray.length >= options.min && mainArray.length === 0) {
return true;
}
// Determine appropriate error message based on context
const hasExistingFiles = dependentArray.length > 0;
const hasNewFiles = mainArray.length > 0;
if (!hasExistingFiles && !hasNewFiles) {
// No files at all
field.report( field.report(
`Both the {{field}} field and dependent array {{dependentArray}} must be arrays.`, `Your dataset must include at least {{ min }} file. Please upload a new file to continue.`,
'array.dependentArrayMinLength',
field,
options,
);
} else if (hasExistingFiles && !hasNewFiles && dependentArray.length < options.min) {
// Has existing files but marked for deletion, no new files
field.report(
`You have marked all existing files for deletion. Please upload at least {{ min }} new file or keep some existing files.`,
'array.dependentArrayMinLength',
field,
options,
);
} else {
// Generic fallback message
field.report(
`Your dataset must have at least {{ min }} file. You can either upload new files or keep existing ones.`,
'array.dependentArrayMinLength', 'array.dependentArrayMinLength',
field, field,
options, options,
); );
} }
return false; // Invalid if none of the conditions are met return false;
} }
export const dependentArrayMinLengthRule = vine.createRule(dependentArrayMinLength); export const dependentArrayMinLengthRule = vine.createRule(dependentArrayMinLength);
// Extend the VineArray interface with the same type parameters // Extend the VineArray interface
declare module '@vinejs/vine' { declare module '@vinejs/vine' {
interface VineArray<Schema extends SchemaTypes> { interface VineArray<Schema extends SchemaTypes> {
dependentArrayMinLength(options: Options): this; dependentArrayMinLength(options: Options): this;

175
start/rules/orcid.ts Normal file
View file

@ -0,0 +1,175 @@
/*
|--------------------------------------------------------------------------
| Preloaded File - node ace make:preload rules/orcid
| Do you want to register the preload file in .adonisrc.ts file? (y/N) · true
| DONE: create start/rules/orcid.ts
| DONE: update adonisrc.ts file
|--------------------------------------------------------------------------
*/
import vine, { VineString } from '@vinejs/vine';
import { FieldContext } from '@vinejs/vine/types';
/**
* ORCID Validator Implementation
*
* Validates ORCID identifiers using both format validation and checksum verification.
* ORCID (Open Researcher and Contributor ID) is a persistent digital identifier
* that distinguishes researchers and supports automated linkages between them
* and their professional activities.
*
* Format: 0000-0000-0000-0000 (where the last digit can be X for checksum 10)
* Algorithm: MOD-11-2 checksum validation as per ISO/IEC 7064:2003
*
* @param value - The ORCID value to validate
* @param _options - Unused options parameter (required by VineJS signature)
* @param field - VineJS field context for error reporting
*/
async function orcidValidator(value: unknown, _options: undefined, field: FieldContext) {
/**
* Type guard: We only validate string values
* The "string" rule should handle type validation before this rule runs
*/
if (typeof value !== 'string') {
return;
}
/**
* Handle optional fields: Skip validation for empty strings
* This allows the field to be truly optional when used with .optional()
*/
if (value.trim() === '') {
return;
}
/**
* Normalize the ORCID value:
* - Remove any whitespace characters
* - Convert to uppercase (for potential X check digit)
*/
const cleanOrcid = value.replace(/\s/g, '').toUpperCase();
/**
* Format Validation
*
* ORCID format regex breakdown:
* ^(\d{4}-){3} - Three groups of exactly 4 digits followed by hyphen
* \d{3} - Three more digits
* [\dX]$ - Final character: either digit or 'X' (for checksum 10)
*
* Valid examples: 0000-0002-1825-0097, 0000-0002-1825-009X
*/
const orcidRegex = /^(\d{4}-){3}\d{3}[\dX]$/;
if (!orcidRegex.test(cleanOrcid)) {
field.report('ORCID must be in format: 0000-0000-0000-0000 or 0000-0000-0000-000X', 'orcid', field);
return;
}
/**
* Checksum Validation - MOD-11-2 Algorithm
*
* This implements the official ORCID checksum algorithm based on ISO/IEC 7064:2003
* to verify mathematical validity and detect typos or invalid identifiers.
*/
// Step 1: Extract digits and separate check digit
const digits = cleanOrcid.replace(/-/g, ''); // Remove hyphens: "0000000218250097"
const baseDigits = digits.slice(0, -1); // First 15 digits: "000000021825009"
const checkDigit = digits.slice(-1); // Last character: "7"
/**
* Step 2: Calculate checksum using MOD-11-2 algorithm
*
* For each digit from left to right:
* 1. Add the digit to running total
* 2. Multiply result by 2
*
* Example for "000000021825009":
* - Start with total = 0
* - Process each digit: total = (total + digit) * 2
* - Continue until all 15 digits are processed
*/
let total = 0;
for (const digit of baseDigits) {
total = (total + parseInt(digit)) * 2;
}
/**
* Step 3: Calculate expected check digit
*
* Formula: (12 - (total % 11)) % 11
* - Get remainder when total is divided by 11
* - Subtract from 12 and take modulo 11 again
* - If result is 10, use 'X' (since we need single character)
*
* Example: total = 1314
* - remainder = 1314 % 11 = 5
* - result = (12 - 5) % 11 = 7
* - expectedCheckDigit = "7"
*/
const remainder = total % 11;
const result = (12 - remainder) % 11;
const expectedCheckDigit = result === 10 ? 'X' : result.toString();
/**
* Step 4: Verify checksum matches
*
* Compare the actual check digit with the calculated expected value.
* If they don't match, the ORCID is invalid (likely contains typos or is fabricated).
*/
if (checkDigit !== expectedCheckDigit) {
field.report('Invalid ORCID checksum', 'orcid', field);
return;
}
// If we reach this point, the ORCID is valid (both format and checksum)
}
/**
* Create the VineJS validation rule
*
* This creates a reusable rule that can be chained with other VineJS validators
*/
const orcidRule = vine.createRule(orcidValidator);
/**
* TypeScript module declaration
*
* Extends the VineString interface to include our custom orcid() method.
* This enables TypeScript autocompletion and type checking when using the rule.
*/
declare module '@vinejs/vine' {
interface VineString {
/**
* Validates that a string is a valid ORCID identifier
*
* Checks both format (0000-0000-0000-0000) and mathematical validity
* using the MOD-11-2 checksum algorithm.
*
* @example
* ```typescript
* // Usage in validation schema
* identifier_orcid: vine.string().trim().maxLength(255).orcid().optional()
* ```
*
* @returns {this} The VineString instance for method chaining
*/
orcid(): this;
}
}
/**
* Register the macro with VineJS
*
* This adds the .orcid() method to all VineString instances,
* allowing it to be used in validation schemas.
*
* Usage example:
* ```typescript
* vine.string().orcid().optional()
* ```
*/
VineString.macro('orcid', function (this: VineString) {
return this.use(orcidRule());
});