diff --git a/src/renderer/src/components/linear-issue-attribute-filter-coverage-notice.tsx b/src/renderer/src/components/linear-issue-attribute-filter-coverage-notice.tsx new file mode 100644 index 00000000000..6b9a793d820 --- /dev/null +++ b/src/renderer/src/components/linear-issue-attribute-filter-coverage-notice.tsx @@ -0,0 +1,59 @@ +// Why: the transport id cap trims a deduplicated facet row silently, and any surviving id +// keeps that row checked — so the picker has to say how many per-team ids it really applies. +import React from 'react' +import { translate } from '@/i18n/i18n' +import { linearMetadataGroupCoverage } from './linear-issue-attribute-filter-team-ids' + +type LinearCoverageFacet = 'status' | 'labels' + +function shortfallMessage(facet: LinearCoverageFacet, applied: number, intended: number): string { + const counts = { value0: applied, value1: intended } + return facet === 'status' + ? translate( + 'auto.components.linear-issue-attribute-filter-coverage-notice.statusPartialTeamCoverage', + 'Filtering {{value0}} of {{value1}} team statuses — issues from the remaining teams are not included.', + counts + ) + : translate( + 'auto.components.linear-issue-attribute-filter-coverage-notice.labelsPartialTeamCoverage', + 'Filtering {{value0}} of {{value1}} team labels — issues from the remaining teams are not included.', + counts + ) +} + +/** Why: a row the cap could not fit leaves no trace, so the spent budget is what we can state. */ +function idLimitMessage(facet: LinearCoverageFacet, max: number): string { + return facet === 'status' + ? translate( + 'auto.components.linear-issue-attribute-filter-coverage-notice.statusAtIdLimit', + 'Filtering the most this can carry: {{value0}} team statuses. Rows picked past that are left out.', + { value0: max } + ) + : translate( + 'auto.components.linear-issue-attribute-filter-coverage-notice.labelsAtIdLimit', + 'Filtering the most this can carry: {{value0}} team labels. Rows picked past that are left out.', + { value0: max } + ) +} + +export function LinearFacetCoverageNotice({ + facet, + options, + selectedIds, + max +}: { + facet: LinearCoverageFacet + options: readonly { key: string; ids: readonly string[] }[] + selectedIds: readonly string[] + max: number +}): React.JSX.Element | null { + const { applied, intended, atLimit } = linearMetadataGroupCoverage(options, selectedIds, max) + if (intended <= applied && !atLimit) { + return null + } + return ( +

+ {intended > applied ? shortfallMessage(facet, applied, intended) : idLimitMessage(facet, max)} +

