diff --git a/.changeset/fifty-pumpkins-film.md b/.changeset/fifty-pumpkins-film.md new file mode 100644 index 00000000..bff79ef7 --- /dev/null +++ b/.changeset/fifty-pumpkins-film.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +Allow to drag and drop saved searches and dashhoards between groups diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index 46fd7918..e3d9a021 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -60,10 +60,10 @@ export async function updateDashboardAndAlerts( query, tags, }: { - name: string; - charts: z.infer[]; - query: string; - tags: z.infer; + name?: string; + charts?: z.infer[]; + query?: string; + tags?: z.infer; }, ) { const oldDashboard = await Dashboard.findOne({ diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index eea38f13..f33333a8 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -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, }), }), diff --git a/packages/api/src/routers/api/logViews.ts b/packages/api/src/routers/api/logViews.ts index 63989191..2daa0b8f 100644 --- a/packages/api/src/routers/api/logViews.ts +++ b/packages/api/src/routers/api/logViews.ts @@ -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 }, diff --git a/packages/app/src/AppNav.tsx b/packages/app/src/AppNav.tsx index 1780bd4b..ddb77f1c 100644 --- a/packages/app/src/AppNav.tsx +++ b/packages/app/src/AppNav.tsx @@ -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 = ({ name, groups, renderLink, + onDragEnd, forceExpandGroups = false, }: { name: string; groups: AppNavLinkGroup[]; 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 = ({ [collapsedGroups, setCollapsedGroups], ); + const [draggingOver, setDraggingOver] = useState(null); + return ( <> {groups.map(group => ( -
+
{ + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDraggingOver(group.name); + }} + onDragEnd={e => { + e.preventDefault(); + onDragEnd?.(e.target as HTMLElement, draggingOver); + setDraggingOver(null); + }} + > handleToggleGroup(group.name)} name={group.name} @@ -621,10 +643,20 @@ function useSearchableList({ 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} >
{lv.name}
{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) => ( {dashboard.name} @@ -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 ( <> @@ -934,6 +1028,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { groups={groupedFilteredSearchesList} renderLink={renderLogViewLink} forceExpandGroups={!!searchesListQ} + onDragEnd={handleLogViewDragEnd} />
@@ -1170,6 +1265,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { groups={groupedFilteredDashboardsList} renderLink={renderDashboardLink} forceExpandGroups={!!dashboardsListQ} + onDragEnd={handleDashboardDragEnd} /> {dashboards.length === 0 && ( diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index cc3cb1c1..c863461a 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -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 }) => diff --git a/packages/app/styles/AppNav.module.scss b/packages/app/styles/AppNav.module.scss index cb6b0c1d..f33eba07 100644 --- a/packages/app/styles/AppNav.module.scss +++ b/packages/app/styles/AppNav.module.scss @@ -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;