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; +}