"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(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(null); const [objectiveTarget, setObjectiveTarget] = useState(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(null); const [objectiveDraftId, setObjectiveDraftId] = useState(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(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(["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 ( {children} {issueTarget && issueMounted ? ( 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 ? ( { // 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} ); } 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); }