ldc-crm/resources/ts/bo/components/SearchBelongsToDropdownComponent.vue
2025-08-04 16:03:38 +02:00

504 lines
14 KiB
Vue

<template>
<div
ref="searchBelongsToDropdown"
class="position-relative"
>
<!-- VUE SELECT -->
<VueSelect
:id="id"
:loading="loading"
v-model="modelSelected"
:multiple="selectParameters.multiple"
:clearable="!selectParameters.required"
:label="selectParameters.fieldName"
@search="search"
:options="modelParameters.list"
:selectable="(option: VselectOption) =>
(modelSelected !== null)
? (Array.isArray(modelSelected))
? (selectParameters.limits !== null && modelSelected.length >= selectParameters.limits[1])
? false
: !modelSelected.includes(option)
: modelSelected.id !== option.id
: true"
:filterable="true"
:required="selectParameters.required"
:disabled="selectParameters.disabled"
:searchable="true"
:placeholder="selectParameters.placeholder"
:close-on-select="!selectParameters.multiple"
>
<template #no-options>
<p class="vs__no-options m-0">
{{ trans.methods.__('Aucun éléments trouvés') }}
</p>
</template>
<template #list-footer>
<ul
class="pagination d-flex p-2"
v-if="pageParameters.totalPage > 1"
>
<li class="page-item">
<button
type="button"
@click="updatePage(pageParameters.page - 1)"
:disabled="!pageParameters.hasPrevPage"
class="page-link"
:class="{'disabled': pageParameters.page === 1}"
>
<FontAwesomeIcon icon="fa-solid fa-chevron-left fa-2xs" />
</button>
</li>
<li
class="page-item"
v-for="(page, pageIndex) in pageParameters.totalPage"
:key="pageIndex"
>
<button
@click="updatePage(page)"
class="page-link btn rounded-0"
:class="{'bg-primary text-white border-primary': pageParameters.page === page}"
type="button"
>
{{ page }}
</button>
</li>
<li class="page-item">
<button
type="button"
@click="updatePage(pageParameters.page + 1)"
:disabled="!pageParameters.hasNextPage"
class="page-link"
:class="{'disabled': pageParameters.page === pageParameters.totalPage}"
>
<FontAwesomeIcon icon="fa-solid fa-chevron-right fa-2xs" />
</button>
</li>
</ul>
</template>
<template #open-indicator>
<span class="dropdown-toggle" />
</template>
<template #spinner>
<div
v-if="loading"
class="spinner-border text-secondary ms-1"
role="status"
>
<span class="visually-hidden">{{ trans.methods.__('Chargement...') }}</span>
</div>
</template>
</VueSelect>
<!-- OUTPUT HTML INPUT -->
<template
v-if="Array.isArray(modelSelected) && modelSelected !== null"
>
<input
v-for="(selectedId, selectedIndex) in modelSelected"
:key="selectedIndex"
:value="selectedId.id"
:name="name + '[][id]'"
type="hidden"
>
</template>
<input
v-else
:value="modelSelected?.id"
:name="name"
type="hidden"
>
<!-- MESSAGES -->
<Transition name="fade">
<p
v-show="message"
class="message text-danger mb-1"
>
{{ message }}
</p>
</Transition>
</div>
</template>
<script lang="ts" setup>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import type { PropType } from "vue";
import { computed, nextTick, onMounted, reactive, ref, useAttrs, watch } from "vue";
import VueSelect from "vue-select";
import trans from "./../../modules/trans";
import errors from "./../../modules/errors";
defineOptions({
name: "SearchBelongsToDropdown",
});
// * EMITS
const emits = defineEmits<{
"selected-value-changed": [VselectOption|VselectOption[]|null],
}>();
// * ATTRIBUTES
const attrs = useAttrs();
// * REFS
const searchBelongsToDropdown = ref<HTMLDivElement|null>(null);
// * PROPS
const props = defineProps({
id: {
type: String,
default: String(Math.pow(10, 16) / Math.random()),
},
name: {
type: String,
default: "",
},
multiple: {
type: Boolean,
default: false,
},
fieldname: {
type: String,
default: "name",
},
targetclass: {
type: String,
default: "",
},
modellistpaginate: {
type: Object as PropType<LaravelPaginator>,
default: () => { return []; },
},
modelselected: {
type: Object as PropType<VselectOption|VselectOption[]|null>,
default: () => { return []; },
},
limits: {
type: Object as PropType<Array<number>|null>,
default: null,
},
required: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
nodeclass: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "",
},
constructedroute: {
type: String,
default: "",
},
allErrors: {
type: Object as PropType<LaravelValidationErrors>,
default: () => { return []; },
},
});
// * DATA
const id = ref<string>(props.id);
const name = ref<string>(props.name);
const message = ref<string>("");
const loading = ref<boolean>(false);
const nodeClass = ref<string>(props.nodeclass);
const searchText = ref<string|null>(null);
const allErrors = ref<LaravelValidationErrors>(props.allErrors);
/** Model parameters. */
const modelParameters = reactive<{
targetClass: string,
list: LaravelModelList,
}>({
targetClass: props.targetclass,
list: props.modellistpaginate.data,
});
/** Exclude from modelParameters to emit an event on update */
const modelSelected = ref<VselectOption|VselectOption[]|null>(props.modelselected);
/** Select parameters. */
const selectParameters = reactive<{
limits: Array<number>|null,
multiple: boolean,
required: boolean,
disabled: boolean,
placeholder: string,
fieldName: string,
}>({
limits: props.limits,
multiple: props.multiple,
required: props.required,
disabled: props.disabled,
placeholder: props.placeholder,
fieldName: props.fieldname,
});
/** Page parameters. */
const pageParameters = reactive<{
page: number,
totalPage: number,
itemPerPage: number,
hasNextPage: boolean,
hasPrevPage: boolean,
constructedRoute: string,
}>({
page: props.modellistpaginate.current_page,
totalPage: props.modellistpaginate.last_page,
itemPerPage: props.modellistpaginate.per_page,
hasPrevPage: false,
hasNextPage: false,
constructedRoute: props.constructedroute,
});
const isUsedWithProps = computed<boolean>(() => attrs.json === undefined);
// * LIFECYCLE
onMounted(() => {
if (!isUsedWithProps.value) {
const json = String(attrs.json ?? "{}"),
data = JSON.parse(json);
/** Model parameters. */
modelParameters.targetClass = data.targetClass;
modelParameters.list = data.modelListPaginate.data;
nextTick(() => {
modelSelected.value = data.modelSelected;
});
/** Select parameters. */
selectParameters.limits = data.limits;
selectParameters.multiple = Boolean(data.multiple);
selectParameters.required = Boolean(data.required);
selectParameters.disabled = Boolean(data.disabled);
selectParameters.placeholder = String(data.placeholder);
selectParameters.fieldName = String(data.fieldName);
/** Page parameters. */
pageParameters.page = data.modelListPaginate.current_page;
pageParameters.totalPage = data.modelListPaginate.last_page;
pageParameters.itemPerPage = data.modelListPaginate.per_page;
pageParameters.hasPrevPage = false,
pageParameters.hasNextPage = false,
pageParameters.constructedRoute = String(data.constructedRoute);
/** Other parameters. */
id.value = String(data.id);
name.value = String(data.name);
allErrors.value = data.allErrors;
}
customNodes();
updateButtonStatus();
});
// * WATCHERS
watch(props, () => {
/** Model parameters. */
modelParameters.list = props.modellistpaginate.data;
modelSelected.value = props.modelselected;
/** Select parameters. */
selectParameters.disabled = props.disabled;
selectParameters.placeholder = props.placeholder;
/** Page parameters. */
pageParameters.page = props.modellistpaginate.current_page;
pageParameters.totalPage = props.modellistpaginate.last_page;
pageParameters.itemPerPage = props.modellistpaginate.per_page;
pageParameters.constructedRoute = props.constructedroute;
/** Need this to apply node class changes. */
nodeClass.value = props.nodeclass;
customNodes();
});
watch(modelSelected, () => {
emits("selected-value-changed", modelSelected.value);
if (
modelSelected.value !== null &&
selectParameters.limits !== null &&
Array.isArray(modelSelected.value)
) {
message.value = (modelSelected.value.length < selectParameters.limits[0]) ?
trans.methods.__(`Minimum ${selectParameters.limits[0]} requis`) :
"";
message.value = (modelSelected.value.length > selectParameters.limits[1]) ?
trans.methods.__(`Maxmimum ${selectParameters.limits[1]} requis`) :
"";
}
});
// * METHODS
/**
* Update the page and search items on it.
* @return void
*/
function updatePage(number: number): void {
pageParameters.page = number;
search();
}
/**
* Customize clear button.
* @return void
*/
function customNodes(): void {
const btnClear = searchBelongsToDropdown.value?.querySelector(".vs__clear") as HTMLButtonElement|null;
btnClear?.setAttribute("title", trans.methods.__("Supprimer la sélection"));
btnClear?.setAttribute("aria-label", trans.methods.__("Supprimer la sélection"));
btnClear?.setAttribute("data-bs-tooltip", "tooltip");
const inputSearchWrapper = searchBelongsToDropdown.value?.querySelector(".vs__dropdown-toggle") as HTMLInputElement|null;
inputSearchWrapper!.className = `vs__dropdown-toggle ${nodeClass.value}`;
if (getErrorMessage() !== null) {
inputSearchWrapper!.className = `vs__dropdown-toggle form-control is-invalid ${nodeClass.value}`;
}
}
/**
* Update pagination buttons status.
* @return void
*/
function updateButtonStatus(): void {
pageParameters.hasPrevPage = (pageParameters.page > 1) ? true : false;
pageParameters.hasNextPage = (pageParameters.page < pageParameters.totalPage) ? true : false;
}
/**
* Get items from a specific page and actualize pagination.
* @return void
*/
function search(search: string|false|null = null): void {
loading.value = true;
if (search !== false && search !== null) {
if (search !== searchText.value)
pageParameters.page = 1;
searchText.value = search;
}
window.axios
.get(getJsonRouteUrl())
.then((response) => {
modelParameters.list = response.data.data;
pageParameters.totalPage = response.data.last_page;
pageParameters.page = response.data.current_page;
pageParameters.itemPerPage = response.data.per_page;
updateButtonStatus();
})
.then(() => { loading.value = false; })
.catch(errors.methods.ajaxErrorHandler);
}
/**
* Return json route to get paginate models.
* @return string
*/
function getJsonRouteUrl(): string {
return pageParameters.constructedRoute + "?targetModel=" + modelParameters.targetClass +
"&page=" + pageParameters.page +
"&paginate=" + pageParameters.itemPerPage +
"&fieldName=" + selectParameters.fieldName +
((searchText.value !== null) ? "&search=" + searchText.value : "");
}
/** Return error message or null. */
function getErrorMessage(): string|null {
return errors.methods.parseValidationInput(
`${name.value}`,
allErrors.value
);
}
</script>
<style lang="scss" scopped>
@import "vue-select/dist/vue-select.css";
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/helpers";
@import "bootstrap/scss/close";
@import "bootstrap/scss/spinners";
@import "bootstrap/scss/badge";
:root {
--vs-dropdown-max-height: 30rem;
--vs-dropdown-option--active-bg: var(--bs-primary);
}
.vs--disabled .vs__dropdown-toggle, .vs--disabled .vs__clear, .vs--disabled .vs__search, .vs--disabled .vs__selected, .vs--disabled .vs__open-indicator {
background-color: var(--bs-secondary-bg) !important;
}
.vs__dropdown-toggle, .vs__dropdown-option, .vs__no-options {
padding: .375rem .75rem;
}
.vs__dropdown-toggle {
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: 0.375rem;
padding-top: 0 !important;
background-color: var(--bs-body-bg);
}
.vs__selected-options, .vs__search, .vs--single .vs__selected {
padding: 0 !important;
margin: 0 !important;
}
.vs__open-indicator {
display: none;
}
.vs__actions, .vs__dropdown-menu {
padding: 0;
}
.vs__actions, .vs__search, .vs--single .vs__selected {
margin: .375rem 0 0 0 !important;
}
.vs__dropdown-menu, .vs__search, .vs--single .vs__selected {
color: var(--bs-body-color);
}
.vs__search {
border: 0 !important;
}
.vs__dropdown-menu {
border-color: var(--bs-primary-border-subtle);
background-color: var(--bs-body-bg);
}
.vs__dropdown-toggle:has(.vs__search:focus) {
border-color: var(--bs-primary-border-subtle) !important;
outline: 0;
box-shadow: 0 0 0 .25rem rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity));
transition: .15s;
}
.vs__dropdown-option--selected, .vs__dropdown-option--disabled {
background-color: var(--bs-tertiary-bg);
color: var(--bs-secondary);
cursor: not-allowed;
}
.vs--multiple .vs__selected {
@extend .badge;
@extend .text-bg-secondary;
display: flex;
justify-content: center;
align-items: center;
font-size: 1em !important;
font-weight: initial;
margin: .375rem .5rem 0 0 !important;
padding: .19rem .25rem !important;
}
.vs__clear, .vs--multiple .vs__selected .vs__deselect {
@extend .btn-close;
padding: 0;
}
.vs__clear svg, .vs--multiple .vs__selected .vs__deselect svg {
display: none;
}
.spinner-border {
width: 1.25rem;
height: 1.25rem;
}
.vs__fade-enter-active,
.vs__fade-leave-active {
transition: none;
}
.vs--open .vs__dropdown-toggle {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
</style>