Some checks failed
build.yaml / feat: Enhance Dataset Edit Page with Unsaved Changes Indicator and Improved Structure (push) Failing after 0s
- Added a progress indicator for unsaved changes at the top of the dataset edit page. - Enhanced the title section with a dataset status badge and improved layout. - Introduced collapsible sections for better organization of form fields. - Improved notifications for success/error messages. - Refactored form fields into distinct sections: Basic Information, Licenses, Titles, Descriptions, Creators & Contributors, Additional Metadata, Geographic Coverage, and Files. - Enhanced loading spinner with a more visually appealing overlay. - Added new project validation logic in the backend with create and update validators.
1666 lines
81 KiB
Vue
1666 lines
81 KiB
Vue
<!-- 1. Add progress indicator at the top -->
|
|
<template>
|
|
<LayoutAuthenticated>
|
|
<Head title="Edit dataset" />
|
|
|
|
<!-- Progress Bar for Unsaved Changes -->
|
|
<!-- Progress Bar for Unsaved Changes -->
|
|
<div v-if="hasUnsavedChanges" class="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-white px-4 py-2.5 text-sm shadow-lg h-14">
|
|
<div class="container mx-auto flex items-center justify-between h-full">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="animate-pulse w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
<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>
|
|
<span class="font-medium">You have unsaved changes</span>
|
|
</div>
|
|
<BaseButton @click="submitAlternative" label="Save Now" color="white" small :disabled="form.processing" />
|
|
</div>
|
|
</div>
|
|
|
|
<SectionMain>
|
|
<!-- Enhanced Title Section -->
|
|
<SectionTitleLineWithButton :icon="mdiImageText" title="Update Dataset" main>
|
|
<div class="flex items-center gap-3">
|
|
<!-- Dataset Status Badge -->
|
|
<!-- <span
|
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
|
|
>
|
|
Draft
|
|
</span> -->
|
|
<BaseButton
|
|
:route-name="stardust.route('dataset.list')"
|
|
:icon="mdiArrowLeftBoldOutline"
|
|
label="Back"
|
|
color="white"
|
|
rounded-full
|
|
small
|
|
/>
|
|
</div>
|
|
</SectionTitleLineWithButton>
|
|
|
|
<!-- Success/Error Notifications -->
|
|
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
|
{{ flash.message }}
|
|
</NotificationBar>
|
|
<FormValidationErrors v-bind:errors="errors" />
|
|
|
|
<!-- Main Form with Sections -->
|
|
<CardBox :form="true" class="shadow-lg">
|
|
<UnsavedChangesWarning
|
|
:show="hasUnsavedChanges"
|
|
:changes-summary="getChangesSummary()"
|
|
:show-details="true"
|
|
:show-actions="false"
|
|
:show-auto-save-progress="true"
|
|
:auto-save-delay="30"
|
|
@save="submitAlternative"
|
|
/>
|
|
<!-- Collapsible Sections for Better Organization -->
|
|
|
|
<!-- Section 1: Basic Information -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm">1</span>
|
|
Basic Information
|
|
</h2>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 ml-10">
|
|
<!-- (1) Language -->
|
|
<FormField
|
|
label="Language *"
|
|
help="required: select dataset main language"
|
|
:class="{ 'text-red-400': form.errors.language }"
|
|
>
|
|
<FormControl
|
|
required
|
|
v-model="form.language"
|
|
type="select"
|
|
placeholder="[Enter Language]"
|
|
:errors="form.errors.language"
|
|
:options="{ de: 'de', en: 'en' }"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors.language">
|
|
{{ form.errors.language.join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
|
|
<!-- (3) dataset_type -->
|
|
<FormField label="Dataset Type *" help="required: dataset type" :class="{ 'text-red-400': form.errors.type }">
|
|
<FormControl
|
|
required
|
|
v-model="form.type"
|
|
:type="'select'"
|
|
placeholder="-- select type --"
|
|
:errors="form.errors.type"
|
|
:options="doctypes"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors.type && Array.isArray(form.errors.type)">
|
|
{{ form.errors.type.join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
<!-- (4) creating_corporation -->
|
|
<FormField
|
|
label="Creating Corporation *"
|
|
:class="{ 'text-red-400': form.errors.creating_corporation }"
|
|
class="w-full mx-2 flex-1"
|
|
>
|
|
<FormControl
|
|
required
|
|
v-model="form.creating_corporation"
|
|
type="text"
|
|
placeholder="[enter creating corporation]"
|
|
:is-read-only="true"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors.creating_corporation && Array.isArray(form.errors.creating_corporation)"
|
|
>
|
|
{{ form.errors.creating_corporation.join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
|
|
<BaseDivider />
|
|
|
|
<!-- Section 2: Licenses -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-green-500 text-white flex items-center justify-center text-sm">2</span>
|
|
Licenses
|
|
</h2>
|
|
|
|
<div class="ml-10">
|
|
<!-- (2) licenses -->
|
|
<FormField label="Select License" wrap-body :class="{ 'text-red-400': form.errors.licenses }">
|
|
<FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column :options="licenses" />
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
|
|
<BaseDivider />
|
|
|
|
<!-- Section 3: Titles -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center text-sm">3</span>
|
|
Titles
|
|
</h2>
|
|
<!-- (5) titles -->
|
|
<CardBox
|
|
class="ml-10 shadow-md"
|
|
title="Main & Additional Titles"
|
|
:icon="mdiFinance"
|
|
:header-icon="mdiPlusCircle"
|
|
@header-icon-click="addTitle()"
|
|
>
|
|
<div class="flex flex-col md:flex-row">
|
|
<FormField
|
|
label="Main Title *"
|
|
help="required: main title"
|
|
:class="{ 'text-red-400': form.errors['titles.0.value'] }"
|
|
class="w-full mr-1 flex-1"
|
|
>
|
|
<FormControl
|
|
required
|
|
v-model="form.titles[0].value"
|
|
type="textarea"
|
|
placeholder="[enter main title]"
|
|
:show-char-count="true"
|
|
:max-input-length="255"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['titles.0.value'] && Array.isArray(form.errors['titles.0.value'])"
|
|
>
|
|
{{ form.errors['titles.0.value'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
<FormField
|
|
label="Main Title Language*"
|
|
help="required: main title language"
|
|
:class="{ 'text-red-400': form.errors['titles.0.language'] }"
|
|
class="w-full ml-1 flex-1"
|
|
>
|
|
<FormControl required v-model="form.titles[0].language" type="text" :is-read-only="true">
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['titles.0.language'] && Array.isArray(form.errors['titles.0.language'])"
|
|
>
|
|
{{ form.errors['titles.0.language'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
</div>
|
|
|
|
<label v-if="form.titles.length > 1">additional titles </label>
|
|
<!-- <BaseButton :icon="mdiPlusCircle" @click.prevent="addTitle()" color="modern" rounded-full small /> -->
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<!-- <th v-if="checkable" /> -->
|
|
<th>Title Value</th>
|
|
<th>Title Type</th>
|
|
<th>Title Language</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="(title, index) in form.titles" :key="index">
|
|
<tr v-if="title.type != 'Main'">
|
|
<!-- <td scope="row">{{ index + 1 }}</td> -->
|
|
<td data-label="Title Value">
|
|
<FormControl
|
|
required
|
|
v-model="form.titles[index].value"
|
|
type="textarea"
|
|
placeholder="[enter main title]"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors[`titles.${index}.value`]">
|
|
{{ form.errors[`titles.${index}.value`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td data-label="Title Type">
|
|
<FormControl
|
|
required
|
|
v-model="form.titles[index].type"
|
|
type="select"
|
|
:options="titletypes"
|
|
placeholder="[select title type]"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="Array.isArray(form.errors[`titles.${index}.type`])">
|
|
{{ form.errors[`titles.${index}.type`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td data-label="Title Language">
|
|
<FormControl
|
|
required
|
|
v-model="form.titles[index].language"
|
|
type="select"
|
|
:options="{ de: 'de', en: 'en' }"
|
|
placeholder="[select title language]"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors[`titles.${index}.language`]">
|
|
{{ form.errors[`titles.${index}.language`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
|
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
|
|
<BaseButton
|
|
color="danger"
|
|
:icon="mdiTrashCan"
|
|
small
|
|
v-if="title.id == undefined"
|
|
@click.prevent="removeTitle(index)"
|
|
/>
|
|
</BaseButtons>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</CardBox>
|
|
</div>
|
|
|
|
<BaseDivider />
|
|
|
|
<!-- Section 4: Descriptions -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-orange-500 text-white flex items-center justify-center text-sm">4</span>
|
|
Descriptions
|
|
</h2>
|
|
<!-- (6) descriptions -->
|
|
<CardBox
|
|
class="ml-10 shadow-md"
|
|
title="Abstract & Additional Descriptions"
|
|
:icon="mdiFinance"
|
|
:header-icon="mdiPlusCircle"
|
|
@header-icon-click="addDescription()"
|
|
>
|
|
<div class="flex flex-col md:flex-row">
|
|
<FormField
|
|
label="Main Abstract *"
|
|
help="required: main abstract"
|
|
:class="{ 'text-red-400': form.errors['descriptions.0.value'] }"
|
|
class="w-full mr-1 flex-1"
|
|
>
|
|
<FormControl
|
|
required
|
|
v-model="form.descriptions[0].value"
|
|
type="textarea"
|
|
placeholder="[enter main abstract]"
|
|
:show-char-count="true"
|
|
:max-input-length="2500"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['descriptions.0.value'] && Array.isArray(form.errors['descriptions.0.value'])"
|
|
>
|
|
{{ form.errors['descriptions.0.value'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
<FormField
|
|
label="Main Description Language*"
|
|
help="required: main abstract language"
|
|
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }"
|
|
class="w-full ml-1 flex-1"
|
|
>
|
|
<FormControl required v-model="form.descriptions[0].language" type="text" :is-read-only="true">
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['descriptions.0.value'] && Array.isArray(form.errors['descriptions.0.language'])"
|
|
>
|
|
{{ form.errors['descriptions.0.language'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<!-- <th v-if="checkable" /> -->
|
|
<th>Title Value</th>
|
|
<th>Title Type</th>
|
|
<th>Title Language</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="(item, index) in form.descriptions" :key="index">
|
|
<tr v-if="item.type != 'Abstract'">
|
|
<!-- <td scope="row">{{ index + 1 }}</td> -->
|
|
<td data-label="Description Value">
|
|
<FormControl
|
|
required
|
|
v-model="form.descriptions[index].value"
|
|
type="textarea"
|
|
placeholder="[enter main title]"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.value`]">
|
|
{{ form.errors[`descriptions.${index}.value`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td data-label="Description Type">
|
|
<FormControl
|
|
required
|
|
v-model="form.descriptions[index].type"
|
|
type="select"
|
|
:options="descriptiontypes"
|
|
placeholder="[select title type]"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="Array.isArray(form.errors[`descriptions.${index}.type`])"
|
|
>
|
|
{{ form.errors[`descriptions.${index}.type`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td data-label="Description Language">
|
|
<FormControl
|
|
required
|
|
v-model="form.descriptions[index].language"
|
|
type="select"
|
|
:options="{ de: 'de', en: 'en' }"
|
|
placeholder="[select title language]"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.language`]">
|
|
{{ form.errors[`descriptions.${index}.language`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
|
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
|
|
<BaseButton
|
|
color="danger"
|
|
:icon="mdiTrashCan"
|
|
small
|
|
v-if="item.id == undefined"
|
|
@click.prevent="removeDescription(index)"
|
|
/>
|
|
</BaseButtons>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</CardBox>
|
|
</div>
|
|
|
|
<BaseDivider />
|
|
|
|
<!-- Section 5: People (Authors & Contributors) -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-pink-500 text-white flex items-center justify-center text-sm">5</span>
|
|
Creators & Contributors
|
|
</h2>
|
|
|
|
<div class="ml-10 space-y-6">
|
|
<!-- (7) authors -->
|
|
<CardBox
|
|
class="shadow-md"
|
|
has-table
|
|
title="Creators"
|
|
:icon="mdiBookOpenPageVariant"
|
|
:header-icon="mdiPlusCircle"
|
|
@header-icon-click="addNewAuthor()"
|
|
>
|
|
<div
|
|
class="mb-4 p-3 bg-blue-50 dark:bg-slate-800 rounded-lg text-sm text-blue-800 dark:text-blue-300 flex items-start gap-2"
|
|
>
|
|
<BaseIcon :path="mdiAlertBoxOutline" class="flex-shrink-0 mt-0.5" />
|
|
<span>Search for existing persons or add new ones manually. Creators are displayed in citation order.</span>
|
|
</div>
|
|
<div class="mb-2 text-gray-600 text-sm flex items-center gap-2">
|
|
<span>Add creators by searching existing persons or manually adding new ones.</span>
|
|
<BaseIcon
|
|
:path="mdiAlertBoxOutline"
|
|
class="text-blue-400"
|
|
title="You can add existing persons via search, or create new ones manually."
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col md:flex-row gap-4 mb-4">
|
|
<div class="flex-1">
|
|
<!-- <label class="block text-xs font-semibold mb-1">Find existing person</label> -->
|
|
<SearchAutocomplete
|
|
source="/api/persons"
|
|
:response-property="'first_name'"
|
|
placeholder="Search for existing persons..."
|
|
v-on:person="onAddAuthor"
|
|
/>
|
|
</div>
|
|
<!-- <div class="flex-1 flex items-end">
|
|
<BaseButton
|
|
:icon="mdiPlusCircle"
|
|
@click.prevent="addNewAuthor"
|
|
color="modern"
|
|
rounded-full
|
|
small
|
|
title="Add new creator manually"
|
|
/>
|
|
</div> -->
|
|
</div>
|
|
<TablePersons
|
|
:persons="form.authors"
|
|
v-if="form.authors.length > 0"
|
|
:errors="form.errors"
|
|
:relation="'authors'"
|
|
/>
|
|
<div class="text-red-400 text-sm" v-if="form.errors.authors && Array.isArray(form.errors.authors)">
|
|
{{ form.errors.authors.join(', ') }}
|
|
</div>
|
|
</CardBox>
|
|
|
|
<!-- (8) contributors -->
|
|
<CardBox
|
|
class="shadow-md"
|
|
has-table
|
|
title="Contributors"
|
|
:icon="mdiBookOpenPageVariant"
|
|
:header-icon="mdiPlusCircle"
|
|
@header-icon-click="addNewContributor()"
|
|
>
|
|
<div class="mb-2 text-gray-600 text-sm flex items-center gap-2">
|
|
<span>Add contributors by searching existing persons or manually adding new ones.</span>
|
|
<BaseIcon
|
|
:path="mdiAlertBoxOutline"
|
|
class="text-blue-400"
|
|
title="You can add existing persons via search, or create new ones manually."
|
|
/>
|
|
</div>
|
|
<SearchAutocomplete
|
|
source="/api/persons"
|
|
:response-property="'first_name'"
|
|
placeholder="search in person table...."
|
|
v-on:person="onAddContributor"
|
|
>
|
|
</SearchAutocomplete>
|
|
|
|
<TablePersons
|
|
:persons="form.contributors"
|
|
v-if="form.contributors.length > 0"
|
|
:contributortypes="contributorTypes"
|
|
:errors="form.errors"
|
|
:relation="'contributors'"
|
|
/>
|
|
<div class="text-red-400 text-sm" v-if="form.errors.contributors && Array.isArray(form.errors.contributors)">
|
|
{{ form.errors.contributors.join(', ') }}
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
</div>
|
|
|
|
<BaseDivider />
|
|
|
|
<!-- Section 6: Additional Metadata -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-indigo-500 text-white flex items-center justify-center text-sm">6</span>
|
|
Additional Metadata
|
|
</h2>
|
|
|
|
<div class="ml-10 space-y-6">
|
|
<!-- Project & Embargo -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<!-- (9) project_id -->
|
|
<FormField
|
|
label="Project.."
|
|
help="project is optional"
|
|
:class="{ 'text-red-400': form.errors.project_id }"
|
|
class="w-full mx-2 flex-1"
|
|
>
|
|
<FormControl
|
|
required
|
|
v-model="form.project_id"
|
|
:type="'select'"
|
|
placeholder="[Select Project]"
|
|
:errors="form.errors.project_id"
|
|
:options="projects"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors.project_id">
|
|
{{ form.errors.project_id.join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
|
|
<!-- (10) embargo_date -->
|
|
<FormField
|
|
label="Embargo Date.."
|
|
help="embargo date is optional"
|
|
:class="{ 'text-red-400': form.errors.embargo_date }"
|
|
class="w-full mx-2 flex-1"
|
|
>
|
|
<FormControl
|
|
v-model="form.embargo_date"
|
|
:type="'date'"
|
|
placeholder="date('y-m-d')"
|
|
:errors="form.errors.embargo_date"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors.embargo_date">
|
|
{{ form.errors.embargo_date.join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
</div>
|
|
|
|
<!-- Keywords -->
|
|
<CardBox
|
|
class="shadow-md"
|
|
has-table
|
|
title="Keywords"
|
|
:icon="mdiEarthPlus"
|
|
:header-icon="mdiPlusCircle"
|
|
@header-icon-click="addKeyword"
|
|
>
|
|
<TableKeywords
|
|
:keywords="form.subjects"
|
|
:errors="form.errors"
|
|
:subjectTypes="subjectTypes"
|
|
v-model:subjects-to-delete="form.subjectsToDelete"
|
|
v-if="form.subjects.length > 0"
|
|
/>
|
|
</CardBox>
|
|
|
|
<!-- References -->
|
|
<CardBox
|
|
class="shadow-md"
|
|
has-table
|
|
title="Dataset References"
|
|
:icon="mdiEarthPlus"
|
|
:header-icon="mdiPlusCircle"
|
|
@header-icon-click="addReference"
|
|
>
|
|
<!-- Message when no references exist -->
|
|
<div v-if="form.references.length === 0" class="text-center py-4">
|
|
<p class="text-gray-600">No references added yet.</p>
|
|
<p class="text-gray-400">Click the plus icon above to add a new reference.</p>
|
|
</div>
|
|
<!-- Reference form -->
|
|
<table class="table-fixed border-green-900" v-if="form.references.length">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-4/12">Value</th>
|
|
<th class="w-2/12">Type</th>
|
|
<th class="w-3/12">Relation</th>
|
|
<th class="w-2/12">Label</th>
|
|
<th class="w-1/12"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(item, index) in form.references">
|
|
<td data-label="Reference Value">
|
|
<!-- <input name="Reference Value" class="form-control"
|
|
placeholder="[VALUE]" v-model="item.value" /> -->
|
|
<FormControl
|
|
required
|
|
v-model="item.value"
|
|
:type="'text'"
|
|
placeholder="[VALUE]"
|
|
:errors="form.errors.embargo_date"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="
|
|
form.errors[`references.${index}.value`] &&
|
|
Array.isArray(form.errors[`references.${index}.value`])
|
|
"
|
|
>
|
|
{{ form.errors[`references.${index}.value`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td>
|
|
<FormControl
|
|
required
|
|
v-model="form.references[index].type"
|
|
type="select"
|
|
:options="referenceIdentifierTypes"
|
|
placeholder="[type]"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="Array.isArray(form.errors[`references.${index}.type`])"
|
|
>
|
|
{{ form.errors[`references.${index}.type`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
|
|
<td>
|
|
<FormControl
|
|
required
|
|
v-model="form.references[index].relation"
|
|
type="select"
|
|
:options="relationTypes"
|
|
placeholder="[relation type]"
|
|
>
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="Array.isArray(form.errors[`references.${index}.relation`])"
|
|
>
|
|
{{ form.errors[`references.${index}.relation`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td data-label="Reference Label">
|
|
<!-- <input name="Reference Label" class="form-control" v-model="item.label" /> -->
|
|
<FormControl
|
|
required
|
|
v-model="form.references[index].label"
|
|
type="text"
|
|
placeholder="[reference label]"
|
|
>
|
|
<div class="text-red-400 text-sm" v-if="form.errors[`references.${index}.label`]">
|
|
{{ form.errors[`references.${index}.label`].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
|
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
|
|
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeReference(index)" />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- References to delete section -->
|
|
<div v-if="form.referencesToDelete && form.referencesToDelete.length > 0" class="mt-8">
|
|
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">References To Delete</h1>
|
|
<ul class="flex flex-1 flex-wrap -m-1">
|
|
<li
|
|
v-for="(element, index) in form.referencesToDelete"
|
|
:key="index"
|
|
class="block p-1 w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/6 xl:w-1/8 h-40"
|
|
>
|
|
<article
|
|
tabindex="0"
|
|
class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm overflow-hidden"
|
|
>
|
|
<section
|
|
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3"
|
|
>
|
|
<h1
|
|
class="flex-1 text-gray-700 group-hover:text-blue-800 font-medium text-sm mb-1 truncate overflow-hidden whitespace-nowrap"
|
|
>
|
|
{{ element.value }}
|
|
</h1>
|
|
<div class="flex flex-col mt-auto">
|
|
<p class="p-1 size text-xs text-gray-700">
|
|
<span class="font-semibold">Type:</span> {{ element.type }}
|
|
</p>
|
|
<p class="p-1 size text-xs text-gray-700">
|
|
<span class="font-semibold">Relation:</span> {{ element.relation }}
|
|
</p>
|
|
<div class="flex justify-end mt-1">
|
|
<button
|
|
class="restore ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
|
|
@click.prevent="restoreReference(index)"
|
|
>
|
|
<svg viewBox="0 0 24 24" class="w-5 h-5">
|
|
<path fill="currentColor" :d="mdiRestore"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</article>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
</div>
|
|
|
|
<BaseDivider />
|
|
|
|
<!-- Section 7: Geographic Coverage -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-teal-500 text-white flex items-center justify-center text-sm">7</span>
|
|
Geographic Coverage
|
|
</h2>
|
|
|
|
<div class="ml-10">
|
|
<MapComponent
|
|
v-if="form.coverage"
|
|
:mapOptions="mapOptions"
|
|
:baseMaps="baseMaps"
|
|
:fitBounds="fitBounds"
|
|
:coverage="form.coverage"
|
|
:mapId="mapId"
|
|
class="mb-4 rounded-lg overflow-hidden shadow-md"
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col md:flex-row ml-10">
|
|
<!-- x min and max -->
|
|
<FormField
|
|
label="Coverage X Min"
|
|
:class="{ 'text-red-400': form.errors['coverage.x_min'] }"
|
|
class="w-full mx-2 flex-1"
|
|
>
|
|
<FormControl required v-model="form.coverage.x_min" type="text" placeholder="[enter x_min]">
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['coverage.x_min'] && Array.isArray(form.errors['coverage.x_min'])"
|
|
>
|
|
{{ form.errors['coverage.x_min'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
<FormField
|
|
label="Coverage X Max"
|
|
:class="{ 'text-red-400': form.errors['coverage.x_max'] }"
|
|
class="w-full mx-2 flex-1"
|
|
>
|
|
<FormControl required v-model="form.coverage.x_max" type="text" placeholder="[enter x_max]">
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['coverage.x_max'] && Array.isArray(form.errors['coverage.x_max'])"
|
|
>
|
|
{{ form.errors['coverage.x_max'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
<!-- y min and max -->
|
|
<FormField
|
|
label="Coverage Y Min"
|
|
:class="{ 'text-red-400': form.errors['coverage.y_min'] }"
|
|
class="w-full mx-2 flex-1"
|
|
>
|
|
<FormControl required v-model="form.coverage.y_min" type="text" placeholder="[enter y_min]">
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['coverage.y_min'] && Array.isArray(form.errors['coverage.y_min'])"
|
|
>
|
|
{{ form.errors['coverage.y_min'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
<FormField
|
|
label="Coverage Y Max"
|
|
:class="{ 'text-red-400': form.errors['coverage.y_max'] }"
|
|
class="w-full mx-2 flex-1"
|
|
>
|
|
<FormControl required v-model="form.coverage.y_max" type="text" placeholder="[enter y_max]">
|
|
<div
|
|
class="text-red-400 text-sm"
|
|
v-if="form.errors['coverage.y_max'] && Array.isArray(form.errors['coverage.y_max'])"
|
|
>
|
|
{{ form.errors['coverage.y_max'].join(', ') }}
|
|
</div>
|
|
</FormControl>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
|
|
<BaseDivider />
|
|
|
|
<!-- Section 8: Files -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<span class="w-8 h-8 rounded-full bg-cyan-500 text-white flex items-center justify-center text-sm">8</span>
|
|
Files
|
|
</h2>
|
|
|
|
<div class="ml-10">
|
|
<FileUploadComponent
|
|
v-model:files="form.files"
|
|
v-model:filesToDelete="form.filesToDelete"
|
|
:showClearButton="false"
|
|
class="shadow-md"
|
|
/>
|
|
<div class="text-red-400 text-sm" v-if="form.errors['file'] && Array.isArray(form.errors['files'])">
|
|
{{ form.errors['files'].join(', ') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Enhanced Footer with Action Buttons -->
|
|
<template #footer>
|
|
<div class="flex items-center justify-between">
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
<span v-if="hasUnsavedChanges" class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span class="font-medium">{{ getChangesSummary().length }} unsaved change(s)</span>
|
|
</span>
|
|
<span v-else class="flex items-center gap-2 text-green-600 dark:text-green-400">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span class="font-medium">All changes saved</span>
|
|
</span>
|
|
</div>
|
|
|
|
<BaseButtons>
|
|
<BaseButton
|
|
v-if="can.edit"
|
|
@click.stop="submitAlternative"
|
|
:disabled="form.processing"
|
|
label="Save Changes"
|
|
color="info"
|
|
:icon="mdiDisc"
|
|
:class="{ 'opacity-25': form.processing }"
|
|
class="shadow-md hover:shadow-lg transition-shadow"
|
|
/>
|
|
<BaseButton
|
|
v-if="can.edit"
|
|
:route-name="stardust.route('dataset.release', [dataset.id])"
|
|
color="success"
|
|
:icon="mdiLockOpen"
|
|
label="Release"
|
|
:disabled="hasUnsavedChanges || form.processing"
|
|
:title="hasUnsavedChanges ? 'Please save your changes before releasing' : 'Publish this dataset'"
|
|
class="shadow-md hover:shadow-lg transition-shadow"
|
|
/>
|
|
</BaseButtons>
|
|
</div>
|
|
</template>
|
|
</CardBox>
|
|
|
|
<!-- Enhanced Loading Spinner Overlay -->
|
|
<div
|
|
v-if="form.processing"
|
|
class="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-50 backdrop-blur-sm"
|
|
>
|
|
<div class="bg-white dark:bg-slate-800 rounded-lg p-8 shadow-2xl flex flex-col items-center gap-4">
|
|
<svg class="animate-spin h-12 w-12 text-blue-500" xmlns="http://www.w3.org/2000/svg" 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>
|
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">Saving changes...</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Please wait while we update your dataset</p>
|
|
</div>
|
|
</div>
|
|
</SectionMain>
|
|
</LayoutAuthenticated>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// import EditComponent from "./../EditComponent";
|
|
// export default EditComponent;
|
|
|
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
|
import { useForm, Head, usePage } from '@inertiajs/vue3';
|
|
import { computed, ComputedRef, ref, watch } from 'vue';
|
|
import { Dataset, Title, Subject, TethysFile, Person, License } from '@/Dataset';
|
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
|
|
|
import FormField from '@/Components/FormField.vue';
|
|
import FormControl from '@/Components/FormControl.vue';
|
|
import SectionMain from '@/Components/SectionMain.vue';
|
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
|
import FormCheckRadioGroup from '@/Components/FormCheckRadioGroup.vue';
|
|
import BaseButton from '@/Components/BaseButton.vue';
|
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
|
import BaseDivider from '@/Components/BaseDivider.vue';
|
|
import CardBox from '@/Components/CardBox.vue';
|
|
import MapComponent from '@/Components/Map/map.component.vue';
|
|
import SearchAutocomplete from '@/Components/SearchAutocomplete.vue';
|
|
import TablePersons from '@/Components/TablePersons.vue';
|
|
import TableKeywords from '@/Components/TableKeywords.vue';
|
|
import FormValidationErrors from '@/Components/FormValidationErrors.vue';
|
|
import FileUploadComponent from '@/Components/FileUpload.vue';
|
|
import { MapOptions } from '@/Components/Map/MapOptions';
|
|
import { LatLngBoundsExpression } from 'leaflet';
|
|
import { LayerOptions } from '@/Components/Map/LayerOptions';
|
|
import {
|
|
mdiImageText,
|
|
mdiArrowLeftBoldOutline,
|
|
mdiPlusCircle,
|
|
mdiFinance,
|
|
mdiTrashCan,
|
|
mdiBookOpenPageVariant,
|
|
mdiEarthPlus,
|
|
mdiAlertBoxOutline,
|
|
mdiRestore,
|
|
mdiLockOpen,
|
|
mdiDisc,
|
|
} from '@mdi/js';
|
|
import { notify } from '@/notiwind';
|
|
import NotificationBar from '@/Components/NotificationBar.vue';
|
|
import UnsavedChangesWarning from '@/Components/UnsavedChangesWarning.vue';
|
|
// import { isEqual } from 'lodash';
|
|
|
|
const props = defineProps({
|
|
licenses: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
languages: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
doctypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
titletypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
projects: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
descriptiontypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
contributorTypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
subjectTypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
referenceIdentifierTypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
relationTypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
dataset: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
can: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
});
|
|
const flash: ComputedRef<any> = computed(() => {
|
|
return usePage().props.flash;
|
|
});
|
|
const errors: ComputedRef<any> = computed(() => {
|
|
return usePage().props.errors;
|
|
});
|
|
|
|
// Create a reactive copy of the original dataset
|
|
const originalDataset = ref(JSON.parse(JSON.stringify(props.dataset)));
|
|
|
|
const mapOptions: MapOptions = {
|
|
center: [48.208174, 16.373819],
|
|
zoom: 3,
|
|
zoomControl: false,
|
|
attributionControl: false,
|
|
};
|
|
const baseMaps: Map<string, LayerOptions> = new Map<string, LayerOptions>();
|
|
const fitBounds: LatLngBoundsExpression = [
|
|
[46.4318173285, 9.47996951665],
|
|
[49.0390742051, 16.9796667823],
|
|
];
|
|
const mapId = 'test';
|
|
|
|
props.dataset.filesToDelete = [];
|
|
props.dataset.subjectsToDelete = [];
|
|
props.dataset.referencesToDelete = [];
|
|
let form = useForm<Dataset>(props.dataset as Dataset);
|
|
|
|
// Add this computed property to the script section
|
|
const hasUnsavedChanges = computed(() => {
|
|
// Check if form is processing
|
|
if (form.processing) return true;
|
|
|
|
// Compare against the originalDataset ref instead of props.dataset
|
|
const original = originalDataset.value;
|
|
|
|
/// Basic properties
|
|
if (form.language !== original.language) return true;
|
|
if (form.type !== original.type) return true;
|
|
if (form.creating_corporation !== original.creating_corporation) return true;
|
|
if (Number(form.project_id) !== Number(original.project_id)) return true;
|
|
if (form.embargo_date !== original.embargo_date) return true;
|
|
|
|
// Check deletion arrays
|
|
if (form.filesToDelete?.length > 0) return true;
|
|
if (form.subjectsToDelete?.length > 0) return true;
|
|
if (form.referencesToDelete?.length > 0) return true;
|
|
|
|
// Licenses comparison (use original, not props.dataset)
|
|
const originalLicenses = Array.isArray(original.licenses)
|
|
? original.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()
|
|
: [];
|
|
if (JSON.stringify(currentLicenses) !== JSON.stringify(originalLicenses)) return true;
|
|
|
|
// Helper function to 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) {
|
|
// For items with specific comparison keys (like sort_order)
|
|
if (currentItem[compareKey] !== originalItem[compareKey]) return true;
|
|
}
|
|
|
|
// Deep comparison for the rest of the properties
|
|
if (JSON.stringify(currentItem) !== JSON.stringify(originalItem)) return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Helper function to normalize and compare arrays (order-insensitive for non-sortable items)
|
|
const compareArraysContent = (current: any[], original: any[]): boolean => {
|
|
if (current.length !== original.length) return true;
|
|
|
|
// Create normalized versions for comparison
|
|
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);
|
|
};
|
|
|
|
// Files comparison - ORDER SENSITIVE (sorting affects display/processing order)
|
|
const currentFiles = form.files || [];
|
|
const originalFiles = original.files || [];
|
|
|
|
// Check for new files first
|
|
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 (sort_order or position changes)
|
|
if (compareArraysWithOrder(currentFiles, originalFiles, 'sort_order')) return true;
|
|
|
|
// Authors comparison - ORDER SENSITIVE (order matters for citation/display)
|
|
const currentAuthors = form.authors || [];
|
|
const originalAuthors = original.authors || [];
|
|
if (compareArraysWithOrder(currentAuthors, originalAuthors)) return true;
|
|
|
|
// Contributors comparison - ORDER SENSITIVE (order matters for acknowledgments)
|
|
const currentContributors = form.contributors || [];
|
|
const originalContributors = original.contributors || [];
|
|
if (compareArraysWithOrder(currentContributors, originalContributors)) return true;
|
|
|
|
// Titles comparison - ORDER SENSITIVE (first title is main title, order matters)
|
|
if (compareArraysWithOrder(form.titles, original.titles)) return true;
|
|
|
|
// Descriptions comparison - ORDER SENSITIVE (first description is main abstract)
|
|
if (compareArraysWithOrder(form.descriptions, original.descriptions)) return true;
|
|
|
|
// Subjects/Keywords comparison - ORDER INSENSITIVE (keywords don't have meaningful order)
|
|
if (compareArraysContent(form.subjects || [], original.subjects || [])) return true;
|
|
|
|
// References comparison - ORDER INSENSITIVE (references don't have meaningful order typically)
|
|
if (compareArraysContent(form.references || [], original.references || [])) return true;
|
|
|
|
// Coverage comparison
|
|
const currentCoverage = form.coverage || {};
|
|
const originalCoverage = original.coverage || {};
|
|
if (Number(currentCoverage.x_min) !== Number(originalCoverage.x_min)) return true;
|
|
if (Number(currentCoverage.x_max) !== Number(originalCoverage.x_max)) return true;
|
|
if (Number(currentCoverage.y_min) !== Number(originalCoverage.y_min)) return true;
|
|
if (Number(currentCoverage.y_max) !== Number(originalCoverage.y_max)) return true;
|
|
|
|
// // Files comparison (consolidated)
|
|
// const originalFileIds = (original.files || []).map((f) => f.id).filter(Boolean);
|
|
// const currentFileIds = (form.files || []).map((f) => f.id).filter(Boolean);
|
|
// const newFiles = (form.files || []).filter((f) => !f.id);
|
|
|
|
// if (originalFileIds.length !== currentFileIds.length) return true;
|
|
// if (newFiles.length > 0) return true;
|
|
// if (!originalFileIds.every((id) => currentFileIds.includes(id))) return true;
|
|
|
|
// No changes detected
|
|
return false;
|
|
});
|
|
|
|
// Alternative submit function that updates the original reference
|
|
const submitAlternative = async (): Promise<void> => {
|
|
let route = stardust.route('dataset.update', [props.dataset.id]);
|
|
|
|
let licenses = form.licenses.map((obj) => {
|
|
if (hasIdAttribute(obj)) {
|
|
return obj.id.toString();
|
|
} else {
|
|
return obj;
|
|
}
|
|
});
|
|
|
|
const [fileUploads, fileInputs] = form.files?.reduce(
|
|
([fileUploads, fileInputs], obj) => {
|
|
if (!obj.id) {
|
|
// return MultipartFile for file upload
|
|
const options: FilePropertyBag = {
|
|
type: obj.type,
|
|
lastModified: obj.lastModified,
|
|
sortOrder: obj.sort_order,
|
|
};
|
|
// const file = new File([obj.blob], `${obj.label}?sortOrder=${obj.sort_order}`, options);
|
|
// const metadata = JSON.stringify({ sort_order: obj.sort_order });
|
|
// const metadataBlob = new Blob([metadata + '\n'], { type: 'application/json' });
|
|
const file = new File([obj.blob], `${obj.label}?sortorder=${obj.sort_order}`, options);
|
|
|
|
// const file = new File([obj.blob], `${obj.label}`, options);
|
|
|
|
// fileUploads[obj.sort_order] = file;
|
|
fileUploads.push(file);
|
|
} else {
|
|
// return normal request input
|
|
fileInputs.push(obj);
|
|
}
|
|
return [fileUploads, fileInputs];
|
|
},
|
|
[[], []] as [Array<File>, Array<TethysFile>],
|
|
) as [Array<File>, Array<TethysFile>];
|
|
|
|
await form
|
|
.transform((data) => ({
|
|
...data,
|
|
licenses: licenses,
|
|
files: fileUploads,
|
|
fileInputs: fileInputs,
|
|
rights: 'true',
|
|
}))
|
|
.put(route, {
|
|
onSuccess: (page) => {
|
|
// reset the form with the updated dataset from server
|
|
// Get the updated dataset from the server response
|
|
const updatedDataset = page.props.dataset || props.dataset;
|
|
|
|
// Clear deletion arrays in the updated dataset
|
|
updatedDataset.filesToDelete = [];
|
|
updatedDataset.subjectsToDelete = [];
|
|
updatedDataset.referencesToDelete = [];
|
|
|
|
// // Ensure licenses are in the correct format for the form
|
|
// if (updatedDataset.licenses) {
|
|
// // Normalize licenses to the expected format
|
|
// updatedDataset.licenses = updatedDataset.licenses.map((license) => {
|
|
// if (typeof license === 'object' && license.id) {
|
|
// return license.id;
|
|
// }
|
|
// return license;
|
|
// });
|
|
// }
|
|
|
|
// Reset the form with the updated dataset from server
|
|
// form = useForm<Dataset>(updatedDataset as Dataset);
|
|
|
|
// Instead of recreating the form, selectively update properties
|
|
// Update all properties EXCEPT licenses to preserve reactivity
|
|
Object.keys(updatedDataset).forEach((key) => {
|
|
if (key !== 'licenses' && key in form) {
|
|
form[key] = updatedDataset[key];
|
|
}
|
|
});
|
|
|
|
// Clear form errors
|
|
form.clearErrors();
|
|
|
|
// IMPORTANT: Normalize originalDataset to match form format
|
|
// const normalizedDataset = JSON.parse(JSON.stringify(updatedDataset));
|
|
|
|
// // Update the original dataset reference with the fresh server data
|
|
// originalDataset.value = normalizedDataset;
|
|
originalDataset.value = JSON.parse(JSON.stringify(updatedDataset));
|
|
|
|
// // Or if server returns updated data, use that
|
|
// if (page.props.dataset) {
|
|
// originalDataset.value = JSON.parse(JSON.stringify(page.props.dataset));
|
|
// }
|
|
|
|
notify(
|
|
{
|
|
type: 'success',
|
|
title: 'Success',
|
|
text: 'Dataset updated successfully',
|
|
},
|
|
4000,
|
|
);
|
|
},
|
|
});
|
|
};
|
|
|
|
const submit = async (): Promise<void> => {
|
|
let route = stardust.route('dataset.update', [props.dataset.id]);
|
|
|
|
let licenses = form.licenses.map((obj) => {
|
|
if (hasIdAttribute(obj)) {
|
|
return obj.id.toString();
|
|
} else {
|
|
return obj;
|
|
}
|
|
});
|
|
|
|
const [fileUploads, fileInputs] = form.files?.reduce(
|
|
([fileUploads, fileInputs], obj) => {
|
|
if (!obj.id) {
|
|
// return MultipartFile for file upload
|
|
const options: FilePropertyBag = {
|
|
type: obj.type,
|
|
lastModified: obj.lastModified,
|
|
sortOrder: obj.sort_order,
|
|
};
|
|
// const file = new File([obj.blob], `${obj.label}?sortOrder=${obj.sort_order}`, options);
|
|
// const metadata = JSON.stringify({ sort_order: obj.sort_order });
|
|
// const metadataBlob = new Blob([metadata + '\n'], { type: 'application/json' });
|
|
const file = new File([obj.blob], `${obj.label}?sortorder=${obj.sort_order}`, options);
|
|
|
|
// const file = new File([obj.blob], `${obj.label}`, options);
|
|
|
|
// fileUploads[obj.sort_order] = file;
|
|
fileUploads.push(file);
|
|
} else {
|
|
// return normal request input
|
|
fileInputs.push(obj);
|
|
}
|
|
return [fileUploads, fileInputs];
|
|
},
|
|
[[], []] as [Array<File>, Array<TethysFile>],
|
|
) as [Array<File>, Array<TethysFile>];
|
|
|
|
await form
|
|
.transform((data) => ({
|
|
...data,
|
|
licenses: licenses,
|
|
files: fileUploads,
|
|
fileInputs: fileInputs,
|
|
// files: form.files.map((obj) => {
|
|
// let file;
|
|
// if (!obj.id) {
|
|
// // return MultipartFile for file upload
|
|
// file = new File([obj.blob], obj.label, { type: obj.type, lastModified: obj.lastModified });
|
|
// } else {
|
|
// // return normal request input
|
|
// file = obj;
|
|
// }
|
|
// return file;
|
|
// }),
|
|
|
|
rights: 'true',
|
|
}))
|
|
// .put(route);
|
|
.put(route, {
|
|
onSuccess: () => {
|
|
// console.log(form.data());
|
|
// mainService.setDataset(form.data());
|
|
// formStep.value++;
|
|
// form.filesToDelete = [];
|
|
// Clear the array using splice
|
|
form.filesToDelete?.splice(0, form.filesToDelete.length);
|
|
form.subjectsToDelete?.splice(0, form.subjectsToDelete.length);
|
|
form.referencesToDelete?.splice(0, form.referencesToDelete.length);
|
|
},
|
|
});
|
|
};
|
|
|
|
const hasIdAttribute = (obj: License | number): obj is License => {
|
|
return typeof obj === 'object' && 'id' in obj;
|
|
};
|
|
|
|
const addTitle = () => {
|
|
let newTitle: Title = { value: '', language: '', type: '' };
|
|
form.titles.push(newTitle);
|
|
};
|
|
const removeTitle = (key: any) => {
|
|
form.titles.splice(key, 1);
|
|
};
|
|
|
|
const addDescription = () => {
|
|
let newDescription = { value: '', language: '', type: '' };
|
|
form.descriptions.push(newDescription);
|
|
};
|
|
const removeDescription = (key: any) => {
|
|
form.descriptions.splice(key, 1);
|
|
};
|
|
|
|
const addNewAuthor = () => {
|
|
let newAuthor = {
|
|
status: false,
|
|
first_name: '',
|
|
last_name: '',
|
|
email: '',
|
|
academic_title: '',
|
|
identifier_orcid: '',
|
|
name_type: 'Personal',
|
|
};
|
|
form.authors.push(newAuthor);
|
|
};
|
|
|
|
const onAddAuthor = (person: Person) => {
|
|
if (form.authors.filter((e) => e.id === person.id).length > 0) {
|
|
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as author' }, 4000);
|
|
} else if (form.contributors.filter((e) => e.id === person.id).length > 0) {
|
|
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as contributor' });
|
|
} else {
|
|
form.authors.push(person);
|
|
notify({ type: 'info', text: 'person has been successfully added as author' });
|
|
}
|
|
};
|
|
|
|
const addNewContributor = () => {
|
|
let newContributor = {
|
|
status: false,
|
|
first_name: '',
|
|
last_name: '',
|
|
email: '',
|
|
academic_title: '',
|
|
identifier_orcid: '',
|
|
name_type: 'Personal',
|
|
pivot: { contributor_type: '' },
|
|
};
|
|
form.contributors.push(newContributor);
|
|
};
|
|
|
|
const onAddContributor = (person: Person) => {
|
|
if (form.contributors.filter((e) => e.id === person.id).length > 0) {
|
|
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as contributor' }, 4000);
|
|
} else if (form.authors.filter((e) => e.id === person.id).length > 0) {
|
|
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as author' }, 4000);
|
|
} else {
|
|
// person.pivot = { contributor_type: '' };
|
|
// // person.pivot = { name_type: '', contributor_type: '' };
|
|
form.contributors.push(person);
|
|
notify({ type: 'info', text: 'person has been successfully added as contributor' }, 4000);
|
|
}
|
|
};
|
|
|
|
const addKeyword = () => {
|
|
let newSubject: Subject = { value: '', language: '', type: 'uncontrolled' };
|
|
//this.dataset.files.push(uploadedFiles[i]);
|
|
form.subjects.push(newSubject);
|
|
};
|
|
|
|
const addReference = () => {
|
|
let newReference = { value: '', label: '', relation: '', type: '' };
|
|
//this.dataset.files.push(uploadedFiles[i]);
|
|
form.references.push(newReference);
|
|
};
|
|
|
|
const removeReference = (key: any) => {
|
|
const reference = form.references[key];
|
|
|
|
// If the reference has an ID, it exists in the database
|
|
// and should be added to referencesToDelete
|
|
if (reference.id) {
|
|
// Initialize referencesToDelete array if it doesn't exist
|
|
if (!form.referencesToDelete) {
|
|
form.referencesToDelete = [];
|
|
}
|
|
|
|
// Add to referencesToDelete
|
|
form.referencesToDelete.push(reference);
|
|
}
|
|
|
|
// Remove from form.references array
|
|
form.references.splice(key, 1);
|
|
};
|
|
|
|
const restoreReference = (index: number) => {
|
|
// Get the reference from referencesToDelete
|
|
const reference = form.referencesToDelete[index];
|
|
|
|
// Add it back to form.references
|
|
form.references.push(reference);
|
|
|
|
// Remove it from referencesToDelete
|
|
form.referencesToDelete.splice(index, 1);
|
|
};
|
|
|
|
const onMapInitialized = (newItem: any) => {
|
|
console.log(newItem);
|
|
};
|
|
|
|
// Add this method to generate change summaries
|
|
const getChangesSummary = () => {
|
|
const changes = [];
|
|
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.filesToDelete?.length > 0) changes.push(`${form.filesToDelete.length} file(s) marked for deletion`);
|
|
if (form.subjectsToDelete?.length > 0) changes.push(`${form.subjectsToDelete.length} keyword(s) marked for deletion`);
|
|
if (form.referencesToDelete?.length > 0) changes.push(`${form.referencesToDelete.length} reference(s) marked for deletion`);
|
|
|
|
// License changes
|
|
const originalLicenses = Array.isArray(original.licenses)
|
|
? original.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()
|
|
: [];
|
|
if (JSON.stringify(currentLicenses) !== JSON.stringify(originalLicenses)) {
|
|
changes.push('Licenses modified');
|
|
}
|
|
|
|
// Helper function to detect array changes and sorting
|
|
const analyzeArrayChanges = (current: any[], original: any[], itemName: string): string[] => {
|
|
const arrayChanges = [];
|
|
|
|
// Check for count changes
|
|
if (current.length !== original.length) {
|
|
const diff = current.length - original.length;
|
|
if (diff > 0) {
|
|
arrayChanges.push(`${diff} ${itemName}(s) added`);
|
|
} else {
|
|
arrayChanges.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 we have IDs and they're in different order
|
|
if (currentIds.length === originalIds.length && currentIds.length > 0) {
|
|
const orderChanged = currentIds.some((id, index) => id !== originalIds[index]);
|
|
if (orderChanged) {
|
|
arrayChanges.push(`${itemName} order changed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for content changes (when count is same but content differs)
|
|
if (current.length === original.length) {
|
|
const contentChanged = JSON.stringify(current) !== JSON.stringify(original);
|
|
const orderChanged = arrayChanges.some((change) => change.includes('order changed'));
|
|
|
|
if (contentChanged && !orderChanged) {
|
|
arrayChanges.push(`${itemName} content modified`);
|
|
}
|
|
}
|
|
|
|
return arrayChanges;
|
|
};
|
|
|
|
// Files analysis with detailed logic for new files and reordering
|
|
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`);
|
|
}
|
|
|
|
// Check for file order changes specifically
|
|
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 analysis
|
|
const authorChanges = analyzeArrayChanges(form.authors || [], original.authors || [], 'author');
|
|
changes.push(...authorChanges);
|
|
|
|
// Contributors analysis
|
|
const contributorChanges = analyzeArrayChanges(form.contributors || [], original.contributors || [], 'contributor');
|
|
changes.push(...contributorChanges);
|
|
|
|
// Titles analysis (order-sensitive)
|
|
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) {
|
|
// Check if main title changed
|
|
if (form.titles[0]?.value !== original.titles[0]?.value) {
|
|
changes.push('Main title changed');
|
|
}
|
|
// Check for other title changes
|
|
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 (order-sensitive)
|
|
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) {
|
|
// Check if main abstract changed
|
|
if (form.descriptions[0]?.value !== original.descriptions[0]?.value) {
|
|
changes.push('Main abstract changed');
|
|
}
|
|
// Check for other description changes
|
|
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 (order-insensitive)
|
|
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) {
|
|
// Check content changes without order sensitivity
|
|
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)) {
|
|
let test = JSON.stringify(currentSubjectsNormalized);
|
|
let test2 = JSON.stringify(originalSubjectsNormalized);
|
|
changes.push('Keywords modified');
|
|
}
|
|
}
|
|
|
|
// References analysis (order-insensitive)
|
|
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) {
|
|
// Check content changes without order sensitivity
|
|
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
|
|
const currentCoverage = form.coverage || {};
|
|
const originalCoverage = original.coverage || {};
|
|
const coverageFields = ['x_min', 'x_max', 'y_min', 'y_max'];
|
|
const coverageChanged = coverageFields.some((field) => Number(currentCoverage[field]) !== Number(originalCoverage[field]));
|
|
if (coverageChanged) {
|
|
changes.push('Geographic coverage changed');
|
|
}
|
|
|
|
return changes;
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.max-w-2xl {
|
|
max-width: 2xl;
|
|
}
|
|
|
|
.text-2xl {
|
|
font-size: 2xl;
|
|
}
|
|
|
|
.font-bold {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.mb-4 {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.block {
|
|
display: block;
|
|
}
|
|
|
|
.text-gray-700 {
|
|
color: #4b5563;
|
|
}
|
|
|
|
.shadow {
|
|
box-shadow:
|
|
0 0 0 1px rgba(66, 72, 78, 0.05),
|
|
0 1px 2px 0 rgba(66, 72, 78, 0.08),
|
|
0 2px 4px 0 rgba(66, 72, 78, 0.12),
|
|
0 4px 8px 0 rgba(66, 72, 78, 0.16);
|
|
}
|
|
</style>
|