From 094fcc1e529b2e3eceae9a64ae9e33b6837385b2 Mon Sep 17 00:00:00 2001
From: ahnwarez <7552088+ahnwarez@users.noreply.github.com>
Date: Sun, 5 Jan 2025 21:16:53 +1300
Subject: [PATCH] Feature: Group queues in Menu (#867)

* feat: add delimiter property to queue adapter and app queue interfaces

This change enhances the flexibility of queue configurations by allowing the specification of a delimiter.

* feat(Menu): implement hierarchical queue display and enhance styling

- Introduced a new `QueueTree` component to render queues in a hierarchical structure based on a delimiter.
- Updated the `Menu` component to utilize the new `QueueTree` for displaying queues.
- Enhanced CSS styles for the menu, including padding adjustments and added margin for nested levels.

* refactor(example.ts): update queue names and add delimiter support (hierarchy support)

* feat(menu): added two queue examples to demonestrate grouping in the menu component

* style(Menu): update CSS for improved layout and padding

* fix(example.ts): added an example with a different delimiter

* refactor: renamed variable

* perf(Menu, toTree): improved toTree to use arrays instead of objects for improved performance

---------

Co-authored-by: ahmad anwar <7552088+AhmedAnwarHafez@users.noreply.github.com>
---
 example.ts                                    | 11 ++-
 packages/api/src/handlers/queues.ts           |  1 +
 packages/api/src/queueAdapters/base.ts        |  2 +
 packages/api/typings/app.ts                   |  2 +
 .../ui/src/components/Menu/Menu.module.css    | 17 ++++-
 packages/ui/src/components/Menu/Menu.tsx      | 75 ++++++++++++-------
 packages/ui/src/utils/toTree.ts               | 49 ++++++++++++
 7 files changed, 125 insertions(+), 32 deletions(-)
 create mode 100644 packages/ui/src/utils/toTree.ts

diff --git a/example.ts b/example.ts
index 3a27c33e2..b2522f88d 100644
--- a/example.ts
+++ b/example.ts
@@ -53,7 +53,9 @@ const run = async () => {
   const app = express();
 
   const exampleBull = createQueue3('ExampleBull');
-  const exampleBullMq = createQueueMQ('ExampleBullMQ');
+  const exampleBullMq = createQueueMQ('Examples.BullMQ');
+  const newRegistration = createQueueMQ('Notifications.User.NewRegistration');
+  const resetPassword = createQueueMQ('Notifications:User:ResetPassword');
   const flow = new FlowProducer({ connection: redisOptions });
 
   setupBullProcessor(exampleBull); // needed only for example proposes
@@ -135,7 +137,12 @@ const run = async () => {
   serverAdapter.setBasePath('/ui');
 
   createBullBoard({
-    queues: [new BullMQAdapter(exampleBullMq), new BullAdapter(exampleBull)],
+    queues: [
+      new BullMQAdapter(exampleBullMq, { delimiter: '.' }),
+      new BullAdapter(exampleBull, { delimiter: '.' }),
+      new BullMQAdapter(newRegistration, { delimiter: '.' }),
+      new BullMQAdapter(resetPassword, { delimiter: ':' }),
+    ],
     serverAdapter,
   });
 
diff --git a/packages/api/src/handlers/queues.ts b/packages/api/src/handlers/queues.ts
index 49bf5cfbf..4de6b99f0 100644
--- a/packages/api/src/handlers/queues.ts
+++ b/packages/api/src/handlers/queues.ts
@@ -90,6 +90,7 @@ async function getAppQueues(
         allowCompletedRetries: queue.allowCompletedRetries,
         isPaused,
         type: queue.type,
+        delimiter: queue.delimiter,
       };
     })
   );
