Ctrl K
ComunidadGuías…

lib/create-context.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { expandParam } from "@/lib/ref-registry";
import { hasVisibleOpenDialog } from "@/lib/visible-overlays";

import {
  createContext,
  startTransition,
  useCallback,
  useContext,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from "react";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useProjects } from "@/lib/projects-context";
import { useOfferedProject } from "@/lib/use-offered-projects";
import { capabilitiesForProject } from "@/lib/use-project-capabilities";
import { useTranslations } from "next-intl";
import { useAuth } from "@/lib/auth-context";
import { useMembersQuery } from "@/lib/use-members-query";
import { useCategoriesQuery } from "@/lib/use-categories-query";
import { useObjectivesQuery } from "@/lib/use-objectives-query";
import { createIssueApi } from "@/lib/issues-api";
import { createObjectiveOptimistic } from "@/lib/optimistic-objective";
import {
  findCachedIssue,
  insertIssueEverywhere,
  issueWrites,
  mergeServerIssue,
  removeIssueEverywhere,
} from "@/lib/optimistic/issue-writes";
import { createIssueDeferred } from "@/lib/create-issue-deferred";
import { issueIdentifier } from "@/lib/issue-constants";
import { buildOptimisticIssue } from "@/lib/optimistic-issue";
import { useUndoHistory } from "@/lib/undo/undo-context";
import { snapshotIssue } from "@/lib/undo/undo-core";
import { projectIdFromPath } from "@/lib/project-id-from-path";
import {
  defaultCreateProjectId,
  lastCreateProjectId,
} from "@/lib/last-create-project";
import { eventKey } from "@/lib/keyboard/event-key";
import { matchesModShiftCombo } from "@/lib/keyboard/mod-combo";
import {
  loadCreateIssueDialog,
  loadObjectiveDialog,
  preloadSurface,
} from "@/lib/lazy-app-surfaces";
import type { IssueStatus } from "@/lib/issue-constants";
import type {
  CreateIssueInput,
  CreateObjectiveInput,
  Issue,
  Objective,
} from "@/lib/types";

// Deferred, like the assistant panel: keeps the create dialogs (markdown editor,
// dictation, attachments) out of every route's initial bundle. Loaded the first
// time a create action fires.
const CreateIssueDialog = dynamic(
  () => loadCreateIssueDialog().then((m) => m.CreateIssueDialog),
  { ssr: false }
);
const ObjectiveDialog = dynamic(
  () => loadObjectiveDialog().then((m) => m.ObjectiveDialog),
  { ssr: false }
);

interface OpenIssueOptions {
  /** Target project; defaults to the current route's project, else the last one
   *  a ticket was created in, else the first. */
  projectId?: string;
  status?: IssueStatus;
  objectiveId?: string | null;
  assigneeId?: string | null;
  /** Prefills a "sub-issue of" relation to this issue, shown in the form. */
  parentId?: string | null;
  /** Opens the already open microphone dialog (global shortcut ⌘⇧D). */
  dictate?: boolean;
  /** Reopens this saved draft of `projectId` in the form (drafts tray, MIN-753). */
  draftId?: string;
}

interface OpenObjectiveOptions {
  /** Same defaulting as {@link OpenIssueOptions.projectId}. */
  projectId?: string;
  /** Prefills the name — quickly adding from a goal picker passes the
 * typed text into the search. */
  name?: string;
  /** Recalled with the created objective, so that the picker who opened the dialog
 * links to his ticket. Never recalled for a creation in ANOTHER project
 * (split button): the objective is not linkable. */
  onCreated?: (objective: Objective) => void;
  /** Same as {@link OpenIssueOptions.draftId}. */
  draftId?: string;
}

interface CreateContextValue {
  openCreateIssue: (opts?: OpenIssueOptions) => void;
  openCreateObjective: (opts?: OpenObjectiveOptions) => void;
  warmCreateIssue: () => void;
  warmCreateObjective: () => void;
  /** No project → nothing to create in; drives the header actions' disabled state. */
  canCreate: boolean;
}

const CreateContext = createContext<CreateContextValue | null>(null);

/** The objective the current project board is filtered to (`?objective=`), read
 *  at call time so a quick-created issue (the `C` shortcut, the header "New
 *  issue") inherits it without threading state through every trigger. */
