From f32de141d5efc16623c6f6d76825bad87c26c582 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Sat, 30 May 2026 00:12:49 +0000 Subject: [PATCH 01/14] feat(common-utils): SmartView Zod schemas (tag-only rules, resource discriminator) Adds the `SmartView*` schemas to common-utils ahead of the model, router, and UI work. The rule discriminated union is intentionally narrow in v1 (`tag-includes`, `tag-excludes`, `untagged`) so the storage + sidebar plumbing can ship without dragging in non-tag rule machinery; a follow-up widens the union with recency / has-alerts / created-by-me / provisioned / has-tile-type kinds and existing documents keep parsing because the extension is additive. The `resource` discriminator already includes `savedSearch` so the Saved Searches sidebar parity work drops in without a schema change. Co-Authored-By: Claude Opus 4.6 --- packages/common-utils/src/types.ts | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 64b5cde975..cd009ce064 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1261,6 +1261,58 @@ export const DashboardTemplateSchema = DashboardWithoutIdSchema.omit({ }); export type DashboardTemplate = z.infer; +// -------------------------- +// SMART VIEWS +// -------------------------- +// +// A SmartView 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 intentionally narrow in v1 +// (tag-only) so PR-2 lands the storage + sidebar plumbing without +// dragging in non-tag rule machinery. PR-3 widens this union with +// `updated-within-days`, `has-active-alerts`, `created-by-me`, +// `provisioned`, `has-tile-type`; the existing stored documents keep +// parsing because the union extension is additive. +export const SmartViewTagRuleSchema = 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') }), +]); +export type SmartViewTagRule = z.infer; + +// In v1 every supported rule is a tag rule. The alias keeps call +// sites future-proof: when PR-3 widens the union, references to +// `SmartViewRuleSchema` flip without rewrites at every consumer. +export const SmartViewRuleSchema = SmartViewTagRuleSchema; +export type SmartViewRule = SmartViewTagRule; + +export const SmartViewResourceSchema = z.enum(['dashboard', 'savedSearch']); +export type SmartViewResource = z.infer; + +export const SmartViewCombinatorSchema = z.enum(['all', 'any']); +export type SmartViewCombinator = z.infer; + +export const SmartViewSchema = z.object({ + id: z.string(), + name: z.string().min(1).max(120), + icon: z.string().max(64).optional(), + resource: SmartViewResourceSchema, + rules: z.array(SmartViewRuleSchema).max(32), + combinator: SmartViewCombinatorSchema, + 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 SmartView = z.infer; + +export const SmartViewWithoutIdSchema = SmartViewSchema.omit({ id: true }); +export type SmartViewWithoutId = z.infer; + export const ConnectionSchema = z.object({ id: z.string(), name: z.string(), From 04866ead1582607d3a87f94bfc00c015e4ba0b4b Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Sat, 30 May 2026 00:12:58 +0000 Subject: [PATCH 02/14] feat(api): SmartView model + CRUD router under /smart-views Mongoose model mirrors `favorite.ts` for the per-user index pattern and `dashboard.ts` for the Mixed-typed JSON column that stores the rules array. The Zod schema in `@hyperdx/common-utils/dist/types` is the source of truth for rule shape; widening the rule union later does not require a model migration. Controller exposes `getSmartViews(userId, teamId, resource?)`, `getSmartView`, `createSmartView`, `updateSmartView`, `deleteSmartView`, all scoped by `{ owner, team }`. Cross-user and cross-team access fall through to a 404 in the router. Router mounted at `/smart-views` next to `favoritesRouter` and `savedSearchRouter`. Body and query validation via zod-express-middleware against the Zod schemas. Tests round-trip POST -> GET, exercise the resource discriminator filter, and confirm that another user on the same team cannot patch or delete the view (both 404). Co-Authored-By: Claude Opus 4.6 --- packages/api/src/api-app.ts | 2 + packages/api/src/controllers/smartView.ts | 51 +++++ packages/api/src/models/smartView.ts | 83 ++++++++ .../routers/api/__tests__/smartViews.test.ts | 186 ++++++++++++++++++ packages/api/src/routers/api/smartViews.ts | 130 ++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 packages/api/src/controllers/smartView.ts create mode 100644 packages/api/src/models/smartView.ts create mode 100644 packages/api/src/routers/api/__tests__/smartViews.test.ts create mode 100644 packages/api/src/routers/api/smartViews.ts diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 5f4c3181d4..69b8de7327 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -15,6 +15,7 @@ import connectionsRouter from './routers/api/connections'; import favoritesRouter from './routers/api/favorites'; import pinnedFiltersRouter from './routers/api/pinnedFilters'; import savedSearchRouter from './routers/api/savedSearch'; +import smartViewsRouter from './routers/api/smartViews'; import sourcesRouter from './routers/api/sources'; import externalRoutersV2 from './routers/external-api/v2'; import usageStats from './tasks/usageStats'; @@ -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('/smart-views', isUserAuthenticated, smartViewsRouter); app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter); app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter); if (config.IS_PROMQL_ENABLED) { diff --git a/packages/api/src/controllers/smartView.ts b/packages/api/src/controllers/smartView.ts new file mode 100644 index 0000000000..73d17647cb --- /dev/null +++ b/packages/api/src/controllers/smartView.ts @@ -0,0 +1,51 @@ +import { + SmartViewResource, + SmartViewWithoutId, +} from '@hyperdx/common-utils/dist/types'; + +import SmartView, { ISmartView } from '@/models/smartView'; + +export function getSmartViews( + userId: string, + teamId: string, + resource?: SmartViewResource, +) { + const filter: Record = { owner: userId, team: teamId }; + if (resource) filter.resource = resource; + return SmartView.find(filter).sort({ ordering: 1, createdAt: 1 }); +} + +export function getSmartView(id: string, userId: string, teamId: string) { + return SmartView.findOne({ _id: id, owner: userId, team: teamId }); +} + +export function createSmartView( + userId: string, + teamId: string, + view: SmartViewWithoutId, +) { + return SmartView.create({ + ...view, + owner: userId, + team: teamId, + }); +} + +export function updateSmartView( + id: string, + userId: string, + teamId: string, + patch: Partial, +) { + return SmartView.findOneAndUpdate( + { _id: id, owner: userId, team: teamId }, + patch, + { new: true }, + ); +} + +export function deleteSmartView(id: string, userId: string, teamId: string) { + return SmartView.deleteOne({ _id: id, owner: userId, team: teamId }); +} + +export type SmartViewExport = ISmartView; diff --git a/packages/api/src/models/smartView.ts b/packages/api/src/models/smartView.ts new file mode 100644 index 0000000000..1a2a0ed39c --- /dev/null +++ b/packages/api/src/models/smartView.ts @@ -0,0 +1,83 @@ +import { SmartViewSchema } from '@hyperdx/common-utils/dist/types'; +import mongoose, { Schema } from 'mongoose'; +import { z } from 'zod'; + +import type { ObjectId } from '.'; + +export interface ISmartView extends z.infer { + _id: ObjectId; + team: ObjectId; + owner: ObjectId; + createdAt: Date; + updatedAt: Date; +} + +export type SmartViewDocument = mongoose.HydratedDocument; + +const smartViewSchema = 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). +smartViewSchema.index({ team: 1, owner: 1, resource: 1, ordering: 1 }); + +export default mongoose.model('SmartView', smartViewSchema); diff --git a/packages/api/src/routers/api/__tests__/smartViews.test.ts b/packages/api/src/routers/api/__tests__/smartViews.test.ts new file mode 100644 index 0000000000..3c5eeffc2a --- /dev/null +++ b/packages/api/src/routers/api/__tests__/smartViews.test.ts @@ -0,0 +1,186 @@ +import mongoose from 'mongoose'; + +import { getLoggedInAgent, getServer } from '@/fixtures'; + +describe('smart 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 smart view through POST and GET', async () => { + const create = await agent + .post('/smart-views') + .send(dashboardView) + .expect(200); + expect(create.body).toMatchObject(dashboardView); + expect(create.body.id).toBeDefined(); + + const list = await agent.get('/smart-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('/smart-views').send(dashboardView).expect(200); + await agent + .post('/smart-views') + .send({ + ...dashboardView, + name: 'Errors search', + resource: 'savedSearch', + }) + .expect(200); + + const dashList = await agent + .get('/smart-views?resource=dashboard') + .expect(200); + expect(dashList.body).toHaveLength(1); + expect(dashList.body[0].resource).toBe('dashboard'); + + const searchList = await agent + .get('/smart-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('/smart-views').send(dashboardView).expect(200); + await agent + .post('/smart-views') + .send({ + ...dashboardView, + name: 'Errors search', + resource: 'savedSearch', + }) + .expect(200); + + const list = await agent.get('/smart-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('/smart-views') + .send(dashboardView) + .expect(200); + const { id } = create.body; + + const patch = await agent + .patch(`/smart-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 smart view and removes it from the listing', async () => { + const create = await agent + .post('/smart-views') + .send(dashboardView) + .expect(200); + const { id } = create.body; + + await agent.delete(`/smart-views/${id}`).expect(204); + + const list = await agent.get('/smart-views?resource=dashboard').expect(200); + expect(list.body).toHaveLength(0); + }); + + it('returns 404 when patching a non-existent smart view', async () => { + const fakeId = new mongoose.Types.ObjectId().toString(); + await agent + .patch(`/smart-views/${fakeId}`) + .send({ name: 'never' }) + .expect(404); + }); + + it('returns 404 when deleting a non-existent smart view', async () => { + const fakeId = new mongoose.Types.ObjectId().toString(); + await agent.delete(`/smart-views/${fakeId}`).expect(404); + }); + + it('rejects a body missing required fields', async () => { + await agent + .post('/smart-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('/smart-views') + .send({ + ...dashboardView, + rules: [{ kind: 'has-active-alerts' }], + }) + .expect(400); + }); + + it('rejects a name longer than 120 chars', async () => { + await agent + .post('/smart-views') + .send({ ...dashboardView, name: 'x'.repeat(121) }) + .expect(400); + }); + + it('isolates smart views between users on the same team', async () => { + // Create a view as the default agent. + const create = await agent + .post('/smart-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('/smart-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(`/smart-views/${id}`) + .send({ name: 'hijack' }) + .expect(404); + await other.agent.delete(`/smart-views/${id}`).expect(404); + }); +}); diff --git a/packages/api/src/routers/api/smartViews.ts b/packages/api/src/routers/api/smartViews.ts new file mode 100644 index 0000000000..e5cb1cc63b --- /dev/null +++ b/packages/api/src/routers/api/smartViews.ts @@ -0,0 +1,130 @@ +import { + SmartViewResourceSchema, + SmartViewSchema, + SmartViewWithoutIdSchema, +} from '@hyperdx/common-utils/dist/types'; +import express from 'express'; +import _ from 'lodash'; +import { z } from 'zod'; +import { validateRequest } from 'zod-express-middleware'; + +import { + createSmartView, + deleteSmartView, + getSmartView, + getSmartViews, + updateSmartView, +} from '@/controllers/smartView'; +import { getNonNullUserWithTeam } from '@/middleware/auth'; +import { objectIdSchema } from '@/utils/zod'; + +const router = express.Router(); + +router.get( + '/', + validateRequest({ + query: z.object({ + resource: SmartViewResourceSchema.optional(), + }), + }), + async (req, res, next) => { + try { + const { teamId, userId } = getNonNullUserWithTeam(req); + + const views = await getSmartViews( + userId.toString(), + teamId.toString(), + req.query.resource, + ); + + return res.json(views); + } catch (e) { + next(e); + } + }, +); + +router.post( + '/', + validateRequest({ + body: SmartViewWithoutIdSchema, + }), + async (req, res, next) => { + try { + const { teamId, userId } = getNonNullUserWithTeam(req); + + const view = await createSmartView( + 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: SmartViewWithoutIdSchema.partial(), + }), + async (req, res, next) => { + try { + const { teamId, userId } = getNonNullUserWithTeam(req); + const { id } = req.params; + + const existing = await getSmartView( + id, + userId.toString(), + teamId.toString(), + ); + if (existing == null) { + return res.sendStatus(404); + } + + const updates = _.omitBy(req.body, _.isUndefined); + const updated = await updateSmartView( + 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 deleteSmartView( + 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; From cd0d6d865fc33e99b5971265ce4d80d6aa9af592 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Sat, 30 May 2026 00:13:07 +0000 Subject: [PATCH 03/14] feat(app): useSmartViews hook + pure evaluateSmartView function `packages/app/src/smartView.ts` exposes `useSmartViews(resource)`, `useCreateSmartView`, `useUpdateSmartView`, `useDeleteSmartView`. React Query keys are `['smart-views', resource]` so a mutation that changes one resource's views does not invalidate the other's cache. `packages/app/src/utils/evaluateSmartView.ts` is a pure function that takes `{ rules, combinator }` and an item with a `tags` array and returns a boolean. An empty rule list matches every item. Combinator `all` requires every rule to pass; `any` short-circuits on the first success. The switch over rule kinds is exhaustive for the tag-only v1 set; the rule-widening follow-up extends the switch. Unit tests cover every rule kind, empty rules, both combinators, multi-tag items, untagged items, and an `any` combinator that combines an `untagged` rule with a `tag-includes` rule. Co-Authored-By: Claude Opus 4.6 --- packages/app/src/smartView.ts | 82 +++++++++++++++++ .../utils/__tests__/evaluateSmartView.test.ts | 87 +++++++++++++++++++ packages/app/src/utils/evaluateSmartView.ts | 38 ++++++++ 3 files changed, 207 insertions(+) create mode 100644 packages/app/src/smartView.ts create mode 100644 packages/app/src/utils/__tests__/evaluateSmartView.test.ts create mode 100644 packages/app/src/utils/evaluateSmartView.ts diff --git a/packages/app/src/smartView.ts b/packages/app/src/smartView.ts new file mode 100644 index 0000000000..1710d5e359 --- /dev/null +++ b/packages/app/src/smartView.ts @@ -0,0 +1,82 @@ +import { + SmartView as SmartViewBase, + SmartViewResource, +} from '@hyperdx/common-utils/dist/types'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { hdxServer } from './api'; + +export type SmartView = SmartViewBase & { + createdAt?: string; + updatedAt?: string; +}; + +export type SmartViewInput = Omit; + +export function useSmartViews(resource: SmartViewResource) { + return useQuery({ + queryKey: ['smart-views', resource], + queryFn: () => + hdxServer(`smart-views?resource=${resource}`).json(), + }); +} + +export function useCreateSmartView() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: SmartViewInput) => + hdxServer('smart-views', { + method: 'POST', + json: body, + }).json(), + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ + queryKey: ['smart-views', vars.resource], + }); + }, + }); +} + +export function useUpdateSmartView() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + patch, + }: { + id: string; + patch: Partial; + }) => + hdxServer(`smart-views/${id}`, { + method: 'PATCH', + json: patch, + }).json(), + onSuccess: data => { + queryClient.invalidateQueries({ + queryKey: ['smart-views', data.resource], + }); + }, + }); +} + +export function useDeleteSmartView() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + resource, + }: { + id: string; + resource: SmartViewResource; + }) => hdxServer(`smart-views/${id}`, { method: 'DELETE' }).json(), + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ + queryKey: ['smart-views', vars.resource], + }); + }, + }); +} diff --git a/packages/app/src/utils/__tests__/evaluateSmartView.test.ts b/packages/app/src/utils/__tests__/evaluateSmartView.test.ts new file mode 100644 index 0000000000..81f1a59c12 --- /dev/null +++ b/packages/app/src/utils/__tests__/evaluateSmartView.test.ts @@ -0,0 +1,87 @@ +import { evaluateSmartView } from '../evaluateSmartView'; + +type Item = { tags: string[] }; + +const view = ( + rules: Parameters[0]['rules'], + combinator: 'all' | 'any' = 'all', +) => ({ rules, combinator }); + +describe('evaluateSmartView', () => { + it('matches every item when the rule list is empty', () => { + const v = view([]); + expect(evaluateSmartView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateSmartView(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(evaluateSmartView(v, { tags: ['checkout'] } as Item)).toBe(true); + expect( + evaluateSmartView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(true); + expect(evaluateSmartView(v, { tags: ['payments'] } as Item)).toBe(false); + expect(evaluateSmartView(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(evaluateSmartView(v, { tags: ['checkout'] } as Item)).toBe(true); + expect(evaluateSmartView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateSmartView(v, { tags: ['payments'] } as Item)).toBe(false); + expect( + evaluateSmartView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(false); + }); + + it('untagged passes only when the item has zero tags', () => { + const v = view([{ kind: 'untagged' }]); + expect(evaluateSmartView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateSmartView(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(evaluateSmartView(v, { tags: ['checkout'] } as Item)).toBe(true); + // Has checkout but also payments -> the second rule fails + expect( + evaluateSmartView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(false); + // No checkout -> first rule fails + expect(evaluateSmartView(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(evaluateSmartView(v, { tags: ['checkout'] } as Item)).toBe(true); + expect(evaluateSmartView(v, { tags: ['payments'] } as Item)).toBe(true); + expect( + evaluateSmartView(v, { tags: ['checkout', 'payments'] } as Item), + ).toBe(true); + expect(evaluateSmartView(v, { tags: ['other'] } as Item)).toBe(false); + expect(evaluateSmartView(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(evaluateSmartView(v, { tags: [] } as Item)).toBe(true); + expect(evaluateSmartView(v, { tags: ['incident'] } as Item)).toBe(true); + expect(evaluateSmartView(v, { tags: ['other'] } as Item)).toBe(false); + }); +}); diff --git a/packages/app/src/utils/evaluateSmartView.ts b/packages/app/src/utils/evaluateSmartView.ts new file mode 100644 index 0000000000..7943ee19e1 --- /dev/null +++ b/packages/app/src/utils/evaluateSmartView.ts @@ -0,0 +1,38 @@ +import { + SmartViewCombinator, + SmartViewRule, +} from '@hyperdx/common-utils/dist/types'; + +/** + * Pure client-side evaluator for SmartView rules. + * + * A view with zero rules matches everything (the rule list is a + * narrower filter on top of whatever the listing already shows). + * + * In v1 every rule is a tag rule. PR-3 widens the rule type to + * include non-tag kinds (recency, has-active-alerts, created-by-me, + * provisioned, has-tile-type); when that lands, the `pass()` switch + * extends with the new kinds and consumers gain `T` constraints for + * the new fields they reference. + */ +export function evaluateSmartView( + view: { rules: SmartViewRule[]; combinator: SmartViewCombinator }, + item: T, +): boolean { + if (view.rules.length === 0) return true; + + const pass = (rule: SmartViewRule): 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; + } + }; + + return view.combinator === 'all' + ? view.rules.every(pass) + : view.rules.some(pass); +} From eb72d5e88c8dc9e7093b61d3cb42c3733e95ed5e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Sat, 30 May 2026 00:13:16 +0000 Subject: [PATCH 04/14] feat(app): SmartViewsSidebar and SmartViewEditorDrawer components `SmartViewsSidebar` renders a 240px-wide rail with the current user's smart views for a given resource. The empty state nudges toward the "+ New Smart View" affordance; the populated state shows each view as an UnstyledButton with optional icon, name, and a kebab Menu (Edit / Delete). The Delete flow uses the existing `useConfirm` for parity with the dashboard / saved-search delete flows on the same page. `SmartViewEditorDrawer` is a Mantine Drawer that opens on the right and accepts: name, optional icon (free text, intended for an emoji or short symbol), top-level combinator (`all` | `any`), and a dynamic rule list. Each rule row picks a kind from the discriminated union and (for the tag-includes / tag-excludes kinds) a tag from the available-tags pool. Saving calls `useCreateSmartView` for a fresh view and `useUpdateSmartView` for an existing one. Draft rules with an empty tag are dropped on save, so the editor stays forgiving while the persisted shape stays clean. Co-Authored-By: Claude Opus 4.6 --- .../SmartViewEditorDrawer.tsx | 319 ++++++++++++++++++ .../SmartViewsSidebar/SmartViewsSidebar.tsx | 178 ++++++++++ 2 files changed, 497 insertions(+) create mode 100644 packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx create mode 100644 packages/app/src/components/SmartViewsSidebar/SmartViewsSidebar.tsx diff --git a/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx b/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx new file mode 100644 index 0000000000..8526a78684 --- /dev/null +++ b/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + SmartViewCombinator, + SmartViewResource, + SmartViewTagRule, +} from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Box, + Button, + Drawer, + Group, + Radio, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; + +import { + type SmartView, + useCreateSmartView, + useUpdateSmartView, +} from '@/smartView'; + +type RuleDraft = + | { kind: 'tag-includes'; tag: string } + | { kind: 'tag-excludes'; tag: string } + | { kind: 'untagged' }; + +const RULE_KIND_LABEL: Record = { + 'tag-includes': 'tag includes', + 'tag-excludes': 'tag excludes', + untagged: 'is untagged', +}; + +const DEFAULT_DRAFT: { + name: string; + icon: string; + combinator: SmartViewCombinator; + rules: RuleDraft[]; +} = { + name: '', + icon: '', + combinator: 'all', + rules: [{ kind: 'tag-includes', tag: '' }], +}; + +export function SmartViewEditorDrawer({ + opened, + onClose, + resource, + existingView, + availableTags, +}: { + opened: boolean; + onClose: () => void; + resource: SmartViewResource; + existingView?: SmartView; + availableTags: string[]; +}) { + const [draft, setDraft] = useState(DEFAULT_DRAFT); + const [nameError, setNameError] = useState(null); + + const createSmartView = useCreateSmartView(); + const updateSmartView = useUpdateSmartView(); + + // Seed the draft from the existing view when the drawer opens for + // editing; reset to defaults when opening for a new view. + useEffect(() => { + if (!opened) return; + if (existingView) { + setDraft({ + name: existingView.name, + icon: existingView.icon ?? '', + combinator: existingView.combinator, + rules: + existingView.rules.length > 0 + ? (existingView.rules as RuleDraft[]) + : [{ kind: 'tag-includes', tag: '' }], + }); + } else { + setDraft(DEFAULT_DRAFT); + } + setNameError(null); + }, [opened, existingView]); + + const isPending = createSmartView.isPending || updateSmartView.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). 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: SmartViewTagRule[] = draft.rules.filter(r => { + if (r.kind === 'untagged') return true; + return r.tag.trim().length > 0; + }); + + const payload = { + name, + icon: draft.icon.trim() || undefined, + resource, + rules, + combinator: draft.combinator, + ordering: existingView?.ordering ?? 0, + }; + + const onSuccess = () => { + notifications.show({ + message: existingView ? 'Smart view updated' : 'Smart view created', + color: 'green', + }); + onClose(); + }; + const onError = () => { + notifications.show({ + message: existingView + ? 'Failed to update smart view' + : 'Failed to create smart view', + color: 'red', + }); + }; + + if (existingView) { + updateSmartView.mutate( + { id: existingView.id, patch: payload }, + { onSuccess, onError }, + ); + } else { + createSmartView.mutate(payload, { onSuccess, onError }); + } + }; + + return ( + + + setDraft(d => ({ ...d, name: e.currentTarget.value }))} + placeholder="e.g. Checkout team" + error={nameError} + data-testid="smart-view-name-input" + /> + + setDraft(d => ({ ...d, icon: e.currentTarget.value }))} + placeholder="🛒" + maxLength={64} + /> + + + setDraft(d => ({ + ...d, + combinator: value as SmartViewCombinator, + })) + } + > + + + + + + + + + + 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={`smart-view-rule-tag-${i}`} + /> + )} + removeRule(i)} + aria-label="Remove rule" + data-testid={`remove-smart-view-rule-${i}`} + > + + + + )) + )} + + + + + + + + + + ); +} diff --git a/packages/app/src/components/SmartViewsSidebar/SmartViewsSidebar.tsx b/packages/app/src/components/SmartViewsSidebar/SmartViewsSidebar.tsx new file mode 100644 index 0000000000..6d84bf86e5 --- /dev/null +++ b/packages/app/src/components/SmartViewsSidebar/SmartViewsSidebar.tsx @@ -0,0 +1,178 @@ +import { useCallback } from 'react'; +import { SmartViewResource } from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Box, + Button, + Group, + Menu, + Stack, + Text, + UnstyledButton, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconDots, IconPencil, IconPlus, IconTrash } from '@tabler/icons-react'; + +import { type SmartView, useDeleteSmartView, useSmartViews } from '@/smartView'; +import { useConfirm } from '@/useConfirm'; + +export function SmartViewsSidebar({ + resource, + activeId, + onActivate, + onCreate, + onEdit, +}: { + resource: SmartViewResource; + activeId: string | null; + onActivate: (id: string | null) => void; + onCreate: () => void; + onEdit: (view: SmartView) => void; +}) { + const { data: views, isLoading } = useSmartViews(resource); + const deleteSmartView = useDeleteSmartView(); + const confirm = useConfirm(); + + const handleDelete = useCallback( + async (view: SmartView) => { + const confirmed = await confirm( + `Delete the "${view.name}" smart view? This action cannot be undone.`, + 'Delete', + { variant: 'danger' }, + ); + if (!confirmed) return; + deleteSmartView.mutate( + { id: view.id, resource: view.resource }, + { + onSuccess: () => { + if (activeId === view.id) { + onActivate(null); + } + notifications.show({ + message: 'Smart view deleted', + color: 'green', + }); + }, + onError: () => { + notifications.show({ + message: 'Failed to delete smart view', + color: 'red', + }); + }, + }, + ); + }, + [activeId, confirm, deleteSmartView, onActivate], + ); + + return ( + + + + Smart Views + + + + + + + {isLoading ? ( + + Loading... + + ) : !views || views.length === 0 ? ( + + + No smart views yet. Pin a tag filter combination to jump back to it. + + + + ) : ( + + {views.map(view => { + const isActive = view.id === activeId; + return ( + + onActivate(isActive ? null : view.id)} + style={{ + flex: 1, + padding: '6px 8px', + borderRadius: 4, + backgroundColor: isActive + ? 'var(--mantine-color-default-hover)' + : undefined, + }} + aria-pressed={isActive} + > + + {view.icon && ( + + {view.icon} + + )} + + {view.name} + + + + + + + + + + + } + onClick={() => onEdit(view)} + > + Edit + + } + color="red" + onClick={() => handleDelete(view)} + > + Delete + + + + + ); + })} + + )} + + ); +} From af3324bf0095ca0d8d2a7c3583147b9aff071ef9 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Sat, 30 May 2026 00:13:28 +0000 Subject: [PATCH 05/14] feat(dashboards): wire Smart Views sidebar + apply-view URL state into the listing page DashboardsListPage now pulls smart views for the dashboard resource and renders the sidebar in a left rail next to the existing listing column. A new nuqs `?view=` state drives the active view; clicking a sidebar entry sets it and clicking the active entry again clears it (shareable URL). The `filteredDashboards` memo factors the active view's rules through `evaluateSmartView` AFTER the manual tag and search filters, so manual chips and the view's rule list AND-combine. The existing favorites + preset sections sit above the listing and are unchanged. `SmartViewEditorDrawer` renders at the page level via `useDisclosure`; the sidebar's "+ New" and per-item Edit open it with the right initial state. The drawer reuses the page's `allTags` memo as the tag pool so a user only picks from tags already in their dashboards. Extends the existing RTL test with a fourth case: when the mocked `useQueryState('view')` returns a known smart-view id, only items that pass that view's rules show up in the grid. Co-Authored-By: Claude Opus 4.6 --- .changeset/dashboards-smart-views-tag-only.md | 19 + .../Dashboards/DashboardsListPage.tsx | 525 ++++++++++-------- .../__tests__/DashboardsListPage.test.tsx | 44 ++ 3 files changed, 357 insertions(+), 231 deletions(-) create mode 100644 .changeset/dashboards-smart-views-tag-only.md diff --git a/.changeset/dashboards-smart-views-tag-only.md b/.changeset/dashboards-smart-views-tag-only.md new file mode 100644 index 0000000000..431d6b3189 --- /dev/null +++ b/.changeset/dashboards-smart-views-tag-only.md @@ -0,0 +1,19 @@ +--- +'@hyperdx/app': minor +'@hyperdx/api': minor +--- + +feat(dashboards): smart views sidebar with tag-only rules on the Dashboards listing page + +Save reusable filter combinations as named "smart 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 `/smart-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/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index 7dbfdeea1f..3b10271368 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -6,6 +6,7 @@ import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { ActionIcon, Anchor, + Box, Button, Container, Flex, @@ -17,7 +18,7 @@ import { Text, TextInput, } from '@mantine/core'; -import { useLocalStorage } from '@mantine/hooks'; +import { useDisclosure, useLocalStorage } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { IconChevronDown, @@ -35,6 +36,8 @@ import { FavoriteButton } from '@/components/FavoriteButton'; import { ListingCard } from '@/components/ListingCard'; import { ListingRow } from '@/components/ListingListRow'; import { PageHeader } from '@/components/PageHeader'; +import { SmartViewEditorDrawer } from '@/components/SmartViewsSidebar/SmartViewEditorDrawer'; +import { SmartViewsSidebar } from '@/components/SmartViewsSidebar/SmartViewsSidebar'; import { IS_K8S_DASHBOARD_ENABLED } from '@/config'; import { type Dashboard, @@ -43,8 +46,10 @@ import { useDeleteDashboard, } from '@/dashboard'; import { useFavorites } from '@/favorites'; +import { type SmartView, useSmartViews } from '@/smartView'; import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { useConfirm } from '@/useConfirm'; +import { evaluateSmartView } from '@/utils/evaluateSmartView'; import { withAppNav } from '../../layout'; @@ -88,11 +93,32 @@ export default function DashboardsListPage() { .withOptions({ history: 'replace' }), ); const [legacyTag, setLegacyTag] = useQueryState('tag'); + const [activeViewId, setActiveViewId] = useQueryState('view'); const [viewMode, setViewMode] = useLocalStorage<'grid' | 'list'>({ key: 'dashboardsViewMode', defaultValue: 'grid', }); + const { data: smartViews } = useSmartViews('dashboard'); + const [editorOpened, { open: openEditor, close: closeEditor }] = + useDisclosure(false); + const [editingView, setEditingView] = useState( + undefined, + ); + + const handleCreateSmartView = useCallback(() => { + setEditingView(undefined); + openEditor(); + }, [openEditor]); + + const handleEditSmartView = useCallback( + (view: SmartView) => { + 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 +154,11 @@ export default function DashboardsListPage() { return Array.from(tags).sort(); }, [dashboards]); + const activeView = useMemo( + () => smartViews?.find(v => v.id === activeViewId) ?? null, + [smartViews, activeViewId], + ); + const filteredDashboards = useMemo(() => { if (!dashboards) return []; let result = dashboards; @@ -142,8 +173,11 @@ export default function DashboardsListPage() { d.tags.some(t => t.toLowerCase().includes(q)), ); } + if (activeView) { + result = result.filter(d => evaluateSmartView(activeView, d)); + } return result.slice().sort((a, b) => a.name.localeCompare(b.name)); - }, [dashboards, search, selectedTags]); + }, [dashboards, search, selectedTags, activeView]); const handleCreate = useCallback(() => { createDashboard.mutate( @@ -197,38 +231,259 @@ export default function DashboardsListPage() { Dashboards - {brandName} - - - Preset Dashboards - - - {PRESET_DASHBOARDS.map(p => ( - - ))} - - - - Browse dashboard templates → - - + + + + + + Preset Dashboards + + + {PRESET_DASHBOARDS.map(p => ( + + ))} + + + + Browse dashboard templates → + + + + {favoritedDashboards.length > 0 && ( + <> + + Favorites + + + {favoritedDashboards.map(d => ( + handleDelete(d.id)} + statusIcon={ + + } + resourceId={d.id} + resourceType="dashboard" + updatedAt={d.updatedAt} + updatedBy={d.updatedBy?.name || d.updatedBy?.email} + /> + ))} + + + )} + + + 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 + + + + + + - {favoritedDashboards.length > 0 && ( - <> - - Favorites + {isLoading ? ( + + Loading dashboards... - + Failed to load dashboards. Please try refreshing the page. + + ) : filteredDashboards.length === 0 ? ( + - {favoritedDashboards.map(d => ( + } + title={ + search || selectedTags.length > 0 + ? 'No matching dashboards yet' + : 'No dashboards yet' + } + > + + + + + + + ) : viewMode === 'list' ? ( + + + + + Name + Tags + Created By + Last Updated + + + + + {filteredDashboards.map(d => ( + + + + + } + /> + ))} + +
+ ) : ( + + {filteredDashboards.map(d => ( ))} - - )} - - - 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 - - - - - - - - {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' - } - > - - - - - - - ) : 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..26b5c87eb6 100644 --- a/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx +++ b/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx @@ -5,15 +5,19 @@ import DashboardsListPage from '../DashboardsListPage'; const mockSetSelectedTags = jest.fn(); const mockSetLegacyTag = jest.fn(); +const mockSetActiveViewId = jest.fn(); const mockUseDashboards = jest.fn(); const mockUseFavorites = jest.fn(); const mockUseCreateDashboard = jest.fn(); const mockUseDeleteDashboard = jest.fn(); +const mockUseSmartViews = jest.fn(); +const mockUseDeleteSmartView = jest.fn(); const mockUseConfirm = jest.fn(); const mockUseBrandDisplayName = jest.fn(); let mockSelectedTags: string[] = []; let mockLegacyTag: string | null = null; +let mockActiveViewId: string | null = null; jest.mock('next/router', () => ({ __esModule: true, @@ -54,10 +58,18 @@ jest.mock('nuqs', () => ({ useQueryState: (key: string) => { if (key === 'tags') return [mockSelectedTags, mockSetSelectedTags]; if (key === 'tag') return [mockLegacyTag, mockSetLegacyTag]; + if (key === 'view') return [mockActiveViewId, mockSetActiveViewId]; return [null, jest.fn()]; }, })); +jest.mock('@/smartView', () => ({ + useSmartViews: () => mockUseSmartViews(), + useDeleteSmartView: () => mockUseDeleteSmartView(), + useCreateSmartView: () => ({ mutate: jest.fn(), isPending: false }), + useUpdateSmartView: () => ({ mutate: jest.fn(), isPending: false }), +})); + jest.mock('@/dashboard', () => ({ useDashboards: () => mockUseDashboards(), useCreateDashboard: () => mockUseCreateDashboard(), @@ -112,8 +124,10 @@ const seedDashboards = [ beforeEach(() => { mockSelectedTags = []; mockLegacyTag = null; + mockActiveViewId = null; mockSetSelectedTags.mockClear(); mockSetLegacyTag.mockClear(); + mockSetActiveViewId.mockClear(); mockUseDashboards.mockReturnValue({ data: seedDashboards, isLoading: false, @@ -125,6 +139,8 @@ beforeEach(() => { isPending: false, }); mockUseDeleteDashboard.mockReturnValue({ mutate: jest.fn() }); + mockUseSmartViews.mockReturnValue({ data: [], isLoading: false }); + mockUseDeleteSmartView.mockReturnValue({ mutate: jest.fn() }); mockUseConfirm.mockReturnValue(jest.fn()); mockUseBrandDisplayName.mockReturnValue('HyperDX'); }); @@ -163,4 +179,32 @@ describe('DashboardsListPage', () => { expect(screen.getByText('No matching dashboards yet')).toBeInTheDocument(); }); + + it('filters the listing through the active smart 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, + }; + mockUseSmartViews.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(); + }); }); From 60e8871f4abe74740871922e050efa17031e7d51 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Sat, 30 May 2026 00:28:13 +0000 Subject: [PATCH 06/14] fix(smart-view): local-mode fallback so the Vercel preview and IS_LOCAL_MODE builds work useSmartViews / useCreateSmartView / useUpdateSmartView / useDeleteSmartView all unconditionally hit the API. On the Vercel preview and any `IS_LOCAL_MODE` build there is no `/smart-views` backend, so GETs 504 (sidebar shows "Loading..." for ~7s while React Query retries before falling through to the empty state) and POST/PATCH/DELETE 504 too (the editor drawer shows "Failed to create smart view" but keeps the user staring at a useless modal). Mirror the pattern used by `favorites.ts` and `dashboard.ts`: each hook short-circuits to `createEntityStore( 'hdx-local-smart-views')` when `IS_LOCAL_MODE` is true. The listing is filtered + sorted by `ordering` on the read path. The React Query invalidation logic is unchanged so the sidebar refreshes on create / update / delete. Drive-by: switch the editor drawer's Cancel button from `variant="default"` to `variant="secondary"` to satisfy `agent_docs/code_style.md`'s Button variant rule (caught by `no-restricted-syntax` after rebuild). Co-Authored-By: Claude Opus 4.6 --- .../SmartViewEditorDrawer.tsx | 2 +- packages/app/src/smartView.ts | 54 +++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx b/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx index 8526a78684..0d497b2145 100644 --- a/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx +++ b/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx @@ -301,7 +301,7 @@ export function SmartViewEditorDrawer({ - + + + + + + } + 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 + + + + + + + + {isLoading ? ( + + Loading dashboards... + + ) : isError ? ( + + Failed to load dashboards. Please try refreshing the page. - - {favoritedDashboards.map(d => ( + } + title={ + search || selectedTags.length > 0 + ? 'No matching dashboards yet' + : 'No dashboards yet' + } + > + + + + + + + ) : viewMode === 'list' ? ( + + + + + Name + Tags + Created By + Last Updated + + + + + {filteredDashboards.map(d => ( + + + + + } + /> + ))} + +
+ ) : ( + + {filteredDashboards.map(d => ( ))} - - )} - - - 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 - - - - - - - - {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' - } - > - - - - - - - ) : 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} - /> - ))} - - )} - - + )} + + + = { + dashboard: 'All Dashboards', + savedSearch: 'All Saved Searches', +}; + export function SmartViewsSidebar({ resource, activeId, onActivate, onCreate, onEdit, + totalCount, + viewCounts, }: { resource: SmartViewResource; activeId: string | null; onActivate: (id: string | null) => void; onCreate: () => void; onEdit: (view: SmartView) => 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 } = useSmartViews(resource); const deleteSmartView = useDeleteSmartView(); @@ -65,82 +79,65 @@ export function SmartViewsSidebar({ [activeId, confirm, deleteSmartView, onActivate], ); + const hasViews = (views?.length ?? 0) > 0; + return ( - - - - Smart Views - - - - - + + + onActivate(null)} + testId="smart-view-row-all" + /> - {isLoading ? ( - - Loading... - - ) : !views || views.length === 0 ? ( - - - No smart views yet. Pin a tag filter combination to jump back to it. + + + Smart Views - - - ) : ( - - {views.map(view => { - const isActive = view.id === activeId; - return ( - - onActivate(isActive ? null : view.id)} - style={{ - flex: 1, - padding: '6px 8px', - borderRadius: 4, - backgroundColor: isActive - ? 'var(--mantine-color-default-hover)' - : undefined, - }} - aria-pressed={isActive} - > - - {view.icon && ( - - {view.icon} - - )} - - {view.name} - - - + + New Smart View + + ) : ( + views!.map(view => ( + onActivate(view.id === activeId ? null : view.id)} + testId={`smart-view-row-${view.id}`} + menu={ - - ); - })} - - )} + } + /> + )) + )} + ); } + +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} + + ); +} From 90a23477203bb3509c13b0faacb6fb2d78d99734 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:47:59 +0000 Subject: [PATCH 10/14] refactor: rename SmartView -> ListView across api / app / common-utils Mechanical rename. No behavior change. Same routes (renamed to /list-views), same wire shape, same combinator + tag rule kinds. * common-utils: SmartView* Zod symbols become ListView*. The intermediate SmartViewTagRuleSchema alias collapses into ListViewRuleSchema directly so PR-3's union widening edits one symbol instead of two. * api: smartView.ts model and controllers rename to listView.ts; router file rename to listViews.ts and mount point change to /list-views. Mongoose model name becomes 'ListView' so the default collection name becomes 'listviews'. * app: smartView.ts hook file, evaluateSmartView util, and SmartViewsSidebar component directory all rename. React Query keys and localStorage keys swap to 'list-views' / 'hdx-local- list-views'. User-visible copy ("Smart View", "smart view") becomes "View" / "view"; the sidebar header reads "Views" and the empty-state button reads "+ New View". Existing tests round-trip unchanged after path / symbol updates. Co-Authored-By: Claude Opus 4.7 --- .changeset/dashboards-smart-views-tag-only.md | 6 +- packages/api/src/api-app.ts | 4 +- packages/api/src/controllers/listView.ts | 51 +++++++++ packages/api/src/controllers/smartView.ts | 51 --------- .../src/models/{smartView.ts => listView.ts} | 12 +- .../{smartViews.test.ts => listViews.test.ts} | 58 +++++----- .../api/{smartViews.ts => listViews.ts} | 33 +++--- .../Dashboards/DashboardsListPage.tsx | 40 +++---- .../__tests__/DashboardsListPage.test.tsx | 22 ++-- .../ListViewEditorDrawer.tsx} | 60 +++++----- .../ListViewsSidebar.tsx} | 48 ++++---- .../app/src/{smartView.ts => listView.ts} | 72 ++++++------ .../utils/__tests__/evaluateListView.test.ts | 104 +++++++++++++++++ .../utils/__tests__/evaluateSmartView.test.ts | 106 ------------------ ...aluateSmartView.ts => evaluateListView.ts} | 16 +-- packages/common-utils/src/types.ts | 36 +++--- 16 files changed, 354 insertions(+), 365 deletions(-) create mode 100644 packages/api/src/controllers/listView.ts delete mode 100644 packages/api/src/controllers/smartView.ts rename packages/api/src/models/{smartView.ts => listView.ts} (80%) rename packages/api/src/routers/api/__tests__/{smartViews.test.ts => listViews.test.ts} (73%) rename packages/api/src/routers/api/{smartViews.ts => listViews.ts} (79%) rename packages/app/src/components/{SmartViewsSidebar/SmartViewEditorDrawer.tsx => ListViewsSidebar/ListViewEditorDrawer.tsx} (86%) rename packages/app/src/components/{SmartViewsSidebar/SmartViewsSidebar.tsx => ListViewsSidebar/ListViewsSidebar.tsx} (83%) rename packages/app/src/{smartView.ts => listView.ts} (54%) create mode 100644 packages/app/src/utils/__tests__/evaluateListView.test.ts delete mode 100644 packages/app/src/utils/__tests__/evaluateSmartView.test.ts rename packages/app/src/utils/{evaluateSmartView.ts => evaluateListView.ts} (80%) diff --git a/.changeset/dashboards-smart-views-tag-only.md b/.changeset/dashboards-smart-views-tag-only.md index 431d6b3189..d0bdb22601 100644 --- a/.changeset/dashboards-smart-views-tag-only.md +++ b/.changeset/dashboards-smart-views-tag-only.md @@ -3,9 +3,9 @@ '@hyperdx/api': minor --- -feat(dashboards): smart views sidebar with tag-only rules on the Dashboards listing page +feat(dashboards): list views sidebar with tag-only rules on the Dashboards listing page -Save reusable filter combinations as named "smart views" pinned to a +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 @@ -13,7 +13,7 @@ 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 `/smart-views` CRUD endpoint scoped per user and per +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 69b8de7327..e13bd2524a 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -13,9 +13,9 @@ 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 smartViewsRouter from './routers/api/smartViews'; import sourcesRouter from './routers/api/sources'; import externalRoutersV2 from './routers/external-api/v2'; import usageStats from './tasks/usageStats'; @@ -107,7 +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('/smart-views', isUserAuthenticated, smartViewsRouter); +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/controllers/smartView.ts b/packages/api/src/controllers/smartView.ts deleted file mode 100644 index 73d17647cb..0000000000 --- a/packages/api/src/controllers/smartView.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - SmartViewResource, - SmartViewWithoutId, -} from '@hyperdx/common-utils/dist/types'; - -import SmartView, { ISmartView } from '@/models/smartView'; - -export function getSmartViews( - userId: string, - teamId: string, - resource?: SmartViewResource, -) { - const filter: Record = { owner: userId, team: teamId }; - if (resource) filter.resource = resource; - return SmartView.find(filter).sort({ ordering: 1, createdAt: 1 }); -} - -export function getSmartView(id: string, userId: string, teamId: string) { - return SmartView.findOne({ _id: id, owner: userId, team: teamId }); -} - -export function createSmartView( - userId: string, - teamId: string, - view: SmartViewWithoutId, -) { - return SmartView.create({ - ...view, - owner: userId, - team: teamId, - }); -} - -export function updateSmartView( - id: string, - userId: string, - teamId: string, - patch: Partial, -) { - return SmartView.findOneAndUpdate( - { _id: id, owner: userId, team: teamId }, - patch, - { new: true }, - ); -} - -export function deleteSmartView(id: string, userId: string, teamId: string) { - return SmartView.deleteOne({ _id: id, owner: userId, team: teamId }); -} - -export type SmartViewExport = ISmartView; diff --git a/packages/api/src/models/smartView.ts b/packages/api/src/models/listView.ts similarity index 80% rename from packages/api/src/models/smartView.ts rename to packages/api/src/models/listView.ts index 1a2a0ed39c..5f8bf3b556 100644 --- a/packages/api/src/models/smartView.ts +++ b/packages/api/src/models/listView.ts @@ -1,10 +1,10 @@ -import { SmartViewSchema } from '@hyperdx/common-utils/dist/types'; +import { ListViewSchema } from '@hyperdx/common-utils/dist/types'; import mongoose, { Schema } from 'mongoose'; import { z } from 'zod'; import type { ObjectId } from '.'; -export interface ISmartView extends z.infer { +export interface IListView extends z.infer { _id: ObjectId; team: ObjectId; owner: ObjectId; @@ -12,9 +12,9 @@ export interface ISmartView extends z.infer { updatedAt: Date; } -export type SmartViewDocument = mongoose.HydratedDocument; +export type ListViewDocument = mongoose.HydratedDocument; -const smartViewSchema = new Schema( +const listViewSchema = new Schema( { name: { type: String, @@ -78,6 +78,6 @@ const smartViewSchema = new Schema( // views the same intentionally (e.g. "checkout" dashboards vs // "checkout" saved searches across resources, or two iterations // during editing). -smartViewSchema.index({ team: 1, owner: 1, resource: 1, ordering: 1 }); +listViewSchema.index({ team: 1, owner: 1, resource: 1, ordering: 1 }); -export default mongoose.model('SmartView', smartViewSchema); +export default mongoose.model('ListView', listViewSchema); diff --git a/packages/api/src/routers/api/__tests__/smartViews.test.ts b/packages/api/src/routers/api/__tests__/listViews.test.ts similarity index 73% rename from packages/api/src/routers/api/__tests__/smartViews.test.ts rename to packages/api/src/routers/api/__tests__/listViews.test.ts index 3c5eeffc2a..e3cd9ab570 100644 --- a/packages/api/src/routers/api/__tests__/smartViews.test.ts +++ b/packages/api/src/routers/api/__tests__/listViews.test.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { getLoggedInAgent, getServer } from '@/fixtures'; -describe('smart views router', () => { +describe('list views router', () => { const server = getServer(); let agent: Awaited>['agent']; @@ -32,23 +32,23 @@ describe('smart views router', () => { isShared: false, }; - it('round-trips a dashboard smart view through POST and GET', async () => { + it('round-trips a dashboard list view through POST and GET', async () => { const create = await agent - .post('/smart-views') + .post('/list-views') .send(dashboardView) .expect(200); expect(create.body).toMatchObject(dashboardView); expect(create.body.id).toBeDefined(); - const list = await agent.get('/smart-views?resource=dashboard').expect(200); + 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('/smart-views').send(dashboardView).expect(200); + await agent.post('/list-views').send(dashboardView).expect(200); await agent - .post('/smart-views') + .post('/list-views') .send({ ...dashboardView, name: 'Errors search', @@ -57,22 +57,22 @@ describe('smart views router', () => { .expect(200); const dashList = await agent - .get('/smart-views?resource=dashboard') + .get('/list-views?resource=dashboard') .expect(200); expect(dashList.body).toHaveLength(1); expect(dashList.body[0].resource).toBe('dashboard'); const searchList = await agent - .get('/smart-views?resource=savedSearch') + .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('/smart-views').send(dashboardView).expect(200); + await agent.post('/list-views').send(dashboardView).expect(200); await agent - .post('/smart-views') + .post('/list-views') .send({ ...dashboardView, name: 'Errors search', @@ -80,19 +80,19 @@ describe('smart views router', () => { }) .expect(200); - const list = await agent.get('/smart-views').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('/smart-views') + .post('/list-views') .send(dashboardView) .expect(200); const { id } = create.body; const patch = await agent - .patch(`/smart-views/${id}`) + .patch(`/list-views/${id}`) .send({ name: 'Checkout + payments', rules: [ @@ -108,42 +108,42 @@ describe('smart views router', () => { expect(patch.body.combinator).toBe('all'); }); - it('deletes a smart view and removes it from the listing', async () => { + it('deletes a list view and removes it from the listing', async () => { const create = await agent - .post('/smart-views') + .post('/list-views') .send(dashboardView) .expect(200); const { id } = create.body; - await agent.delete(`/smart-views/${id}`).expect(204); + await agent.delete(`/list-views/${id}`).expect(204); - const list = await agent.get('/smart-views?resource=dashboard').expect(200); + const list = await agent.get('/list-views?resource=dashboard').expect(200); expect(list.body).toHaveLength(0); }); - it('returns 404 when patching a non-existent smart view', async () => { + it('returns 404 when patching a non-existent list view', async () => { const fakeId = new mongoose.Types.ObjectId().toString(); await agent - .patch(`/smart-views/${fakeId}`) + .patch(`/list-views/${fakeId}`) .send({ name: 'never' }) .expect(404); }); - it('returns 404 when deleting a non-existent smart view', async () => { + it('returns 404 when deleting a non-existent list view', async () => { const fakeId = new mongoose.Types.ObjectId().toString(); - await agent.delete(`/smart-views/${fakeId}`).expect(404); + await agent.delete(`/list-views/${fakeId}`).expect(404); }); it('rejects a body missing required fields', async () => { await agent - .post('/smart-views') + .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('/smart-views') + .post('/list-views') .send({ ...dashboardView, rules: [{ kind: 'has-active-alerts' }], @@ -153,15 +153,15 @@ describe('smart views router', () => { it('rejects a name longer than 120 chars', async () => { await agent - .post('/smart-views') + .post('/list-views') .send({ ...dashboardView, name: 'x'.repeat(121) }) .expect(400); }); - it('isolates smart views between users on the same team', async () => { + it('isolates list views between users on the same team', async () => { // Create a view as the default agent. const create = await agent - .post('/smart-views') + .post('/list-views') .send(dashboardView) .expect(200); const { id } = create.body; @@ -172,15 +172,15 @@ describe('smart views router', () => { // Second user's listing is empty. const otherList = await other.agent - .get('/smart-views?resource=dashboard') + .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(`/smart-views/${id}`) + .patch(`/list-views/${id}`) .send({ name: 'hijack' }) .expect(404); - await other.agent.delete(`/smart-views/${id}`).expect(404); + await other.agent.delete(`/list-views/${id}`).expect(404); }); }); diff --git a/packages/api/src/routers/api/smartViews.ts b/packages/api/src/routers/api/listViews.ts similarity index 79% rename from packages/api/src/routers/api/smartViews.ts rename to packages/api/src/routers/api/listViews.ts index e5cb1cc63b..3a4593ad10 100644 --- a/packages/api/src/routers/api/smartViews.ts +++ b/packages/api/src/routers/api/listViews.ts @@ -1,7 +1,6 @@ import { - SmartViewResourceSchema, - SmartViewSchema, - SmartViewWithoutIdSchema, + ListViewResourceSchema, + ListViewWithoutIdSchema, } from '@hyperdx/common-utils/dist/types'; import express from 'express'; import _ from 'lodash'; @@ -9,12 +8,12 @@ import { z } from 'zod'; import { validateRequest } from 'zod-express-middleware'; import { - createSmartView, - deleteSmartView, - getSmartView, - getSmartViews, - updateSmartView, -} from '@/controllers/smartView'; + createListView, + deleteListView, + getListView, + getListViews, + updateListView, +} from '@/controllers/listView'; import { getNonNullUserWithTeam } from '@/middleware/auth'; import { objectIdSchema } from '@/utils/zod'; @@ -24,14 +23,14 @@ router.get( '/', validateRequest({ query: z.object({ - resource: SmartViewResourceSchema.optional(), + resource: ListViewResourceSchema.optional(), }), }), async (req, res, next) => { try { const { teamId, userId } = getNonNullUserWithTeam(req); - const views = await getSmartViews( + const views = await getListViews( userId.toString(), teamId.toString(), req.query.resource, @@ -47,13 +46,13 @@ router.get( router.post( '/', validateRequest({ - body: SmartViewWithoutIdSchema, + body: ListViewWithoutIdSchema, }), async (req, res, next) => { try { const { teamId, userId } = getNonNullUserWithTeam(req); - const view = await createSmartView( + const view = await createListView( userId.toString(), teamId.toString(), req.body, @@ -70,14 +69,14 @@ router.patch( '/:id', validateRequest({ params: z.object({ id: objectIdSchema }), - body: SmartViewWithoutIdSchema.partial(), + body: ListViewWithoutIdSchema.partial(), }), async (req, res, next) => { try { const { teamId, userId } = getNonNullUserWithTeam(req); const { id } = req.params; - const existing = await getSmartView( + const existing = await getListView( id, userId.toString(), teamId.toString(), @@ -87,7 +86,7 @@ router.patch( } const updates = _.omitBy(req.body, _.isUndefined); - const updated = await updateSmartView( + const updated = await updateListView( id, userId.toString(), teamId.toString(), @@ -111,7 +110,7 @@ router.delete( const { teamId, userId } = getNonNullUserWithTeam(req); const { id } = req.params; - const result = await deleteSmartView( + const result = await deleteListView( id, userId.toString(), teamId.toString(), diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index c8e0e41392..70f6a64ee7 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -35,9 +35,9 @@ 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 { SmartViewEditorDrawer } from '@/components/SmartViewsSidebar/SmartViewEditorDrawer'; -import { SmartViewsSidebar } from '@/components/SmartViewsSidebar/SmartViewsSidebar'; import { IS_K8S_DASHBOARD_ENABLED } from '@/config'; import { type Dashboard, @@ -46,10 +46,10 @@ import { useDeleteDashboard, } from '@/dashboard'; import { useFavorites } from '@/favorites'; -import { type SmartView, useSmartViews } from '@/smartView'; +import { type ListView, useListViews } from '@/listView'; import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { useConfirm } from '@/useConfirm'; -import { evaluateSmartView } from '@/utils/evaluateSmartView'; +import { evaluateListView } from '@/utils/evaluateListView'; import { withAppNav } from '../../layout'; @@ -99,20 +99,20 @@ export default function DashboardsListPage() { defaultValue: 'grid', }); - const { data: smartViews } = useSmartViews('dashboard'); + const { data: listViews } = useListViews('dashboard'); const [editorOpened, { open: openEditor, close: closeEditor }] = useDisclosure(false); - const [editingView, setEditingView] = useState( + const [editingView, setEditingView] = useState( undefined, ); - const handleCreateSmartView = useCallback(() => { + const handleCreateListView = useCallback(() => { setEditingView(undefined); openEditor(); }, [openEditor]); - const handleEditSmartView = useCallback( - (view: SmartView) => { + const handleEditListView = useCallback( + (view: ListView) => { setEditingView(view); openEditor(); }, @@ -155,23 +155,23 @@ export default function DashboardsListPage() { }, [dashboards]); const activeView = useMemo( - () => smartViews?.find(v => v.id === activeViewId) ?? null, - [smartViews, activeViewId], + () => listViews?.find(v => v.id === activeViewId) ?? null, + [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. const viewCounts = useMemo>(() => { - if (!dashboards || !smartViews) return {}; + if (!dashboards || !listViews) return {}; const result: Record = {}; - for (const view of smartViews) { + for (const view of listViews) { result[view.id] = dashboards.filter(d => - evaluateSmartView(view, d), + evaluateListView(view, d), ).length; } return result; - }, [dashboards, smartViews]); + }, [dashboards, listViews]); const filteredDashboards = useMemo(() => { if (!dashboards) return []; @@ -188,7 +188,7 @@ export default function DashboardsListPage() { ); } if (activeView) { - result = result.filter(d => evaluateSmartView(activeView, d)); + result = result.filter(d => evaluateListView(activeView, d)); } return result.slice().sort((a, b) => a.name.localeCompare(b.name)); }, [dashboards, search, selectedTags, activeView]); @@ -259,12 +259,12 @@ export default function DashboardsListPage() { style={{ flex: 1 }} > - @@ -530,7 +530,7 @@ export default function DashboardsListPage() { - ({ }, })); -jest.mock('@/smartView', () => ({ - useSmartViews: () => mockUseSmartViews(), - useDeleteSmartView: () => mockUseDeleteSmartView(), - useCreateSmartView: () => ({ mutate: jest.fn(), isPending: false }), - useUpdateSmartView: () => ({ mutate: jest.fn(), isPending: false }), +jest.mock('@/listView', () => ({ + useListViews: () => mockUseListViews(), + useDeleteListView: () => mockUseDeleteListView(), + useCreateListView: () => ({ mutate: jest.fn(), isPending: false }), + useUpdateListView: () => ({ mutate: jest.fn(), isPending: false }), })); jest.mock('@/dashboard', () => ({ @@ -139,8 +139,8 @@ beforeEach(() => { isPending: false, }); mockUseDeleteDashboard.mockReturnValue({ mutate: jest.fn() }); - mockUseSmartViews.mockReturnValue({ data: [], isLoading: false }); - mockUseDeleteSmartView.mockReturnValue({ mutate: jest.fn() }); + mockUseListViews.mockReturnValue({ data: [], isLoading: false }); + mockUseDeleteListView.mockReturnValue({ mutate: jest.fn() }); mockUseConfirm.mockReturnValue(jest.fn()); mockUseBrandDisplayName.mockReturnValue('HyperDX'); }); @@ -180,7 +180,7 @@ describe('DashboardsListPage', () => { expect(screen.getByText('No matching dashboards yet')).toBeInTheDocument(); }); - it('filters the listing through the active smart view rules', () => { + it('filters the listing through the active list view rules', () => { const checkoutView = { id: 'view-1', name: 'Checkout team', @@ -190,7 +190,7 @@ describe('DashboardsListPage', () => { ordering: 0, isShared: false, }; - mockUseSmartViews.mockReturnValue({ + mockUseListViews.mockReturnValue({ data: [checkoutView], isLoading: false, }); diff --git a/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx b/packages/app/src/components/ListViewsSidebar/ListViewEditorDrawer.tsx similarity index 86% rename from packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx rename to packages/app/src/components/ListViewsSidebar/ListViewEditorDrawer.tsx index 155b831bdb..9f8ca4d8c6 100644 --- a/packages/app/src/components/SmartViewsSidebar/SmartViewEditorDrawer.tsx +++ b/packages/app/src/components/ListViewsSidebar/ListViewEditorDrawer.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { - SmartViewCombinator, - SmartViewResource, - SmartViewTagRule, + ListViewCombinator, + ListViewResource, + ListViewRule, } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, @@ -20,10 +20,10 @@ import { notifications } from '@mantine/notifications'; import { IconPlus, IconTrash } from '@tabler/icons-react'; import { - type SmartView, - useCreateSmartView, - useUpdateSmartView, -} from '@/smartView'; + type ListView, + useCreateListView, + useUpdateListView, +} from '@/listView'; type RuleDraft = | { kind: 'tag-includes'; tag: string } @@ -39,7 +39,7 @@ const RULE_KIND_LABEL: Record = { const DEFAULT_DRAFT: { name: string; icon: string; - combinator: SmartViewCombinator; + combinator: ListViewCombinator; rules: RuleDraft[]; } = { name: '', @@ -48,7 +48,7 @@ const DEFAULT_DRAFT: { rules: [{ kind: 'tag-includes', tag: '' }], }; -export function SmartViewEditorDrawer({ +export function ListViewEditorDrawer({ opened, onClose, resource, @@ -57,20 +57,20 @@ export function SmartViewEditorDrawer({ }: { opened: boolean; onClose: () => void; - resource: SmartViewResource; - existingView?: SmartView; + resource: ListViewResource; + existingView?: ListView; availableTags: string[]; }) { const [draft, setDraft] = useState(DEFAULT_DRAFT); const [nameError, setNameError] = useState(null); - const createSmartView = useCreateSmartView(); - const updateSmartView = useUpdateSmartView(); + 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 SmartView document stored before + // 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 @@ -98,7 +98,7 @@ export function SmartViewEditorDrawer({ setNameError(null); }, [opened, existingView]); - const isPending = createSmartView.isPending || updateSmartView.isPending; + const isPending = createListView.isPending || updateListView.isPending; const tagOptions = useMemo( () => availableTags.map(t => ({ value: t, label: t })), @@ -139,7 +139,7 @@ export function SmartViewEditorDrawer({ // tag-includes row left with an empty tag). 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: SmartViewTagRule[] = draft.rules.filter(r => { + const rules: ListViewRule[] = draft.rules.filter(r => { if (r.kind === 'untagged') return true; return r.tag.trim().length > 0; }); @@ -155,7 +155,7 @@ export function SmartViewEditorDrawer({ const onSuccess = () => { notifications.show({ - message: existingView ? 'Smart view updated' : 'Smart view created', + message: existingView ? 'View updated' : 'View created', color: 'green', }); onClose(); @@ -163,19 +163,19 @@ export function SmartViewEditorDrawer({ const onError = () => { notifications.show({ message: existingView - ? 'Failed to update smart view' - : 'Failed to create smart view', + ? 'Failed to update view' + : 'Failed to create view', color: 'red', }); }; if (existingView) { - updateSmartView.mutate( + updateListView.mutate( { id: existingView.id, patch: payload }, { onSuccess, onError }, ); } else { - createSmartView.mutate(payload, { onSuccess, onError }); + createListView.mutate(payload, { onSuccess, onError }); } }; @@ -185,8 +185,8 @@ export function SmartViewEditorDrawer({ onClose={onClose} position="right" size="md" - title={existingView ? 'Edit Smart View' : 'New Smart View'} - data-testid="smart-view-editor-drawer" + title={existingView ? 'Edit View' : 'New View'} + data-testid="list-view-editor-drawer" > setDraft(d => ({ ...d, - combinator: value as SmartViewCombinator, + combinator: value as ListViewCombinator, })) } > @@ -248,7 +248,7 @@ export function SmartViewEditorDrawer({ size="xs" leftSection={} onClick={addRule} - data-testid="add-smart-view-rule" + data-testid="add-list-view-rule" > Add rule @@ -264,7 +264,7 @@ export function SmartViewEditorDrawer({ key={i} gap="xs" wrap="nowrap" - data-testid={`smart-view-rule-${i}`} + data-testid={`list-view-rule-${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} + + ))} + + + )} { }; 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/evaluateListView.ts b/packages/app/src/utils/evaluateListView.ts index 88cec2c896..d64c3c7315 100644 --- a/packages/app/src/utils/evaluateListView.ts +++ b/packages/app/src/utils/evaluateListView.ts @@ -9,18 +9,31 @@ import { * A view with zero rules matches everything (the rule list is a * narrower filter on top of whatever the listing already shows). * - * In v1 every rule is a tag rule. PR-3 widens the rule type to - * include non-tag kinds (recency, has-active-alerts, created-by-me, - * provisioned, has-tile-type); when that lands, the `pass()` switch - * extends with the new kinds and consumers gain `T` constraints for - * the new fields they reference. + * 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 function evaluateListView( +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` @@ -42,6 +55,33 @@ export function evaluateListView( 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; + } } }; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index eb98d9f417..c2778f99ba 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1269,16 +1269,21 @@ export type DashboardTemplate = z.infer; // listing sidebar. Rules are evaluated client-side over the listing // endpoint's response, AND/OR combined via `combinator`. // -// The rule discriminated union is intentionally narrow in v1 -// (tag-only) so PR-2 lands the storage + sidebar plumbing without -// dragging in non-tag rule machinery. PR-3 widens this union with -// `updated-within-days`, `has-active-alerts`, `created-by-me`, -// `provisioned`, `has-tile-type`; the existing stored documents keep -// parsing because the union extension is additive. +// 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; From c8d6c2274da8d4f0ca5e3d4ddab123e32371e53b Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:02:25 +0000 Subject: [PATCH 12/14] feat(dashboards): non-tag filter pills with nuqs URL state Three quick-filter pills sit between the search / tag row and the listing. Each independently AND-combines with the rest of the filter chain (search, tag chips, active list view). * Recently updated: pill opens a popover with 1 / 7 / 30 presets. Default to 7 days when first toggled on. URL: ?recentDays=. * With active alerts: zero-config toggle. URL: ?withAlerts=1. * Created by me: zero-config toggle. URL: ?createdByMe=1. The semantics route through evaluateListView so the pill behavior matches what the save flow (next commit) will persist into a ListView rule. Empty state copy widens to include any active pill so a no-match state still reads as "no matches" rather than "no dashboards yet". Two new unit tests cover the created-by-me and recently-updated pills filtering the visible grid. Co-Authored-By: Claude Opus 4.7 --- .../Dashboards/DashboardsListPage.tsx | 166 +++++++++++++++++- .../__tests__/DashboardsListPage.test.tsx | 64 +++++++ 2 files changed, 225 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index f372ddee58..e8b861f868 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -2,7 +2,13 @@ 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, @@ -13,6 +19,8 @@ import { Group, Menu, MultiSelect, + Pill, + Popover, SimpleGrid, Table, Text, @@ -95,6 +103,20 @@ export default function DashboardsListPage() { ); 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', @@ -208,8 +230,40 @@ export default function DashboardsListPage() { }), ); } + // 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, activeView, evalContext]); + }, [ + dashboards, + search, + selectedTags, + activeView, + evalContext, + recentDays, + withAlerts, + createdByMe, + ]); const handleCreate = useCallback(() => { createDashboard.mutate( @@ -344,8 +398,14 @@ export default function DashboardsListPage() { Team Dashboards - - + + } @@ -435,6 +495,97 @@ export default function DashboardsListPage() { + + + 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... @@ -452,7 +603,12 @@ export default function DashboardsListPage() { } title={ - search || selectedTags.length > 0 + search || + selectedTags.length > 0 || + recentDays || + withAlerts || + createdByMe || + activeViewId ? 'No matching dashboards yet' : 'No dashboards yet' } diff --git a/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx b/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx index b0c6435625..5e7682856c 100644 --- a/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx +++ b/packages/app/src/components/Dashboards/__tests__/DashboardsListPage.test.tsx @@ -6,6 +6,9 @@ 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(); @@ -19,6 +22,9 @@ 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, @@ -51,6 +57,8 @@ jest.mock('@/config', () => ({ jest.mock('nuqs', () => ({ parseAsString: 'parseAsString', + parseAsBoolean: 'parseAsBoolean', + parseAsInteger: 'parseAsInteger', parseAsArrayOf: () => ({ withDefault: () => ({ withOptions: () => 'parseAsArrayOfString', @@ -60,6 +68,9 @@ jest.mock('nuqs', () => ({ 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()]; }, })); @@ -133,9 +144,15 @@ 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, @@ -191,6 +208,53 @@ 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', From 72ac760d18d5dfb0bc3605bdb222170140c62057 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:07:48 +0000 Subject: [PATCH 13/14] feat(dashboards): filters-first save flow with Save as view modal Active-filter row now ends with a "Save as view" button. Disabled until the user has at least one chip, pill, or quick filter active; tooltip explains why. Click opens a modal that converts the current filter state into a ListView rule array: * Each tag chip becomes a tag-includes rule. * recentDays becomes updated-within-days. * withAlerts becomes has-active-alerts. * createdByMe becomes created-by-me. * Search query is intentionally NOT persisted (transient). * Combinator is `all` (chips + pills both narrow); the advanced drawer remains the entry point for `any`. On save the modal clears the transient filter state and routes to ?view=, so the user sees the saved view applied rather than the now-duplicated raw filters. The sidebar's inline "+" primary button is gone. A kebab on the Views section header keeps the path to the advanced editor drawer (hand-written rule lists) for power users. 5 new unit tests cover buildRulesFromFilters end-to-end across every combination of inputs. Co-Authored-By: Claude Opus 4.7 --- .../Dashboards/DashboardsListPage.tsx | 44 +++++ .../ListViewsSidebar/ListViewsSidebar.tsx | 54 +++--- .../app/src/components/SaveAsViewButton.tsx | 31 ++++ .../app/src/components/SaveAsViewModal.tsx | 163 ++++++++++++++++++ .../__tests__/SaveAsViewModal.test.tsx | 65 +++++++ 5 files changed, 333 insertions(+), 24 deletions(-) create mode 100644 packages/app/src/components/SaveAsViewButton.tsx create mode 100644 packages/app/src/components/SaveAsViewModal.tsx create mode 100644 packages/app/src/components/__tests__/SaveAsViewModal.test.tsx diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index e8b861f868..8c5c72e6e0 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -47,6 +47,8 @@ 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, @@ -134,10 +136,35 @@ export default function DashboardsListPage() { 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(); @@ -584,6 +611,11 @@ export default function DashboardsListPage() { > Created by me + + {isLoading ? ( @@ -711,6 +743,18 @@ export default function DashboardsListPage() { existingView={editingView} availableTags={allTags} /> + ); } diff --git a/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx b/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx index d7d9a7507c..85596f1c85 100644 --- a/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx +++ b/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx @@ -96,15 +96,33 @@ export function ListViewsSidebar({ 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 ? ( @@ -112,21 +130,9 @@ export function ListViewsSidebar({ Loading... ) : !hasViews ? ( - // Quiet empty state: no nag copy, just a single affordance - // sized to match a row. The "+" header above is the primary - // way in; this is a fallback for users who don't notice it. - - + New View - + + Save your active filters as a view to pin it here. + ) : ( views!.map(view => ( 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' }, + ]); + }); +}); From a06531b829e3a0fe9fb73bb1c77250010f9a731e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:11:06 +0000 Subject: [PATCH 14/14] feat(dashboards): seed four system views in the sidebar above user views A new "Suggested" section in the sidebar pins four non-editable system views above the user's saved views: * My dashboards (created-by-me) * Recently updated (updated-within-days = 7) * With active alerts (has-active-alerts) * Untagged System view ids carry a `system:` prefix so they never collide with API-returned ids. The listing's lookup checks system ids first, then falls through to the user-views response. View counts on the sidebar now include the system rail in the same pass so the suggested counts stay in sync with whatever the grid is rendering. Saved-search system views drop `has-active-alerts` until PR-6 lands the saved-search alert analogue; otherwise the set matches the dashboard rail. 8 new unit tests on getDefaultListViews + isSystemViewId. Co-Authored-By: Claude Opus 4.7 --- .../Dashboards/DashboardsListPage.tsx | 26 ++++--- .../ListViewsSidebar/ListViewsSidebar.tsx | 28 +++++++- .../utils/__tests__/defaultListViews.test.ts | 52 ++++++++++++++ packages/app/src/utils/defaultListViews.ts | 71 +++++++++++++++++++ 4 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/utils/__tests__/defaultListViews.test.ts create mode 100644 packages/app/src/utils/defaultListViews.ts diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index 8c5c72e6e0..d08e4dc843 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -60,6 +60,7 @@ 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'; @@ -213,18 +214,27 @@ export default function DashboardsListPage() { return Array.from(tags).sort(); }, [dashboards]); - const activeView = useMemo( - () => listViews?.find(v => v.id === activeViewId) ?? null, - [listViews, activeViewId], - ); + 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. + // 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 || !listViews) return {}; + if (!dashboards) return {}; const result: Record = {}; - for (const view of listViews) { + const everyView = [...systemViews, ...(listViews ?? [])]; + for (const view of everyView) { result[view.id] = dashboards.filter(d => evaluateListView(view, d, { ...evalContext, @@ -233,7 +243,7 @@ export default function DashboardsListPage() { ).length; } return result; - }, [dashboards, listViews, evalContext]); + }, [dashboards, listViews, systemViews, evalContext]); const filteredDashboards = useMemo(() => { if (!dashboards) return []; diff --git a/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx b/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx index 85596f1c85..5f950d4dc4 100644 --- a/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx +++ b/packages/app/src/components/ListViewsSidebar/ListViewsSidebar.tsx @@ -14,6 +14,7 @@ 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', @@ -80,6 +81,7 @@ export function ListViewsSidebar({ ); const hasViews = (views?.length ?? 0) > 0; + const systemViews = getDefaultListViews(resource); return ( @@ -92,9 +94,33 @@ export function ListViewsSidebar({ testId="list-view-row-all" /> + + Suggested + + {systemViews.map(view => ( + onActivate(view.id === activeId ? null : view.id)} + testId={`list-view-row-${view.id}`} + /> + ))} + - Views + Your views {/* The primary "save a view" entry now lives next to the 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/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); +}