diff --git a/Api/Resource/CustomFrontMenu.php b/Api/Resource/CustomFrontMenu.php new file mode 100644 index 0000000..7137ab8 --- /dev/null +++ b/Api/Resource/CustomFrontMenu.php @@ -0,0 +1,60 @@ + [self::GROUP_FRONT_READ]], + provider: CustomFrontMenuProvider::class, + ), + ], +)] +class CustomFrontMenu +{ + public const GROUP_FRONT_READ = 'custom_front_menu:front:read'; + + /** + * The code the shop owner gave the menu, which is also how a theme addresses it. + * + * The identifier of the resource, so the IRI is stable across installations: the + * numeric id of a menu is not, it comes from an autoincrement shared with the entries. + */ + #[ApiProperty(identifier: true)] + #[Groups([self::GROUP_FRONT_READ])] + public string $code = ''; + + /** + * Nodes of {id, title, href, children}, children nested to any depth. + * + * @var list> + */ + #[Groups([self::GROUP_FRONT_READ])] + public array $items = []; +} diff --git a/Api/State/CustomFrontMenuProvider.php b/Api/State/CustomFrontMenuProvider.php new file mode 100644 index 0000000..45244ad --- /dev/null +++ b/Api/State/CustomFrontMenuProvider.php @@ -0,0 +1,59 @@ + + */ +final readonly class CustomFrontMenuProvider implements ProviderInterface +{ + public function __construct( + private MenuTreeResolver $treeResolver, + private LangService $langService, + ) { + } + + /** + * @param array $uriVariables + * @param array $context + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomFrontMenu + { + $code = trim((string) ($uriVariables['code'] ?? '')); + + if ('' === $code) { + throw new NotFoundHttpException('Menu not found'); + } + + $items = $this->treeResolver->resolve($code, $this->langService->getLocale() ?? 'en_US'); + + if (null === $items) { + throw new NotFoundHttpException('Menu not found'); + } + + $menu = new CustomFrontMenu(); + $menu->code = $code; + $menu->items = $items; + + return $menu; + } +} diff --git a/Config/TheliaMain.sql b/Config/TheliaMain.sql index e7a013e..481ede2 100644 --- a/Config/TheliaMain.sql +++ b/Config/TheliaMain.sql @@ -12,12 +12,14 @@ DROP TABLE IF EXISTS `custom_front_menu_item`; CREATE TABLE `custom_front_menu_item` ( `id` INTEGER NOT NULL AUTO_INCREMENT, + `code` VARCHAR(255), `view` VARCHAR(255), `view_id` INTEGER, `tree_left` INTEGER, `tree_right` INTEGER, `tree_level` INTEGER, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE INDEX `custom_front_menu_item_code_unique` (`code`) ) ENGINE=InnoDB; -- --------------------------------------------------------------------- diff --git a/Config/module.xml b/Config/module.xml index ddf184f..68dbe5c 100644 --- a/Config/module.xml +++ b/Config/module.xml @@ -20,7 +20,7 @@ en_US fr_FR - 1.2.0 + 2.0.0 Delage Mathis @@ -44,7 +44,7 @@ HookSearch --> - 2.4.0 + 3.0.0 other 0 0 diff --git a/Config/routing.xml b/Config/routing.xml deleted file mode 100644 index b2ce795..0000000 --- a/Config/routing.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/Config/schema.xml b/Config/schema.xml index b27c0fb..b0fc0af 100644 --- a/Config/schema.xml +++ b/Config/schema.xml @@ -5,10 +5,18 @@ + + + + + + diff --git a/Controller/Front/CustomFrontMenuAPIController.php b/Controller/Front/CustomFrontMenuAPIController.php deleted file mode 100644 index 861e26a..0000000 --- a/Controller/Front/CustomFrontMenuAPIController.php +++ /dev/null @@ -1,55 +0,0 @@ -loadTableBrowser($customFornMenuService->getMenu($id)); - if ($menu === null) { - return OpenApiService::jsonResponse('Menu not found', Response::HTTP_NOT_FOUND); - } - if (count($menu) === 0) { - return OpenApiService::jsonResponse('Menu has no children', Response::HTTP_NO_CONTENT); - } - return OpenApiService::jsonResponse($menu, Response::HTTP_OK); - } -} \ No newline at end of file diff --git a/Controller/MenuController.php b/Controller/MenuController.php index a504e8d..65584ea 100644 --- a/Controller/MenuController.php +++ b/Controller/MenuController.php @@ -1,182 +1,629 @@ + * + * @throws PropelException + */ + public function menuListData(): array + { + $locale = $this->locale(); + $menus = []; + + foreach ($this->composer->menus() as $menu) { + $menus[] = [ + 'id' => (int) $menu->getId(), + 'code' => (string) $menu->getCode(), + 'title' => $this->presenter->title($menu, $locale), + 'entryCount' => \count($menu->getDescendants()), + ]; + } + + return ['menus' => $menus]; + } + + /** + * @throws PropelException + */ + #[Route('/menus', name: '.menus.create', methods: ['POST'])] + public function createMenu(Request $request): Response + { + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } + + $title = trim((string) $request->request->get('title', '')); + + if ('' === $title) { + return $this->failure('A menu name is required', $this->configurationUrl()); + } + + $code = trim((string) $request->request->get('code', '')); + + if (null !== $rejected = $this->rejectBadCode($code, null, $this->configurationUrl())) { + return $rejected; + } + + $menu = $this->composer->createMenu($title, $this->locale(), $code); + + $this->success('New menu added successfully'); + + return new RedirectResponse($this->menuUrl((int) $menu->getId())); + } + + /** + * @throws PropelException + */ + #[Route('/menus/{menuId}/delete', name: '.menus.delete', methods: ['POST'], requirements: ['menuId' => '\d+'])] + public function deleteMenu(Request $request, int $menuId): Response + { + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } + + $menu = $this->composer->menu($menuId); + + if (null === $menu) { + return $this->failure('This menu does not exist', $this->configurationUrl()); + } + + $this->composer->delete($menu); + $this->success('Current menu deleted successfully'); + + return new RedirectResponse($this->configurationUrl()); + } + + /** + * Rename a menu and set the code a theme calls it by. + * + * A code typed once at creation would otherwise be permanent, and it is the part a + * theme depends on. + * + * @throws PropelException + */ + #[Route('/menus/{menuId}/rename', name: '.menus.rename', methods: ['POST'], requirements: ['menuId' => '\d+'])] + public function renameMenu(Request $request, int $menuId): Response + { + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } + + $menu = $this->composer->menu($menuId); + + if (null === $menu) { + return $this->failure('This menu does not exist', $this->configurationUrl()); + } + + $title = trim((string) $request->request->get('title', '')); + + if ('' === $title) { + return $this->failure('A menu name is required', $this->menuUrl($menuId)); + } + + $code = trim((string) $request->request->get('code', '')); + + if (null !== $rejected = $this->rejectBadCode($code, $menuId, $this->menuUrl($menuId))) { + return $rejected; + } + + $this->composer->renameMenu($menu, $title, $code, $this->locale()); + + $this->success('This menu has been successfully saved'); + + return new RedirectResponse($this->menuUrl($menuId)); + } + + /** + * The tree of one menu. + * + * @throws PropelException + */ + #[Route('/menus/{menuId}', name: '.menus.show', methods: ['GET'], requirements: ['menuId' => '\d+'])] + public function showMenu(int $menuId): Response + { + if (null !== $denied = $this->denyUnlessAllowed(null)) { + return $denied; + } + + $menu = $this->composer->menu($menuId); + + if (null === $menu) { + return new RedirectResponse($this->configurationUrl()); + } + + $locale = $this->locale(); + $tree = $this->presenter->tree($menu, $locale); + + return $this->render('custom-front-menu/tree', [ + 'menuId' => $menuId, + 'menuCode' => (string) $menu->getCode(), + 'menuTitle' => $this->presenter->title($menu, $locale), + 'tree' => $tree, + // The "add an entry" form picks its parent from a flat list: a shared modal + // filled from the clicked row would need module JavaScript. + 'flatTree' => $this->flatten($tree), + 'preselectedParent' => (int) $this->getRequest()->query->get('parent', 0), + ]); + } + + // --------------------------------------------------------------- entries + + /** + * @throws PropelException + */ + #[Route('/menus/{menuId}/entries', name: '.entries.create', methods: ['POST'], requirements: ['menuId' => '\d+'])] + public function createEntry(Request $request, int $menuId): Response + { + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } + + $menu = $this->composer->menu($menuId); + + if (null === $menu) { + return $this->failure('This menu does not exist', $this->configurationUrl()); + } + + $parentId = (int) $request->request->get('parent_id', 0); + $parent = $menu; + + if ($parentId > 0) { + $candidate = $this->composer->entry($parentId); + + if (null === $candidate) { + return $this->failure('This menu entry does not exist', $this->menuUrl($menuId)); + } + + $parent = $candidate; + } + + $title = trim((string) $request->request->get('title', '')); + + if ('' === $title) { + return $this->failure('An entry name is required', $this->menuUrl($menuId)); + } + + $entry = $this->composer->createEntry($parent, $title, $this->locale()); + + return new RedirectResponse($this->entryUrl((int) $entry->getId())); + } + + /** + * @throws PropelException + */ + #[Route('/entries/{itemId}', name: '.entries.edit', methods: ['GET'], requirements: ['itemId' => '\d+'])] + public function editEntry(int $itemId): Response + { + if (null !== $denied = $this->denyUnlessAllowed(null)) { + return $denied; + } + + $entry = $this->composer->entry($itemId); + + if (null === $entry) { + return new RedirectResponse($this->configurationUrl()); + } + + $view = strtolower((string) $entry->getView()); + $menu = $this->menuOf($entry); + $menuId = (int) $menu?->getId(); + $locale = $this->locale(); + + return $this->render('custom-front-menu/entry', [ + 'itemId' => $itemId, + 'menuId' => $menuId, + 'menuTitle' => $this->presenter->title($this->composer->menu($menuId), $locale), + 'entryTitle' => $this->presenter->title($entry, $locale), + 'translations' => $this->composer->translations($entry), + 'view' => \in_array($view, self::VIEWS, true) ? $view : ('' === $view ? 'none' : 'url'), + 'viewId' => (int) $entry->getViewId(), + 'targets' => $this->targetCatalog->targets($locale), + // Reparenting from the form, because dropping an entry back on the root zone of + // the tree means aiming at a few pixels. + 'parents' => null === $menu ? [] : $this->parentOptions($menu, $entry, $locale), + 'parentId' => 2 === $entry->getLevel() ? 0 : (int) $entry->getParent()?->getId(), + ]); + } - protected function getSession(): SessionInterface + /** + * The target field alone, swapped in by HTMX when the kind of target changes. Keeps + * the dependent field server-rendered instead of shipping module JavaScript. + * + * @throws PropelException + */ + #[Route('/entries/{itemId}/target-field', name: '.entries.target_field', methods: ['GET'], requirements: ['itemId' => '\d+'])] + public function entryTargetField(Request $request, int $itemId): Response { - return $this->requestStack->getCurrentRequest()->getSession(); + if (null !== $denied = $this->denyUnlessAllowed(null)) { + return $denied; + } + + $entry = $this->composer->entry($itemId); + + if (null === $entry) { + return new Response('', Response::HTTP_NO_CONTENT); + } + + $view = strtolower((string) $request->query->get('view', 'none')); + + return $this->render('custom-front-menu/_target_field', [ + 'view' => \in_array($view, [...self::VIEWS, 'url'], true) ? $view : 'none', + 'viewId' => (int) $entry->getViewId(), + 'translations' => $this->composer->translations($entry), + 'targets' => $this->targetCatalog->targets($this->locale()), + ]); + } + + /** + * @throws PropelException + */ + #[Route('/entries/{itemId}', name: '.entries.save', methods: ['POST'], requirements: ['itemId' => '\d+'])] + public function saveEntry(Request $request, int $itemId): Response + { + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } + + $entry = $this->composer->entry($itemId); + + if (null === $entry) { + return $this->failure('This menu entry does not exist', $this->configurationUrl()); + } + + if ($request->request->has('parent_id')) { + $menu = $this->menuOf($entry); + $parentId = (int) $request->request->get('parent_id', 0); + $newParent = $parentId > 0 ? $this->composer->entry($parentId) : $menu; + + if (null === $menu || null === $newParent || $this->menuOf($newParent)?->getId() !== $menu->getId()) { + return $this->failure('This menu entry does not exist', $this->entryUrl($itemId)); + } + + $this->composer->move($entry, $newParent); + } + + $view = strtolower(trim((string) $request->request->get('view', 'none'))); + $titles = (array) $request->request->all('title'); + $urls = (array) $request->request->all('url'); + + if (\in_array($view, self::VIEWS, true)) { + $viewId = (int) $request->request->get('view_id', 0); + + if ($viewId <= 0) { + return $this->failure('Pick a target for this entry', $this->entryUrl($itemId)); + } + + $this->composer->setTarget($entry, ucfirst($view), $viewId); + } else { + $this->composer->setTarget($entry, null, null); + } + + foreach ($titles as $locale => $title) { + $this->composer->setTranslation( + $entry, + (string) $locale, + $this->cleanTitle((string) $title), + 'url' === $view ? MenuLink::filter((string) ($urls[$locale] ?? '')) : null, + ); + } + + $this->success('This entry has been successfully saved'); + + return new RedirectResponse($this->menuUrl((int) $this->menuOf($entry)?->getId())); } /** - * Load the menu selected by the user. - * @param Request $request The user request with the desired menu id - * @return RedirectResponse + * @throws PropelException */ - #[Route("/admin/module/CustomFrontMenu/selectMenu", name: "admin.customfrontmenu.select.menu", methods: ["POST"])] - public function selectOtherMenu(Request $request) : RedirectResponse + #[Route('/entries/{itemId}/delete', name: '.entries.delete', methods: ['POST'], requirements: ['itemId' => '\d+'])] + public function deleteEntry(Request $request, int $itemId): Response { - $menuId = intval(str_replace("menu-selected-", "", $request->get('menuId'))); + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } + + $entry = $this->composer->entry($itemId); + + if (null === $entry) { + return $this->failure('This menu entry does not exist', $this->configurationUrl()); + } + + $menuId = (int) $this->menuOf($entry)?->getId(); + $this->composer->delete($entry); - setcookie('menuId', $menuId); + $this->success('This entry has been deleted'); - return new RedirectResponse(URL::getInstance()->absoluteUrl('/admin/module/CustomFrontMenu')); + return new RedirectResponse($this->menuUrl($menuId)); } + // -------------------------------------------------------------- moving + /** - * Save the selected menu items in database. - * @param Request $request The user request with the menu items and the selected menu id - * @param CustomFrontMenuSaveService $customFrontMenuSave The saving service - * @param CustomFrontMenuService $customFrontMenuService + * Reparent an entry by drag and drop. + * + * The field names are those the back-office bo-category-tree Stimulus controller + * posts: reusing the theme's tree controller means accepting its contract, which is + * still cheaper than shipping a second drag and drop implementation. + * * @throws PropelException - * @throws Exception */ - #[Route("/admin/module/CustomFrontMenu/save", name:"admin.customfrontmenu.save", methods:["POST"])] - public function saveMenuItems(Request $request, CustomFrontMenuSaveService $customFrontMenuSave, CustomFrontMenuService $customFrontMenuService) : RedirectResponse + #[Route('/entries/move', name: '.entries.move', methods: ['POST'])] + public function moveEntry(Request $request): Response { + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } - $dataJson = $request->get('menuData'); - $newMenu = json_decode($dataJson, true); - $menuId = json_decode($request->get('menuDataId')); + $entry = $this->composer->entry((int) $request->request->get('category_id', 0)); + $newParentId = (int) $request->request->get('new_parent_id', 0); - if (!$menuId || $menuId === 'undefined' || $menuId === 'null') { - throw new Exception('Save failed : the menu id cannot be null or empty'); + if (null === $entry) { + return new Response('', Response::HTTP_NO_CONTENT); } - $menuToCheck = $customFrontMenuService->getMenu($menuId); + $menu = $this->menuOf($entry); + $newParent = $newParentId > 0 ? $this->composer->entry($newParentId) : $menu; - if (!$menuToCheck || $menuToCheck->getLevel() !== 1) { - throw new Exception('Save failed : the menu id is invalid'); + // A drop outside this menu, or onto the entry's own subtree, is a no-op. + if (null === $newParent || null === $menu || $this->menuOf($newParent)?->getId() !== $menu->getId()) { + return new Response('', Response::HTTP_NO_CONTENT); } - // Delete all the items currently in database for the menu to save - $menu = $customFrontMenuSave->deleteSpecificItems($menuId); + $this->composer->move($entry, $newParent); - // Add all new items in database - $customFrontMenuSave->saveTableBrowser($newMenu, $menu); + return new Response('', Response::HTTP_NO_CONTENT); + } - $this->getSession()->getFlashBag()->add('success', Translator::getInstance()->trans('This menu has been successfully saved !', [], CustomFrontMenu::DOMAIN_NAME)); + /** + * @throws PropelException + */ + #[Route('/entries/{itemId}/up', name: '.entries.up', methods: ['POST'], requirements: ['itemId' => '\d+'])] + public function moveEntryUp(Request $request, int $itemId): Response + { + return $this->reorder($request, $itemId, up: true); + } - return new RedirectResponse(URL::getInstance()->absoluteUrl('/admin/module/CustomFrontMenu')); + /** + * @throws PropelException + */ + #[Route('/entries/{itemId}/down', name: '.entries.down', methods: ['POST'], requirements: ['itemId' => '\d+'])] + public function moveEntryDown(Request $request, int $itemId): Response + { + return $this->reorder($request, $itemId, up: false); } /** - * Add a new menu with the name given by the user. - * The user is redirected in this new menu. - * @param Request $request The user request with the menu name - * @param CustomFrontMenuLoadService $customFrontMenuLoadService The loading service - * @param CustomFrontMenuService $customFrontMenuService The menu service - * @throws Exception + * @throws PropelException */ - #[Route("/admin/module/CustomFrontMenu/add", name: "admin.customfrontmenu.addmenu", methods: ["POST"])] - public function addMenu(Request $request, CustomFrontMenuLoadService $customFrontMenuLoadService, CustomFrontMenuService $customFrontMenuService) : RedirectResponse + private function reorder(Request $request, int $itemId, bool $up): Response { - $menuName = $request->get('menuName'); - $root = $customFrontMenuService->getRoot(); - $itemId = $customFrontMenuService->addMenu($root, $menuName); - $this->loadMenuItems($customFrontMenuLoadService, $customFrontMenuService, $itemId); - setcookie('menuId', $itemId); + if (null !== $denied = $this->denyUnlessAllowed($request)) { + return $denied; + } + + $entry = $this->composer->entry($itemId); + + if (null === $entry) { + return $this->failure('This menu entry does not exist', $this->configurationUrl()); + } - $this->getSession()->getFlashBag()->add('success', Translator::getInstance()->trans('New menu added successfully', [], CustomFrontMenu::DOMAIN_NAME)); + $up ? $this->composer->moveUp($entry) : $this->composer->moveDown($entry); - return new RedirectResponse(URL::getInstance()->absoluteUrl('/admin/module/CustomFrontMenu')); + return new RedirectResponse($this->menuUrl((int) $this->menuOf($entry)?->getId())); } + // ------------------------------------------------------------- plumbing + /** - * Delete the current menu. - * The user is redirected in the first menu if it exists. - * @param Request $request The user request with the menu id - * @param CustomFrontMenuService $customFrontMenuService The menu service - * @throws Exception + * Composing a menu is an administration operation. A null request means a GET screen, + * which needs the permission but carries no token. */ - #[Route("/admin/module/CustomFrontMenu/delete", name:"admin.customfrontmenu.deletemenu", methods:["POST"])] - public function deleteMenu(Request $request, CustomFrontMenuService $customFrontMenuService) : RedirectResponse + private function denyUnlessAllowed(?Request $request): ?Response { - $firstCurrentMenuId = $request->get('menuId'); - if($firstCurrentMenuId === null || $firstCurrentMenuId === 'menu-selected-') { - throw new Exception('Delete failed : the menu id cannot be null or empty'); + if (null !== $response = $this->checkAuth(AdminResources::MODULE, 'CustomFrontMenu', AccessManager::UPDATE)) { + return $response; + } + + if ($request instanceof Request) { + $this->getTokenProvider()->checkToken((string) $request->request->get('_token', '')); } - $currentMenuId = intval(str_replace("menu-selected-", "", $firstCurrentMenuId)); + return null; + } - $customFrontMenuService->deleteMenu($currentMenuId); + /** + * A code is what a theme is written against, so a typed one is kept exactly as typed + * or refused — never quietly slugified into something else. An empty code is not an + * error: it means "derive it from the name". + * + * @throws PropelException + */ + private function rejectBadCode(string $code, ?int $exceptId, string $redirectTo): ?RedirectResponse + { + if ('' === $code) { + return null; + } - $this->getSession()->getFlashBag()->add('success', Translator::getInstance()->trans('Current menu deleted successfully', [], CustomFrontMenu::DOMAIN_NAME)); + if (!MenuCode::isValid($code)) { + return $this->failure('A code takes lowercase letters, digits and single dashes only', $redirectTo); + } - if (isset($_COOKIE['menuId'])) { - setcookie('menuId', -1); + if (MenuCode::isTaken($code, $exceptId)) { + return $this->failure('This code is already used by another menu', $redirectTo); } - return new RedirectResponse(URL::getInstance()->absoluteUrl('/admin/module/CustomFrontMenu')); + return null; } /** - * Clear all flashes + * @param list> $nodes + * + * @return list */ - #[Route("/admin/module/CustomFrontMenu/clearFlashes", name:"admin.customfrontmenu.clearflashes", methods:["GET"])] - public function clearFlashes() : Response + private function flatten(array $nodes): array { - $this->getSession()->getFlashBag()->clear(); - // Clear the response too to limit the data returned by http - return new Response('', ResponseAlias::HTTP_OK); + $flat = []; + + foreach ($nodes as $node) { + $flat[] = ['id' => $node['id'], 'title' => $node['title'], 'depth' => $node['depth']]; + $flat = [...$flat, ...$this->flatten($node['children'])]; + } + + return $flat; } /** - * Load the menu items - * @param CustomFrontMenuLoadService $customFrontMenuLoadService The loading service - * @param CustomFrontMenuService $customFrontMenuService The menu service - * @param ?int $menuId The id of the menu to load - * @return array All the data necessary to load the page content : Menu names, menu items and the current menu id. + * Where one entry may be moved: every entry of its menu, minus itself and its own + * descendants, which the nested set cannot absorb. + * + * @return list + * * @throws PropelException */ - public function loadMenuItems(CustomFrontMenuLoadService $customFrontMenuLoadService, CustomFrontMenuService $customFrontMenuService, ?int $menuId = null) : array + private function parentOptions(CustomFrontMenuItem $menu, CustomFrontMenuItem $entry, string $locale): array { - $menuNames = $customFrontMenuLoadService->loadSelectMenu($customFrontMenuService->getRoot()); - $data = []; + $excluded = [(int) $entry->getId()]; - if (!$menuId && count($menuNames) > 0) { - $menuId = intval(str_replace("menu-selected-", "", $menuNames[0]['id'])); + foreach ($entry->getDescendants() as $descendant) { + $excluded[] = (int) $descendant->getId(); } - if($menuId) { - $menu = $customFrontMenuService->getMenu($menuId); - if (!$menu || $menu->getLevel() !== 1) { - $this->getSession()->getFlashBag()->add('fail', Translator::getInstance()->trans('This menu does not exists', [], CustomFrontMenu::DOMAIN_NAME)); - $menuId = intval(str_replace("menu-selected-", "", $menuNames[0]['id'])); - setcookie('menuId', $menuId, ['path' => '/admin/module/CustomFrontMenu']); - $menu = $customFrontMenuService->getMenu($menuId); + return array_values(array_filter( + $this->flatten($this->presenter->tree($menu, $locale)), + static fn (array $option): bool => !\in_array($option['id'], $excluded, true), + )); + } + + /** + * @throws PropelException + */ + private function menuOf(CustomFrontMenuItem $item): ?CustomFrontMenuItem + { + $current = $item; + + while ($current->getLevel() > 1) { + $parent = $current->getParent(); + + if (!$parent instanceof CustomFrontMenuItem) { + return null; } - $data = $customFrontMenuLoadService->loadTableBrowser($menu); + $current = $parent; } - return [ - 'menuNames' => json_encode($menuNames), - 'menuItems' => json_encode($data), - 'currentMenuId' => utf8_encode($menuId) - ]; + return 1 === $current->getLevel() ? $current : null; + } + + /** + * A menu label is shown on every front page: no markup, and no back quote, which the + * 1.x screen used as its own delimiter. + */ + private function cleanTitle(string $title): ?string + { + $title = trim(strip_tags(str_replace('`', "'", $title))); + + return '' === $title ? null : $title; + } + + private function locale(): string + { + return $this->theliaSession()->getAdminLang()->getLocale(); + } + + private function theliaSession(): Session + { + /** @var Session $session */ + $session = $this->getSession(); + + return $session; + } + + private function success(string $message): void + { + $this->theliaSession()->getFlashBag()->add( + 'success', + Translator::getInstance()->trans($message, [], self::DOMAIN), + ); + } + + private function failure(string $message, string $redirectTo): RedirectResponse + { + $this->theliaSession()->getFlashBag()->add( + 'error', + Translator::getInstance()->trans($message, [], self::DOMAIN), + ); + + return new RedirectResponse($redirectTo); + } + + private function configurationUrl(): string + { + return URL::getInstance()->absoluteUrl('/admin/module/CustomFrontMenu'); + } + + private function menuUrl(int $menuId): string + { + return URL::getInstance()->absoluteUrl('/admin/module/CustomFrontMenu/menus/'.$menuId); + } + + private function entryUrl(int $itemId): string + { + return URL::getInstance()->absoluteUrl('/admin/module/CustomFrontMenu/entries/'.$itemId); } -} \ No newline at end of file +} diff --git a/CustomFrontMenu.php b/CustomFrontMenu.php index 2619046..ca756a6 100644 --- a/CustomFrontMenu.php +++ b/CustomFrontMenu.php @@ -1,4 +1,7 @@ insertSql(null, [__DIR__.'/Config/TheliaMain.sql']); - self::setConfigValue('is_initialized', true); + self::setConfigValue('is_initialized', '1'); } return true; } - public function update($currentVersion, $newVersion, ConnectionInterface $con = null): void + public function update($currentVersion, $newVersion, ?ConnectionInterface $con = null): void { + $this->applyUpdateScripts($currentVersion, $newVersion, $con); + $this->migrateToCodes($con); + } + + /** + * Re-run the schema migration on every activation. + * + * update() only fires when the recorded version differs, and the version is recorded + * before it runs: once a shop is stamped 2.0.0 it will never be called again, however + * it ended. Deactivating and reactivating the module is then the way out, so this has + * to do the work too. Everything it calls is idempotent. + */ + public function postActivation(?ConnectionInterface $con = null): void + { + $this->migrateToCodes($con); + } + + private function applyUpdateScripts(string $currentVersion, string $newVersion, ?ConnectionInterface $con): void + { + $updateDir = __DIR__.DS.'Config'.DS.'update'; + + // Finder::in() throws on a missing directory, which would abort the whole + // module refresh: a module with no update script is a normal case. + if (!is_dir($updateDir)) { + return; + } + $finder = Finder::create() ->name('*.sql') ->depth(0) ->sortByName() - ->in(__DIR__.DS.'Config'.DS.'update'); + ->in($updateDir); $database = new Database($con); - /** @var \SplFileInfo $file */ foreach ($finder as $file) { - if (version_compare($currentVersion, $file->getBasename('.sql'), '<')) { + $scriptVersion = $file->getBasename('.sql'); + + // Bounded on both ends: a script for a version beyond the one being installed + // has no business running during this update. + if (version_compare($currentVersion, $scriptVersion, '<') + && version_compare($scriptVersion, $newVersion, '<=')) { $database->insertSql(null, [$file->getPathname()]); } } } /** - * Delete the cookie, but preserve the database + * Bring an installation created before 2.0.0 up to the code column. + * + * Every step asks the database what it looks like instead of trusting the module + * version, and can be run again: ModuleManagement writes the new version before it + * calls update() (ModuleManagement.php:137-145 then :168), and the first DDL commits + * that write implicitly, so a failure half-way through would otherwise leave a shop + * recorded as 2.0.0 with no way left to finish the job. Running it again from + * postActivation() is then a real recovery path, not a formality. + */ + private function migrateToCodes(?ConnectionInterface $con = null): void + { + $con ??= Propel::getWriteConnection('TheliaMain'); + + $this->addCodeColumn($con); + $this->fillMissingMenuCodes(); + $this->dropUnsafeUrls(); + } + + /** + * `ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are MariaDB extensions: + * a shop on MySQL would take a syntax error. The catalogue answers both. + */ + private function addCodeColumn(ConnectionInterface $con): void + { + if (!$this->exists($con, 'SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table AND COLUMN_NAME = :name', 'code')) { + $con->exec('ALTER TABLE `custom_front_menu_item` ADD COLUMN `code` VARCHAR(255) NULL AFTER `id`'); + } + + if (!$this->exists($con, 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table AND INDEX_NAME = :name', 'custom_front_menu_item_code_unique')) { + $con->exec('CREATE UNIQUE INDEX `custom_front_menu_item_code_unique` ON `custom_front_menu_item` (`code`)'); + } + } + + private function exists(ConnectionInterface $con, string $sql, string $name): bool + { + $statement = $con->prepare($sql); + $statement->execute(['table' => 'custom_front_menu_item', 'name' => $name]); + + return (int) $statement->fetchColumn() > 0; + } + + /** + * Give a code to every menu created before the column existed. + * + * A menu with no code cannot be rendered by a theme at all, so this runs on the data + * rather than leaving the shop owner to discover it menu by menu. Derived from the + * menu title, which is what the person would have typed anyway. + */ + private function fillMissingMenuCodes(): void + { + $menus = CustomFrontMenuItemQuery::create() + ->filterByTreeLevel(1) + ->filterByCode(null) + ->find(); + + foreach ($menus as $menu) { + $title = (string) CustomFrontMenuItemI18nQuery::create() + ->filterById($menu->getId()) + ->findOne() + ?->getTitle(); + + $menu + ->setCode(MenuCode::derive('' === trim($title) ? 'menu-'.$menu->getId() : $title)) + ->save(); + } + } + + /** + * Drop the links the 1.x screen let through. + * + * It filtered with FILTER_SANITIZE_URL, which only strips illegal characters: + * `javascript:` survived it intact. Those rows are rendered on every page of the shop + * and served by a public API, so they are cleared rather than carried over. The filter + * that decides is the one the front applies, so both can never disagree. */ - public function destroy(ConnectionInterface $con = null, $deleteModuleData = false): void + private function dropUnsafeUrls(): void { - setcookie('menuId', '', time() - 3600, '/admin/module/CustomFrontMenu'); + $translations = CustomFrontMenuItemI18nQuery::create() + ->filterByUrl(null, Criteria::NOT_EQUAL) + ->find(); + + foreach ($translations as $translation) { + $url = (string) $translation->getUrl(); + + if ('' !== $url && null === MenuLink::filter($url)) { + $translation->setUrl(null)->save(); + } + } } public static function configureServices(ServicesConfigurator $servicesConfigurator): void { $servicesConfigurator->load(self::getModuleCode().'\\', __DIR__) - ->exclude([THELIA_MODULE_DIR . ucfirst(self::getModuleCode()). "/I18n/*"]) + ->exclude([__DIR__.'/I18n/*']) ->autowire(true) ->autoconfigure(true); } diff --git a/Hook/ConfigHook.php b/Hook/ConfigHook.php index af3d51d..f0b9b02 100644 --- a/Hook/ConfigHook.php +++ b/Hook/ConfigHook.php @@ -1,76 +1,57 @@ [ - [ - "type" => "back", - "method" => "addMenuJs" - ] - ], - "main.head-css" => [ - [ - "type" => "back", - "method" => "addMenuCss" - ] + 'module.configuration' => [ + ['type' => 'back', 'method' => 'onModuleConfiguration'], ], - "module.configuration" => [ - [ - "type" => "back", - "method" => "onModuleConfiguration" - ] - ] ]; } - public function addMenuJs(HookRenderEvent $event):void - { - $event->add($this->addJS("assets/js/main.js")); - } - - public function addMenuCss(HookRenderEvent $event):void - { - $event->add($this->addCSS("assets/css/styles.css")); - } - /** * @throws PropelException */ - public function onModuleConfiguration(HookRenderEvent $event) : void + public function onModuleConfiguration(HookRenderEvent $event): void { - if (isset($_COOKIE['menuId']) && $_COOKIE['menuId'] != -1) { - $data = $this->menuController->loadMenuItems($this->customFrontMenuLoadService, $this->customFrontMenuService, $_COOKIE['menuId']); - } else { - $data = $this->menuController->loadMenuItems($this->customFrontMenuLoadService, $this->customFrontMenuService); - } - - $event->add($this->render("module-config.html", $data)); + $event->add($this->render('module-config.html.twig', $this->menuController->menuListData())); } } diff --git a/I18n/backOffice/default-twig/en_US.php b/I18n/backOffice/default-twig/en_US.php new file mode 100644 index 0000000..9704d60 --- /dev/null +++ b/I18n/backOffice/default-twig/en_US.php @@ -0,0 +1,9 @@ + 'Brand', + 'category' => 'Category', + 'content' => 'Content', + 'folder' => 'Folder', + 'product' => 'Product', +); diff --git a/I18n/backOffice/default-twig/es_ES.php b/I18n/backOffice/default-twig/es_ES.php new file mode 100644 index 0000000..25c02be --- /dev/null +++ b/I18n/backOffice/default-twig/es_ES.php @@ -0,0 +1,54 @@ + 'Un idioma que se deja vacío toma otro idioma en la tienda.', + 'Add' => 'Añadir', + 'Add a new menu' => 'Añadir un menú', + 'Add a sub-entry' => 'Añadir una entrada debajo', + 'Add an entry' => 'Añadir una entrada', + 'Address' => 'Dirección', + 'All menus' => 'Todos los menús', + 'Cancel' => 'Cancelar', + 'Code' => 'Código', + 'Compose' => 'Componer', + 'Create' => 'Crear', + 'Delete' => 'Eliminar', + 'Delete this entry and everything under it?' => '¿Eliminar esta entrada y todo lo que contiene?', + 'Delete this menu' => 'Eliminar este menú', + 'Delete this whole menu and all its entries?' => '¿Eliminar este menú y todas sus entradas?', + 'Deleted target (#%id%)' => 'Destino eliminado (#%id%)', + 'Drag an entry onto another to nest it, and every move is saved right away. To move one back up a level, open it and change where it sits.' => 'Arrastre una entrada sobre otra para anidarla; cada movimiento se guarda de inmediato. Para subirla un nivel, ábrala y cambie su ubicación.', + 'Edit an entry' => 'Editar una entrada', + 'Entries' => 'Entradas', + 'Entry name' => 'Nombre de la entrada', + 'First level of the menu' => 'Primer nivel del menú', + 'Front-office call' => 'Llamada en la tienda', + 'Kind of target' => 'Tipo de destino', + 'Label' => 'Etiqueta', + 'Menu name' => 'Nombre del menú', + 'Menus' => 'Menús', + 'Move down' => 'Bajar', + 'Move up' => 'Subir', + 'Moving an entry takes its own entries with it.' => 'Mover una entrada se lleva consigo las entradas que contiene.', + 'Name' => 'Nombre', + 'Name and code' => 'Nombre y código', + 'No menu yet. Create one to get started.' => 'Todavía no hay ningún menú. Cree uno para empezar.', + 'No target' => 'Sin destino', + 'Only http, https and site-relative addresses are kept.' => 'Solo se conservan las direcciones http, https y relativas al sitio.', + 'Pick one' => 'Elija', + 'Place it under' => 'Colocar debajo de', + 'Position' => 'Ubicación', + 'Save' => 'Guardar', + 'Target' => 'Destino', + 'The name a theme calls this menu by. Left empty, it is derived from the menu name.' => 'El nombre con el que un tema llama a este menú. Si se deja vacío, se deriva del nombre del menú.', + 'This entry is a label with no link.' => 'Esta entrada es una etiqueta sin enlace.', + 'This menu has no entry yet.' => 'Este menú todavía no tiene ninguna entrada.', + 'Toggle children' => 'Mostrar u ocultar las entradas hijas', + 'Untitled' => 'Sin título', + '%title% (offline)' => '%title% (fuera de línea)', + 'brand' => 'Marca', + 'category' => 'Categoría', + 'content' => 'Contenido', + 'folder' => 'Carpeta', + 'product' => 'Producto', +); diff --git a/I18n/backOffice/default-twig/fr_FR.php b/I18n/backOffice/default-twig/fr_FR.php new file mode 100644 index 0000000..7fcb55a --- /dev/null +++ b/I18n/backOffice/default-twig/fr_FR.php @@ -0,0 +1,54 @@ + 'Une langue laissée vide reprend une autre langue côté boutique.', + 'Add' => 'Ajouter', + 'Add a new menu' => 'Ajouter un menu', + 'Add a sub-entry' => 'Ajouter une entrée en dessous', + 'Add an entry' => 'Ajouter une entrée', + 'Address' => 'Adresse', + 'All menus' => 'Tous les menus', + 'Cancel' => 'Annuler', + 'Code' => 'Code', + 'Compose' => 'Composer', + 'Create' => 'Créer', + 'Delete' => 'Supprimer', + 'Delete this entry and everything under it?' => 'Supprimer cette entrée et tout ce qu\'elle contient ?', + 'Delete this menu' => 'Supprimer ce menu', + 'Delete this whole menu and all its entries?' => 'Supprimer ce menu et toutes ses entrées ?', + 'Deleted target (#%id%)' => 'Cible supprimée (#%id%)', + 'Drag an entry onto another to nest it, and every move is saved right away. To move one back up a level, open it and change where it sits.' => 'Faites glisser une entrée sur une autre pour l\'imbriquer ; chaque déplacement est enregistré immédiatement. Pour la remonter d\'un niveau, ouvrez-la et changez son emplacement.', + 'Edit an entry' => 'Modifier une entrée', + 'Entries' => 'Entrées', + 'Entry name' => 'Nom de l\'entrée', + 'First level of the menu' => 'Premier niveau du menu', + 'Front-office call' => 'Appel côté boutique', + 'Kind of target' => 'Type de cible', + 'Label' => 'Libellé', + 'Menu name' => 'Nom du menu', + 'Menus' => 'Menus', + 'Move down' => 'Descendre', + 'Move up' => 'Monter', + 'Moving an entry takes its own entries with it.' => 'Déplacer une entrée emmène les entrées qu\'elle contient.', + 'Name' => 'Nom', + 'Name and code' => 'Nom et code', + 'No menu yet. Create one to get started.' => 'Aucun menu pour le moment. Créez-en un pour commencer.', + 'No target' => 'Aucune cible', + 'Only http, https and site-relative addresses are kept.' => 'Seules les adresses http, https et relatives au site sont conservées.', + 'Pick one' => 'Choisissez', + 'Place it under' => 'Placer sous', + 'Position' => 'Emplacement', + 'Save' => 'Enregistrer', + 'Target' => 'Cible', + 'The name a theme calls this menu by. Left empty, it is derived from the menu name.' => 'Le nom sous lequel un thème appelle ce menu. Laissé vide, il est dérivé du nom du menu.', + 'This entry is a label with no link.' => 'Cette entrée est un libellé sans lien.', + 'This menu has no entry yet.' => 'Ce menu n\'a encore aucune entrée.', + 'Toggle children' => 'Afficher ou masquer les entrées filles', + 'Untitled' => 'Sans titre', + '%title% (offline)' => '%title% (hors ligne)', + 'brand' => 'Marque', + 'category' => 'Catégorie', + 'content' => 'Contenu', + 'folder' => 'Dossier', + 'product' => 'Produit', +); diff --git a/I18n/backOffice/default-twig/it_IT.php b/I18n/backOffice/default-twig/it_IT.php new file mode 100644 index 0000000..e459531 --- /dev/null +++ b/I18n/backOffice/default-twig/it_IT.php @@ -0,0 +1,54 @@ + 'Una lingua lasciata vuota riprende un\'altra lingua sul negozio.', + 'Add' => 'Aggiungi', + 'Add a new menu' => 'Aggiungi un menu', + 'Add a sub-entry' => 'Aggiungi una voce sotto', + 'Add an entry' => 'Aggiungi una voce', + 'Address' => 'Indirizzo', + 'All menus' => 'Tutti i menu', + 'Cancel' => 'Annulla', + 'Code' => 'Codice', + 'Compose' => 'Componi', + 'Create' => 'Crea', + 'Delete' => 'Elimina', + 'Delete this entry and everything under it?' => 'Eliminare questa voce e tutto ciò che contiene?', + 'Delete this menu' => 'Elimina questo menu', + 'Delete this whole menu and all its entries?' => 'Eliminare questo menu e tutte le sue voci?', + 'Deleted target (#%id%)' => 'Destinazione eliminata (#%id%)', + 'Drag an entry onto another to nest it, and every move is saved right away. To move one back up a level, open it and change where it sits.' => 'Trascina una voce su un\'altra per annidarla; ogni spostamento viene salvato subito. Per risalire di un livello, aprila e cambia la sua posizione.', + 'Edit an entry' => 'Modifica una voce', + 'Entries' => 'Voci', + 'Entry name' => 'Nome della voce', + 'First level of the menu' => 'Primo livello del menu', + 'Front-office call' => 'Chiamata sul negozio', + 'Kind of target' => 'Tipo di destinazione', + 'Label' => 'Etichetta', + 'Menu name' => 'Nome del menu', + 'Menus' => 'Menu', + 'Move down' => 'Sposta giù', + 'Move up' => 'Sposta su', + 'Moving an entry takes its own entries with it.' => 'Spostare una voce porta con sé le voci che contiene.', + 'Name' => 'Nome', + 'Name and code' => 'Nome e codice', + 'No menu yet. Create one to get started.' => 'Nessun menu per ora. Creane uno per iniziare.', + 'No target' => 'Nessuna destinazione', + 'Only http, https and site-relative addresses are kept.' => 'Vengono conservati solo gli indirizzi http, https e relativi al sito.', + 'Pick one' => 'Scegli', + 'Place it under' => 'Posiziona sotto', + 'Position' => 'Posizione', + 'Save' => 'Salva', + 'Target' => 'Destinazione', + 'The name a theme calls this menu by. Left empty, it is derived from the menu name.' => 'Il nome con cui un tema richiama questo menu. Se lasciato vuoto, viene derivato dal nome del menu.', + 'This entry is a label with no link.' => 'Questa voce è un\'etichetta senza link.', + 'This menu has no entry yet.' => 'Questo menu non ha ancora nessuna voce.', + 'Toggle children' => 'Mostra o nascondi le voci figlie', + 'Untitled' => 'Senza titolo', + '%title% (offline)' => '%title% (offline)', + 'brand' => 'Marca', + 'category' => 'Categoria', + 'content' => 'Contenuto', + 'folder' => 'Cartella', + 'product' => 'Prodotto', +); diff --git a/I18n/backOffice/default/en_US.php b/I18n/backOffice/default/en_US.php deleted file mode 100644 index 9a25907..0000000 --- a/I18n/backOffice/default/en_US.php +++ /dev/null @@ -1,4 +0,0 @@ - 'Añadir', - 'Add a submenu' => 'Añadir un submenú', - 'Add a menu' => 'Añadir un menú', - 'Add a new item to menu' => 'Añadir un nuevo elemento al menú', - 'Add a new menu' => 'Añadir un nuevo menú', - 'Add a new menu item' => 'Añadir un nuevo elemento al menú', - 'Add a new parent' => 'Añadir un nuevo padre', - 'Cancel' => 'Cancelar', - 'Cannot use back quote ( ` )' => 'No puede usar comilla invertida ( ` )', - 'Close' => 'Cerrar', - 'Create' => 'Crear', - 'Create a new menu' => 'Crear un nuevo menú', - 'Create this menu' => 'Crear este menú', - 'Delete' => 'Eliminar', - 'Delete link' => 'Eliminar el enlace', - 'Delete menu' => 'Eliminar el menú', - 'Delete this menu' => 'Eliminar este menú', - 'Discard changes' => 'Descartar cambios', - 'Edit a menu item' => 'Editar un elemento del menú', - 'Edit menu' => 'Editar el menú', - 'Hide all children' => 'Ocultar todos los hijos', - 'Item name' => 'Nombre del elemento', - 'Item title' => 'Título del elemento', - 'Menu' => 'Menú', - 'Menu name' => 'Nombre del menú', - 'Menu name cannot be empty' => 'El menú no puede estar vacío', - 'Menu title' => 'Título del menú', - 'No parent' => 'Sin padre', - 'No target item' => 'Sin elemento objetivo', - 'Parent' => 'Padre', - 'Personal url' => 'URL personal', - 'Position' => 'Posición', - 'Update' => 'Actualizar', - 'Update item' => 'Actualizar el elemento', - 'Preview menu' => 'Vista previa del menú', - 'Save' => 'Guardar', - 'Save everything' => 'Guardar todo', - 'Select a category' => 'Seleccione una categoría', - 'Show all children' => 'Mostrar todos los hijos', - 'Submenu name' => 'Nombre del submenú', - 'Warning: You have unsaved changes!' => 'Advertencia: ¡Tienes cambios sin guardar!', - 'Target item' => 'Elemento objetivo', - 'Unsaved changes' => 'Cambios no guardados', - 'Would you like to delete this menu item ?' => '¿Le gustaría eliminar este elemento del menú?', - 'Would you like to delete this menu ?' => '¿Le gustaría eliminar este menú?', - 'Would you like to discard all changes ?' => '¿Le gustaría descartar todos los cambios?', - 'You have unsaved changes. If you don\'t save them your changes will be lost' => 'Tiene cambios sin guardar. Si no los guarda, sus cambios se perderán.', - '-- language to translate into --' => '-- idioma al que traducir --', -); diff --git a/I18n/backOffice/default/fr_FR.php b/I18n/backOffice/default/fr_FR.php deleted file mode 100644 index f02dfe7..0000000 --- a/I18n/backOffice/default/fr_FR.php +++ /dev/null @@ -1,52 +0,0 @@ - 'Ajouter', - 'Add a submenu' => 'Ajouter un sous menu ', - 'Add a menu' => 'Ajouter un menu', - 'Add a new item to menu' => 'Ajouter un nouvel élément au menu', - 'Add a new menu' => 'Ajouter un menu', - 'Add a new menu item' => 'Ajouter un nouvel élément au menu', - 'Add a new parent' => 'Ajouter un nouveau parent', - 'Cancel' => 'Annuler', - 'Cannot use back quote ( ` )' => 'Vous ne pouvez pas utiliser de back quote ( ` )', - 'Close' => 'Fermer', - 'Create' => 'Créer', - 'Create a new menu' => 'Créer un nouveau menu', - 'Create this menu' => 'Créer ce menu', - 'Delete' => 'Supprimer', - 'Delete link' => 'Supprimer le lien', - 'Delete menu' => 'Supprimer un menu', - 'Delete this menu' => 'Supprimer ce menu', - 'Discard changes' => 'Annuler les modifications', - 'Edit a menu item' => 'Modifier un élément du menu', - 'Edit menu' => 'Modifier le menu', - 'Hide all children' => 'Masquer les enfants', - 'Item name' => 'Nom de l\'élément', - 'Item title' => 'Titre de l\'élément', - 'Menu' => 'Menu', - 'Menu name' => 'Nom du menu', - 'Menu name cannot be empty' => 'Le nom d\'un menu ne peut pas être vide', - 'Menu title' => "Titre du menu", - 'No parent' => 'Aucun parent', - 'No target item' => 'Pas d\'élément cible', - 'Parent' => 'Parent', - 'Personal url' => 'Url personnelle', - 'Position' => 'Position', - 'Update' => 'Mise à jour', - 'Update item' => 'Mise à jour de l\'élément', - 'Preview menu' => 'Prévisualisation du menu', - 'Save' => 'Enregistrer', - 'Save everything' => 'Tout enregistrer', - 'Select a category' => 'Choisissez une categorie', - 'Show all children' => 'Voir tous les enfants', - 'Submenu name' => 'Nom du sous-menu', - 'Target item' => 'Élément cible', - 'Unsaved changes' => 'Changements non sauvegardés', - 'Warning: You have unsaved changes!' => 'Attention : vous avez des modifications non enregistrées !', - 'Would you like to delete this menu item ?' => 'Voulez-vous supprimer cet élément du menu ?', - 'Would you like to delete this menu ?' => 'Voulez-vous supprimer ce menu ?', - 'Would you like to discard all changes ?' => 'Voulez-vous annuler les modifications ?', - 'You have unsaved changes. If you don\'t save them your changes will be lost' => 'Vous avez des changements non enregistrés. Si vous ne les enregistrez pas vos modifications seront perdues', - '-- language to translate into --' => '-- langue dans laquelle traduire --', -); diff --git a/I18n/backOffice/default/it_IT.php b/I18n/backOffice/default/it_IT.php deleted file mode 100644 index fcea16f..0000000 --- a/I18n/backOffice/default/it_IT.php +++ /dev/null @@ -1,52 +0,0 @@ - 'Aggiungi', - 'Add a submenu' => 'Aggiungi un sottomenu', - 'Add a menu' => 'Aggiungi un menu', - 'Add a new item to menu' => 'Aggiungi un nuovo elemento al menu', - 'Add a new menu' => 'Aggiungi un nuovo menu', - 'Add a new menu item' => 'Aggiungi un nuovo elemento al menu', - 'Add a new parent' => 'Aggiungi un nuovo genitore', - 'Cancel' => 'Annulla', - 'Cannot use back quote ( ` )' => 'Non può usare il back quote ( ` )', - 'Close' => 'Chiudi', - 'Create' => 'Crea', - 'Create a new menu' => 'Crea un nuovo menu', - 'Create this menu' => 'Crea questo menu', - 'Delete' => 'Elimina', - 'Delete link' => 'Elimina il link', - 'Delete menu' => 'Elimina il menu', - 'Delete this menu' => 'Elimina questo menu', - 'Discard changes' => 'Annulla le modifiche', - 'Edit a menu item' => 'Modifica un elemento del menu', - 'Edit menu' => 'Modifica il menu', - 'Hide all children' => 'Nascondi tutti i figli', - 'Item name' => 'Nome dell\'elemento', - 'Item title' => 'Titolo dell\'elemento', - 'Menu' => 'Menu', - 'Menu name' => 'Nome del menu', - 'Menu name cannot be empty' => 'Il menu non può essere vuoto', - 'Menu title' => 'Titolo del menu', - 'No parent' => 'Nessun genitore', - 'No target item' => 'Nessun elemento target', - 'Parent' => 'Genitore', - 'Personal url' => 'URL personale', - 'Position' => 'Posizione', - 'Update' => 'Aggiorna', - 'Update item' => 'Aggiorna l\'elemento', - 'Preview menu' => 'Anteprima del menu', - 'Save' => 'Salva', - 'Save everything' => 'Salva tutto', - 'Select a category' => 'Seleziona una categoria', - 'Show all children' => 'Mostra tutti i figli', - 'Submenu name' => 'Nome del sottomenu', - 'Target item' => 'Elemento target', - 'Unsaved changes' => 'Modifiche non salvate', - 'Warning: You have unsaved changes!' => 'Attenzione: hai modifiche non salvate!', - 'Would you like to delete this menu item ?' => 'Desidera eliminare questo elemento del menu?', - 'Would you like to delete this menu ?' => 'Desidera eliminare questo menu?', - 'Would you like to discard all changes ?' => 'Desidera annullare tutte le modifiche?', - 'You have unsaved changes. If you don\'t save them your changes will be lost' => 'Sono presenti modifiche non salvate. Se non li salvi, le modifiche andranno perse', - '-- language to translate into --' => '-- lingua in cui tradurre --', -); diff --git a/I18n/es_ES.php b/I18n/es_ES.php index e514d92..14234c2 100644 --- a/I18n/es_ES.php +++ b/I18n/es_ES.php @@ -1,23 +1,16 @@ 'Marca', - 'Category' => 'Categoría', - 'Product' => 'Producto', - 'Content' => 'Contenido', - 'Folder' => 'Carpeta', - 'Link to' => 'Enlace a', - 'Menu name' => 'Nombre del menú', - 'New link' => 'Nuevo enlace', - 'No parent' => 'Sin padre', - 'Page' => 'Página', - 'Parent' => 'Padre', - 'URL' => 'URL', - - 'This menu does not exists' => 'Este menú no existe', - - 'Current menu deleted successfully' => 'El menú ha sido eliminado con éxito', - 'New menu added successfully' => 'Nuevo menú añadido con éxito', - 'This menu has been successfully saved !' => '¡Este menú ha sido guardado con éxito!' - + 'A code takes lowercase letters, digits and single dashes only' => 'Un código solo acepta minúsculas, dígitos y guiones simples', + 'A menu name is required' => 'El nombre del menú es obligatorio', + 'An entry name is required' => 'El nombre de la entrada es obligatorio', + 'Current menu deleted successfully' => 'El menú se ha eliminado correctamente', + 'New menu added successfully' => 'Nuevo menú añadido correctamente', + 'Pick a target for this entry' => 'Elija un destino para esta entrada', + 'This code is already used by another menu' => 'Este código ya lo usa otro menú', + 'This entry has been deleted' => 'La entrada se ha eliminado', + 'This entry has been successfully saved' => 'La entrada se ha guardado', + 'This menu does not exist' => 'Este menú no existe', + 'This menu entry does not exist' => 'Esta entrada de menú no existe', + 'This menu has been successfully saved' => 'El menú se ha guardado', ); diff --git a/I18n/fr_FR.php b/I18n/fr_FR.php index 8e11684..a241723 100755 --- a/I18n/fr_FR.php +++ b/I18n/fr_FR.php @@ -1,23 +1,16 @@ 'Marque', - 'Category' => 'Catégorie', - 'Product' => 'Produit', - 'Content' => 'Contenu', - 'Folder' => 'Fichier', - 'Link to' => 'Lien à', - 'Menu name' => 'Nom du menu', - 'New link' => 'Nouveau lien', - 'No parent' => 'Aucun parent', - 'Page' => 'Page', - 'Parent' => 'Parent', - 'URL' => 'URL', - - 'This menu does not exists' => "Ce menu n'existe pas", - + 'A code takes lowercase letters, digits and single dashes only' => 'Un code n\'accepte que des minuscules, des chiffres et des tirets simples', + 'A menu name is required' => 'Le nom du menu est obligatoire', + 'An entry name is required' => 'Le nom de l\'entrée est obligatoire', 'Current menu deleted successfully' => 'Le menu a été supprimé avec succès', 'New menu added successfully' => 'Nouveau menu ajouté avec succès', - 'This menu has been successfully saved !' => 'Ce menu a été sauvegardé avec succès !', - + 'Pick a target for this entry' => 'Choisissez une cible pour cette entrée', + 'This code is already used by another menu' => 'Ce code est déjà utilisé par un autre menu', + 'This entry has been deleted' => 'L\'entrée a été supprimée', + 'This entry has been successfully saved' => 'L\'entrée a été enregistrée', + 'This menu does not exist' => 'Ce menu n\'existe pas', + 'This menu entry does not exist' => 'Cette entrée de menu n\'existe pas', + 'This menu has been successfully saved' => 'Le menu a été enregistré', ); diff --git a/I18n/it_IT.php b/I18n/it_IT.php index f4e38e2..062ac8b 100644 --- a/I18n/it_IT.php +++ b/I18n/it_IT.php @@ -1,23 +1,16 @@ 'Marca', - 'Category' => 'Categoria', - 'Product' => 'Prodotto', - 'Content' => 'Contenuto', - 'Folder' => 'Cartella', - 'Link to' => 'Collegamento a', - 'Menu name' => 'Nome del menu', - 'New link' => 'Nuovo collegamento', - 'No parent' => 'Nessun genitore', - 'Page' => 'Pagina', - 'Parent' => 'Genitore', - 'URL' => 'URL', - - 'This menu does not exists' => 'Questo menu non esiste', - - 'Current menu deleted successfully' => 'Il menu è stato eliminato con successo', - 'New menu added successfully' => 'Nuovo menu aggiunto con successo', - 'This menu has been successfully saved !' => 'Questo menu è stato salvato con successo!' - + 'A code takes lowercase letters, digits and single dashes only' => 'Un codice accetta solo minuscole, cifre e trattini singoli', + 'A menu name is required' => 'Il nome del menu è obbligatorio', + 'An entry name is required' => 'Il nome della voce è obbligatorio', + 'Current menu deleted successfully' => 'Il menu è stato eliminato correttamente', + 'New menu added successfully' => 'Nuovo menu aggiunto correttamente', + 'Pick a target for this entry' => 'Scegli una destinazione per questa voce', + 'This code is already used by another menu' => 'Questo codice è già usato da un altro menu', + 'This entry has been deleted' => 'La voce è stata eliminata', + 'This entry has been successfully saved' => 'La voce è stata salvata', + 'This menu does not exist' => 'Questo menu non esiste', + 'This menu entry does not exist' => 'Questa voce di menu non esiste', + 'This menu has been successfully saved' => 'Il menu è stato salvato', ); diff --git a/README.md b/README.md index 128e0f7..66aa3f7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ This module lets you create dynamic menus. ### Prerequisites -The OpenApi module must be activated to enable CustomFrontMenu. +Thelia 3.0 or later. The menu API is served by the core API Platform stack: no +additional module is required. ### Manually @@ -33,16 +34,57 @@ Menu items can be added, deleted, renamed or moved. Translations can be made dir Each menu item is linked to a URL. This can be entered directly or associated with a `brand`, `category`, `content`, `folder` or `product`. -In front-office, each menu should be called by a smarty plugin manually. +In front-office, the `custom_front_menu()` Twig function answers the composed tree. It +returns **data, not markup**: a navigation is where your layout, breakpoints and +interaction live, so the module hands you the nodes and you write the elements you want. -To override the css file, you can replace or modify : `templates/frontOffice/default/assets/css/customFrontMenu.css.html`. +A menu is addressed by its **code**, not by its id: the id comes from an autoincrement and +differs from one installation to the next, so a theme built on it breaks on the next shop. +The code is yours to choose when you create the menu, and the composition screen shows the +exact call to copy. ## Example -```smarty -{CustomFrontMenuPlugin menu_id=388} +```twig +{% for item in custom_front_menu('header') %} + {{ item.title }} + + {% for child in item.children %} + {{ child.title }} + {% endfor %} +{% endfor %} +``` + +Each node is `{id, title, href, children}`, nested to any depth. An entry with no target +has an empty `href`: it is a label, and it is yours to render as one. + +`href` is always `http(s)`, site-relative, or empty — filtered on the way out, so an +address entered before this rule existed cannot reach your template either. `title` is +plain text that your own template must escape, as Twig does by default. + +The visitor's locale is used by default. Pass a second argument to force one: + +```twig +{% for item in custom_front_menu('header', 'fr_FR') %} ``` +An unknown code answers an empty list rather than raising: a menu deleted in the +back-office must not take the storefront down. + +Entries whose target is deleted or unpublished are dropped from the rendered menu. + +## API + +The composed tree is also readable over HTTP, read-only: + +``` +GET /api/front/custom-front-menus/{code} +``` + +It answers `{code, items}`, each item being `{id, title, href, children}`. It replaces the +`open_api/custom-front-menu/{id}` endpoint of the 1.x line, which relied on the OpenApi +module that Thelia 3 no longer ships. + _________________ ## Version française @@ -55,7 +97,8 @@ Ce module vous permet de créer des menus dynamiques. ### Prérequis -Le module OpenApi doit être activé pour utiliser CustomFrontMenu. +Thelia 3.0 ou supérieur. L'API du menu est servie par API Platform, fourni par le +cœur : aucun module supplémentaire n'est requis. ### Manuellement @@ -80,12 +123,40 @@ Les éléments du menu peuvent être ajoutés, supprimés, renommés ou déplac Chaque élément du menu est lié à une URL. Celle-ci peut être saisie directement ou associée à un `brand`, `category`, `content`, `folder` ou `product`. -Dans le front-office, chaque menu doit être appelé manuellement par un plugin smarty. +Dans le front-office, la fonction Twig `custom_front_menu()` rend l'arbre composé. Elle +rend **des données, pas du markup** : une navigation est l'endroit où vivent la mise en +page, les points de rupture et les interactions du thème, donc le module donne les nœuds et +l'intégrateur écrit les éléments qu'il veut. -Pour remplacer le fichier css, vous pouvez remplacer ou modifier : `templates/frontOffice/default/assets/css/customFrontMenu.css.html`. +Un menu s'appelle par son **code**, pas par son identifiant : l'identifiant vient d'un +auto-incrément et change d'une installation à l'autre, donc un thème qui s'appuie dessus +casse sur la boutique suivante. Le code est choisi à la création du menu, et l'écran de +composition affiche l'appel exact à recopier. ## Exemple -```smarty -{CustomFrontMenuPlugin menu_id=388} +```twig +{% for item in custom_front_menu('header') %} + {{ item.title }} + + {% for child in item.children %} + {{ child.title }} + {% endfor %} +{% endfor %} +``` + +Chaque nœud est `{id, title, href, children}`, imbriqué à toute profondeur. Une entrée sans +cible a un `href` vide : c'est un libellé, à rendre comme tel. + +`href` est toujours `http(s)`, relatif au site, ou vide — filtré à la sortie, donc une +adresse saisie avant l'existence de cette règle n'atteint pas non plus votre gabarit. +`title` est du texte brut, que votre gabarit doit échapper, comme Twig le fait par défaut. + +La locale du visiteur est utilisée par défaut. Un second argument permet de la forcer : + +```twig +{% for item in custom_front_menu('header', 'fr_FR') %} ``` + +Un code inconnu rend une liste vide, sans lever d'exception : un menu supprimé au +back-office ne doit pas emporter la boutique. diff --git a/Service/BackOffice/MenuComposer.php b/Service/BackOffice/MenuComposer.php new file mode 100644 index 0000000..36d5ab0 --- /dev/null +++ b/Service/BackOffice/MenuComposer.php @@ -0,0 +1,269 @@ +findRoot(); + + if (null === $root) { + $root = new CustomFrontMenuItem(); + $root->makeRoot(); + $root->save(); + } + + return $root; + } + + /** + * @return list + * + * @throws PropelException + */ + public function menus(): array + { + return iterator_to_array($this->root()->getChildren()); + } + + /** + * @throws PropelException + */ + public function menu(int $menuId): ?CustomFrontMenuItem + { + $menu = CustomFrontMenuItemQuery::create()->findOneById($menuId); + + // Level 1 is a menu; anything deeper is an entry inside one. + return $menu && 1 === $menu->getLevel() ? $menu : null; + } + + /** + * A menu by the code a theme calls it by. Only a level-1 row carries a code, so this + * can never answer with the nested-set root or with an entry. + * + * @throws PropelException + */ + public function menuByCode(string $code): ?CustomFrontMenuItem + { + $menu = CustomFrontMenuItemQuery::create()->findOneByCode($code); + + return $menu && 1 === $menu->getLevel() ? $menu : null; + } + + /** + * @throws PropelException + */ + public function entry(int $itemId): ?CustomFrontMenuItem + { + $item = CustomFrontMenuItemQuery::create()->findOneById($itemId); + + return $item && $item->getLevel() > 1 ? $item : null; + } + + /** + * @throws PropelException + */ + public function createMenu(string $title, string $locale, string $code = ''): CustomFrontMenuItem + { + $menu = new CustomFrontMenuItem(); + $menu->insertAsLastChildOf($this->root()); + // A typed code reached here already validated and free; only an empty one is + // derived from the name. + $menu->setCode('' === $code ? MenuCode::derive($title) : $code); + $menu->save(); + + $this->setTranslation($menu, $locale, $title, null); + + return $menu; + } + + /** + * Rename a menu, and give it the code a theme will call it by. + * + * An empty code is derived from the new name: a menu with no code is unreachable from + * a theme, which is the one state this column exists to prevent. + * + * @throws PropelException + */ + public function renameMenu(CustomFrontMenuItem $menu, string $title, string $code, string $locale): void + { + $menu + ->setCode('' === $code ? MenuCode::derive($title, (int) $menu->getId()) : $code) + ->save(); + + $this->setTranslation($menu, $locale, $title, null); + } + + /** + * @throws PropelException + */ + public function createEntry(CustomFrontMenuItem $parent, string $title, string $locale): CustomFrontMenuItem + { + $entry = new CustomFrontMenuItem(); + $entry->insertAsLastChildOf($parent); + $entry->save(); + + $this->setTranslation($entry, $locale, $title, null); + + return $entry; + } + + /** + * @throws PropelException + */ + public function delete(CustomFrontMenuItem $item): void + { + foreach ($item->getDescendants() as $descendant) { + $this->deleteTranslations((int) $descendant->getId()); + } + + $item->deleteDescendants(); + $this->deleteTranslations((int) $item->getId()); + $item->delete(); + } + + /** + * Reparent one entry. Passing the menu itself as the new parent moves the entry back + * to the first level of that menu. + * + * @throws PropelException + */ + public function move(CustomFrontMenuItem $item, CustomFrontMenuItem $newParent): void + { + // Moving a node inside its own subtree would corrupt the nested set. + if ($this->isSelfOrDescendant($item, $newParent)) { + return; + } + + $item->moveToLastChildOf($newParent); + } + + /** + * @throws PropelException + */ + public function moveUp(CustomFrontMenuItem $item): void + { + $sibling = $item->getPrevSibling(); + + if ($sibling instanceof CustomFrontMenuItem) { + $item->moveToPrevSiblingOf($sibling); + } + } + + /** + * @throws PropelException + */ + public function moveDown(CustomFrontMenuItem $item): void + { + $sibling = $item->getNextSibling(); + + if ($sibling instanceof CustomFrontMenuItem) { + $item->moveToNextSiblingOf($sibling); + } + } + + /** + * @throws PropelException + */ + public function setTarget(CustomFrontMenuItem $item, ?string $view, ?int $viewId): void + { + $item + ->setView($view) + ->setViewId($viewId) + ->save(); + } + + /** + * @throws PropelException + */ + public function setTranslation(CustomFrontMenuItem $item, string $locale, ?string $title, ?string $url): void + { + $translation = CustomFrontMenuItemI18nQuery::create() + ->filterById($item->getId()) + ->findOneByLocale($locale); + + if (null === $translation) { + $translation = new CustomFrontMenuItemI18n(); + $translation + ->setId($item->getId()) + ->setLocale($locale); + } + + $translation + ->setTitle($title) + ->setUrl($url) + ->save(); + } + + /** + * @return array + * + * @throws PropelException + */ + public function translations(CustomFrontMenuItem $item): array + { + $translations = []; + + foreach (CustomFrontMenuItemI18nQuery::create()->findById($item->getId()) as $translation) { + $translations[$translation->getLocale()] = [ + 'title' => $translation->getTitle(), + 'url' => $translation->getUrl(), + ]; + } + + return $translations; + } + + /** + * @throws PropelException + */ + private function isSelfOrDescendant(CustomFrontMenuItem $item, CustomFrontMenuItem $candidate): bool + { + return $candidate->getTreeLeft() >= $item->getTreeLeft() + && $candidate->getTreeRight() <= $item->getTreeRight(); + } + + /** + * @throws PropelException + */ + private function deleteTranslations(int $itemId): void + { + CustomFrontMenuItemI18nQuery::create()->filterById($itemId)->delete(); + } +} diff --git a/Service/BackOffice/MenuTargetCatalog.php b/Service/BackOffice/MenuTargetCatalog.php new file mode 100644 index 0000000..4066d78 --- /dev/null +++ b/Service/BackOffice/MenuTargetCatalog.php @@ -0,0 +1,83 @@ + tag per row, so a shop with + * ten thousand products shipped ten thousand script tags. This runs the same five queries + * once and hands the screen a single payload to put in a data- attribute. + */ +final readonly class MenuTargetCatalog +{ + private const FALLBACK_LOCALE = 'en_US'; + + /** + * @return array>> + */ + public function targets(?string $locale = null): array + { + $locale ??= self::FALLBACK_LOCALE; + + return [ + 'brand' => $this->rows(BrandQuery::create(), $locale), + 'category' => $this->rows(CategoryQuery::create(), $locale), + 'content' => $this->rows(ContentQuery::create(), $locale), + 'folder' => $this->rows(FolderQuery::create(), $locale), + 'product' => $this->rows(ProductQuery::create(), $locale, withReference: true), + ]; + } + + /** + * @param BrandQuery|CategoryQuery|ContentQuery|FolderQuery|ProductQuery $query + * + * @return list> + */ + private function rows(ModelCriteria $query, string $locale, bool $withReference = false): array + { + // LEFT_JOIN, not the default inner join: an item with no translation in this + // locale must still be pickable, titleless rather than absent. + $results = $query + ->joinWithI18n($locale, Criteria::LEFT_JOIN) + ->orderById() + ->find(); + + $rows = []; + + foreach ($results as $result) { + $row = [ + 'id' => (int) $result->getId(), + 'title' => (string) $result->getTitle(), + ]; + + if ($withReference) { + $row['reference'] = (string) $result->getRef(); + } + + $rows[] = $row; + } + + return $rows; + } +} diff --git a/Service/BackOffice/MenuTreePresenter.php b/Service/BackOffice/MenuTreePresenter.php new file mode 100644 index 0000000..48d77c2 --- /dev/null +++ b/Service/BackOffice/MenuTreePresenter.php @@ -0,0 +1,171 @@ + BrandQuery::class, + 'category' => CategoryQuery::class, + 'content' => ContentQuery::class, + 'folder' => FolderQuery::class, + 'product' => ProductQuery::class, + ]; + + public function __construct( + private TranslatorInterface $translator, + ) { + } + + /** + * @return list> + * + * @throws PropelException + */ + public function tree(CustomFrontMenuItem $parent, string $locale, int $depth = 0): array + { + $nodes = []; + + foreach ($parent->getChildren() as $child) { + $nodes[] = [ + 'id' => (int) $child->getId(), + 'title' => $this->title($child, $locale), + 'depth' => $depth, + 'target' => $this->target($child, $locale), + 'children' => $child->hasChildren() ? $this->tree($child, $locale, $depth + 1) : [], + ]; + } + + return $nodes; + } + + /** + * @throws PropelException + */ + public function title(CustomFrontMenuItem $item, string $locale): string + { + $translations = CustomFrontMenuItemI18nQuery::create()->findById($item->getId()); + $fallback = ''; + + foreach ($translations as $translation) { + $title = (string) $translation->getTitle(); + + if ($translation->getLocale() === $locale && '' !== $title) { + return $title; + } + + if ('' === $fallback) { + $fallback = $title; + } + } + + return '' !== $fallback + ? $fallback + : $this->translator->trans('Untitled', [], 'customfrontmenu.bo.default-twig'); + } + + /** + * What the entry points at, and whether the front will render it. + * + * @return array{kind: string, label: string, ok: bool} + * + * @throws PropelException + */ + private function target(CustomFrontMenuItem $item, string $locale): array + { + $domain = 'customfrontmenu.bo.default-twig'; + $view = strtolower((string) $item->getView()); + $viewId = (int) $item->getViewId(); + + if (!isset(self::TARGET_QUERIES[$view]) || $viewId <= 0) { + $url = $this->url($item, $locale); + + if ('' !== $url) { + return ['kind' => 'url', 'label' => $url, 'ok' => true]; + } + + return [ + 'kind' => 'none', + 'label' => $this->translator->trans('No target', [], $domain), + 'ok' => true, + ]; + } + + $queryClass = self::TARGET_QUERIES[$view]; + $target = $queryClass::create()->findPk($viewId); + + if (null === $target) { + return [ + 'kind' => $view, + 'label' => $this->translator->trans('Deleted target (#%id%)', ['%id%' => $viewId], $domain), + 'ok' => false, + ]; + } + + $target->setLocale($locale); + $label = (string) $target->getTitle(); + + // Unpublished: the entry is dropped from the front, so say so here. + if (!$target->getVisible()) { + return [ + 'kind' => $view, + 'label' => $this->translator->trans('%title% (offline)', ['%title%' => $label], $domain), + 'ok' => false, + ]; + } + + return ['kind' => $view, 'label' => $label, 'ok' => true]; + } + + /** + * @throws PropelException + */ + private function url(CustomFrontMenuItem $item, string $locale): string + { + $translations = CustomFrontMenuItemI18nQuery::create()->findById($item->getId()); + $fallback = ''; + + foreach ($translations as $translation) { + $url = (string) $translation->getUrl(); + + if ($translation->getLocale() === $locale && '' !== $url) { + return $url; + } + + if ('' === $fallback) { + $fallback = $url; + } + } + + return $fallback; + } +} diff --git a/Service/CustomFrontMenuLoadService.php b/Service/CustomFrontMenuLoadService.php deleted file mode 100644 index 394792e..0000000 --- a/Service/CustomFrontMenuLoadService.php +++ /dev/null @@ -1,217 +0,0 @@ -getChildren(); - $dataArray = []; - foreach ($descendants as $descendant) { - $newArray = []; - $newArray['id'] = 'menu-selected-' . $descendant->getId(); - $content = CustomFrontMenuItemI18nQuery::create() - ->filterById($descendant->getId()) - ->findOneByLocale('en_US'); - - $newArray['title'] = $content->getTitle() . ' (id: ' . $descendant->getId() . ')'; - $dataArray[] = $newArray; - } - return $dataArray; - } - - /** - * Generate an url basis on a view type and an id to get the associated content page. - */ - public function generateUrl(string $type, int $id, string $lang = null): string - { - // url of type http://cfm.th/?view=product&product_id=21&lang=en_US - - $parameters = ['view' => strtolower($type), strtolower($type).'_id' => $id]; - if($lang) { - $parameters['lang'] = $lang; - } - return URL::getInstance()->absoluteUrl('', $parameters); - } - - /** - * Load all elements from the database recursively to parse them in an array - * @param CustomFrontMenuItem $parent - * @return array All the descendants items of the menu root given in parameter - * @throws PropelException - * @throws Exception - */ - public function loadTableBrowser(CustomFrontMenuItem $parent) : array - { - $dataArray = []; - - /** @var Session $session */ - $session = $this->requestStack->getCurrentRequest()->getSession(); - - $descendants = $parent->getChildren(); - foreach ($descendants as $descendant) { - $newArray = []; - $I18nMenus = CustomFrontMenuItemI18nQuery::create() - ->findById($descendant->getId()); - - if (count($I18nMenus) <= 0){ - throw new PropelException('No content found for the given id:' . $descendant->getId()); - } - - $view = $descendant->getView(); - if (!$view){ - $view = 'url'; - } - $newArray['type'] = $view; - foreach ($I18nMenus as $I18nMenu) { - $newArray['title'][$I18nMenu->getLocale()] = $I18nMenu->getTitle(); - if($view === 'url') { - $newArray['url'][$I18nMenu->getLocale()] = $I18nMenu->getUrl(); - } - } - - - $viewId = $descendant->getViewId(); - - if($view && $viewId && Validator::viewIsValid($view)) { - - $formatedView = ucfirst($view); - $class = 'Thelia\Model\\' . $formatedView . 'Query'; - if (!class_exists($class)) { - throw new Exception("Class $class does not exist."); - } - /** @var CategoryQuery|ProductQuery|FolderQuery|ContentQuery|BrandQuery $objectQuery */ - $objectQuery = $class::create(); - - $query = $objectQuery - ->filterById($viewId) - ->joinWith($formatedView.'I18n') - ->find(); - - $queryI18n = $query->getColumnValues($formatedView.'I18ns')[0]; - - if ($query->isEmpty()) { - throw new Exception("No results found for the specified id $viewId."); - } - - $title = null; - foreach ($queryI18n as $item) { - if ($item->getLocale() === $session->getAdminLang()->getLocale()) { - $title = $item->getTitle(); - break; - } - if ($item->getLocale() === 'en_US') { - $title = $item->getTitle(); - } - } - if (!$title) { - $title = $queryI18n[0]->getTitle(); - } - $newArray['typeId'] = $title.'-'.$viewId; - if (strtolower($view) === 'product') { - $newArray['typeId'] = $title.'-'.$query->getFirst()->getRef().'-'.$viewId; ; - } - } - - $newArray['depth'] = $descendant->getLevel() - 2; - $newArray['id'] = $this->COUNT_ID; - ++$this->COUNT_ID; - - if ($descendant->hasChildren()) { - $newArray['children'] = $this->loadTableBrowser($descendant); - } - $dataArray[] = $newArray; - } - return $dataArray; - } - - /** - * Load all elements from the database recursively to parse them in an array with a lang - * @param CustomFrontMenuItem $parent - * @param string $lang - * @return array All the descendants items of the menu root given in parameter - * @throws PropelException - */ - public function loadTableBrowserLang(CustomFrontMenuItem $parent, string $lang) : array - { - $dataArray = []; - $descendants = $parent->getChildren(); - foreach ($descendants as $descendant) { - $newArray = []; - $I18nMenus = CustomFrontMenuItemI18nQuery::create()->findById($descendant->getId()); - - if (count($I18nMenus) <= 0){ - throw new PropelException('No content found for the given id:' . $descendant->getId()); - } - - $found = false; - $title = ''; - $url = ''; - foreach ($I18nMenus as $I18nMenu) { - if ($I18nMenu->getLocale() === $lang) { - $title = $I18nMenu->getTitle(); - $url = $I18nMenu->getUrl(); - $found = true; - break; - } - elseif ($I18nMenu->getLocale() === 'en_US') { - $title = $I18nMenu->getTitle(); - $url = $I18nMenu->getUrl(); - } - } - - if (!$found) { - $title = $I18nMenus->getColumnValues('title')[0]; - $url = $I18nMenus->getColumnValues('url')[0]; - } - - $newArray['title'] = $title; - $newArray['url'] = $url; - - if (Validator::viewIsValid($descendant->getView())) { - $view = $descendant->getView(); - $viewId = $descendant->getViewId(); - if ($view && $viewId) { - $newArray['url'] = $this->generateUrl($view, $viewId, $lang); - } - } - - $newArray['depth'] = $descendant->getLevel() - 2; - $newArray['id'] = $this - ->COUNT_ID; - ++$this->COUNT_ID; - - if ($descendant->hasChildren()) { - $newArray['children'] = $this->loadTableBrowserLang($descendant, $lang); - } - $dataArray[] = $newArray; - } - return $dataArray; - } -} \ No newline at end of file diff --git a/Service/CustomFrontMenuSaveService.php b/Service/CustomFrontMenuSaveService.php deleted file mode 100644 index 683c0c5..0000000 --- a/Service/CustomFrontMenuSaveService.php +++ /dev/null @@ -1,135 +0,0 @@ -findOneById($menuId); - - $descendants = $menu->getDescendants(); - foreach ($descendants as $descendant) { - CustomFrontMenuItemI18nQuery::create()->findById($descendant->getId())->delete(); - } - $menu->deleteDescendants(); - - $menu->save(); - return $menu; - } - - - /** - * Save all elements from an array recursively to the database - * @throws PropelException - * @throws Exception - */ - public function saveTableBrowser(array $dataArray, CustomFrontMenuItem $parent) : void - { - /** @var Session $session */ - $session = $this->requestStack->getCurrentRequest()->getSession(); - $adminLocale = $session->getAdminLang()->getLocale(); - - foreach ($dataArray as $element) { - - $item = new CustomFrontMenuItem(); - $item->insertAsLastChildOf($parent) - ->save(); - - if(strtolower($element['type']) === 'url') { - foreach ($element['url'] as $locale => $url) { - $content = new CustomFrontMenuItemI18n(); - $content->setId($item->getId()) - ->setLocale($locale); - if ($url) { - $content->setUrl(Validator::filterValidation(Validator::htmlSafeValidation($url, $session), FilterType::URL)); - } - $content->save(); - if(!isset($element['title'][$locale])) { - if (isset($element['title'][$adminLocale])) { - $element['title'][$locale] = $element['title'][$adminLocale]; - } else { - $found = false; - foreach ($element['title'] as $value) { - if (!$found && !is_null($value)) { - $element['title'][$locale] = $value; - $found = true; - } - } - if (!$found) { - $element['title'][$locale] = 'Empty string'; - } - } - } - } - } elseif (strtolower($element['type']) !== 'empty') { - $viewIdExploded = explode('-', $element['typeId']); - $item->setView(ucfirst(Validator::viewIsValidOrEmpty($element['type']))) - ->setViewId(intval(end($viewIdExploded))) - ->save(); - } else { - $item->setView('Empty') - ->setViewId('') - ->save(); - } - - foreach ($element['title'] as $locale => $title) { - $content = CustomFrontMenuItemI18nQuery::create() - ->filterById($item->getId()) - ->findOneByLocale($locale); - - if ($content === null) { - $content = new CustomFrontMenuItemI18n(); - $content->setId($item->getId()) - ->setLocale($locale); - } - - if(strtolower($element['type']) === 'url' && !isset($element['url'][$locale])) { - if (isset($element['url'][$adminLocale])) { - $content->setUrl(Validator::filterValidation(Validator::htmlSafeValidation($element['url'][$adminLocale], $session), FilterType::URL)); - } else { - $found = false; - foreach ($element['url'] as $value) { - if (!$found && !is_null($value)) { - $content->setUrl(Validator::filterValidation(Validator::htmlSafeValidation($value, $session), FilterType::URL)); - $found = true; - } - } - if (!$found) { - $item->setView('Empty') - ->setViewId('') - ->save(); - } - } - } - - $content->setTitle(Validator::completeValidation($title, $session)) - ->save(); - } - - - - if (!empty($element['children'])) { - $this->saveTableBrowser($element['children'], $item); - } - - $parent->save(); - } - } - -} \ No newline at end of file diff --git a/Service/CustomFrontMenuService.php b/Service/CustomFrontMenuService.php deleted file mode 100644 index 14bb303..0000000 --- a/Service/CustomFrontMenuService.php +++ /dev/null @@ -1,63 +0,0 @@ -findRoot() === null) { - $root = new CustomFrontMenuItem(); - $root->makeRoot(); - $root->save(); - } else { - $root = CustomFrontMenuItemQuery::create()->findRoot(); - } - return $root; - } - - /** - * @throws PropelException - */ - public function addMenu(CustomFrontMenuItem $root, string $menuName) : int - { - $item = new CustomFrontMenuItem(); - $item->insertAsLastChildOf($root); - $item->save(); - - $content = new CustomFrontMenuItemI18n(); - $content->setTitle(Validator::completeValidation($menuName, $this->requestStack->getCurrentRequest()->getSession())); - $content->setId($item->getId()); - $content->setLocale('en_US'); - $content->save(); - - return $item->getId(); - } - - public function deleteMenu(int $menuId) : void - { - CustomFrontMenuItemI18nQuery::create()->findById($menuId)->delete(); - CustomFrontMenuItemQuery::create()->findById($menuId)->delete(); - } - - public function getMenu(int $menuId) : ?CustomFrontMenuItem - { - return CustomFrontMenuItemQuery::create()->findOneById($menuId); - } -} \ No newline at end of file diff --git a/Service/FilterType.php b/Service/FilterType.php deleted file mode 100644 index d18f98c..0000000 --- a/Service/FilterType.php +++ /dev/null @@ -1,9 +0,0 @@ - BrandQuery::class, + 'category' => CategoryQuery::class, + 'content' => ContentQuery::class, + 'folder' => FolderQuery::class, + 'product' => ProductQuery::class, + ]; + + /** + * A menu is addressed by its code, never by its id: the id comes from an autoincrement + * shared with the entries, so it differs from one installation to the next, while a + * theme that calls a menu has to keep working after a reinstall. + * + * The level check is what makes the code safe to trust: only a menu carries one, so + * this cannot be pointed at the nested-set root, whose children are the menus + * themselves, nor at an entry in the middle of a tree. + * + * @return list>|null null when no such menu exists + * + * @throws PropelException + */ + public function resolve(string $code, string $locale): ?array + { + if ('' === $code) { + return null; + } + + $menu = CustomFrontMenuItemQuery::create()->findOneByCode($code); + + if (null === $menu || 1 !== $menu->getLevel()) { + return null; + } + + return $this->branch($menu, $locale); + } + + /** + * @return list> + * + * @throws PropelException + */ + private function branch(CustomFrontMenuItem $parent, string $locale): array + { + $nodes = []; + + foreach ($parent->getChildren() as $child) { + $node = $this->node($child, $locale); + + if (null === $node) { + continue; + } + + $node['children'] = $child->hasChildren() ? $this->branch($child, $locale) : []; + $nodes[] = $node; + } + + return $nodes; + } + + /** + * @return array|null null when the entry must not be rendered + * + * @throws PropelException + */ + private function node(CustomFrontMenuItem $item, string $locale): ?array + { + $view = strtolower((string) $item->getView()); + $viewId = (int) $item->getViewId(); + + // A typed entry stands or falls with its target; a free URL or an untargeted + // label has nothing to check. + if (isset(self::TARGET_QUERIES[$view]) && $viewId > 0) { + $href = $this->publishedTargetUrl($view, $viewId, $locale); + + if (null === $href) { + return null; + } + + return [ + 'id' => (int) $item->getId(), + 'title' => $this->title($item, $locale), + 'href' => $href, + ]; + } + + return [ + 'id' => (int) $item->getId(), + 'title' => $this->title($item, $locale), + 'href' => $this->freeUrl($item, $locale), + ]; + } + + /** + * @throws PropelException + */ + private function publishedTargetUrl(string $view, int $viewId, string $locale): ?string + { + $queryClass = self::TARGET_QUERIES[$view]; + + $target = $queryClass::create() + ->filterByVisible(1) + ->findPk($viewId); + + return $target?->getUrl($locale); + } + + /** + * @throws PropelException + */ + private function title(CustomFrontMenuItem $item, string $locale): string + { + return $this->i18nValue($item, $locale, 'title'); + } + + /** + * Filtered again here, not only where it was written: rows saved by the 1.x screen + * went through FILTER_SANITIZE_URL, which leaves a `javascript:` URL untouched, and + * this value is rendered on every page and served by a public API. + * + * @throws PropelException + */ + private function freeUrl(CustomFrontMenuItem $item, string $locale): string + { + return MenuLink::filter($this->i18nValue($item, $locale, 'url')) ?? ''; + } + + /** + * Locale, then en_US, then whatever exists: an entry with no translation in the + * visitor's language still has to render. + * + * The back-office form writes one row per active language, so a language left blank + * exists in the table with an empty value. An empty value is not a translation: it + * must not win over, nor block, the fallback. + * + * @throws PropelException + */ + private function i18nValue(CustomFrontMenuItem $item, string $locale, string $column): string + { + $translations = CustomFrontMenuItemI18nQuery::create()->findById($item->getId()); + + $english = ''; + $any = ''; + + foreach ($translations as $translation) { + $value = trim((string) ('title' === $column ? $translation->getTitle() : $translation->getUrl())); + + if ('' === $value) { + continue; + } + + if ($translation->getLocale() === $locale) { + return $value; + } + + if ('en_US' === $translation->getLocale()) { + $english = $value; + } + + if ('' === $any) { + $any = $value; + } + } + + return '' !== $english ? $english : $any; + } +} diff --git a/Service/MenuCode.php b/Service/MenuCode.php new file mode 100644 index 0000000..2ca5bcc --- /dev/null +++ b/Service/MenuCode.php @@ -0,0 +1,91 @@ +filterByCode($code); + + if (null !== $exceptId) { + $query->filterById($exceptId, '!='); + } + + return $query->exists(); + } + + public static function slug(string $source): string + { + $slug = (new AsciiSlugger())->slug($source)->lower()->toString(); + + // A name written entirely in a script the slugger cannot transliterate leaves + // nothing behind, and a menu with no code is unreachable from a theme. + return self::isValid($slug) ? $slug : self::FALLBACK; + } + + /** + * A code derived from a menu name: the slug itself when it is free, otherwise the + * first numbered variant that is. + * + * Numbering is for derived codes only. A typed code that collides is refused, so the + * person is told rather than handed a code they did not ask for. + * + * @throws PropelException + */ + public static function derive(string $source, ?int $exceptId = null): string + { + $base = self::slug($source); + $candidate = $base; + $suffix = 1; + + while (self::isTaken($candidate, $exceptId)) { + $candidate = $base.'-'.++$suffix; + } + + return $candidate; + } +} diff --git a/Service/MenuLink.php b/Service/MenuLink.php new file mode 100644 index 0000000..452896a --- /dev/null +++ b/Service/MenuLink.php @@ -0,0 +1,49 @@ +getFlashBag()->add('warning', 'One or more empty fields were replaced by the tag "Empty field".'); - } - return self::backQuoteProhibited($string, $session); - } - - /** - * Replace back quotes with simple quotes and add a warning flash message. - */ - public static function backQuoteProhibited(string $string, SessionInterface $session) : string - { - $string = trim($string); - if (str_contains($string, '`')) { - $string = str_replace('`', "'", $string); - $session->getFlashBag()->add('warning', "One or more back quotes were replaced by simple quotes : ` -> ' ."); - } - return $string; - } - - public static function htmlSafeValidation(string $string, SessionInterface $session, bool $canBeEmpty = true) : string - { - $string = trim($string); - - $string = strip_tags($string); - - if (!$canBeEmpty) { - $string = self::stringValidation($string, $session); - } - - return $string; - } - - public static function sqlSafeValidation(string $string, SessionInterface $session, bool $canBeEmpty = true) : string - { - $string = trim($string); - - if (!$canBeEmpty) { - $string = self::stringValidation($string, $session); - } - - return addslashes($string); - } - - public static function filterValidation(string $string, int $filter): string - { - if (filter_var($string, $filter)) { - return $string; - } - return ''; - } - - /** - * Check the empty space, back quote, html and sql constraints - */ - public static function completeValidation(string $string, SessionInterface $session) : string - { - $string = self::stringValidation($string, $session); - $string = self::htmlSafeValidation($string, $session); - return self::sqlSafeValidation($string, $session, false); - } - - /** - * /** - * Check if the string is a valid view for the url generation. - * - * Valid strings : 'brand', 'category', 'content', 'folder', 'product'. - * (case-insensitive) - * @param ?string $string - * @return bool Return true if the view $string is valid - */ - public static function viewIsValid(?string $string) : bool - { - $type = strtolower($string); - $validTypes = ['brand', 'category', 'content', 'folder', 'product']; - if (!in_array($type, $validTypes)) { - return false; - } - return true; - } - - /** - * @throws Exception - */ - public static function viewIsValidOrEmpty(string $string) : string - { - $type = strtolower($string); - if (self::viewIsValid($type) || $type === 'empty') { - return $string; - } - throw new Exception('Invalid view type : '.$string); - } -} - diff --git a/Smarty/Plugins/CustomFrontMenuPlugin.php b/Smarty/Plugins/CustomFrontMenuPlugin.php deleted file mode 100644 index 1b1f50e..0000000 --- a/Smarty/Plugins/CustomFrontMenuPlugin.php +++ /dev/null @@ -1,53 +0,0 @@ -requestStack->getCurrentRequest()->getSession()->getLang()->getLocale(); - - if (!$params['menu_id']) { - throw new \InvalidArgumentException('The menu_id parameter is required', 1); - } - - $menu = $this->customFrontMenuService->getMenu($params['menu_id']); - if (!$menu) { - throw new \InvalidArgumentException('The menu does not exist', 2); - } - - $menuItems = $this->CustomFrontMenuLoadService->loadTableBrowserLang($menu, $lang); - $smarty->assign('menuItems', $menuItems); - - $cssPath = $smarty->getTemplateDir("CustomFrontMenu"). "assets/css/customFrontMenu.css.html"; - $smarty->display($cssPath); - $templatePath = $smarty->getTemplateDir("CustomFrontMenu"). "customFrontMenu.html"; - $smarty->display($templatePath); - } -} \ No newline at end of file diff --git a/Twig/CustomFrontMenuExtension.php b/Twig/CustomFrontMenuExtension.php new file mode 100644 index 0000000..6f95e8c --- /dev/null +++ b/Twig/CustomFrontMenuExtension.php @@ -0,0 +1,69 @@ +menu(...)), + ]; + } + + /** + * Nodes of {id, title, href, children}, children nested to any depth. + * + * An unknown code answers an empty list rather than raising: a {% for %} over it + * renders nothing, and a menu deleted in the back-office must not take a page down. + * + * @return list> + */ + public function menu(string $code, ?string $locale = null): array + { + return $this->treeResolver->resolve($code, $locale ?? $this->locale()) ?? []; + } + + private function locale(): string + { + /** @var Session|null $session */ + $session = $this->requestStack->getCurrentRequest()?->getSession(); + + return $session?->getLang()?->getLocale() ?? 'en_US'; + } +} diff --git a/composer.json b/composer.json index 4f089f9..9cd3aa0 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "license": "LGPL-3.0-or-later", "type": "thelia-module", "require": { - "thelia/installer": "~1.1" + "thelia/installer": "^1.6" }, "extra": { "installer-name": "CustomFrontMenu" diff --git a/templates/backOffice/default-twig/custom-front-menu/_target_field.html.twig b/templates/backOffice/default-twig/custom-front-menu/_target_field.html.twig new file mode 100644 index 0000000..afe0f55 --- /dev/null +++ b/templates/backOffice/default-twig/custom-front-menu/_target_field.html.twig @@ -0,0 +1,34 @@ +{# + The target field, which depends on the chosen kind. Swapped in by HTMX when the kind + changes, so no module JavaScript is needed. +#} +{% set domain = 'customfrontmenu.bo.default-twig' %} + +
+ {% if view == 'url' %} + {% for lang in bo_languages() %} +
+ + {{ lang.title }} + + +
+ {% endfor %} +
{{ 'Only http, https and site-relative addresses are kept.'|trans({}, domain) }}
+ + {% elseif view in ['brand', 'category', 'content', 'folder', 'product'] %} + + + {% else %} +
{{ 'This entry is a label with no link.'|trans({}, domain) }}
+ {% endif %} +
diff --git a/templates/backOffice/default-twig/custom-front-menu/_tree_nodes.html.twig b/templates/backOffice/default-twig/custom-front-menu/_tree_nodes.html.twig new file mode 100644 index 0000000..5ba2485 --- /dev/null +++ b/templates/backOffice/default-twig/custom-front-menu/_tree_nodes.html.twig @@ -0,0 +1,71 @@ +{% set domain = 'customfrontmenu.bo.default-twig' %} +{% for item in items %} +
  • +
    + + + + + + {{ item.title }} + + + {% if item.target.kind == 'none' %} + {{ item.target.label }} + {% elseif item.target.kind == 'url' %} + + {{ item.target.label|u.truncate(40, '…') }} + + {% else %} + + {{ item.target.kind|trans({}, domain) }} · {{ item.target.label }} + + {% endif %} + +
    +
    + + + +
    + + + +
    + + {# A link rather than a modal: filling a shared modal would need module + JavaScript, and the theme registers no controller for us. #} + + + + +
    + + + +
    + + {% if item.children|length > 0 %} +
      + {% include '@CustomFrontMenuModule/backOffice/default-twig/custom-front-menu/_tree_nodes.html.twig' with { items: item.children, menuId: menuId } only %} +
    + {% endif %} +
  • +{% endfor %} diff --git a/templates/backOffice/default-twig/custom-front-menu/entry.html.twig b/templates/backOffice/default-twig/custom-front-menu/entry.html.twig new file mode 100644 index 0000000..eb3494a --- /dev/null +++ b/templates/backOffice/default-twig/custom-front-menu/entry.html.twig @@ -0,0 +1,92 @@ +{% extends '@BackOfficeDefaultTwig/base.html.twig' %} + +{% set domain = 'customfrontmenu.bo.default-twig' %} + +{% block title %}{{ entryTitle }}{% endblock %} +{% block body_testid %}custom-front-menu-entry{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +
    +

    {{ 'Edit an entry'|trans({}, domain) }}

    + +
    + + +
    +
    {{ 'Label'|trans({}, domain) }}
    +
    + {% for lang in bo_languages() %} +
    + + {{ lang.title }} + + +
    + {% endfor %} +
    {{ 'A language left empty falls back to another one on the front.'|trans({}, domain) }}
    +
    +
    + +
    +
    {{ 'Position'|trans({}, domain) }}
    +
    + + +
    {{ 'Moving an entry takes its own entries with it.'|trans({}, domain) }}
    +
    +
    + +
    +
    {{ 'Target'|trans({}, domain) }}
    +
    +
    + + +
    + + {% include '@CustomFrontMenuModule/backOffice/default-twig/custom-front-menu/_target_field.html.twig' %} +
    +
    + +
    + + + {{ 'Cancel'|trans({}, domain) }} + +
    + +
    +{% endblock %} diff --git a/templates/backOffice/default-twig/custom-front-menu/tree.html.twig b/templates/backOffice/default-twig/custom-front-menu/tree.html.twig new file mode 100644 index 0000000..eea098b --- /dev/null +++ b/templates/backOffice/default-twig/custom-front-menu/tree.html.twig @@ -0,0 +1,115 @@ +{% extends '@BackOfficeDefaultTwig/base.html.twig' %} + +{% set domain = 'customfrontmenu.bo.default-twig' %} + +{% block title %}{{ menuTitle }}{% endblock %} +{% block body_testid %}custom-front-menu-tree{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +
    + + +
    +
    {{ 'Name and code'|trans({}, domain) }}
    +
    +
    +
    + + +
    +
    + + +
    + {{ 'Front-office call'|trans({}, domain) }} : + {{ "{{ custom_front_menu('" ~ menuCode ~ "') }}" }} +
    +
    +
    + + +
    + +
    +
    + +

    + {{ 'Drag an entry onto another to nest it, and every move is saved right away. To move one back up a level, open it and change where it sits.'|trans({}, domain) }} +

    + +
    +
    +
      + {% if tree is empty %} +
    • + {{ 'This menu has no entry yet.'|trans({}, domain) }} +
    • + {% else %} + {% include '@CustomFrontMenuModule/backOffice/default-twig/custom-front-menu/_tree_nodes.html.twig' with { items: tree, menuId: menuId } only %} + {% endif %} +
    +
    +
    + +
    +
    {{ 'Add an entry'|trans({}, domain) }}
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + +
    +
    + + + +
    +
    +{% endblock %} diff --git a/templates/backOffice/default-twig/module-config.html.twig b/templates/backOffice/default-twig/module-config.html.twig new file mode 100644 index 0000000..83e6f20 --- /dev/null +++ b/templates/backOffice/default-twig/module-config.html.twig @@ -0,0 +1,78 @@ +{# + Module configuration screen: the list of menus. Composing one happens on its own page, + so the menu being edited is in the URL rather than in a cookie. +#} +{% set domain = 'customfrontmenu.bo.default-twig' %} + +
    +
    +
    +
    {{ 'Menus'|trans({}, domain) }}
    + + {% if menus is empty %} +
    + {{ 'No menu yet. Create one to get started.'|trans({}, domain) }} +
    + {% else %} +
    +
    + + + + + + + + + + {% for menu in menus %} + + + + + + + {% endfor %} + +
    {{ 'Name'|trans({}, domain) }}{{ 'Entries'|trans({}, domain) }}{{ 'Front-office call'|trans({}, domain) }}
    + + {{ menu.title }} + + + {{ menu.entryCount }} + + {{ "{{ custom_front_menu('" ~ menu.code ~ "') }}" }} + + + {{ 'Compose'|trans({}, domain) }} + +
    + + {% endif %} + + + +
    +
    +
    {{ 'Add a new menu'|trans({}, domain) }}
    +
    +
    +
    + + +
    +
    + + +
    {{ 'The name a theme calls this menu by. Left empty, it is derived from the menu name.'|trans({}, domain) }}
    +
    + + +
    +
    +
    +
    + diff --git a/templates/backOffice/default/assets/css/styles.css b/templates/backOffice/default/assets/css/styles.css deleted file mode 100644 index 476e140..0000000 --- a/templates/backOffice/default/assets/css/styles.css +++ /dev/null @@ -1,302 +0,0 @@ -/* ------------------------------ Begin style of module config ------------------------------ */ - -.top { - margin-bottom: 3%; - justify-content: space-between; -} - -.top-btn:hover{ - background-color: #ccc; -} - -.top-btn{ - border-radius: 10px; - background-color: #f5f5f5; - border-color: #ccc; - transition: all 0.3s; -} - -.add-parent-btn{ - float: right; - border-radius: 10px; -} - -.menu{ - margin: 0 5% 0 5%; - padding-left: 0; - flex-direction: column; -} - -.bottom { - width: 100%; - text-align: right; - margin-top: 3%; -} - -.bottom button{ - border-radius: 10px; -} - -/* ------------------------------ End style of module config ------------------------------ */ - -/* ------------------------------ Begin style of menu item ------------------------------ */ - -.item { - display: flex; - flex-direction: row; - width: 100%; - padding: 5px 10px 5px 10px; - border-bottom: 1px solid #ccc; - align-items: center; -} - -li { - list-style: none; - align-items: center; -} - -ul.menu-item:last-child { - border-bottom: none; -} - -#menu-item-list{ - padding-left: 0; -} - -.btn-group { - margin: 0 30px 0 3%; -} - -.title-container{ - flex: 1; - cursor: pointer; -} - -.drag-and-drop-icon { - font-size: 150%; - margin-right: 3%; - cursor:move; -} - -.leftArrow { - float: left; - cursor: pointer; -} - -.rightArrow { - float: right; - cursor: pointer; -} - -.arrows{ - font-size: 120%; - width: 50px; -} - -.zero-depth { - background-color: #f2f2f2; -} - -.even-depth{ - background-color: #f9f9f9; -} - -.end-arrow { - color: #ccc; -} - -/* ------------------------------ End item ------------------------------ */ - -/* ------------------------------ Begin edit modal ------------------------------ */ - -.top-edit{ - display: flex; - flex-direction: row; - justify-content: space-between; - margin-bottom: 20px; -} - -.edit-modal-line label{ - margin-bottom: 0; -} - -.edit-modal-line label, .edit-modal-line input, .edit-modal-line button { - margin-right: 10px; -} - -.edit-modal-line input, .edit-modal-line button { - height: 32px; - box-sizing: border-box; -} - -.edit-modal-line:last-child{ - margin-bottom: 0; -} - -.dropdown-menu { - padding: 15px; - min-width: 50px; - right: 0; - left: auto; -} - -.dropdown-menu li:hover { - background-color: #ccc; -} - -.rounded{ - border-radius: 10px; -} - -.edit-modal-line select{ - margin-right: 10px; -} - -#selectedLanguageBtn{ - color: #646464; -} - -#selectedLanguageBtn:hover{ - color: black; -} - -.itemList option { - display: none; -} - -.edit-menu-flags{ - width: 30px; -} - -.item-name-by-language-maskable{ - margin: 5px 0 5px 0; -} - -.item-name-by-language-maskable input{ - margin-right: 5px; - width: calc(100% - 40px); -} - -/* ------------------------------ End edit modal ------------------------------ */ - -/* ------------------------------ Begin edit modal && add modal ------------------------------ */ - -.item-name-by-language { - display: flex; - align-items: center; - justify-content: center; - margin: 5px 0 5px 0; -} - -.item-name-by-language input { - width: 50%; - margin-right: 5px; -} - -.item-name-by-language img, .item-name-by-language-maskable img{ - height: calc(100% - 10px); -} - -.edit-modal-line { - margin: 15px 0 15px 0; -} - -#menuItemEdit, #menuItemAddChild{ - width: 100%; - margin-top: 5px; -} -/* ------------------------------ End edit modal && add modal ------------------------------ */ - -/* ------------------------------ Begin drop down ------------------------------ */ - -.drop-indicator { - position: absolute; - height: 4px; - background-color: black; - opacity: 50%; -} -/* ------------------------------ End drop down ------------------------------ */ - -/* ------------------------------ Begin preview ------------------------------ */ - -#preview-menu { - width: 100%; - margin: 3% 0; - border: rgba(200, 0, 0, 0.2) 1px solid; - - h3 { - margin: 1%; - } - - #menus { - display: flex; - flex-direction: row; - background-color: #f5f5f5; - padding: 0 2%; - } - - .parent { - display: none; - position: absolute; - background-color: #f5f5f5; - width: fit-content; - left: 0; - z-index: 1; - box-shadow: 5px 5px 5px grey; - } - - .deep { - display: none; - position: absolute; - background-color: #f5f5f5; - top: 0; - z-index: 1; - left: 100%; - } - - li { - position: relative; - } - - li:hover>.parent { - display: block; - } - - li:hover>.deep { - display: block; - } - - li:hover { - background-color: #f49a17; - } - - li>a{ - display: block; - padding: 10px 15px; - } - - li:hover>a { - color: #fff; - } - - a { - text-decoration: none; - color: #7a7a7a; - white-space: nowrap; - font-family: 'Open Sans',sans-serif; - } - - ul { - padding: 0; - } - /* ------------------------------ End preview ------------------------------ */ - - .list-style-type{ - display:none; - padding: 0; - } -} - -.error { - color: red; -} \ No newline at end of file diff --git a/templates/backOffice/default/assets/js/main.js b/templates/backOffice/default/assets/js/main.js deleted file mode 100644 index 75e776c..0000000 --- a/templates/backOffice/default/assets/js/main.js +++ /dev/null @@ -1,1290 +0,0 @@ -var MENU_NAMES -var MENU_LIST -var CURRENT_SELECTED_MENU_ID -var LOCALE -var loopsDictionary = { - "brand": brandLoopData, - "category": categoryLoopData, - "content": contentLoopData, - "folder": folderLoopData, - "product": productLoopData -}; -let CURRENT_ID = null -let allowUnload = false -let selectedLanguage -const quotePattern = '&280"e&280&' -const percentPattern = '&280&percent&280&' - -// Get from json -function getFromJson(json) { - return JSON.parse(decodeURIComponent(replaceQuoteAndPercent(json))) -} - -// End get from json - -// Get value by locale -function getValueByLocaleOf(element, locale) { - if (!locale) { - locale = LOCALE - } - let found = false - let result - for (const [lang, title] of Object.entries(element)) { - if (lang === locale) { - result = title - found = true - break - } - if (lang === 'en_US') { - result = title - found = true - } - } - if (!found) { - result = element[Object.keys(element)[0]] - } - return result -} -// End get value by locale - -// Close closest modal -function closeClosestModal(element) { - let modal = element.closest('.modal'); - if (modal) { - let modalId = modal.getAttribute('id'); - $(`#${modalId}`).modal('hide'); - } -} -// End close closest modal - -// Current id -function getCurrentId() { - if (CURRENT_ID === null) { - console.error("CURRENT_ID not set") - } - return CURRENT_ID -} - -function setCurrentId(id) { - CURRENT_ID = id -} -// End current id - -// Get next id -function getNextId() { - let nextId = 1 - let arrayOfIds = getAllIdOf(MENU_LIST) - arrayOfIds.sort((a, b) => a - b) - for (const id of arrayOfIds) { - if (id !== nextId) { - break - } - nextId++ - } - return nextId -} - -function getAllIdOf(list) { - let arrayOfIds = [] - for (const menuItem of list) { - arrayOfIds.push(menuItem.id) - if (menuItem.children && menuItem.children.length > 0) { - let children = menuItem.children - for (const child of children) { - if (!arrayOfIds.includes(child.id)) { - arrayOfIds.push(child.id) - } - if (!child.children || child.children.length <= 0) { - continue - } - const result = getAllIdOf(child.children) - - for (const id of result) { - if (!arrayOfIds.includes(id)) { - arrayOfIds.push(id) - } - } - } - } - } - return arrayOfIds -} -// End get next id - -// Replace annoying characters -function replaceQuoteAndPercent(string) { - while (string.includes("\\'") || string.includes("%")) { - string = string - .replace("\\'", quotePattern) - .replace("%", percentPattern); - } - return string -} - -function putQuoteAndPercent(string) { - while (string.includes(quotePattern) || string.includes(percentPattern)) { - string = string - .replace(quotePattern, "'") - .replace(percentPattern, "%"); - } - return string -} - -function replaceAllQuotesAndPercent(MenuList) { - for (const val of MenuList) { - replaceAllQuotesAndPercentRec(val) - } -} - -function replaceAllQuotesAndPercentRec(MenuList) { - for (const [lang, title] of Object.entries(MenuList.title)) { - MenuList.title[lang] = putQuoteAndPercent(title) - } - - if (!MenuList.children || MenuList.children.length <= 0) { - return - } - for (let child of MenuList.children) { - replaceAllQuotesAndPercentRec(child) - } -} -// End replace annoying characters - -// Add menu -function addMenu() { - const menuName = document.getElementById('menuName').value; - const errorMessageEmpty = document.getElementById('error-message-empty'); - const errorMessageBackQuote = document.getElementById('error-message-back-quote'); - - if (menuName.trim().length === 0) { - errorMessageEmpty.style.display = 'block'; - errorMessageBackQuote.style.display = 'none'; - } else if (menuName.includes("`")) { - errorMessageEmpty.style.display = 'none'; - errorMessageBackQuote.style.display = 'block'; - } else { - $('#ConfirmAddMenu').modal('hide'); - errorMessageEmpty.style.display = 'none'; - errorMessageBackQuote.style.display = 'none'; - document.getElementById('addMenuForm').submit(); - } -} - -function addInList(id, item, list) { - list = list.map(function (element) { - if (element.id === id) { - if (!Array.isArray(element.children)) { - element.children = [] - } - element.children.push(item) - } - if (element.children && element.children.length > 0) { - element.children = addInList(id, item, element.children) - } - return element; - }) - return list -} - -function addCustomMenuItem(element, id = "0") { - const form = element.form - if (!isValid(form)) { - return - } - - let [menuItemName, menuItemType, menuItemUrl] = getFormItems(form) - let menu = findMenuInList(id, MENU_LIST) - let depthToAdd = 0 - if (menu !== null) { - depthToAdd = menu.depth + 1 - } - let newItem = { - id: getNextId(), - title: menuItemName, - type: menuItemType, - depth: depthToAdd, - children: [] - } - - if (menuItemType.toLowerCase() !== "url"){ - newItem.typeId = menuItemUrl - } - else if (menuItemType.toLowerCase() !== "empty"){ - newItem.url = menuItemUrl - } - - let newMenu = generateMenuRecursive(newItem) - if (menu === null) { - document.getElementById('menu-item-list').innerHTML += newMenu - MENU_LIST.push(newItem) - } else { - let childNodes = document.getElementById(id).parentNode.childNodes - let ulElement = null - for (let i = 0; i < childNodes.length; i++) { - if (childNodes[i].nodeType === Node.ELEMENT_NODE && childNodes[i].classList.contains('menu-item')) { - ulElement = childNodes[i]; - break; - } - } - if (ulElement) { - ulElement.innerHTML += newMenu; - MENU_LIST = addInList(id, newItem, MENU_LIST); - - // Add sub-menu icon if not already present - let parentElement = document.getElementById(id).parentNode; - let titleContainer = parentElement.querySelector('.title-container'); - if (titleContainer && !titleContainer.querySelector('.tree-icon')) { - let titleSpan = titleContainer.querySelector('[data-id="titleSpan"]'); - if (titleSpan) { - titleSpan.insertAdjacentHTML('afterend', ' '); - } - } - - // Open the parent menu - const treeIcon = parentElement.querySelector('.tree-icon'); - const childrenUl = parentElement.querySelector('ul'); - if (treeIcon === null) { - return - } - if (childrenUl) { - childrenUl.style.display = 'block' - treeIcon.classList.remove('fa-caret-up'); - treeIcon.classList.add('fa-caret-down'); - } - } - } - deleteEditField(form.id); - generatePreviewMenus(); - updateArrowStyles(); - - closeClosestModal(form); -} -// End add menu - -// Find menu -function findMenuInList(id, list) { - for (const menuItem of list) { - if (menuItem.id === id) { - return menuItem - } - if (menuItem.children && menuItem.children.length > 0) { - let children = menuItem.children - for (const child of children) { - let result = findMenuInList(id, children) - if (result) { - return result - } - } - } - } - return null -} -// End find menu - -// Edit menu -function changeParameters(id) { - const form = document.getElementById('editMenuItemForm') - if (!isValid(form)) { - return - } - - const [title, type, url] = getFormItems(form) - const menuItem = document.getElementById(id).parentElement - if (menuItem === null) { - console.error("The id given in changeParameters parameter doesn't exist") - return - } - - saveTitleTypeAndUrl(id, title, type, url) - - const titleSpan = menuItem.querySelector('[data-id="titleSpan"]') - titleSpan.textContent = getValueByLocaleOf(findMenuInList(id, MENU_LIST).title) - - deleteEditField('editMenuItemForm') - generatePreviewMenus() - closeClosestModal(form) -} - -function getFormItems(form) { - let listOfNames = {} - let listOfUrls = {} - let found = false - let englishOrLocaleName = "" - let englishOrLocaleUrl = "" - - const menuItemNameInputs = form.getElementsByClassName("item-name-by-language") - for (child of menuItemNameInputs){ - const input = child.querySelector("input") - const locale = input.getAttribute("data-locale") - const nameOfMenu = input.value.trim() - listOfNames[input.getAttribute("data-locale")] = input.value.trim() - if (!found && locale === LOCALE && nameOfMenu !== ""){ - englishOrLocaleName = nameOfMenu - found = true - } - else if(!found && locale === "en_US"){ - englishOrLocaleName = nameOfMenu - } - } - - if (englishOrLocaleName !== ""){ - for (let [local, name] of Object.entries(listOfNames)){ - if (name === ""){ - listOfNames[local] = englishOrLocaleName - } - } - } - - let menuItemType = form.elements['menuType'].value.trim() - if (menuItemType === null || menuItemType === '') { - menuItemType = 'empty' - } - - if (menuItemType === 'url') { - const menuItemUrlInputs = form.getElementsByClassName("menu-item-url") - found = false - for (input of menuItemUrlInputs){ - const locale = input.getAttribute("data-locale") - const urlOfMenu = input.value.trim() - listOfUrls[input.getAttribute("data-locale")] = input.value.trim() - if (!found && locale === LOCALE && urlOfMenu !== ""){ - englishOrLocaleUrl = urlOfMenu - found = true - } - else if(!found && locale === "en_US"){ - englishOrLocaleUrl = urlOfMenu - } - } - - if (englishOrLocaleUrl !== ""){ - for (let [local, name] of Object.entries(listOfUrls)){ - if (name === ""){ - listOfUrls[local] = englishOrLocaleUrl - } - } - } - } - else{ - listOfUrls = form.elements['menuItemProduct'].value.trim() - } - return [listOfNames, menuItemType, listOfUrls] -} -// End edit menu - -// Delete menu -function deleteMenuItem(id) { - let elementToRemove = document.getElementById(id).parentElement; - - let parentInMenuList = findParentOf(id, MENU_LIST) - if (parentInMenuList[1].children && parentInMenuList[1].children.length === 1) { - const parentInHtml = elementToRemove.parentElement.parentElement; - const treeIcon = parentInHtml.querySelector('.tree-icon') - treeIcon.classList.remove('fa-caret-up') - treeIcon.classList.remove('fa-caret-down') - } - - if (!elementToRemove) { - console.error("The id doesn't exist") - return - } - - if (elementToRemove.remove) { - elementToRemove.remove() - } else { - elementToRemove.parentNode.removeChild(elementToRemove) - } - MENU_LIST = deleteFromList(id, MENU_LIST) - generatePreviewMenus() -} - -function deleteFromList(id, list) { - list = list.filter(function (element) { - if (element.children && element.children.length > 0) { - element.children = deleteFromList(id, element.children) - } - return element.id !== id; - }) - return list -} - -function deleteMenu() { - document.getElementById('deleteForm').submit(); -} -// End delete menu - -// Validation -function isValid(form) { - const errorMessageTitle = form.querySelector('#error-message-title') - const errorMessageUrl = form.querySelector('#error-message-url') - errorMessageTitle.style.display = 'none'; - errorMessageUrl.style.display = 'none'; - - const menuItemNames = form.getElementsByClassName("item-name-by-language") - let found = false - let englishOrLocaleName - - for (nameInput of menuItemNames){ - input = nameInput.querySelector("input") - - const locale = input.getAttribute("data-locale") - const nameOfMenu = input.value.trim() - if (nameOfMenu.includes("`")){ - errorMessageTitle.style.display = 'block'; - return false - } - - if (!found && locale === LOCALE && nameOfMenu !== ""){ - englishOrLocaleName = nameOfMenu - found = true - } - else if(!found && locale === "en_US"){ - englishOrLocaleName = nameOfMenu - } - } - - const menuItemType = form.elements['menuType'].value.trim() - const errorType = form.getElementsByClassName("error")[0] - errorType.innerText = "" - if (englishOrLocaleName === ""){ - errorType.innerText = "English value or local value must be entered for the title" - return false - } - - if (menuItemType === ""){ - errorType.innerText = "Choose a category" - return false - } - if (!(menuItemType in loopsDictionary) && menuItemType !== "url" && menuItemType !== "empty"){ - errorType.innerText = "Invalid selection" - return false - } - - if (menuItemType !== "url" && menuItemType !== "empty"){ - let found = false - const menuItemUrl = form.elements['menuItemProduct'].value - for (const [key, value] of Object.entries(loopsDictionary[menuItemType])){ - if (value.title + "-" + value.id === menuItemUrl || (value.reference && value.title + "-" + value.reference + "-" + value.id === menuItemUrl)){ - found = true - break - } - } - if (!found){ - errorType.innerText = "Invalid " + menuItemType - return false - } - } - else if (menuItemType === "url"){ - const menuItemUrls = form.getElementsByClassName("menu-item-url") - let found = false - let englishOrLocaleUrl - - for (const urlInput of menuItemUrls){ - - const locale = urlInput.getAttribute("data-locale") - const urlOfMenu = urlInput.value.trim() - if (urlOfMenu.includes("`")){ - errorMessageTitle.style.display = 'block'; - return false - } - - if (!found && locale === LOCALE && urlOfMenu !== ""){ - englishOrLocaleUrl = urlOfMenu - found = true - } - else if(!found && locale === "en_US"){ - englishOrLocaleUrl = urlOfMenu - } - } - - if (englishOrLocaleUrl === ""){ - errorType.innerText = "English value or local value must be entered for the url" - return false - } - } - - return true -} -// End validation - -// Save data -function saveData() { - allowUnload = true - document.getElementById('menuData').value = JSON.stringify(MENU_LIST) - document.getElementById('menuDataId').value = JSON.stringify(CURRENT_SELECTED_MENU_ID) - document.getElementById('savedData').submit() -} - -function saveMenuItemName() { - const editForm = document.getElementById('editMenuItemForm') - if (!isValid(editForm)) { - return - } - - const modifiedLocal = selectedLanguage ? selectedLanguage : LOCALE; - menuToModify = findMenuInList(CURRENT_ID, MENU_LIST) - - if (menuToModify === null) { - console.error("The id given in saveMenuItemName doesn't exist") - return - } - - menuToModify.title[modifiedLocal] = editForm["menuItemName"].value; - - const titleSpan = document.getElementById(getCurrentId()).parentElement.querySelector('[data-id="titleSpan"]') - titleSpan.textContent = getValueByLocaleOf(findMenuInList(getCurrentId(), MENU_LIST).title) - - generatePreviewMenus() -} - -function saveMenuItemUrl() { - const editForm = document.getElementById('editMenuItemForm') - if (!isValid(editForm)) { - return - } - - const menuToModify = findMenuInList(CURRENT_ID, MENU_LIST) - - if (menuToModify === null) { - console.error("The id given in saveMenuItemUrl doesn't exist") - return - } - - const itemType = editForm["menuType"].value - menuToModify.type = itemType - - if (itemType.toLowerCase() === "url") { - const modifiedLocal = selectedLanguage ? selectedLanguage : LOCALE - menuToModify.url[modifiedLocal] = editForm["menuItem"].value - } else { - menuToModify.typeId = editForm["menuItem"].value - } -} - -function saveTitleTypeAndUrl(id, title, type, url) { - const menuToModify = findMenuInList(id, MENU_LIST) - - if (menuToModify === null) { - console.error("The id given in saveTitleTypeAndUrl doesn't exist") - return - } - - menuToModify.title = title - menuToModify.type = type - if (type.toLowerCase() !== "url"){ - menuToModify.typeId = url - } - else{ - menuToModify.url = url - } -} -// End save data - -// Form edit field -function setEditFields(id) { - CURRENT_ID = id - const element = findMenuInList(id, MENU_LIST) - if (element === null) { - console.error("The id given in setEditField doesn't exist") - return - } - const form = document.getElementById('editMenuItemForm') - form.elements['menuType'].value = element.type.toLowerCase() - - const menuTitles = element.title - for (const inputToFill of form.elements['menuItemName']){ - const title = menuTitles[inputToFill.getAttribute("data-locale")] - if (title){ - inputToFill.value = title - } - } - - if (element.type.toLowerCase() !== 'url' && element.type.toLowerCase() !== "empty") { - form.elements['menuItemProduct'].value = element.typeId - } - else if (element.type.toLowerCase() === 'url'){ - const menuItemUrls = form.getElementsByClassName("menu-item-url") - for (const urlInput of menuItemUrls){ - urlInput.parentNode.style.display = "block" - const url = element.url[urlInput.getAttribute("data-locale")] - if (url){ - urlInput.value = url - } - } - } - else{ - for (const urlInput of form.getElementsByClassName("menu-item-url")){ - urlInput.parentNode.style.display = "none" - } - form.elements['menuItemProduct'].style.display = "none" - } - - const select = document.getElementById('select-edit-type') - updateInputOrDatalist(select) -} - -function deleteEditField(formId) { - const form = document.getElementById(formId) - for (const inputToClear of form.querySelectorAll("input")){ - inputToClear.value = "" - } -} -// End form edit field - -// Generate menu -function generateSelect(list) { - let menu = document.getElementById('selectMenuName') - menu.innerHTML = "" - for (const menuName of list) { - let option = document.createElement('option'); - option.text = menuName.title; - option.id = menuName.id; - if (option.id === "menu-selected-" + CURRENT_SELECTED_MENU_ID) { - option.selected = true; - } - menu.appendChild(option); - } -} - -function generateMenu(list) { - let menu = document.getElementById('menu-item-list') - menu.innerHTML = "" - for (const menuItem of list) { - menu.innerHTML += generateMenuRecursive(menuItem) - } - updateArrowStyles(); -} - -function generateMenuRecursive(menuItem) { - let depth = "zero-depth" - if (menuItem.depth !== 0) { - depth = "" - if (menuItem.depth % 2 === 0) { - depth = "even-depth" - } - } - - let children = "" - if (menuItem.children && menuItem.children.length > 0) { - for (const child of menuItem.children) { - children += generateMenuRecursive(child) - } - } - - let arrowSpan = "" - if (children !== "") { - arrowSpan = ` `; - } - - let newMenu = ` -
  • - - -
  • ` - - updateArrowStyles(); - return newMenu; -} -// End generate menu - -// Move menu -function moveMenuUp(id) { - menuToMove = document.getElementById(id).parentNode - if (menuToMove.previousElementSibling) { - menuToMove.parentElement.insertBefore(menuToMove, menuToMove.previousElementSibling) - } - - MENU_LIST = moveMenuUpInList(id, MENU_LIST) - generatePreviewMenus() - updateArrowStyles(); -} - - -function moveMenuDown(id) { - menuToMove = document.getElementById(id).parentElement - if (menuToMove.nextElementSibling) { - menuToMove.parentElement.insertBefore(menuToMove.nextElementSibling, menuToMove) - } - - MENU_LIST = moveMenuDownInList(id, MENU_LIST) - generatePreviewMenus() - updateArrowStyles(); -} - -function moveMenuUpInList(id, list) { // recursive - for (let i = 0; i < list.length; i++) { - if (list[i].id === id) { - if (i > 0) { - let temp = list[i] - list[i] = list[i - 1] - list[i - 1] = temp - } - return list - } - if (list[i].children && list[i].children.length > 0) { - list[i].children = moveMenuUpInList(id, list[i].children) - } - } - return list -} - -function moveMenuDownInList(id, list) { - for (let i = 0; i < list.length; i++) { - if (list[i].id === id) { - if (i < list.length - 1) { - let temp = list[i] - list[i] = list[i + 1] - list[i + 1] = temp - } - return list - } - if (list[i].children && list[i].children.length > 0) { - list[i].children = moveMenuDownInList(id, list[i].children) - } - } - return list -} - -function updateArrowStyles() { - const ulItems = document.querySelectorAll('.menu-item'); - - ulItems.forEach((ul) => { - const liItems = ul.querySelectorAll(':scope > li'); - liItems.forEach((li, index) => { - const upArrow = li.querySelector('.leftArrow i'); - const downArrow = li.querySelector('.rightArrow i'); - - if (upArrow) { - upArrow.classList.remove('end-arrow'); - } - if (downArrow) { - downArrow.classList.remove('end-arrow'); - } - - if (index === 0 && upArrow) { - upArrow.classList.add('end-arrow'); - } - if (index === liItems.length - 1 && downArrow) { - downArrow.classList.add('end-arrow'); - } - }); - }); -} -// End move menu - -// Drop down -function toggleTopLevelVisibility() { - const button = document.getElementById('toggle-all-children'); - const isVisible = (buttonState === 'hide'); - - MENU_LIST.forEach(item => { - if (item.depth === 0) { - const itemId = item.id.toString(); - const listItem = document.getElementById(itemId); - if (listItem) { - const childrenUl = listItem.parentElement.querySelector('ul'); - if (childrenUl && childrenUl.tagName === 'UL') { - childrenUl.style.display = isVisible ? 'none' : 'block'; - const treeIcon = listItem.querySelector('.tree-icon'); - if (treeIcon) { - treeIcon.classList.toggle('fa-caret-down', !isVisible); - treeIcon.classList.toggle('fa-caret-up', isVisible); - } - } - } - } - }); - buttonState = isVisible ? 'show' : 'hide'; - button.textContent = isVisible ? translations.showAllChildren : translations.hideAllChildren; -} - -function toggleChildren(span, event) { - - if (event.target.closest('.priority-over-drop-and-down')) { - return; - } - - const listItem = span.closest('.item').parentElement; - const treeIcon = listItem.querySelector('.tree-icon'); - const childrenUl = listItem.querySelector('ul'); - - if (treeIcon === null) { - return - } - - if (childrenUl) { - childrenUl.style.display = childrenUl.style.display === 'none' ? 'block' : 'none'; - treeIcon.classList.remove('fa-caret-up'); - treeIcon.classList.add('fa-caret-down'); - if (childrenUl.style.display === 'none') { - treeIcon.classList.remove('fa-caret-down'); - treeIcon.classList.add('fa-caret-up'); - } - } -} - -// End drop down - -// Drag and drop -function drag(ev) { - ev.dataTransfer.setData("text/plain", ev.target.children[0].id); -} - -function drop(ev) { - ev.stopPropagation(); - ev.preventDefault(); - - document.querySelector('.drop-indicator').style.display = 'none' - - var data = ev.dataTransfer.getData("text/plain"); - var draggedItemId = parseInt(data); - - var draggedItem = findMenuInList(draggedItemId, MENU_LIST); - - if (draggedItem) { - var targetItemId = parseInt(ev.target.closest(".item").id) - - var rect = ev.target.closest("div.item").getBoundingClientRect() - var mouseY = ev.clientY - rect.top; - var mouseX = ev.clientX - rect.left; - - const insertionBefore = mouseY < rect.height / 2 - const insertAsChild = !insertionBefore && mouseX > rect.width / 6 - - // inserts the moved element before or after the target element, depending on the drop position - const problems = insertMenuItem(draggedItemId, targetItemId, insertionBefore, insertAsChild) - - if (problems === 0) { - console.log("success") - } else if (problems === 1) { - console.log("OSKOUR: element not found in list") - } else if (problems === 2) { - console.log("is parent") - } else if (problems === 3) { - console.log("same element") - } - - generateMenu(MENU_LIST) - - generatePreviewMenus(); - - const button = document.getElementById('toggle-all-children'); - const isVisible = false; - buttonState = isVisible ? 'show' : 'hide'; - button.textContent = isVisible ? translations.showAllChildren : translations.hideAllChildren; - - } else { - console.error("L'élément avec l'ID", draggedItemId, "n'a pas été trouvé dans MENU_LIST."); - } -} - -function insertMenuItem(draggedItemId, positionToInsert, insertionBefore, insertAsChild) { - if (draggedItemId === positionToInsert) { - return 3 - } - - if (draggedItemId >= 0) { - if (isParentOf(draggedItemId, positionToInsert)) { - let [root, parentOfDragged] = findParentOf(draggedItemId, MENU_LIST) - if (root === 0) { - parentOfDragged = MENU_LIST - } - if (!parentOfDragged) { - return 1 - } - - let draggedItem = findMenuInList(draggedItemId, MENU_LIST) - while (draggedItem.children.length > 0) { - let popedChild = draggedItem.children.pop() - if (root === 0) { - MENU_LIST.splice(MENU_LIST.indexOf(draggedItem) + 1, 0, popedChild) - } else { - parentOfDragged.children.splice(parentOfDragged.children.indexOf(draggedItem) + 1, 0, popedChild) - } - popedChild.depth = (root === 0) ? 0 : parentOfDragged.depth + 1 - updateDepth(popedChild, popedChild.depth) - } - } - if (insertAsChild) { - let newParent = findMenuInList(positionToInsert, MENU_LIST) - if (newParent === null) { - return 1 - } - - const draggedItem = popFromMenuList(draggedItemId, MENU_LIST) - - if (newParent.children == null) { - newParent.children = [draggedItem] - } else { - newParent.children.push(draggedItem) - } - draggedItem.depth = newParent.depth + 1 - updateDepth(draggedItem, draggedItem.depth) - return 0 - } - - let [root, parent] = findParentOf(positionToInsert, MENU_LIST) - - let menuToMove = popFromMenuList(draggedItemId, MENU_LIST) - if (menuToMove == null) { - return 1 - } - if (root === 0) { - insertionBefore ? MENU_LIST.splice(MENU_LIST.indexOf(parent), 0, menuToMove) : MENU_LIST.splice(MENU_LIST.indexOf(parent) + 1, 0, menuToMove) - menuToMove.depth = 0 - } else { - insertionBefore ? parent.children.splice(parent.children.indexOf(findMenuInList(positionToInsert, MENU_LIST)), 0, menuToMove) : parent.children.splice(parent.children.indexOf(findMenuInList(positionToInsert, MENU_LIST)) + 1, 0, menuToMove) - menuToMove.depth = parent.depth + 1 - } - - updateDepth(menuToMove, menuToMove.depth) - return 0 - } - return null -} - -function isParentOf(parent, child) { - let parentElement = findMenuInList(parent, MENU_LIST) - if (parentElement.children && parentElement.children.length > 0) { - for (const childElement of parentElement.children) { - if (childElement.id === child || isParentOf(childElement.id, child)) { - return true - } - } - } - return false -} - -function updateDepth(menuItem, depth) { - if (menuItem.children && menuItem.children.length > 0) { - for (const child of menuItem.children) { - child.depth = depth + 1 - updateDepth(child, child.depth) - } - } -} - -function findParentOf(id, list) { - for (const menuItem of list) { - if (menuItem.id === id) { - return [0, menuItem] - } - if (menuItem.children && menuItem.children.length > 0) { - let children = menuItem.children - for (const _ of children) { - let result = findParentOf(id, children) - if (result) { - if (result[0] === 0) { - return [1, menuItem] - } - return result - } - } - } - } - return null -} - -function popFromMenuList(id, list) { - for (const menuItem of list) { - if (menuItem.id === id) { - if (menuItem.depth < 1) { - return list.splice(list.indexOf(menuItem), 1)[0] - } - return menuItem - } - if (menuItem.children && menuItem.children.length > 0) { - let children = menuItem.children - for (const child of children) { - let result = popFromMenuList(id, children) - if (result) { - if (children.indexOf(result) !== -1) { - return children.splice(children.indexOf(result), 1)[0] - } - return result - } - } - } - } - return null -} - -function findMenuItemById(itemId) { - return MENU_LIST.find(item => item.id === itemId); -} - -function allowDrop(ev) { - ev.preventDefault(); - const dropIndicator = document.querySelector('.drop-indicator'); - - try { - // retrieve mouse position relative to the target element - const rect = ev.target.closest("div.item").getBoundingClientRect(); - const mouseY = ev.clientY - rect.top; - const mouseX = ev.clientX - rect.left; - - const targetItem = ev.target.closest(".item").parentElement; - // display bar above or below the target element - - dropIndicator.style.left = targetItem.offsetLeft + 'px'; // positions the bar to the left of the target element - dropIndicator.style.width = targetItem.offsetWidth + 'px'; // adjust bar width to that of the target elem - - if (mouseY < rect.height / 2) { // if the mouse is over the target element - dropIndicator.style.top = targetItem.offsetTop + 'px'; // position the bar above the target element - } else { // if the mouse is below the target element => positions bar below - dropIndicator.style.top = (targetItem.offsetTop + targetItem.offsetHeight) + 'px'; - if (mouseX > rect.width / 6) { // if the mouse is to the right of the target element - dropIndicator.style.left = (targetItem.offsetLeft + targetItem.offsetWidth * 0.04) + 'px'; - dropIndicator.style.width = (targetItem.offsetWidth * 0.96) + 'px'; - } - } - dropIndicator.style.display = 'block'; - } catch { - dropIndicator.style.display = 'none'; - } -} -// End drag and drop - -// Preview -function generatePreviewMenus() { - const previewUl = document.getElementById('menus') - previewUl.innerHTML = "" - for (const menuItem of MENU_LIST) { - previewUl.innerHTML += generatePreviewMenuRecursive(menuItem, 1) - } -} - -function generatePreviewMenuRecursive(menuItem) { - let children = "" - if (menuItem.children && menuItem.children.length > 0) { - for (const child of menuItem.children) { - children += generatePreviewMenuRecursive(child) - } - } - let classes = (menuItem.depth >= 1) ? "parent deep" : "parent" - return ` -
  • - ` + getValueByLocaleOf(menuItem.title) + ` -
      - ` + children + ` -
    -
  • - `; -} -// End Preview - -// Search product -function searchProducts(query, formId) { - const matchingProducts = document.querySelector(`#${formId} ~ ul`); - matchingProducts.innerHTML = ''; - - if (query.trim() === '') return; - - const filteredProducts = products.filter(product => - product.title.toLowerCase().includes(query.toLowerCase()) - ); - - filteredProducts.forEach(product => { - const li = document.createElement('li'); - li.textContent = `${product.title} (${product.ref})`; - li.addEventListener('click', () => { - document.querySelector(`#${formId} input[name="menuItem"]`).value = product.url; - }); - matchingProducts.appendChild(li); - }); -} - -function addOptionsOfSelectedCategory() { - const menuTypeSelect = document.getElementsByClassName('menuType'); - for (const category of menuTypeSelect) { - for (const key in loopsDictionary) { - const option = document.createElement('option'); - option.value = key; - option.textContent = key; - category.appendChild(option); - } - } -} - -function updateDataList(selectedKey, parentDiv) { - const dataList = parentDiv.querySelector('.itemList'); - dataList.innerHTML = ""; - - if (loopsDictionary[selectedKey]) { - loopsDictionary[selectedKey].forEach(item => { - const option = document.createElement('option'); - option.value = `${item.title}-${item.id}`; - if (item.reference) { - option.value = `${item.title}-${item.reference}-${item.id}`; - } - dataList.appendChild(option); - }); - } -} - -function updateInputOrDatalist(selectElement) { - const selectedKey = selectElement.value; - const form = selectElement.form; - const parentDiv = selectElement.closest('.edit-modal-line'); - const languageDivs = form.getElementsByClassName('item-name-by-language-maskable'); - const datalistElement = form.getElementsByClassName("itemList")[0] - const defaultInputElement = form['menuItemProduct']; - - if (selectedKey === "" || selectedKey === "empty") { - defaultInputElement.style.display = "none"; - defaultInputElement.value = ""; - datalistElement.style.display = "none"; - for (const div of languageDivs){ - div.style.display = "none"; - }; - } else if (selectedKey === "url") { - defaultInputElement.style.display = "none"; - datalistElement.style.display = "none"; - datalistElement.innerHTML = ""; - for (const div of languageDivs){ - div.style.display = "block"; - }; - } else { - defaultInputElement.style.display = "block"; - datalistElement.style.display = "block"; - updateDataList(selectedKey, parentDiv); - for (const div of languageDivs){ - div.style.display = "none"; - }; - } -} - -function resetSelect(modalId) { - const form = document.getElementById(modalId).getElementsByTagName("form")[0] - form.reset() - const selectElement = form['menuType'] - const options = selectElement.getElementsByTagName('option') - - for (const option of options) { - if (option.value === "") { - selectElement.insertBefore(option, options[0]); - option.disabled = true; - break; - } - } - - const languageDivs = form.getElementsByClassName('item-name-by-language-maskable'); - for (const div of languageDivs){ - div.style.display = "none"; - }; - - const menuItemProduct = form["menuItemProduct"]; - menuItemProduct.style.display = "none"; -} - -function resetTargetField(select) { - select.form["menuItemProduct"].value = "" -} -// End search product - -// Flashes - -// Function to remove flash messages from the DOM -function removeFlashMessages() { - const flashMessages = document.getElementsByClassName('alert-flash-to-delete') - Array.from(flashMessages).forEach(function (message) { - message.remove() - }); -} - -// Function to notify server to clear flash messages -function clearFlashMessagesOnServer() { - let xhr = new XMLHttpRequest() - xhr.open('GET', '/admin/module/CustomFrontMenu/clearFlashes', true) - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - console.error('Network response was not ok:', xhr.statusText) - } - } - }; - - xhr.onerror = function () { - console.error('Error:', xhr.statusText); - } - - xhr.send() -} - -// End flashes - -// Event Listener -window.addEventListener('beforeunload', function(event) { - if (!allowUnload) { - event.preventDefault(); - } -}, { capture: true }); - -document.getElementById('selectMenuName').addEventListener('change', function() { - const selectedOption = this.options[this.selectedIndex]; - - document.getElementById('menuId').value = selectedOption.id; - document.getElementById('askedMenu').submit(); -}); -// End Event Listener - -// Initialization -window.onload = function() { - - // Get data - MENU_NAMES = getFromJson(menuNames) - MENU_LIST = getFromJson(menuItems) - replaceAllQuotesAndPercent(MENU_LIST) - for (let menu of MENU_NAMES){ - menu.title = putQuoteAndPercent(menu.title) - } - - // Generate elements - generateSelect(MENU_NAMES) - generateMenu(MENU_LIST) - generatePreviewMenus() - document.getElementById('deleteForm').elements['menuNameToDelete'].value = CURRENT_SELECTED_MENU_ID - - // Disable buttons if there aren't any menu - if (CURRENT_SELECTED_MENU_ID === 'undefined' || CURRENT_SELECTED_MENU_ID === -1 || isNaN(CURRENT_SELECTED_MENU_ID)) { - let listToDelete = Array.from(document.getElementsByClassName('delete-if-no-menu')) - listToDelete.forEach(function (elementToDelete) { - elementToDelete.disabled = true - }) - } - - // Manage flashes - if (document.getElementsByClassName('alert-flash-to-delete').length > 0) { - clearFlashMessagesOnServer() - document.addEventListener('click', function() { - removeFlashMessages() - }) - } - - addOptionsOfSelectedCategory(); -} -// End Initialization \ No newline at end of file diff --git a/templates/backOffice/default/module-config.html b/templates/backOffice/default/module-config.html deleted file mode 100644 index 21fced7..0000000 --- a/templates/backOffice/default/module-config.html +++ /dev/null @@ -1,484 +0,0 @@ - - - - - -{loop type="brand" name="brand_loop"} - -{/loop} - -{loop type="category" name="category_loop"} - -{/loop} - -{loop type="content" name="content_loop"} - -{/loop} - -{loop type="folder" name="folder_loop"} - -{/loop} - -{loop type="product" name="product_loop"} - -{/loop} - - - -
    -
    - - - - -
    - - {if isset($smarty.session["_symfony_flashes"]["success"])} - {foreach $smarty.session["_symfony_flashes"]["success"] as $message} -
    - {$message} -
    - {/foreach} - {/if} - {if isset($smarty.session["_symfony_flashes"]["warning"])} - {foreach $smarty.session["_symfony_flashes"]["warning"] as $message} -
    - {$message} -
    - {/foreach} - {/if} - {if isset($smarty.session["_symfony_flashes"]["fail"])} - {foreach $smarty.session["_symfony_flashes"]["fail"] as $message} -
    - {$message} -
    - {/foreach} - {/if} - -
    -

    {intl l="Preview menu" d="customfrontmenu.bo.default"}

    - - -
    - - -
    - - -
    - -
    - - - -
    - -
    - - -
    -
    - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/templates/frontOffice/default/assets/css/customFrontMenu.css.html b/templates/frontOffice/default/assets/css/customFrontMenu.css.html deleted file mode 100644 index 1476135..0000000 --- a/templates/frontOffice/default/assets/css/customFrontMenu.css.html +++ /dev/null @@ -1,40 +0,0 @@ - \ No newline at end of file diff --git a/templates/frontOffice/default/customFrontMenu.html b/templates/frontOffice/default/customFrontMenu.html deleted file mode 100644 index 802b909..0000000 --- a/templates/frontOffice/default/customFrontMenu.html +++ /dev/null @@ -1,28 +0,0 @@ -{function name=printMenu} - {foreach $menuItems as $menuItem} -
  • - {if isset($menuItem.url) && $menuItem.url != ""} - {$menuItem.title} - {else} - {$menuItem.title} - {/if} - - -
      - {if isset($menuItem.children) && $menuItem.children|count > 0} - {call name=printMenu menuItems=$menuItem.children} - {/if} -
    -
  • - {/foreach} -{/function} - -