diff --git a/.changeset/lemon-planes-eat.md b/.changeset/lemon-planes-eat.md new file mode 100644 index 000000000..8f228447e --- /dev/null +++ b/.changeset/lemon-planes-eat.md @@ -0,0 +1,5 @@ +--- +'hive': patch +--- + +Enhanced Insights filters UI with include/exclude options diff --git a/integration-tests/testkit/saved-filters.ts b/integration-tests/testkit/saved-filters.ts index 97498109b..1455ce571 100644 --- a/integration-tests/testkit/saved-filters.ts +++ b/integration-tests/testkit/saved-filters.ts @@ -18,6 +18,8 @@ export const GetSavedFilterQuery = graphql(` from to } + excludeOperations + excludeClientFilters } visibility viewsCount @@ -93,6 +95,8 @@ export const CreateSavedFilterMutation = graphql(` from to } + excludeOperations + excludeClientFilters } visibility viewsCount @@ -129,6 +133,8 @@ export const UpdateSavedFilterMutation = graphql(` from to } + excludeOperations + excludeClientFilters } visibility viewsCount diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 5e76888e0..377578512 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -1020,6 +1020,7 @@ export function initSeed() { from: string, to: string, ttarget: TargetOverwrite = target, + filter: GraphQLSchema.OperationStatsFilterInput = {}, ) { const statsResult = await readOperationsStats( { byId: ttarget.id }, @@ -1027,7 +1028,7 @@ export function initSeed() { from, to, }, - {}, + filter, ownerToken, ).then(r => r.expectNoGraphQLErrors()); diff --git a/integration-tests/tests/api/saved-filters/saved-filters.spec.ts b/integration-tests/tests/api/saved-filters/saved-filters.spec.ts index 50a3edd70..2d11140fd 100644 --- a/integration-tests/tests/api/saved-filters/saved-filters.spec.ts +++ b/integration-tests/tests/api/saved-filters/saved-filters.spec.ts @@ -431,4 +431,105 @@ describe('Saved Filters', () => { expect(result.error?.message).toContain('Array must contain at most 100 element'); }); }); + + describe('Exclude filters', () => { + test.concurrent('create and retrieve saved filter with exclude fields', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter, getSavedFilter } = await createProject(ProjectType.Single); + + const createResult = await createSavedFilter({ + name: 'External Clients Only', + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { + clientFilters: [{ name: 'internal-bot' }], + excludeOperations: false, + excludeClientFilters: true, + }, + }); + + expect(createResult.error).toBeNull(); + expect(createResult.ok?.savedFilter.filters.excludeOperations).toBe(false); + expect(createResult.ok?.savedFilter.filters.excludeClientFilters).toBe(true); + expect(createResult.ok?.savedFilter.filters.clientFilters).toEqual([ + { name: 'internal-bot', versions: null }, + ]); + + // Verify persisted via fetch + const filterId = createResult.ok?.savedFilter.id!; + const fetched = await getSavedFilter({ filterId }); + expect(fetched?.filters.excludeOperations).toBe(false); + expect(fetched?.filters.excludeClientFilters).toBe(true); + }); + + test.concurrent('update saved filter exclude fields', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter, updateSavedFilter, getSavedFilter } = await createProject( + ProjectType.Single, + ); + + // Create without exclude + const createResult = await createSavedFilter({ + name: 'My Filter', + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { + operationHashes: ['op1'], + excludeOperations: false, + excludeClientFilters: false, + }, + }); + + expect(createResult.ok?.savedFilter.filters.excludeOperations).toBe(false); + expect(createResult.ok?.savedFilter.filters.excludeClientFilters).toBe(false); + + const filterId = createResult.ok?.savedFilter.id!; + + // Update to enable exclude + const updateResult = await updateSavedFilter({ + filterId, + insightsFilter: { + operationHashes: ['op1'], + excludeOperations: true, + excludeClientFilters: false, + }, + }); + + expect(updateResult.error).toBeNull(); + expect(updateResult.ok?.savedFilter.filters.excludeOperations).toBe(true); + expect(updateResult.ok?.savedFilter.filters.excludeClientFilters).toBe(false); + + // Verify persisted + const fetched = await getSavedFilter({ filterId }); + expect(fetched?.filters.excludeOperations).toBe(true); + }); + + test.concurrent( + 'existing filters without exclude fields default to false', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createSavedFilter, getSavedFilter } = await createProject(ProjectType.Single); + + // Create without specifying exclude fields at all + const createResult = await createSavedFilter({ + name: 'Legacy Filter', + visibility: SavedFilterVisibilityType.Private, + insightsFilter: { + operationHashes: ['op1'], + }, + }); + + expect(createResult.error).toBeNull(); + + // Exclude fields should default to false + expect(createResult.ok?.savedFilter.filters.excludeOperations).toBe(false); + expect(createResult.ok?.savedFilter.filters.excludeClientFilters).toBe(false); + + const fetched = await getSavedFilter({ filterId: createResult.ok?.savedFilter.id! }); + expect(fetched?.filters.excludeOperations).toBe(false); + expect(fetched?.filters.excludeClientFilters).toBe(false); + }, + ); + }); }); diff --git a/integration-tests/tests/api/target/usage.spec.ts b/integration-tests/tests/api/target/usage.spec.ts index 365dc5fe2..e40e31f5c 100644 --- a/integration-tests/tests/api/target/usage.spec.ts +++ b/integration-tests/tests/api/target/usage.spec.ts @@ -3567,3 +3567,133 @@ test.concurrent('collect an operation from undefined client', async ({ expect }) } `); }); + +describe('exclude filters', () => { + test.concurrent( + 'excludeOperations filters out matching operations from stats', + { timeout: 30_000 }, + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, readOperationsStats, waitForOperationsCollected } = + await createProject(ProjectType.Single); + const token = await createTargetAccessToken({}); + + // Collect two different operations + const collectResult = await token.collectLegacyOperations([ + { + timestamp: Date.now(), + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + execution: { ok: true, duration: 200_000_000, errorsTotal: 0 }, + metadata: { client: { name: 'web', version: '1.0' } }, + }, + { + timestamp: Date.now(), + operation: 'query me { me }', + operationName: 'me', + fields: ['Query', 'Query.me'], + execution: { ok: true, duration: 100_000_000, errorsTotal: 0 }, + metadata: { client: { name: 'web', version: '1.0' } }, + }, + ]); + expect(collectResult.status).toEqual(200); + await waitForOperationsCollected(2); + + const from = formatISO(subHours(Date.now(), 6)); + const to = formatISO(Date.now()); + + // Without filter: both operations present + const allStats = await readOperationsStats(from, to); + expect(allStats.totalOperations).toBe(2); + + // Get the hash of the 'ping' operation + const pingOp = allStats.operations.edges.find(e => e.node.name.endsWith('_ping')); + expect(pingOp).toBeDefined(); + const pingHash = pingOp!.node.operationHash!; + + // Include filter: only 'ping' operation + const includeStats = await readOperationsStats(from, to, undefined, { + operationIds: [pingHash], + excludeOperations: false, + }); + expect(includeStats.totalOperations).toBe(1); + expect(includeStats.operations.edges[0].node.name).toContain('_ping'); + + // Exclude filter: everything except 'ping' → only 'me' + const excludeStats = await readOperationsStats(from, to, undefined, { + operationIds: [pingHash], + excludeOperations: true, + }); + expect(excludeStats.totalOperations).toBe(1); + expect(excludeStats.operations.edges[0].node.name).toContain('_me'); + }, + ); + + test.concurrent( + 'excludeClientVersionFilters filters out matching clients from stats', + { timeout: 30_000 }, + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, readOperationsStats, waitForRequestsCollected } = + await createProject(ProjectType.Single); + const token = await createTargetAccessToken({}); + + // Collect operations from three different clients + const collectResult = await token.collectLegacyOperations([ + { + timestamp: Date.now(), + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + execution: { ok: true, duration: 200_000_000, errorsTotal: 0 }, + metadata: { client: { name: 'web', version: '1.0' } }, + }, + { + timestamp: Date.now(), + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + execution: { ok: true, duration: 200_000_000, errorsTotal: 0 }, + metadata: { client: { name: 'internal-bot', version: '2.0' } }, + }, + { + timestamp: Date.now(), + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + execution: { ok: true, duration: 200_000_000, errorsTotal: 0 }, + metadata: { client: { name: 'mobile', version: '3.0' } }, + }, + ]); + expect(collectResult.status).toEqual(200); + await waitForRequestsCollected(3); + + const from = formatISO(subHours(Date.now(), 6)); + const to = formatISO(Date.now()); + + // Without filter: all 3 clients visible + const allStats = await readOperationsStats(from, to); + expect(allStats.clients.edges).toHaveLength(3); + + // Include filter: only 'internal-bot' client + const includeStats = await readOperationsStats(from, to, undefined, { + clientVersionFilters: [{ clientName: 'internal-bot' }], + excludeClientVersionFilters: false, + }); + expect(includeStats.clients.edges).toHaveLength(1); + expect(includeStats.clients.edges[0].node.name).toBe('internal-bot'); + + // Exclude filter: everything except 'internal-bot' → 'web' and 'mobile' + const excludeStats = await readOperationsStats(from, to, undefined, { + clientVersionFilters: [{ clientName: 'internal-bot' }], + excludeClientVersionFilters: true, + }); + expect(excludeStats.clients.edges).toHaveLength(2); + const excludedClientNames = excludeStats.clients.edges.map(e => e.node.name).sort(); + expect(excludedClientNames).toEqual(['mobile', 'web']); + }, + ); +}); diff --git a/packages/services/api/src/modules/operations/module.graphql.mappers.ts b/packages/services/api/src/modules/operations/module.graphql.mappers.ts index 2781f8b7c..8188ad13e 100644 --- a/packages/services/api/src/modules/operations/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/operations/module.graphql.mappers.ts @@ -37,6 +37,8 @@ export interface OperationsStatsMapper { operations: readonly string[]; clients: readonly string[]; clientVersionFilters: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; } export interface DurationValuesMapper { avg: number | null; diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index 7d469b8b9..a2cae1a74 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -42,6 +42,14 @@ export default gql` More precise than clientNames - allows filtering to specific versions. """ clientVersionFilters: [ClientVersionFilterInput!] @tag(name: "public") + """ + When true, the operationIds filter is negated — matching operations are excluded instead of included. + """ + excludeOperations: Boolean @tag(name: "public") + """ + When true, the clientVersionFilters filter is negated — matching clients are excluded instead of included. + """ + excludeClientVersionFilters: Boolean @tag(name: "public") } extend type Target { diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index a1a9d608c..830ff976a 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -139,11 +139,15 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; } & TargetSelector) { this.logger.info('Counting unique operations (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -161,6 +165,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); } @@ -248,11 +254,15 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; } & Listify) { this.logger.info('Counting requests and failures (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -271,6 +281,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }) .then(r => r.total); } @@ -327,11 +339,15 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; } & TargetSelector) { this.logger.info('Counting failures (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -349,6 +365,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); } @@ -445,12 +463,16 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; } & TargetSelector) { this.logger.info('Reading operations stats (period=%o, target=%s)', period, target); @@ -470,6 +492,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }); } @@ -584,6 +608,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { period: DateRange; @@ -591,6 +617,8 @@ export class OperationsManager { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; } & TargetSelector) { this.logger.info( @@ -615,6 +643,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }); } @@ -628,12 +658,16 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { period: DateRange; resolution: number; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; } & TargetSelector) { this.logger.info( 'Reading failures over time (period=%o, resolution=%s, target=%s)', @@ -657,6 +691,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); } @@ -669,12 +705,16 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { period: DateRange; resolution: number; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; } & TargetSelector) { this.logger.info( 'Reading duration over time (period=%o, resolution=%s, target=%s)', @@ -698,6 +738,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); } @@ -709,11 +751,15 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; } & TargetSelector) { this.logger.info('Reading overall duration percentiles (period=%o, target=%s)', period, target); await this.session.assertPerformAction({ @@ -731,6 +777,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); } @@ -743,12 +791,16 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; } & TargetSelector) { this.logger.info( @@ -772,6 +824,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }); } @@ -784,12 +838,16 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; } & TargetSelector) { this.logger.info('Counting unique clients (period=%o, target=%s)', period, target); @@ -808,6 +866,8 @@ export class OperationsManager { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }); } diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index 021c5f88a..0c3f163dd 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -456,6 +456,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { target: string | readonly string[]; @@ -463,6 +465,8 @@ export class OperationsReader { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; }): Promise<{ total: number; @@ -484,6 +488,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -521,16 +527,26 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; }): Promise { - return this.countRequests({ target, period, operations, clients, clientVersionFilters }).then( - r => r.notOk, - ); + return this.countRequests({ + target, + period, + operations, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + }).then(r => r.notOk); } async countUniqueDocuments({ @@ -539,12 +555,16 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; }): Promise { const query = this.pickAggregationByPeriod({ period, @@ -561,6 +581,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, )}`, queryId: aggregation => `count_unique_documents_${aggregation}`, @@ -579,6 +601,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { target: string; @@ -586,6 +610,8 @@ export class OperationsReader { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; }): Promise< Array<{ @@ -613,6 +639,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -657,6 +685,8 @@ export class OperationsReader { period, operations, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${sql.raw('coordinates_' + query.queryType)} ${this.createFilter( @@ -811,6 +841,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { target: string; @@ -818,6 +850,8 @@ export class OperationsReader { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; }): Promise< Array<{ @@ -849,6 +883,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -1731,6 +1767,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { target: string; @@ -1739,6 +1777,8 @@ export class OperationsReader { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; }) { const results = await this.getDurationAndCountOverTime({ @@ -1748,6 +1788,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }); @@ -1764,6 +1806,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { target: string; period: DateRange; @@ -1771,6 +1815,8 @@ export class OperationsReader { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; }) { const result = await this.getDurationAndCountOverTime({ target, @@ -1779,6 +1825,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); return result.map(row => ({ @@ -1794,11 +1842,15 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { target: string; period: DateRange; resolution: number; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; operations?: readonly string[]; clients?: readonly string[]; }): Promise< @@ -1814,6 +1866,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); } @@ -1823,12 +1877,16 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }: { target: string; period: DateRange; operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; }): Promise { const result = await this.clickHouse.query<{ percentiles: [number, number, number, number]; @@ -1840,7 +1898,7 @@ export class OperationsReader { avgMerge(duration_avg) as average, quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles FROM ${aggregationTableName('operations')} - ${this.createFilter({ target, period, operations, clients, clientVersionFilters })} + ${this.createFilter({ target, period, operations, clients, clientVersionFilters, excludeOperations, excludeClientVersionFilters })} `, queryId: aggregation => `general_duration_percentiles_${aggregation}`, timeout: 15_000, @@ -1857,9 +1915,13 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; target: string; period: DateRange; operations?: readonly string[]; @@ -1884,6 +1946,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -1946,6 +2010,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, schemaCoordinate, }: { target: string; @@ -1954,6 +2020,8 @@ export class OperationsReader { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; schemaCoordinate?: string; }) { const interval = calculateTimeWindow({ period, resolution }); @@ -1997,6 +2065,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, extra: schemaCoordinate ? [ sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter( @@ -2290,6 +2360,8 @@ export class OperationsReader { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, extra = [], skipWhere = false, namespace, @@ -2299,6 +2371,8 @@ export class OperationsReader { operations?: readonly string[]; clients?: readonly string[]; clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[]; + excludeOperations?: boolean; + excludeClientVersionFilters?: boolean; extra?: SqlValue[]; skipWhere?: boolean; namespace?: string; @@ -2323,7 +2397,11 @@ export class OperationsReader { } if (operations?.length) { - where.push(sql`(${columnPrefix}hash) IN (${sql.array(operations, 'String')})`); + if (excludeOperations) { + where.push(sql`(${columnPrefix}hash) NOT IN (${sql.array(operations, 'String')})`); + } else { + where.push(sql`(${columnPrefix}hash) IN (${sql.array(operations, 'String')})`); + } } if (clients?.length) { @@ -2340,7 +2418,11 @@ export class OperationsReader { } return sql`(${columnPrefix}client_name = ${clientName} AND ${columnPrefix}client_version IN (${sql.array(filter.versions, 'String')}))`; }); - where.push(sql`(${sql.join(versionConditions, ' OR ')})`); + if (excludeClientVersionFilters) { + where.push(sql`NOT (${sql.join(versionConditions, ' OR ')})`); + } else { + where.push(sql`(${sql.join(versionConditions, ' OR ')})`); + } } if (extra.length) { diff --git a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts index 506a4b8a0..663dc4bd3 100644 --- a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts @@ -12,6 +12,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, _, { injector }, @@ -26,6 +28,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }), operationsManager.readDetailedDurationMetrics({ organizationId: organization, @@ -35,6 +39,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }), ]); @@ -64,7 +70,17 @@ export const OperationsStats: OperationsStatsResolvers = { }; }, totalRequests: ( - { organization, project, target, period, operations, clients, clientVersionFilters }, + { + organization, + project, + target, + period, + operations, + clients, + clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, + }, _, { injector }, ) => { @@ -76,6 +92,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); }, totalFailures: ( @@ -87,6 +105,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, _, { injector }, @@ -99,6 +119,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); }, totalOperations: ( @@ -110,6 +132,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, _, { injector }, @@ -122,6 +146,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); }, requestsOverTime: ( @@ -133,6 +159,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, { resolution }, { injector }, @@ -146,6 +174,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); }, failuresOverTime: ( @@ -157,6 +187,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, { resolution }, { injector }, @@ -170,6 +202,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); }, durationOverTime: ( @@ -181,6 +215,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, { resolution }, { injector }, @@ -194,6 +230,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); }, clients: async ( @@ -205,6 +243,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, _, { injector }, @@ -217,6 +257,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); return { @@ -238,6 +280,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }, _, { injector }, @@ -250,6 +294,8 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, clientVersionFilters, + excludeOperations, + excludeClientVersionFilters, }); }, }; diff --git a/packages/services/api/src/modules/operations/resolvers/Target.ts b/packages/services/api/src/modules/operations/resolvers/Target.ts index 5ec919786..3ba748e1a 100644 --- a/packages/services/api/src/modules/operations/resolvers/Target.ts +++ b/packages/services/api/src/modules/operations/resolvers/Target.ts @@ -90,6 +90,8 @@ export const Target: Pick< clientName: f.clientName === 'unknown' ? '' : f.clientName, versions: f.versions ? [...f.versions] : null, })) ?? [], + excludeOperations: args.filter?.excludeOperations ?? false, + excludeClientVersionFilters: args.filter?.excludeClientVersionFilters ?? false, }; }, schemaCoordinateStats: async (target, args, _ctx) => { diff --git a/packages/services/api/src/modules/saved-filters/module.graphql.ts b/packages/services/api/src/modules/saved-filters/module.graphql.ts index c09b57f01..e7a8a2ebb 100644 --- a/packages/services/api/src/modules/saved-filters/module.graphql.ts +++ b/packages/services/api/src/modules/saved-filters/module.graphql.ts @@ -25,6 +25,8 @@ export const typeDefs = gql` operationHashes: [String!]! clientFilters: [ClientFilter!]! dateRange: InsightsDateRange + excludeOperations: Boolean + excludeClientFilters: Boolean } type ClientFilter { @@ -78,6 +80,8 @@ export const typeDefs = gql` operationHashes: [String!] clientFilters: [ClientFilterInput!] dateRange: InsightsDateRangeInput + excludeOperations: Boolean + excludeClientFilters: Boolean } input InsightsDateRangeInput { diff --git a/packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts b/packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts index 2a6cf3055..4363c4d57 100644 --- a/packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts +++ b/packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts @@ -33,6 +33,8 @@ const SavedFilterModel = zod.object({ to: zod.string(), }) .nullable(), + excludeOperations: zod.boolean().optional().default(false), + excludeClientFilters: zod.boolean().optional().default(false), }), visibility: zod.enum(['private', 'shared']), viewsCount: zod.number(), diff --git a/packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts b/packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts index 5661b43fd..8bf072b2f 100644 --- a/packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts +++ b/packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts @@ -38,6 +38,8 @@ const InsightsFilterConfigurationModel = zod.object({ }) .nullish() .transform(v => v ?? null), + excludeOperations: zod.boolean().optional().default(false), + excludeClientFilters: zod.boolean().optional().default(false), }); // Transform GraphQL uppercase enum values to lowercase for database storage @@ -155,6 +157,8 @@ export class SavedFiltersProvider { operationHashes?: string[] | null; clientFilters?: Array<{ name: string; versions?: string[] | null }> | null; dateRange?: { from: string; to: string } | null; + excludeOperations?: boolean; + excludeClientFilters?: boolean; } | null; }, ): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> { @@ -211,6 +215,8 @@ export class SavedFiltersProvider { versions: cf.versions ?? null, })) ?? [], dateRange: data.insightsFilter?.dateRange ?? null, + excludeOperations: data.insightsFilter?.excludeOperations ?? false, + excludeClientFilters: data.insightsFilter?.excludeClientFilters ?? false, }; const savedFilter = await this.savedFiltersStorage.createSavedFilter({ @@ -250,6 +256,8 @@ export class SavedFiltersProvider { operationHashes?: string[] | null; clientFilters?: Array<{ name: string; versions?: string[] | null }> | null; dateRange?: { from: string; to: string } | null; + excludeOperations?: boolean; + excludeClientFilters?: boolean; } | null; }, ): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> { @@ -328,6 +336,8 @@ export class SavedFiltersProvider { versions: cf.versions ?? null, })) ?? [], dateRange: data.insightsFilter.dateRange ?? null, + excludeOperations: data.insightsFilter.excludeOperations ?? false, + excludeClientFilters: data.insightsFilter.excludeClientFilters ?? false, } : null; diff --git a/packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts index 52bb2d3b2..07184dc25 100644 --- a/packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts +++ b/packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts @@ -23,6 +23,8 @@ export const createSavedFilter: NonNullable; dateRange?: { from: string; to: string } | null; + excludeOperations?: boolean; + excludeClientFilters?: boolean; }; return { operationHashes: filters.operationHashes ?? [], @@ -20,6 +22,8 @@ export const SavedFilter: SavedFilterResolvers = { versions: cf.versions ?? null, })) ?? [], dateRange: filters.dateRange ?? null, + excludeOperations: filters.excludeOperations ?? false, + excludeClientFilters: filters.excludeClientFilters ?? false, }; }, visibility: filter => (filter.visibility === 'private' ? 'PRIVATE' : 'SHARED'), diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 245b7c11c..6df4ec141 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -303,6 +303,8 @@ export interface InsightsFilterData { operationHashes: string[]; clientFilters: Array<{ name: string; versions: string[] | null }>; dateRange: { from: string; to: string } | null; + excludeOperations?: boolean; + excludeClientFilters?: boolean; } export interface SavedFilter { diff --git a/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.stories.tsx b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.stories.tsx index e632b3755..7aa5af7c2 100644 --- a/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.stories.tsx +++ b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.stories.tsx @@ -172,55 +172,66 @@ const mockOperations: FilterItem[] = [ export const InsightsFiltersDropdown: Story = () => { const [clientSelections, setClientSelections] = useState([]); const [operationSelections, setOperationSelections] = useState([]); - const allSelections = [ - ...operationSelections.map(s => ({ ...s, category: 'Operation' })), - ...clientSelections.map(s => ({ ...s, category: 'Client' })), - ]; + const [excludeOperations, setExcludeOperations] = useState(false); + const [excludeClients, setExcludeClients] = useState(false); return (
-
-
- Active filters -
- {allSelections.length === 0 ? ( -
No filters active
- ) : ( -
    - {allSelections.map(selection => ( -
  • - {selection.category}:{' '} - {selection.name} - {selection.values !== null && selection.values.length > 0 && ( - <> - {' — '} - {selection.values.join(', ')} - - )} -
  • - ))} -
+
+ {}} + /> + {operationSelections.length > 0 && ( + { + setOperationSelections([]); + setExcludeOperations(false); + }} + excludeMode={excludeOperations} + onExcludeModeChange={setExcludeOperations} + /> + )} + {clientSelections.length > 0 && ( + { + setClientSelections([]); + setExcludeClients(false); + }} + valuesLabel="versions" + excludeMode={excludeClients} + onExcludeModeChange={setExcludeClients} + /> )}
- - {}} - />
); }; diff --git a/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.tsx b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.tsx index 289a11ba8..f10642646 100644 --- a/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.tsx +++ b/packages/web/app/src/components/base/filter-dropdown/filter-dropdown.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { X } from 'lucide-react'; import { Menu, MenuItem } from '@/components/base/menu/menu'; -import { TriggerButton } from '@/components/base/trigger-button'; import { FilterContent } from './filter-content'; import type { FilterItem, FilterSelection } from './types'; @@ -20,8 +19,25 @@ export type FilterDropdownProps = { valuesLabel?: string; /** When true, the trigger is visually dimmed and the menu cannot be opened */ disabled?: boolean; + /** When true, selected items are excluded instead of included */ + excludeMode?: boolean; + /** Called when the exclude mode changes */ + onExcludeModeChange?: (exclude: boolean) => void; }; +const chipClass = + 'inline-flex items-center rounded-sm border text-xs font-medium bg-neutral-2 border-neutral-5 text-neutral-9 dark:text-neutral-11 dark:bg-neutral-3 dark:border-neutral-4'; + +const segmentSeparator = 'border-l [border-left-color:inherit]'; + +const segmentButton = + 'px-2.5 py-1.5 text-[13px] transition-colors cursor-pointer hover:bg-neutral-4/50 hover:text-neutral-12'; + +function pluralize(count: number, singular: string): string { + const lower = singular.toLowerCase(); + return `${count} ${count === 1 ? lower : `${lower}s`}`; +} + export function FilterDropdown({ label, items, @@ -30,49 +46,97 @@ export function FilterDropdown({ onRemove, valuesLabel = 'values', disabled, + excludeMode, + onExcludeModeChange, }: FilterDropdownProps) { - const [open, setOpen] = useState(false); + const [filterOpen, setFilterOpen] = useState(false); const selectedCount = selectedItems.length; return ( - 0 ? selectedCount.toString() : undefined} - disabled={disabled} - label={label} - rightIcon={{ icon: ChevronDown, withSeparator: true }} +
+ {/* Label — static */} + {label} + + {/* Operator — dropdown for "is" / "is not" */} + {onExcludeModeChange && ( + + e.stopPropagation()} + onClick={e => { + e.stopPropagation(); + e.preventDefault(); + }} + > + {excludeMode ? 'is not' : 'is'} + + } + modal={false} + side="bottom" + align="start" + maxWidth="sm" + minWidth="none" + sections={[ + [ + onExcludeModeChange(false)}> + is + , + onExcludeModeChange(true)}> + is not + , + ], + ]} + /> + + )} + + {/* Count — opens the main filter dropdown */} + + + {pluralize(selectedCount, label)} + + } + open={filterOpen} + onOpenChange={setFilterOpen} + modal={false} + lockScroll + side="bottom" + align="start" + maxWidth="lg" + stableWidth + sections={[ + , + ]} /> - } - open={open} - onOpenChange={setOpen} - modal={false} - lockScroll - side="bottom" - align="start" - maxWidth="lg" - stableWidth - sections={[ - , - { - onRemove(); - setOpen(false); - }} - > - Remove filter - , - ]} - /> + + + {/* Remove button */} + +
); } diff --git a/packages/web/app/src/components/base/insights-filters.tsx b/packages/web/app/src/components/base/insights-filters.tsx index 0ad0f8624..62df7df08 100644 --- a/packages/web/app/src/components/base/insights-filters.tsx +++ b/packages/web/app/src/components/base/insights-filters.tsx @@ -13,6 +13,8 @@ export type SavedFilterView = { operationHashes: string[]; clientFilters: Array<{ name: string; versions: string[] | null }>; dateRange: { from: string; to: string } | null; + excludeOperations: boolean; + excludeClientFilters: boolean; }; }; diff --git a/packages/web/app/src/components/base/menu/menu.stories.tsx b/packages/web/app/src/components/base/menu/menu.stories.tsx index b7197b6cc..547c2f0a5 100644 --- a/packages/web/app/src/components/base/menu/menu.stories.tsx +++ b/packages/web/app/src/components/base/menu/menu.stories.tsx @@ -58,9 +58,7 @@ export const WithSubmenu: Story = () => ( } sections={[ [ - - acme-corp - , + acme-corp, personal, test-org, ], diff --git a/packages/web/app/src/components/base/menu/menu.tsx b/packages/web/app/src/components/base/menu/menu.tsx index d6c49b107..653bef8a6 100644 --- a/packages/web/app/src/components/base/menu/menu.tsx +++ b/packages/web/app/src/components/base/menu/menu.tsx @@ -26,7 +26,7 @@ const SubmenuTriggerContext = createContext(null); // --- Styles --- const menuVariants = cva( - 'px-2 pb-2 z-50 min-w-[12rem] text-[13px] rounded-md border shadow-md shadow-neutral-1/30 outline-none bg-neutral-2 border-neutral-5 dark:bg-neutral-4 dark:border-neutral-5', + 'px-2 pb-2 z-50 text-[13px] rounded-md border shadow-md shadow-neutral-1/30 outline-none bg-neutral-2 border-neutral-5 dark:bg-neutral-4 dark:border-neutral-5', { variants: { maxWidth: { @@ -35,9 +35,14 @@ const menuVariants = cva( sm: 'max-w-60', // 240px lg: 'max-w-[380px]', }, + minWidth: { + default: 'min-w-[12rem]', + none: 'min-w-0', + }, }, defaultVariants: { maxWidth: 'default', + minWidth: 'default', }, }, ); @@ -47,7 +52,7 @@ const menuItemVariants = cva( { variants: { variant: { - default: 'pl-2 text-neutral-10', + default: 'px-2 text-neutral-10', navigationLink: 'hover:text-accent text-accent_80 justify-end pr-2 hover:bg-transparent', action: 'pl-2 hover:bg-accent_10 hover:text-accent text-accent_80', destructiveAction: 'pl-2 text-red-400 hover:bg-red-300/10', @@ -56,23 +61,15 @@ const menuItemVariants = cva( true: '', false: '', }, - active: { - true: '', - false: '', - }, disabled: { true: 'pointer-events-none opacity-50', false: '', }, }, - compoundVariants: [ - { highlighted: true, className: 'bg-neutral-5 text-neutral-12' }, - { active: true, className: 'bg-neutral-5 text-neutral-12' }, - ], + compoundVariants: [{ highlighted: true, className: 'bg-neutral-5 text-neutral-12' }], defaultVariants: { variant: 'default', highlighted: false, - active: false, disabled: false, }, }, @@ -83,10 +80,8 @@ const menuItemVariants = cva( function menuItemClassName( state: { highlighted: boolean; disabled: boolean }, { - active, variant, }: { - active?: boolean; variant?: VariantProps['variant']; }, ) { @@ -94,7 +89,6 @@ function menuItemClassName( variant, highlighted: state.highlighted, disabled: state.disabled, - active: active ?? false, }); } @@ -168,6 +162,8 @@ type MenuProps = { sideOffset?: number; /** Controls the max-width of the popup. Defaults to 300px. */ maxWidth?: 'default' | 'none' | 'sm' | 'lg'; + /** Controls the min-width of the popup. Defaults to 12rem. Use 'none' for compact menus. */ + minWidth?: 'default' | 'none'; /** Open the submenu when the trigger is hovered (only relevant for nested menus) */ openOnHover?: boolean; /** Delay in ms before the submenu opens on hover */ @@ -197,6 +193,7 @@ function Menu({ align, sideOffset, maxWidth, + minWidth, openOnHover, delay, closeDelay, @@ -247,7 +244,7 @@ function Menu({ sideOffset={resolvedSideOffset} className="outline-none" > - + {popupContent} @@ -266,7 +263,7 @@ function Menu({ sideOffset={resolvedSideOffset} className="outline-none" > - + {popupContent} @@ -278,19 +275,16 @@ function Menu({ // --- MenuItem --- type MenuItemProps = Omit & { - active?: boolean; variant?: VariantProps['variant']; }; -function MenuItem({ active, variant, children, ...props }: MenuItemProps) { +function MenuItem({ variant, children, ...props }: MenuItemProps) { const submenuTriggerCtx = useContext(SubmenuTriggerContext); if (submenuTriggerCtx) { return ( - menuItemClassName(state, { active, variant }) - } + className={(state: BaseMenu.SubmenuTrigger.State) => menuItemClassName(state, { variant })} openOnHover={submenuTriggerCtx.openOnHover} delay={submenuTriggerCtx.delay} closeDelay={submenuTriggerCtx.closeDelay} @@ -304,7 +298,7 @@ function MenuItem({ active, variant, children, ...props }: MenuItemProps) { return ( menuItemClassName(state, { active, variant })} + className={(state: BaseMenu.Item.State) => menuItemClassName(state, { variant })} {...(props as Omit)} > {children} diff --git a/packages/web/app/src/components/base/popover/popover.tsx b/packages/web/app/src/components/base/popover/popover.tsx index 4ccfc0ad1..fc22d3aed 100644 --- a/packages/web/app/src/components/base/popover/popover.tsx +++ b/packages/web/app/src/components/base/popover/popover.tsx @@ -88,7 +88,7 @@ export function Popover(props: PopoverProps) { inner = (
- {props.title} + {props.title} {!props.hideCloseButton && (