"use client"; /** * Bulk-selection bridge between the boards' floating pill and the global command * palette (MIN-75). The kanban boards own the selection; the pill packages it * into a {@link BulkActionsRequest} and calls `requestBulkActions`, which the * reads to build a transient "N tickets selected" item and * open its actions popover — so bulk edits live in ⌘K (with the palette's inline * submenu forms) instead of a bespoke cmdk dropdown. * * The request is captured at click time (the palette is modal, so the selection * can't change underneath it); `openSignal` increments on every request so the * palette re-opens even when the same selection is acted on twice. */ import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactElement, type ReactNode, } from "react"; import type { IconSvgElement } from "@hugeicons/react"; import type { FormSelectOption } from "@/lib/command-palette/registry/types"; import type { IssueUpdateInput, Member, Objective } from "@/lib/types"; import { trackEvent } from "./analytics"; /** Bulk moves in and out of the key owner's current cycle (MIN-32). */ export interface BulkCycleActions { /** Selected tickets that can still join the cycle (live status, not already in). */ addable: number; /** Selected tickets currently in the cycle. */ removable: number; /** Adds every addable ticket — the ineligible ones are left alone. */ onAdd: () => void; /** Removes every ticket of the selection that sits in the cycle. */ onRemove: () => void; } export interface BulkActionsRequest { /** Ticket selections leave it unset; see {@link EntityBulkActionsRequest}. */ kind?: "issues"; /** Number of selected tickets — drives the item title, plurals and confirms. */ count: number; /** Where does the selection come from, for analytics only. By default “board” — the * triage (its left column) is named, otherwise everything would be counted together. */ surface?: string; /** Assignable members across the selection's projects (already de-duplicated). */ members: Member[]; /** Smart Assign fills an empty assignee on every selected ticket (MIN-811): * the clear option reads "Smart Assign" instead of "Unassigned". */ smartAssign?: boolean; /** Applies a field patch to every selected ticket. */ onUpdate: (patch: IssueUpdateInput) => void; /** Deletes the selection — absent when deletion isn't allowed (e.g. cycles). */ onDelete?: () => void; /** Hands the selection to Numo (opens the agent composer). */ onAskNumo: () => void; /** Copies ONE combined prompt of the whole selection to the clipboard * (⇧P, MIN-539). */ onCopyPrompt?: () => void; /** Cycle moves — absent when the user has no current cycle, or when nothing * in the selection can move either way. */ cycle?: BulkCycleActions; /** Objectives the whole selection can join. Empty/absent when the selection * spans several projects (objectives are project-scoped) or the project has * none — the "Changer l'objectif" row is then not offered. */ objectives?: Objective[]; /** Marks the selection as related to each other (MIN-25). Only wired when * the selection is EXACTLY two tickets of the same project. */ onLink?: () => void; /** Linking the selection to a pull request — absent when no open pull * request can take any selected ticket. */ pullRequestLinks?: BulkPullRequestLinks; } /** * One palette row of a non-ticket selection (MIN-873). It either runs at once * or, with `choices`, opens the palette's inline select first; the record * surface owns the writes, the palette only presents them. */ export interface EntityBulkAction { id: string; title: string; /** A Hugeicons glyph, or an element for a glyph of its own (Numo's). */ icon: IconSvgElement | ReactElement; keywords?: string[]; /** Runs at once (a confirmation dialog, when the action needs one). */ run?: () => void; /** A value to pick for the whole selection, then applied with `onPick`. * Options carry their icon, and the value the whole selection shares is * checked. */ choices?: { options: FormSelectOption[]; onPick: (value: string) => void; /** Toast shown once applied; null when `onPick` only asks for a * confirmation first. */ appliedMessage: (label: string, value: string) => string | null; }; } /** * The selection of a list surface other than an issue board (MIN-873): * feedback posts, routines, objectives, wiki pages. Every row comes from the * surface, so the palette needs no knowledge of the record type. */ export interface EntityBulkActionsRequest { kind: "entities"; count: number; /** "3 feedback posts selected" — the palette group. */ label: string; /** The palette's search placeholder, naming what the selection holds: * "Actions for 3 feedback posts…". */ placeholder: string; /** Where the selection comes from, for analytics. */ surface: string; /** Hands the whole selection to Numo. */ onAskNumo: () => void; actions: EntityBulkAction[]; } export type AnyBulkActionsRequest = BulkActionsRequest | EntityBulkActionsRequest; /** A pull request a selection can be linked to. */ export interface BulkPullRequestOption { prId: string; prNumber: number; state: string; title: string; } export interface BulkPullRequestLinks { /** Open pull requests of the selection's projects that at least one selected * ticket without a live pull request can join. */ options: BulkPullRequestOption[]; /** Pull requests already live on part of the selection, with the number of * selected tickets of their project still unlinked. */ partial: Array; /** Links every selected ticket of the PR's project not yet linked to it. */ onLink: (pr: BulkPullRequestOption) => void; /** Selected tickets with at least one linked pull request, in any state. */ unlinkCount: number; /** Unlinks those tickets from all of their pull requests. */ onUnlinkAll: () => void; } interface BulkActionsContextValue { /** The last requested selection (read by the palette). Null until first request. */ request: AnyBulkActionsRequest | null; /** Bumps on each request so the palette re-opens on repeat actions. */ openSignal: number; /** Claims a launch signal once, including across palette remounts. */ consumeOpenSignal: (signal: number) => boolean; /** Called by the pill's "Actions" button — stores the request and opens ⌘K. */ requestBulkActions: (request: AnyBulkActionsRequest) => void; /** The mounted selection pill registers its "Actions" gesture (null on unmount). */ registerSelectionActions: (open: (() => void) | null) => void; /** ⌘K with a selection: opens its actions instead of the search palette. * False when nothing is selected. */ openSelectionActions: () => boolean; } const BulkActionsContext = createContext(null); export function BulkActionsProvider({ children }: { children: ReactNode }) { const [request, setRequest] = useState(null); const [openSignal, setOpenSignal] = useState(0); const consumedOpenSignal = useRef(0); const selectionActions = useRef<(() => void) | null>(null); const registerSelectionActions = useCallback((open: (() => void) | null) => { selectionActions.current = open; }, []); const openSelectionActions = useCallback(() => { if (!selectionActions.current) return false; selectionActions.current(); return true; }, []); const consumeOpenSignal = useCallback((signal: number) => { if (signal <= consumedOpenSignal.current) return false; consumedOpenSignal.current = signal; return true; }, []); const requestBulkActions = useCallback((next: AnyBulkActionsRequest) => { // MIN-75: the “Actions” button of the selection pill. It's the only one // waypoint to bulk actions — the size of the selection // says if the function is used for two tickets or thirty. trackEvent("bulk_selection_started", { surface: next.surface ?? "board", count: next.count, }); setRequest(next); setOpenSignal((n) => n + 1); }, []); const value = useMemo( () => ({ request, openSignal, consumeOpenSignal, requestBulkActions, registerSelectionActions, openSelectionActions }), [request, openSignal, consumeOpenSignal, requestBulkActions, registerSelectionActions, openSelectionActions] ); return ( {children} ); } export function useBulkActions(): BulkActionsContextValue { const ctx = useContext(BulkActionsContext); if (!ctx) { throw new Error("useBulkActions must be used within a BulkActionsProvider"); } return ctx; }