function activeObjectiveIdFromUrl(): string | null {
  if (typeof window === "undefined") return null;
  return expandParam(new URLSearchParams(window.location.search).get("objective"));
}

/**
 * App-wide issue/objective creation (MIN-33). Mounts {@link CreateIssueDialog}
 * and {@link ObjectiveDialog} once so "New ticket/objective" works from
 * anywhere — the home page, the cross-project board, the header, the mobile "+"
 * — not just inside a project. The target project defaults to the current route,
 * else the last project a ticket was created in (see {@link lastCreateProjectId}),
 * else the first; both dialogs let the user retarget via their split button.
 * Ticket creation is optimistic and goes through the write register en
 * wait + shared cache helpers (MIN-156), like `createIssue` des
 * two board hooks.
 *
 * The per-project board keeps its own local dialog for column-scoped presets
 * (a column's "+", objective mode, assigned-to-me) and the `C` shortcut.
 */
export function CreateProvider({ children }: { children: ReactNode }) {
  const pathname = usePathname();
  const queryClient = useQueryClient();
  const { projects: allProjects } = useProjects();
  const { user } = useAuth();
  const tAccess = useTranslations("ProjectAccess");
  // Read through a ref: creation actions must keep a stable identity.
  const tAccessRef = useRef(tAccess);
  useLayoutEffect(() => { tAccessRef.current = tAccess; }, [tAccess]);
  // Creation targets only projects the viewer can write (MIN-693): a reader's
  // projects never appear in the dialogs' project picker nor as a default.
  const userId = user?.id ?? null;
  const projects = useMemo(
    () => allProjects.filter((p) => capabilitiesForProject(p, userId).canWrite),
    [allProjects, userId]
  );
  // Hidden projects (MIN-692) are not offered (MIN-751): neither listed in the
  // pickers nor chosen as the default target, unless the project is on screen
  // or explicitly requested. Requesting one still creates there.
  const isOffered = useOfferedProject();
  const offeredProjects = useMemo(() => projects.filter((p) => isOffered(p.id)), [projects, isOffered]);
  const targetInputs = useRef({ pathname, projects, offeredProjects });
  // Creation actions resolve the committed route when invoked. Publishing a
  // new action on every navigation wakes all closed card pickers, even inside
  // an Activity-hidden board. Layout synchronization avoids abandoned renders
  // changing the target used by an event on the still-visible route.
  useLayoutEffect(() => { targetInputs.current = { pathname, projects, offeredProjects }; }, [pathname, projects, offeredProjects]);
  const allProjectsRef = useRef(allProjects);
  useLayoutEffect(() => { allProjectsRef.current = allProjects; }, [allProjects]);
  // Local undo history (MIN-35): creations from the global dialog record too.
  const { record } = useUndoHistory();

  // Project whose members/categories/objectives feed each dialog. Set on the
  // first open and left in place (dialogs stay mounted for reuse). One per
  // dialog: an objective quick-added from the open issue dialog's picker must
  // not move that issue form to another project mid-edit.
  const [issueTarget, setIssueTarget] = useState<string | null>(null);
  const [objectiveTarget, setObjectiveTarget] = useState<string | null>(null);
  const [issueMounted, setIssueMounted] = useState(false);
  const [issueOpen, setIssueOpen] = useState(false);
  // Does the dialog open in dictation? Rested at EVERY opening, never
  // left lying around: otherwise a `C` typed after a ⌘⇧D would turn the microphone back on.
  const [issueDictate, setIssueDictate] = useState(false);
  // The draft the next opening restores — reset at every opening too, so a
  // later `C` opens a blank form.
  const [issueDraftId, setIssueDraftId] = useState<string | null>(null);
  const [objectiveDraftId, setObjectiveDraftId] = useState<string | null>(null);
  const [issuePresets, setIssuePresets] = useState<{
    status?: IssueStatus;
    objectiveId: string | null;
    assigneeId: string | null;
    parentId: string | null;
  }>({ objectiveId: null, assigneeId: null, parentId: null });
  const [objectiveOpen, setObjectiveOpen] = useState(false);
  const [objectiveMounted, setObjectiveMounted] = useState(false);
  // Pre-filled name + recipient of the created objective, set by quick addition
  // of a picker. The callback lives in a ref: it does not redraw anything, and it is
  // CONSUMED upon creation — a subsequent opening (shortcut `O`, header) does not
  // must not link its objective to the previous ticket.
  const [objectiveName, setObjectiveName] = useState<string | undefined>(undefined);
  const objectiveCreatedRef = useRef<((objective: Objective) => void) | null>(null);

  const { members: issueMembers } = useMembersQuery(issueTarget, issueMounted && !!issueTarget);
  const { members: objectiveMembers } = useMembersQuery(
    objectiveTarget,
    objectiveMounted && !!objectiveTarget
  );
  const { categories } = useCategoriesQuery(issueMounted ? issueTarget : null);
  const { objectives } = useObjectivesQuery(issueMounted ? issueTarget : null);

  // Optimistic (MIN-40): the card appears in the project cache AND the board
  // aggregated upon opening, the dialog closes without waiting for POST; reconciled
  // to success, removed + toast to failure. Realtime propagates to other clients.
  const createIssueGlobal = useCallback(
    async (projectId: string, input: CreateIssueInput) => {
      // Smart-fill (MIN-260): the server fills the ticket BEFORE inserting the
      // line, so no optimistic map — it would be empty for the duration of
      // remplissage. Cf. [create-issue-deferred](create-issue-deferred.ts).
      if (input.smart_fill) {
        createIssueDeferred({ queryClient, projectId, input, record });
        return null;
      }
      const optimistic = buildOptimisticIssue(
        input,
        projectId,
        user?.id ?? null,
        queryClient.getQueryData<Issue[]>(["issues", projectId]) ?? []
      );
      // Registered in the register BEFORE the patch (MIN-156): a GET response
      // played earlier can no longer make the newly created map disappear.
      const handle = issueWrites.begin({ kind: "insert", row: optimistic });
      insertIssueEverywhere(queryClient, projectId, optimistic);
      // The map names its line: the real-time echo of our creation is
      // recognized, not adopted alongside it (lib/optimistic-issue.ts).
      void createIssueApi(projectId, { ...input, id: optimistic.id }).then(
        (issue) => {
          insertIssueEverywhere(queryClient, projectId, issue);
          mergeServerIssue(queryClient, projectId, issue);
          issueWrites.settle(handle, issue);
          record({
            kind: "create",
            projectId,
            issueId: issue.id,
            snapshot: snapshotIssue(issue),
          });
        },
        (err) => {
          issueWrites.fail(handle);
          removeIssueEverywhere(queryClient, projectId, optimistic.id);
          toast.error((err as Error).message);
        }
      );
      return optimistic;
    },
    [queryClient, user, record]
  );

  // Optimistic: the objective is in the project cache and the cross-project
  // board as soon as the dialog closes (lib/optimistic-objective.ts).
  const createObjectiveGlobal = useCallback(
    async (projectId: string, input: CreateObjectiveInput) => {
      const { objective, persisted } = createObjectiveOptimistic(queryClient, projectId, input);
      // A refusal is reported by the creation itself.
      persisted.catch(() => undefined);
      return objective;
    },
    [queryClient]
  );

  /** Creation from the dialog, in the targeted project: this is what the quick addition
 * of a picker is waiting for to link the objective to his ticket. The link
 * waits for the server row, which the ticket's reference requires. */
  const createObjectiveForTarget = useCallback(
    async (projectId: string, input: CreateObjectiveInput) => {
      const { objective, persisted } = createObjectiveOptimistic(queryClient, projectId, input);
      const notify = objectiveCreatedRef.current;
      objectiveCreatedRef.current = null;
      if (notify) persisted.then(notify, () => undefined);
      else persisted.catch(() => undefined);
      return objective;
    },
    [queryClient]
  );

  const resolveTarget = useCallback(
    (projectId?: string, notify = false) => {
      const { pathname: currentPath, projects: currentProjects, offeredProjects: offered } = targetInputs.current;
      const requested = projectId ?? projectIdFromPath(currentPath);
      if (requested) {
        // Only projects the list knows as read-only are refused; an unknown
        // id (list still loading) keeps the historical behavior.
        const known = allProjectsRef.current.some((p) => p.id === requested);
        if (!known || currentProjects.some((p) => p.id === requested)) return requested;
        if (notify) {
          toast.info(tAccessRef.current("readOnlyToast"), { id: "read-only-project" });
        }
        return null;
      }
      // Outside a project, prefer the last creation target when it is still
      // accessible, then fall back to the current project's display order.
      // Every writable project hidden: creating still needs a target.
      return defaultCreateProjectId(offered.length > 0 ? offered : currentProjects, lastCreateProjectId());
    },
    []
  );

  const warmCreateIssue = useCallback(() => {
    preloadSurface(loadCreateIssueDialog);
    const pid = resolveTarget();
    if (!pid) return;
    setIssueTarget(pid);
    startTransition(() => setIssueMounted(true));
  }, [resolveTarget]);
  const warmCreateObjective = useCallback(() => {
    preloadSurface(loadObjectiveDialog);
    const pid = resolveTarget();
    if (!pid) return;
    setObjectiveTarget(pid);
    startTransition(() => setObjectiveMounted(true));
  }, [resolveTarget]);

  const openCreateIssue = useCallback(
    (opts?: OpenIssueOptions) => {
      const pid = resolveTarget(opts?.projectId, true);
      if (!pid) return;
      // On an objective-filtered board (/projects/[id]?objective=X), default the
      // new issue into that objective — so `C` and the header "New issue" land it
      // there like the column "+" already does. Only when the caller left the
      // objective unset AND we're creating in that same board's project (the
      // `?objective=` param belongs to the current route).
      const objectiveId =
        opts?.objectiveId !== undefined
          ? opts.objectiveId
          : pid === projectIdFromPath(targetInputs.current.pathname)
            ? activeObjectiveIdFromUrl()
            : null;
      setIssueTarget(pid);
      setIssuePresets({
        status: opts?.status,
        objectiveId,
        assigneeId: opts?.assigneeId ?? null,
        parentId: opts?.parentId ?? null,
      });
      setIssueDictate(opts?.dictate === true);
      setIssueDraftId(opts?.draftId ?? null);
      setIssueMounted(true);
      setIssueOpen(true);
    },
    [resolveTarget]
  );

  // The pill resolves the parent from the dialog's issues; this label only
  // stands in while they load.
  const parentRelationLabel = (projectId: string, issueId: string): string => {
    const parent = findCachedIssue(queryClient, projectId, issueId);
    const key = allProjectsRef.current.find((p) => p.id === projectId)?.key;
    return parent ? `${key ? issueIdentifier(key, parent.number) : `#${parent.number}`} ${parent.title}` : "";
  };

  const openCreateObjective = useCallback(
    (opts?: OpenObjectiveOptions) => {
      const pid = resolveTarget(opts?.projectId, true);
      if (!pid) return;
      setObjectiveTarget(pid);
      setObjectiveName(opts?.name?.trim() || undefined);
      objectiveCreatedRef.current = opts?.onCreated ?? null;
      setObjectiveDraftId(opts?.draftId ?? null);
      setObjectiveMounted(true);
      setObjectiveOpen(true);
    },
    [resolveTarget]
  );

  // App-wide quick-create shortcuts (MIN-33): `C` = new issue, `O` = new
  // objective, from anywhere. Registered in the BUBBLE phase on purpose — the
  // hover field-shortcut (`O` = change an issue's objective) and the `G O`
  // navigation chord run in the capture phase and stopImmediatePropagation when
  // they own the key, so `O` only reaches here (and opens the create dialog)
  // when nothing else claimed it. `c` is absent from the field shortcuts, so it
  // always reaches here.
  useEffect(() => {
    if (projects.length === 0) return;
    const onKeyDown = (e: KeyboardEvent) => {
      const key = eventKey(e);
      if (key !== "c" && key !== "o") return;
      if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
      const el = e.target as HTMLElement | null;
      if (
        el &&
        (el.tagName === "INPUT" ||
          el.tagName === "TEXTAREA" ||
          el.isContentEditable)
      )
        return;
      // A dialog / side panel (both Radix dialogs) owns the keyboard while open.
      if (hasVisibleOpenDialog()) return;
      e.preventDefault();
      if (key === "c") openCreateIssue();
      else openCreateObjective();
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [projects.length, openCreateIssue, openCreateObjective]);

  // ⌘⇧D everywhere: “new ticket, by voice”. This is the dictation shortcut,
  // extended to screens that had nothing to dictate — instead of doing nothing,
  // it opens the creation form with the microphone already open.
  //
  // He never TAKES the suit from whoever already wears it: each button of
  // dictation (ticket panel, Objectives page, objective dialog, return,
  // creation dialog itself) listens to it in the CAPTURE phase and does
  // `preventDefault`. This listener is in bubble phase, on the window:
  // so he goes AFTER them, and `defaultPrevented` tells him that a microphone more
  // close responded. Nothing to record, nothing to keep up to date.
  useEffect(() => {
    if (projects.length === 0) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if (!matchesModShiftCombo(e, "d")) return;
      if (e.defaultPrevented) return;
      // An open dialog/side panel holds the keyboard (same guard as
      // `C` and `O` just above): we do not stack a form.
      if (hasVisibleOpenDialog()) return;
      e.preventDefault();
      openCreateIssue({ dictate: true });
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [projects.length, openCreateIssue]);

  // Only availability changes are render state; every action reads current
  // inputs when invoked, without broadcasting route changes to card pickers.
  const canCreate = projects.length > 0;
  // The dialogs' project picker: what may be offered, plus the target itself.
  const issuePickerProjects = useMemo(
    () => projects.filter((p) => p.id === issueTarget || offeredProjects.includes(p)),
    [projects, offeredProjects, issueTarget]
  );
  const objectivePickerProjects = useMemo(
    () => projects.filter((p) => p.id === objectiveTarget || offeredProjects.includes(p)),
    [projects, offeredProjects, objectiveTarget]
  );
  const value = useMemo(
    () => ({
      openCreateIssue,
      openCreateObjective,
      warmCreateIssue,
      warmCreateObjective,
      canCreate,
    }),
    [
      openCreateIssue,
      openCreateObjective,
      warmCreateIssue,
      warmCreateObjective,
      canCreate,
    ]
  );

  return (
    <CreateContext.Provider value={value}>
      {children}
      {issueTarget && issueMounted ? (
        <CreateIssueDialog
          open={issueOpen}
          onOpenChange={setIssueOpen}
          projectId={issueTarget}
          projects={issuePickerProjects}
          members={issueMembers}
          categories={categories}
          objectives={objectives}
          onCreate={(input) => createIssueGlobal(issueTarget, input)}
          onCreateInProject={createIssueGlobal}
          initialStatus={issuePresets.status}
          initialObjectiveId={issuePresets.objectiveId}
          initialAssigneeId={issuePresets.assigneeId}
          initialSubIssueOf={issuePresets.parentId ? {
            id: issuePresets.parentId,
            label: parentRelationLabel(issueTarget, issuePresets.parentId),
          } : null}
          autoDictate={issueDictate}
          initialDraftId={issueDraftId}
        />
      ) : null}
      {objectiveTarget && objectiveMounted ? (
        <ObjectiveDialog
          open={objectiveOpen}
          onOpenChange={(next) => {
            // Closed without creating: the picker callback dies with the dialog.
            if (!next) objectiveCreatedRef.current = null;
            setObjectiveOpen(next);
          }}
          members={objectiveMembers}
          projects={objectivePickerProjects}
          projectId={objectiveTarget}
          initialName={objectiveName}
          initialDraftId={objectiveDraftId}
          onCreate={(input) => createObjectiveForTarget(objectiveTarget, input)}
          onCreateInProject={createObjectiveGlobal}
          onUpdate={async () => {}}
        />
      ) : null}
    </CreateContext.Provider>
  );
}

export function useCreate(): CreateContextValue {
  const ctx = useContext(CreateContext);
  if (!ctx) throw new Error("useCreate must be used within CreateProvider");
  return ctx;
}

/** Same context, but tolerated absent: the components shared with the board
 * PUBLIC (the boards) have no one to mount the creation dialogs, and
 * simply hide what depends on them. */
export function useCreateOptional(): CreateContextValue | null {
  return useContext(CreateContext);
}