+ ) +} diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx index 2fc0bf5fd61..fb3e2cc32a5 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx @@ -8,11 +8,13 @@ import { clearLinearIssueAttributeFacet, countLinearIssueAttributeFilters, linearIssueAttributeFilterPillLabels -} from './linear-issue-attribute-filter-sections' +} from './linear-issue-attribute-filter-pills' import { + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS, type LinearIssueAttributeFilter } from '../../../shared/linear/issue-attribute-filter' +import { TooltipProvider } from '@/components/ui/tooltip' import LinearIssueAttributeFilterDropdowns from './linear-issue-attribute-filter-dropdowns' const metadataMocks = vi.hoisted(() => ({ @@ -84,7 +86,9 @@ describe('linear-issue-attribute-filter helpers', () => { ['fe-todo', 'Todo'] ]), memberNamesById: new Map(), - labelNamesById: new Map() + labelNamesById: new Map(), + statusOptions: [{ key: 'be-todo', primary: 'Todo', ids: ['be-todo', 'fe-todo'] }], + labelOptions: [] }) expect(pills[0]?.value).toBe('Todo') }) @@ -97,7 +101,9 @@ describe('linear-issue-attribute-filter helpers', () => { ['s2', 'In Progress'] ]), memberNamesById: new Map(), - labelNamesById: new Map([['l1', 'Bug']]) + labelNamesById: new Map([['l1', 'Bug']]), + statusOptions: [], + labelOptions: [] }) expect(pills.map((p) => p.key)).toEqual(['status', 'priority', 'assignee', 'labels']) expect(pills[0]?.value).toContain('Todo') @@ -260,40 +266,61 @@ const multiTeamStates = [ const teamBe: LinearTeam = { id: 'team-be', name: 'Backend', key: 'BE' } const teamFe: LinearTeam = { id: 'team-fe', name: 'Frontend', key: 'FE' } -function openStatusSection(onChange: (next: LinearIssueAttributeFilter) => void): void { +function renderDropdowns(value: LinearIssueAttributeFilter): { + rerender: (next: LinearIssueAttributeFilter) => void + onChange: ReturnType +} { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) roots.push(root) + const onChange = vi.fn() - act(() => { - root.render( - - ) - }) + const rerender = (next: LinearIssueAttributeFilter): void => { + act(() => { + root.render( + // The app mounts one provider at its root; the pill's partial marker needs it. + + + + ) + }) + } + rerender(value) const trigger = container.querySelector('button') act(() => { trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + return { rerender, onChange } +} - const statusButton = [...document.body.querySelectorAll('button')].find( - (button) => button.textContent?.trim() === 'Status' +// A section button carries its label plus its selection summary, so match on the label. +function openSectionNamed(label: string): void { + const sectionButton = [...document.body.querySelectorAll('button')].find((button) => + button.textContent?.trim().startsWith(label) ) act(() => { - statusButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + sectionButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) } -function statusRowsNamed(name: string): HTMLElement[] { +const emptyFilter: LinearIssueAttributeFilter = { + stateIds: [], + priorities: [], + assignee: null, + labelIds: [] +} + +function pickerRowsNamed(name: string): HTMLElement[] { return [...document.body.querySelectorAll('[role="option"]')].filter( (row) => row.textContent?.trim() === name ) @@ -309,10 +336,11 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () = error: null })) - openStatusSection(() => undefined) + renderDropdowns(emptyFilter) + openSectionNamed('Status') - expect(statusRowsNamed('Todo')).toHaveLength(1) - expect(statusRowsNamed('Backlog')).toHaveLength(1) + expect(pickerRowsNamed('Todo')).toHaveLength(1) + expect(pickerRowsNamed('Backlog')).toHaveLength(1) }) it('selects every team state id behind the picked status name', () => { @@ -321,11 +349,10 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () = loading: false, error: null })) - const onChange = vi.fn() - - openStatusSection(onChange) + const { onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Status') act(() => { - statusRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onChange).toHaveBeenCalledWith( @@ -346,11 +373,10 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () = loading: false, error: null })) - const onChange = vi.fn() - - openStatusSection(onChange) + const { onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Status') act(() => { - statusRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onChange.mock.calls[0]?.[0].stateIds).toHaveLength( @@ -358,3 +384,198 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () = ) }) }) + +function sameNamedMetadata(name: string, count: number): { id: string; name: string }[] { + return Array.from({ length: count }, (_unused, index) => ({ id: `team-${index}-${name}`, name })) +} + +// Why: the bound keeps the row checked (any surviving id maps back to it), so without a +// notice the picker claims team coverage the filter never had (#16879). +describe('LinearIssueAttributeFilterDropdowns transport-cap coverage notice', () => { + it('reports the team statuses left out once a picked status exceeds the id cap', () => { + const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20 + metadataMocks.useTeamsStates.mockImplementation(() => ({ + data: sameNamedMetadata('todo', overCap), + loading: false, + error: null + })) + + const { rerender, onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Status') + act(() => { + pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter + expect(bounded.stateIds).toHaveLength(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS) + rerender(bounded) + + expect(pickerRowsNamed('todo')).toHaveLength(1) + expect(document.body.textContent).toContain( + `Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS} of ${overCap} team statuses` + ) + }) + + it('reports the shrunken coverage of a picked status when a second status is added', () => { + const perStatus = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20 + metadataMocks.useTeamsStates.mockImplementation(() => ({ + data: [...sameNamedMetadata('todo', perStatus), ...sameNamedMetadata('doing', perStatus)], + loading: false, + error: null + })) + + const { rerender, onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Status') + act(() => { + pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter) + act(() => { + pickerRowsNamed('doing')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + rerender(onChange.mock.calls[1]?.[0] as LinearIssueAttributeFilter) + + expect(document.body.textContent).toContain( + `Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS} of ${perStatus * 2} team statuses` + ) + }) + + it('stays silent while every team id behind the picked status is applied', () => { + metadataMocks.useTeamsStates.mockImplementation(() => ({ + data: multiTeamStates, + loading: false, + error: null + })) + + const { rerender, onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Status') + act(() => { + pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter) + + expect(document.body.textContent).not.toContain('team statuses') + openSectionNamed('Back') + expect(document.body.textContent).not.toContain('partial') + }) + + // Why: the notice only exists inside the detail panel, but the surfaces a user works from + // after applying a filter are the collapsed section menu and the pill (#16879). + it('flags the trimmed status filter in the section menu and the pill', () => { + const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20 + metadataMocks.useTeamsStates.mockImplementation(() => ({ + data: sameNamedMetadata('todo', overCap), + loading: false, + error: null + })) + + const { rerender, onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Status') + act(() => { + pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter) + openSectionNamed('Back') + + expect(document.body.textContent).toContain('1 selected · partial') + // Why: the marker has to be reachable by keyboard and named for a screen reader, + // which a bare title attribute never was (#17342). + const pillMarkers = [...document.body.querySelectorAll('button')].filter( + (button) => button.textContent === 'partial' + ) + expect(pillMarkers).toHaveLength(1) + expect(pillMarkers[0]?.getAttribute('data-slot')).toBe('tooltip-trigger') + }) + + // Why: the canonical id list is sorted before the cap slices it, so a picked row whose ids + // all sort last used to vanish outright — unchecked, uncounted, and with no notice at all. + it('keeps and counts a picked status whose ids all sort past the cap', () => { + const cap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + metadataMocks.useTeamsStates.mockImplementation(() => ({ + data: [ + ...Array.from({ length: cap }, (_unused, index) => ({ + id: `a-${String(index).padStart(3, '0')}`, + name: 'Alpha' + })), + { id: 'z-000', name: 'Zeta' } + ], + loading: false, + error: null + })) + + const { rerender, onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Status') + act(() => { + pickerRowsNamed('Alpha')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter) + act(() => { + pickerRowsNamed('Zeta')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + const bounded = onChange.mock.calls[1]?.[0] as LinearIssueAttributeFilter + rerender(bounded) + + expect(bounded.stateIds).toHaveLength(cap) + expect(bounded.stateIds).toContain('z-000') + expect(document.body.textContent).toContain(`Filtering ${cap} of ${cap + 1} team statuses`) + }) + + // Why: with more single-id status rows than the cap, the row the user clicks cannot fit at + // all — the picker used to check nothing and still report full coverage (#17342). + it('says the status filter is full instead of claiming coverage it cannot have', () => { + const cap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + const states = Array.from({ length: cap + 1 }, (_unused, index) => ({ + id: `s-${String(index).padStart(3, '0')}`, + name: `Status ${String(index).padStart(3, '0')}` + })) + metadataMocks.useTeamsStates.mockImplementation(() => ({ + data: states, + loading: false, + error: null + })) + + const { rerender, onChange } = renderDropdowns({ + ...emptyFilter, + stateIds: states.slice(0, cap).map((state) => state.id) + }) + openSectionNamed('Status') + act(() => { + pickerRowsNamed(`Status ${String(cap).padStart(3, '0')}`)[0]?.dispatchEvent( + new MouseEvent('click', { bubbles: true }) + ) + }) + + const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter + expect(bounded.stateIds).toHaveLength(cap) + rerender(bounded) + + expect(document.body.textContent).toContain( + `Filtering the most this can carry: ${cap} team statuses` + ) + openSectionNamed('Back') + expect(document.body.textContent).toContain(`${cap} selected · partial`) + }) + + it('reports the team labels left out once a picked label exceeds the id cap', () => { + const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS + 20 + metadataMocks.useTeamsLabels.mockImplementation(() => ({ + data: sameNamedMetadata('bug', overCap), + loading: false, + error: null + })) + + const { rerender, onChange } = renderDropdowns(emptyFilter) + openSectionNamed('Labels') + act(() => { + pickerRowsNamed('bug')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter + expect(bounded.labelIds).toHaveLength(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS) + rerender(bounded) + + expect(document.body.textContent).toContain( + `Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS} of ${overCap} team labels` + ) + }) +}) diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx index cde4545e5c3..d8b69a9b3a1 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx @@ -4,10 +4,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { ListFilter, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useTeamsLabels, useTeamsMembers, useTeamsStates } from '@/hooks/useIssueMetadata' import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import { translate } from '@/i18n/i18n' import { + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS, boundLinearIssueAttributeFilter, canonicalizeLinearIssueAttributeFilter, emptyLinearIssueAttributeFilter, @@ -15,14 +18,17 @@ import { } from '../../../shared/linear/issue-attribute-filter' import type { LinearTeam } from '../../../shared/linear/workspace-types' import { - LinearIssueFilterSectionDetail, - LinearIssueFilterSectionMenu, clearLinearIssueAttributeFacet, countLinearIssueAttributeFilters, - linearIssueAttributeFilterPillLabels, + linearIssueAttributeFilterPillLabels +} from './linear-issue-attribute-filter-pills' +import { + LinearIssueFilterSectionDetail, + LinearIssueFilterSectionMenu, type LinearIssueFilterSectionKey } from './linear-issue-attribute-filter-sections' import { + capLinearMetadataIdsAcrossGroups, groupLinearMetadataByName, resolveLinearIssueAttributeFilterTeamIds } from './linear-issue-attribute-filter-team-ids' @@ -44,16 +50,40 @@ type Props = { function ActivePill({ label, value, + partial, onClear }: { label: string value: string + partial: boolean onClear: () => void }): React.JSX.Element { return ( {label}: {value} + {partial ? ( + + + {/* Why: a bare title attribute reaches neither keyboard nor screen reader. */} + + + + {translate( + 'auto.components.linear-issue-attribute-filter-dropdowns.partialCoverageTitle', + 'Some teams may be left out of this filter. Open Filters for details.' + )} + + + ) : null}