All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 40s
commit 579f0878e5240dc17db69be1e0b0c0f5af7ef9fe
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date: Tue Jun 9 09:25:44 2026 +0200
feat: Refactor error handling in Dataset Edit form and improve validation messages
- Updated error handling in the Dataset Edit form to use a centralized formatError function for displaying validation messages.
- Enhanced user feedback by ensuring that error messages are displayed consistently across various fields.
- Modified the validation rule for arrayContainsTypes to provide clearer error messages for missing main and translated titles/abstracts.
- Introduced a new ValidationService to manage manual construction of validation errors.
- Updated Vite configuration to streamline asset loading and improve performance.
- Adjusted Inertia setup to utilize dynamic imports for page-specific assets.
- Cleaned up unnecessary comments and code in various files for better readability.
commit 5efddc2a58c0e164fef585cc7344c06155dbc2c1
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date: Mon Jan 12 17:02:47 2026 +0100
feat: add dataset change detection and form submission composables
- Implemented `useDatasetChangeDetection` for tracking unsaved changes in dataset forms, including comparisons for licenses, basic properties, files, coverage, and more.
- Added `useDatasetFormSubmission` for handling dataset form submissions with validation, success/error handling, and auto-save functionality.
260 lines
No EOL
10 KiB
Vue
260 lines
No EOL
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from 'vue';
|
|
import { StyleService } from '@/Stores/style.service';
|
|
import { mdiTrashCan } from '@mdi/js';
|
|
import BaseLevel from '@/Components/BaseLevel.vue';
|
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
|
import BaseButton from '@/Components/BaseButton.vue';
|
|
import { Subject } from '@/Dataset';
|
|
import FormControl from '@/Components/FormControl.vue';
|
|
import SearchCategoryAutocomplete from '@/Components/SearchCategoryAutocomplete.vue';
|
|
import { mdiRefresh } from '@mdi/js';
|
|
|
|
const props = defineProps({
|
|
checkable: Boolean,
|
|
keywords: {
|
|
type: Array<Subject>,
|
|
default: () => [],
|
|
},
|
|
subjectTypes: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
errors: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
subjectsToDelete: {
|
|
type: Array<Subject>,
|
|
default: [],
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:subjectsToDelete']);
|
|
|
|
// Create a computed property for subjectsToDelete with getter and setter
|
|
const deletetSubjects = computed({
|
|
get: () => props.subjectsToDelete,
|
|
set: (values: Array<Subject>) => {
|
|
props.subjectsToDelete.length = 0;
|
|
props.subjectsToDelete.push(...values);
|
|
emit('update:subjectsToDelete', values);
|
|
}
|
|
});
|
|
|
|
const styleService = StyleService();
|
|
const items = computed(() => props.keywords);
|
|
|
|
const perPage = ref(5);
|
|
const currentPage = ref(0);
|
|
|
|
const itemsPaginated = computed(() => {
|
|
return items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1));
|
|
});
|
|
|
|
const numPages = computed(() => Math.ceil(items.value.length / perPage.value));
|
|
|
|
const currentPageHuman = computed(() => currentPage.value + 1);
|
|
|
|
const pagesList = computed(() => {
|
|
const pagesList: Array<number> = [];
|
|
|
|
for (let i = 0; i < numPages.value; i++) {
|
|
pagesList.push(i);
|
|
}
|
|
|
|
return pagesList;
|
|
});
|
|
|
|
const removeItem = (key: number) => {
|
|
const item = items.value[key];
|
|
|
|
// If the item has an ID, add it to the delete list
|
|
if (item.id) {
|
|
addToDeleteList(item);
|
|
}
|
|
|
|
// Remove from the visible list
|
|
items.value.splice(key, 1);
|
|
};
|
|
|
|
// Helper function to add a subject to the delete list
|
|
const addToDeleteList = (subject: Subject) => {
|
|
if (subject.id) {
|
|
const newList = [...props.subjectsToDelete, subject];
|
|
deletetSubjects.value = newList;
|
|
}
|
|
};
|
|
|
|
// Helper function to reactivate a subject (remove from delete list)
|
|
const reactivateSubject = (index: number) => {
|
|
const newList = [...props.subjectsToDelete];
|
|
const removedSubject = newList.splice(index, 1)[0];
|
|
deletetSubjects.value = newList;
|
|
|
|
// Add the subject back to the keywords list if it's not already there
|
|
if (removedSubject && !props.keywords.some(k => k.id === removedSubject.id)) {
|
|
props.keywords.push(removedSubject);
|
|
}
|
|
};
|
|
|
|
const isKeywordReadOnly = (item: Subject) => {
|
|
return (item.dataset_count ?? 0) > 1 || item.type !== 'uncontrolled';
|
|
};
|
|
|
|
const formatError = (error: string | string[] | undefined | null): string => {
|
|
if (!error) return '';
|
|
return Array.isArray(error) ? error.join(', ') : error;
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Type</th>
|
|
<th scope="col" class="relative">
|
|
Value
|
|
<div class="inline-block relative ml-1 group">
|
|
<button
|
|
class="w-4 h-4 rounded-full bg-gray-200 text-gray-600 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300">
|
|
i
|
|
</button>
|
|
<div
|
|
class="absolute left-0 top-full mt-1 w-64 bg-white shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100">
|
|
<p class="text-gray-700">
|
|
Keywords are only editable if they are used by a single dataset (Usage Count = 1)".
|
|
</p>
|
|
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45"></div>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
<th scope="col">Language</th>
|
|
<th scope="col">Usage Count</th>
|
|
<th scope="col" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(item, index) in itemsPaginated" :key="item.id ?? index">
|
|
|
|
<td data-label="Type" scope="row">
|
|
<FormControl required v-model="item.type"
|
|
@update:modelValue="() => { item.value = ''; }" :type="'select'"
|
|
placeholder="[Enter Language]" :options="props.subjectTypes">
|
|
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.type`]">
|
|
{{ formatError(errors[`subjects.${index}.type`]) }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
|
|
<td data-label="Value" scope="row">
|
|
<SearchCategoryAutocomplete v-if="item.type !== 'uncontrolled'" v-model="item.value" @subject="
|
|
(result) => {
|
|
item.language = result.language;
|
|
item.external_key = result.uri;
|
|
}
|
|
" :is-read-only="item.dataset_count > 1">
|
|
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]">
|
|
{{ formatError(errors[`subjects.${index}.value`]) }}
|
|
</div>
|
|
</SearchCategoryAutocomplete>
|
|
|
|
<FormControl v-else required v-model="item.value" type="text" placeholder="[enter keyword value]"
|
|
:borderless="true" :is-read-only="item.dataset_count > 1">
|
|
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]">
|
|
{{ formatError(errors[`subjects.${index}.value`]) }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
|
|
<td data-label="Language" scope="row">
|
|
<FormControl required v-model="item.language" :type="'select'" placeholder="[Enter Lang]"
|
|
:options="{ de: 'de', en: 'en' }" :is-read-only="isKeywordReadOnly(item)">
|
|
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.language`]">
|
|
{{ formatError(errors[`subjects.${index}.language`]) }}
|
|
</div>
|
|
</FormControl>
|
|
</td>
|
|
|
|
<td data-label="Usage Count" scope="row">
|
|
<div class="text-center">
|
|
{{ item.dataset_count || 1 }}
|
|
</div>
|
|
</td>
|
|
|
|
<td class="before:hidden lg:w-1 whitespace-nowrap" scope="row">
|
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
|
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeItem(index)" />
|
|
</BaseButtons>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
|
|
<BaseLevel>
|
|
<BaseButtons>
|
|
<BaseButton v-for="page in pagesList" :key="page" :active="page === currentPage" :label="page + 1" small
|
|
:outline="styleService.darkMode" @click="currentPage = page" />
|
|
</BaseButtons>
|
|
<small>Page {{ currentPageHuman }} of {{ numPages }}</small>
|
|
</BaseLevel>
|
|
</div>
|
|
|
|
<!-- Aggregate error for the whole subjects collection, e.g. "at least 3 keywords must be defined" -->
|
|
<div class="text-red-400 text-sm" v-if="errors.subjects">
|
|
{{ formatError(errors.subjects) }}
|
|
</div>
|
|
|
|
<!-- Subjects to delete section -->
|
|
<div v-if="deletetSubjects.length > 0" class="mt-8">
|
|
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Keywords To Delete</h1>
|
|
<ul id="deleteSubjects" tag="ul" class="flex flex-1 flex-wrap -m-1">
|
|
<li v-for="(element, index) in deletetSubjects" :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-32">
|
|
<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">{{
|
|
element.value }}</h1>
|
|
<div class="flex items-center justify-between mt-auto">
|
|
<div class="flex flex-col">
|
|
<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" v-if="element.dataset_count">
|
|
<span class="font-semibold">Used by:</span>
|
|
<span
|
|
class="inline-flex items-center justify-center bg-gray-200 text-gray-800 rounded-full w-5 h-5 text-xs">
|
|
{{ element.dataset_count }}
|
|
</span> datasets
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
|
|
@click.prevent="reactivateSubject(index)">
|
|
<svg viewBox="0 0 24 24" class="w-5 h-5">
|
|
<path fill="currentColor" :d="mdiRefresh"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</article>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* tr:nth-child(even) {
|
|
background: gray;
|
|
}
|
|
|
|
tr:nth-child(od) {
|
|
background: white;
|
|
} */
|
|
</style> |