diff --git a/.changeset/dashboards-smart-views-tag-only.md b/.changeset/dashboards-smart-views-tag-only.md new file mode 100644 index 0000000000..d0bdb22601 --- /dev/null +++ b/.changeset/dashboards-smart-views-tag-only.md @@ -0,0 +1,19 @@ +--- +'@hyperdx/app': minor +'@hyperdx/api': minor +--- + +feat(dashboards): list views sidebar with tag-only rules on the Dashboards listing page + +Save reusable filter combinations as named "views" pinned to a +left-rail sidebar on the Dashboards listing page. Rules in v1 are +tag-only (`tag includes X`, `tag excludes Y`, `is untagged`) with an +`all` / `any` combinator. Clicking a view applies its rules to the +listing and shares the URL via `?view=`; clicking it again clears +the active view. Edit and delete actions live on a kebab menu per +sidebar entry. + +Backed by a new `/list-views` CRUD endpoint scoped per user and per +resource. The resource discriminator (`dashboard` | `savedSearch`) +is in the schema so Saved Searches parity drops in without a schema +change. diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 5f4c3181d4..e13bd2524a 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -13,6 +13,7 @@ import routers from './routers/api'; import clickhouseProxyRouter from './routers/api/clickhouseProxy'; import connectionsRouter from './routers/api/connections'; import favoritesRouter from './routers/api/favorites'; +import listViewsRouter from './routers/api/listViews'; import pinnedFiltersRouter from './routers/api/pinnedFilters'; import savedSearchRouter from './routers/api/savedSearch'; import sourcesRouter from './routers/api/sources'; @@ -106,6 +107,7 @@ app.use('/connections', isUserAuthenticated, connectionsRouter); app.use('/sources', isUserAuthenticated, sourcesRouter); app.use('/saved-search', isUserAuthenticated, savedSearchRouter); app.use('/favorites', isUserAuthenticated, favoritesRouter); +app.use('/list-views', isUserAuthenticated, listViewsRouter); app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter); app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter); if (config.IS_PROMQL_ENABLED) { diff --git a/packages/api/src/controllers/listView.ts b/packages/api/src/controllers/listView.ts new file mode 100644 index 0000000000..c6a59fedd4 --- /dev/null +++ b/packages/api/src/controllers/listView.ts @@ -0,0 +1,51 @@ +import { + ListViewResource, + ListViewWithoutId, +} from '@hyperdx/common-utils/dist/types'; + +import ListView, { IListView } from '@/models/listView'; + +export function getListViews( + userId: string, + teamId: string, + resource?: ListViewResource, +) { + const filter: Record = { owner: userId, team: teamId }; + if (resource) filter.resource = resource; + return ListView.find(filter).sort({ ordering: 1, createdAt: 1 }); +} + +export function getListView(id: string, userId: string, teamId: string) { + return ListView.findOne({ _id: id, owner: userId, team: teamId }); +} + +export function createListView( + userId: string, + teamId: string, + view: ListViewWithoutId, +) { + return ListView.create({ + ...view, + owner: userId, + team: teamId, + }); +} + +export function updateListView( + id: string, + userId: string, + teamId: string, + patch: Partial, +) { + return ListView.findOneAndUpdate( + { _id: id, owner: userId, team: teamId }, + patch, + { new: true }, + ); +} + +export function deleteListView(id: string, userId: string, teamId: string) { + return ListView.deleteOne({ _id: id, owner: userId, team: teamId }); +} + +export type ListViewExport = IListView; diff --git a/packages/api/src/models/listView.ts b/packages/api/src/models/listView.ts new file mode 100644 index 0000000000..5f8bf3b556 --- /dev/null +++ b/packages/api/src/models/listView.ts @@ -0,0 +1,83 @@ +import { ListViewSchema } from '@hyperdx/common-utils/dist/types'; +import mongoose, { Schema } from 'mongoose'; +import { z } from 'zod'; + +import type { ObjectId } from '.'; + +export interface IListView extends z.infer { + _id: ObjectId; + team: ObjectId; + owner: ObjectId; + createdAt: Date; + updatedAt: Date; +} + +export type ListViewDocument = mongoose.HydratedDocument; + +const listViewSchema = new Schema( + { + name: { + type: String, + required: true, + maxlength: 120, + }, + icon: { + type: String, + required: false, + maxlength: 64, + }, + resource: { + type: String, + required: true, + enum: ['dashboard', 'savedSearch'], + }, + // Stored as Mixed; the Zod schema in @hyperdx/common-utils is the + // source of truth for shape. PR-3 widens the rule union (non-tag + // kinds) additively; existing documents keep parsing. + rules: { + type: mongoose.Schema.Types.Mixed, + required: true, + default: [], + }, + combinator: { + type: String, + required: true, + enum: ['all', 'any'], + default: 'all', + }, + ordering: { + type: Number, + required: true, + default: 0, + }, + isShared: { + type: Boolean, + required: true, + default: false, + }, + team: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + }, +); + +// The listing query is always scoped to (team, owner, resource) and +// sorted by ordering. Mirrors the unique index pattern from +// favorite.ts but without the unique constraint: a user can name two +// views the same intentionally (e.g. "checkout" dashboards vs +// "checkout" saved searches across resources, or two iterations +// during editing). +listViewSchema.index({ team: 1, owner: 1, resource: 1, ordering: 1 }); + +export default mongoose.model('ListView', listViewSchema); diff --git a/packages/api/src/routers/api/__tests__/listViews.test.ts b/packages/api/src/routers/api/__tests__/listViews.test.ts new file mode 100644 index 0000000000..6e62a1ec2f --- /dev/null +++ b/packages/api/src/routers/api/__tests__/listViews.test.ts @@ -0,0 +1,230 @@ +import mongoose from 'mongoose'; + +import { getLoggedInAgent, getServer } from '@/fixtures'; + +describe('list views router', () => { + const server = getServer(); + let agent: Awaited>['agent']; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + agent = result.agent; + }); + + afterEach(async () => { + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + const dashboardView = { + name: 'Checkout team', + resource: 'dashboard' as const, + rules: [{ kind: 'tag-includes' as const, tag: 'checkout' }], + combinator: 'any' as const, + ordering: 0, + isShared: false, + }; + + it('round-trips a dashboard list view through POST and GET', async () => { + const create = await agent + .post('/list-views') + .send(dashboardView) + .expect(200); + expect(create.body).toMatchObject(dashboardView); + expect(create.body.id).toBeDefined(); + + const list = await agent.get('/list-views?resource=dashboard').expect(200); + expect(list.body).toHaveLength(1); + expect(list.body[0]).toMatchObject(dashboardView); + }); + + it('filters list by resource discriminator', async () => { + await agent.post('/list-views').send(dashboardView).expect(200); + await agent + .post('/list-views') + .send({ + ...dashboardView, + name: 'Errors search', + resource: 'savedSearch', + }) + .expect(200); + + const dashList = await agent + .get('/list-views?resource=dashboard') + .expect(200); + expect(dashList.body).toHaveLength(1); + expect(dashList.body[0].resource).toBe('dashboard'); + + const searchList = await agent + .get('/list-views?resource=savedSearch') + .expect(200); + expect(searchList.body).toHaveLength(1); + expect(searchList.body[0].resource).toBe('savedSearch'); + }); + + it('returns all resources when no resource query param is set', async () => { + await agent.post('/list-views').send(dashboardView).expect(200); + await agent + .post('/list-views') + .send({ + ...dashboardView, + name: 'Errors search', + resource: 'savedSearch', + }) + .expect(200); + + const list = await agent.get('/list-views').expect(200); + expect(list.body).toHaveLength(2); + }); + + it('patches name and rules and reflects the change on GET', async () => { + const create = await agent + .post('/list-views') + .send(dashboardView) + .expect(200); + const { id } = create.body; + + const patch = await agent + .patch(`/list-views/${id}`) + .send({ + name: 'Checkout + payments', + rules: [ + { kind: 'tag-includes', tag: 'checkout' }, + { kind: 'tag-includes', tag: 'payments' }, + ], + combinator: 'all', + }) + .expect(200); + + expect(patch.body.name).toBe('Checkout + payments'); + expect(patch.body.rules).toHaveLength(2); + expect(patch.body.combinator).toBe('all'); + }); + + it('deletes a list view and removes it from the listing', async () => { + const create = await agent + .post('/list-views') + .send(dashboardView) + .expect(200); + const { id } = create.body; + + await agent.delete(`/list-views/${id}`).expect(204); + + const list = await agent.get('/list-views?resource=dashboard').expect(200); + expect(list.body).toHaveLength(0); + }); + + it('returns 404 when patching a non-existent list view', async () => { + const fakeId = new mongoose.Types.ObjectId().toString(); + await agent + .patch(`/list-views/${fakeId}`) + .send({ name: 'never' }) + .expect(404); + }); + + it('returns 404 when deleting a non-existent list view', async () => { + const fakeId = new mongoose.Types.ObjectId().toString(); + await agent.delete(`/list-views/${fakeId}`).expect(404); + }); + + it('rejects a body missing required fields', async () => { + await agent + .post('/list-views') + .send({ name: 'no resource', rules: [], combinator: 'all', ordering: 0 }) + .expect(400); + }); + + it('rejects a body with an unknown rule kind', async () => { + await agent + .post('/list-views') + .send({ + ...dashboardView, + rules: [{ kind: 'definitely-not-a-rule' }], + }) + .expect(400); + }); + + it('round-trips a view that mixes tag and non-tag rule kinds', async () => { + // One per non-tag kind plus a tag rule, all under combinator=all. + // toMatchObject covers everything in one assertion to keep the + // canonical config declared in a single place. + const mixed = { + name: 'My fresh dashes with checkout', + resource: 'dashboard' as const, + combinator: 'all' as const, + ordering: 0, + isShared: false, + rules: [ + { kind: 'tag-includes' as const, tag: 'checkout' }, + { kind: 'updated-within-days' as const, days: 7 }, + { kind: 'has-active-alerts' as const }, + { kind: 'created-by-me' as const }, + ], + }; + + const create = await agent.post('/list-views').send(mixed).expect(200); + expect(create.body).toMatchObject(mixed); + expect(create.body.id).toBeDefined(); + + const list = await agent.get('/list-views?resource=dashboard').expect(200); + expect(list.body).toHaveLength(1); + expect(list.body[0]).toMatchObject(mixed); + }); + + it('rejects updated-within-days with days out of range', async () => { + await agent + .post('/list-views') + .send({ + ...dashboardView, + rules: [{ kind: 'updated-within-days', days: 0 }], + }) + .expect(400); + await agent + .post('/list-views') + .send({ + ...dashboardView, + rules: [{ kind: 'updated-within-days', days: 366 }], + }) + .expect(400); + }); + + it('rejects a name longer than 120 chars', async () => { + await agent + .post('/list-views') + .send({ ...dashboardView, name: 'x'.repeat(121) }) + .expect(400); + }); + + it('isolates list views between users on the same team', async () => { + // Create a view as the default agent. + const create = await agent + .post('/list-views') + .send(dashboardView) + .expect(200); + const { id } = create.body; + + // A second login for another user (default `getLoggedInAgent` + // creates a fresh user per call when an email is not pinned). + const other = await getLoggedInAgent(server); + + // Second user's listing is empty. + const otherList = await other.agent + .get('/list-views?resource=dashboard') + .expect(200); + expect(otherList.body).toHaveLength(0); + + // Second user cannot patch or delete the first user's view. + await other.agent + .patch(`/list-views/${id}`) + .send({ name: 'hijack' }) + .expect(404); + await other.agent.delete(`/list-views/${id}`).expect(404); + }); +}); diff --git a/packages/api/src/routers/api/listViews.ts b/packages/api/src/routers/api/listViews.ts new file mode 100644 index 0000000000..3a4593ad10 --- /dev/null +++ b/packages/api/src/routers/api/listViews.ts @@ -0,0 +1,129 @@ +import { + ListViewResourceSchema, + ListViewWithoutIdSchema, +} from '@hyperdx/common-utils/dist/types'; +import express from 'express'; +import _ from 'lodash'; +import { z } from 'zod'; +import { validateRequest } from 'zod-express-middleware'; + +import { + createListView, + deleteListView, + getListView, + getListViews, + updateListView, +} from '@/controllers/listView'; +import { getNonNullUserWithTeam } from '@/middleware/auth'; +import { objectIdSchema } from '@/utils/zod'; + +const router = express.Router(); + +router.get( + '/', + validateRequest({ + query: z.object({ + resource: ListViewResourceSchema.optional(), + }), + }), + async (req, res, next) => { + try { + const { teamId, userId } = getNonNullUserWithTeam(req); + + const views = await getListViews( + userId.toString(), + teamId.toString(), + req.query.resource, + ); + + return res.json(views); + } catch (e) { + next(e); + } + }, +); + +router.post( + '/', + validateRequest({ + body: ListViewWithoutIdSchema, + }), + async (req, res, next) => { + try { + const { teamId, userId } = getNonNullUserWithTeam(req); + + const view = await createListView( + userId.toString(), + teamId.toString(), + req.body, + ); + + return res.json(view.toJSON()); + } catch (e) { + next(e); + } + }, +); + +router.patch( + '/:id', + validateRequest({ + params: z.object({ id: objectIdSchema }), + body: ListViewWithoutIdSchema.partial(), + }), + async (req, res, next) => { + try { + const { teamId, userId } = getNonNullUserWithTeam(req); + const { id } = req.params; + + const existing = await getListView( + id, + userId.toString(), + teamId.toString(), + ); + if (existing == null) { + return res.sendStatus(404); + } + + const updates = _.omitBy(req.body, _.isUndefined); + const updated = await updateListView( + id, + userId.toString(), + teamId.toString(), + updates, + ); + + return res.json(updated?.toJSON()); + } catch (e) { + next(e); + } + }, +); + +router.delete( + '/:id', + validateRequest({ + params: z.object({ id: objectIdSchema }), + }), + async (req, res, next) => { + try { + const { teamId, userId } = getNonNullUserWithTeam(req); + const { id } = req.params; + + const result = await deleteListView( + id, + userId.toString(), + teamId.toString(), + ); + if (result.deletedCount === 0) { + return res.sendStatus(404); + } + + return res.status(204).send(); + } catch (e) { + next(e); + } + }, +); + +export default router; diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index 7dbfdeea1f..d08e4dc843 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -2,22 +2,31 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import Head from 'next/head'; import Link from 'next/link'; import Router from 'next/router'; -import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; +import { + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, + useQueryState, +} from 'nuqs'; import { ActionIcon, Anchor, + Box, Button, Container, Flex, Group, Menu, MultiSelect, + Pill, + Popover, SimpleGrid, Table, Text, TextInput, } from '@mantine/core'; -import { useLocalStorage } from '@mantine/hooks'; +import { useDisclosure, useLocalStorage } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { IconChevronDown, @@ -29,12 +38,17 @@ import { IconUpload, } from '@tabler/icons-react'; +import api from '@/api'; import { AlertStatusIcon } from '@/components/AlertStatusIcon'; import EmptyState from '@/components/EmptyState'; import { FavoriteButton } from '@/components/FavoriteButton'; import { ListingCard } from '@/components/ListingCard'; import { ListingRow } from '@/components/ListingListRow'; +import { ListViewEditorDrawer } from '@/components/ListViewsSidebar/ListViewEditorDrawer'; +import { ListViewsSidebar } from '@/components/ListViewsSidebar/ListViewsSidebar'; import { PageHeader } from '@/components/PageHeader'; +import { SaveAsViewButton } from '@/components/SaveAsViewButton'; +import { SaveAsViewModal } from '@/components/SaveAsViewModal'; import { IS_K8S_DASHBOARD_ENABLED } from '@/config'; import { type Dashboard, @@ -43,8 +57,11 @@ import { useDeleteDashboard, } from '@/dashboard'; import { useFavorites } from '@/favorites'; +import { type ListView, useListViews } from '@/listView'; import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { useConfirm } from '@/useConfirm'; +import { getDefaultListViews } from '@/utils/defaultListViews'; +import { evaluateListView } from '@/utils/evaluateListView'; import { withAppNav } from '../../layout'; @@ -88,11 +105,80 @@ export default function DashboardsListPage() { .withOptions({ history: 'replace' }), ); const [legacyTag, setLegacyTag] = useQueryState('tag'); + const [activeViewId, setActiveViewId] = useQueryState('view'); + // Pill filter state. Each pill is independent and AND-combines + // with tag chips, search, and the active list view. + const [recentDays, setRecentDays] = useQueryState( + 'recentDays', + parseAsInteger, + ); + const [withAlerts, setWithAlerts] = useQueryState( + 'withAlerts', + parseAsBoolean, + ); + const [createdByMe, setCreatedByMe] = useQueryState( + 'createdByMe', + parseAsBoolean, + ); const [viewMode, setViewMode] = useLocalStorage<'grid' | 'list'>({ key: 'dashboardsViewMode', defaultValue: 'grid', }); + const { data: listViews } = useListViews('dashboard'); + const { data: me } = api.useMe(); + const evalContext = useMemo( + () => ({ + currentUserId: me?.id, + currentUserEmail: me?.email, + }), + [me?.id, me?.email], + ); + + const [editorOpened, { open: openEditor, close: closeEditor }] = + useDisclosure(false); + const [saveModalOpened, { open: openSaveModal, close: closeSaveModal }] = + useDisclosure(false); + const [editingView, setEditingView] = useState( + undefined, + ); + + const hasActiveFilters = + selectedTags.length > 0 || !!recentDays || !!withAlerts || !!createdByMe; + + const handleSaveAsView = useCallback( + (newId: string) => { + // Clear the transient filter state and route to the new view so + // the user sees the saved view applied with `?view=` rather + // than the (now duplicated) raw filters. + setSelectedTags([]); + setRecentDays(null); + setWithAlerts(null); + setCreatedByMe(null); + setActiveViewId(newId); + }, + [ + setActiveViewId, + setCreatedByMe, + setRecentDays, + setSelectedTags, + setWithAlerts, + ], + ); + + const handleCreateListView = useCallback(() => { + setEditingView(undefined); + openEditor(); + }, [openEditor]); + + const handleEditListView = useCallback( + (view: ListView) => { + setEditingView(view); + openEditor(); + }, + [openEditor], + ); + // Backward compat for shared links / bookmarks built before multi-select. // `?tag=foo` becomes `?tags=foo` once on mount. Modern `?tags=...` wins // when both are present. @@ -128,6 +214,37 @@ export default function DashboardsListPage() { return Array.from(tags).sort(); }, [dashboards]); + const systemViews = useMemo(() => getDefaultListViews('dashboard'), []); + + const activeView = useMemo(() => { + if (!activeViewId) return null; + // Look up system views first; ids never collide with user views + // because they carry the `system:` prefix. + const sys = systemViews.find(v => v.id === activeViewId); + if (sys) return sys; + return listViews?.find(v => v.id === activeViewId) ?? null; + }, [systemViews, listViews, activeViewId]); + + // Per-view match counts shown as badges in the sidebar. Computed + // off the same `dashboards` reference that drives the grid so the + // count and the visible result set never drift apart. Includes + // system views in the same pass so the suggested rail shows live + // counts. + const viewCounts = useMemo>(() => { + if (!dashboards) return {}; + const result: Record = {}; + const everyView = [...systemViews, ...(listViews ?? [])]; + for (const view of everyView) { + result[view.id] = dashboards.filter(d => + evaluateListView(view, d, { + ...evalContext, + itemHasActiveAlerts: getDashboardAlerts(d.tiles).length > 0, + }), + ).length; + } + return result; + }, [dashboards, listViews, systemViews, evalContext]); + const filteredDashboards = useMemo(() => { if (!dashboards) return []; let result = dashboards; @@ -142,8 +259,48 @@ export default function DashboardsListPage() { d.tags.some(t => t.toLowerCase().includes(q)), ); } + if (activeView) { + result = result.filter(d => + evaluateListView(activeView, d, { + ...evalContext, + itemHasActiveAlerts: getDashboardAlerts(d.tiles).length > 0, + }), + ); + } + // Filter pills layer on top of everything else; reuse the + // evaluator so the pill semantics match what the save flow + // persists. + if (recentDays && recentDays > 0) { + result = result.filter(d => + evaluateListView( + { rules: [{ kind: 'updated-within-days', days: recentDays }] }, + d, + ), + ); + } + if (withAlerts) { + result = result.filter(d => getDashboardAlerts(d.tiles).length > 0); + } + if (createdByMe) { + result = result.filter(d => + evaluateListView( + { rules: [{ kind: 'created-by-me' }] }, + d, + evalContext, + ), + ); + } return result.slice().sort((a, b) => a.name.localeCompare(b.name)); - }, [dashboards, search, selectedTags]); + }, [ + dashboards, + search, + selectedTags, + activeView, + evalContext, + recentDays, + withAlerts, + createdByMe, + ]); const handleCreate = useCallback(() => { createDashboard.mutate( @@ -198,256 +355,416 @@ export default function DashboardsListPage() { - - Preset Dashboards - - - {PRESET_DASHBOARDS.map(p => ( - - ))} - - - - Browse dashboard templates → - - - - {favoritedDashboards.length > 0 && ( - <> + + + + + - Favorites + Preset Dashboards - - {favoritedDashboards.map(d => ( - handleDelete(d.id)} - statusIcon={ - - } - resourceId={d.id} - resourceType="dashboard" - updatedAt={d.updatedAt} - updatedBy={d.updatedBy?.name || d.updatedBy?.email} - /> + + {PRESET_DASHBOARDS.map(p => ( + ))} - - )} - - - Team Dashboards - + + + Browse dashboard templates → + + - - - } - value={search} - onChange={e => setSearch(e.currentTarget.value)} - style={{ flex: 1, maxWidth: 400 }} - miw={100} - /> - {allTags.length > 0 && ( - - )} - - - - setViewMode('grid')} - aria-label="Grid view" - > - - - setViewMode('list')} - aria-label="List view" - > - - - - - - - - - - } - onClick={handleCreate} - data-testid="create-dashboard-button" - > - Saved Dashboard - - Persisted for your team - - - } - data-testid="temp-dashboard-button" - > - Temporary Dashboard - - Lives in your browser only - - - - - - + {favoritedDashboards.map(d => ( + handleDelete(d.id)} + statusIcon={ + + } + resourceId={d.id} + resourceType="dashboard" + updatedAt={d.updatedAt} + updatedBy={d.updatedBy?.name || d.updatedBy?.email} + /> + ))} + + + )} - {isLoading ? ( - - Loading dashboards... - - ) : isError ? ( - - Failed to load dashboards. Please try refreshing the page. - - ) : filteredDashboards.length === 0 ? ( - - } - title={ - search || selectedTags.length > 0 - ? 'No matching dashboards yet' - : 'No dashboards yet' - } + + Team Dashboards + + + - + + } + value={search} + onChange={e => setSearch(e.currentTarget.value)} + style={{ flex: 1, maxWidth: 400 }} + miw={100} + /> + {allTags.length > 0 && ( + + )} + + + + setViewMode('grid')} + aria-label="Grid view" + > + + + setViewMode('list')} + aria-label="List view" + > + + + - + + + + + + } + onClick={handleCreate} + data-testid="create-dashboard-button" + > + Saved Dashboard + + Persisted for your team + + + } + data-testid="temp-dashboard-button" + > + Temporary Dashboard + + Lives in your browser only + + + + - - - ) : viewMode === 'list' ? ( - - - - - Name - Tags - Created By - Last Updated - - - - - {filteredDashboards.map(d => ( - - - - - } - /> - ))} - -
- ) : ( - - {filteredDashboards.map(d => ( - handleDelete(d.id)} - statusIcon={ - - } - resourceId={d.id} - resourceType="dashboard" - updatedAt={d.updatedAt} - updatedBy={d.updatedBy?.name || d.updatedBy?.email} +
+ + + + Quick filters + + + + setRecentDays(null)} + onClick={() => { + if (!recentDays) setRecentDays(7); + }} + style={{ + cursor: 'pointer', + backgroundColor: recentDays + ? 'var(--mantine-color-default-hover)' + : undefined, + boxShadow: recentDays + ? 'inset 2px 0 0 var(--color-text-brand)' + : undefined, + }} + data-testid="list-view-pill-recent" + > + {recentDays + ? `Updated in ${recentDays}d` + : 'Recently updated'} + + + + + {[1, 7, 30].map(d => ( + setRecentDays(d)} + style={{ + cursor: 'pointer', + backgroundColor: + recentDays === d + ? 'var(--mantine-color-default-hover)' + : undefined, + }} + data-testid={`list-view-pill-recent-preset-${d}`} + > + {d}d + + ))} + + + + setWithAlerts(null)} + onClick={() => setWithAlerts(withAlerts ? null : true)} + style={{ + cursor: 'pointer', + backgroundColor: withAlerts + ? 'var(--mantine-color-default-hover)' + : undefined, + boxShadow: withAlerts + ? 'inset 2px 0 0 var(--color-text-brand)' + : undefined, + }} + data-testid="list-view-pill-alerts" + > + With active alerts + + setCreatedByMe(null)} + onClick={() => setCreatedByMe(createdByMe ? null : true)} + style={{ + cursor: 'pointer', + backgroundColor: createdByMe + ? 'var(--mantine-color-default-hover)' + : undefined, + boxShadow: createdByMe + ? 'inset 2px 0 0 var(--color-text-brand)' + : undefined, + }} + data-testid="list-view-pill-created-by-me" + > + Created by me + + + - ))} - - )} + + + {isLoading ? ( + + Loading dashboards... + + ) : isError ? ( + + Failed to load dashboards. Please try refreshing the page. + + ) : filteredDashboards.length === 0 ? ( + + } + title={ + search || + selectedTags.length > 0 || + recentDays || + withAlerts || + createdByMe || + activeViewId + ? 'No matching dashboards yet' + : 'No dashboards yet' + } + > + + + + + + + ) : viewMode === 'list' ? ( + + + + + Name + Tags + Created By + Last Updated + + + + + {filteredDashboards.map(d => ( + + + + + } + /> + ))} + +
+ ) : ( + + {filteredDashboards.map(d => ( + handleDelete(d.id)} + statusIcon={ + + } + resourceId={d.id} + resourceType="dashboard" + updatedAt={d.updatedAt} + updatedBy={d.updatedBy?.name || d.updatedBy?.email} + /> + ))} + + )} + +
+ + ); } diff --git a/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx b/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx index c30f5f5be6..5e7682856c 100644 --- a/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx +++ b/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx @@ -5,15 +5,26 @@ import DashboardsListPage from '../DashboardsListPage'; const mockSetSelectedTags = jest.fn(); const mockSetLegacyTag = jest.fn(); +const mockSetActiveViewId = jest.fn(); +const mockSetRecentDays = jest.fn(); +const mockSetWithAlerts = jest.fn(); +const mockSetCreatedByMe = jest.fn(); const mockUseDashboards = jest.fn(); const mockUseFavorites = jest.fn(); const mockUseCreateDashboard = jest.fn(); const mockUseDeleteDashboard = jest.fn(); +const mockUseListViews = jest.fn(); +const mockUseDeleteListView = jest.fn(); +const mockUseMe = jest.fn(); const mockUseConfirm = jest.fn(); const mockUseBrandDisplayName = jest.fn(); let mockSelectedTags: string[] = []; let mockLegacyTag: string | null = null; +let mockActiveViewId: string | null = null; +let mockRecentDays: number | null = null; +let mockWithAlerts: boolean | null = null; +let mockCreatedByMe: boolean | null = null; jest.mock('next/router', () => ({ __esModule: true, @@ -46,6 +57,8 @@ jest.mock('@/config', () => ({ jest.mock('nuqs', () => ({ parseAsString: 'parseAsString', + parseAsBoolean: 'parseAsBoolean', + parseAsInteger: 'parseAsInteger', parseAsArrayOf: () => ({ withDefault: () => ({ withOptions: () => 'parseAsArrayOfString', @@ -54,10 +67,28 @@ jest.mock('nuqs', () => ({ useQueryState: (key: string) => { if (key === 'tags') return [mockSelectedTags, mockSetSelectedTags]; if (key === 'tag') return [mockLegacyTag, mockSetLegacyTag]; + if (key === 'view') return [mockActiveViewId, mockSetActiveViewId]; + if (key === 'recentDays') return [mockRecentDays, mockSetRecentDays]; + if (key === 'withAlerts') return [mockWithAlerts, mockSetWithAlerts]; + if (key === 'createdByMe') return [mockCreatedByMe, mockSetCreatedByMe]; return [null, jest.fn()]; }, })); +jest.mock('@/listView', () => ({ + useListViews: () => mockUseListViews(), + useDeleteListView: () => mockUseDeleteListView(), + useCreateListView: () => ({ mutate: jest.fn(), isPending: false }), + useUpdateListView: () => ({ mutate: jest.fn(), isPending: false }), +})); + +jest.mock('@/api', () => ({ + __esModule: true, + default: { + useMe: () => mockUseMe(), + }, +})); + jest.mock('@/dashboard', () => ({ useDashboards: () => mockUseDashboards(), useCreateDashboard: () => mockUseCreateDashboard(), @@ -112,8 +143,16 @@ const seedDashboards = [ beforeEach(() => { mockSelectedTags = []; mockLegacyTag = null; + mockActiveViewId = null; + mockRecentDays = null; + mockWithAlerts = null; + mockCreatedByMe = null; mockSetSelectedTags.mockClear(); mockSetLegacyTag.mockClear(); + mockSetActiveViewId.mockClear(); + mockSetRecentDays.mockClear(); + mockSetWithAlerts.mockClear(); + mockSetCreatedByMe.mockClear(); mockUseDashboards.mockReturnValue({ data: seedDashboards, isLoading: false, @@ -125,6 +164,11 @@ beforeEach(() => { isPending: false, }); mockUseDeleteDashboard.mockReturnValue({ mutate: jest.fn() }); + mockUseListViews.mockReturnValue({ data: [], isLoading: false }); + mockUseDeleteListView.mockReturnValue({ mutate: jest.fn() }); + mockUseMe.mockReturnValue({ + data: { id: 'u-tester', email: 'tester@local' }, + }); mockUseConfirm.mockReturnValue(jest.fn()); mockUseBrandDisplayName.mockReturnValue('HyperDX'); }); @@ -163,4 +207,79 @@ describe('DashboardsListPage', () => { expect(screen.getByText('No matching dashboards yet')).toBeInTheDocument(); }); + + it('filters the listing when the Created by me pill is active', () => { + const ownedSeed = [ + dashboard('d-mine', 'Mine dash', []), + dashboard('d-other', 'Other dash', []), + ]; + ownedSeed[0].createdBy = { name: 'tester', email: 'tester@local' }; + ownedSeed[1].createdBy = { name: 'someone', email: 'someone@else' }; + mockUseDashboards.mockReturnValue({ + data: ownedSeed, + isLoading: false, + isError: false, + }); + mockCreatedByMe = true; + + renderWithMantine(); + + const grid = screen.getByTestId('dashboards-list-page'); + expect(within(grid).getAllByText('Mine dash')).toHaveLength(1); + expect(within(grid).queryByText('Other dash')).toBeNull(); + }); + + it('filters the listing when the Recently updated pill is active', () => { + const now = Date.now(); + const seed = [ + { + ...dashboard('d-fresh', 'Fresh dash', []), + updatedAt: new Date(now - 2 * 86_400_000).toISOString(), + }, + { + ...dashboard('d-stale', 'Stale dash', []), + updatedAt: new Date(now - 30 * 86_400_000).toISOString(), + }, + ]; + mockUseDashboards.mockReturnValue({ + data: seed, + isLoading: false, + isError: false, + }); + mockRecentDays = 7; + + renderWithMantine(); + + const grid = screen.getByTestId('dashboards-list-page'); + expect(within(grid).getAllByText('Fresh dash')).toHaveLength(1); + expect(within(grid).queryByText('Stale dash')).toBeNull(); + }); + + it('filters the listing through the active list view rules', () => { + const checkoutView = { + id: 'view-1', + name: 'Checkout team', + resource: 'dashboard' as const, + rules: [{ kind: 'tag-includes' as const, tag: 'checkout' }], + combinator: 'all' as const, + ordering: 0, + isShared: false, + }; + mockUseListViews.mockReturnValue({ + data: [checkoutView], + isLoading: false, + }); + mockActiveViewId = 'view-1'; + + renderWithMantine(); + + const grid = screen.getByTestId('dashboards-list-page'); + + // Only the two checkout-tagged dashboards are present; payments-only + // and untagged are filtered by the active view. + expect(within(grid).getAllByText('Checkout dash')).toHaveLength(1); + expect(within(grid).getAllByText('Multi dash')).toHaveLength(1); + expect(within(grid).queryByText('Payments dash')).toBeNull(); + expect(within(grid).queryByText('Untagged dash')).toBeNull(); + }); }); diff --git a/packages/app/src/components/ListViewsSidebar/ListViewEditorDrawer.tsx b/packages/app/src/components/ListViewsSidebar/ListViewEditorDrawer.tsx new file mode 100644 index 0000000000..1d2338577c --- /dev/null +++ b/packages/app/src/components/ListViewsSidebar/ListViewEditorDrawer.tsx @@ -0,0 +1,429 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + ListViewCombinator, + ListViewResource, + ListViewRule, +} from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Box, + Button, + Drawer, + Group, + NumberInput, + Pill, + Radio, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; + +import { + type ListView, + useCreateListView, + useUpdateListView, +} from '@/listView'; + +type RuleDraft = + | { kind: 'tag-includes'; tag: string } + | { kind: 'tag-excludes'; tag: string } + | { kind: 'untagged' } + | { kind: 'updated-within-days'; days: number } + | { kind: 'has-active-alerts' } + | { kind: 'created-by-me' }; + +const RULE_KIND_LABEL: Record = { + 'tag-includes': 'tag includes', + 'tag-excludes': 'tag excludes', + untagged: 'is untagged', + 'updated-within-days': 'updated within', + 'has-active-alerts': 'has active alerts', + 'created-by-me': 'created by me', +}; + +const RECENT_PRESETS = [1, 7, 30]; + +const DEFAULT_DRAFT: { + name: string; + icon: string; + combinator: ListViewCombinator; + rules: RuleDraft[]; +} = { + name: '', + icon: '', + combinator: 'all', + rules: [{ kind: 'tag-includes', tag: '' }], +}; + +export function ListViewEditorDrawer({ + opened, + onClose, + resource, + existingView, + availableTags, +}: { + opened: boolean; + onClose: () => void; + resource: ListViewResource; + existingView?: ListView; + availableTags: string[]; +}) { + const [draft, setDraft] = useState(DEFAULT_DRAFT); + const [nameError, setNameError] = useState(null); + + const createListView = useCreateListView(); + const updateListView = useUpdateListView(); + + // Seed the draft from the existing view when the drawer opens for + // editing; reset to defaults when opening for a new view. + // + // Defensive on every field: an older ListView document stored before + // the local-mode default kicked in (or returned by a server that + // dropped a field) may have `rules` undefined, `combinator` missing, + // or any rule entry null. Coerce to safe defaults rather than letting + // `.length` / `.tag` throw and crash the whole listing page. + useEffect(() => { + if (!opened) return; + if (existingView) { + const safeRules = Array.isArray(existingView.rules) + ? (existingView.rules.filter( + (r): r is RuleDraft => r != null && typeof r === 'object', + ) as RuleDraft[]) + : []; + setDraft({ + name: existingView.name ?? '', + icon: existingView.icon ?? '', + combinator: existingView.combinator ?? 'all', + rules: + safeRules.length > 0 + ? safeRules + : [{ kind: 'tag-includes', tag: '' }], + }); + } else { + setDraft(DEFAULT_DRAFT); + } + setNameError(null); + }, [opened, existingView]); + + const isPending = createListView.isPending || updateListView.isPending; + + const tagOptions = useMemo( + () => availableTags.map(t => ({ value: t, label: t })), + [availableTags], + ); + + const updateRule = (index: number, next: RuleDraft) => { + setDraft(d => { + const rules = d.rules.slice(); + rules[index] = next; + return { ...d, rules }; + }); + }; + + const removeRule = (index: number) => { + setDraft(d => ({ + ...d, + rules: d.rules.filter((_, i) => i !== index), + })); + }; + + const addRule = () => { + setDraft(d => ({ + ...d, + rules: [...d.rules, { kind: 'tag-includes', tag: '' }], + })); + }; + + const handleSave = () => { + const name = draft.name.trim(); + if (!name) { + setNameError('Name is required'); + return; + } + setNameError(null); + + // Drop any draft rules that are missing required fields (e.g. a + // tag-includes row left with an empty tag, or an + // updated-within-days row with a non-positive day count). Saving an + // empty list is allowed: a view with no rules matches everything, + // which matches the "pin" semantics of bookmarking the current + // state. + const rules: ListViewRule[] = draft.rules.filter(r => { + if (r.kind === 'tag-includes' || r.kind === 'tag-excludes') { + return r.tag.trim().length > 0; + } + if (r.kind === 'updated-within-days') { + return Number.isFinite(r.days) && r.days >= 1 && r.days <= 365; + } + return true; + }); + + const payload = { + name, + icon: draft.icon.trim() || undefined, + resource, + rules, + combinator: draft.combinator, + ordering: existingView?.ordering ?? 0, + }; + + const onSuccess = () => { + notifications.show({ + message: existingView ? 'View updated' : 'View created', + color: 'green', + }); + onClose(); + }; + const onError = () => { + notifications.show({ + message: existingView + ? 'Failed to update view' + : 'Failed to create view', + color: 'red', + }); + }; + + if (existingView) { + updateListView.mutate( + { id: existingView.id, patch: payload }, + { onSuccess, onError }, + ); + } else { + createListView.mutate(payload, { onSuccess, onError }); + } + }; + + return ( + + + { + // Capture the value EAGERLY: React 18 nulls out + // `event.currentTarget` after the synthetic-event handler + // returns, so reading `e.currentTarget.value` inside a + // deferred state updater throws `Cannot read properties + // of null (reading 'value')` under concurrent rendering. + // Same pattern matters for every TextInput onChange that + // pipes through a `setState(prev => ...)` updater. + const nextName = e.currentTarget.value; + setDraft(d => ({ ...d, name: nextName })); + }} + placeholder="e.g. Checkout team" + error={nameError} + data-testid="list-view-name-input" + /> + + { + const nextIcon = e.currentTarget.value; + setDraft(d => ({ ...d, icon: nextIcon })); + }} + placeholder="🛒" + maxLength={64} + /> + + + setDraft(d => ({ + ...d, + combinator: value as ListViewCombinator, + })) + } + > + + + + + + + + + + Rules + + + + + {draft.rules.length === 0 ? ( + + No rules; this view will match every {resource}. + + ) : ( + draft.rules.map((rule, i) => ( + + + updateRule(i, { ...rule, tag: value ?? '' }) + } + placeholder="Select tag" + searchable + style={{ flex: 1 }} + data-testid={`list-view-rule-tag-${i}`} + /> + )} + {rule.kind === 'updated-within-days' && ( + + { + const days = + typeof value === 'number' ? value : Number(value); + updateRule(i, { + kind: 'updated-within-days', + days: Number.isFinite(days) ? days : 7, + }); + }} + min={1} + max={365} + step={1} + w={90} + data-testid={`list-view-rule-days-${i}`} + /> + + days + + + {RECENT_PRESETS.map(d => ( + + updateRule(i, { + kind: 'updated-within-days', + days: d, + }) + } + style={{ + cursor: 'pointer', + backgroundColor: + rule.days === d + ? 'var(--mantine-color-default-hover)' + : undefined, + }} + data-testid={`list-view-rule-days-preset-${i}-${d}`} + > + {d} + + ))} + + + )} + removeRule(i)} + aria-label="Remove rule" + data-testid={`remove-list-view-rule-${i}`} + > + + + + )) + )} + + + + + + + + + + ); +} diff --git a/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx b/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx new file mode 100644 index 0000000000..5f950d4dc4 --- /dev/null +++ b/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx @@ -0,0 +1,286 @@ +import { useCallback } from 'react'; +import { ListViewResource } from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Box, + Group, + Menu, + Stack, + Text, + UnstyledButton, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconDots, IconPencil, IconPlus, IconTrash } from '@tabler/icons-react'; + +import { type ListView, useDeleteListView, useListViews } from '@/listView'; +import { useConfirm } from '@/useConfirm'; +import { getDefaultListViews } from '@/utils/defaultListViews'; + +const ALL_VIEW_LABEL: Record = { + dashboard: 'All Dashboards', + savedSearch: 'All Saved Searches', +}; + +export function ListViewsSidebar({ + resource, + activeId, + onActivate, + onCreate, + onEdit, + totalCount, + viewCounts, +}: { + resource: ListViewResource; + activeId: string | null; + onActivate: (id: string | null) => void; + onCreate: () => void; + onEdit: (view: ListView) => void; + /** Total number of items in the listing, shown next to the + * default "All ..." entry. */ + totalCount: number; + /** Pre-computed match count per view id. Keeps the sidebar a + * pure presentation layer; the parent owns the evaluator call + * so the same `dashboards` reference drives the grid and the + * badges in lockstep. */ + viewCounts: Record; +}) { + const { data: views, isLoading } = useListViews(resource); + const deleteListView = useDeleteListView(); + const confirm = useConfirm(); + + const handleDelete = useCallback( + async (view: ListView) => { + const confirmed = await confirm( + `Delete the "${view.name}" view? This action cannot be undone.`, + 'Delete', + { variant: 'danger' }, + ); + if (!confirmed) return; + deleteListView.mutate( + { id: view.id, resource: view.resource }, + { + onSuccess: () => { + if (activeId === view.id) { + onActivate(null); + } + notifications.show({ + message: 'View deleted', + color: 'green', + }); + }, + onError: () => { + notifications.show({ + message: 'Failed to delete view', + color: 'red', + }); + }, + }, + ); + }, + [activeId, confirm, deleteListView, onActivate], + ); + + const hasViews = (views?.length ?? 0) > 0; + const systemViews = getDefaultListViews(resource); + + return ( + + + onActivate(null)} + testId="list-view-row-all" + /> + + + Suggested + + {systemViews.map(view => ( + onActivate(view.id === activeId ? null : view.id)} + testId={`list-view-row-${view.id}`} + /> + ))} + + + + Your views + + {/* + The primary "save a view" entry now lives next to the + filter chips on the listing (filters-first flow). The + kebab here is the secondary path to the advanced editor + drawer for hand-written rule lists. + */} + + + + + + + + } + onClick={onCreate} + data-testid="new-list-view-button" + > + New view (advanced) + + + + + + {isLoading ? ( + + Loading... + + ) : !hasViews ? ( + + Save your active filters as a view to pin it here. + + ) : ( + views!.map(view => ( + onActivate(view.id === activeId ? null : view.id)} + testId={`list-view-row-${view.id}`} + menu={ + + + + + + + + } + onClick={() => onEdit(view)} + > + Edit + + } + color="red" + onClick={() => handleDelete(view)} + > + Delete + + + + } + /> + )) + )} + + + ); +} + +function SidebarEntry({ + label, + icon, + count, + isActive, + onClick, + menu, + testId, +}: { + label: string; + icon?: string; + count?: number; + isActive: boolean; + onClick: () => void; + menu?: React.ReactNode; + testId: string; +}) { + return ( + + + + {icon && ( + + {icon} + + )} + + {label} + + {typeof count === 'number' && ( + + {count} + + )} + + + {menu} + + ); +} diff --git a/packages/app/src/components/SaveAsViewButton.tsx b/packages/app/src/components/SaveAsViewButton.tsx new file mode 100644 index 0000000000..928eab2bc4 --- /dev/null +++ b/packages/app/src/components/SaveAsViewButton.tsx @@ -0,0 +1,31 @@ +import { Button, Tooltip } from '@mantine/core'; +import { IconBookmark } from '@tabler/icons-react'; + +export function SaveAsViewButton({ + disabled, + onClick, +}: { + disabled: boolean; + onClick: () => void; +}) { + const button = ( + + ); + if (disabled) { + return ( + + {button} + + ); + } + return button; +} diff --git a/packages/app/src/components/SaveAsViewModal.tsx b/packages/app/src/components/SaveAsViewModal.tsx new file mode 100644 index 0000000000..2b75d60570 --- /dev/null +++ b/packages/app/src/components/SaveAsViewModal.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from 'react'; +import { + ListViewResource, + ListViewRule, +} from '@hyperdx/common-utils/dist/types'; +import { Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; + +import { useCreateListView } from '@/listView'; + +/** + * Active-filter snapshot that the listing page hands to the modal. + * Each truthy field becomes one rule on the persisted view. The + * combinator is fixed to `all` here (chips + pills both narrow); + * the advanced drawer is the entry point for `any`. + */ +export type SaveAsViewFilters = { + tags: string[]; + recentDays: number | null; + withAlerts: boolean | null; + createdByMe: boolean | null; +}; + +export function buildRulesFromFilters( + filters: SaveAsViewFilters, +): ListViewRule[] { + const rules: ListViewRule[] = []; + for (const tag of filters.tags) { + rules.push({ kind: 'tag-includes', tag }); + } + if (filters.recentDays && filters.recentDays > 0) { + rules.push({ kind: 'updated-within-days', days: filters.recentDays }); + } + if (filters.withAlerts) { + rules.push({ kind: 'has-active-alerts' }); + } + if (filters.createdByMe) { + rules.push({ kind: 'created-by-me' }); + } + return rules; +} + +export function SaveAsViewModal({ + opened, + onClose, + resource, + filters, + onSaved, +}: { + opened: boolean; + onClose: () => void; + resource: ListViewResource; + filters: SaveAsViewFilters; + /** Called with the new view id after a successful save. The + * listing uses this to clear the active filters and route to + * the new view via ?view=. */ + onSaved: (id: string) => void; +}) { + const [name, setName] = useState(''); + const [icon, setIcon] = useState(''); + const [nameError, setNameError] = useState(null); + const createListView = useCreateListView(); + + useEffect(() => { + if (!opened) return; + setName(''); + setIcon(''); + setNameError(null); + }, [opened]); + + const handleSave = () => { + const trimmed = name.trim(); + if (!trimmed) { + setNameError('Name is required'); + return; + } + setNameError(null); + + const rules = buildRulesFromFilters(filters); + + createListView.mutate( + { + name: trimmed, + icon: icon.trim() || undefined, + resource, + rules, + combinator: 'all', + ordering: 0, + }, + { + onSuccess: data => { + notifications.show({ + message: 'View saved', + color: 'green', + }); + onSaved(data.id); + onClose(); + }, + onError: () => { + notifications.show({ + message: 'Failed to save view', + color: 'red', + }); + }, + }, + ); + }; + + const ruleCount = buildRulesFromFilters(filters).length; + + return ( + + + + Pins these {ruleCount} filter{ruleCount === 1 ? '' : 's'} to the left + rail so you can jump back with one click. + + { + const next = e.currentTarget.value; + setName(next); + }} + placeholder="e.g. Checkout team" + error={nameError} + maxLength={120} + data-testid="save-as-view-name-input" + /> + { + const next = e.currentTarget.value; + setIcon(next); + }} + placeholder="🛒" + maxLength={64} + /> + + + + + + + ); +} diff --git a/packages/app/src/components/__tests__/SaveAsViewModal.test.tsx b/packages/app/src/components/__tests__/SaveAsViewModal.test.tsx new file mode 100644 index 0000000000..d4a0d315e4 --- /dev/null +++ b/packages/app/src/components/__tests__/SaveAsViewModal.test.tsx @@ -0,0 +1,65 @@ +import { buildRulesFromFilters } from '../SaveAsViewModal'; + +describe('buildRulesFromFilters', () => { + it('emits zero rules when no filter is active', () => { + expect( + buildRulesFromFilters({ + tags: [], + recentDays: null, + withAlerts: null, + createdByMe: null, + }), + ).toEqual([]); + }); + + it('emits one tag-includes rule per selected tag, in input order', () => { + expect( + buildRulesFromFilters({ + tags: ['checkout', 'payments'], + recentDays: null, + withAlerts: null, + createdByMe: null, + }), + ).toEqual([ + { kind: 'tag-includes', tag: 'checkout' }, + { kind: 'tag-includes', tag: 'payments' }, + ]); + }); + + it('emits an updated-within-days rule when recentDays is set', () => { + expect( + buildRulesFromFilters({ + tags: [], + recentDays: 7, + withAlerts: null, + createdByMe: null, + }), + ).toEqual([{ kind: 'updated-within-days', days: 7 }]); + }); + + it('emits has-active-alerts and created-by-me when toggled', () => { + expect( + buildRulesFromFilters({ + tags: [], + recentDays: null, + withAlerts: true, + createdByMe: true, + }), + ).toEqual([{ kind: 'has-active-alerts' }, { kind: 'created-by-me' }]); + }); + + it('mixes tag and non-tag rules', () => { + expect( + buildRulesFromFilters({ + tags: ['incident'], + recentDays: 30, + withAlerts: true, + createdByMe: false, + }), + ).toEqual([ + { kind: 'tag-includes', tag: 'incident' }, + { kind: 'updated-within-days', days: 30 }, + { kind: 'has-active-alerts' }, + ]); + }); +}); diff --git a/packages/app/src/listView.ts b/packages/app/src/listView.ts new file mode 100644 index 0000000000..374c6db17c --- /dev/null +++ b/packages/app/src/listView.ts @@ -0,0 +1,135 @@ +import { + ListView as ListViewBase, + ListViewResource, +} from '@hyperdx/common-utils/dist/types'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { hdxServer } from './api'; +import { IS_LOCAL_MODE } from './config'; +import { createEntityStore } from './localStore'; + +export type ListView = ListViewBase & { + createdAt?: string; + updatedAt?: string; +}; + +export type ListViewInput = Omit; + +// Local-mode storage mirrors the favorites + dashboards pattern; the +// Vercel preview deployments and standalone `IS_LOCAL_MODE` builds have +// no `/list-views` backend, so all CRUD goes through localStorage and +// React Query never tries to hit the API. +const localListViews = createEntityStore('hdx-local-list-views'); + +function normalizeListView(view: Partial): ListView { + // Coerce any missing fields so downstream consumers (sidebar, drawer, + // evaluateListView) never have to defend against undefined `rules`, + // missing `combinator`, etc. A ListView stored before a default + // landed will still render and edit cleanly. + return { + id: view.id ?? '', + name: view.name ?? '', + icon: view.icon, + resource: view.resource ?? 'dashboard', + rules: Array.isArray(view.rules) ? view.rules : [], + combinator: view.combinator ?? 'all', + ordering: typeof view.ordering === 'number' ? view.ordering : 0, + isShared: view.isShared, + createdAt: view.createdAt, + updatedAt: view.updatedAt, + }; +} + +async function fetchListViews(resource: ListViewResource): Promise { + if (IS_LOCAL_MODE) { + return localListViews + .getAll() + .filter(v => v?.resource === resource) + .map(normalizeListView) + .sort((a, b) => (a.ordering ?? 0) - (b.ordering ?? 0)); + } + const raw = await hdxServer(`list-views?resource=${resource}`).json< + Partial[] + >(); + return Array.isArray(raw) ? raw.map(normalizeListView) : []; +} + +export function useListViews(resource: ListViewResource) { + return useQuery({ + queryKey: ['list-views', resource], + queryFn: () => fetchListViews(resource), + }); +} + +export function useCreateListView() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ListViewInput) => { + if (IS_LOCAL_MODE) { + return Promise.resolve(localListViews.create(body)); + } + return hdxServer('list-views', { + method: 'POST', + json: body, + }).json(); + }, + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ + queryKey: ['list-views', vars.resource], + }); + }, + }); +} + +export function useUpdateListView() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + patch, + }: { + id: string; + patch: Partial; + }) => { + if (IS_LOCAL_MODE) { + return Promise.resolve(localListViews.update(id, patch)); + } + return hdxServer(`list-views/${id}`, { + method: 'PATCH', + json: patch, + }).json(); + }, + onSuccess: data => { + queryClient.invalidateQueries({ + queryKey: ['list-views', data.resource], + }); + }, + }); +} + +export function useDeleteListView() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + resource: _resource, + }: { + id: string; + resource: ListViewResource; + }) => { + if (IS_LOCAL_MODE) { + localListViews.delete(id); + return Promise.resolve(); + } + return hdxServer(`list-views/${id}`, { method: 'DELETE' }).json(); + }, + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ + queryKey: ['list-views', vars.resource], + }); + }, + }); +} diff --git a/packages/app/src/utils/__tests__/defaultListViews.test.ts b/packages/app/src/utils/__tests__/defaultListViews.test.ts new file mode 100644 index 0000000000..8cdb8888ba --- /dev/null +++ b/packages/app/src/utils/__tests__/defaultListViews.test.ts @@ -0,0 +1,52 @@ +import { + getDefaultListViews, + isSystemViewId, + SYSTEM_VIEW_ID_PREFIX, +} from '../defaultListViews'; + +describe('getDefaultListViews', () => { + it('returns the four pinned system views for dashboards', () => { + const views = getDefaultListViews('dashboard'); + expect(views).toHaveLength(4); + expect(views.map(v => v.id)).toEqual([ + 'system:created-by-me', + 'system:recent-7d', + 'system:has-active-alerts', + 'system:untagged', + ]); + expect(views.every(v => v.id.startsWith(SYSTEM_VIEW_ID_PREFIX))).toBe(true); + expect(views.every(v => v.resource === 'dashboard')).toBe(true); + }); + + it('drops has-active-alerts for saved-search resource', () => { + const views = getDefaultListViews('savedSearch'); + expect(views.map(v => v.id)).toEqual([ + 'system:created-by-me', + 'system:recent-7d', + 'system:untagged', + ]); + expect(views.every(v => v.resource === 'savedSearch')).toBe(true); + }); + + it('seeds the recent view with a 7-day window', () => { + const recent = getDefaultListViews('dashboard').find( + v => v.id === 'system:recent-7d', + ); + expect(recent?.rules).toEqual([{ kind: 'updated-within-days', days: 7 }]); + }); +}); + +describe('isSystemViewId', () => { + it('recognises system ids', () => { + expect(isSystemViewId('system:created-by-me')).toBe(true); + expect(isSystemViewId('system:anything')).toBe(true); + }); + + it('rejects everything else', () => { + expect(isSystemViewId(null)).toBe(false); + expect(isSystemViewId(undefined)).toBe(false); + expect(isSystemViewId('')).toBe(false); + expect(isSystemViewId('user-view-123')).toBe(false); + expect(isSystemViewId('SYSTEM:created-by-me')).toBe(false); + }); +}); diff --git a/packages/app/src/utils/__tests__/evaluateListView.test.ts b/packages/app/src/utils/__tests__/evaluateListView.test.ts new file mode 100644 index 0000000000..61e55d29f3 --- /dev/null +++ b/packages/app/src/utils/__tests__/evaluateListView.test.ts @@ -0,0 +1,216 @@ +import { evaluateListView } from '../evaluateListView'; + +type Item = { tags: string[] }; + +const view = ( + rules: Parameters[0]['rules'], + combinator: 'all' | 'any' = 'all', +) => ({ rules, combinator }); + +describe('evaluateListView', () => { + it('matches every item when the rule list is empty', () => { + const v = view([]); + expect(evaluateListView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateListView(v, { tags: ['anything'] } as Item)).toBe(true); + }); + + it('tag-includes passes only when the item carries that tag', () => { + const v = view([{ kind: 'tag-includes', tag: 'checkout' }]); + expect(evaluateListView(v, { tags: ['checkout'] } as Item)).toBe(true); + expect( + evaluateListView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(true); + expect(evaluateListView(v, { tags: ['payments'] } as Item)).toBe(false); + expect(evaluateListView(v, { tags: [] } as Item)).toBe(false); + }); + + it('tag-excludes passes when the item does not carry that tag', () => { + const v = view([{ kind: 'tag-excludes', tag: 'payments' }]); + expect(evaluateListView(v, { tags: ['checkout'] } as Item)).toBe(true); + expect(evaluateListView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateListView(v, { tags: ['payments'] } as Item)).toBe(false); + expect( + evaluateListView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(false); + }); + + it('untagged passes only when the item has zero tags', () => { + const v = view([{ kind: 'untagged' }]); + expect(evaluateListView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateListView(v, { tags: ['anything'] } as Item)).toBe(false); + }); + + it('combinator=all requires every rule to pass', () => { + const v = view( + [ + { kind: 'tag-includes', tag: 'checkout' }, + { kind: 'tag-excludes', tag: 'payments' }, + ], + 'all', + ); + // Has checkout and not payments -> passes both + expect(evaluateListView(v, { tags: ['checkout'] } as Item)).toBe(true); + // Has checkout but also payments -> the second rule fails + expect( + evaluateListView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(false); + // No checkout -> first rule fails + expect(evaluateListView(v, { tags: ['payments'] } as Item)).toBe(false); + }); + + it('combinator=any passes when at least one rule passes', () => { + const v = view( + [ + { kind: 'tag-includes', tag: 'checkout' }, + { kind: 'tag-includes', tag: 'payments' }, + ], + 'any', + ); + expect(evaluateListView(v, { tags: ['checkout'] } as Item)).toBe(true); + expect(evaluateListView(v, { tags: ['payments'] } as Item)).toBe(true); + expect( + evaluateListView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(true); + expect(evaluateListView(v, { tags: ['other'] } as Item)).toBe(false); + expect(evaluateListView(v, { tags: [] } as Item)).toBe(false); + }); + + it('combinator=any with untagged plus tag-includes accepts either branch', () => { + const v = view( + [{ kind: 'untagged' }, { kind: 'tag-includes', tag: 'incident' }], + 'any', + ); + expect(evaluateListView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateListView(v, { tags: ['incident'] } as Item)).toBe(true); + expect(evaluateListView(v, { tags: ['other'] } as Item)).toBe(false); + }); + + it('treats a view with missing rules / combinator as match-all instead of crashing', () => { + // Defensive: an older ListView document persisted before the + // local-mode defaults landed may have `rules` or `combinator` + // undefined. The evaluator must not blow up on either. + const undefinedView = {} as Parameters[0]; + expect(evaluateListView(undefinedView, { tags: [] } as Item)).toBe(true); + expect(evaluateListView(undefinedView, { tags: ['any'] } as Item)).toBe( + true, + ); + + const nullRulesView = { + rules: null as unknown as Parameters[0]['rules'], + combinator: 'all' as const, + }; + expect(evaluateListView(nullRulesView, { tags: [] } as Item)).toBe(true); + }); + + describe('updated-within-days', () => { + const NOW = Date.parse('2026-06-03T00:00:00.000Z'); + let nowSpy: jest.SpyInstance; + + beforeEach(() => { + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + afterEach(() => { + nowSpy.mockRestore(); + }); + + it('passes items updated within the window', () => { + const v = view([{ kind: 'updated-within-days', days: 7 }]); + // Updated 1 day ago -> passes + expect( + evaluateListView(v, { + tags: [], + updatedAt: new Date(NOW - 1 * 86_400_000).toISOString(), + }), + ).toBe(true); + // Updated 6 days ago -> still inside the 7-day window + expect( + evaluateListView(v, { + tags: [], + updatedAt: new Date(NOW - 6 * 86_400_000).toISOString(), + }), + ).toBe(true); + }); + + it('rejects items older than the window', () => { + const v = view([{ kind: 'updated-within-days', days: 7 }]); + // 8 days ago -> outside the window + expect( + evaluateListView(v, { + tags: [], + updatedAt: new Date(NOW - 8 * 86_400_000).toISOString(), + }), + ).toBe(false); + }); + + it('rejects items with missing or unparseable updatedAt', () => { + const v = view([{ kind: 'updated-within-days', days: 7 }]); + expect(evaluateListView(v, { tags: [] })).toBe(false); + expect(evaluateListView(v, { tags: [], updatedAt: 'not-a-date' })).toBe( + false, + ); + }); + }); + + describe('has-active-alerts', () => { + it('uses the caller-provided context flag', () => { + const v = view([{ kind: 'has-active-alerts' }]); + expect( + evaluateListView(v, { tags: [] }, { itemHasActiveAlerts: true }), + ).toBe(true); + expect( + evaluateListView(v, { tags: [] }, { itemHasActiveAlerts: false }), + ).toBe(false); + }); + + it('rejects when no context is provided (no alert info available)', () => { + const v = view([{ kind: 'has-active-alerts' }]); + expect(evaluateListView(v, { tags: [] })).toBe(false); + }); + }); + + describe('created-by-me', () => { + it('matches on createdBy._id when the user id is in context', () => { + const v = view([{ kind: 'created-by-me' }]); + expect( + evaluateListView( + v, + { tags: [], createdBy: { _id: 'u-1', email: 'a@b' } }, + { currentUserId: 'u-1' }, + ), + ).toBe(true); + }); + + it('falls back to email comparison when _id is missing', () => { + const v = view([{ kind: 'created-by-me' }]); + expect( + evaluateListView( + v, + { tags: [], createdBy: { email: 'a@b' } }, + { currentUserEmail: 'a@b' }, + ), + ).toBe(true); + }); + + it('rejects when neither _id nor email matches', () => { + const v = view([{ kind: 'created-by-me' }]); + expect( + evaluateListView( + v, + { tags: [], createdBy: { _id: 'u-2', email: 'b@c' } }, + { currentUserId: 'u-1', currentUserEmail: 'a@b' }, + ), + ).toBe(false); + }); + + it('rejects when the item has no createdBy at all', () => { + const v = view([{ kind: 'created-by-me' }]); + expect( + evaluateListView( + v, + { tags: [], createdBy: null }, + { currentUserId: 'u-1' }, + ), + ).toBe(false); + }); + }); +}); diff --git a/packages/app/src/utils/defaultListViews.ts b/packages/app/src/utils/defaultListViews.ts new file mode 100644 index 0000000000..cc288151b4 --- /dev/null +++ b/packages/app/src/utils/defaultListViews.ts @@ -0,0 +1,71 @@ +import { ListViewResource } from '@hyperdx/common-utils/dist/types'; + +import type { ListView } from '@/listView'; + +/** + * Pinned system views that ship above the user's saved views in + * the sidebar. Non-editable; no kebab menu. Lookup logic checks + * `system:*` ids first before falling through to the API response + * so a click on a system row still applies the same evaluator + * pipeline as a user-created view. + * + * Dashboards get the full set. Saved searches drop + * `has-active-alerts` until the SavedSearch alert analogue lands + * in PR-6. + */ +export const SYSTEM_VIEW_ID_PREFIX = 'system:'; + +const DASHBOARD_SYSTEM_VIEWS: ListView[] = [ + { + id: 'system:created-by-me', + name: 'My dashboards', + icon: '👤', + resource: 'dashboard', + rules: [{ kind: 'created-by-me' }], + combinator: 'all', + ordering: 0, + }, + { + id: 'system:recent-7d', + name: 'Recently updated', + icon: '⏱', + resource: 'dashboard', + rules: [{ kind: 'updated-within-days', days: 7 }], + combinator: 'all', + ordering: 0, + }, + { + id: 'system:has-active-alerts', + name: 'With active alerts', + icon: '🔔', + resource: 'dashboard', + rules: [{ kind: 'has-active-alerts' }], + combinator: 'all', + ordering: 0, + }, + { + id: 'system:untagged', + name: 'Untagged', + icon: '🏷', + resource: 'dashboard', + rules: [{ kind: 'untagged' }], + combinator: 'all', + ordering: 0, + }, +]; + +const SAVED_SEARCH_SYSTEM_VIEWS: ListView[] = DASHBOARD_SYSTEM_VIEWS.filter( + // has-active-alerts is dashboard-specific until PR-6 lands the + // saved-search alert analogue. + v => v.rules.every(r => r.kind !== 'has-active-alerts'), +).map(v => ({ ...v, resource: 'savedSearch' as const })); + +export function getDefaultListViews(resource: ListViewResource): ListView[] { + return resource === 'dashboard' + ? DASHBOARD_SYSTEM_VIEWS + : SAVED_SEARCH_SYSTEM_VIEWS; +} + +export function isSystemViewId(id: string | null | undefined): boolean { + return typeof id === 'string' && id.startsWith(SYSTEM_VIEW_ID_PREFIX); +} diff --git a/packages/app/src/utils/evaluateListView.ts b/packages/app/src/utils/evaluateListView.ts new file mode 100644 index 0000000000..d64c3c7315 --- /dev/null +++ b/packages/app/src/utils/evaluateListView.ts @@ -0,0 +1,89 @@ +import { + ListViewCombinator, + ListViewRule, +} from '@hyperdx/common-utils/dist/types'; + +/** + * Pure client-side evaluator for ListView rules. + * + * A view with zero rules matches everything (the rule list is a + * narrower filter on top of whatever the listing already shows). + * + * Non-tag rules (recency, has-active-alerts, created-by-me) need + * per-item context that the listing must precompute and pass in. + * The evaluator stays pure: it does not read the alert config off + * tiles or compare the current user identity, the caller does that + * once per item and feeds the boolean / id in. + */ +export type ListViewEvalContext = { + currentUserId?: string; + currentUserEmail?: string; + itemHasActiveAlerts?: boolean; +}; + +export type ListViewEvalItem = { + tags: string[]; + updatedAt?: Date | string; + createdBy?: { _id?: string; email?: string } | null; +}; + +export function evaluateListView( + view: { + rules?: ListViewRule[] | null; + combinator?: ListViewCombinator | null; + }, + item: T, + context?: ListViewEvalContext, +): boolean { + // Defensive: a view persisted before the local-mode default kicked in + // (or returned by a server that dropped a field) may have `rules` + // null/undefined or contain non-object entries. Skip the entries that + // don't fit any rule shape rather than crashing the caller. + const rules = Array.isArray(view.rules) + ? view.rules.filter( + (r): r is ListViewRule => + r != null && typeof r === 'object' && 'kind' in r, + ) + : []; + if (rules.length === 0) return true; + + const pass = (rule: ListViewRule): boolean => { + switch (rule.kind) { + case 'tag-includes': + return item.tags.includes(rule.tag); + case 'tag-excludes': + return !item.tags.includes(rule.tag); + case 'untagged': + return item.tags.length === 0; + case 'updated-within-days': { + if (!item.updatedAt) return false; + // Pure evaluator runs once per item at filter time. Using a + // module-level NOW would stop the window from advancing as + // the page sits open; the consumer is non-React here so the + // re-render heuristic in `no-restricted-syntax` doesn't apply. + // eslint-disable-next-line no-restricted-syntax + const ageMs = Date.now() - new Date(item.updatedAt).valueOf(); + if (Number.isNaN(ageMs)) return false; + return ageMs / 86_400_000 < rule.days; + } + case 'has-active-alerts': + return Boolean(context?.itemHasActiveAlerts); + case 'created-by-me': { + const cb = item.createdBy; + if (!cb) return false; + if (context?.currentUserId && cb._id === context.currentUserId) { + return true; + } + if ( + context?.currentUserEmail && + cb.email === context.currentUserEmail + ) { + return true; + } + return false; + } + } + }; + + return view.combinator === 'any' ? rules.some(pass) : rules.every(pass); +} diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 64b5cde975..c2778f99ba 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1261,6 +1261,57 @@ export const DashboardTemplateSchema = DashboardWithoutIdSchema.omit({ }); export type DashboardTemplate = z.infer; +// -------------------------- +// LIST VIEWS +// -------------------------- +// +// A ListView is a per-user, per-resource saved filter pinned to the +// listing sidebar. Rules are evaluated client-side over the listing +// endpoint's response, AND/OR combined via `combinator`. +// +// The rule discriminated union is additive: tag rules (the original +// v1 set) live alongside the non-tag kinds (recency, has-active- +// alerts, created-by-me). Stored documents from the tag-only era +// keep parsing because every rule still carries a `kind` literal +// and the union includes the original three members. +export const ListViewRuleSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('tag-includes'), tag: z.string().min(1).max(64) }), + z.object({ kind: z.literal('tag-excludes'), tag: z.string().min(1).max(64) }), + z.object({ kind: z.literal('untagged') }), + z.object({ + kind: z.literal('updated-within-days'), + days: z.number().int().min(1).max(365), + }), + z.object({ kind: z.literal('has-active-alerts') }), + z.object({ kind: z.literal('created-by-me') }), +]); +export type ListViewRule = z.infer; + +export const ListViewResourceSchema = z.enum(['dashboard', 'savedSearch']); +export type ListViewResource = z.infer; + +export const ListViewCombinatorSchema = z.enum(['all', 'any']); +export type ListViewCombinator = z.infer; + +export const ListViewSchema = z.object({ + id: z.string(), + name: z.string().min(1).max(120), + icon: z.string().max(64).optional(), + resource: ListViewResourceSchema, + rules: z.array(ListViewRuleSchema).max(32), + combinator: ListViewCombinatorSchema, + ordering: z.number().int().nonnegative(), + // Optional in the wire shape; defaults to `false` in the Mongoose + // model. UI for promoting a per-user view to a team-shared one + // arrives in a follow-up; for now consumers should treat absence + // as `false`. + isShared: z.boolean().optional(), +}); +export type ListView = z.infer; + +export const ListViewWithoutIdSchema = ListViewSchema.omit({ id: true }); +export type ListViewWithoutId = z.infer; + export const ConnectionSchema = z.object({ id: z.string(), name: z.string(),