ldc-legaulois/resources/ts/bo/components/MainMenu.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>