diff --git a/packages/api/src/queueAdapters/base.ts b/packages/api/src/queueAdapters/base.ts
index 4973d1f02..79e26b2a9 100644
--- a/packages/api/src/queueAdapters/base.ts
+++ b/packages/api/src/queueAdapters/base.ts
@@ -15,6 +15,7 @@ export abstract class BaseAdapter {
   public readonly allowRetries: boolean;
   public readonly allowCompletedRetries: boolean;
   public readonly prefix: string;
+  public readonly delimiter: string;
   public readonly description: string;
   public readonly type: QueueType;
   private formatters = new Map<FormatterField, (data: any) => any>();
@@ -27,6 +28,7 @@ export abstract class BaseAdapter {
     this.allowRetries = this.readOnlyMode ? false : options.allowRetries !== false;
     this.allowCompletedRetries = this.allowRetries && options.allowCompletedRetries !== false;
     this.prefix = options.prefix || '';
+    this.delimiter = options.delimiter || '';
     this.description = options.description || '';
     this.type = type;
   }
diff --git a/packages/api/typings/app.ts b/packages/api/typings/app.ts
index bcedbbe6d..fcc3fc41a 100644
--- a/packages/api/typings/app.ts
+++ b/packages/api/typings/app.ts
@@ -31,6 +31,7 @@ export interface QueueAdapterOptions {
   allowRetries: boolean;
   prefix: string;
   description: string;
+  delimiter: string;
 }
 
 export type BullBoardQueues = Map<string, BaseAdapter>;
@@ -117,6 +118,7 @@ export interface AppJob {
 export type QueueType = 'bull' | 'bullmq';
 
 export interface AppQueue {
+  delimiter: string;
   name: string;
   description?: string;
   counts: Record<Status, number>;
diff --git a/packages/ui/src/components/Menu/Menu.module.css b/packages/ui/src/components/Menu/Menu.module.css
index 03f91f5ca..11a4eb193 100644
--- a/packages/ui/src/components/Menu/Menu.module.css
+++ b/packages/ui/src/components/Menu/Menu.module.css
@@ -31,18 +31,27 @@
 
 .menu {
   list-style: none;
-  padding: 0;
+  padding: 0.5rem 0;
 }
 
-.menu li + li {
-  border-top: 1px solid hsl(206, 9%, 25%);
+.menuLevel {
+  margin-left: 0.5rem;
+}
+
+.menuLevel details {
+  padding: 0.5rem 1rem;
+  cursor: pointer;
+}
+
+.menuLevel details summary {
+  padding: 0.5rem 0;
 }
 
 .menu a {
   color: inherit;
   text-decoration: none;
   display: block;
-  padding: 1rem 1.25rem;
+  padding: 0.5rem 1rem;
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx
index a991f6f28..ed8245723 100644
--- a/packages/ui/src/components/Menu/Menu.tsx
+++ b/packages/ui/src/components/Menu/Menu.tsx
@@ -7,18 +7,22 @@ import { useQueues } from './../../hooks/useQueues';
 import { links } from '../../utils/links';
 import { SearchIcon } from '../Icons/Search';
 import s from './Menu.module.css';
+import { AppQueueTreeNode, toTree } from '../../utils/toTree';
 
 export const Menu = () => {
   const { t } = useTranslation();
   const { queues } = useQueues();
-
-  const selectedStatuses = useSelectedStatuses();
   const [searchTerm, setSearchTerm] = useState('');
 
+  const tree = toTree(
+    queues?.filter((queue) =>
+      queue.name?.toLowerCase().includes(searchTerm?.toLowerCase() as string)
+    ) || []
+  );
+
   return (
     <aside className={s.aside}>
       <div className={s.secondary}>{t('MENU.QUEUES')}</div>
-
       {(queues?.length || 0) > 5 && (
         <div className={s.searchWrapper}>
           <SearchIcon />
@@ -33,30 +37,49 @@ export const Menu = () => {
         </div>
       )}
       <nav>
-        {!!queues && (
-          <ul className={s.menu}>
-            {queues
-              .filter(({ name }) =>
-                name?.toLowerCase().includes(searchTerm?.toLowerCase() as string)
-              )
-              .map(({ name: queueName, isPaused }) => (
-                <li key={queueName}>
-                  <NavLink
-                    to={links.queuePage(queueName, selectedStatuses)}
-                    activeClassName={s.active}
-                    title={queueName}
-                  >
-                    {queueName}{' '}
-                    {isPaused && <span className={s.isPaused}>[ {t('MENU.PAUSED')} ]</span>}
-                  </NavLink>
-                </li>
-              ))}
-          </ul>
-        )}
+        <QueueTree tree={tree} />
       </nav>
-        <a className={cn(s.appVersion, s.secondary)} target="_blank" rel="noreferrer"
-           href="https://github.com/felixmosh/bull-board/releases"
-        >{process.env.APP_VERSION}</a>
+      <a
+        className={cn(s.appVersion, s.secondary)}
+        target="_blank"
+        rel="noreferrer"
+        href="https://github.com/felixmosh/bull-board/releases"
+      >
+        {process.env.APP_VERSION}
+      </a>
     </aside>
   );
 };
+
+function QueueTree({ tree }: { tree: AppQueueTreeNode }) {
+  const { t } = useTranslation();
+  const selectedStatuses = useSelectedStatuses();
+
+  if (!tree.children.length) return null;
+
+  return (
+    <div className={s.menuLevel}>
+      {tree.children.map((node) => {
+        const isLeafNode = !node.children.length;
+
+        return isLeafNode ? (
+          <div key={node.name} className={s.menu}>
+            <NavLink
+              to={links.queuePage(node.name, selectedStatuses)}
+              activeClassName={s.active}
+              title={node.name}
+            >
+              {node.name}
+              {node.queue?.isPaused && <span className={s.isPaused}>[ {t('MENU.PAUSED')} ]</span>}
+            </NavLink>
+          </div>
+        ) : (
+          <details key={node.name} className={s.menu} open>
+            <summary>{node.name}</summary>
+            <QueueTree tree={node} />
+          </details>
+        );
+      })}
+    </div>
+  );
+}
diff --git a/packages/ui/src/utils/toTree.ts b/packages/ui/src/utils/toTree.ts
new file mode 100644
index 000000000..e444756b6
--- /dev/null
+++ b/packages/ui/src/utils/toTree.ts
@@ -0,0 +1,49 @@
+import { AppQueue } from '@bull-board/api/typings/app';
+
+export interface AppQueueTreeNode {
+  name: string;
+  queue?: AppQueue;
+  children: AppQueueTreeNode[];
+}
+
+export function toTree(queues: AppQueue[]): AppQueueTreeNode {
+  const root: AppQueueTreeNode = {
+    name: 'root',
+    children: [],
+  };
+
+  queues.forEach((queue) => {
+    if (!queue.delimiter) {
+      // If no delimiter, add as direct child to root
+      root.children.push({
+        name: queue.name,
+        queue,
+        children: [],
+      });
+      return;
+    }
+
+    const parts = queue.name.split(queue.delimiter);
+    let currentLevel = root.children;
+    let currentPath = '';
+
+    parts.forEach((part, index) => {
+      currentPath = currentPath ? `${currentPath}${queue.delimiter}${part}` : part;
+      let node = currentLevel.find((n) => n.name === part);
+
+      if (!node) {
+        node = {
+          name: part,
+          children: [],
+          // Only set queue data if we're at the leaf node
+          ...(index === parts.length - 1 ? { queue } : {}),
+        };
+        currentLevel.push(node);
+      }
+
+      currentLevel = node.children;
+    });
+  });
+
+  return root;
+}