mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Navbar Drag and Drop (#313)
https://github.com/hyperdxio/hyperdx/assets/149748269/15fb4bb0-db52-4a6c-b7c3-ac6357f04820
This commit is contained in:
parent
95f5041c36
commit
dba8a434be
7 changed files with 130 additions and 22 deletions
5
.changeset/fifty-pumpkins-film.md
Normal file
5
.changeset/fifty-pumpkins-film.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hyperdx/app': patch
|
||||
---
|
||||
|
||||
Allow to drag and drop saved searches and dashhoards between groups
|
||||
|
|
@ -60,10 +60,10 @@ export async function updateDashboardAndAlerts(
|
|||
query,
|
||||
tags,
|
||||
}: {
|
||||
name: string;
|
||||
charts: z.infer<typeof chartSchema>[];
|
||||
query: string;
|
||||
tags: z.infer<typeof tagsSchema>;
|
||||
name?: string;
|
||||
charts?: z.infer<typeof chartSchema>[];
|
||||
query?: string;
|
||||
tags?: z.infer<typeof tagsSchema>;
|
||||
},
|
||||
) {
|
||||
const oldDashboard = await Dashboard.findOne({
|
||||
|
|
|
|||
|
|
@ -95,9 +95,9 @@ router.put(
|
|||
id: objectIdSchema,
|
||||
}),
|
||||
body: z.object({
|
||||
name: z.string().max(1024),
|
||||
charts: z.array(chartSchema),
|
||||
query: z.string().max(2048),
|
||||
name: z.string().max(1024).optional(),
|
||||
charts: z.array(chartSchema).optional(),
|
||||
query: z.string().max(2048).optional(),
|
||||
tags: tagsSchema,
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ router.patch(
|
|||
}),
|
||||
body: z.object({
|
||||
name: z.string().max(1024).min(1).optional(),
|
||||
query: z.string().max(2048),
|
||||
query: z.string().max(2048).optional(),
|
||||
tags: tagsSchema,
|
||||
}),
|
||||
}),
|
||||
|
|
@ -103,7 +103,7 @@ router.patch(
|
|||
},
|
||||
{
|
||||
...(name && { name }),
|
||||
query,
|
||||
...(query && { query }),
|
||||
tags: tags && uniq(tags),
|
||||
},
|
||||
{ new: true },
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ import { useLocalStorage, useWindowSize } from './utils';
|
|||
|
||||
import styles from '../styles/AppNav.module.scss';
|
||||
|
||||
const UNTAGGED_SEARCHES_GROUP_NAME = 'Saved Searches';
|
||||
const UNTAGGED_DASHBOARDS_GROUP_NAME = 'Saved Dashboards';
|
||||
|
||||
const APP_PERFORMANCE_DASHBOARD_CONFIG = {
|
||||
id: '',
|
||||
name: 'App Performance',
|
||||
|
|
@ -539,11 +542,13 @@ const AppNavLinkGroups = <T extends AppNavLinkItem>({
|
|||
name,
|
||||
groups,
|
||||
renderLink,
|
||||
onDragEnd,
|
||||
forceExpandGroups = false,
|
||||
}: {
|
||||
name: string;
|
||||
groups: AppNavLinkGroup<T>[];
|
||||
renderLink: (item: T) => React.ReactNode;
|
||||
onDragEnd?: (target: HTMLElement | null, newGroup: string | null) => void;
|
||||
forceExpandGroups?: boolean;
|
||||
}) => {
|
||||
const [collapsedGroups, setCollapsedGroups] = useLocalStorage<
|
||||
|
|
@ -560,10 +565,27 @@ const AppNavLinkGroups = <T extends AppNavLinkItem>({
|
|||
[collapsedGroups, setCollapsedGroups],
|
||||
);
|
||||
|
||||
const [draggingOver, setDraggingOver] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map(group => (
|
||||
<div key={group.name}>
|
||||
<div
|
||||
key={group.name}
|
||||
className={cx(
|
||||
draggingOver === group.name && styles.listGroupDragEnter,
|
||||
)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDraggingOver(group.name);
|
||||
}}
|
||||
onDragEnd={e => {
|
||||
e.preventDefault();
|
||||
onDragEnd?.(e.target as HTMLElement, draggingOver);
|
||||
setDraggingOver(null);
|
||||
}}
|
||||
>
|
||||
<AppNavGroupLabel
|
||||
onClick={() => handleToggleGroup(group.name)}
|
||||
name={group.name}
|
||||
|
|
@ -621,10 +643,20 @@ function useSearchableList<T extends AppNavLinkItem>({
|
|||
if (untaggedItems.length) {
|
||||
groupedItems[untaggedGroupName] = untaggedItems;
|
||||
}
|
||||
return Object.entries(groupedItems).map(([name, items]) => ({
|
||||
name,
|
||||
items,
|
||||
}));
|
||||
return Object.entries(groupedItems)
|
||||
.map(([name, items]) => ({
|
||||
name,
|
||||
items,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.name === untaggedGroupName) {
|
||||
return 1;
|
||||
}
|
||||
if (b.name === untaggedGroupName) {
|
||||
return -1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [filteredList, untaggedGroupName]);
|
||||
|
||||
return {
|
||||
|
|
@ -660,8 +692,14 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
} = api.useLogViews();
|
||||
const logViews = logViewsData?.data ?? [];
|
||||
|
||||
const { data: dashboardsData, isLoading: isDashboardsLoading } =
|
||||
api.useDashboards();
|
||||
const updateDashboard = api.useUpdateDashboard();
|
||||
const updateLogView = api.useUpdateLogView();
|
||||
|
||||
const {
|
||||
data: dashboardsData,
|
||||
isLoading: isDashboardsLoading,
|
||||
refetch: refetchDashboards,
|
||||
} = api.useDashboards();
|
||||
const dashboards = dashboardsData?.data ?? [];
|
||||
|
||||
const { data: alertsData, isLoading: isAlertsLoading } = api.useAlerts();
|
||||
|
|
@ -734,7 +772,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
groupedFilteredList: groupedFilteredSearchesList,
|
||||
} = useSearchableList({
|
||||
items: logViews,
|
||||
untaggedGroupName: 'Saved Searches',
|
||||
untaggedGroupName: UNTAGGED_SEARCHES_GROUP_NAME,
|
||||
});
|
||||
|
||||
const [isSearchPresetsCollapsed, setSearchPresetsCollapsed] = useLocalStorage(
|
||||
|
|
@ -749,7 +787,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
groupedFilteredList: groupedFilteredDashboardsList,
|
||||
} = useSearchableList({
|
||||
items: dashboards,
|
||||
untaggedGroupName: 'Saved Dashboards',
|
||||
untaggedGroupName: UNTAGGED_DASHBOARDS_GROUP_NAME,
|
||||
});
|
||||
|
||||
const [isDashboardsPresetsCollapsed, setDashboardsPresetsCollapsed] =
|
||||
|
|
@ -777,6 +815,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
lv._id === query.savedSearchId && styles.listLinkActive,
|
||||
)}
|
||||
title={lv.name}
|
||||
draggable
|
||||
data-savedsearchid={lv._id}
|
||||
>
|
||||
<div className="d-inline-block text-truncate">{lv.name}</div>
|
||||
{Array.isArray(lv.alerts) && lv.alerts.length > 0 ? (
|
||||
|
|
@ -802,6 +842,32 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
],
|
||||
);
|
||||
|
||||
const handleLogViewDragEnd = useCallback(
|
||||
(target: HTMLElement | null, name: string | null) => {
|
||||
if (!target?.dataset.savedsearchid || name == null) {
|
||||
return;
|
||||
}
|
||||
const logView = logViews.find(
|
||||
lv => lv._id === target.dataset.savedsearchid,
|
||||
);
|
||||
if (logView?.tags?.includes(name)) {
|
||||
return;
|
||||
}
|
||||
updateLogView.mutate(
|
||||
{
|
||||
id: target.dataset.savedsearchid,
|
||||
tags: name === UNTAGGED_SEARCHES_GROUP_NAME ? [] : [name],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchLogViews();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[logViews, refetchLogViews, updateLogView],
|
||||
);
|
||||
|
||||
const renderDashboardLink = useCallback(
|
||||
(dashboard: Dashboard) => (
|
||||
<Link
|
||||
|
|
@ -811,6 +877,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
className={cx(styles.listLink, {
|
||||
[styles.listLinkActive]: dashboard._id === query.dashboardId,
|
||||
})}
|
||||
draggable
|
||||
data-dashboardid={dashboard._id}
|
||||
>
|
||||
{dashboard.name}
|
||||
</Link>
|
||||
|
|
@ -818,6 +886,32 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
[query.dashboardId],
|
||||
);
|
||||
|
||||
const handleDashboardDragEnd = useCallback(
|
||||
(target: HTMLElement | null, name: string | null) => {
|
||||
if (!target?.dataset.dashboardid || name == null) {
|
||||
return;
|
||||
}
|
||||
const dashboard = dashboards.find(
|
||||
d => d._id === target.dataset.dashboardid,
|
||||
);
|
||||
if (dashboard?.tags?.includes(name)) {
|
||||
return;
|
||||
}
|
||||
updateDashboard.mutate(
|
||||
{
|
||||
id: target.dataset.dashboardid,
|
||||
tags: name === UNTAGGED_DASHBOARDS_GROUP_NAME ? [] : [name],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchDashboards();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[dashboards, refetchDashboards, updateDashboard],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthLoadingBlocker />
|
||||
|
|
@ -934,6 +1028,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
groups={groupedFilteredSearchesList}
|
||||
renderLink={renderLogViewLink}
|
||||
forceExpandGroups={!!searchesListQ}
|
||||
onDragEnd={handleLogViewDragEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1170,6 +1265,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
groups={groupedFilteredDashboardsList}
|
||||
renderLink={renderDashboardLink}
|
||||
forceExpandGroups={!!dashboardsListQ}
|
||||
onDragEnd={handleDashboardDragEnd}
|
||||
/>
|
||||
|
||||
{dashboards.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@ const api = {
|
|||
Error,
|
||||
{
|
||||
id: string;
|
||||
query: string;
|
||||
query?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
>(`log-views`, async ({ id, query, tags }) =>
|
||||
|
|
@ -624,9 +624,9 @@ const api = {
|
|||
HTTPError,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
query: string;
|
||||
charts: any[];
|
||||
name?: string;
|
||||
query?: string;
|
||||
charts?: any[];
|
||||
tags?: string[];
|
||||
}
|
||||
>(async ({ id, name, charts, query, tags }) =>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@
|
|||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.listGroupDragEnter {
|
||||
opacity: 0.7;
|
||||
background: #ffffff1a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.listGroupName {
|
||||
color: $slate-400;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -41,6 +47,7 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 16px;
|
||||
user-select: none;
|
||||
gap: 10px;
|
||||
&:hover {
|
||||
color: $slate-100;
|
||||
|
|
|
|||
Loading…
Reference in a new issue