Ctrl K
ComunidadGuías…

lib/bulk-actions-context.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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;
}