From e40e8086a2f64adf1beaf444f2a00365f3bd9f8a Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 23 Oct 2026 09:03:09 -0600 Subject: [PATCH] feat: allow toggles unselect other toggles --- .../issue-list/issue-toggles.vue | 21 ++++--- .../providers/project-review/review-panels.ts | 62 ++++++++++++++----- .../issues/component-builders/item-panels.ts | 3 + .../data/issues/component-builders/types.ts | 2 + .../moderation/src/data/issues/disclosures.ts | 21 ++++--- packages/moderation/src/data/issues/rules.ts | 4 +- 6 files changed, 79 insertions(+), 34 deletions(-) diff --git a/apps/frontend/src/components/project-review/issue-list/issue-toggles.vue b/apps/frontend/src/components/project-review/issue-list/issue-toggles.vue index ee2da225b7..05d6a11587 100644 --- a/apps/frontend/src/components/project-review/issue-list/issue-toggles.vue +++ b/apps/frontend/src/components/project-review/issue-list/issue-toggles.vue @@ -197,9 +197,11 @@ function updateRow(row: ToggleRow, values: string[]) { ) ) panels.addIssue(props.issue.id) - for (const entry of row.entries) { - if (!panels.selected(entry.binding, entry.control) && next.has(optionValue(entry))) - panels.write(entry.binding, entry.control, true) + const added = row.entries.filter( + (entry) => !panels.selected(entry.binding, entry.control) && next.has(optionValue(entry)), + ) + for (const entry of added) { + panels.write(entry.binding, entry.control, true) } for (const entry of row.entries) { if (panels.selected(entry.binding, entry.control) && !next.has(optionValue(entry))) @@ -210,9 +212,11 @@ function updateRow(row: ToggleRow, values: string[]) { function updateOptions(values: string[]) { if (pending.value || props.disabled) return const next = new Set(values) - for (const entry of optionControls.value) { - if (!panels.selected(entry.binding, entry.control) && next.has(optionValue(entry))) - panels.write(entry.binding, entry.control, true) + const added = optionControls.value.filter( + (entry) => !panels.selected(entry.binding, entry.control) && next.has(optionValue(entry)), + ) + for (const entry of added) { + panels.write(entry.binding, entry.control, true) } for (const entry of optionControls.value) { if (panels.selected(entry.binding, entry.control) && !next.has(optionValue(entry))) @@ -222,7 +226,10 @@ function updateOptions(values: string[]) { function changeOptions(values: string[]) { optionDraft.value = values - if (values.length) updateOptions(values) + if (values.length) { + updateOptions(values) + optionDraft.value = [...selectedOptionValues.value] + } } function openOptions() { diff --git a/apps/frontend/src/providers/project-review/review-panels.ts b/apps/frontend/src/providers/project-review/review-panels.ts index 2bc55579ce..7a0f71cf88 100644 --- a/apps/frontend/src/providers/project-review/review-panels.ts +++ b/apps/frontend/src/providers/project-review/review-panels.ts @@ -35,6 +35,7 @@ export type ResolvedIssueControl = ResolvedIssueControlOptions & | { type: 'toggle' id?: string + unselectToggles?: readonly string[] issueCardDisplay?: { icon?: string label: string @@ -329,11 +330,11 @@ export function createReviewPanels( const bindings = new Map() const projectV3 = project.value if (!projectV3) return bindings - const issueIds = preview - ? [...new Set([...selectedIssueIds.value, preview.control.issueId])] - : selectedIssueIds.value const toggleIds = (issueId: string) => { const ids = new Set(selectedToggleIds(projectV3.id, issueId)) + if (preview?.control.type === 'toggle') { + for (const id of preview.control.unselectToggles ?? []) ids.delete(id) + } if (preview?.control.type === 'toggle' && preview.control.issueId === issueId) { for (const id of preview.control.childToggleIds ?? (preview.control.id === undefined ? [] : [preview.control.id])) { @@ -342,6 +343,17 @@ export function createReviewPanels( } return [...ids] } + const issueIds = preview + ? [ + ...new Set([ + ...selectedIssueIds.value.filter( + (id) => + session.read(projectV3.id, 'issue-active')[id] === true || toggleIds(id).length > 0, + ), + preview.control.issueId, + ]), + ] + : selectedIssueIds.value const showAllIssueControls = reviewData.value.showAllIssueControls === true const context: ReviewContext = { projectV3, @@ -430,6 +442,7 @@ export function createReviewPanels( ...options, type: 'toggle', id: node.id, + unselectToggles: node.unselectToggles, issueCardDisplay: node.issueCardDisplay ? { icon: resolveWithContext(node.issueCardDisplay.icon, issueContext), @@ -522,6 +535,14 @@ export function createReviewPanels( control.childToggleIds = children.flatMap((item) => item.type === 'toggle' && !item.disabled && item.id ? [item.id] : [], ) + control.unselectToggles = [ + ...new Set([ + ...(control.unselectToggles ?? []), + ...children.flatMap((item) => + item.type === 'toggle' && !item.disabled ? (item.unselectToggles ?? []) : [], + ), + ]), + ] control.disabled ||= control.childToggleIds.length === 0 } } @@ -645,6 +666,16 @@ export function createReviewPanels( return } if (typeof value !== 'boolean') return + if (value && current.unselectToggles?.length) { + const toggleIdsToUnselect = new Set(current.unselectToggles) + for (const [issueId, selected] of Object.entries(session.read(binding.projectId, 'issues'))) { + if (!(selected instanceof Set) || ![...selected].some((id) => toggleIdsToUnselect.has(id))) + continue + const keys = new Set([...selected].filter((id) => !toggleIdsToUnselect.has(id))) + session.write(binding.projectId, 'issues', issueId, keys.size ? keys : undefined) + if (issueId !== current.issueId) updateIssueOrder(binding.projectId, issueId) + } + } if (current.childToggleIds) { const keys = new Set(selectedToggleIds(binding.projectId, current.issueId)) for (const id of current.childToggleIds) { @@ -839,22 +870,21 @@ export function createReviewPanels( sharedInputs.textValues[control.key] = textValue(binding, control) continue } + const previewSelected = + control.type === 'toggle' && + control.issueId === preview?.control.issueId && + preview.control.type === 'toggle' && + (control.id === preview.control.id || + (control.id !== undefined && preview.control.childToggleIds?.includes(control.id))) if ( control.childToggleIds || control.disabled || (control.type === 'toggle' && - !( - binding.key === preview?.binding.key && - control.issueId === preview.control.issueId && - preview.control.type === 'toggle' && - control.id === preview.control.id - ) && - !( - control.issueId === preview?.control.issueId && - control.id !== undefined && - preview.control.childToggleIds?.includes(control.id) - ) && - !selected(binding, control)) + !previewSelected && + (!selected(binding, control) || + (preview?.control.type === 'toggle' && + control.id !== undefined && + preview.control.unselectToggles?.includes(control.id)))) ) continue const selection: IssueSelection = issues.get(control.issueId) ?? { @@ -896,7 +926,7 @@ export function createReviewPanels( selected: { items: {}, issueIds: preview - ? [...new Set([...selectedIssueIds.value, preview.control.issueId])] + ? [...issues].filter(([, { active }]) => active).map(([id]) => id) : selectedIssueIds.value, toggleIds: [...keys], }, diff --git a/packages/moderation/src/data/issues/component-builders/item-panels.ts b/packages/moderation/src/data/issues/component-builders/item-panels.ts index 84d51ae563..5d02515dca 100644 --- a/packages/moderation/src/data/issues/component-builders/item-panels.ts +++ b/packages/moderation/src/data/issues/component-builders/item-panels.ts @@ -209,6 +209,9 @@ export function expandItemReviewPanels( : (ctx) => resolve(node.placeholder, scopedContext(ctx, item)) ?? '' } if (node.type !== 'toggle') return [scoped] + if (scoped.type === 'toggle') { + scoped.unselectToggles = node.unselectToggles?.map((id) => `${item.key}:${id}`) + } if (scoped.type === 'toggle' && node.issueCardDisplay !== undefined) { const display = node.issueCardDisplay scoped.issueCardDisplay = { diff --git a/packages/moderation/src/data/issues/component-builders/types.ts b/packages/moderation/src/data/issues/component-builders/types.ts index de09f624cf..6b6bf14cc8 100644 --- a/packages/moderation/src/data/issues/component-builders/types.ts +++ b/packages/moderation/src/data/issues/component-builders/types.ts @@ -95,6 +95,8 @@ export interface IssueToggle extends IssueControlOptions { * Without an ID, the toggle activates the issue directly and contributes no selected toggle ID. */ id?: string + /** Explicit toggle IDs to clear across issues when enabled. Item panels scope these IDs to the current item. */ + unselectToggles?: readonly string[] } export type IssueToggleConfig = Omit diff --git a/packages/moderation/src/data/issues/disclosures.ts b/packages/moderation/src/data/issues/disclosures.ts index bacdfc9774..b745ca4d4a 100644 --- a/packages/moderation/src/data/issues/disclosures.ts +++ b/packages/moderation/src/data/issues/disclosures.ts @@ -468,9 +468,10 @@ export const telemetryDisclosureReviewPanel = panel({ label: 'Telemetry: Opt In', group: 'Missing', }, - disabled: ({ selected }) => - selected.toggleIds.includes('telemetry-disclosure-missing-opt-out') || - selected.toggleIds.includes('telemetry-disclosure-missing-always'), + unselectToggles: [ + 'telemetry-disclosure-missing-opt-out', + 'telemetry-disclosure-missing-always', + ], }), toggle({ issue: disclosuresIssue, @@ -480,9 +481,10 @@ export const telemetryDisclosureReviewPanel = panel({ label: 'Telemetry: Opt Out', group: 'Missing', }, - disabled: ({ selected }) => - selected.toggleIds.includes('telemetry-disclosure-missing-opt-in') || - selected.toggleIds.includes('telemetry-disclosure-missing-always'), + unselectToggles: [ + 'telemetry-disclosure-missing-opt-in', + 'telemetry-disclosure-missing-always', + ], }), toggle({ issue: disclosuresIssue, @@ -492,9 +494,10 @@ export const telemetryDisclosureReviewPanel = panel({ label: 'Telemetry: Always Online', group: 'Missing', }, - disabled: ({ selected }) => - selected.toggleIds.includes('telemetry-disclosure-missing-opt-in') || - selected.toggleIds.includes('telemetry-disclosure-missing-opt-out'), + unselectToggles: [ + 'telemetry-disclosure-missing-opt-in', + 'telemetry-disclosure-missing-opt-out', + ], }), ), ) diff --git a/packages/moderation/src/data/issues/rules.ts b/packages/moderation/src/data/issues/rules.ts index 106ce83a24..c2793f79af 100644 --- a/packages/moderation/src/data/issues/rules.ts +++ b/packages/moderation/src/data/issues/rules.ts @@ -207,13 +207,13 @@ export const rulesReviewPanel = panel({ issue: rulesServerSideOptOutIssue, label: 'Opt-out', id: 'rules-server-side-opt-out', - disabled: ({ selected }) => selected.toggleIds.includes('rules-server-side-opt-in'), + unselectToggles: ['rules-server-side-opt-in'], }), toggle({ issue: rulesServerSideOptInIssue, label: 'Opt-in', id: 'rules-server-side-opt-in', - disabled: ({ selected }) => selected.toggleIds.includes('rules-server-side-opt-out'), + unselectToggles: ['rules-server-side-opt-out'], }), toggle({ issue: rulesAiGeneratedIssue,