504 lines
14 KiB
Vue
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>
|