363 lines
12 KiB
Vue
363 lines
12 KiB
Vue
<template>
|
|
<fieldset
|
|
ref="mainMenu"
|
|
class="container-fluid main-menu-component"
|
|
:disabled="loading"
|
|
>
|
|
<div class="row justify-content-start">
|
|
<!-- ORDERABLE NODES -->
|
|
<div class="col-12 col-xl-4 p-0">
|
|
<fieldset
|
|
:disabled="nodeToEdit !== null"
|
|
:inert="nodeIndexToEdit !== null"
|
|
>
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<p class="fw-bold m-0">
|
|
{{ __('Ordonancement du menu') }}
|
|
<span
|
|
tabindex="0"
|
|
:title="__('Réorganisez le menu principal comme vous le souhaitez ou ajoutez un lien.')"
|
|
data-bs-toggle="tooltip"
|
|
><FontAwesomeIcon icon="fa-solid fa-circle-info fa-xs" /></span>
|
|
</p>
|
|
<button
|
|
class="btn btn-sm btn-primary float-end mt-1"
|
|
:title="__('Ajouter un lien au menu')"
|
|
data-bs-toggle="tooltip"
|
|
@click.prevent="addLinkNode"
|
|
>
|
|
<FontAwesomeIcon icon="fa fa-plus" />
|
|
</button>
|
|
</div>
|
|
<div class="list-group">
|
|
<Draggable
|
|
:list="nodeList"
|
|
item-key="id"
|
|
>
|
|
<!-- LIST ITEM -->
|
|
<template #item="{ element, index } : {element: MenuNodeInterface, index: number}">
|
|
<div :class="['list-group-item d-flex flux-column justify-content-between', {'bg-light border border-primary': index === nodeIndexToEdit}]">
|
|
<div class="d-flex justify-content-center align-items-center h6 m-0">
|
|
<span class="me-3">
|
|
<FontAwesomeIcon
|
|
icon="fa-solid fa-align-justify"
|
|
class="handle text-secondary"
|
|
/>
|
|
</span>
|
|
<span
|
|
v-if="element.published"
|
|
class="me-3"
|
|
:title="__('Ce noeud est publié')"
|
|
data-bs-toggle="tooltip"
|
|
>
|
|
<FontAwesomeIcon
|
|
icon="fa fa-circle-check"
|
|
class="text-success"
|
|
/>
|
|
</span>
|
|
{{ element.title }}
|
|
</div>
|
|
<!-- ACTIONS -->
|
|
<div class="btn-group">
|
|
<button
|
|
v-if="element.type !== MenuNodeTypeEnum.siteLogo"
|
|
class="btn btn-sm btn-primary"
|
|
@click.prevent="editNode(index)"
|
|
:title="__('Modifier le noeud')"
|
|
data-bs-toggle="tooltip"
|
|
>
|
|
<FontAwesomeIcon icon="fa fa-pencil" />
|
|
</button>
|
|
<button
|
|
v-if="element.type === MenuNodeTypeEnum.link"
|
|
class="btn btn-sm btn-danger"
|
|
@click.prevent="removeLinkNode(index)"
|
|
>
|
|
<FontAwesomeIcon icon="fa fa-trash" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Draggable>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
<div class="col-12 col-xl-8 main-menu-node-editor-component p-0 pt-3 ps-xl-3 pt-xl-0">
|
|
<div
|
|
v-if="nodeToEdit !== null"
|
|
class="border border-primary p-3"
|
|
>
|
|
<!-- NODE EDITOR -->
|
|
<MainMenuNodeEditor
|
|
v-if="nodeToEdit !== null"
|
|
:menu-node="nodeToEdit"
|
|
:enum="getEnum(nodeToEdit.type)"
|
|
@close-editor="closeEditor"
|
|
@updated-node="(menuNode) => nodeIndexToEdit !== null ? saveEditedNode(nodeIndexToEdit, menuNode) : null"
|
|
/>
|
|
<!-- NODE DATA EDITOR -->
|
|
<MainMenuNodeDataEditor
|
|
v-if="nodeDataToEdit !== null && nodeDataToEdit.type !== MenuNodeTypeEnum.link"
|
|
:menu-node-data="getNodeData(nodeDataToEdit.type)"
|
|
:errors="menuNodeDataErrors"
|
|
@updated-node-data="(prop, value) => nodeDataToEdit !== null ? saveMenuNodeData(nodeDataToEdit.type, prop, value) : null"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- INPUTS FOR SUBMISSION -->
|
|
<input
|
|
type="hidden"
|
|
name="jsonValue"
|
|
:value="JSON.stringify(nodeList)"
|
|
>
|
|
</div>
|
|
</fieldset>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
import { errorNotify } from "@modules/toast";
|
|
import { __ } from "@modules/trans";
|
|
import type { AxiosError } from "axios";
|
|
import axios, { type AxiosResponse } from "axios";
|
|
import { onMounted, reactive, ref, useAttrs } from "vue";
|
|
import Draggable from "vuedraggable";
|
|
import type { IMenuNodeDataInputInterface } from "../../interfaces/IMenuNodeDataInputInterface";
|
|
import route from "../../modules/route";
|
|
import { MenuNodeTypeEnum } from "../enums";
|
|
import { Tooltips } from "./../../modules/bsTooltips";
|
|
import type { MenuNodeInterface } from "./../../types/models/MenuNode";
|
|
import type { MenuNodeDataInterface } from "./../../types/models/MenuNodeData";
|
|
import MainMenuNodeDataEditor from "./MainMenuNodeDataEditor.vue";
|
|
import MainMenuNodeEditor from "./MainMenuNodeEditor.vue";
|
|
|
|
defineOptions({
|
|
name: "MainMenu",
|
|
inheritAttrs: true,
|
|
});
|
|
|
|
// * REFS
|
|
const mainMenu = ref<HTMLDivElement|null>(null);
|
|
|
|
// * ATTRIBUTES
|
|
const attrs = useAttrs();
|
|
|
|
// * DATA
|
|
const loading = ref<boolean>(false);
|
|
const nodeList = ref<Array<MenuNodeInterface>>([]);
|
|
const menuNodeDataList = ref<Array<MenuNodeDataInterface>>([]);
|
|
let nodeTypeEnumList = reactive<Map<MenuNodeTypeEnum,Enum>>(new Map<MenuNodeTypeEnum,Enum>());
|
|
/** If set not null then opens the node editor */
|
|
const nodeIndexToEdit = ref<number|null>(null);
|
|
const nodeToEdit = ref<MenuNodeInterface|null>(null);
|
|
const nodeDataToEdit = ref<MenuNodeDataInterface|null>(null);
|
|
let menuNodeDataErrors = reactive<LaravelValidationErrors>({});
|
|
const tooltips = ref<Tooltips|null>(null);
|
|
|
|
// * LIFECYCLE
|
|
onMounted(() => {
|
|
const json = String(attrs.json ?? "{}"),
|
|
data = JSON.parse(json);
|
|
nodeList.value = data.nodeList ?? [];
|
|
nodeTypeEnumList = data.nodeTypeEnumList ?? [];
|
|
fetchMenuNodeDataList().then((dataList) => {
|
|
menuNodeDataList.value = dataList;
|
|
initTooltips();
|
|
});
|
|
});
|
|
|
|
// * METHODS
|
|
|
|
/** Add a new link node to the menu */
|
|
function addLinkNode(): void {
|
|
const newNode = {
|
|
id: Math.max(...nodeList.value.map(o => o.id)) + 1,
|
|
type: MenuNodeTypeEnum.link,
|
|
title: __("Lien"),
|
|
published: false,
|
|
link: {
|
|
routeName: null,
|
|
url: "https://example.net"
|
|
}
|
|
};
|
|
nodeList.value.push(newNode);
|
|
}
|
|
|
|
/** Remove a specific link node by index */
|
|
function removeLinkNode(index: number): void {
|
|
nodeList.value.splice(index, 1);
|
|
}
|
|
|
|
/** Edit a specific link node by index */
|
|
function editNode(index: number): void {
|
|
nodeIndexToEdit.value = index;
|
|
nodeToEdit.value = nodeList.value[index];
|
|
nodeDataToEdit.value = menuNodeDataList.value.find((nodeData) => nodeData.type === nodeToEdit.value?.type) ?? null;
|
|
}
|
|
|
|
/** Save a specific link node by index */
|
|
function saveEditedNode(index: number, menuNode: MenuNodeInterface): void {
|
|
nodeList.value[index] = menuNode;
|
|
initTooltips();
|
|
}
|
|
|
|
/** Close the editor part */
|
|
function closeEditor(): void {
|
|
nodeIndexToEdit.value = null;
|
|
nodeToEdit.value = null;
|
|
nodeDataToEdit.value = null;
|
|
}
|
|
|
|
/** Refresh the editor part */
|
|
function refreshEditor(): void {
|
|
if (nodeIndexToEdit.value !== null) {
|
|
editNode(nodeIndexToEdit.value);
|
|
}
|
|
}
|
|
|
|
/** Get the node data */
|
|
function getNodeData(type: MenuNodeTypeEnum): MenuNodeDataInterface
|
|
{
|
|
const NodeData = menuNodeDataList.value.find((nodeData) => nodeData.type === type);
|
|
if(!NodeData) {
|
|
throw new Error(`menuNodeDataList does not contain value of type '${type}'`);
|
|
}
|
|
return NodeData;
|
|
}
|
|
|
|
async function fetchMenuNodeDataList(): Promise<Array<MenuNodeDataInterface>>
|
|
{
|
|
return new Promise((resolve, reject) => {
|
|
const menuNodeDataListRoute = route("bo.menu_node_data.index");
|
|
if(!menuNodeDataListRoute) {
|
|
throw new Error("Could not load route 'bo.menu_node_data.index'");
|
|
}
|
|
|
|
if(loading.value) {
|
|
errorNotify("An action is already occuring");
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
axios.get<Array<MenuNodeDataInterface>, AxiosResponse<Array<MenuNodeDataInterface>, any>>(menuNodeDataListRoute)
|
|
.then((res) => resolve(res.data))
|
|
.catch(() => {
|
|
errorNotify(__("Impossible de récuperer les donnés des noeuds"));
|
|
reject(`Failed to fetch api data on route ${menuNodeDataListRoute}`);
|
|
}).finally(() => { loading.value = false; });
|
|
});
|
|
}
|
|
|
|
async function saveMenuNodeData(
|
|
type: MenuNodeTypeEnum,
|
|
prop: string,
|
|
menuNodeData: ValueOf<IMenuNodeDataInputInterface>
|
|
): Promise<boolean> {
|
|
return new Promise((resolve, reject) => {
|
|
const menuNodeDataListRoute = route("bo.menu_node_data.update", {MENU_NODE_DATA: type});
|
|
if(!menuNodeDataListRoute) {
|
|
reject();
|
|
throw new Error("Could not load route 'bo.menu_node_data.update'");
|
|
}
|
|
|
|
if(loading.value) {
|
|
errorNotify("An action is already occuring");
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
|
|
const succeedAction = () => {
|
|
loading.value = false;
|
|
menuNodeDataErrors = {};
|
|
fetchMenuNodeDataList().then((dataList) => { menuNodeDataList.value = dataList; refreshEditor(); });
|
|
resolve(true);
|
|
};
|
|
|
|
if(menuNodeData instanceof File) {
|
|
const formData = new FormData();
|
|
formData.append("_method", "PUT");
|
|
formData.append(prop, menuNodeData);
|
|
// ! Apache or PHP ? cannot fetch bin file without using POST
|
|
axios.post<boolean>(menuNodeDataListRoute, formData, {
|
|
headers: {
|
|
"content-type": "multipart/form-data"
|
|
}
|
|
}).then(succeedAction).catch(ajaxErrorHandler(resolve))
|
|
.finally(() => { loading.value = false; });
|
|
return;
|
|
}
|
|
|
|
axios.patch<boolean>(menuNodeDataListRoute, {
|
|
[prop]: menuNodeData
|
|
}).then(succeedAction).catch(ajaxErrorHandler(resolve))
|
|
.finally(() => { loading.value = false; });
|
|
});
|
|
}
|
|
|
|
/** Catch ajax errors */
|
|
function ajaxErrorHandler(resolve: (value: boolean | PromiseLike<boolean>) => void) {
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
return (error: AxiosError<{errors: LaravelValidationErrors|{}}>) => {
|
|
const message = "Une erreur est survenue merci de ressayer plus tard";
|
|
if (error!.response!.status === 422) {
|
|
// ts:ignore
|
|
menuNodeDataErrors = error.response?.data?.errors ?? {};
|
|
errorNotify(__("La valeur saisie est incorrecte"));
|
|
return;
|
|
}
|
|
if (error!.response!.status === 401) {
|
|
// ts:ignore
|
|
errorNotify(__("Vous devez vous reconnecter"));
|
|
console.warn(__("Vous devez vous reconnecter"));
|
|
return;
|
|
}
|
|
if (error!.response!.status === 419) {
|
|
// ts:ignore
|
|
errorNotify(__("Vous devez rafraichir la page"));
|
|
return;
|
|
}
|
|
if (error!.response) {
|
|
console.error(error.response.data);
|
|
} else if (error.request) {
|
|
console.error(error.request);
|
|
} else {
|
|
// Something happened in setting up the request that triggered an Error
|
|
console.error("Error", error.message);
|
|
}
|
|
errorNotify(message);
|
|
resolve(false);
|
|
};
|
|
}
|
|
|
|
/** Get the enum value */
|
|
function getEnum(tsEnum: MenuNodeTypeEnum): Enum {
|
|
const enumValue: Enum|undefined = Object.values(nodeTypeEnumList).find((value) => value.value === tsEnum);
|
|
if (!enumValue) {
|
|
throw new Error(`Undefined enumValue for ${tsEnum}`);
|
|
}
|
|
return enumValue;
|
|
}
|
|
|
|
/** Initialise all tooltips in the component */
|
|
function initTooltips(): void {
|
|
setTimeout(() => {
|
|
tooltips.value = Tooltips.make({
|
|
type: "dom",
|
|
elements: mainMenu.value!.querySelectorAll("[data-bs-toggle=\"tooltip\"]")
|
|
});
|
|
}, 500);
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.main-menu-component {
|
|
.handle {
|
|
cursor: grab;
|
|
}
|
|
}
|
|
</style>
|