Squashed commit of the following:
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 40s
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.
This commit is contained in:
parent
0680879e2f
commit
9368a0dd8d
38 changed files with 5588 additions and 6181 deletions
|
|
@ -102,7 +102,11 @@ const activeStyle = computed(() => {
|
|||
|
||||
const hasRoles = computed(() => {
|
||||
if (props.item.roles) {
|
||||
return user.value.roles.some(role => props.item.roles?.includes(role.name));
|
||||
// Normalize user roles to strings in case roles are objects
|
||||
const userRoles = (user.value.roles || []).map(r =>
|
||||
typeof r === 'string' ? r : (r as any).name ?? String(r)
|
||||
);
|
||||
return userRoles.some(role => props.item.roles?.includes(role));
|
||||
// return test;
|
||||
}
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -1,17 +1,29 @@
|
|||
<template>
|
||||
<section aria-label="File Upload Modal"
|
||||
<section
|
||||
aria-label="File Upload Modal"
|
||||
class="relative h-full flex flex-col bg-white dark:bg-slate-900/70 shadow-xl rounded-md"
|
||||
v-on:dragenter="dragEnterHandler" v-on:dragleave="dragLeaveHandler" v-on:dragover="dragOverHandler"
|
||||
v-on:drop="dropHandler">
|
||||
|
||||
v-on:dragenter="dragEnterHandler"
|
||||
v-on:dragleave="dragLeaveHandler"
|
||||
v-on:dragover="dragOverHandler"
|
||||
v-on:drop="dropHandler"
|
||||
>
|
||||
<!-- overlay -->
|
||||
<div id="overlay" ref="overlay"
|
||||
class="w-full h-full absolute top-0 left-0 pointer-events-none z-50 flex flex-col items-center justify-center rounded-md">
|
||||
<div
|
||||
id="overlay"
|
||||
ref="overlay"
|
||||
class="w-full h-full absolute top-0 left-0 pointer-events-none z-50 flex flex-col items-center justify-center rounded-md"
|
||||
>
|
||||
<i>
|
||||
<svg class="fill-current w-12 h-12 mb-3 text-blue-700" xmlns="http://www.w3.org/2000/svg" width="24"
|
||||
height="24" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="fill-current w-12 h-12 mb-3 text-blue-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M19.479 10.092c-.212-3.951-3.473-7.092-7.479-7.092-4.005 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408zm-7.479-1.092l4 4h-3v4h-2v-4h-3l4-4z" />
|
||||
d="M19.479 10.092c-.212-3.951-3.473-7.092-7.479-7.092-4.005 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408zm-7.479-1.092l4 4h-3v4h-2v-4h-3l4-4z"
|
||||
/>
|
||||
</svg>
|
||||
</i>
|
||||
<p class="text-lg text-blue-700">Drop files to upload</p>
|
||||
|
|
@ -19,8 +31,7 @@
|
|||
|
||||
<!-- Loading Spinner when processing big files -->
|
||||
<div v-if="isLoading" class="absolute inset-0 z-60 flex items-center justify-center bg-gray-500 bg-opacity-50">
|
||||
<svg class="animate-spin h-12 w-12 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<svg class="animate-spin h-12 w-12 text-white" 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="M12 2a10 10 0 0110 10h-4a6 6 0 00-6-6V2z"></path>
|
||||
</svg>
|
||||
|
|
@ -29,21 +40,39 @@
|
|||
<!-- scroll area -->
|
||||
<div class="h-full p-8 w-full h-full flex flex-col">
|
||||
<header class="flex items-center justify-center w-full">
|
||||
<label for="dropzone-file"
|
||||
class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600">
|
||||
<label
|
||||
for="dropzone-file"
|
||||
class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<svg aria-hidden="true" class="w-10 h-10 mb-3 text-gray-400" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12">
|
||||
</path>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-10 h-10 mb-3 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
</div>
|
||||
<input id="dropzone-file" type="file" class="hidden" @click="showSpinner" @change="onChangeFile"
|
||||
@cancel="cancelSpinner" multiple="true" />
|
||||
<input
|
||||
id="dropzone-file"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@click="showSpinner"
|
||||
@change="onChangeFile"
|
||||
@cancel="cancelSpinner"
|
||||
multiple="true"
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
|
|
@ -107,17 +136,16 @@
|
|||
</section>
|
||||
</article> -->
|
||||
<!-- :class="errors && errors[`files.${index}`] ? 'bg-red-400' : 'bg-gray-100'" -->
|
||||
<article tabindex="0"
|
||||
class="bg-gray-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
|
||||
<section
|
||||
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
|
||||
<article tabindex="0" class="bg-gray-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
|
||||
<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">{{ element.name }}</h1>
|
||||
<div class="flex">
|
||||
<p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p>
|
||||
<p class="p-1 size text-xs text-gray-700">sort: {{ element.sort_order }}</p>
|
||||
<button
|
||||
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
|
||||
@click.prevent="removeFile(index)">
|
||||
@click.prevent="removeFile(index)"
|
||||
>
|
||||
<DeleteIcon></DeleteIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -131,11 +159,13 @@
|
|||
<!--<ul id="deletetFiles"></ul> -->
|
||||
|
||||
<div>
|
||||
<h1 v-if="deletetFiles.length > 0" class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Files To
|
||||
Delete</h1>
|
||||
<h1 v-if="deletetFiles.length > 0" class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Files To Delete</h1>
|
||||
<ul id="deletetFiles" tag="ul" class="flex flex-1 flex-wrap -m-1">
|
||||
<li v-for="(element, index) in deletetFiles" :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-24">
|
||||
<li
|
||||
v-for="(element, index) in deletetFiles"
|
||||
: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-24"
|
||||
>
|
||||
<!-- <article
|
||||
v-if="element.type.match('image.*')"
|
||||
tabindex="0"
|
||||
|
|
@ -160,17 +190,16 @@
|
|||
</div>
|
||||
</section>
|
||||
</article> -->
|
||||
<article tabindex="0"
|
||||
class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
|
||||
<section
|
||||
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
|
||||
<article tabindex="0" class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
|
||||
<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">{{ element.name }}</h1>
|
||||
<div class="flex">
|
||||
<!-- <p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p> -->
|
||||
<p class="p-1 size text-xs text-gray-700">{{ element.sort_order }}</p>
|
||||
<button
|
||||
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
|
||||
@click.prevent="reactivateFile(index)">
|
||||
@click.prevent="reactivateFile(index)"
|
||||
>
|
||||
<RefreshIcon></RefreshIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -183,17 +212,19 @@
|
|||
<div v-if="fileErrors" class="flex flex-col mt-6 animate-fade-in" v-for="fileError in fileErrors">
|
||||
<div class="bg-yellow-500 border-l-4 border-orange-400 text-white p-4" role="alert">
|
||||
<p class="font-bold">Be Warned</p>
|
||||
<p>{{ fileError.join(', ') }}</p>
|
||||
<p>{{ formatError(fileError) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- sticky footer -->
|
||||
<footer class="flex justify-end px-8 pb-8 pt-4">
|
||||
<button v-if="showClearButton" id="cancel"
|
||||
<button
|
||||
v-if="showClearButton"
|
||||
id="cancel"
|
||||
class="ml-3 rounded-sm px-3 py-1 hover:bg-gray-300 focus:shadow-outline focus:outline-none"
|
||||
@click="clearAllFiles">
|
||||
@click="clearAllFiles"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -247,10 +278,8 @@ interface InteriaPage {
|
|||
},
|
||||
})
|
||||
class FileUploadComponent extends Vue {
|
||||
|
||||
@Ref('overlay') overlay: HTMLDivElement;
|
||||
|
||||
|
||||
public isLoading: boolean = false;
|
||||
private counter: number = 0;
|
||||
// @Prop() files: Array<TestFile>;
|
||||
|
|
@ -261,7 +290,6 @@ class FileUploadComponent extends Vue {
|
|||
})
|
||||
files: Array<TethysFile | File>;
|
||||
|
||||
|
||||
@Prop({
|
||||
type: Array<File>,
|
||||
default: [],
|
||||
|
|
@ -335,6 +363,11 @@ class FileUploadComponent extends Vue {
|
|||
++this.counter && this.overlay.classList.add('draggedover');
|
||||
}
|
||||
|
||||
public formatError(error: string | string[] | undefined) {
|
||||
if (!error) return '';
|
||||
return Array.isArray(error) ? error.join(', ') : error;
|
||||
}
|
||||
|
||||
public dragLeaveHandler() {
|
||||
1 > --this.counter && this.overlay.classList.remove('draggedover');
|
||||
}
|
||||
|
|
@ -425,7 +458,6 @@ class FileUploadComponent extends Vue {
|
|||
// this.isLoading = true;
|
||||
// }
|
||||
this._addFile(file);
|
||||
|
||||
}
|
||||
}
|
||||
// if (bigFileFound) {
|
||||
|
|
|
|||
|
|
@ -1,53 +1,75 @@
|
|||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
labelFor: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
help: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
// class: {
|
||||
// type: Object,
|
||||
// default: {},
|
||||
// },
|
||||
label: { type: String, default: null },
|
||||
labelFor: { type: String, default: null },
|
||||
help: { type: String, default: null },
|
||||
// Handles Inertia.js string errors or standard array errors
|
||||
errors: { type: [String, Array], default: null },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
// Normalize errors to an array for consistent rendering
|
||||
const errorList = computed(() => {
|
||||
if (!props.errors) return [];
|
||||
return Array.isArray(props.errors) ? props.errors : [props.errors];
|
||||
});
|
||||
|
||||
const hasErrors = computed(() => errorList.value.length > 0);
|
||||
|
||||
const wrapperClass = computed(() => {
|
||||
const base = [];
|
||||
const slotsLength = slots.default().length;
|
||||
|
||||
if (slotsLength > 1) {
|
||||
base.push('grid grid-cols-1 gap-3');
|
||||
}
|
||||
|
||||
if (slotsLength === 2) {
|
||||
base.push('md:grid-cols-2');
|
||||
}
|
||||
|
||||
return base;
|
||||
const base = [];
|
||||
const children = slots.default?.().filter(node => node.type.toString() !== 'Symbol(v-cmt)') || [];
|
||||
|
||||
// Apply grid logic only if there are multiple child controls
|
||||
if (children.length > 1) {
|
||||
base.push('grid grid-cols-1 gap-3');
|
||||
if (children.length === 2) base.push('md:grid-cols-2');
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['last:mb-0', 'mb-6']">
|
||||
<!-- <label v-if="label" :for="labelFor" class="block font-bold mb-2">{{ label }}</label> -->
|
||||
<label v-if="label" :for="labelFor" class="font-bold h-6 mt-3 text-xs leading-8 uppercase">{{ label }}</label>
|
||||
<div v-bind:class="wrapperClass">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="help" class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||
{{ help }}
|
||||
</div>
|
||||
<div class="mb-6 last:mb-0">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="labelFor"
|
||||
class="block font-bold text-xs uppercase tracking-wide mb-2 transition-colors duration-200"
|
||||
:class="hasErrors ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-slate-300'"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div :class="wrapperClass">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mt-1 min-h-[1.25rem]">
|
||||
<transition-group
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="transform -translate-y-1 opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
>
|
||||
<p
|
||||
v-for="(error, index) in errorList"
|
||||
:key="`err-${index}`"
|
||||
class="text-xs text-red-600 dark:text-red-400 italic font-medium"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!hasErrors && help"
|
||||
key="help-text"
|
||||
class="text-xs text-gray-500 dark:text-slate-400"
|
||||
>
|
||||
{{ help }}
|
||||
</p>
|
||||
</transition-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,23 +1,45 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, Ref, defineProps, computed } from 'vue';
|
||||
import { onMounted, onUnmounted, ref, Ref, computed } from 'vue';
|
||||
import SectionMain from '@/Components/SectionMain.vue';
|
||||
import { Map } from 'leaflet/src/map/index';
|
||||
import { Rectangle } from 'leaflet';
|
||||
import { canvas } from 'leaflet/src/layer/vector/Canvas';
|
||||
import { svg } from 'leaflet/src/layer/vector/SVG';
|
||||
import axios from 'axios';
|
||||
import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
|
||||
import L, {
|
||||
Map as LeafletMap,
|
||||
Rectangle,
|
||||
LayerGroup,
|
||||
Control,
|
||||
type Layer,
|
||||
type LatLng,
|
||||
type LatLngBounds,
|
||||
type LatLngBoundsExpression,
|
||||
type MapOptions,
|
||||
type Renderer,
|
||||
type RendererOptions,
|
||||
type LeafletEvent,
|
||||
} from 'leaflet';
|
||||
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
|
||||
import { Attribution } from 'leaflet/src/control/Control.Attribution';
|
||||
import axios from 'axios';
|
||||
import DrawControlComponent from '@/Components/Map/draw.component.vue';
|
||||
import ZoomControlComponent from '@/Components/Map/zoom.component.vue';
|
||||
import { MapService } from '@/Stores/map.service';
|
||||
import { LayerGroup } from 'leaflet/src/layer/LayerGroup';
|
||||
import { OpensearchDocument } from '@/Dataset';
|
||||
|
||||
Map.include({
|
||||
getRenderer: function (layer) {
|
||||
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
|
||||
/**
|
||||
* Leaflet's internal renderer machinery is not part of the public @types/leaflet
|
||||
* surface, so we describe the bits we touch here. This keeps the mixin body typed
|
||||
* instead of falling back to `any`.
|
||||
*/
|
||||
interface RendererCapableMap extends LeafletMap {
|
||||
options: MapOptions & { renderer?: Renderer; preferCanvas?: boolean };
|
||||
_renderer?: Renderer;
|
||||
_paneRenderers: Record<string, Renderer | undefined>;
|
||||
_getPaneRenderer(name?: string): Renderer | false;
|
||||
_createRenderer(options?: RendererOptions): Renderer;
|
||||
}
|
||||
|
||||
LeafletMap.include({
|
||||
getRenderer(this: RendererCapableMap, layer: Layer): Renderer {
|
||||
const layerOptions = layer.options as { renderer?: Renderer; pane?: string };
|
||||
let renderer: Renderer | false | undefined =
|
||||
layerOptions.renderer || this._getPaneRenderer(layerOptions.pane) || this.options.renderer || this._renderer;
|
||||
|
||||
if (!renderer) {
|
||||
renderer = this._renderer = this._createRenderer();
|
||||
|
|
@ -29,12 +51,12 @@ Map.include({
|
|||
return renderer;
|
||||
},
|
||||
|
||||
_getPaneRenderer: function (name) {
|
||||
_getPaneRenderer(this: RendererCapableMap, name?: string): Renderer | false {
|
||||
if (name === 'overlayPane' || name === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var renderer = this._paneRenderers[name];
|
||||
let renderer = this._paneRenderers[name];
|
||||
if (renderer === undefined) {
|
||||
renderer = this._createRenderer({ pane: name });
|
||||
this._paneRenderers[name] = renderer;
|
||||
|
|
@ -42,16 +64,15 @@ Map.include({
|
|||
return renderer;
|
||||
},
|
||||
|
||||
_createRenderer: function (options) {
|
||||
return (this.options.preferCanvas && canvas(options)) || svg(options);
|
||||
_createRenderer(this: RendererCapableMap, options?: RendererOptions): Renderer {
|
||||
return (this.options.preferCanvas && L.canvas(options)) || L.svg(options);
|
||||
},
|
||||
});
|
||||
|
||||
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
|
||||
const DEFAULT_BASE_LAYER_ATTRIBUTION = '© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
|
||||
const OPENSEARCH_HOST = 'https://catalog.geosphere.at';
|
||||
|
||||
let map: Map;
|
||||
let map: LeafletMap;
|
||||
|
||||
const props = defineProps({
|
||||
checkable: Boolean,
|
||||
|
|
@ -63,6 +84,12 @@ const props = defineProps({
|
|||
type: String,
|
||||
default: 'map',
|
||||
},
|
||||
// OpenSearch host is provided by the server (prop / shared data), never imported
|
||||
// from server-only modules into client code.
|
||||
opensearchHost: {
|
||||
type: String,
|
||||
default: 'localhost',
|
||||
},
|
||||
mapOptions: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
|
|
@ -74,7 +101,9 @@ const props = defineProps({
|
|||
},
|
||||
});
|
||||
|
||||
const items = computed({
|
||||
const OPENSEARCH_HOST = computed(() => `${window.location.protocol}//${props.opensearchHost}:9200`);
|
||||
|
||||
const items = computed<OpensearchDocument[]>({
|
||||
get() {
|
||||
return props.datasets;
|
||||
},
|
||||
|
|
@ -84,19 +113,43 @@ const items = computed({
|
|||
},
|
||||
});
|
||||
|
||||
const resultCount = computed(() => items.value.length);
|
||||
|
||||
const fitBounds: LatLngBoundsExpression = [
|
||||
[46.4318173285, 9.47996951665],
|
||||
[49.0390742051, 16.9796667823],
|
||||
];
|
||||
|
||||
const drawControl: Ref<DrawControlComponent | null> = ref(null);
|
||||
const southWest = ref(null);
|
||||
const northEast = ref(null);
|
||||
const drawControl = ref<InstanceType<typeof DrawControlComponent> | null>(null);
|
||||
const southWest = ref<LatLng | null>(null);
|
||||
const northEast = ref<LatLng | null>(null);
|
||||
const mapService = MapService();
|
||||
const isLoading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
|
||||
const filterLayerGroup = new LayerGroup();
|
||||
|
||||
/** Minimal shape of the leaflet-draw "created" event (no @types/leaflet-draw needed). */
|
||||
interface DrawCreatedEvent extends LeafletEvent {
|
||||
layer: Layer & { getBounds(): LatLngBounds };
|
||||
layerType: string;
|
||||
}
|
||||
|
||||
/** Shape of the OpenSearch _search response we rely on. */
|
||||
interface OpenSearchHit {
|
||||
_source: OpensearchDocument & {
|
||||
bbox_xmin: number;
|
||||
bbox_xmax: number;
|
||||
bbox_ymin: number;
|
||||
bbox_ymax: number;
|
||||
};
|
||||
}
|
||||
interface OpenSearchSearchResponse {
|
||||
hits: {
|
||||
hits: OpenSearchHit[];
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initMap();
|
||||
});
|
||||
|
|
@ -105,8 +158,8 @@ onUnmounted(() => {
|
|||
map.off('zoomend zoomlevelschange');
|
||||
});
|
||||
|
||||
const initMap = async () => {
|
||||
map = new Map('map', props.mapOptions);
|
||||
const initMap = async (): Promise<void> => {
|
||||
map = new LeafletMap('map', props.mapOptions as MapOptions);
|
||||
mapService.setMap(props.mapId, map);
|
||||
map.scrollWheelZoom.disable();
|
||||
map.fitBounds(fitBounds);
|
||||
|
|
@ -114,37 +167,39 @@ const initMap = async () => {
|
|||
|
||||
map.addLayer(filterLayerGroup);
|
||||
|
||||
const attributionControl = new Attribution().addTo(map);
|
||||
const attributionControl = new Control.Attribution().addTo(map);
|
||||
attributionControl.setPrefix(false);
|
||||
|
||||
let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
|
||||
const osmGray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
|
||||
format: 'image/png',
|
||||
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
|
||||
layers: 'OSM-WMS',
|
||||
});
|
||||
|
||||
let layerOptions = {
|
||||
const layerOptions = {
|
||||
label: DEFAULT_BASE_LAYER_NAME,
|
||||
visible: true,
|
||||
layer: osmGgray,
|
||||
layer: osmGray,
|
||||
};
|
||||
layerOptions.layer.addTo(map);
|
||||
|
||||
map.on('Draw.Event.CREATED', handleDrawEventCreated);
|
||||
map.on('Draw.Event.CREATED', handleDrawEventCreated as L.LeafletEventHandlerFn);
|
||||
};
|
||||
|
||||
const handleDrawEventCreated = async (event) => {
|
||||
const handleDrawEventCreated = async (event: DrawCreatedEvent): Promise<void> => {
|
||||
isLoading.value = true;
|
||||
hasSearched.value = true;
|
||||
filterLayerGroup.clearLayers();
|
||||
items.value = [];
|
||||
|
||||
let layer = event.layer;
|
||||
let bounds = layer.getBounds();
|
||||
const bounds: LatLngBounds = event.layer.getBounds();
|
||||
|
||||
try {
|
||||
let response = await axios({
|
||||
// NOTE: OpenSearch _search with a query body must be POST — browsers/XHR
|
||||
// drop the request body on GET, which would send an empty query.
|
||||
const response = await axios<OpenSearchSearchResponse>({
|
||||
method: 'POST',
|
||||
url: OPENSEARCH_HOST + '/tethys-records/_search',
|
||||
url: OPENSEARCH_HOST.value + '/tethys-records/_search',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
size: 1000,
|
||||
|
|
@ -172,17 +227,17 @@ const handleDrawEventCreated = async (event) => {
|
|||
},
|
||||
});
|
||||
|
||||
response.data.hits.hits.forEach((hit) => {
|
||||
let xMin = hit._source.bbox_xmin;
|
||||
let xMax = hit._source.bbox_xmax;
|
||||
let yMin = hit._source.bbox_ymin;
|
||||
let yMax = hit._source.bbox_ymax;
|
||||
var bbox: LatLngBoundsExpression = [
|
||||
response.data.hits.hits.forEach((hit: OpenSearchHit) => {
|
||||
const xMin = hit._source.bbox_xmin;
|
||||
const xMax = hit._source.bbox_xmax;
|
||||
const yMin = hit._source.bbox_ymin;
|
||||
const yMax = hit._source.bbox_ymax;
|
||||
const bbox: LatLngBoundsExpression = [
|
||||
[yMin, xMin],
|
||||
[yMax, xMax],
|
||||
];
|
||||
|
||||
let rect = new Rectangle(bbox, {
|
||||
const rect = new Rectangle(bbox, {
|
||||
color: '#65DC21',
|
||||
weight: 2,
|
||||
fillColor: '#65DC21',
|
||||
|
|
@ -193,7 +248,14 @@ const handleDrawEventCreated = async (event) => {
|
|||
items.value.push(hit._source);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (axios.isAxiosError(error) && error.response?.status === 400) {
|
||||
// Mapping in diesem Index unterstützt keine geo_shape-Suche
|
||||
console.warn('Geo search unavailable for this index mapping');
|
||||
// optional: ein dezentes Hinweis-Flag setzen, z. B. searchUnavailable.value = true
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
items.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
|
@ -202,74 +264,177 @@ const handleDrawEventCreated = async (event) => {
|
|||
|
||||
<template>
|
||||
<SectionMain>
|
||||
<div class="map-container-wrapper">
|
||||
<!-- Loading Overlay -->
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">Searching datasets...</p>
|
||||
<div class="map-shell">
|
||||
<!-- Header -->
|
||||
<div class="map-header">
|
||||
<div class="map-header-title">
|
||||
<span class="map-dot"></span>
|
||||
<h2>Geospatial Discovery</h2>
|
||||
</div>
|
||||
<transition name="badge">
|
||||
<div v-if="hasSearched && !isLoading" class="result-badge" :class="{ 'is-empty': resultCount === 0 }">
|
||||
{{ resultCount }} {{ resultCount === 1 ? 'dataset' : 'datasets' }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Map Instructions Banner -->
|
||||
<div class="map-instructions">
|
||||
<svg class="instruction-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
<p class="instruction-text">
|
||||
<strong>Tip:</strong> Use the drawing tool to select an area on the map and discover datasets
|
||||
</p>
|
||||
</div>
|
||||
<div class="map-container-wrapper">
|
||||
<!-- Loading Overlay -->
|
||||
<transition name="fade">
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">Searching datasets…</p>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div id="map" class="map-container">
|
||||
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
|
||||
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast">
|
||||
</DrawControlComponent>
|
||||
<!-- Floating instruction chip -->
|
||||
<div class="map-instructions">
|
||||
<svg class="instruction-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
<p class="instruction-text"><strong>Tip:</strong> draw an area to discover datasets within it</p>
|
||||
</div>
|
||||
|
||||
<!-- Floating "no results" hint -->
|
||||
<transition name="fade">
|
||||
<div v-if="hasSearched && !isLoading && resultCount === 0" class="empty-hint">
|
||||
No datasets in the selected area — try a larger region.
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div id="map" class="map-container">
|
||||
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
|
||||
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast">
|
||||
</DrawControlComponent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-container-wrapper {
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
.map-shell {
|
||||
border-radius: 1.25rem;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
0 10px 30px -12px rgba(0, 0, 0, 0.25),
|
||||
0 4px 8px -4px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .map-shell {
|
||||
background: #1f2937;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.map-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.map-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.map-header-title h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.map-dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 9999px;
|
||||
background: #65dc21;
|
||||
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0.6);
|
||||
animation: pulseDot 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseDot {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 0.5rem rgba(101, 220, 33, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(101, 220, 33, 0.15);
|
||||
color: #a3f57c;
|
||||
border: 1px solid rgba(101, 220, 33, 0.4);
|
||||
}
|
||||
|
||||
.result-badge.is-empty {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: #cbd5e1;
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
/* Map Container */
|
||||
.map-container-wrapper {
|
||||
position: relative;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .map-container-wrapper {
|
||||
background: #1f2937;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
/* Map Instructions Banner */
|
||||
/* Floating instruction chip */
|
||||
.map-instructions {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.dark .map-instructions {
|
||||
background: linear-gradient(135deg, rgba(101, 220, 33, 0.2) 0%, rgba(53, 124, 6, 0.2) 100%);
|
||||
border-bottom-color: #374151;
|
||||
background: rgba(31, 41, 55, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.instruction-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
color: #65dc21;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #4b5563;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .instruction-text {
|
||||
|
|
@ -277,28 +442,53 @@ const handleDrawEventCreated = async (event) => {
|
|||
}
|
||||
|
||||
.instruction-text strong {
|
||||
color: #4d9e1a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dark .instruction-text strong {
|
||||
color: #65dc21;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Floating empty hint */
|
||||
.empty-hint {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 500;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.dark .empty-hint {
|
||||
background: rgba(31, 41, 55, 0.85);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.dark .loading-overlay {
|
||||
background: rgba(31, 41, 55, 0.95);
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
|
|
@ -325,6 +515,10 @@ const handleDrawEventCreated = async (event) => {
|
|||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #4d9e1a;
|
||||
}
|
||||
|
||||
.dark .loading-text {
|
||||
color: #65dc21;
|
||||
}
|
||||
|
||||
|
|
@ -369,15 +563,14 @@ const handleDrawEventCreated = async (event) => {
|
|||
|
||||
/* Control Enhancements */
|
||||
:deep(.leaflet-control) {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 0.65rem;
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.2);
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.leaflet-bar a) {
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
|
|
@ -402,34 +595,50 @@ const handleDrawEventCreated = async (event) => {
|
|||
|
||||
/* Popup Enhancements */
|
||||
:deep(.leaflet-popup-content-wrapper) {
|
||||
border-radius: 0.75rem;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 0.85rem;
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:deep(.leaflet-popup-tip) {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.badge-enter-active,
|
||||
.badge-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.badge-enter-from,
|
||||
.badge-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.map-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.map-instructions {
|
||||
padding: 0.75rem 1rem;
|
||||
.map-container,
|
||||
:deep(.leaflet-container) {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
font-size: 0.8125rem;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.map-container {
|
||||
height: 350px;
|
||||
.map-container,
|
||||
:deep(.leaflet-container) {
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const menuOpenLg = () => {
|
|||
layoutStore.isAsideLgActive = true;
|
||||
};
|
||||
const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||
return user.value.roles.some(role => roleNames.includes(role.name));
|
||||
return user.value.roles.some(role => roleNames.includes(role));
|
||||
};
|
||||
|
||||
// const logout = () => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
// import { MainService } from '@/Stores/main';
|
||||
import { StyleService } from '@/Stores/style.service';
|
||||
import { mdiTrashCan } from '@mdi/js';
|
||||
// import CardBoxModal from '@/Components/CardBoxModal.vue';
|
||||
// import TableCheckboxCell from '@/Components/TableCheckboxCell.vue';
|
||||
import BaseLevel from '@/Components/BaseLevel.vue';
|
||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||
import BaseButton from '@/Components/BaseButton.vue';
|
||||
import { Subject } from '@/Dataset';
|
||||
// import FormField from '@/Components/FormField.vue';
|
||||
import FormControl from '@/Components/FormControl.vue';
|
||||
import SearchCategoryAutocomplete from '@/Components/SearchCategoryAutocomplete.vue';
|
||||
import { mdiRefresh } from '@mdi/js';
|
||||
|
|
@ -47,14 +43,10 @@ const deletetSubjects = computed({
|
|||
});
|
||||
|
||||
const styleService = StyleService();
|
||||
// const mainService = MainService();
|
||||
const items = computed(() => props.keywords);
|
||||
|
||||
// const isModalActive = ref(false);
|
||||
// const isModalDangerActive = ref(false);
|
||||
const perPage = ref(5);
|
||||
const currentPage = ref(0);
|
||||
// const checkedRows = ref([]);
|
||||
|
||||
const itemsPaginated = computed(() => {
|
||||
return items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1));
|
||||
|
|
@ -75,7 +67,6 @@ const pagesList = computed(() => {
|
|||
});
|
||||
|
||||
const removeItem = (key: number) => {
|
||||
// items.value.splice(key, 1);
|
||||
const item = items.value[key];
|
||||
|
||||
// If the item has an ID, add it to the delete list
|
||||
|
|
@ -95,7 +86,6 @@ const addToDeleteList = (subject: Subject) => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
// Helper function to reactivate a subject (remove from delete list)
|
||||
const reactivateSubject = (index: number) => {
|
||||
const newList = [...props.subjectsToDelete];
|
||||
|
|
@ -111,22 +101,18 @@ const reactivateSubject = (index: number) => {
|
|||
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>
|
||||
|
||||
<!-- <div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800">
|
||||
<span v-for="checkedRow in checkedRows" :key="checkedRow.id"
|
||||
class="inline-block px-2 py-1 rounded-sm mr-2 text-sm bg-gray-100 dark:bg-slate-700">
|
||||
{{ checkedRow.name }}
|
||||
</span>
|
||||
</div> -->
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- <th v-if="checkable" /> -->
|
||||
<!-- <th class="hidden lg:table-cell"></th> -->
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col" class="relative">
|
||||
Value
|
||||
|
|
@ -150,14 +136,14 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in itemsPaginated" :key="index">
|
||||
<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`]">
|
||||
{{ errors[`subjects.${index}.type`].join(', ') }}
|
||||
{{ formatError(errors[`subjects.${index}.type`]) }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
|
|
@ -170,14 +156,14 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
}
|
||||
" :is-read-only="item.dataset_count > 1">
|
||||
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]">
|
||||
{{ errors[`subjects.${index}.value`].join(', ') }}
|
||||
{{ 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`]">
|
||||
{{ errors[`subjects.${index}.value`].join(', ') }}
|
||||
{{ formatError(errors[`subjects.${index}.value`]) }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
|
|
@ -186,7 +172,7 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
<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`]">
|
||||
{{ errors[`subjects.${index}.language`].join(', ') }}
|
||||
{{ formatError(errors[`subjects.${index}.language`]) }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
|
|
@ -199,7 +185,6 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
|
||||
<td class="before:hidden lg:w-1 whitespace-nowrap" scope="row">
|
||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
|
||||
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeItem(index)" />
|
||||
</BaseButtons>
|
||||
</td>
|
||||
|
|
@ -207,7 +192,6 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- :class="[ pagesList.length > 1 ? 'block' : 'hidden']" -->
|
||||
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
|
||||
<BaseLevel>
|
||||
<BaseButtons>
|
||||
|
|
@ -218,8 +202,9 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
</BaseLevel>
|
||||
</div>
|
||||
|
||||
<div class="text-red-400 text-sm" v-if="errors.subjects && Array.isArray(errors.subjects)">
|
||||
{{ errors.subjects.join(', ') }}
|
||||
<!-- 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 -->
|
||||
|
|
@ -261,7 +246,7 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -269,7 +254,7 @@ const isKeywordReadOnly = (item: Subject) => {
|
|||
background: gray;
|
||||
}
|
||||
|
||||
tr:nth-child(od) {
|
||||
tr:nth-child(od) {
|
||||
background: white;
|
||||
} */
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue