mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
[HDX-2300] introduce Shared Filters for team-wide filter visibility and discoverability (#2047)
## Summary Introduces a "Shared Filters" feature (in addition to locally pinned items in HyperDX. Especially helpful for teams with lots of filters and team members, allows users to highlight the top filters easily for all members. This has been one of the most requested features we have received from enterprise customers. \ > **Note:** - currently any user on a team can modify shared filters - we may want/need to introduce role limits to this, but that is oos ### Screenshots or video https://github.com/user-attachments/assets/9613d37c-d8d6-4aeb-9e47-1ad25532a862 ### How to test locally or on Vercel 1. Start the dev server (`yarn dev`) 2. Navigate to the Search page 3. Pin a filter field using the 📌 icon on any filter group header —you should be asked to pin (existing) or add to shared filters. 4. Share a specific value by hovering over a filter checkbox row and clicking the pin icon — it should also appear in Shared Filters 5. Reload the page — pins should persist (MongoDB-backed) 6. Open a second browser/incognito window with the same team — pins should be visible there too 7. Click the ⚙ gear icon next to "Filters" — toggle "Show Shared Filters" off/on 8. Click "Reset Shared Filters" in the gear popover to clear all team pins ### References - Linear Issue: https://linear.app/clickhouse/issue/HDX-2300/sailpoint-neara-global-filter-pinning - Related PRs: Previous WIP branch `brandon/shared-filters-ui` (superseded by this implementation)
This commit is contained in:
parent
5149fabdca
commit
5885d47964
20 changed files with 2301 additions and 419 deletions
7
.changeset/serious-chicken-hammer.md
Normal file
7
.changeset/serious-chicken-hammer.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": minor
|
||||
"@hyperdx/api": minor
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
Introduces Shared Filters, enabling teams to pin and surface common filters across all members.
|
||||
|
|
@ -13,6 +13,7 @@ import routers from './routers/api';
|
|||
import clickhouseProxyRouter from './routers/api/clickhouseProxy';
|
||||
import connectionsRouter from './routers/api/connections';
|
||||
import favoritesRouter from './routers/api/favorites';
|
||||
import pinnedFiltersRouter from './routers/api/pinnedFilters';
|
||||
import savedSearchRouter from './routers/api/savedSearch';
|
||||
import sourcesRouter from './routers/api/sources';
|
||||
import externalRoutersV2 from './routers/external-api/v2';
|
||||
|
|
@ -105,6 +106,7 @@ app.use('/connections', isUserAuthenticated, connectionsRouter);
|
|||
app.use('/sources', isUserAuthenticated, sourcesRouter);
|
||||
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
|
||||
app.use('/favorites', isUserAuthenticated, favoritesRouter);
|
||||
app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter);
|
||||
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
42
packages/api/src/controllers/pinnedFilter.ts
Normal file
42
packages/api/src/controllers/pinnedFilter.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
import type { ObjectId } from '@/models';
|
||||
import PinnedFilterModel from '@/models/pinnedFilter';
|
||||
|
||||
/**
|
||||
* Get team-level pinned filters for a team+source combination.
|
||||
*/
|
||||
export async function getPinnedFilters(
|
||||
teamId: string | ObjectId,
|
||||
sourceId: string | ObjectId,
|
||||
) {
|
||||
return PinnedFilterModel.findOne({
|
||||
team: new mongoose.Types.ObjectId(teamId),
|
||||
source: new mongoose.Types.ObjectId(sourceId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert team-level pinned filters for a team+source.
|
||||
*/
|
||||
export async function updatePinnedFilters(
|
||||
teamId: string | ObjectId,
|
||||
sourceId: string | ObjectId,
|
||||
data: { fields: string[]; filters: PinnedFiltersValue },
|
||||
) {
|
||||
const filter = {
|
||||
team: new mongoose.Types.ObjectId(teamId),
|
||||
source: new mongoose.Types.ObjectId(sourceId),
|
||||
};
|
||||
|
||||
return PinnedFilterModel.findOneAndUpdate(
|
||||
filter,
|
||||
{
|
||||
...filter,
|
||||
fields: data.fields,
|
||||
filters: data.filters,
|
||||
},
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
}
|
||||
49
packages/api/src/models/pinnedFilter.ts
Normal file
49
packages/api/src/models/pinnedFilter.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
|
||||
import mongoose, { Schema } from 'mongoose';
|
||||
|
||||
import type { ObjectId } from '.';
|
||||
|
||||
interface IPinnedFilter {
|
||||
_id: ObjectId;
|
||||
team: ObjectId;
|
||||
source: ObjectId;
|
||||
fields: string[];
|
||||
filters: PinnedFiltersValue;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const PinnedFilterSchema = new Schema<IPinnedFilter>(
|
||||
{
|
||||
team: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: 'Team',
|
||||
},
|
||||
source: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: 'Source',
|
||||
},
|
||||
fields: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
filters: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { getters: true },
|
||||
},
|
||||
);
|
||||
|
||||
// One document per team+source combination
|
||||
PinnedFilterSchema.index({ team: 1, source: 1 }, { unique: true });
|
||||
|
||||
export default mongoose.model<IPinnedFilter>(
|
||||
'PinnedFilter',
|
||||
PinnedFilterSchema,
|
||||
);
|
||||
214
packages/api/src/routers/api/__tests__/pinnedFilters.test.ts
Normal file
214
packages/api/src/routers/api/__tests__/pinnedFilters.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
import { getLoggedInAgent, getServer } from '@/fixtures';
|
||||
import { Source } from '@/models/source';
|
||||
|
||||
const MOCK_SOURCE: Omit<Extract<TSource, { kind: 'log' }>, 'id'> = {
|
||||
kind: SourceKind.Log,
|
||||
name: 'Test Source',
|
||||
connection: new Types.ObjectId().toString(),
|
||||
from: { databaseName: 'test_db', tableName: 'test_table' },
|
||||
timestampValueExpression: 'timestamp',
|
||||
defaultTableSelectExpression: 'body',
|
||||
};
|
||||
|
||||
describe('pinnedFilters router', () => {
|
||||
const server = getServer();
|
||||
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
|
||||
let team: Awaited<ReturnType<typeof getLoggedInAgent>>['team'];
|
||||
let sourceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await server.start();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await getLoggedInAgent(server);
|
||||
agent = result.agent;
|
||||
team = result.team;
|
||||
|
||||
// Create a real source owned by this team
|
||||
const source = await Source.create({ ...MOCK_SOURCE, team: team._id });
|
||||
sourceId = source._id.toString();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.clearDBs();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
describe('GET /pinned-filters', () => {
|
||||
it('returns null when no pinned filters exist', async () => {
|
||||
const res = await agent
|
||||
.get(`/pinned-filters?source=${sourceId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.team).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid source id', async () => {
|
||||
await agent.get('/pinned-filters?source=not-an-objectid').expect(400);
|
||||
});
|
||||
|
||||
it('rejects missing source param', async () => {
|
||||
await agent.get('/pinned-filters').expect(400);
|
||||
});
|
||||
|
||||
it('returns 404 for a source not owned by the team', async () => {
|
||||
const foreignSourceId = new Types.ObjectId().toString();
|
||||
await agent.get(`/pinned-filters?source=${foreignSourceId}`).expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /pinned-filters', () => {
|
||||
it('can create pinned filters', async () => {
|
||||
const res = await agent
|
||||
.put('/pinned-filters')
|
||||
.send({
|
||||
source: sourceId,
|
||||
fields: ['ServiceName', 'SeverityText'],
|
||||
filters: { ServiceName: ['web', 'api'] },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
|
||||
expect(res.body.filters).toEqual({ ServiceName: ['web', 'api'] });
|
||||
expect(res.body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('upserts on repeated PUT', async () => {
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({
|
||||
source: sourceId,
|
||||
fields: ['ServiceName'],
|
||||
filters: { ServiceName: ['web'] },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const res = await agent
|
||||
.put('/pinned-filters')
|
||||
.send({
|
||||
source: sourceId,
|
||||
fields: ['ServiceName', 'SeverityText'],
|
||||
filters: { ServiceName: ['web', 'api'], SeverityText: ['error'] },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
|
||||
expect(res.body.filters).toEqual({
|
||||
ServiceName: ['web', 'api'],
|
||||
SeverityText: ['error'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid source id', async () => {
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({ source: 'not-valid', fields: [], filters: {} })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 404 for a source not owned by the team', async () => {
|
||||
const foreignSourceId = new Types.ObjectId().toString();
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({ source: foreignSourceId, fields: [], filters: {} })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET + PUT round-trip', () => {
|
||||
it('returns data after PUT', async () => {
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({
|
||||
source: sourceId,
|
||||
fields: ['ServiceName'],
|
||||
filters: { ServiceName: ['web'] },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const res = await agent
|
||||
.get(`/pinned-filters?source=${sourceId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.team).not.toBeNull();
|
||||
expect(res.body.team.fields).toEqual(['ServiceName']);
|
||||
expect(res.body.team.filters).toEqual({ ServiceName: ['web'] });
|
||||
});
|
||||
|
||||
it('can reset by sending empty fields and filters', async () => {
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({
|
||||
source: sourceId,
|
||||
fields: ['ServiceName'],
|
||||
filters: { ServiceName: ['web'] },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({ source: sourceId, fields: [], filters: {} })
|
||||
.expect(200);
|
||||
|
||||
const res = await agent
|
||||
.get(`/pinned-filters?source=${sourceId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.team).not.toBeNull();
|
||||
expect(res.body.team.fields).toEqual([]);
|
||||
expect(res.body.team.filters).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('source scoping', () => {
|
||||
it('pins are scoped to their source', async () => {
|
||||
const source2 = await Source.create({ ...MOCK_SOURCE, team: team._id });
|
||||
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({
|
||||
source: sourceId,
|
||||
fields: ['ServiceName'],
|
||||
filters: { ServiceName: ['web'] },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const res = await agent
|
||||
.get(`/pinned-filters?source=${source2._id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.team).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Note: cross-team isolation (Team B cannot read Team A's pins) is enforced
|
||||
// by the MongoDB query filtering on teamId AND the source ownership check
|
||||
// (getSource validates source.team === teamId). Multi-team integration tests
|
||||
// are not possible in this single-team environment (register returns 409).
|
||||
|
||||
describe('filter values with booleans', () => {
|
||||
it('supports boolean values in filters', async () => {
|
||||
await agent
|
||||
.put('/pinned-filters')
|
||||
.send({
|
||||
source: sourceId,
|
||||
fields: ['isRootSpan'],
|
||||
filters: { isRootSpan: [true, false] },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const res = await agent
|
||||
.get(`/pinned-filters?source=${sourceId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.team.filters).toEqual({ isRootSpan: [true, false] });
|
||||
});
|
||||
});
|
||||
});
|
||||
95
packages/api/src/routers/api/pinnedFilters.ts
Normal file
95
packages/api/src/routers/api/pinnedFilters.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { PinnedFiltersValueSchema } from '@hyperdx/common-utils/dist/types';
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from 'zod-express-middleware';
|
||||
|
||||
import {
|
||||
getPinnedFilters,
|
||||
updatePinnedFilters,
|
||||
} from '@/controllers/pinnedFilter';
|
||||
import { getSource } from '@/controllers/sources';
|
||||
import { getNonNullUserWithTeam } from '@/middleware/auth';
|
||||
import { objectIdSchema } from '@/utils/zod';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /pinned-filters?source=<sourceId>
|
||||
* Returns the team-level pinned filters for the source.
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
validateRequest({
|
||||
query: z.object({
|
||||
source: objectIdSchema,
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { teamId } = getNonNullUserWithTeam(req);
|
||||
const { source } = req.query;
|
||||
|
||||
// Verify the source belongs to this team
|
||||
const sourceDoc = await getSource(teamId.toString(), source);
|
||||
if (!sourceDoc) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
|
||||
const doc = await getPinnedFilters(teamId.toString(), source);
|
||||
|
||||
return res.json({
|
||||
team: doc
|
||||
? {
|
||||
id: doc._id.toString(),
|
||||
fields: doc.fields,
|
||||
filters: doc.filters,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const updateBodySchema = z.object({
|
||||
source: objectIdSchema,
|
||||
fields: z.array(z.string().max(1024)).max(100),
|
||||
filters: PinnedFiltersValueSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /pinned-filters
|
||||
* Upserts team-level pinned filters for the given source.
|
||||
*/
|
||||
router.put(
|
||||
'/',
|
||||
validateRequest({ body: updateBodySchema }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { teamId } = getNonNullUserWithTeam(req);
|
||||
const { source, fields, filters } = req.body;
|
||||
|
||||
// Verify the source belongs to this team
|
||||
const sourceDoc = await getSource(teamId.toString(), source);
|
||||
if (!sourceDoc) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
|
||||
const doc = await updatePinnedFilters(teamId.toString(), source, {
|
||||
fields,
|
||||
filters,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
id: doc._id.toString(),
|
||||
fields: doc.fields,
|
||||
filters: doc.filters,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
169
packages/app/src/__tests__/pinnedFilters.test.ts
Normal file
169
packages/app/src/__tests__/pinnedFilters.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Tests for pinned filters logic:
|
||||
* - mergePinnedData (exported from searchFilters.tsx)
|
||||
* - localStorage migration logic
|
||||
*/
|
||||
|
||||
import { mergePinnedData } from '../searchFilters';
|
||||
|
||||
describe('mergePinnedData', () => {
|
||||
it('returns empty fields and filters when both are null', () => {
|
||||
const result = mergePinnedData(null, null);
|
||||
expect(result.fields).toEqual([]);
|
||||
expect(result.filters).toEqual({});
|
||||
});
|
||||
|
||||
it('returns team data when personal is null', () => {
|
||||
const team = {
|
||||
fields: ['ServiceName'],
|
||||
filters: { ServiceName: ['web', 'api'] },
|
||||
};
|
||||
const result = mergePinnedData(team, null);
|
||||
expect(result.fields).toEqual(['ServiceName']);
|
||||
expect(result.filters).toEqual({ ServiceName: ['web', 'api'] });
|
||||
});
|
||||
|
||||
it('returns personal data when team is null', () => {
|
||||
const personal = {
|
||||
fields: ['level'],
|
||||
filters: { level: ['error'] },
|
||||
};
|
||||
const result = mergePinnedData(null, personal);
|
||||
expect(result.fields).toEqual(['level']);
|
||||
expect(result.filters).toEqual({ level: ['error'] });
|
||||
});
|
||||
|
||||
it('unions fields from both team and personal', () => {
|
||||
const team = { fields: ['ServiceName', 'level'], filters: {} };
|
||||
const personal = { fields: ['level', 'host'], filters: {} };
|
||||
const result = mergePinnedData(team, personal);
|
||||
expect(result.fields).toEqual(['ServiceName', 'level', 'host']);
|
||||
});
|
||||
|
||||
it('unions filter values and deduplicates', () => {
|
||||
const team = { fields: [], filters: { ServiceName: ['web', 'api'] } };
|
||||
const personal = {
|
||||
fields: [],
|
||||
filters: { ServiceName: ['api', 'worker'] },
|
||||
};
|
||||
const result = mergePinnedData(team, personal);
|
||||
expect(result.filters.ServiceName).toEqual(['web', 'api', 'worker']);
|
||||
});
|
||||
|
||||
it('merges filter keys that only exist in one side', () => {
|
||||
const team = { fields: [], filters: { ServiceName: ['web'] } };
|
||||
const personal = { fields: [], filters: { level: ['error'] } };
|
||||
const result = mergePinnedData(team, personal);
|
||||
expect(result.filters).toEqual({
|
||||
ServiceName: ['web'],
|
||||
level: ['error'],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles boolean values in filters', () => {
|
||||
const team = { fields: [], filters: { isRootSpan: [true] } };
|
||||
const personal = { fields: [], filters: { isRootSpan: [false] } };
|
||||
const result = mergePinnedData(team, personal);
|
||||
expect(result.filters.isRootSpan).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('does not duplicate boolean values', () => {
|
||||
const team = { fields: [], filters: { isRootSpan: [true] } };
|
||||
const personal = { fields: [], filters: { isRootSpan: [true] } };
|
||||
const result = mergePinnedData(team, personal);
|
||||
expect(result.filters.isRootSpan).toEqual([true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage migration', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('reads pinned filters from localStorage correctly', () => {
|
||||
const sourceId = 'source123';
|
||||
const storedFilters = {
|
||||
[sourceId]: { ServiceName: ['web', 'api'] },
|
||||
};
|
||||
const storedFields = {
|
||||
[sourceId]: ['ServiceName', 'level'],
|
||||
};
|
||||
|
||||
window.localStorage.setItem(
|
||||
'hdx-pinned-search-filters',
|
||||
JSON.stringify(storedFilters),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
'hdx-pinned-fields',
|
||||
JSON.stringify(storedFields),
|
||||
);
|
||||
|
||||
const filtersRaw = window.localStorage.getItem('hdx-pinned-search-filters');
|
||||
const fieldsRaw = window.localStorage.getItem('hdx-pinned-fields');
|
||||
|
||||
const filters = filtersRaw ? JSON.parse(filtersRaw) : {};
|
||||
const fields = fieldsRaw ? JSON.parse(fieldsRaw) : {};
|
||||
|
||||
expect(filters[sourceId]).toEqual({ ServiceName: ['web', 'api'] });
|
||||
expect(fields[sourceId]).toEqual(['ServiceName', 'level']);
|
||||
});
|
||||
|
||||
it('handles missing localStorage keys gracefully', () => {
|
||||
const filtersRaw = window.localStorage.getItem('hdx-pinned-search-filters');
|
||||
const fieldsRaw = window.localStorage.getItem('hdx-pinned-fields');
|
||||
|
||||
expect(filtersRaw).toBeNull();
|
||||
expect(fieldsRaw).toBeNull();
|
||||
|
||||
const filters = filtersRaw ? JSON.parse(filtersRaw) : {};
|
||||
const fields = fieldsRaw ? JSON.parse(fieldsRaw) : {};
|
||||
|
||||
expect(filters).toEqual({});
|
||||
expect(fields).toEqual({});
|
||||
});
|
||||
|
||||
it('handles corrupted localStorage data gracefully', () => {
|
||||
window.localStorage.setItem('hdx-pinned-search-filters', 'not-valid-json');
|
||||
|
||||
expect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('hdx-pinned-search-filters');
|
||||
JSON.parse(raw!);
|
||||
} catch {
|
||||
// Migration should catch this and continue
|
||||
}
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('cleans up localStorage for a specific source after migration', () => {
|
||||
const sourceA = 'sourceA';
|
||||
const sourceB = 'sourceB';
|
||||
|
||||
const storedFilters = {
|
||||
[sourceA]: { ServiceName: ['web'] },
|
||||
[sourceB]: { level: ['error'] },
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
'hdx-pinned-search-filters',
|
||||
JSON.stringify(storedFilters),
|
||||
);
|
||||
|
||||
// Simulate cleanup for sourceA (as the migration would do)
|
||||
const updated: Record<string, unknown> = { ...storedFilters };
|
||||
delete updated[sourceA];
|
||||
window.localStorage.setItem(
|
||||
'hdx-pinned-search-filters',
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
const result = JSON.parse(
|
||||
window.localStorage.getItem('hdx-pinned-search-filters')!,
|
||||
);
|
||||
expect(result[sourceA]).toBeUndefined();
|
||||
expect(result[sourceB]).toEqual({ level: ['error'] });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,167 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Flex,
|
||||
Popover,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { IconSettings } from '@tabler/icons-react';
|
||||
|
||||
function SettingsPopover({
|
||||
target,
|
||||
children,
|
||||
}: {
|
||||
target: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Popover width={250} trapFocus position="right" withArrow shadow="md">
|
||||
<Popover.Target>{target}</Popover.Target>
|
||||
<Popover.Dropdown>{children}</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global filter settings gear icon — shown next to the "Filters" header.
|
||||
* Controls visibility of the shared filters section and filter counts.
|
||||
*/
|
||||
export function FilterSettingsPanel({
|
||||
isSharedFiltersVisible,
|
||||
onSharedFiltersVisibilityChange,
|
||||
showFilterCounts,
|
||||
onShowFilterCountsChange,
|
||||
hasPersonalPins,
|
||||
onResetPersonalPins,
|
||||
hasSharedPins,
|
||||
onResetSharedFilters,
|
||||
}: {
|
||||
isSharedFiltersVisible: boolean;
|
||||
onSharedFiltersVisibilityChange: (visible: boolean) => void;
|
||||
showFilterCounts: boolean;
|
||||
onShowFilterCountsChange: (show: boolean) => void;
|
||||
hasPersonalPins: boolean;
|
||||
onResetPersonalPins: VoidFunction;
|
||||
hasSharedPins: boolean;
|
||||
onResetSharedFilters: VoidFunction;
|
||||
}) {
|
||||
const showResetSection = hasPersonalPins || hasSharedPins;
|
||||
|
||||
return (
|
||||
<SettingsPopover
|
||||
target={
|
||||
<Tooltip
|
||||
label="Filter Settings"
|
||||
position="top"
|
||||
withArrow
|
||||
fz="xxs"
|
||||
color="gray"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
aria-label="Filter settings"
|
||||
>
|
||||
<IconSettings size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Flex direction="column" gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Filter Settings
|
||||
</Text>
|
||||
<Divider />
|
||||
<Checkbox
|
||||
label="Show Shared Filters"
|
||||
labelPosition="left"
|
||||
size="xs"
|
||||
styles={{ labelWrapper: { width: '100%' } }}
|
||||
checked={isSharedFiltersVisible}
|
||||
onChange={e =>
|
||||
onSharedFiltersVisibilityChange(e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Show Applied Filter Counts"
|
||||
labelPosition="left"
|
||||
size="xs"
|
||||
styles={{ labelWrapper: { width: '100%' } }}
|
||||
checked={showFilterCounts}
|
||||
onChange={e => onShowFilterCountsChange(e.currentTarget.checked)}
|
||||
/>
|
||||
{showResetSection && (
|
||||
<>
|
||||
<Divider />
|
||||
{hasPersonalPins && (
|
||||
<ResetAction
|
||||
label="Reset My Pins"
|
||||
confirmationText="This will clear your personal pinned filters."
|
||||
onReset={onResetPersonalPins}
|
||||
/>
|
||||
)}
|
||||
{hasSharedPins && (
|
||||
<ResetAction
|
||||
label="Reset Shared Filters"
|
||||
confirmationText="This will clear all shared filters for the entire team."
|
||||
onReset={onResetSharedFilters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingsPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetAction({
|
||||
label,
|
||||
confirmationText,
|
||||
onReset,
|
||||
}: {
|
||||
label: string;
|
||||
confirmationText: string;
|
||||
onReset: VoidFunction;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<Flex direction="column" gap={4}>
|
||||
<Text size="xs" c="yellow">
|
||||
{confirmationText}
|
||||
</Text>
|
||||
<Flex gap="xs">
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
onReset();
|
||||
setConfirming(false);
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="red" fw={500}>
|
||||
Confirm
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton onClick={() => setConfirming(false)}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Cancel
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton onClick={() => setConfirming(true)}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,8 +22,13 @@ type NestedFilterGroupProps = {
|
|||
onExcludeClick: (key: string, value: string | boolean) => void;
|
||||
onPinClick: (key: string, value: string | boolean) => void;
|
||||
isPinned: (key: string, value: string | boolean) => boolean;
|
||||
onSharedPinClick?: (key: string, value: string | boolean) => void;
|
||||
isSharedPinned?: (key: string, value: string | boolean) => boolean;
|
||||
onFieldPinClick?: (key: string) => void;
|
||||
isFieldPinned?: (key: string) => boolean;
|
||||
onToggleSharedFieldPin?: (key: string) => void;
|
||||
isSharedFieldPinned?: (key: string) => boolean;
|
||||
showFilterCounts?: boolean;
|
||||
onColumnToggle?: (column: string) => void;
|
||||
displayedColumns?: string[];
|
||||
onLoadMore: (key: string) => void;
|
||||
|
|
@ -48,8 +53,13 @@ export const NestedFilterGroup = ({
|
|||
onExcludeClick,
|
||||
onPinClick,
|
||||
isPinned,
|
||||
onSharedPinClick,
|
||||
isSharedPinned,
|
||||
onFieldPinClick,
|
||||
isFieldPinned,
|
||||
onToggleSharedFieldPin,
|
||||
isSharedFieldPinned,
|
||||
showFilterCounts,
|
||||
onColumnToggle,
|
||||
displayedColumns,
|
||||
onLoadMore,
|
||||
|
|
@ -121,7 +131,7 @@ export const NestedFilterGroup = ({
|
|||
color="gray"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" flex="1">
|
||||
<Text size="xs" fw="500">
|
||||
<Text size="xs" fw="500" truncate="end">
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
|
|
@ -134,8 +144,11 @@ export const NestedFilterGroup = ({
|
|||
<Accordion.Panel
|
||||
data-testid="nested-filter-group-panel"
|
||||
classNames={{
|
||||
content: 'pl-3 pt-1 pb-0',
|
||||
content: 'px-0 pb-0',
|
||||
}}
|
||||
pt={1}
|
||||
pb={0}
|
||||
pl="xs"
|
||||
>
|
||||
{isExpanded && (
|
||||
<div className={classes.filterGroupPanel}>
|
||||
|
|
@ -206,12 +219,27 @@ export const NestedFilterGroup = ({
|
|||
onExcludeClick={value =>
|
||||
onExcludeClick(child.key, value)
|
||||
}
|
||||
onPinClick={value => onPinClick(child.key, value)}
|
||||
isPinned={value => isPinned(child.key, value)}
|
||||
onFieldPinClick={() =>
|
||||
onFieldPinClick?.(child.key)
|
||||
}
|
||||
isFieldPinned={isFieldPinned?.(child.key)}
|
||||
valuePins={{
|
||||
onPinClick: value =>
|
||||
onPinClick(child.key, value),
|
||||
isPinned: value => isPinned(child.key, value),
|
||||
onSharedPinClick: onSharedPinClick
|
||||
? value => onSharedPinClick(child.key, value)
|
||||
: undefined,
|
||||
isSharedPinned: isSharedPinned
|
||||
? value => isSharedPinned(child.key, value)
|
||||
: undefined,
|
||||
}}
|
||||
fieldPins={{
|
||||
onFieldPinClick: () =>
|
||||
onFieldPinClick?.(child.key),
|
||||
isFieldPinned: isFieldPinned?.(child.key),
|
||||
onToggleSharedFieldPin: () =>
|
||||
onToggleSharedFieldPin?.(child.key),
|
||||
isSharedFieldPinned: isSharedFieldPinned?.(
|
||||
child.key,
|
||||
),
|
||||
}}
|
||||
onColumnToggle={
|
||||
onColumnToggle
|
||||
? () => onColumnToggle(child.key)
|
||||
|
|
@ -225,6 +253,7 @@ export const NestedFilterGroup = ({
|
|||
loadMoreLoading[child.key] || false
|
||||
}
|
||||
hasLoadedMore={hasLoadedMore[child.key] || false}
|
||||
showFilterCounts={showFilterCounts}
|
||||
isDefaultExpanded={childHasSelections}
|
||||
chartConfig={chartConfig}
|
||||
isLive={isLive}
|
||||
|
|
|
|||
120
packages/app/src/components/DBSearchPageFilters/PinShareMenu.tsx
Normal file
120
packages/app/src/components/DBSearchPageFilters/PinShareMenu.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { ActionIcon, Center, Menu } from '@mantine/core';
|
||||
import { IconPin, IconPinFilled, IconUsers } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Shared pin/share dropdown menu used on both value rows and group headers.
|
||||
* Shows contextual actions: "Remove from Shared" / "Pin for me" / "Share with team"
|
||||
* with the most relevant action first.
|
||||
*
|
||||
* Icon logic:
|
||||
* - sharedPinned → IconUsers (people icon)
|
||||
* - personalPinned → IconPinFilled
|
||||
* - neither → IconPin (outline)
|
||||
*/
|
||||
export function PinShareMenu({
|
||||
personalPinned,
|
||||
sharedPinned,
|
||||
onTogglePersonalPin,
|
||||
onToggleSharedPin,
|
||||
size = 14,
|
||||
onChange,
|
||||
'data-testid': dataTestId,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
personalPinned: boolean;
|
||||
sharedPinned: boolean;
|
||||
onTogglePersonalPin: VoidFunction;
|
||||
onToggleSharedPin?: VoidFunction;
|
||||
size?: number;
|
||||
onChange?: (opened: boolean) => void;
|
||||
'data-testid'?: string;
|
||||
'aria-label'?: string;
|
||||
}) {
|
||||
const isPinnedAny = personalPinned || sharedPinned;
|
||||
|
||||
// Personal pin icon takes priority over shared icon
|
||||
const triggerIcon = personalPinned ? (
|
||||
<IconPinFilled size={size} />
|
||||
) : sharedPinned ? (
|
||||
<IconUsers size={size} />
|
||||
) : (
|
||||
<IconPin size={size} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
position="right"
|
||||
withArrow
|
||||
shadow="sm"
|
||||
width={200}
|
||||
onChange={onChange}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={ariaLabel ?? (isPinnedAny ? 'Unpin' : 'Pin')}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{triggerIcon}
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{onToggleSharedPin && sharedPinned && (
|
||||
<Menu.Item
|
||||
leftSection={<IconUsers size={14} />}
|
||||
onClick={onToggleSharedPin}
|
||||
fz="xs"
|
||||
>
|
||||
Remove from Shared
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
personalPinned ? <IconPinFilled size={14} /> : <IconPin size={14} />
|
||||
}
|
||||
onClick={onTogglePersonalPin}
|
||||
fz="xs"
|
||||
>
|
||||
{personalPinned ? 'Unpin for me' : 'Pin for me'}
|
||||
</Menu.Item>
|
||||
{onToggleSharedPin && !sharedPinned && (
|
||||
<Menu.Item
|
||||
leftSection={<IconUsers size={14} />}
|
||||
onClick={onToggleSharedPin}
|
||||
fz="xs"
|
||||
>
|
||||
Share with team
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small indicator icon shown persistently on pinned/shared values.
|
||||
*/
|
||||
export function PinShareIndicator({
|
||||
personalPinned,
|
||||
sharedPinned,
|
||||
'data-testid': dataTestId,
|
||||
}: {
|
||||
personalPinned: boolean;
|
||||
sharedPinned: boolean;
|
||||
'data-testid'?: string;
|
||||
}) {
|
||||
if (!personalPinned && !sharedPinned) return null;
|
||||
|
||||
// Personal pin icon takes priority over shared icon
|
||||
return (
|
||||
<Center me="1px">
|
||||
{personalPinned ? (
|
||||
<IconPinFilled size={12} data-testid={dataTestId} />
|
||||
) : (
|
||||
<IconUsers size={12} data-testid={dataTestId} />
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { memo, type ReactNode } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronDown, IconFilterOff, IconUsers } from '@tabler/icons-react';
|
||||
|
||||
interface SharedFiltersSectionProps {
|
||||
/** Whether there are any shared facets to display */
|
||||
hasSharedFacets: boolean;
|
||||
/** Whether the section is expanded */
|
||||
opened: boolean;
|
||||
/** Toggle the section open/closed */
|
||||
onToggle: VoidFunction;
|
||||
/** Whether any shared facets have active filter selections */
|
||||
showClearButton: boolean;
|
||||
/** Callback to clear all shared filter selections */
|
||||
onClearSelections: VoidFunction;
|
||||
/** Pre-rendered FilterGroup components for shared/pinned facets */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible "Shared Filters" section header.
|
||||
* Wraps pre-rendered FilterGroup components passed as children.
|
||||
* This avoids duplicating FilterGroup logic — the parent renders real
|
||||
* FilterGroup components with full distribution/load-more support.
|
||||
*/
|
||||
function SharedFiltersSectionComponent({
|
||||
hasSharedFacets,
|
||||
opened,
|
||||
onToggle,
|
||||
showClearButton,
|
||||
onClearSelections,
|
||||
children,
|
||||
}: SharedFiltersSectionProps) {
|
||||
if (!hasSharedFacets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" data-testid="shared-filters-section">
|
||||
<Flex align="center" justify="space-between">
|
||||
<UnstyledButton
|
||||
onClick={onToggle}
|
||||
data-testid="shared-filters-toggle"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Group gap={4}>
|
||||
<IconUsers
|
||||
size={12}
|
||||
style={{ color: 'var(--mantine-color-gray-6)' }}
|
||||
/>
|
||||
<Text size="xxs" c="dimmed" fw="bold">
|
||||
Shared Filters
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Group gap={0} wrap="nowrap">
|
||||
{showClearButton && (
|
||||
<Tooltip
|
||||
label="Clear Shared Filters"
|
||||
position="top"
|
||||
withArrow
|
||||
fz="xxs"
|
||||
color="gray"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={onClearSelections}
|
||||
aria-label="Clear Shared Filters"
|
||||
>
|
||||
<IconFilterOff size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<UnstyledButton onClick={onToggle}>
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
transform: opened ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
}}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Flex>
|
||||
<Collapse expanded={opened}>
|
||||
<Stack gap={8}>{children}</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const SharedFiltersSection = memo(SharedFiltersSectionComponent);
|
||||
|
|
@ -389,8 +389,10 @@ describe('FilterGroup', () => {
|
|||
onClearClick: jest.fn(),
|
||||
onOnlyClick: jest.fn(),
|
||||
onExcludeClick: jest.fn(),
|
||||
onPinClick: jest.fn(),
|
||||
isPinned: jest.fn(),
|
||||
valuePins: {
|
||||
onPinClick: jest.fn(),
|
||||
isPinned: jest.fn().mockReturnValue(false),
|
||||
},
|
||||
onLoadMore: jest.fn(),
|
||||
loadMoreLoading: false,
|
||||
hasLoadedMore: false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import objectHash from 'object-hash';
|
||||
import store from 'store2';
|
||||
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
SavedSearchListApiResponse,
|
||||
TSource,
|
||||
|
|
@ -111,3 +112,14 @@ export const localSources = createEntityStore<TSource>(
|
|||
export const localSavedSearches = createEntityStore<SavedSearchListApiResponse>(
|
||||
'hdx-local-saved-searches',
|
||||
);
|
||||
|
||||
/** Pinned filters store for local mode. */
|
||||
type LocalPinnedFilter = {
|
||||
id: string;
|
||||
source: string;
|
||||
fields: string[];
|
||||
filters: PinnedFiltersValue;
|
||||
};
|
||||
export const localPinnedFilters = createEntityStore<LocalPinnedFilter>(
|
||||
'hdx-local-pinned-filters',
|
||||
);
|
||||
|
|
|
|||
75
packages/app/src/pinnedFilters.ts
Normal file
75
packages/app/src/pinnedFilters.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { hdxServer } from './api';
|
||||
import { IS_LOCAL_MODE } from './config';
|
||||
import { localPinnedFilters } from './localStore';
|
||||
|
||||
type PinnedFiltersApiResponse = {
|
||||
team: { id: string; fields: string[]; filters: PinnedFiltersValue } | null;
|
||||
};
|
||||
|
||||
function pinnedFiltersQueryKey(sourceId: string | null) {
|
||||
return ['pinned-filters', sourceId];
|
||||
}
|
||||
|
||||
async function fetchPinnedFilters(
|
||||
sourceId: string,
|
||||
): Promise<PinnedFiltersApiResponse> {
|
||||
if (IS_LOCAL_MODE) {
|
||||
const stored = localPinnedFilters.getAll();
|
||||
const entry = stored.find(s => s.source === sourceId) ?? null;
|
||||
return {
|
||||
team: entry
|
||||
? { id: entry.id, fields: entry.fields, filters: entry.filters }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return hdxServer(`pinned-filters?source=${sourceId}`).json();
|
||||
}
|
||||
|
||||
export function usePinnedFiltersApi(sourceId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: pinnedFiltersQueryKey(sourceId),
|
||||
queryFn: () => fetchPinnedFilters(sourceId!),
|
||||
enabled: sourceId != null,
|
||||
});
|
||||
}
|
||||
|
||||
type UpdatePinnedFiltersInput = {
|
||||
source: string;
|
||||
fields: string[];
|
||||
filters: PinnedFiltersValue;
|
||||
};
|
||||
|
||||
export function useUpdatePinnedFilters() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdatePinnedFiltersInput) => {
|
||||
if (IS_LOCAL_MODE) {
|
||||
const stored = localPinnedFilters.getAll();
|
||||
const existing = stored.find(s => s.source === data.source);
|
||||
if (existing) {
|
||||
return Promise.resolve(
|
||||
localPinnedFilters.update(existing.id, {
|
||||
fields: data.fields,
|
||||
filters: data.filters,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(localPinnedFilters.create(data));
|
||||
}
|
||||
|
||||
return hdxServer('pinned-filters', {
|
||||
method: 'PUT',
|
||||
json: data,
|
||||
}).json();
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pinnedFiltersQueryKey(variables.source),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import produce from 'immer';
|
||||
import type { Filter } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
import { usePinnedFiltersApi, useUpdatePinnedFilters } from './pinnedFilters';
|
||||
import { useLocalStorage } from './utils';
|
||||
|
||||
export const IS_ROOT_SPAN_COLUMN_NAME = 'isRootSpan';
|
||||
|
|
@ -404,7 +405,7 @@ export const useSearchPageFilterState = ({
|
|||
searchQuery?: Filter[];
|
||||
onFilterChange: (filters: Filter[]) => void;
|
||||
}) => {
|
||||
const parsedQuery = React.useMemo(() => {
|
||||
const parsedQuery = useMemo(() => {
|
||||
try {
|
||||
return parseQuery(searchQuery);
|
||||
} catch (e) {
|
||||
|
|
@ -413,9 +414,9 @@ export const useSearchPageFilterState = ({
|
|||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
const [filters, setFilters] = React.useState<FilterState>({});
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!areFiltersEqual(filters, parsedQuery.filters)) {
|
||||
setFilters(parsedQuery.filters);
|
||||
}
|
||||
|
|
@ -423,14 +424,14 @@ export const useSearchPageFilterState = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parsedQuery.filters]);
|
||||
|
||||
const updateFilterQuery = React.useCallback(
|
||||
const updateFilterQuery = useCallback(
|
||||
(newFilters: FilterState) => {
|
||||
onFilterChange(filtersToQuery(newFilters));
|
||||
},
|
||||
[onFilterChange],
|
||||
);
|
||||
|
||||
const setFilterValue = React.useCallback(
|
||||
const setFilterValue = useCallback(
|
||||
(
|
||||
property: string,
|
||||
value: string | boolean,
|
||||
|
|
@ -477,7 +478,7 @@ export const useSearchPageFilterState = ({
|
|||
[updateFilterQuery],
|
||||
);
|
||||
|
||||
const setFilterRange = React.useCallback(
|
||||
const setFilterRange = useCallback(
|
||||
(property: string, range: { min: number; max: number }) => {
|
||||
setFilters(prevFilters => {
|
||||
const newFilters = produce(prevFilters, draft => {
|
||||
|
|
@ -493,7 +494,7 @@ export const useSearchPageFilterState = ({
|
|||
[updateFilterQuery],
|
||||
);
|
||||
|
||||
const clearFilter = React.useCallback(
|
||||
const clearFilter = useCallback(
|
||||
(property: string) => {
|
||||
setFilters(prevFilters => {
|
||||
const newFilters = produce(prevFilters, draft => {
|
||||
|
|
@ -506,7 +507,7 @@ export const useSearchPageFilterState = ({
|
|||
[updateFilterQuery],
|
||||
);
|
||||
|
||||
const clearAllFilters = React.useCallback(() => {
|
||||
const clearAllFilters = useCallback(() => {
|
||||
setFilters(() => ({}));
|
||||
updateFilterQuery({});
|
||||
}, [updateFilterQuery]);
|
||||
|
|
@ -527,129 +528,325 @@ type PinnedFilters = {
|
|||
|
||||
export type FilterStateHook = ReturnType<typeof useSearchPageFilterState>;
|
||||
|
||||
function usePinnedFilterBySource(sourceId: string | null) {
|
||||
// Keep the original structure for backwards compatibility
|
||||
/**
|
||||
* Merge team-level and personal pinned filter data into a single view.
|
||||
* Fields and filter values are unioned (deduplicated).
|
||||
*/
|
||||
export function mergePinnedData(
|
||||
team: { fields: string[]; filters: PinnedFilters } | null,
|
||||
personal: { fields: string[]; filters: PinnedFilters } | null,
|
||||
): { fields: string[]; filters: PinnedFilters } {
|
||||
const teamFields = team?.fields ?? [];
|
||||
const personalFields = personal?.fields ?? [];
|
||||
const fields = [...new Set([...teamFields, ...personalFields])];
|
||||
|
||||
const teamFilters = team?.filters ?? {};
|
||||
const personalFilters = personal?.filters ?? {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(teamFilters),
|
||||
...Object.keys(personalFilters),
|
||||
]);
|
||||
|
||||
const filters: PinnedFilters = {};
|
||||
for (const key of allKeys) {
|
||||
const teamVals = teamFilters[key] ?? [];
|
||||
const personalVals = personalFilters[key] ?? [];
|
||||
const merged = [...teamVals];
|
||||
for (const v of personalVals) {
|
||||
if (!merged.some(existing => existing === v)) {
|
||||
merged.push(v);
|
||||
}
|
||||
}
|
||||
filters[key] = merged;
|
||||
}
|
||||
|
||||
return { fields, filters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a value in a PinnedFilters map. Returns a new map with the value
|
||||
* added or removed under the given property key.
|
||||
*/
|
||||
function toggleValueInFilters(
|
||||
filters: PinnedFilters,
|
||||
property: string,
|
||||
value: string | boolean,
|
||||
): PinnedFilters {
|
||||
const updated = { ...filters };
|
||||
if (!updated[property]) {
|
||||
updated[property] = [];
|
||||
}
|
||||
const idx = updated[property].findIndex((v: string | boolean) => v === value);
|
||||
if (idx >= 0) {
|
||||
updated[property] = updated[property].filter(
|
||||
(_: string | boolean, i: number) => i !== idx,
|
||||
);
|
||||
if (updated[property].length === 0) {
|
||||
delete updated[property];
|
||||
}
|
||||
} else {
|
||||
updated[property] = [...updated[property], value];
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for personal pinned filters stored in localStorage.
|
||||
* This is the original storage mechanism — per-user, per-browser.
|
||||
*/
|
||||
function usePersonalPinnedFilters(sourceId: string | null) {
|
||||
const [_pinnedFilters, _setPinnedFilters] = useLocalStorage<{
|
||||
[sourceId: string]: PinnedFilters;
|
||||
}>('hdx-pinned-search-filters', {});
|
||||
|
||||
// Separate storage for pinned fields
|
||||
const [_pinnedFields, _setPinnedFields] = useLocalStorage<{
|
||||
[sourceId: string]: string[];
|
||||
}>('hdx-pinned-fields', {});
|
||||
|
||||
const pinnedFilters = React.useMemo<PinnedFilters>(
|
||||
const filters = useMemo<PinnedFilters>(
|
||||
() =>
|
||||
!sourceId || !_pinnedFilters[sourceId] ? {} : _pinnedFilters[sourceId],
|
||||
[_pinnedFilters, sourceId],
|
||||
);
|
||||
|
||||
const pinnedFields = React.useMemo<string[]>(
|
||||
const fields = useMemo<string[]>(
|
||||
() =>
|
||||
!sourceId || !_pinnedFields[sourceId] ? [] : _pinnedFields[sourceId],
|
||||
[_pinnedFields, sourceId],
|
||||
);
|
||||
|
||||
const setPinnedFilters = React.useCallback<
|
||||
(val: PinnedFilters | ((pf: PinnedFilters) => PinnedFilters)) => void
|
||||
>(
|
||||
val => {
|
||||
const setFilters = useCallback(
|
||||
(val: PinnedFilters | ((pf: PinnedFilters) => PinnedFilters)) => {
|
||||
if (!sourceId) return;
|
||||
_setPinnedFilters(prev =>
|
||||
produce(prev, draft => {
|
||||
draft[sourceId] =
|
||||
val instanceof Function ? val(draft[sourceId] ?? {}) : val;
|
||||
}),
|
||||
);
|
||||
_setPinnedFilters(prev => {
|
||||
const updated = { ...prev };
|
||||
updated[sourceId] =
|
||||
val instanceof Function ? val(prev[sourceId] ?? {}) : val;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[sourceId, _setPinnedFilters],
|
||||
);
|
||||
|
||||
const setPinnedFields = React.useCallback<
|
||||
(val: string[] | ((pf: string[]) => string[])) => void
|
||||
>(
|
||||
val => {
|
||||
const setFields = useCallback(
|
||||
(val: string[] | ((pf: string[]) => string[])) => {
|
||||
if (!sourceId) return;
|
||||
_setPinnedFields(prev =>
|
||||
produce(prev, draft => {
|
||||
draft[sourceId] =
|
||||
val instanceof Function ? val(draft[sourceId] ?? []) : val;
|
||||
}),
|
||||
);
|
||||
_setPinnedFields(prev => {
|
||||
const updated = { ...prev };
|
||||
updated[sourceId] =
|
||||
val instanceof Function ? val(prev[sourceId] ?? []) : val;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[sourceId, _setPinnedFields],
|
||||
);
|
||||
|
||||
return { pinnedFilters, setPinnedFilters, pinnedFields, setPinnedFields };
|
||||
return { filters, fields, setFilters, setFields };
|
||||
}
|
||||
|
||||
export function usePinnedFilters(sourceId: string | null) {
|
||||
const { pinnedFilters, setPinnedFilters, pinnedFields, setPinnedFields } =
|
||||
usePinnedFilterBySource(sourceId);
|
||||
// Personal pins: localStorage (per-user, per-browser)
|
||||
const personal = usePersonalPinnedFilters(sourceId);
|
||||
|
||||
const toggleFilterPin = React.useCallback(
|
||||
// Team/shared pins: MongoDB via API (shared across team)
|
||||
const { data: teamApiData } = usePinnedFiltersApi(sourceId);
|
||||
const updateTeamMutation = useUpdatePinnedFilters();
|
||||
|
||||
// Optimistic state keyed by sourceId so it is automatically ignored when
|
||||
// the source changes — no useEffect needed to clear stale state.
|
||||
const [optimisticTeam, setOptimisticTeam] = useState<{
|
||||
sourceId: string;
|
||||
fields: string[];
|
||||
filters: PinnedFilters;
|
||||
} | null>(null);
|
||||
|
||||
const effectiveTeam = useMemo(
|
||||
() =>
|
||||
optimisticTeam?.sourceId === sourceId
|
||||
? { fields: optimisticTeam.fields, filters: optimisticTeam.filters }
|
||||
: {
|
||||
fields: teamApiData?.team?.fields ?? [],
|
||||
filters: teamApiData?.team?.filters ?? {},
|
||||
},
|
||||
[optimisticTeam, sourceId, teamApiData],
|
||||
);
|
||||
|
||||
// Merge team + personal into a unified view for read operations
|
||||
const { fields: pinnedFields, filters: pinnedFilters } = useMemo(
|
||||
() =>
|
||||
mergePinnedData(effectiveTeam, {
|
||||
fields: personal.fields,
|
||||
filters: personal.filters,
|
||||
}),
|
||||
[effectiveTeam, personal.fields, personal.filters],
|
||||
);
|
||||
|
||||
// Debounce for team API writes — cancelled on unmount to prevent stale writes.
|
||||
const pendingTeamUpdateRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingTeamUpdateRef.current) {
|
||||
clearTimeout(pendingTeamUpdateRef.current);
|
||||
pendingTeamUpdateRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flushTeamUpdate = useCallback(
|
||||
(newFields: string[], newFilters: PinnedFilters) => {
|
||||
if (!sourceId) return;
|
||||
|
||||
setOptimisticTeam({ sourceId, fields: newFields, filters: newFilters });
|
||||
|
||||
if (pendingTeamUpdateRef.current) {
|
||||
clearTimeout(pendingTeamUpdateRef.current);
|
||||
}
|
||||
pendingTeamUpdateRef.current = setTimeout(() => {
|
||||
updateTeamMutation.mutate(
|
||||
{
|
||||
source: sourceId,
|
||||
fields: newFields,
|
||||
filters: newFilters,
|
||||
},
|
||||
{
|
||||
onSettled: () => setOptimisticTeam(null),
|
||||
},
|
||||
);
|
||||
pendingTeamUpdateRef.current = null;
|
||||
}, 300);
|
||||
},
|
||||
[sourceId, updateTeamMutation],
|
||||
);
|
||||
|
||||
// Personal pin: value-level pin (localStorage, instant)
|
||||
const toggleFilterPin = useCallback(
|
||||
(property: string, value: string | boolean) => {
|
||||
setPinnedFilters(prevFilters =>
|
||||
produce(prevFilters, draft => {
|
||||
if (!draft[property]) {
|
||||
draft[property] = [];
|
||||
}
|
||||
const idx = draft[property].findIndex(v => v === value);
|
||||
if (idx >= 0) {
|
||||
draft[property].splice(idx, 1);
|
||||
} else {
|
||||
draft[property].push(value);
|
||||
}
|
||||
return draft;
|
||||
}),
|
||||
);
|
||||
|
||||
personal.setFilters(prev => toggleValueInFilters(prev, property, value));
|
||||
// When pinning a value, also pin the field if not already pinned
|
||||
setPinnedFields(prevFields => {
|
||||
if (!prevFields.includes(property)) {
|
||||
return [...prevFields, property];
|
||||
}
|
||||
return prevFields;
|
||||
});
|
||||
personal.setFields(prev =>
|
||||
prev.includes(property) ? prev : [...prev, property],
|
||||
);
|
||||
},
|
||||
[setPinnedFilters, setPinnedFields],
|
||||
[personal],
|
||||
);
|
||||
|
||||
const toggleFieldPin = React.useCallback(
|
||||
// Personal pin: field-level pin (localStorage, instant)
|
||||
const toggleFieldPin = useCallback(
|
||||
(field: string) => {
|
||||
setPinnedFields(prevFields => {
|
||||
const fieldIndex = prevFields.findIndex(f => f === field);
|
||||
if (fieldIndex >= 0) {
|
||||
return prevFields.filter((_, i) => i !== fieldIndex);
|
||||
} else {
|
||||
return [...prevFields, field];
|
||||
}
|
||||
personal.setFields(prev => {
|
||||
const idx = prev.indexOf(field);
|
||||
return idx >= 0 ? prev.filter((_, i) => i !== idx) : [...prev, field];
|
||||
});
|
||||
},
|
||||
[setPinnedFields],
|
||||
[personal],
|
||||
);
|
||||
|
||||
const isFilterPinned = React.useCallback(
|
||||
// Personal-only checks (not merged) — so team pins don't show as personal
|
||||
const isFilterPinned = useCallback(
|
||||
(property: string, value: string | boolean): boolean => {
|
||||
return (
|
||||
pinnedFilters[property] &&
|
||||
pinnedFilters[property].some(v => v === value)
|
||||
personal.filters[property] != null &&
|
||||
personal.filters[property].some((v: string | boolean) => v === value)
|
||||
);
|
||||
},
|
||||
[pinnedFilters],
|
||||
[personal.filters],
|
||||
);
|
||||
|
||||
const isFieldPinned = React.useCallback(
|
||||
const isFieldPinned = useCallback(
|
||||
(field: string): boolean => {
|
||||
return pinnedFields.includes(field);
|
||||
return personal.fields.includes(field);
|
||||
},
|
||||
[pinnedFields],
|
||||
[personal.fields],
|
||||
);
|
||||
|
||||
const getPinnedFields = React.useCallback((): string[] => {
|
||||
// Merged view for getPinnedFields (used for sorting and always-fetch logic)
|
||||
const getPinnedFields = useCallback((): string[] => {
|
||||
return pinnedFields;
|
||||
}, [pinnedFields]);
|
||||
|
||||
// Team pin: field-level (MongoDB via API, debounced)
|
||||
const toggleSharedFieldPin = useCallback(
|
||||
(field: string) => {
|
||||
const currentFields = [...effectiveTeam.fields];
|
||||
const currentFilters = { ...effectiveTeam.filters };
|
||||
const fieldIndex = currentFields.indexOf(field);
|
||||
|
||||
if (fieldIndex >= 0) {
|
||||
// Removing field from shared — also clean up its filter values
|
||||
const newFields = currentFields.filter((_, i) => i !== fieldIndex);
|
||||
delete currentFilters[field];
|
||||
flushTeamUpdate(newFields, currentFilters);
|
||||
} else {
|
||||
// Adding field to shared
|
||||
flushTeamUpdate([...currentFields, field], currentFilters);
|
||||
}
|
||||
},
|
||||
[effectiveTeam, flushTeamUpdate],
|
||||
);
|
||||
|
||||
const isSharedFieldPinned = useCallback(
|
||||
(field: string): boolean => {
|
||||
// A field is shared if it's in the fields list OR has shared filter values
|
||||
return (
|
||||
effectiveTeam.fields.includes(field) ||
|
||||
(effectiveTeam.filters[field] != null &&
|
||||
effectiveTeam.filters[field].length > 0)
|
||||
);
|
||||
},
|
||||
[effectiveTeam],
|
||||
);
|
||||
|
||||
// Team pin: value-level (MongoDB via API, debounced)
|
||||
const toggleSharedFilterPin = useCallback(
|
||||
(property: string, value: string | boolean) => {
|
||||
const newFilters = toggleValueInFilters(
|
||||
effectiveTeam.filters,
|
||||
property,
|
||||
value,
|
||||
);
|
||||
// When sharing a value, also add the field to shared fields
|
||||
const newFields = effectiveTeam.fields.includes(property)
|
||||
? effectiveTeam.fields
|
||||
: [...effectiveTeam.fields, property];
|
||||
|
||||
flushTeamUpdate(newFields, newFilters);
|
||||
},
|
||||
[effectiveTeam, flushTeamUpdate],
|
||||
);
|
||||
|
||||
const isSharedFilterPinned = useCallback(
|
||||
(property: string, value: string | boolean): boolean => {
|
||||
const vals = effectiveTeam.filters[property];
|
||||
return vals != null && vals.some(v => v === value);
|
||||
},
|
||||
[effectiveTeam],
|
||||
);
|
||||
|
||||
const resetPersonalPins = useCallback(() => {
|
||||
personal.setFields(() => []);
|
||||
personal.setFilters(() => ({}));
|
||||
}, [personal]);
|
||||
|
||||
const resetSharedFilters = useCallback(() => {
|
||||
flushTeamUpdate([], {});
|
||||
}, [flushTeamUpdate]);
|
||||
|
||||
const hasPersonalPins = useMemo(
|
||||
() =>
|
||||
personal.fields.length > 0 || Object.keys(personal.filters).length > 0,
|
||||
[personal.fields, personal.filters],
|
||||
);
|
||||
|
||||
const hasSharedPins = useMemo(
|
||||
() =>
|
||||
effectiveTeam.fields.length > 0 ||
|
||||
Object.keys(effectiveTeam.filters).length > 0,
|
||||
[effectiveTeam],
|
||||
);
|
||||
|
||||
return {
|
||||
toggleFilterPin,
|
||||
toggleFieldPin,
|
||||
|
|
@ -657,5 +854,13 @@ export function usePinnedFilters(sourceId: string | null) {
|
|||
isFieldPinned,
|
||||
getPinnedFields,
|
||||
pinnedFilters,
|
||||
toggleSharedFieldPin,
|
||||
isSharedFieldPinned,
|
||||
toggleSharedFilterPin,
|
||||
isSharedFilterPinned,
|
||||
resetPersonalPins,
|
||||
resetSharedFilters,
|
||||
hasPersonalPins,
|
||||
hasSharedPins,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ export class FilterComponent {
|
|||
);
|
||||
await locator.hover();
|
||||
|
||||
// The action buttons live in a CSS-hover-revealed overlay (display:none → flex).
|
||||
// Use dispatchEvent so we don't depend on the hover state still being active
|
||||
// at the moment Playwright attempts the click.
|
||||
const button = locator.getByTestId(testId);
|
||||
await button.waitFor({ state: 'visible' });
|
||||
await button.click();
|
||||
await button.waitFor({ state: 'attached' });
|
||||
await button.dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -86,7 +89,8 @@ export class FilterComponent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Pin a filter value to persist it
|
||||
* Pin a filter value personally (localStorage).
|
||||
* Opens the PinShareMenu dropdown and clicks "Pin for me".
|
||||
*/
|
||||
async pinFilter(columnName: string, valueName: string) {
|
||||
const filterCheckbox = this.getFilterCheckbox(columnName, valueName);
|
||||
|
|
@ -94,6 +98,10 @@ export class FilterComponent {
|
|||
filterCheckbox,
|
||||
`filter-checkbox-${columnName}-${valueName}-pin`,
|
||||
);
|
||||
// PinShareMenu opens a dropdown — click "Pin for me"
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: 'Pin for me' })
|
||||
.click({ timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -258,4 +266,67 @@ export class FilterComponent {
|
|||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
// ---- Shared Filters ----
|
||||
|
||||
/**
|
||||
* Get the shared filters section container
|
||||
*/
|
||||
getSharedFiltersSection() {
|
||||
return this.page.getByTestId('shared-filters-section');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the shared filters section is visible
|
||||
*/
|
||||
async isSharedFiltersSectionVisible(): Promise<boolean> {
|
||||
return this.getSharedFiltersSection()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a field (group-level pin, not a value pin).
|
||||
* Opens the PinShareMenu on the filter group header and clicks "Pin for me".
|
||||
*/
|
||||
async pinField(filterName: string) {
|
||||
const group = this.getFilterGroup(filterName);
|
||||
await group.hover();
|
||||
// The pin button is the PinShareMenu trigger inside the filter group header
|
||||
const pinButton = group.locator('button[aria-label="Pin"]').first();
|
||||
await pinButton.click();
|
||||
// Click "Pin for me" in the dropdown menu
|
||||
await this.page.getByRole('menuitem', { name: 'Pin for me' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a field with the team via the PinShareMenu dropdown.
|
||||
*/
|
||||
async shareFieldWithTeam(filterName: string) {
|
||||
const group = this.getFilterGroup(filterName);
|
||||
await group.hover();
|
||||
const pinButton = group.locator('button[aria-label="Pin"]').first();
|
||||
await pinButton.click();
|
||||
await this.page.getByRole('menuitem', { name: 'Share with team' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshare a field from the team via the PinShareMenu dropdown.
|
||||
* Looks in the Shared Filters section since shared fields are moved there.
|
||||
*/
|
||||
async unshareField(filterName: string) {
|
||||
// Shared fields live in the Shared Filters section, not the regular list
|
||||
const sharedSection = this.getSharedFiltersSection();
|
||||
const group = sharedSection.getByTestId(
|
||||
`shared-filter-group-${filterName}`,
|
||||
);
|
||||
await group.hover();
|
||||
const pinButton = group
|
||||
.locator('button[aria-label="Unpin"], button[aria-label="Pin"]')
|
||||
.first();
|
||||
await pinButton.click();
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: 'Remove from Shared' })
|
||||
.click();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,21 +69,26 @@ test.describe('Search Filters', { tag: ['@search'] }, () => {
|
|||
test('Should pin filter and verify it persists after reload', async () => {
|
||||
await searchPage.filters.pinFilter(TEST_FILTER_GROUP, TEST_FILTER_VALUE);
|
||||
|
||||
// Reload page and verify filter persists
|
||||
// Reload page and wait for search results to populate
|
||||
await searchPage.page.reload();
|
||||
await searchPage.table.waitForRowsToPopulate();
|
||||
|
||||
// Verify filter checkbox is still visible
|
||||
// After reload the pinned field should auto-expand; open it explicitly
|
||||
// in case it hasn't expanded yet (handles slower CI environments).
|
||||
await searchPage.filters.openFilterGroup(TEST_FILTER_GROUP);
|
||||
|
||||
// Verify filter checkbox is still visible with a generous timeout for CI
|
||||
const filterCheckbox = searchPage.filters.getFilterCheckbox(
|
||||
TEST_FILTER_GROUP,
|
||||
TEST_FILTER_VALUE,
|
||||
);
|
||||
await expect(filterCheckbox).toBeVisible();
|
||||
await expect(filterCheckbox).toBeVisible({ timeout: 15000 });
|
||||
|
||||
//verify there is a pin icon
|
||||
// Verify there is a pin icon showing the value is still pinned
|
||||
const pinIcon = searchPage.page.getByTestId(
|
||||
`filter-checkbox-${TEST_FILTER_GROUP}-${TEST_FILTER_VALUE}-pin-pinned`,
|
||||
);
|
||||
await expect(pinIcon).toBeVisible();
|
||||
await expect(pinIcon).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
// TODO: Implement these tests following the same pattern
|
||||
|
|
|
|||
184
packages/app/tests/e2e/features/search/shared-filters.spec.ts
Normal file
184
packages/app/tests/e2e/features/search/shared-filters.spec.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { SearchPage } from '../../page-objects/SearchPage';
|
||||
import { getApiUrl, getSources } from '../../utils/api-helpers';
|
||||
import { expect, test } from '../../utils/base-test';
|
||||
|
||||
// Serial: all tests write to the same MongoDB document (team pinned filters).
|
||||
// Parallel execution causes beforeEach resets to race with ongoing mutations.
|
||||
test.describe.serial(
|
||||
'Shared Filters',
|
||||
{ tag: ['@search', '@full-stack'] },
|
||||
() => {
|
||||
let searchPage: SearchPage;
|
||||
const TEST_FILTER_GROUP = 'SeverityText';
|
||||
const TEST_FILTER_VALUE = 'info';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
searchPage = new SearchPage(page);
|
||||
|
||||
// Navigate to a neutral page first so TanStack Query cache is not
|
||||
// carrying stale data from any previous test.
|
||||
await page.goto('/');
|
||||
const sources = await getSources(page, 'log');
|
||||
const sourceId = sources[0]._id;
|
||||
|
||||
// Reset team pinned filters via API
|
||||
await page.request.put(`${getApiUrl()}/pinned-filters`, {
|
||||
data: { source: sourceId, fields: [], filters: {} },
|
||||
});
|
||||
|
||||
await searchPage.goto();
|
||||
|
||||
// Confirm the Shared Filters section is hidden before proceeding
|
||||
await expect(searchPage.filters.getSharedFiltersSection()).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await searchPage.filters.openFilterGroup(TEST_FILTER_GROUP);
|
||||
});
|
||||
|
||||
test('Shared filters section does not appear when no filters are pinned', async () => {
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeHidden();
|
||||
});
|
||||
|
||||
test('Sharing a field with team shows it in the shared filters section', async () => {
|
||||
await searchPage.filters.shareFieldWithTeam(TEST_FILTER_GROUP);
|
||||
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeVisible();
|
||||
await expect(sharedSection).toContainText(TEST_FILTER_GROUP);
|
||||
});
|
||||
|
||||
test('Shared field persists after page reload', async ({ page }) => {
|
||||
await searchPage.filters.shareFieldWithTeam(TEST_FILTER_GROUP);
|
||||
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeVisible();
|
||||
await expect(sharedSection).toContainText(TEST_FILTER_GROUP);
|
||||
|
||||
// Poll the API until the debounced write has landed on the server
|
||||
const sources = await getSources(page, 'log');
|
||||
const sourceId = sources[0]._id;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const resp = await page.request.get(
|
||||
`${getApiUrl()}/pinned-filters?source=${sourceId}`,
|
||||
);
|
||||
const data = await resp.json();
|
||||
return data?.team?.fields?.length ?? 0;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Reload and wait for page to load
|
||||
await searchPage.goto();
|
||||
|
||||
// Shared filters section should still be visible with the pinned field
|
||||
const sharedSectionAfterReload =
|
||||
searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSectionAfterReload).toBeVisible({ timeout: 15000 });
|
||||
await expect(sharedSectionAfterReload).toContainText(TEST_FILTER_GROUP);
|
||||
});
|
||||
|
||||
test('Shared field is removed from the main filters list', async () => {
|
||||
await searchPage.filters.shareFieldWithTeam(TEST_FILTER_GROUP);
|
||||
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeVisible();
|
||||
|
||||
// The field should NOT appear in the main filters list below
|
||||
const mainFilterGroup =
|
||||
searchPage.filters.getFilterGroup(TEST_FILTER_GROUP);
|
||||
await expect(mainFilterGroup).toBeHidden();
|
||||
});
|
||||
|
||||
test('Unsharing a field removes it from the shared filters section', async () => {
|
||||
await searchPage.filters.shareFieldWithTeam(TEST_FILTER_GROUP);
|
||||
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeVisible();
|
||||
|
||||
// Unshare the field via the PinShareMenu dropdown
|
||||
await searchPage.filters.unshareField(TEST_FILTER_GROUP);
|
||||
|
||||
// Shared filters section should disappear (no more shared fields)
|
||||
await expect(sharedSection).toBeHidden();
|
||||
|
||||
// The field should reappear in the main filters list
|
||||
const mainFilterGroup =
|
||||
searchPage.filters.getFilterGroup(TEST_FILTER_GROUP);
|
||||
await expect(mainFilterGroup).toBeVisible();
|
||||
});
|
||||
|
||||
test('Filter settings gear allows toggling shared filters visibility', async () => {
|
||||
await searchPage.filters.shareFieldWithTeam(TEST_FILTER_GROUP);
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeVisible();
|
||||
|
||||
// Open filter settings
|
||||
const settingsButton = searchPage.page.getByRole('button', {
|
||||
name: 'Filter settings',
|
||||
});
|
||||
await settingsButton.click();
|
||||
|
||||
// Uncheck "Show Shared Filters"
|
||||
const showSharedCheckbox = searchPage.page.getByRole('checkbox', {
|
||||
name: 'Show Shared Filters',
|
||||
});
|
||||
await showSharedCheckbox.click();
|
||||
|
||||
// Shared filters section should be hidden
|
||||
await expect(sharedSection).toBeHidden();
|
||||
|
||||
// The shared field should reappear in the main filters list
|
||||
const mainFilterGroup =
|
||||
searchPage.filters.getFilterGroup(TEST_FILTER_GROUP);
|
||||
await expect(mainFilterGroup).toBeVisible();
|
||||
});
|
||||
|
||||
test('Applying a filter in the shared section works', async () => {
|
||||
await searchPage.filters.shareFieldWithTeam(TEST_FILTER_GROUP);
|
||||
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeVisible();
|
||||
|
||||
// Click on a filter value within the shared section
|
||||
const filterCheckbox = sharedSection.getByTestId(
|
||||
`filter-checkbox-${TEST_FILTER_GROUP}-${TEST_FILTER_VALUE}`,
|
||||
);
|
||||
await expect(filterCheckbox).toBeVisible({ timeout: 10000 });
|
||||
await filterCheckbox.click();
|
||||
|
||||
// The filter should be applied — verify the checkbox is checked
|
||||
const filterInput = sharedSection.getByTestId(
|
||||
`filter-checkbox-${TEST_FILTER_GROUP}-${TEST_FILTER_VALUE}-input`,
|
||||
);
|
||||
await expect(filterInput).toBeChecked({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Reset shared filters clears all shared fields', async () => {
|
||||
await searchPage.filters.shareFieldWithTeam(TEST_FILTER_GROUP);
|
||||
const sharedSection = searchPage.filters.getSharedFiltersSection();
|
||||
await expect(sharedSection).toBeVisible();
|
||||
|
||||
// Open filter settings and click reset
|
||||
const settingsButton = searchPage.page.getByRole('button', {
|
||||
name: 'Filter settings',
|
||||
});
|
||||
await settingsButton.click();
|
||||
|
||||
// Click "Reset Shared Filters" — this opens a confirmation
|
||||
await searchPage.page
|
||||
.getByText('Reset Shared Filters', { exact: true })
|
||||
.click();
|
||||
|
||||
// Click "Confirm" to actually execute the reset
|
||||
await searchPage.page.getByText('Confirm', { exact: true }).click();
|
||||
|
||||
// Shared filters section should disappear after the reset takes effect
|
||||
await expect(sharedSection).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -517,6 +517,27 @@ export type SavedSearchListApiResponse = z.infer<
|
|||
typeof SavedSearchListApiResponseSchema
|
||||
>;
|
||||
|
||||
// --------------------------
|
||||
// PINNED FILTERS
|
||||
// --------------------------
|
||||
export const PinnedFiltersValueSchema = z
|
||||
.record(
|
||||
z.string().max(1024),
|
||||
z.array(z.union([z.string().max(1024), z.boolean()])).max(50),
|
||||
)
|
||||
.refine(val => Object.keys(val).length <= 100, {
|
||||
message: 'Too many filter keys (max 100)',
|
||||
});
|
||||
export type PinnedFiltersValue = z.infer<typeof PinnedFiltersValueSchema>;
|
||||
|
||||
export const PinnedFilterSchema = z.object({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
fields: z.array(z.string().max(1024)).max(100),
|
||||
filters: PinnedFiltersValueSchema,
|
||||
});
|
||||
export type PinnedFilter = z.infer<typeof PinnedFilterSchema>;
|
||||
|
||||
// --------------------------
|
||||
// DASHBOARDS
|
||||
// --------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue