From 316cb1ca4b2c6c2528bbdcaeabd230e99c8538bf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:19:34 -0700 Subject: [PATCH] fix(linear): surface truncation when a deduplicated status exceeds the state-id cap (#17342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linear): flag partially applied status and label filters (STA-5983) Since #16879 one status/label row expands to an id per team, and the renderer bounds that list to the 100-id transport cap. Any surviving id kept the row fully checked, so the picker claimed coverage the filter never had; show how many of the row's per-team ids are actually applied. * fix(linear): keep every picked status row inside the transport cap The cap sliced a lexicographically sorted id list, so a whole picked row could lose every id — reverting to unchecked with no notice, and dropping out of the coverage denominator that was supposed to explain it. Spread the cap across the picked rows, and carry the partial-coverage signal to the section menu and the pill, which are what the user reads once the detail panel is closed. * fix(linear): stop the status filter claiming coverage it cannot apply More picked rows than the transport id cap cannot all be represented, and MultiSelectList.toggle appends the clicked key last — so the starved row was always the row the user had just clicked: it stayed unchecked, no notice fired, and coverage still reported a full 100 of 100. Coverage now takes the cap and reports a spent id budget as its own shortfall, so the picker says how much it is really carrying instead of claiming teams it never covered. Capping also bucketed by the click order the picker hands it, so the same visible selection could resolve to different ids between renders; it now buckets in metadata order, with ids from unloaded teams sorted after. boundLinear- IssueAttributeFilter stays the last word on the transport bound. The pill's `partial` marker moves from a bare title attribute to the Tooltip primitive, which keyboard and screen-reader users can actually reach. Pill labels and facet clearing move to their own module so sections stays under the max-lines cap. * test(linear): assert coverage at the cap it actually caps to The exactly-on-the-cap test capped at max=4 but asserted non-partial at max=5, so it never covered its own subject. Pin both: at the cap coverage warns (a starved row leaves no trace in the ids), below it stays quiet. --- ...issue-attribute-filter-coverage-notice.tsx | 59 ++++ ...-issue-attribute-filter-dropdowns.test.tsx | 285 ++++++++++++++++-- ...inear-issue-attribute-filter-dropdowns.tsx | 76 ++++- .../linear-issue-attribute-filter-pills.ts | 126 ++++++++ ...linear-issue-attribute-filter-sections.tsx | 214 +++++-------- ...ar-issue-attribute-filter-team-ids.test.ts | 99 ++++++ .../linear-issue-attribute-filter-team-ids.ts | 86 +++++- src/renderer/src/i18n/locales/en.json | 11 +- 8 files changed, 768 insertions(+), 188 deletions(-) create mode 100644 src/renderer/src/components/linear-issue-attribute-filter-coverage-notice.tsx create mode 100644 src/renderer/src/components/linear-issue-attribute-filter-pills.ts 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}