ldc-legaulois/resources/ts/bo/components/ReorderElementsFromMorphToManyComponent.vue

447 lines
15 KiB
Vue

<template>
<div
ref="reorderElementsFromMorphToMany"
class="reorder-elements-from-morph-to-many-component"
>
<div class="row justify-content-start">
<!-- ITEMS LIST -->
<div class="col-12 col-xl-6 mb-3 mb-xl-0">
<!-- SEARCH INPUT -->
<label class="col-form-label">
<b>{{ titleList }} *</b>
</label>
<Transition name="fade">
<div v-if="errorMessage.length">
<span
class="d-block text-danger mb-2"
>
{{ errorMessage }}
</span>
</div>
</Transition>
<div class="position-relative">
<!-- LOADING SCREEN -->
<div
v-if="loading"
class="position-absolute top-0 start-0 w-100 h-100 bg-dark bg-opacity-25 rounded-3 z-1"
>
<div class="position-absolute top-50 start-50 translate-middle">
<div
class="spinner-border text-white"
role="status"
>
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<!-- SELECTED ITEMS -->
<div class="selected-items-search d-flex flex-wrap">
<div
v-for="(item, itemId) in itemsParameters.selected"
:key="itemId"
class="d-flex justify-content-center align-items-center bg-light w-fit border rounded-1 p-1 mt-1 me-1"
>
<span class="text-bg-light">{{ item.label }}</span>
<button
:class="['btn btn-sm btn-danger ms-1', (itemsParameters.selected.length <= itemsParameters.selectedLimit[0]) ? 'disabled': '']"
:aria-label="__('Dissocier ce produit à la recette')"
:title="__('Dissocier ce produit à la recette')"
data-bs-toggle="tooltip"
type="button"
@click.prevent="removeElementFromSelectedList(Number(item.id))"
>
<FontAwesomeIcon icon="fa-solid fa-trash" />
</button>
</div>
</div>
<!-- INPUT SEARCH -->
<div :class="['d-flex flex-wrap border rounded-3 my-1', getErrorMessage() !== null ? 'border-danger': '']">
<div :class="['d-flex justify-content-center align-items-center w-100', (reorderElementsFromMorphToManySearch?.value.length ? 'pe-1': '')]">
<input
ref="reorderElementsFromMorphToManySearch"
type="text"
placeholder="Saisissez ici vos mots clés..."
:class="['form-control border-0 w-100', getErrorMessage() !== null ? 'is-invalid': '']"
@input.prevent="searchItem()"
:disabled="loading"
>
<button
v-if="reorderElementsFromMorphToManySearch?.value.length"
class="btn btn-sm btn-primary"
:title="__('Effacer la recherche')"
data-bs-toggle="tooltip"
:aria-label="__('Effacer la recherche')"
type="button"
@click.prevent="resetSearchValue()"
>
<FontAwesomeIcon icon="fa-solid fa-xmark" />
</button>
</div>
</div>
<!-- ITEMS LIST -->
<div class="items-list-wrapper border rounded-3">
<template v-if="itemsParameters.listFiltered.length">
<template
v-for="(item, itemId) in itemsParameters.listFiltered"
:key="itemId"
>
<div
v-if="!checkItemSelected(Number(item.id))"
:class="[
'd-flex justify-content-between align-items-center px-2 py-1',
(itemId !== itemsParameters.listFiltered.length - 1 ? 'border-bottom' : ''),
((checkItemSelected(Number(item.id)) && itemsParameters.selected.length <= itemsParameters.selectedLimit[0]) || (!checkItemSelected(Number(item.id)) && itemsParameters.selected.length >= itemsParameters.selectedLimit[1]) ? 'bg-light': ''),
]"
>
<div class="col-11">
<p class="m-0">
{{ item.label }}
</p>
</div>
<div class="col-1 text-end">
<button
:class="['btn btn-sm btn-primary', (itemsParameters.selected.length >= itemsParameters.selectedLimit[1]) ? 'disabled': '']"
:aria-label="__('Associer ce produit à la recette')"
:title="__('Associer ce produit à la recette')"
data-bs-toggle="tooltip"
type="button"
@click.prevent="addElementFromSelectedList(item)"
>
<FontAwesomeIcon icon="fa-solid fa-plus" />
</button>
</div>
</div>
</template>
</template>
<div
v-else
class="d-flex flex-column justify-content-center align-items-center h-100"
>
<span class="search-result-icon">
<FontAwesomeIcon
icon="fa-solid fa-triangle-exclamation fa-2xl"
class="w-100 h-100"
/>
</span>
<p class="mt-1 mb-2">
{{ __('Aucun produit ne correspond à votre recherche') }}
</p>
<button
class="btn btn-sm btn-primary"
:title="__('Effacer la recherche')"
data-bs-toggle="tooltip"
:aria-label="__('Effacer la recherche')"
type="button"
@click.prevent="resetSearchValue()"
>
{{ __('Réinitialiser la recherche') }}
</button>
</div>
</div>
</div>
<div class="w-100">
<small class="text-danger m-0">
{{ getErrorMessage() }}
</small>
</div>
<div class="w-100">
<small class="form-text text-body-secondary">
{{ helper }}
</small>
</div>
</div>
<!-- REORDER SELECTED ITEMS -->
<div class="col-12 col-xl-6">
<label class="col-form-label">
<b>{{ __('Réorganisez les éléments en les déplaçant.') }}</b>
</label>
<ReorderElements :nodelist="itemsParameters.selected" />
</div>
<!-- INPUTS FOR SUBMISSION -->
<input
v-for="(item, itemIndex) in itemsParameters.selected"
:key="itemIndex"
type="hidden"
:name="name + '[]'"
:value="item.id"
>
</div>
</div>
</template>
<script lang="ts" setup>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { __ } from "@modules/trans";
import { nextTick, onMounted, reactive, ref, useAttrs } from "vue";
import axios from "~/axios";
import parseLaravelErrorMessages from "../../modules/parseLaravelErrorMessages";
import { Tooltips } from "./../../modules/bsTooltips";
import route from "./../../modules/route";
import ReorderElements from "./ReorderElements.vue";
defineOptions({
name: "ReorderElementsFromMorphToManyComponent",
inheritAttrs: true,
});
// * ATTRIBUTES
const attrs = useAttrs();
// * REFS
const reorderElementsFromMorphToMany = ref<HTMLDivElement|null>(null);
const reorderElementsFromMorphToManySearch = ref<HTMLInputElement|null>(null);
// * DATA
const loading = ref<boolean>(false);
const name = ref<string>("");
const fieldName = ref<string>("");
const titleList = ref<string>("");
const helper = ref<string>("");
const errorMessage = ref<string>("");
const allErrors = ref<LaravelValidationErrors>({});
const tooltips = ref<Tooltips|null>(null);
/** Select parameters. */
const itemsParameters = reactive<{
selected: LaravelModelList,
selectedLimit: number[],
list: LaravelModelList,
listFiltered: LaravelModelList,
}>({
selected: [],
selectedLimit: [1,5],
list: [],
listFiltered: [],
});
/** Search parameters. */
const searchParameters = reactive<{
targetClass: string,
routeName: string,
}>({
targetClass: "",
routeName: "",
});
/** Page parameters. */
const pageParameters = reactive<{
page: number,
totalPage: number,
itemPerPage: number,
hasNextPage: boolean,
hasPrevPage: boolean,
}>({
page: 1,
totalPage: 1,
itemPerPage: 1,
hasPrevPage: false,
hasNextPage: false,
});
// * LIFECYCLE
onMounted(() => {
const json = String(attrs.json ?? "{}"),
data = JSON.parse(json);
/** Items parameters. */
itemsParameters.selected = data.itemsSelected;
itemsParameters.selectedLimit = data.itemsSelectedLimit;
itemsParameters.list = data.itemsList.data;
itemsParameters.listFiltered = itemsParameters.list;
/** Search parameters. */
searchParameters.targetClass = String(data.targetClass);
searchParameters.routeName = String(data.routeName);
/** Page parameters. */
pageParameters.page = data.itemsList.current_page;
pageParameters.totalPage = data.itemsList.last_page;
pageParameters.itemPerPage = data.itemsList.per_page;
pageParameters.hasPrevPage = false;
pageParameters.hasNextPage = false;
/** Others parameters. */
name.value = data.name;
fieldName.value = data.fieldName;
titleList.value = data.titleList;
helper.value = data.helper;
allErrors.value = data.errors;
initTooltips();
setScrollPagination();
});
/** Set the infinite pagination. */
function setScrollPagination(): void {
document.querySelector(".items-list-wrapper")?.addEventListener("scroll", (event) => {
const { scrollHeight, scrollTop, clientHeight } = event.target as HTMLDivElement;
if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1 && pageParameters.page < pageParameters.totalPage) {
pageParameters.page++;
loading.value = true;
window.axios
.get(getJsonRouteUrl())
.then((response) => {
itemsParameters.listFiltered = itemsParameters.listFiltered.concat(response.data.data);
pageParameters.totalPage = response.data.last_page;
pageParameters.page = response.data.current_page;
})
.then(() => { loading.value = false; })
.catch(ajaxErrorHandler);
}
});
}
/** Get items from a specific page. */
function searchItem(): void {
loading.value = true;
pageParameters.page = 1;
document.querySelector(".items-list-wrapper")!.scrollTop = 0;
window.axios
.get(getJsonRouteUrl())
.then((response) => {
itemsParameters.listFiltered = response.data.data;
pageParameters.totalPage = response.data.last_page;
pageParameters.page = response.data.current_page;
})
.then(() => {
loading.value = false;
nextTick(() => {
reorderElementsFromMorphToManySearch.value!.focus();
});
})
.catch(ajaxErrorHandler);
}
/**
* Return error message after the ajax call.
* @param e any
* @return void
*/
function ajaxErrorHandler(e: unknown): void {
let message = __("Une erreur est survenue");
if (axios.isAxiosError<LaravelValidationErrorResponse>(e)) {
if (e.response) {
if (e.response.status === 422) {
message = parseLaravelErrorMessages.methods.parseValidationErrors(
e.response?.data?.errors ?? {}
) || message;
if (window.vueDebug) {
console.warn(e.response.data.errors);
}
} else if (e.response.status === 419) {
console.log(__("Votre session a expiré, la page va être rechargée"));
setTimeout(() => window.location.reload(), 2000);
} else if (e.response.status === 403) {
console.log(__("Vous n'êtes pas autorisé a effectuer cette action"));
setTimeout(() => window.location.reload(), 2000);
} else {
if (window.vueDebug) {
console.error(e);
}
}
} else if (e.request) {
console.error("Aucune réponse reçue :", e.request);
} else {
console.error("Erreur de configuration de la requête :", e.message);
}
}
console.log(message);
}
/** Return json route to get paginate models. */
function getJsonRouteUrl(): string {
const routeUrl = route(searchParameters.routeName);
if (!routeUrl) {
throw new Error("Undefined route " + searchParameters.routeName);
}
return routeUrl + "?targetModel=" + searchParameters.targetClass +
"&page=" + pageParameters.page +
"&paginate=" + pageParameters.itemPerPage +
"&fieldName=" + fieldName.value +
((reorderElementsFromMorphToManySearch.value?.value.length) ? "&search=" + reorderElementsFromMorphToManySearch.value?.value : "");
}
/** Reset the textual search value. */
function resetSearchValue(): void {
reorderElementsFromMorphToManySearch.value!.value = "";
tooltips.value?.closeBootstrapTooltip();
searchItem();
}
/** Add specific element from the selected list. */
function addElementFromSelectedList(item: LaravelModel): void {
if (itemsParameters.selected.length >= itemsParameters.selectedLimit[1]) {
errorMessage.value = "Limite maximale atteinte, vous ne pouvez pas ajouter d'éléments supplémentaires";
setTimeout(() => errorMessage.value = "", 5000);
throw new Error("Maximum limit reached, you cannot add an additional element.");
}
itemsParameters.selected = itemsParameters.selected.concat(item);
tooltips.value?.closeBootstrapTooltip();
initTooltips();
}
/** Remove specific element from the selected list. */
function removeElementFromSelectedList(id: number): void {
if (itemsParameters.selected.length <= itemsParameters.selectedLimit[0]) {
errorMessage.value = "Limite minimale atteinte, vous ne pouvez pas supprimer d'éléments supplémentaires";
setTimeout(() => errorMessage.value = "", 5000);
throw new Error("Minimum limit reached, you cannot delete an additional element");
}
itemsParameters.selected = itemsParameters.selected.filter((item) => {
return item.id !== id;
});
tooltips.value?.closeBootstrapTooltip();
initTooltips();
}
/** Check if an id exist in selected items list. */
function checkItemSelected(id: number): boolean {
return Boolean(itemsParameters.selected.find((item) => {
return item.id === id;
}));
}
/** Return error message or null. */
function getErrorMessage(): string|null {
return parseLaravelErrorMessages.methods.parseValidationInput(
`${name.value}`,
allErrors.value
);
}
/** Initialise all tooltips in the component. */
function initTooltips(): void {
setTimeout(() => {
tooltips.value = Tooltips.make({
type: "dom",
elements: reorderElementsFromMorphToMany.value!.querySelectorAll("[data-bs-toggle=\"tooltip\"]")
});
}, 500);
}
</script>
<style lang="scss" scoped>
.reorder-elements-from-morph-to-many-component {
.spinner-border {
width: 3rem;
height: 3rem;
}
.items-list-wrapper {
height: 20rem;
overflow: auto;
}
.search-result-icon {
width: 3rem;
height: 3rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.reorder-elements {
.handle {
cursor: grab;
}
}
}
</style>