lib/bulk-actions-context.tsx
Rama main · réplica de lectura del repositorio original.
"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
* <CommandPalette> 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<BulkPullRequestOption & { missing: number }>;
/** 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<BulkActionsContextValue | null>(null);
export function BulkActionsProvider({ children }: { children: ReactNode }) {
const [request, setRequest] = useState<AnyBulkActionsRequest | null>(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<BulkActionsContextValue>(
() => ({ request, openSignal, consumeOpenSignal, requestBulkActions, registerSelectionActions, openSelectionActions }),
[request, openSignal, consumeOpenSignal, requestBulkActions, registerSelectionActions, openSelectionActions]
);
return (
<BulkActionsContext.Provider value={value}>
{children}
</BulkActionsContext.Provider>
);
}
export function useBulkActions(): BulkActionsContextValue {
const ctx = useContext(BulkActionsContext);
if (!ctx) {
throw new Error("useBulkActions must be used within a BulkActionsProvider");
}
return ctx;
}