mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
fix(linear): surface truncation when a deduplicated status exceeds the state-id cap (#17342)
* 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.
This commit is contained in:
@@ -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 (
|
||||
<p className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{intended > applied ? shortfallMessage(facet, applied, intended) : idLimitMessage(facet, max)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof vi.fn>
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
const onChange = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<LinearIssueAttributeFilterDropdowns
|
||||
value={{ stateIds: [], priorities: [], assignee: null, labelIds: [] }}
|
||||
onChange={onChange}
|
||||
workspaceId="workspace-1"
|
||||
primaryTeam={teamBe}
|
||||
selectedTeamIds={['team-be', 'team-fe']}
|
||||
availableTeams={[teamBe, teamFe]}
|
||||
teamsSettled
|
||||
/>
|
||||
)
|
||||
})
|
||||
const rerender = (next: LinearIssueAttributeFilter): void => {
|
||||
act(() => {
|
||||
root.render(
|
||||
// The app mounts one provider at its root; the pill's partial marker needs it.
|
||||
<TooltipProvider>
|
||||
<LinearIssueAttributeFilterDropdowns
|
||||
value={next}
|
||||
onChange={onChange}
|
||||
workspaceId="workspace-1"
|
||||
primaryTeam={teamBe}
|
||||
selectedTeamIds={['team-be', 'team-fe']}
|
||||
availableTeams={[teamBe, teamFe]}
|
||||
teamsSettled
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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<HTMLElement>('[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`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<span className="inline-flex h-6 items-center gap-1 rounded-full border border-border/60 bg-muted/50 pl-2 pr-1 text-[11px] text-foreground">
|
||||
<span className="text-muted-foreground">{label}:</span>
|
||||
<span className="max-w-[160px] truncate font-medium">{value}</span>
|
||||
{partial ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why: a bare title attribute reaches neither keyboard nor screen reader. */}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm text-muted-foreground underline decoration-dotted underline-offset-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{translate(
|
||||
'auto.components.linear-issue-attribute-filter-dropdowns.partialCoverage',
|
||||
'partial'
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.linear-issue-attribute-filter-dropdowns.partialCoverageTitle',
|
||||
'Some teams may be left out of this filter. Open Filters for details.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={translate(
|
||||
@@ -217,9 +247,34 @@ export default function LinearIssueAttributeFilterDropdowns({
|
||||
value,
|
||||
stateNamesById,
|
||||
memberNamesById,
|
||||
labelNamesById
|
||||
labelNamesById,
|
||||
statusOptions,
|
||||
labelOptions
|
||||
})
|
||||
|
||||
// Why: one picked row expands to an id per team, so bound here — the IPC/RPC parser rejects a
|
||||
// filter over the transport cap outright. Spread the cap over the picked rows first: the
|
||||
// canonical slice is lexicographic, so it can drop every id of a row the user just checked.
|
||||
const applyPickedFilter = (next: LinearIssueAttributeFilter): void => {
|
||||
onChange(
|
||||
boundLinearIssueAttributeFilter(
|
||||
canonicalizeLinearIssueAttributeFilter({
|
||||
...next,
|
||||
stateIds: capLinearMetadataIdsAcrossGroups(
|
||||
statusOptions,
|
||||
next.stateIds,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
|
||||
),
|
||||
labelIds: capLinearMetadataIdsAcrossGroups(
|
||||
labelOptions,
|
||||
next.labelIds,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const teamRequiredMessage = !primaryTeam
|
||||
? translate(
|
||||
'auto.components.linear-issue-attribute-filter-dropdowns.teamRequired',
|
||||
@@ -283,13 +338,7 @@ export default function LinearIssueAttributeFilterDropdowns({
|
||||
<LinearIssueFilterSectionDetail
|
||||
section={openSection}
|
||||
value={value}
|
||||
// Why: one status now expands to an id per team, so bound here — the
|
||||
// IPC/RPC parser rejects a filter over the transport id cap outright.
|
||||
onChange={(next) =>
|
||||
onChange(
|
||||
boundLinearIssueAttributeFilter(canonicalizeLinearIssueAttributeFilter(next))
|
||||
)
|
||||
}
|
||||
onChange={applyPickedFilter}
|
||||
statusOptions={statusOptions}
|
||||
assigneeOptions={assigneeOptions}
|
||||
labelOptions={labelOptions}
|
||||
@@ -305,8 +354,8 @@ export default function LinearIssueAttributeFilterDropdowns({
|
||||
) : (
|
||||
<LinearIssueFilterSectionMenu
|
||||
value={value}
|
||||
stateNamesById={stateNamesById}
|
||||
labelNamesById={labelNamesById}
|
||||
statusOptions={statusOptions}
|
||||
labelOptions={labelOptions}
|
||||
onOpenSection={setOpenSection}
|
||||
/>
|
||||
)}
|
||||
@@ -334,6 +383,7 @@ export default function LinearIssueAttributeFilterDropdowns({
|
||||
key={pill.key}
|
||||
label={pill.label}
|
||||
value={pill.value}
|
||||
partial={pill.partial}
|
||||
onClear={() =>
|
||||
onChange(
|
||||
canonicalizeLinearIssueAttributeFilter(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Why: the pills are the only place an applied Linear facet filter is visible once the
|
||||
// popover closes, so they carry the same coverage truth the picker shows inline.
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS,
|
||||
canonicalizeLinearIssueAttributeFilter,
|
||||
type LinearIssueAttributeFilter
|
||||
} from '../../../shared/linear/issue-attribute-filter'
|
||||
import { getLinearPriorityLabel } from './task-page-localized-options'
|
||||
import { isLinearMetadataGroupSelectionPartial } from './linear-issue-attribute-filter-team-ids'
|
||||
import type {
|
||||
LinearIssueFilterGroupedOption,
|
||||
LinearIssueFilterSectionKey
|
||||
} from './linear-issue-attribute-filter-sections'
|
||||
|
||||
/** Same-named ids from different teams are one selection to the user. */
|
||||
function distinctFacetNames(ids: readonly string[], namesById: Map<string, string>): string[] {
|
||||
return [...new Set(ids.map((id) => namesById.get(id) ?? id))]
|
||||
}
|
||||
|
||||
export function countLinearIssueAttributeFilters(value: LinearIssueAttributeFilter): number {
|
||||
const canonical = canonicalizeLinearIssueAttributeFilter(value)
|
||||
return (
|
||||
(canonical.stateIds.length > 0 ? 1 : 0) +
|
||||
(canonical.priorities.length > 0 ? 1 : 0) +
|
||||
(canonical.assignee ? 1 : 0) +
|
||||
(canonical.labelIds.length > 0 ? 1 : 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function clearLinearIssueAttributeFacet(
|
||||
value: LinearIssueAttributeFilter,
|
||||
facet: LinearIssueFilterSectionKey
|
||||
): LinearIssueAttributeFilter {
|
||||
switch (facet) {
|
||||
case 'status':
|
||||
return { ...value, stateIds: [] }
|
||||
case 'priority':
|
||||
return { ...value, priorities: [] }
|
||||
case 'assignee':
|
||||
return { ...value, assignee: null }
|
||||
case 'labels':
|
||||
return { ...value, labelIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/** A removable filter pill; `partial` marks a facet the transport id cap trimmed (#16879). */
|
||||
export type LinearIssueFilterPill = {
|
||||
key: LinearIssueFilterSectionKey
|
||||
label: string
|
||||
value: string
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export function linearIssueAttributeFilterPillLabels(options: {
|
||||
value: LinearIssueAttributeFilter
|
||||
stateNamesById: Map<string, string>
|
||||
memberNamesById: Map<string, string>
|
||||
labelNamesById: Map<string, string>
|
||||
statusOptions: readonly LinearIssueFilterGroupedOption[]
|
||||
labelOptions: readonly LinearIssueFilterGroupedOption[]
|
||||
}): LinearIssueFilterPill[] {
|
||||
const canonical = canonicalizeLinearIssueAttributeFilter(options.value)
|
||||
const pills: LinearIssueFilterPill[] = []
|
||||
if (canonical.stateIds.length > 0) {
|
||||
pills.push({
|
||||
key: 'status',
|
||||
label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
|
||||
value: distinctFacetNames(canonical.stateIds, options.stateNamesById).join(', '),
|
||||
partial: isLinearMetadataGroupSelectionPartial(
|
||||
options.statusOptions,
|
||||
canonical.stateIds,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
|
||||
)
|
||||
})
|
||||
}
|
||||
if (canonical.priorities.length > 0) {
|
||||
pills.push({
|
||||
key: 'priority',
|
||||
label: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.priority',
|
||||
'Priority'
|
||||
),
|
||||
value: canonical.priorities.map((p) => getLinearPriorityLabel(p)).join(', '),
|
||||
partial: false
|
||||
})
|
||||
}
|
||||
if (canonical.assignee?.kind === 'unassigned') {
|
||||
pills.push({
|
||||
key: 'assignee',
|
||||
label: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.assignee',
|
||||
'Assignee'
|
||||
),
|
||||
value: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.unassigned',
|
||||
'Unassigned'
|
||||
),
|
||||
partial: false
|
||||
})
|
||||
} else if (canonical.assignee?.kind === 'user') {
|
||||
pills.push({
|
||||
key: 'assignee',
|
||||
label: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.assignee',
|
||||
'Assignee'
|
||||
),
|
||||
value: options.memberNamesById.get(canonical.assignee.id) ?? canonical.assignee.id,
|
||||
partial: false
|
||||
})
|
||||
}
|
||||
if (canonical.labelIds.length > 0) {
|
||||
pills.push({
|
||||
key: 'labels',
|
||||
label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
|
||||
value: distinctFacetNames(canonical.labelIds, options.labelNamesById).join(', '),
|
||||
partial: isLinearMetadataGroupSelectionPartial(
|
||||
options.labelOptions,
|
||||
canonical.labelIds,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
|
||||
)
|
||||
})
|
||||
}
|
||||
return pills
|
||||
}
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
canonicalizeLinearIssueAttributeFilter,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS,
|
||||
type LinearIssueAttributeFilter
|
||||
} from '../../../shared/linear/issue-attribute-filter'
|
||||
import { getLinearPriorityLabel } from './task-page-localized-options'
|
||||
import { LinearFacetCoverageNotice } from './linear-issue-attribute-filter-coverage-notice'
|
||||
import {
|
||||
expandLinearMetadataGroupKeys,
|
||||
isLinearMetadataGroupSelectionPartial,
|
||||
selectedLinearMetadataGroupKeys
|
||||
} from './linear-issue-attribute-filter-team-ids'
|
||||
|
||||
@@ -22,94 +25,6 @@ export type LinearIssueFilterSectionKey = 'status' | 'priority' | 'assignee' | '
|
||||
/** Picker row backed by every same-named id across the selected teams (#16785). */
|
||||
export type LinearIssueFilterGroupedOption = PickerOption & { ids: string[] }
|
||||
|
||||
/** Same-named ids from different teams are one selection to the user. */
|
||||
function distinctFacetNames(ids: readonly string[], namesById: Map<string, string>): string[] {
|
||||
return [...new Set(ids.map((id) => namesById.get(id) ?? id))]
|
||||
}
|
||||
|
||||
export function countLinearIssueAttributeFilters(value: LinearIssueAttributeFilter): number {
|
||||
const canonical = canonicalizeLinearIssueAttributeFilter(value)
|
||||
return (
|
||||
(canonical.stateIds.length > 0 ? 1 : 0) +
|
||||
(canonical.priorities.length > 0 ? 1 : 0) +
|
||||
(canonical.assignee ? 1 : 0) +
|
||||
(canonical.labelIds.length > 0 ? 1 : 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function clearLinearIssueAttributeFacet(
|
||||
value: LinearIssueAttributeFilter,
|
||||
facet: LinearIssueFilterSectionKey
|
||||
): LinearIssueAttributeFilter {
|
||||
switch (facet) {
|
||||
case 'status':
|
||||
return { ...value, stateIds: [] }
|
||||
case 'priority':
|
||||
return { ...value, priorities: [] }
|
||||
case 'assignee':
|
||||
return { ...value, assignee: null }
|
||||
case 'labels':
|
||||
return { ...value, labelIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function linearIssueAttributeFilterPillLabels(options: {
|
||||
value: LinearIssueAttributeFilter
|
||||
stateNamesById: Map<string, string>
|
||||
memberNamesById: Map<string, string>
|
||||
labelNamesById: Map<string, string>
|
||||
}): { key: LinearIssueFilterSectionKey; label: string; value: string }[] {
|
||||
const canonical = canonicalizeLinearIssueAttributeFilter(options.value)
|
||||
const pills: { key: LinearIssueFilterSectionKey; label: string; value: string }[] = []
|
||||
if (canonical.stateIds.length > 0) {
|
||||
pills.push({
|
||||
key: 'status',
|
||||
label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
|
||||
value: distinctFacetNames(canonical.stateIds, options.stateNamesById).join(', ')
|
||||
})
|
||||
}
|
||||
if (canonical.priorities.length > 0) {
|
||||
pills.push({
|
||||
key: 'priority',
|
||||
label: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.priority',
|
||||
'Priority'
|
||||
),
|
||||
value: canonical.priorities.map((p) => getLinearPriorityLabel(p)).join(', ')
|
||||
})
|
||||
}
|
||||
if (canonical.assignee?.kind === 'unassigned') {
|
||||
pills.push({
|
||||
key: 'assignee',
|
||||
label: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.assignee',
|
||||
'Assignee'
|
||||
),
|
||||
value: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.unassigned',
|
||||
'Unassigned'
|
||||
)
|
||||
})
|
||||
} else if (canonical.assignee?.kind === 'user') {
|
||||
pills.push({
|
||||
key: 'assignee',
|
||||
label: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.assignee',
|
||||
'Assignee'
|
||||
),
|
||||
value: options.memberNamesById.get(canonical.assignee.id) ?? canonical.assignee.id
|
||||
})
|
||||
}
|
||||
if (canonical.labelIds.length > 0) {
|
||||
pills.push({
|
||||
key: 'labels',
|
||||
label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
|
||||
value: distinctFacetNames(canonical.labelIds, options.labelNamesById).join(', ')
|
||||
})
|
||||
}
|
||||
return pills
|
||||
}
|
||||
|
||||
function priorityOptions(): PickerOption[] {
|
||||
return [0, 1, 2, 3, 4].map((priority) => ({
|
||||
key: String(priority),
|
||||
@@ -117,31 +32,50 @@ function priorityOptions(): PickerOption[] {
|
||||
}))
|
||||
}
|
||||
|
||||
/** "{{count}} selected", flagged when the transport id cap left teams out (#16879). */
|
||||
function facetSummary(
|
||||
options: readonly LinearIssueFilterGroupedOption[],
|
||||
selectedIds: readonly string[],
|
||||
max: number
|
||||
): string {
|
||||
const count = selectedLinearMetadataGroupKeys(options, selectedIds).length
|
||||
if (count === 0) {
|
||||
return ''
|
||||
}
|
||||
const summary = translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.countSelected',
|
||||
'{{count}} selected',
|
||||
{ count }
|
||||
)
|
||||
return isLinearMetadataGroupSelectionPartial(options, selectedIds, max)
|
||||
? translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.partialCoverageSuffix',
|
||||
'{{value0}} · partial',
|
||||
{ value0: summary }
|
||||
)
|
||||
: summary
|
||||
}
|
||||
|
||||
export function LinearIssueFilterSectionMenu({
|
||||
value,
|
||||
stateNamesById,
|
||||
labelNamesById,
|
||||
statusOptions,
|
||||
labelOptions,
|
||||
onOpenSection
|
||||
}: {
|
||||
value: LinearIssueAttributeFilter
|
||||
stateNamesById: Map<string, string>
|
||||
labelNamesById: Map<string, string>
|
||||
statusOptions: LinearIssueFilterGroupedOption[]
|
||||
labelOptions: LinearIssueFilterGroupedOption[]
|
||||
onOpenSection: (section: LinearIssueFilterSectionKey) => void
|
||||
}): React.JSX.Element {
|
||||
const selectedStatusCount = distinctFacetNames(value.stateIds, stateNamesById).length
|
||||
const selectedLabelCount = distinctFacetNames(value.labelIds, labelNamesById).length
|
||||
const sections: { key: LinearIssueFilterSectionKey; label: string; summary: string }[] = [
|
||||
{
|
||||
key: 'status',
|
||||
label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
|
||||
summary:
|
||||
selectedStatusCount > 0
|
||||
? translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.countSelected',
|
||||
'{{count}} selected',
|
||||
{ count: selectedStatusCount }
|
||||
)
|
||||
: ''
|
||||
summary: facetSummary(
|
||||
statusOptions,
|
||||
value.stateIds,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
@@ -176,14 +110,11 @@ export function LinearIssueFilterSectionMenu({
|
||||
{
|
||||
key: 'labels',
|
||||
label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
|
||||
summary:
|
||||
selectedLabelCount > 0
|
||||
? translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.countSelected',
|
||||
'{{count}} selected',
|
||||
{ count: selectedLabelCount }
|
||||
)
|
||||
: ''
|
||||
summary: facetSummary(
|
||||
labelOptions,
|
||||
value.labelIds,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
@@ -300,42 +231,43 @@ export function LinearIssueFilterSectionDetail({
|
||||
)
|
||||
}
|
||||
|
||||
if (section === 'status') {
|
||||
// Status and labels are the same grouped, cap-bounded picker over a different facet.
|
||||
if (section === 'status' || section === 'labels') {
|
||||
const isStatus = section === 'status'
|
||||
const options = isStatus ? statusOptions : labelOptions
|
||||
const selectedIds = isStatus ? value.stateIds : value.labelIds
|
||||
return (
|
||||
<div>
|
||||
<SectionBack onBack={onBack} />
|
||||
<MultiSelectList
|
||||
options={statusOptions}
|
||||
selected={selectedLinearMetadataGroupKeys(statusOptions, value.stateIds)}
|
||||
loading={statusLoading}
|
||||
error={statusError}
|
||||
searchPlaceholder={translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.searchStatus',
|
||||
'Filter status…'
|
||||
)}
|
||||
onChange={(keys) =>
|
||||
onChange({ ...value, stateIds: expandLinearMetadataGroupKeys(statusOptions, keys) })
|
||||
options={options}
|
||||
selected={selectedLinearMetadataGroupKeys(options, selectedIds)}
|
||||
loading={isStatus ? statusLoading : labelLoading}
|
||||
error={isStatus ? statusError : labelError}
|
||||
searchPlaceholder={
|
||||
isStatus
|
||||
? translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.searchStatus',
|
||||
'Filter status…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.searchLabels',
|
||||
'Filter labels…'
|
||||
)
|
||||
}
|
||||
onChange={(keys) => {
|
||||
const ids = expandLinearMetadataGroupKeys(options, keys)
|
||||
onChange(isStatus ? { ...value, stateIds: ids } : { ...value, labelIds: ids })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (section === 'labels') {
|
||||
return (
|
||||
<div>
|
||||
<SectionBack onBack={onBack} />
|
||||
<MultiSelectList
|
||||
options={labelOptions}
|
||||
selected={selectedLinearMetadataGroupKeys(labelOptions, value.labelIds)}
|
||||
loading={labelLoading}
|
||||
error={labelError}
|
||||
searchPlaceholder={translate(
|
||||
'auto.components.linear-issue-attribute-filter-sections.searchLabels',
|
||||
'Filter labels…'
|
||||
)}
|
||||
onChange={(keys) =>
|
||||
onChange({ ...value, labelIds: expandLinearMetadataGroupKeys(labelOptions, keys) })
|
||||
<LinearFacetCoverageNotice
|
||||
facet={section}
|
||||
options={options}
|
||||
selectedIds={selectedIds}
|
||||
max={
|
||||
isStatus
|
||||
? LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
|
||||
: LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { LinearTeam } from '../../../shared/linear/workspace-types'
|
||||
import {
|
||||
capLinearMetadataIdsAcrossGroups,
|
||||
expandLinearMetadataGroupKeys,
|
||||
groupLinearMetadataByName,
|
||||
isLinearMetadataGroupSelectionPartial,
|
||||
linearMetadataGroupCoverage,
|
||||
resolveLinearIssueAttributeFilterTeamIds,
|
||||
selectedLinearMetadataGroupKeys,
|
||||
unionLinearMetadataById
|
||||
@@ -131,3 +134,99 @@ describe('groupLinearMetadataByName', () => {
|
||||
expect(expandLinearMetadataGroupKeys(groups, ['other-team-todo'])).toEqual(['other-team-todo'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('capLinearMetadataIdsAcrossGroups', () => {
|
||||
const groups = [
|
||||
{ key: 'alpha', ids: ['a-1', 'a-2', 'a-3'] },
|
||||
{ key: 'zeta', ids: ['z-1'] }
|
||||
]
|
||||
|
||||
it('leaves a selection already within the cap untouched', () => {
|
||||
expect(capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2'], 3)).toEqual(['a-1', 'a-2'])
|
||||
})
|
||||
|
||||
// Why: a plain slice of the sorted id list drops the whole trailing group, which then
|
||||
// renders unchecked and disappears from the coverage count (#16879).
|
||||
it('keeps an id from every picked group instead of slicing the last one away', () => {
|
||||
expect(capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2', 'a-3', 'z-1'], 3)).toEqual([
|
||||
'a-1',
|
||||
'z-1',
|
||||
'a-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats an id no group covers as its own group', () => {
|
||||
expect(capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2', 'a-3', 'other'], 2)).toEqual([
|
||||
'a-1',
|
||||
'other'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('capLinearMetadataIdsAcrossGroups over-subscribed rows', () => {
|
||||
const singleIdGroups = (count: number): { key: string; ids: string[] }[] =>
|
||||
Array.from({ length: count }, (_unused, index) => ({
|
||||
key: `s${index}`,
|
||||
ids: [`s${index}`]
|
||||
}))
|
||||
|
||||
it('leaves a selection sitting exactly on the cap untouched', () => {
|
||||
const groups = singleIdGroups(4)
|
||||
const ids = groups.flatMap((group) => group.ids)
|
||||
expect(capLinearMetadataIdsAcrossGroups(groups, ids, 4)).toEqual(ids)
|
||||
// Why: on the cap, full coverage is indistinguishable from a starved row, so coverage
|
||||
// warns rather than claim what it cannot prove — below the cap it stays quiet (#17342).
|
||||
expect(isLinearMetadataGroupSelectionPartial(groups, ids, 4)).toBe(true)
|
||||
expect(isLinearMetadataGroupSelectionPartial(groups, ids, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps one id per row when the selection is one id over the cap', () => {
|
||||
const groups = [
|
||||
{ key: 'alpha', ids: ['a-1', 'a-2'] },
|
||||
{ key: 'beta', ids: ['b-1'] },
|
||||
{ key: 'gamma', ids: ['c-1'] }
|
||||
]
|
||||
const capped = capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2', 'b-1', 'c-1'], 3)
|
||||
expect(new Set(capped)).toEqual(new Set(['a-1', 'b-1', 'c-1']))
|
||||
})
|
||||
|
||||
// Why: MultiSelectList.toggle appends the clicked key last, so the starved row was always
|
||||
// the row the user just clicked — and coverage still reported a full 100 of 100.
|
||||
it('never claims full coverage when more rows are picked than the cap can hold', () => {
|
||||
const groups = singleIdGroups(101)
|
||||
const ids = groups.flatMap((group) => group.ids)
|
||||
const capped = capLinearMetadataIdsAcrossGroups(groups, ids, 100)
|
||||
expect(capped).toHaveLength(100)
|
||||
expect(linearMetadataGroupCoverage(groups, capped, 100).atLimit).toBe(true)
|
||||
expect(isLinearMetadataGroupSelectionPartial(groups, capped, 100)).toBe(true)
|
||||
})
|
||||
|
||||
it('never starves a single-id row to widen a row that has many ids', () => {
|
||||
const groups = [
|
||||
{ key: 'wide', ids: Array.from({ length: 10 }, (_unused, index) => `w-${index}`) },
|
||||
{ key: 'x', ids: ['x-1'] },
|
||||
{ key: 'y', ids: ['y-1'] },
|
||||
{ key: 'z', ids: ['z-1'] }
|
||||
]
|
||||
const ids = groups.flatMap((group) => group.ids)
|
||||
const capped = capLinearMetadataIdsAcrossGroups(groups, ids, 5)
|
||||
expect(capped).toHaveLength(5)
|
||||
expect(capped).toContain('x-1')
|
||||
expect(capped).toContain('y-1')
|
||||
expect(capped).toContain('z-1')
|
||||
})
|
||||
|
||||
// Why: the picker hands us click order, so the cap has to sort by metadata order instead.
|
||||
it('caps the same visible selection to the same ids whatever the click order', () => {
|
||||
const groups = [
|
||||
{ key: 'alpha', ids: ['a-1', 'a-2', 'a-3'] },
|
||||
{ key: 'beta', ids: ['b-1', 'b-2'] },
|
||||
{ key: 'gamma', ids: ['c-1'] }
|
||||
]
|
||||
const alphaFirst = ['a-1', 'a-2', 'a-3', 'b-1', 'b-2', 'c-1']
|
||||
const gammaFirst = ['c-1', 'b-1', 'b-2', 'a-1', 'a-2', 'a-3']
|
||||
expect(capLinearMetadataIdsAcrossGroups(groups, gammaFirst, 4)).toEqual(
|
||||
capLinearMetadataIdsAcrossGroups(groups, alphaFirst, 4)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,13 +81,97 @@ export function selectedLinearMetadataGroupKeys(
|
||||
groups: readonly { key: string; ids: readonly string[] }[],
|
||||
selectedIds: readonly string[]
|
||||
): string[] {
|
||||
const keyById = linearMetadataKeyById(groups)
|
||||
return [...new Set(selectedIds.map((id) => keyById.get(id) ?? id))]
|
||||
}
|
||||
|
||||
function linearMetadataKeyById(
|
||||
groups: readonly { key: string; ids: readonly string[] }[]
|
||||
): Map<string, string> {
|
||||
const keyById = new Map<string, string>()
|
||||
for (const group of groups) {
|
||||
for (const id of group.ids) {
|
||||
keyById.set(id, group.key)
|
||||
}
|
||||
}
|
||||
return [...new Set(selectedIds.map((id) => keyById.get(id) ?? id))]
|
||||
return keyById
|
||||
}
|
||||
|
||||
/** Ids the picked rows stand for, against the ids the transport cap actually kept (#16879). */
|
||||
export function linearMetadataGroupCoverage(
|
||||
groups: readonly { key: string; ids: readonly string[] }[],
|
||||
selectedIds: readonly string[],
|
||||
max: number
|
||||
): { applied: number; intended: number; atLimit: boolean } {
|
||||
const keys = selectedLinearMetadataGroupKeys(groups, selectedIds)
|
||||
return {
|
||||
applied: selectedIds.length,
|
||||
intended: expandLinearMetadataGroupKeys(groups, keys).length,
|
||||
// Why: a row the cap could not fit leaves no trace in the ids, so once the budget is
|
||||
// spent the honest claim is the exhausted budget itself — never full coverage (#17342).
|
||||
atLimit: selectedIds.length >= max
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the filter cannot be shown to cover every team id the picked rows stand for. */
|
||||
export function isLinearMetadataGroupSelectionPartial(
|
||||
groups: readonly { key: string; ids: readonly string[] }[],
|
||||
selectedIds: readonly string[],
|
||||
max: number
|
||||
): boolean {
|
||||
const { applied, intended, atLimit } = linearMetadataGroupCoverage(groups, selectedIds, max)
|
||||
return intended > applied || atLimit
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim an expanded selection to `max` ids by taking turns across the picked groups.
|
||||
* A plain slice of the canonical (sorted) id list can drop every id of one picked row,
|
||||
* which then renders unchecked with no explanation and vanishes from the coverage count.
|
||||
* More picked rows than `max` cannot all be represented; `linearMetadataGroupCoverage`
|
||||
* reports that shortfall so the starved row is never passed off as full coverage.
|
||||
*/
|
||||
export function capLinearMetadataIdsAcrossGroups(
|
||||
groups: readonly { key: string; ids: readonly string[] }[],
|
||||
ids: readonly string[],
|
||||
max: number
|
||||
): string[] {
|
||||
if (ids.length <= max) {
|
||||
return [...ids]
|
||||
}
|
||||
const selected = new Set(ids)
|
||||
// Why: the picker hands us click order, so bucket by metadata order instead — the same
|
||||
// visible selection must always cap to the same ids (#17342).
|
||||
const lists = groups
|
||||
.map((group) => group.ids.filter((id) => selected.has(id)))
|
||||
.filter((list) => list.length > 0)
|
||||
const grouped = new Set(lists.flat())
|
||||
// An id no loaded group covers is its own row; sorted so its slot is stable too (R12).
|
||||
lists.push(
|
||||
...[...new Set(ids)]
|
||||
.filter((id) => !grouped.has(id))
|
||||
.sort()
|
||||
.map((id) => [id])
|
||||
)
|
||||
const capped: string[] = []
|
||||
// Round 0 gives every row one id before any row gets a second.
|
||||
for (let round = 0; capped.length < max; round += 1) {
|
||||
let advanced = false
|
||||
for (const list of lists) {
|
||||
const id = list[round]
|
||||
if (id === undefined) {
|
||||
continue
|
||||
}
|
||||
advanced = true
|
||||
capped.push(id)
|
||||
if (capped.length >= max) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!advanced) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return capped
|
||||
}
|
||||
|
||||
/** Every id behind the picked group keys; an unknown key is itself an id. */
|
||||
|
||||
@@ -16185,7 +16185,15 @@
|
||||
"allWorkspacesTitle": "Select one workspace",
|
||||
"allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.",
|
||||
"optionsFromTeam": "Options from {{team}}",
|
||||
"clearAll": "Clear all filters"
|
||||
"clearAll": "Clear all filters",
|
||||
"partialCoverage": "partial",
|
||||
"partialCoverageTitle": "Some teams may be left out of this filter. Open Filters for details."
|
||||
},
|
||||
"linear-issue-attribute-filter-coverage-notice": {
|
||||
"statusPartialTeamCoverage": "Filtering {{value0}} of {{value1}} team statuses — issues from the remaining teams are not included.",
|
||||
"labelsPartialTeamCoverage": "Filtering {{value0}} of {{value1}} team labels — issues from the remaining teams are not included.",
|
||||
"statusAtIdLimit": "Filtering the most this can carry: {{value0}} team statuses. Rows picked past that are left out.",
|
||||
"labelsAtIdLimit": "Filtering the most this can carry: {{value0}} team labels. Rows picked past that are left out."
|
||||
},
|
||||
"linear-issue-attribute-filter-sections": {
|
||||
"status": "Status",
|
||||
@@ -16194,6 +16202,7 @@
|
||||
"unassigned": "Unassigned",
|
||||
"labels": "Labels",
|
||||
"countSelected": "{{count}} selected",
|
||||
"partialCoverageSuffix": "{{value0}} · partial",
|
||||
"selected": "selected",
|
||||
"searchPriority": "Filter priority…",
|
||||
"searchStatus": "Filter status…",
|
||||
|
||||
Reference in New Issue
Block a user