diff --git a/app/Controllers/Http/Editor/DatasetController.ts b/app/Controllers/Http/Editor/DatasetController.ts index a4671c7..84e1f52 100644 --- a/app/Controllers/Http/Editor/DatasetController.ts +++ b/app/Controllers/Http/Editor/DatasetController.ts @@ -821,6 +821,10 @@ export default class DatasetsController { 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: { + edit: await auth.user?.can(['dataset-editor-update']), + // delete: await auth.user?.can(['dataset-delete']), + }, }); } diff --git a/resources/js/Pages/Editor/Dataset/Edit.vue b/resources/js/Pages/Editor/Dataset/Edit.vue index bbaf3c1..ba5fe06 100644 --- a/resources/js/Pages/Editor/Dataset/Edit.vue +++ b/resources/js/Pages/Editor/Dataset/Edit.vue @@ -1,90 +1,194 @@ + \ No newline at end of file + diff --git a/resources/js/composables/useDatasetChangeDetection.ts b/resources/js/composables/useDatasetChangeDetection.ts new file mode 100644 index 0000000..a265914 --- /dev/null +++ b/resources/js/composables/useDatasetChangeDetection.ts @@ -0,0 +1,475 @@ +// ==================================================================== +// FILE: composables/useDatasetChangeDetection.ts +// ==================================================================== + +import { computed, Ref } from 'vue'; +import type { Dataset } from '@/Dataset'; +import { InertiaForm } from '@inertiajs/vue3'; + +interface ComparisonOptions { + orderSensitive?: boolean; + compareKey?: string; +} + +export function useDatasetChangeDetection( + form: InertiaForm, + originalDataset: Ref +) { + /** + * Compare arrays with order sensitivity + */ + const compareArraysWithOrder = ( + current: any[], + original: any[], + compareKey?: string + ): boolean => { + if (current.length !== original.length) return true; + + for (let i = 0; i < current.length; i++) { + const currentItem = current[i]; + const originalItem = original[i]; + + if (compareKey && currentItem[compareKey] !== originalItem[compareKey]) { + return true; + } + + if (JSON.stringify(currentItem) !== JSON.stringify(originalItem)) { + return true; + } + } + + return false; + }; + + /** + * Compare arrays without order sensitivity (content-based) + */ + const compareArraysContent = (current: any[], original: any[]): boolean => { + if (current.length !== original.length) return true; + + const normalizedCurrent = current + .map((item) => JSON.stringify(item)) + .sort(); + const normalizedOriginal = original + .map((item) => JSON.stringify(item)) + .sort(); + + return ( + JSON.stringify(normalizedCurrent) !== JSON.stringify(normalizedOriginal) + ); + }; + + /** + * Check if licenses have changed + */ + const hasLicenseChanges = (): boolean => { + const originalLicenses = Array.isArray(originalDataset.value.licenses) + ? originalDataset.value.licenses + .map((l) => (typeof l === 'object' ? l.id.toString() : String(l))) + .sort() + : []; + + const currentLicenses = Array.isArray(form.licenses) + ? form.licenses + .map((l) => (typeof l === 'object' ? l.id.toString() : String(l))) + .sort() + : []; + + return JSON.stringify(currentLicenses) !== JSON.stringify(originalLicenses); + }; + + /** + * Check if basic properties have changed + */ + const hasBasicPropertyChanges = (): boolean => { + const original = originalDataset.value; + + return ( + form.language !== original.language || + form.type !== original.type || + form.creating_corporation !== original.creating_corporation || + Number(form.project_id) !== Number(original.project_id) || + form.embargo_date !== original.embargo_date + ); + }; + + /** + * Check if there are items marked for deletion + */ + const hasDeletionChanges = (): boolean => { + return ( + (form.subjectsToDelete?.length ?? 0) > 0 || + (form.referencesToDelete?.length ?? 0) > 0 + ); + }; + + /** + * Check if files have changed + */ + const hasFileChanges = (): boolean => { + const currentFiles = form.files || []; + const originalFiles = originalDataset.value.files || []; + + // Check for new files + const newFiles = currentFiles.filter((f) => !f.id); + if (newFiles.length > 0) return true; + + // Check for deleted files + const originalFileIds = originalFiles.map((f) => f.id).filter(Boolean); + const currentFileIds = currentFiles.map((f) => f.id).filter(Boolean); + if (!originalFileIds.every((id) => currentFileIds.includes(id))) { + return true; + } + + // Check for file order changes + return compareArraysWithOrder(currentFiles, originalFiles, 'sort_order'); + }; + + /** + * Check if coverage has changed + */ + const hasCoverageChanges = (): boolean => { + const currentCoverage = form.coverage || {}; + const originalCoverage = originalDataset.value.coverage || {}; + + return ( + Number(currentCoverage.x_min) !== Number(originalCoverage.x_min) || + Number(currentCoverage.x_max) !== Number(originalCoverage.x_max) || + Number(currentCoverage.y_min) !== Number(originalCoverage.y_min) || + Number(currentCoverage.y_max) !== Number(originalCoverage.y_max) + ); + }; + + /** + * Main change detection computed property + */ + const hasUnsavedChanges = computed(() => { + // Check if form is processing + if (form.processing) return true; + + const original = originalDataset.value; + + // Check basic properties + if (hasBasicPropertyChanges()) return true; + + // Check deletion arrays + if (hasDeletionChanges()) return true; + + // Check licenses + if (hasLicenseChanges()) return true; + + // Check files (order-sensitive) + if (hasFileChanges()) return true; + + // Check authors (order-sensitive) + if ( + compareArraysWithOrder( + form.authors || [], + original.authors || [] + ) + ) { + return true; + } + + // Check contributors (order-sensitive) + if ( + compareArraysWithOrder( + form.contributors || [], + original.contributors || [] + ) + ) { + return true; + } + + // Check titles (order-sensitive) + if (compareArraysWithOrder(form.titles, original.titles)) { + return true; + } + + // Check descriptions (order-sensitive) + if (compareArraysWithOrder(form.descriptions, original.descriptions)) { + return true; + } + + // Check subjects/keywords (order-insensitive) + if ( + compareArraysContent( + form.subjects || [], + original.subjects || [] + ) + ) { + return true; + } + + // Check references (order-insensitive) + if ( + compareArraysContent( + form.references || [], + original.references || [] + ) + ) { + return true; + } + + // Check coverage + if (hasCoverageChanges()) return true; + + return false; + }); + + /** + * Analyze array changes with detailed information + */ + const analyzeArrayChanges = ( + current: any[], + original: any[], + itemName: string + ): string[] => { + const changes: string[] = []; + + // Check for count changes + if (current.length !== original.length) { + const diff = current.length - original.length; + if (diff > 0) { + changes.push(`${diff} ${itemName}(s) added`); + } else { + changes.push(`${Math.abs(diff)} ${itemName}(s) removed`); + } + } + + // Check for order changes (only if same count) + if (current.length === original.length && current.length > 1) { + const currentIds = current.map((item) => item.id).filter(Boolean); + const originalIds = original.map((item) => item.id).filter(Boolean); + + if (currentIds.length === originalIds.length && currentIds.length > 0) { + const orderChanged = currentIds.some( + (id, index) => id !== originalIds[index] + ); + if (orderChanged) { + changes.push(`${itemName} order changed`); + } + } + } + + // Check for content changes + if (current.length === original.length) { + const contentChanged = + JSON.stringify(current) !== JSON.stringify(original); + const orderChanged = changes.some((change) => + change.includes('order changed') + ); + + if (contentChanged && !orderChanged) { + changes.push(`${itemName} content modified`); + } + } + + return changes; + }; + + /** + * Generate detailed changes summary + */ + const getChangesSummary = (): string[] => { + const changes: string[] = []; + const original = originalDataset.value; + + // Basic property changes + if (form.language !== original.language) { + changes.push('Language changed'); + } + if (form.type !== original.type) { + changes.push('Dataset type changed'); + } + if (form.creating_corporation !== original.creating_corporation) { + changes.push('Creating corporation changed'); + } + if (Number(form.project_id) !== Number(original.project_id)) { + changes.push('Project changed'); + } + if (form.embargo_date !== original.embargo_date) { + changes.push('Embargo date changed'); + } + + // Deletion tracking + if ((form.subjectsToDelete?.length ?? 0) > 0) { + changes.push( + `${form.subjectsToDelete.length} keyword(s) marked for deletion` + ); + } + if ((form.referencesToDelete?.length ?? 0) > 0) { + changes.push( + `${form.referencesToDelete.length} reference(s) marked for deletion` + ); + } + + // License changes + if (hasLicenseChanges()) { + changes.push('Licenses modified'); + } + + // Files analysis + const currentFiles = form.files || []; + const originalFiles = original.files || []; + const newFiles = currentFiles.filter((f) => !f.id); + + if (newFiles.length > 0) { + changes.push(`${newFiles.length} new file(s) added`); + } + + const existingCurrentFiles = currentFiles.filter((f) => f.id); + const existingOriginalFiles = originalFiles.filter((f) => f.id); + + if ( + existingCurrentFiles.length === existingOriginalFiles.length && + existingCurrentFiles.length > 1 + ) { + const currentOrder = existingCurrentFiles.map((f) => f.id); + const originalOrder = existingOriginalFiles.map((f) => f.id); + const orderChanged = currentOrder.some( + (id, index) => id !== originalOrder[index] + ); + + if (orderChanged) { + changes.push('File order changed'); + } + } + + // Authors and contributors + changes.push( + ...analyzeArrayChanges( + form.authors || [], + original.authors || [], + 'author' + ) + ); + changes.push( + ...analyzeArrayChanges( + form.contributors || [], + original.contributors || [], + 'contributor' + ) + ); + + // Titles analysis + if (JSON.stringify(form.titles) !== JSON.stringify(original.titles)) { + if (form.titles.length !== original.titles.length) { + const diff = form.titles.length - original.titles.length; + changes.push( + diff > 0 + ? `${diff} title(s) added` + : `${Math.abs(diff)} title(s) removed` + ); + } else if (form.titles.length > 0) { + if (form.titles[0]?.value !== original.titles[0]?.value) { + changes.push('Main title changed'); + } + const otherTitlesChanged = form.titles + .slice(1) + .some( + (title, index) => + JSON.stringify(title) !== JSON.stringify(original.titles[index + 1]) + ); + if (otherTitlesChanged) { + changes.push('Additional titles modified'); + } + } + } + + // Descriptions analysis + if ( + JSON.stringify(form.descriptions) !== + JSON.stringify(original.descriptions) + ) { + if (form.descriptions.length !== original.descriptions.length) { + const diff = form.descriptions.length - original.descriptions.length; + changes.push( + diff > 0 + ? `${diff} description(s) added` + : `${Math.abs(diff)} description(s) removed` + ); + } else if (form.descriptions.length > 0) { + if ( + form.descriptions[0]?.value !== original.descriptions[0]?.value + ) { + changes.push('Main abstract changed'); + } + const otherDescChanged = form.descriptions + .slice(1) + .some( + (desc, index) => + JSON.stringify(desc) !== + JSON.stringify(original.descriptions[index + 1]) + ); + if (otherDescChanged) { + changes.push('Additional descriptions modified'); + } + } + } + + // Subjects/Keywords analysis + const currentSubjects = form.subjects || []; + const originalSubjects = original.subjects || []; + if (currentSubjects.length !== originalSubjects.length) { + const diff = currentSubjects.length - originalSubjects.length; + changes.push( + diff > 0 + ? `${diff} keyword(s) added` + : `${Math.abs(diff)} keyword(s) removed` + ); + } else if (currentSubjects.length > 0) { + const currentSubjectsNormalized = currentSubjects + .map((s) => JSON.stringify(s)) + .sort(); + const originalSubjectsNormalized = originalSubjects + .map((s) => JSON.stringify(s)) + .sort(); + if ( + JSON.stringify(currentSubjectsNormalized) !== + JSON.stringify(originalSubjectsNormalized) + ) { + changes.push('Keywords modified'); + } + } + + // References analysis + const currentRefs = form.references || []; + const originalRefs = original.references || []; + if (currentRefs.length !== originalRefs.length) { + const diff = currentRefs.length - originalRefs.length; + changes.push( + diff > 0 + ? `${diff} reference(s) added` + : `${Math.abs(diff)} reference(s) removed` + ); + } else if (currentRefs.length > 0) { + const currentRefsNormalized = currentRefs + .map((r) => JSON.stringify(r)) + .sort(); + const originalRefsNormalized = originalRefs + .map((r) => JSON.stringify(r)) + .sort(); + if ( + JSON.stringify(currentRefsNormalized) !== + JSON.stringify(originalRefsNormalized) + ) { + changes.push('References modified'); + } + } + + // Coverage changes + if (hasCoverageChanges()) { + changes.push('Geographic coverage changed'); + } + + return changes; + }; + + return { + hasUnsavedChanges, + getChangesSummary, + compareArraysWithOrder, + compareArraysContent, + }; +} \ No newline at end of file diff --git a/resources/js/composables/useDatasetFormSubmission.ts b/resources/js/composables/useDatasetFormSubmission.ts new file mode 100644 index 0000000..85fbdd2 --- /dev/null +++ b/resources/js/composables/useDatasetFormSubmission.ts @@ -0,0 +1,217 @@ +// ==================================================================== +// FILE: composables/useDatasetFormSubmission.ts +// ==================================================================== + +import { Ref } from 'vue'; +import type { Dataset, License } from '@/Dataset'; +import { InertiaForm } from '@inertiajs/vue3'; +import { stardust } from '@eidellev/adonis-stardust/client'; +import { notify } from '@/notiwind'; + +interface SubmissionOptions { + onSuccess?: (updatedDataset: Dataset) => void; + onError?: (errors: any) => void; + showNotification?: boolean; +} + +export function useDatasetFormSubmission( + form: InertiaForm, + originalDataset: Ref +) { + /** + * Check if object has id attribute (type guard) + */ + const hasIdAttribute = (obj: License | number): obj is License => { + return typeof obj === 'object' && 'id' in obj; + }; + + /** + * Transform licenses for submission + */ + const transformLicenses = (): string[] => { + return form.licenses.map((obj) => { + if (hasIdAttribute(obj)) { + return obj.id.toString(); + } + return String(obj); + }); + }; + + /** + * Validate form before submission + */ + const validateForm = (): { valid: boolean; errors: string[] } => { + const errors: string[] = []; + + // Required field validations + if (!form.language) { + errors.push('Language is required'); + } + if (!form.type) { + errors.push('Dataset type is required'); + } + if (!form.creating_corporation) { + errors.push('Creating corporation is required'); + } + if (!form.titles || !form.titles[0]?.value) { + errors.push('Main title is required'); + } + if (!form.descriptions || !form.descriptions[0]?.value) { + errors.push('Main abstract is required'); + } + + return { + valid: errors.length === 0, + errors, + }; + }; + + /** + * Handle successful submission + */ + const handleSubmitSuccess = ( + updatedDataset: Dataset, + showNotification: boolean = true + ) => { + // Clear deletion arrays + if (updatedDataset.subjectsToDelete) { + updatedDataset.subjectsToDelete = []; + } + if (updatedDataset.referencesToDelete) { + updatedDataset.referencesToDelete = []; + } + + // Update form with fresh data from server + Object.keys(updatedDataset).forEach((key) => { + if (key !== 'licenses' && key in form) { + form[key] = updatedDataset[key]; + } + }); + + // Clear form errors + form.clearErrors(); + + // Update original dataset reference + originalDataset.value = JSON.parse(JSON.stringify(updatedDataset)); + + // Show success notification + if (showNotification) { + notify( + { + type: 'success', + title: 'Success', + text: 'Dataset updated successfully', + }, + 4000, + ); + } + }; + + /** + * Handle submission errors + */ + const handleSubmitError = (errors: any) => { + console.error('Submission errors:', errors); + + notify( + { + type: 'error', + title: 'Error', + text: 'Failed to update dataset. Please check the form for errors.', + }, + 5000, + ); + }; + + /** + * Submit form with auto-save behavior + */ + const submitWithAutoSave = async ( + options: SubmissionOptions = {} + ): Promise => { + try { + const route = stardust.route('editor.dataset.update', [form.id]); + const licenses = transformLicenses(); + + await form + .transform((data) => ({ + ...data, + licenses, + rights: 'true', + })) + .put(route, { + onSuccess: (page) => { + const updatedDataset = page.props.dataset || form.data(); + handleSubmitSuccess( + updatedDataset, + options.showNotification ?? true + ); + + if (options.onSuccess) { + options.onSuccess(updatedDataset); + } + }, + onError: (errors) => { + handleSubmitError(errors); + + if (options.onError) { + options.onError(errors); + } + }, + }); + } catch (error) { + console.error('Unexpected error during submission:', error); + notify( + { + type: 'error', + title: 'Error', + text: 'An unexpected error occurred. Please try again.', + }, + 5000, + ); + } + }; + + /** + * Standard submit with validation + */ + const submit = async ( + options: SubmissionOptions = {} + ): Promise => { + // Validate form first + const validation = validateForm(); + if (!validation.valid) { + notify( + { + type: 'error', + title: 'Validation Error', + text: validation.errors.join(', '), + }, + 5000, + ); + return; + } + + await submitWithAutoSave({ + ...options, + showNotification: true, + }); + }; + + /** + * Silent submit without notification (for auto-save) + */ + const submitSilently = async (): Promise => { + await submitWithAutoSave({ + showNotification: false, + }); + }; + + return { + submit, + submitWithAutoSave, + submitSilently, + validateForm, + transformLicenses, + }; +} \ No newline at end of file