mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
* MUL-3903 refactor project issue surface state Co-authored-by: multica-agent <github@multica.ai> * Refactor project issue surface ownership Co-authored-by: multica-agent <github@multica.ai> * Extract shared issue surface entrypoints Co-authored-by: multica-agent <github@multica.ai> * Fix issue surface create defaults and selection reset Co-authored-by: multica-agent <github@multica.ai> * test(editor): add missing AbortSignal to suggestion items() calls The suggestion items() contract gained a required signal param; the mention/slash test call sites were never updated, breaking pnpm typecheck for @multica/views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(issues): server-side assignee_types filter on ListIssues ListGroupedIssues has taken assignee_types since squads shipped, but ListIssues never did — so the workspace Members/Agents tabs had to fetch the unfiltered workspace list and post-filter loaded pages client-side, which made column totals and load-more pagination reflect the unfiltered counts. Add the same parse + WHERE clause to ListIssues (count query shares the WHERE, so totals agree), thread the param through the TS client, and widen MyIssuesFilter so scoped list caches can carry it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(issues): route issue cache writes through a membership-aware coordinator useUpdateIssue, useBatchUpdateIssues, and the WS issue:updated handler each maintained their own similar-but-diverging patch/invalidate rules. Consolidate them into cache-coordinator.ts (applyIssueChange / rollbackIssueChange / invalidateIssueDerivatives) so local writes and remote echoes follow one rules table by construction. The coordinator is membership-aware via surface/membership.ts (true | false | unknown against each list cache's own filter contract): - a change that moves an issue off a filtered surface removes the card surgically (bucket total decremented) — fixes assignee changes leaving stale cards on My Assigned with no local safety net (previously only the WS echo recovered it), and replaces the blanket invalidate-myAll net for project moves (MUL-3669) with per-key precision - possible entry into a loaded list marks that key stale — never hard-insert; page/slot is server knowledge - stale keys flush on settle for mutations (a mid-flight refetch would stomp the optimistic state) and immediately for WS - batch updates now patch detail + inbox like single updates; the off-screen bucket-count recovery previously exclusive to the WS path now covers local mutations too Preserved invariants: synchronous optimistic patches (dnd-kit), MUL-3375 control-field stripping, and no refetch of surgically reconciled lists (the drag-flicker fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(issues): resolve surfaces via core query plan/repository with window-keyed remount Read-path convergence and the loading/empty semantics that fall out of it: - scope -> API params moves from scope.ts helpers into surface/query-plan.ts; workspace members/agents become server-filtered scoped plans (assignee_types) and the client postFilter machinery is deleted — tab counts and load-more are now exact - query selection moves behind surface/repository.ts; the views data hook no longer branches on workspace-vs-scoped plumbing - IssueSurfaceContent remounts on data-window change (wsId + scope): keepPreviousData placeholders keep sort/filter changes flicker-free within one window but must never let project A's (or workspace A's) cards impersonate B's with no loading state — cold window shows the skeleton, warm window hits cache instantly - isEmpty is only asserted from full-window data; the gantt scheduled-only projection can't prove the window is empty, so GanttView's own "no scheduled issues" empty state renders instead of the generic create-issue one - per-card project lookups hoist into a surface-level projectMap (drops a per-card useQuery), create-defaults typing tightens to IssueCreateDefaults Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(issues): count-only arithmetic for off-window status/membership changes An issue beyond a list's loaded page window used to force a full first-page refetch just to fix two column counts. When the change is CERTAIN (base entity known, membership definitive) the coordinator now does the arithmetic locally: - stayed a member + status changed: move one unit of total between the two buckets (loaded arrays untouched; hasMore stays consistent) - left the list (reassigned / re-projected): old status bucket total -1 - member-to-member reassignment: counts unaffected, not even a stale key Entering a list and any uncertainty (no base, unknown membership) still refetch — the right page/slot is server knowledge. Branches on membership OUTCOMES, not on which field changed, so future dimensions (team) join automatically. Biggest win is the WS path: agents flipping off-screen statuses no longer trigger refetch storms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(issues): deferred view-refresh indicator during placeholder revalidation Sort/date changes (and any grouped-board filter change) revalidate behind the previous snapshot — correct, but on a slow network the click felt dead: content stays put and isLoading never fires. Surface the state as isRefreshing (isPlaceholderData of the active query) and render a shared ViewRefreshIndicator in every issues header: a fixed-width slot (zero layout shift) whose spinner fades in after 300ms, so sub-second responses show nothing (NN/g) while slow ones get a working signal. Bound to the revalidation STATE, not to any particular control — any current or future server-side view change lights it automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
9.9 KiB
TypeScript
270 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, type ReactNode } from "react";
|
|
import { ListTodo, Plus } from "lucide-react";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
|
|
import { getIssueSurfaceViewStore } from "@multica/core/issues/stores/surface-view-store";
|
|
import { issueScopeKey } from "@multica/core/issues/surface/scope";
|
|
import type { Issue } from "@multica/core/types";
|
|
import { BoardView } from "../components/board-view";
|
|
import { BatchActionToolbar } from "../components/batch-action-toolbar";
|
|
import { GanttView } from "../components/gantt-view";
|
|
import { IssuesHeader } from "../components/issues-header";
|
|
import { ListView } from "../components/list-view";
|
|
import { SwimLaneView } from "../components/swimlane-view";
|
|
import { useT } from "../../i18n";
|
|
import { IssueSurfaceActionsProvider } from "./actions-context";
|
|
import { IssueSurfaceSelectionProvider } from "./selection-context";
|
|
import type { IssueCreateDefaults, IssueSurfaceProps } from "./types";
|
|
import {
|
|
useIssueSurfaceController,
|
|
type IssueSurfaceController,
|
|
} from "./use-issue-surface-controller";
|
|
|
|
export interface IssueSurfaceRenderContext {
|
|
controller: IssueSurfaceController;
|
|
issues: Issue[];
|
|
}
|
|
|
|
interface IssueSurfaceComponentProps extends IssueSurfaceProps {
|
|
renderHeader?: (context: IssueSurfaceRenderContext) => ReactNode;
|
|
renderEmpty?: (context: IssueSurfaceRenderContext) => ReactNode;
|
|
renderLoading?: (context: IssueSurfaceRenderContext) => ReactNode;
|
|
clientFilter?: (issue: Issue) => boolean;
|
|
showClientEmpty?: (context: IssueSurfaceRenderContext) => boolean;
|
|
batchToolbar?: "always" | "list" | "never";
|
|
contentClassName?: string;
|
|
}
|
|
|
|
export function IssueSurface({
|
|
scope,
|
|
modes,
|
|
surfaceKey,
|
|
createDefaults,
|
|
renderHeader,
|
|
renderEmpty,
|
|
renderLoading,
|
|
clientFilter,
|
|
showClientEmpty,
|
|
batchToolbar = "always",
|
|
contentClassName,
|
|
}: IssueSurfaceComponentProps) {
|
|
const wsId = useWorkspaceId();
|
|
const resolvedSurfaceKey = surfaceKey ?? issueScopeKey(scope);
|
|
const store = useMemo(
|
|
() => getIssueSurfaceViewStore(resolvedSurfaceKey),
|
|
[resolvedSurfaceKey],
|
|
);
|
|
|
|
return (
|
|
<ViewStoreProvider store={store}>
|
|
{/* Remount on data-window change: the list queries keep the previous
|
|
key's data as a placeholder (keepPreviousData) so sort/filter
|
|
changes within ONE surface never flash a skeleton — but reusing the
|
|
mounted observer across windows made project A's cards impersonate
|
|
project B (with isLoading=false, so no skeleton either) until B's
|
|
fetch landed. A window-keyed remount gives the new window a fresh
|
|
observer: cold window → skeleton, warm window → instant cache hit.
|
|
The window identity is wsId + scope — wsId is required because the
|
|
workspace layout does not remount on workspace switch and two
|
|
workspaces share the same scope key (e.g. "workspace:all"). Keyed
|
|
by data identity, not surfaceKey (view-preference identity). */}
|
|
<IssueSurfaceContent
|
|
key={`${wsId}:${issueScopeKey(scope)}`}
|
|
scope={scope}
|
|
modes={modes}
|
|
createDefaults={createDefaults}
|
|
renderHeader={renderHeader}
|
|
renderEmpty={renderEmpty}
|
|
renderLoading={renderLoading}
|
|
clientFilter={clientFilter}
|
|
showClientEmpty={showClientEmpty}
|
|
batchToolbar={batchToolbar}
|
|
contentClassName={contentClassName}
|
|
/>
|
|
</ViewStoreProvider>
|
|
);
|
|
}
|
|
|
|
function IssueSurfaceContent({
|
|
scope,
|
|
modes,
|
|
createDefaults,
|
|
renderHeader,
|
|
renderEmpty,
|
|
renderLoading,
|
|
clientFilter,
|
|
showClientEmpty,
|
|
batchToolbar,
|
|
contentClassName,
|
|
}: Omit<IssueSurfaceComponentProps, "surfaceKey">) {
|
|
const { t } = useT("projects");
|
|
const controller = useIssueSurfaceController({
|
|
scope,
|
|
modes,
|
|
createDefaults,
|
|
});
|
|
const issues = useMemo(
|
|
() =>
|
|
clientFilter
|
|
? controller.issues.filter((issue) => clientFilter(issue))
|
|
: controller.issues,
|
|
[clientFilter, controller.issues],
|
|
);
|
|
const swimlaneIssues = useMemo(
|
|
() =>
|
|
clientFilter
|
|
? controller.swimlaneIssues.filter((issue) => clientFilter(issue))
|
|
: controller.swimlaneIssues,
|
|
[clientFilter, controller.swimlaneIssues],
|
|
);
|
|
const renderContext = useMemo(
|
|
() => ({ controller, issues }),
|
|
[controller, issues],
|
|
);
|
|
const openCreateIssue = useCallback(
|
|
(defaults?: IssueCreateDefaults) => {
|
|
controller.openCreateIssue(defaults);
|
|
},
|
|
[controller],
|
|
);
|
|
const shouldShowClientEmpty =
|
|
!!clientFilter &&
|
|
issues.length === 0 &&
|
|
(showClientEmpty ? showClientEmpty(renderContext) : true);
|
|
const shouldShowBatchToolbar =
|
|
batchToolbar !== "never" &&
|
|
(batchToolbar === "always" || controller.viewMode === "list");
|
|
|
|
return (
|
|
<IssueSurfaceActionsProvider actions={controller.actions}>
|
|
<IssueSurfaceSelectionProvider selection={controller.selection}>
|
|
{renderHeader ? (
|
|
renderHeader(renderContext)
|
|
) : (
|
|
<IssuesHeader
|
|
scopedIssues={controller.surfaceIssues}
|
|
allowGantt={controller.allowGantt}
|
|
isRefreshing={controller.isRefreshing}
|
|
/>
|
|
)}
|
|
{controller.isLoading ? (
|
|
renderLoading ? (
|
|
renderLoading(renderContext)
|
|
) : (
|
|
<IssueSurfaceSkeleton mode={controller.viewMode} />
|
|
)
|
|
) : controller.isEmpty || shouldShowClientEmpty ? (
|
|
renderEmpty ? (
|
|
renderEmpty(renderContext)
|
|
) : (
|
|
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
|
<p className="text-sm">{t(($) => $.detail.empty_issues_title)}</p>
|
|
<p className="text-xs">{t(($) => $.detail.empty_issues_hint)}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-1"
|
|
onClick={() => controller.openCreateIssue()}
|
|
>
|
|
<Plus className="size-3.5 mr-1.5" />
|
|
{t(($) => $.detail.empty_issues_new_button)}
|
|
</Button>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className={cn("flex flex-col flex-1 min-h-0", contentClassName)}>
|
|
{controller.viewMode === "board" && (
|
|
<BoardView
|
|
issues={
|
|
controller.assigneeGroups
|
|
? controller.assigneeGroups.flatMap((group) => group.issues)
|
|
: issues
|
|
}
|
|
assigneeGroups={controller.assigneeGroups}
|
|
assigneeGroupQueryKey={controller.assigneeGroupQueryKey}
|
|
assigneeGroupFilter={controller.assigneeGroupFilter}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
hiddenStatuses={controller.hiddenStatuses}
|
|
onMoveIssue={controller.moveIssue}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
myIssuesScope={controller.loadMoreScope}
|
|
myIssuesFilter={controller.loadMoreFilter}
|
|
sort={controller.sort}
|
|
projectId={controller.projectId}
|
|
onCreateIssue={openCreateIssue}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "list" && (
|
|
<ListView
|
|
issues={issues}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
myIssuesScope={controller.loadMoreScope}
|
|
myIssuesFilter={controller.loadMoreFilter}
|
|
sort={controller.sort}
|
|
projectId={controller.projectId}
|
|
onMoveIssue={controller.moveIssue}
|
|
onCreateIssue={openCreateIssue}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "gantt" && (
|
|
<GanttView issues={controller.filteredGanttIssues} />
|
|
)}
|
|
{controller.viewMode === "swimlane" && (
|
|
<SwimLaneView
|
|
issues={issues}
|
|
unfilteredIssues={swimlaneIssues}
|
|
activeFilters={controller.activeFilters}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
hiddenStatuses={controller.hiddenStatuses}
|
|
onMoveIssue={controller.moveIssue}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
myIssuesScope={controller.loadMoreScope}
|
|
myIssuesFilter={controller.loadMoreFilter}
|
|
sort={controller.sort}
|
|
projectId={controller.projectId}
|
|
activityByIssueId={controller.activity.activityByIssueId}
|
|
onCreateIssue={openCreateIssue}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{shouldShowBatchToolbar && <BatchActionToolbar issues={issues} />}
|
|
</IssueSurfaceSelectionProvider>
|
|
</IssueSurfaceActionsProvider>
|
|
);
|
|
}
|
|
|
|
function IssueSurfaceSkeleton({ mode }: { mode: string }) {
|
|
if (mode === "list") {
|
|
return (
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-10 w-full rounded-lg" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|