diff --git a/.changeset/serious-chicken-hammer.md b/.changeset/serious-chicken-hammer.md new file mode 100644 index 00000000..bb3d3471 --- /dev/null +++ b/.changeset/serious-chicken-hammer.md @@ -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. \ No newline at end of file diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 9b7aa74d..bec663c7 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -13,6 +13,7 @@ import routers from './routers/api'; import clickhouseProxyRouter from './routers/api/clickhouseProxy'; import connectionsRouter from './routers/api/connections'; import favoritesRouter from './routers/api/favorites'; +import 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); // --------------------------------------------------------------------- diff --git a/packages/api/src/controllers/pinnedFilter.ts b/packages/api/src/controllers/pinnedFilter.ts new file mode 100644 index 00000000..706f8671 --- /dev/null +++ b/packages/api/src/controllers/pinnedFilter.ts @@ -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 }, + ); +} diff --git a/packages/api/src/models/pinnedFilter.ts b/packages/api/src/models/pinnedFilter.ts new file mode 100644 index 00000000..5241fff4 --- /dev/null +++ b/packages/api/src/models/pinnedFilter.ts @@ -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( + { + 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( + 'PinnedFilter', + PinnedFilterSchema, +); diff --git a/packages/api/src/routers/api/__tests__/pinnedFilters.test.ts b/packages/api/src/routers/api/__tests__/pinnedFilters.test.ts new file mode 100644 index 00000000..3e86bde9 --- /dev/null +++ b/packages/api/src/routers/api/__tests__/pinnedFilters.test.ts @@ -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, '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>['agent']; + let team: Awaited>['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] }); + }); + }); +}); diff --git a/packages/api/src/routers/api/pinnedFilters.ts b/packages/api/src/routers/api/pinnedFilters.ts new file mode 100644 index 00000000..d0b997ce --- /dev/null +++ b/packages/api/src/routers/api/pinnedFilters.ts @@ -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= + * 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; diff --git a/packages/app/src/__tests__/pinnedFilters.test.ts b/packages/app/src/__tests__/pinnedFilters.test.ts new file mode 100644 index 00000000..564ce143 --- /dev/null +++ b/packages/app/src/__tests__/pinnedFilters.test.ts @@ -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 = { ...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'] }); + }); +}); diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 8f501dfa..49f06455 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -15,6 +15,8 @@ import { Button, Center, Checkbox, + Collapse, + Divider, Flex, Group, Loader, @@ -38,8 +40,6 @@ import { IconChevronUp, IconFilterOff, IconMinus, - IconPin, - IconPinFilled, IconPlus, IconRefresh, IconSearch, @@ -58,15 +58,23 @@ import { } from '@/hooks/useMetadata'; import { useMetadataWithSettings } from '@/hooks/useMetadata'; import useResizable from '@/hooks/useResizable'; +import { usePinnedFiltersApi } from '@/pinnedFilters'; import { + type FilterState, FilterStateHook, IS_ROOT_SPAN_COLUMN_NAME, usePinnedFilters, } from '@/searchFilters'; import { useSource } from '@/source'; -import { mergePath } from '@/utils'; +import { mergePath, useLocalStorage } from '@/utils'; +import { FilterSettingsPanel } from './DBSearchPageFilters/FilterSettingsPopover'; import { NestedFilterGroup } from './DBSearchPageFilters/NestedFilterGroup'; +import { + PinShareIndicator, + PinShareMenu, +} from './DBSearchPageFilters/PinShareMenu'; +import { SharedFiltersSection } from './DBSearchPageFilters/SharedFilters'; import { groupFacetsByBaseName } from './DBSearchPageFilters/utils'; import resizeStyles from '../../styles/ResizablePanel.module.scss'; @@ -99,15 +107,33 @@ export function cleanedFacetName(key: string): string { return key; } +/** Value-level pin callbacks and state (personal + shared). */ +export type ValuePinHandlers = { + onPinClick: (value: string | boolean) => void; + isPinned: (value: string | boolean) => boolean; + onSharedPinClick?: (value: string | boolean) => void; + isSharedPinned?: (value: string | boolean) => boolean; +}; + +/** Field/group-level pin callbacks and state (personal + shared). */ +export type FieldPinHandlers = { + onFieldPinClick?: VoidFunction; + isFieldPinned?: boolean; + onToggleSharedFieldPin?: VoidFunction; + isSharedFieldPinned?: boolean; +}; + type FilterCheckboxProps = { columnName: string; label: string; value?: 'included' | 'excluded' | false; pinned: boolean; + sharedPinned?: boolean; onChange?: (checked: boolean) => void; onClickOnly?: VoidFunction; onClickExclude?: VoidFunction; onClickPin: VoidFunction; + onClickSharedPin?: VoidFunction; className?: string; percentage?: number; isPercentageLoading?: boolean; @@ -165,19 +191,29 @@ const FilterCheckbox = ({ value, label, pinned, + sharedPinned, onChange, onClickOnly, onClickExclude, onClickPin, + onClickSharedPin, className, percentage, isPercentageLoading, }: FilterCheckboxProps) => { + const [pinMenuOpened, setPinMenuOpened] = useState(false); const testIdPrefix = `filter-checkbox-${columnName}-${label}`; + const isPinnedAny = pinned || sharedPinned; return (
-
+
{onClickOnly && ( )} - - {pinned ? : } - + aria-label={isPinnedAny ? 'Unpin value' : 'Pin value'} + />
- {pinned && ( -
- -
- )} +
); }; @@ -345,16 +386,15 @@ export type FilterGroupProps = { onClearClick: VoidFunction; onOnlyClick: (value: string | boolean) => void; onExcludeClick: (value: string | boolean) => void; - onPinClick: (value: string | boolean) => void; - isPinned: (value: string | boolean) => boolean; - onFieldPinClick?: VoidFunction; - isFieldPinned?: boolean; + valuePins: ValuePinHandlers; + fieldPins?: FieldPinHandlers; onColumnToggle?: VoidFunction; isColumnDisplayed?: boolean; onLoadMore: (key: string) => void; loadMoreLoading: boolean; hasLoadedMore: boolean; isDefaultExpanded?: boolean; + showFilterCounts?: boolean; 'data-testid'?: string; chartConfig: BuilderChartConfigWithDateRange; isLive?: boolean; @@ -377,6 +417,8 @@ const FilterGroupBody = ({ onExcludeClick, isPinned, onPinClick, + isSharedPinned, + onSharedPinClick, onLoadMore, loadMoreLoading, hasLoadedMore, @@ -390,16 +432,14 @@ const FilterGroupBody = ({ name: string; options: { value: string | boolean; label: string }[]; optionsLoading?: boolean; - selectedValues: { - included: Set; - excluded: Set; - range?: { min: number; max: number }; - }; + selectedValues: SelectedValues; onChange: (value: string | boolean) => void; onOnlyClick: (value: string | boolean) => void; onExcludeClick: (value: string | boolean) => void; isPinned: (value: string | boolean) => boolean; onPinClick: (value: string | boolean) => void; + isSharedPinned?: (value: string | boolean) => boolean; + onSharedPinClick?: (value: string | boolean) => void; onLoadMore: (key: string) => void; loadMoreLoading: boolean; hasLoadedMore: boolean; @@ -508,19 +548,26 @@ const FilterGroupBody = ({ ); } - // When not searching, sort by pinned, selected, distribution, then alphabetically + // When not searching, sort by personal pinned, shared pinned, selected, + // distribution, then alphabetically return augmentedOptions.toSorted((a, b) => { const aPinned = isPinned(a.value); + const aShared = isSharedPinned?.(a.value) ?? false; const aIncluded = selectedValues.included.has(a.value); const aExcluded = selectedValues.excluded.has(a.value); const bPinned = isPinned(b.value); + const bShared = isSharedPinned?.(b.value) ?? false; const bIncluded = selectedValues.included.has(b.value); const bExcluded = selectedValues.excluded.has(b.value); - // First sort by pinned status + // First sort by personal pinned status if (aPinned && !bPinned) return -1; if (!aPinned && bPinned) return 1; + // Then sort by shared pinned status + if (aShared && !bShared) return -1; + if (!aShared && bShared) return 1; + // Then sort by included status if (aIncluded && !bIncluded) return -1; if (!aIncluded && bIncluded) return 1; @@ -543,6 +590,7 @@ const FilterGroupBody = ({ search, augmentedOptions, isPinned, + isSharedPinned, selectedValues.included, selectedValues.excluded, distributionData, @@ -623,6 +671,10 @@ const FilterGroupBody = ({ onClickOnly={() => onOnlyClick(option.value)} onClickExclude={() => onExcludeClick(option.value)} onClickPin={() => onPinClick(option.value)} + sharedPinned={isSharedPinned?.(option.value)} + onClickSharedPin={ + onSharedPinClick ? () => onSharedPinClick(option.value) : undefined + } isPercentageLoading={isFetchingDistribution} percentage={ showDistributions && distributionData @@ -712,10 +764,12 @@ type FilterGroupActionsProps = { isFetchingDistribution: boolean; isColumnDisplayed: boolean; isFieldPinned: boolean; + isSharedFieldPinned: boolean; totalAppliedFiltersSize: number; toggleShowDistributions: VoidFunction; - onColumnToggle: VoidFunction; + onColumnToggle?: VoidFunction; onFieldPinClick: VoidFunction; + onToggleSharedFieldPin: VoidFunction; onClearClick: VoidFunction; }; function FilterGroupActions({ @@ -725,10 +779,12 @@ function FilterGroupActions({ isFetchingDistribution, isColumnDisplayed, isFieldPinned, + isSharedFieldPinned, totalAppliedFiltersSize, toggleShowDistributions, onColumnToggle, onFieldPinClick, + onToggleSharedFieldPin, onClearClick, }: FilterGroupActionsProps) { return ( @@ -764,30 +820,9 @@ function FilterGroupActions({ )} - - - {isColumnDisplayed ? ( - - ) : ( - - )} - - - {onFieldPinClick && ( + {onColumnToggle && ( - {isFieldPinned ? ( - + {isColumnDisplayed ? ( + ) : ( - + )} )} + )} {totalAppliedFiltersSize > 0 && ( @@ -842,16 +884,15 @@ export const FilterGroup = ({ onClearClick, onOnlyClick, onExcludeClick, - isPinned, - onPinClick, - onFieldPinClick, - isFieldPinned, + valuePins, + fieldPins, onColumnToggle, isColumnDisplayed, onLoadMore, loadMoreLoading, hasLoadedMore, isDefaultExpanded, + showFilterCounts, 'data-testid': dataTestId, chartConfig, isLive, @@ -931,6 +972,13 @@ export const FilterGroup = ({ > {name} + {showFilterCounts && ( + {` (${totalAppliedFiltersSize})`} + )} @@ -940,11 +988,15 @@ export const FilterGroup = ({ showDistributions={showDistributions} isFetchingDistribution={isFetchingDistribution} isColumnDisplayed={isColumnDisplayed ?? false} - isFieldPinned={isFieldPinned ?? false} - totalAppliedFiltersSize={totalAppliedFiltersSize} + isFieldPinned={fieldPins?.isFieldPinned ?? false} + isSharedFieldPinned={fieldPins?.isSharedFieldPinned ?? false} toggleShowDistributions={toggleShowDistributions} - onColumnToggle={onColumnToggle ?? voidFunc} - onFieldPinClick={onFieldPinClick ?? voidFunc} + onColumnToggle={onColumnToggle} + onFieldPinClick={fieldPins?.onFieldPinClick ?? voidFunc} + onToggleSharedFieldPin={ + fieldPins?.onToggleSharedFieldPin ?? voidFunc + } + totalAppliedFiltersSize={totalAppliedFiltersSize} onClearClick={onClearClick} /> @@ -971,8 +1023,10 @@ export const FilterGroup = ({ onChange={onChange} onOnlyClick={onOnlyClick} onExcludeClick={onExcludeClick} - isPinned={isPinned} - onPinClick={onPinClick} + isPinned={valuePins.isPinned} + onPinClick={valuePins.onPinClick} + isSharedPinned={valuePins.isSharedPinned} + onSharedPinClick={valuePins.onSharedPinClick} onLoadMore={onLoadMore} loadMoreLoading={loadMoreLoading} hasLoadedMore={hasLoadedMore} @@ -1040,7 +1094,32 @@ const DBSearchPageFiltersComponent = ({ isFieldPinned, getPinnedFields, pinnedFilters, + toggleSharedFieldPin, + isSharedFieldPinned, + toggleSharedFilterPin, + isSharedFilterPinned, + resetPersonalPins, + resetSharedFilters, + hasPersonalPins, + hasSharedPins, } = usePinnedFilters(sourceId ?? null); + const { data: pinnedFiltersApiData } = usePinnedFiltersApi(sourceId ?? null); + const [isSharedFiltersVisible, setSharedFiltersVisible] = useLocalStorage( + 'hdx-shared-filters-visible', + true, + ); + const [showFilterCounts, setShowFilterCounts] = useLocalStorage( + 'hdx-show-filter-counts', + true, + ); + const [isFiltersExpanded, setFiltersExpanded] = useLocalStorage( + 'hdx-filters-expanded', + true, + ); + const [isSharedFiltersExpanded, setSharedFiltersExpanded] = useLocalStorage( + 'hdx-shared-filters-expanded', + true, + ); const { size, startResize } = useResizable(16, 'left'); const { data: jsonColumns } = useJsonColumns({ @@ -1104,7 +1183,8 @@ const DBSearchPageFiltersComponent = ({ field.type.includes('LowCardinality') || // query only low cardinality fields by default field.isMapSubField || // always include Map/JSON sub-fields (e.g. LogAttributes, ResourceAttributes keys) Object.keys(filterState).includes(field.path) || // keep selected fields - isFieldPinned(field.path), // keep pinned fields + isFieldPinned(field.path) || // keep personally pinned fields + isSharedFieldPinned(field.path), // keep team-shared fields ) .map(({ path }) => path) .filter( @@ -1112,7 +1192,14 @@ const DBSearchPageFiltersComponent = ({ !['body', 'timestamp', '_hdx_body'].includes(path.toLowerCase()), ); return strings; - }, [data, jsonColumns, filterState, showMoreFields, isFieldPinned]); + }, [ + data, + jsonColumns, + filterState, + showMoreFields, + isFieldPinned, + isSharedFieldPinned, + ]); // Special case for live tail const [dateRange, setDateRange] = useState<[Date, Date]>( @@ -1203,6 +1290,61 @@ const DBSearchPageFiltersComponent = ({ [chartConfig, setExtraFacets, dateRange, metadata, source], ); + // Build the set of team-pinned fields for the Shared Filters section, + // so we can avoid duplicating them in the regular Filters list below. + const sharedFilterKeys = useMemo(() => { + if (!isSharedFiltersVisible || !pinnedFiltersApiData?.team) { + return new Set(); + } + const team = pinnedFiltersApiData.team; + return new Set([...team.fields, ...Object.keys(team.filters)]); + }, [isSharedFiltersVisible, pinnedFiltersApiData]); + + // Build the facet list for the Shared Filters section. + // For each team-pinned field: merge pinned values with dynamic facet values. + const sharedFacets = useMemo(() => { + if (sharedFilterKeys.size === 0) return []; + + const facetMap = new Map( + (facetsWithPinnedValues ?? []).map(f => [f.key, f.value]), + ); + + return Array.from(sharedFilterKeys).map(key => { + const teamVals = pinnedFiltersApiData?.team?.filters[key] ?? []; + const dynamicValues = facetMap.get(key) ?? []; + + let merged: (string | boolean)[]; + if (teamVals.length > 0) { + merged = [...teamVals]; + for (const v of dynamicValues) { + if (!merged.some(existing => existing === v)) { + merged.push(v); + } + } + } else { + // Field-only pin — show dynamic values + merged = [...dynamicValues]; + } + + // Merge in extra values loaded via "Load more" + const extraValues = extraFacets[key]; + if (extraValues && extraValues.length > 0) { + for (const v of extraValues) { + if (!merged.includes(v)) { + merged.push(v); + } + } + } + + return { key, value: merged }; + }); + }, [ + sharedFilterKeys, + facetsWithPinnedValues, + pinnedFiltersApiData, + extraFacets, + ]); + const shownFacets = useMemo(() => { const _facets: { key: string; value: (string | boolean)[] }[] = []; for (const _facet of facetsWithPinnedValues ?? []) { @@ -1211,6 +1353,11 @@ const DBSearchPageFiltersComponent = ({ facet.key = `toString(${facet.key})`; } + // Skip fields already shown in the Shared Filters section + if (sharedFilterKeys.has(facet.key)) { + continue; + } + // don't include empty facets, unless they are already selected or pinned const filter = filterState[facet.key]; const hasSelectedValues = @@ -1236,7 +1383,8 @@ const DBSearchPageFiltersComponent = ({ } // get remaining filterState that are not in _facets const remainingFilterState = Object.keys(filterState).filter( - key => !_facets.some(facet => facet.key === key), + key => + !_facets.some(facet => facet.key === key) && !sharedFilterKeys.has(key), ); for (const key of remainingFilterState) { _facets.push({ @@ -1252,13 +1400,20 @@ const DBSearchPageFiltersComponent = ({ return aIsPk && !bIsPk ? -1 : bIsPk && !aIsPk ? 1 : 0; }); - // prioritize facets that are pinned + // prioritize facets that are pinned (either personal or shared) _facets.sort((a, b) => { - const aPinned = isFieldPinned(a.key); - const bPinned = isFieldPinned(b.key); + const aPinned = isFieldPinned(a.key) || isSharedFieldPinned(a.key); + const bPinned = isFieldPinned(b.key) || isSharedFieldPinned(b.key); return aPinned && !bPinned ? -1 : bPinned && !aPinned ? 1 : 0; }); + // among pinned, prioritize shared over personal + _facets.sort((a, b) => { + const aShared = isSharedFieldPinned(a.key); + const bShared = isSharedFieldPinned(b.key); + return aShared && !bShared ? -1 : bShared && !aShared ? 1 : 0; + }); + // prioritize facets that have checked items _facets.sort((a, b) => { const aChecked = filterState?.[a.key]?.included.size > 0; @@ -1282,17 +1437,47 @@ const DBSearchPageFiltersComponent = ({ tableMetadata, extraFacets, isFieldPinned, + isSharedFieldPinned, jsonColumns, + sharedFilterKeys, ]); - const showClearAllButton = useMemo( + // Check if shared facets have active selections + const showSharedClearButton = useMemo( () => - Object.values(filterState).some( - f => f.included.size > 0 || f.excluded.size > 0 || f.range != null, - ), - [filterState], + sharedFacets.some(facet => { + const f = filterState[facet.key]; + return ( + f && (f.included.size > 0 || f.excluded.size > 0 || f.range != null) + ); + }), + [sharedFacets, filterState], ); + // Check if non-shared facets have active selections + const showFiltersClearButton = useMemo( + () => + shownFacets.some(facet => { + const f = filterState[facet.key]; + return ( + f && (f.included.size > 0 || f.excluded.size > 0 || f.range != null) + ); + }), + [shownFacets, filterState], + ); + + const clearSharedSelections = useCallback(() => { + for (const facet of sharedFacets) { + clearFilter(facet.key); + } + }, [sharedFacets, clearFilter]); + + const clearRegularSelections = useCallback(() => { + for (const facet of shownFacets) { + clearFilter(facet.key); + } + }, [shownFacets, clearFilter]); + const parentSpanIdExpr = source?.kind === SourceKind.Trace ? source.parentSpanIdExpression @@ -1329,9 +1514,173 @@ const DBSearchPageFiltersComponent = ({ ); }, [filterState, source, parentSpanIdExpr]); - const { grouped, nonGrouped } = useMemo( - () => groupFacetsByBaseName(shownFacets), - [shownFacets], + /** + * Renders a list of facets as FilterGroup and NestedFilterGroup components. + * Used for both the Shared Filters section and the regular Filters section. + */ + const renderFacetList = useCallback( + ( + facets: { key: string; value: (string | boolean)[] }[], + options?: { keyPrefix?: string; isDefaultExpanded?: boolean }, + ) => { + const { keyPrefix = '', isDefaultExpanded: forceExpanded } = + options ?? {}; + const { grouped, nonGrouped } = groupFacetsByBaseName(facets); + + const makeValuePins = (key: string): ValuePinHandlers => ({ + onPinClick: (value: string | boolean) => toggleFilterPin(key, value), + isPinned: (value: string | boolean) => isFilterPinned(key, value), + onSharedPinClick: (value: string | boolean) => + toggleSharedFilterPin(key, value), + isSharedPinned: (value: string | boolean) => + isSharedFilterPinned(key, value), + }); + + const makeFieldPins = (key: string): FieldPinHandlers => ({ + onFieldPinClick: () => toggleFieldPin(key), + isFieldPinned: isFieldPinned(key), + onToggleSharedFieldPin: () => toggleSharedFieldPin(key), + isSharedFieldPinned: isSharedFieldPinned(key), + }); + + return ( + <> + {grouped.map(group => ( + { + acc[child.key] = filterState[child.key] ?? { + included: new Set(), + excluded: new Set(), + }; + return acc; + }, {} as FilterState)} + onChange={(key, value) => setFilterValue(key, value)} + onClearClick={key => clearFilter(key)} + onOnlyClick={(key, value) => setFilterValue(key, value, 'only')} + onExcludeClick={(key, value) => + setFilterValue(key, value, 'exclude') + } + onPinClick={(key, value) => toggleFilterPin(key, value)} + isPinned={(key, value) => isFilterPinned(key, value)} + onSharedPinClick={(key, value) => + toggleSharedFilterPin(key, value) + } + isSharedPinned={(key, value) => isSharedFilterPinned(key, value)} + onFieldPinClick={key => toggleFieldPin(key)} + isFieldPinned={key => isFieldPinned(key)} + onToggleSharedFieldPin={key => toggleSharedFieldPin(key)} + isSharedFieldPinned={key => isSharedFieldPinned(key)} + showFilterCounts={showFilterCounts} + onColumnToggle={onColumnToggle} + displayedColumns={displayedColumns} + onLoadMore={loadMoreFilterValuesForKey} + loadMoreLoading={group.children.reduce( + (acc, child) => { + acc[child.key] = loadMoreLoadingKeys.has(child.key); + return acc; + }, + {} as Record, + )} + hasLoadedMore={group.children.reduce( + (acc, child) => { + acc[child.key] = Boolean(extraFacets[child.key]); + return acc; + }, + {} as Record, + )} + isDefaultExpanded={ + forceExpanded ?? + group.children.some( + child => + (filterState[child.key] && + (filterState[child.key].included.size > 0 || + filterState[child.key].excluded.size > 0)) || + isFieldPinned(child.key) || + isSharedFieldPinned(child.key), + ) + } + chartConfig={chartConfig} + isLive={isLive} + /> + ))} + {nonGrouped.map(facet => ( + ({ + value, + label: value.toString(), + }))} + optionsLoading={isFacetsLoading} + selectedValues={ + filterState[facet.key] ?? { + included: new Set(), + excluded: new Set(), + } + } + onChange={value => setFilterValue(facet.key, value)} + onClearClick={() => clearFilter(facet.key)} + onOnlyClick={value => setFilterValue(facet.key, value, 'only')} + onExcludeClick={value => + setFilterValue(facet.key, value, 'exclude') + } + valuePins={makeValuePins(facet.key)} + fieldPins={makeFieldPins(facet.key)} + onColumnToggle={ + onColumnToggle ? () => onColumnToggle(facet.key) : undefined + } + isColumnDisplayed={displayedColumns?.includes(facet.key)} + onLoadMore={loadMoreFilterValuesForKey} + loadMoreLoading={loadMoreLoadingKeys.has(facet.key)} + hasLoadedMore={Boolean(extraFacets[facet.key])} + isDefaultExpanded={ + forceExpanded ?? + (isFieldPrimary(tableMetadata, facet.key) || + isFieldPinned(facet.key) || + isSharedFieldPinned(facet.key) || + (filterState[facet.key] != null && + (filterState[facet.key].included.size > 0 || + filterState[facet.key].excluded.size > 0 || + filterState[facet.key].range != null))) + } + chartConfig={chartConfig} + isLive={isLive} + onRangeChange={range => setFilterRange(facet.key, range)} + /> + ))} + + ); + }, + [ + filterState, + setFilterValue, + clearFilter, + toggleFilterPin, + isFilterPinned, + toggleSharedFilterPin, + isSharedFilterPinned, + toggleFieldPin, + isFieldPinned, + toggleSharedFieldPin, + isSharedFieldPinned, + onColumnToggle, + displayedColumns, + loadMoreFilterValuesForKey, + loadMoreLoadingKeys, + extraFacets, + showFilterCounts, + isFacetsLoading, + chartConfig, + isLive, + setFilterRange, + tableMetadata, + ], ); return ( @@ -1352,18 +1701,40 @@ const DBSearchPageFiltersComponent = ({ Analysis Mode - {onCollapse && ( - - - - - - )} + + {showRefreshButton && ( + setDateRange(chartConfig.dateRange)} + /> + } + /> + )} + + {onCollapse && ( + + + + + + )} + - - - - Filters {isFacetsFetching && '···'} - - {showRefreshButton && ( - setDateRange(chartConfig.dateRange)} - /> - } - /> - )} - - {showClearAllButton && ( - { - clearAllFilters(); - }} - /> - )} - - - {analysisMode === 'results' && ( - - - - - Denoise Results - - - + {isSharedFiltersVisible && ( + 0} + opened={isSharedFiltersExpanded} + onToggle={() => + setSharedFiltersExpanded(!isSharedFiltersExpanded) } - onChange={() => setDenoiseResults(!denoiseResults)} - /> + showClearButton={showSharedClearButton} + onClearSelections={clearSharedSelections} + > + {renderFacetList(sharedFacets, { + keyPrefix: 'shared-', + isDefaultExpanded: true, + })} + )} - {source?.kind === SourceKind.Trace && - source.parentSpanIdExpression && ( - 0 && ( + + )} + + {/* Collapsible "Filters" section */} + + + setFiltersExpanded(!isFiltersExpanded)} + style={{ flex: 1 }} + > + + Filters {isFacetsFetching && '···'} + + + + {showFiltersClearButton && ( - - - - Root Spans Only - - + + + - } - onChange={event => setRootSpansOnly(event.target.checked)} - /> - )} - - {isLoading || isFacetsLoading ? ( - - + )} + setFiltersExpanded(!isFiltersExpanded)} + > + + + - ) : ( - shownFacets.length === 0 && ( - No filters available - ) - )} - {/* Show facets even when loading to ensure pinned filters are visible while loading */} - <> - {/* Render grouped facets as nested filter groups */} - {grouped.map(group => ( - { - acc[child.key] = filterState[child.key] - ? filterState[child.key] - : { included: new Set(), excluded: new Set() }; - return acc; - }, - {} as Record< - string, - { - included: Set; - excluded: Set; + + + {analysisMode === 'results' && ( + + + + + Denoise Results + + + } - >, + onChange={() => setDenoiseResults(!denoiseResults)} + /> )} - onChange={(key, value) => { - setFilterValue(key, value); - }} - onClearClick={key => clearFilter(key)} - onOnlyClick={(key, value) => { - setFilterValue(key, value, 'only'); - }} - onExcludeClick={(key, value) => { - setFilterValue(key, value, 'exclude'); - }} - onPinClick={(key, value) => toggleFilterPin(key, value)} - isPinned={(key, value) => isFilterPinned(key, value)} - onFieldPinClick={key => toggleFieldPin(key)} - isFieldPinned={key => isFieldPinned(key)} - onColumnToggle={onColumnToggle} - displayedColumns={displayedColumns} - onLoadMore={loadMoreFilterValuesForKey} - loadMoreLoading={group.children.reduce( - (acc, child) => { - acc[child.key] = loadMoreLoadingKeys.has(child.key); - return acc; - }, - {} as Record, - )} - hasLoadedMore={group.children.reduce( - (acc, child) => { - acc[child.key] = Boolean(extraFacets[child.key]); - return acc; - }, - {} as Record, - )} - isDefaultExpanded={ - // open by default if has selected values or pinned children - group.children.some( - child => - (filterState[child.key] && - (filterState[child.key].included.size > 0 || - filterState[child.key].excluded.size > 0)) || - isFieldPinned(child.key), + + {source?.kind === SourceKind.Trace && + source.parentSpanIdExpression && ( + + + + + Root Spans Only + + + + } + onChange={event => setRootSpansOnly(event.target.checked)} + /> + )} + + {isLoading || isFacetsLoading ? ( + + + + ) : ( + shownFacets.length === 0 && ( + No filters available ) - } - chartConfig={chartConfig} - isLive={isLive} - /> - ))} + )} + {/* Show facets even when loading to ensure pinned filters are visible while loading */} + {renderFacetList(shownFacets)} - {/* Render non-grouped facets as regular filter groups */} - {nonGrouped.map(facet => ( - ({ - value, - label: value.toString(), - }))} - optionsLoading={isFacetsLoading} - selectedValues={ - filterState[facet.key] - ? filterState[facet.key] - : { included: new Set(), excluded: new Set() } - } - onChange={value => { - setFilterValue(facet.key, value); - }} - onClearClick={() => clearFilter(facet.key)} - onOnlyClick={value => { - setFilterValue(facet.key, value, 'only'); - }} - onExcludeClick={value => { - setFilterValue(facet.key, value, 'exclude'); - }} - onPinClick={value => toggleFilterPin(facet.key, value)} - isPinned={value => isFilterPinned(facet.key, value)} - onFieldPinClick={() => toggleFieldPin(facet.key)} - isFieldPinned={isFieldPinned(facet.key)} - onColumnToggle={ - onColumnToggle ? () => onColumnToggle(facet.key) : undefined - } - isColumnDisplayed={displayedColumns?.includes(facet.key)} - onLoadMore={loadMoreFilterValuesForKey} - loadMoreLoading={loadMoreLoadingKeys.has(facet.key)} - hasLoadedMore={Boolean(extraFacets[facet.key])} - isDefaultExpanded={ - // open by default if PK, or has selected values - isFieldPrimary(tableMetadata, facet.key) || - isFieldPinned(facet.key) || - (filterState[facet.key] && - (filterState[facet.key].included.size > 0 || - filterState[facet.key].excluded.size > 0 || - filterState[facet.key].range != null)) - } - chartConfig={chartConfig} - isLive={isLive} - onRangeChange={range => setFilterRange(facet.key, range)} - /> - ))} - + - - - {showMoreFields && ( -
- - Not seeing a filter? - - - {`Try searching instead (e.g. column:foo)`} - -
- )} + {showMoreFields && ( +
+ + Not seeing a filter? + + + {`Try searching instead (e.g. column:foo)`} + +
+ )} +
+
+
diff --git a/packages/app/src/components/DBSearchPageFilters/FilterSettingsPopover.tsx b/packages/app/src/components/DBSearchPageFilters/FilterSettingsPopover.tsx new file mode 100644 index 00000000..b32224d0 --- /dev/null +++ b/packages/app/src/components/DBSearchPageFilters/FilterSettingsPopover.tsx @@ -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 ( + + {target} + {children} + + ); +} + +/** + * 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 ( + + + + + + } + > + + + Filter Settings + + + + onSharedFiltersVisibilityChange(e.currentTarget.checked) + } + /> + onShowFilterCountsChange(e.currentTarget.checked)} + /> + {showResetSection && ( + <> + + {hasPersonalPins && ( + + )} + {hasSharedPins && ( + + )} + + )} + + + ); +} + +function ResetAction({ + label, + confirmationText, + onReset, +}: { + label: string; + confirmationText: string; + onReset: VoidFunction; +}) { + const [confirming, setConfirming] = useState(false); + + if (confirming) { + return ( + + + {confirmationText} + + + { + onReset(); + setConfirming(false); + }} + > + + Confirm + + + setConfirming(false)}> + + Cancel + + + + + ); + } + + return ( + setConfirming(true)}> + + {label} + + + ); +} diff --git a/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx b/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx index 83cbf225..027aeee1 100644 --- a/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx +++ b/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx @@ -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" > - + {name} @@ -134,8 +144,11 @@ export const NestedFilterGroup = ({ {isExpanded && (
@@ -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} diff --git a/packages/app/src/components/DBSearchPageFilters/PinShareMenu.tsx b/packages/app/src/components/DBSearchPageFilters/PinShareMenu.tsx new file mode 100644 index 00000000..da22a7d8 --- /dev/null +++ b/packages/app/src/components/DBSearchPageFilters/PinShareMenu.tsx @@ -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 ? ( + + ) : sharedPinned ? ( + + ) : ( + + ); + + return ( + + + + {triggerIcon} + + + + {onToggleSharedPin && sharedPinned && ( + } + onClick={onToggleSharedPin} + fz="xs" + > + Remove from Shared + + )} + : + } + onClick={onTogglePersonalPin} + fz="xs" + > + {personalPinned ? 'Unpin for me' : 'Pin for me'} + + {onToggleSharedPin && !sharedPinned && ( + } + onClick={onToggleSharedPin} + fz="xs" + > + Share with team + + )} + + + ); +} + +/** + * 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 ( +
+ {personalPinned ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/app/src/components/DBSearchPageFilters/SharedFilters.tsx b/packages/app/src/components/DBSearchPageFilters/SharedFilters.tsx new file mode 100644 index 00000000..6f6e423d --- /dev/null +++ b/packages/app/src/components/DBSearchPageFilters/SharedFilters.tsx @@ -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 ( + + + + + + + Shared Filters + + + + + {showClearButton && ( + + + + + + )} + + + + + + + {children} + + + ); +} + +export const SharedFiltersSection = memo(SharedFiltersSectionComponent); diff --git a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx index 0431de99..f60f7be3 100644 --- a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx +++ b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx @@ -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, diff --git a/packages/app/src/localStore.ts b/packages/app/src/localStore.ts index 37a0911d..74af78cc 100644 --- a/packages/app/src/localStore.ts +++ b/packages/app/src/localStore.ts @@ -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( export const localSavedSearches = createEntityStore( 'hdx-local-saved-searches', ); + +/** Pinned filters store for local mode. */ +type LocalPinnedFilter = { + id: string; + source: string; + fields: string[]; + filters: PinnedFiltersValue; +}; +export const localPinnedFilters = createEntityStore( + 'hdx-local-pinned-filters', +); diff --git a/packages/app/src/pinnedFilters.ts b/packages/app/src/pinnedFilters.ts new file mode 100644 index 00000000..c49562a1 --- /dev/null +++ b/packages/app/src/pinnedFilters.ts @@ -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 { + 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), + }); + }, + }); +} diff --git a/packages/app/src/searchFilters.tsx b/packages/app/src/searchFilters.tsx index e7903b31..4deb6e07 100644 --- a/packages/app/src/searchFilters.tsx +++ b/packages/app/src/searchFilters.tsx @@ -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({}); + const [filters, setFilters] = useState({}); - 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; -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( + const filters = useMemo( () => !sourceId || !_pinnedFilters[sourceId] ? {} : _pinnedFilters[sourceId], [_pinnedFilters, sourceId], ); - const pinnedFields = React.useMemo( + const fields = useMemo( () => !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 | 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, }; } diff --git a/packages/app/tests/e2e/components/FilterComponent.ts b/packages/app/tests/e2e/components/FilterComponent.ts index d07cccc9..0aef338f 100644 --- a/packages/app/tests/e2e/components/FilterComponent.ts +++ b/packages/app/tests/e2e/components/FilterComponent.ts @@ -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 { + 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(); + } } diff --git a/packages/app/tests/e2e/features/search/search-filters.spec.ts b/packages/app/tests/e2e/features/search/search-filters.spec.ts index c15908d1..7e5d2c3c 100644 --- a/packages/app/tests/e2e/features/search/search-filters.spec.ts +++ b/packages/app/tests/e2e/features/search/search-filters.spec.ts @@ -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 diff --git a/packages/app/tests/e2e/features/search/shared-filters.spec.ts b/packages/app/tests/e2e/features/search/shared-filters.spec.ts new file mode 100644 index 00000000..d8596c55 --- /dev/null +++ b/packages/app/tests/e2e/features/search/shared-filters.spec.ts @@ -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 }); + }); + }, +); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index cffdecb5..c0fd1b8e 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -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; + +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; + // -------------------------- // DASHBOARDS // --------------------------