- Renamed "Collections" to "Classify" in dataset category views for submitters and editors to better reflect the page's purpose. - Modified the `SectionTitleLineWithButton` component to conditionally render the cog button based on the `showCogButton` prop. - Updated the Dataset Edit and Create views to use `textarea` instead of `text` for title and description input fields, allowing for multi-line text. - Added authorization checks for dataset review and reject actions in the `Reviewer/DatasetController`, and passed the `can` object to the `Review` view. - Added a "Reject" button to the dataset review page, visible only to users with the `dataset-review-reject` permission and when the dataset is in the 'approved' state. - Improved the display of dataset information in index views by adding dark mode styling to table headers. - Removed unused code and comments from the Dashboard.vue file. - Removed the `show-header-icon` property from the CardBox component in the Create.vue file. - Updated dependencies
371 lines
17 KiB
Vue
371 lines
17 KiB
Vue
<template>
|
|
<LayoutAuthenticated>
|
|
<Head title="Classify"></Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton :icon="mdiLibraryShelves" title="Library Classification" main>
|
|
<div class="bg-lime-100 shadow rounded-lg p-6 mb-6 flex items-center justify-between">
|
|
<div>
|
|
<label for="role-select" class="block text-lg font-medium text-gray-700 mb-1">
|
|
Select Classification Role <span class="text-red-500">*</span>
|
|
</label>
|
|
<select id="role-select" v-model="selectedCollectionRole"
|
|
class="w-full border border-gray-300 rounded-md p-2 text-gray-700 focus:ring-2 focus:ring-indigo-500"
|
|
required>
|
|
<!-- <option value="" disabled selected>Please select a role</option> -->
|
|
<option v-for="collRole in collectionRoles" :key="collRole.id" :value="collRole">
|
|
{{ collRole.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="ml-4 hidden md:block">
|
|
<span class="text-sm text-gray-600 italic">* required</span>
|
|
</div>
|
|
</div>
|
|
</SectionTitleLineWithButton>
|
|
|
|
|
|
<!-- Available TopLevel Collections -->
|
|
<CardBox class="mb-4 rounded-lg p-4">
|
|
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Available Toplevel-Collections
|
|
<span v-if="selectedCollectionRole && !selectedToplevelCollection"
|
|
class="text-sm text-red-500 italic">(click to
|
|
select)</span>
|
|
</h2>
|
|
<ul class="flex flex-wrap gap-2">
|
|
<li v-for="col in collections" :key="col.id" :class="{
|
|
'cursor-pointer p-2 border border-gray-200 rounded hover:bg-sky-50 text-sky-700 text-sm': true,
|
|
'bg-sky-100 border-sky-500': selectedToplevelCollection && selectedToplevelCollection.id === col.id
|
|
}" @click="onToplevelCollectionSelected(col)">
|
|
{{ `${col.name} (${col.number})` }}
|
|
</li>
|
|
<li v-if="collections.length === 0" class="text-gray-800 dark:text-slate-400">
|
|
No collections available.
|
|
</li>
|
|
</ul>
|
|
</CardBox>
|
|
|
|
<!-- Collections Listing -->
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
|
|
|
<!-- Broader Collection (Parent) -->
|
|
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
|
|
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Broader Collection</h2>
|
|
<draggable v-if="broaderCollections.length > 0" v-model="broaderCollections"
|
|
:group="{ name: 'collections' }" tag="ul" class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
|
<template #item="{ element: parent }">
|
|
<li :key="parent.id" :draggable="!parent.inUse" :class="getChildClasses(parent)"
|
|
@click="onCollectionSelected(parent)">
|
|
{{ `${parent.name} (${parent.number})` }}
|
|
</li>
|
|
</template>
|
|
</draggable>
|
|
<ul v-else class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
|
<li class="text-gray-500 text-sm">
|
|
No broader collections available.
|
|
</li>
|
|
</ul>
|
|
</CardBox>
|
|
|
|
<!-- Selected Collection Details -->
|
|
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
|
|
<h3 class="text-xl font-bold text-gray-800 dark:text-slate-400 mb-2">Selected Collection</h3>
|
|
<!-- <p :class="[
|
|
'cursor-pointer p-2 border border-gray-200 rounded text-sm',
|
|
selectedCollection.inUse ? 'bg-gray-200 text-gray-500 drag-none' : 'bg-green-50 text-green-700 hover:bg-green-100 hover:underline cursor-move'
|
|
]"></p> -->
|
|
<draggable v-model="selectedCollectionArray" :group="{ name: 'collections', pull: 'clone', put: false }" tag="ul" class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
|
<template #item="{ element }">
|
|
<li :key="element.id" :class="[
|
|
'p-2 border border-gray-200 rounded text-sm',
|
|
element.inUse ? 'bg-gray-200 text-gray-500 drag-none' : 'bg-green-50 text-green-700 hover:bg-green-100 hover:underline cursor-move'
|
|
]">
|
|
{{ `${element.name} (${element.number})` }}
|
|
</li>
|
|
</template>
|
|
</draggable>
|
|
</CardBox>
|
|
<!-- Narrower Collections (Children) -->
|
|
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
|
|
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Narrower Collections</h2>
|
|
<draggable v-if="narrowerCollections.length > 0" v-model="narrowerCollections"
|
|
:group="{ name: 'collections' }" tag="ul" class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
|
<template #item="{ element: child }">
|
|
<li :key="child.id" :draggable="!child.inUse" :class="getChildClasses(child)"
|
|
@click="onCollectionSelected(child)">
|
|
{{ `${child.name} (${child.number})` }}
|
|
</li>
|
|
</template>
|
|
</draggable>
|
|
<ul v-else class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
|
<li class="text-gray-500 text-sm">
|
|
No sub-collections available.
|
|
</li>
|
|
</ul>
|
|
</CardBox>
|
|
|
|
</div>
|
|
|
|
<div class="mb-4 rounded-lg">
|
|
<div v-if="selectedCollection || selectedCollectionList.length > 0" class="bg-gray-100 shadow rounded-lg p-6 mb-6" :class="{ 'opacity-50': selectedCollection && selectedCollectionList.length === 0 }">
|
|
<p class="mb-4 text-gray-700">Please drag your collections here to classify your previously created
|
|
dataset
|
|
according to library classification standards.</p>
|
|
<draggable v-model="selectedCollectionList" :group="{ name: 'collections' }"
|
|
class="min-h-36 border-dashed border-2 border-gray-400 p-4 text-sm flex flex-wrap gap-2 max-h-60 overflow-y-auto"
|
|
tag="ul"
|
|
:disabled="selectedCollection === null && selectedCollectionList.length > 0"
|
|
:style="{ opacity: (selectedCollection === null && selectedCollectionList.length > 0) ? 0.5 : 1, pointerEvents: (selectedCollection === null && selectedCollectionList.length > 0) ? 'none' : 'auto' }">
|
|
<template #item="{ element }">
|
|
<div :key="element.id"
|
|
class="p-2 m-1 bg-sky-200 text-sky-800 rounded flex items-center gap-2 h-7">
|
|
<span>{{ element.name }} ({{ element.number }})</span>
|
|
<button
|
|
@click="selectedCollectionList = selectedCollectionList.filter(item => item.id !== element.id)"
|
|
class="hover:text-sky-600 flex items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20"
|
|
fill="currentColor">
|
|
<path fill-rule="evenodd"
|
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</draggable>
|
|
</div>
|
|
</div>
|
|
<div class="p-6 border-t border-gray-100 dark:border-slate-800">
|
|
<BaseButtons>
|
|
<BaseButton @click.stop="syncDatasetCollections" label="Save" color="info" small
|
|
:disabled="isSaveDisabled" :style="{ opacity: isSaveDisabled ? 0.5 : 1 }">
|
|
</BaseButton>
|
|
</BaseButtons>
|
|
</div>
|
|
</SectionMain>
|
|
</LayoutAuthenticated>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, Ref, watch, computed } from 'vue';
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
|
import SectionMain from '@/Components/SectionMain.vue';
|
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
|
import axios from 'axios';
|
|
import { mdiLibraryShelves } from '@mdi/js';
|
|
import draggable from 'vuedraggable';
|
|
import BaseButton from '@/Components/BaseButton.vue';
|
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
|
import CardBox from '@/Components/CardBox.vue';
|
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
|
import { CollectionRole, Collection } from '@/types/models';
|
|
|
|
// import CollectionRoleSelector from '@/Components/Collection/CollectionRoleSelector.vue';
|
|
// import ToplevelCollections from '@/Components/Collection/ToplevelCollections.vue';
|
|
// import CollectionHierarchy from '@/Components/Collection/CollectionHierarchy.vue';
|
|
// import CollectionDropZone from '@/Components/Collection/CollectionDropZone.vue';
|
|
|
|
|
|
|
|
/* --------------------------------------------------------------------------
|
|
Props & Reactive State
|
|
-------------------------------------------------------------------------- */
|
|
const props = defineProps({
|
|
collectionRoles: {
|
|
type: Array,
|
|
required: true,
|
|
default: () => []
|
|
},
|
|
dataset: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
relatedCollections: {
|
|
type: Array as () => Collection[],
|
|
default: () => [] as const
|
|
}
|
|
});
|
|
|
|
const collectionRoles: Ref<CollectionRole[]> = ref(props.collectionRoles as CollectionRole[]);
|
|
const collections: Ref<Collection[]> = ref<Collection[]>([]);
|
|
const selectedCollectionRole = ref<CollectionRole | null>(null);
|
|
const selectedToplevelCollection = ref<Collection | null>(null);
|
|
const selectedCollection = ref<Collection | null>(null);
|
|
const narrowerCollections = ref<Collection[]>([]);
|
|
const broaderCollections = ref<Collection[]>([]);
|
|
// Reactive list that holds collections dropped by the user
|
|
const selectedCollectionList: Ref<Collection[]> = ref<Collection[]>([]);
|
|
|
|
|
|
// Wrap selectedCollection in an array for draggable (always expects an array)
|
|
const selectedCollectionArray = computed({
|
|
get: () => (selectedCollection.value ? [selectedCollection.value] : []),
|
|
set: (value: Collection[]) => {
|
|
selectedCollection.value = value.length ? value[0] : null
|
|
}
|
|
})
|
|
|
|
|
|
const form = useForm({
|
|
collections: [] as number[],
|
|
});
|
|
|
|
// Watch for changes in dropCollections
|
|
watch(
|
|
() => selectedCollectionList.value,
|
|
() => {
|
|
if (selectedCollection.value) {
|
|
fetchCollections(selectedCollection.value.id);
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
|
|
/* --------------------------------------------------------------------------
|
|
Watchers and Initial Setup
|
|
-------------------------------------------------------------------------- */
|
|
// If the collectionRoles prop might load asynchronously (or change), you can watch for it:
|
|
watch(
|
|
() => props.collectionRoles as CollectionRole[],
|
|
(newCollectionRoles: CollectionRole[]) => {
|
|
collectionRoles.value = newCollectionRoles;
|
|
// Preselect the role with name "ccs" if it exists
|
|
const found: CollectionRole | undefined = collectionRoles.value.find(
|
|
role => role.name.toLowerCase() === 'ccs'
|
|
);
|
|
if (found?.name === 'ccs') {
|
|
selectedCollectionRole.value = found;
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// When collection role changes, update available collections and clear dependent state.
|
|
watch(
|
|
() => selectedCollectionRole.value as CollectionRole,
|
|
(newSelectedCollectionRole: CollectionRole | null) => {
|
|
if (newSelectedCollectionRole != null) {
|
|
collections.value = newSelectedCollectionRole.collections || []
|
|
} else {
|
|
selectedToplevelCollection.value = null;
|
|
selectedCollection.value = null;
|
|
collections.value = []
|
|
}
|
|
// Reset dependent variables when the role changes
|
|
selectedCollection.value = null
|
|
narrowerCollections.value = []
|
|
broaderCollections.value = []
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
/* --------------------------------------------------------------------------
|
|
Methods
|
|
-------------------------------------------------------------------------- */
|
|
const onToplevelCollectionSelected = (collection: Collection) => {
|
|
selectedToplevelCollection.value = collection;
|
|
selectedCollection.value = collection;
|
|
// call the API endpoint to get both.
|
|
fetchCollections(collection.id);
|
|
};
|
|
|
|
const onCollectionSelected = (collection: Collection) => {
|
|
selectedCollection.value = collection;
|
|
// call the API endpoint to get both.
|
|
fetchCollections(collection.id);
|
|
};
|
|
|
|
/**
|
|
* fetchCollections: Retrieves broader and narrower collections.
|
|
* Marks any narrower collection as inUse if it appears in selectedCollectionList.
|
|
*/
|
|
const fetchCollections = async (collectionId: number) => {
|
|
try {
|
|
const response = await axios.get(`/api/collections/${collectionId}`);
|
|
const data = response.data;
|
|
// Map each returned narrower collection
|
|
narrowerCollections.value = data.narrowerCollections.map((collection: Collection) => {
|
|
// If found, mark it as inUse.
|
|
const alreadyDropped = selectedCollectionList.value.find(dc => dc.id === collection.id);
|
|
return alreadyDropped ? { ...collection, inUse: true } : { ...collection, inUse: false };
|
|
});
|
|
broaderCollections.value = data.broaderCollection.map((collection: Collection) => {
|
|
const alreadyDropped = selectedCollectionList.value.find(dc => dc.id === collection.id);
|
|
return alreadyDropped ? { ...collection, inUse: true } : { ...collection, inUse: false };
|
|
});
|
|
// Check if selected collection is in the selected list
|
|
if (selectedCollection.value && selectedCollectionList.value.find(dc => dc.id === selectedCollection.value?.id)) {
|
|
selectedCollection.value = { ...selectedCollection.value, inUse: true };
|
|
} else if (selectedCollection.value) {
|
|
selectedCollection.value = { ...selectedCollection.value, inUse: false };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in fetchCollections:', error);
|
|
}
|
|
};
|
|
|
|
const syncDatasetCollections = async () => {
|
|
// Extract the ids from the dropCollections list
|
|
form.collections = selectedCollectionList.value.map((item: Collection) => item.id);
|
|
form.put(stardust.route('dataset.categorizeUpdate', [props.dataset.id]), {
|
|
preserveState: true,
|
|
onSuccess: () => {
|
|
console.log('Dataset collections synced successfully');
|
|
},
|
|
onError: (errors) => {
|
|
console.error('Error syncing dataset collections:', errors);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* getChildClasses returns the Tailwind CSS classes to apply to each collection list item.
|
|
*/
|
|
const getChildClasses = (child: Collection) => {
|
|
return child.inUse
|
|
? 'p-2 border border-gray-200 rounded bg-gray-200 text-gray-500 cursor-pointer drag-none'
|
|
: 'p-2 border border-gray-200 rounded bg-green-50 text-green-700 cursor-move hover:bg-green-100 hover:underline'
|
|
}
|
|
|
|
// If there are related collections passed in, fill dropCollections with these.
|
|
if (props.relatedCollections && props.relatedCollections.length > 0) {
|
|
selectedCollectionList.value = props.relatedCollections;
|
|
}
|
|
|
|
// Add a computed property for the disabled state based on dropCollections length
|
|
const isSaveDisabled = computed(() => selectedCollectionList.value.length === 0);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.btn-primary {
|
|
background-color: #4f46e5;
|
|
color: white;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background-color: #4338ca;
|
|
}
|
|
|
|
.btn-primary:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #4f46e5;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background-color: white;
|
|
color: #374151;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background-color: #f9fafb;
|
|
}
|
|
|
|
.btn-secondary:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6366f1;
|
|
}
|
|
</style>
|