Files
kandev/apps/web/components/kanban/mobile-menu-sheet.tsx
T
2026-07-28 21:25:39 +01:00

601 lines
19 KiB
TypeScript

"use client";
import { type ReactNode, type RefObject, useRef, useState } from "react";
import { useRouter } from "@/lib/routing/client-router";
import Link from "@/components/routing/app-link";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import {
IconAlertTriangle,
IconActivity,
IconLayoutKanban,
IconList,
IconSettings,
IconStethoscope,
IconTimeline,
} from "@tabler/icons-react";
import { AppSidebarWorkspacePicker } from "@/components/app-sidebar/app-sidebar-workspace-picker";
import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu";
import { TaskSearchInput } from "./task-search-input";
import {
MobileTasksListOptions,
type TasksListDisplayOptions,
} from "./mobile-menu-task-list-options";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { linkToTask, linkToTasks } from "@/lib/links";
import { cn } from "@/lib/utils";
import type { Repository, Task } from "@/lib/types/http";
import type { WorkflowsState } from "@/lib/state/slices";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
type MobileMenuSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId?: string;
currentPage?: "kanban" | "tasks";
searchQuery?: string;
onSearchChange?: (query: string) => void;
isSearchLoading?: boolean;
tasksListOptions?: TasksListDisplayOptions;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
};
const mobileSectionClass = "space-y-2";
const mobileSectionTitleClass = "text-sm font-medium";
const mobileFieldClass = "space-y-1.5";
const mobileFieldLabelClass = "text-xs font-medium text-muted-foreground";
const mobileControlClass = "h-10 w-full px-3 text-sm";
const mobileControlIconClass = "h-4 w-4 shrink-0";
function getRepositoryPlaceholder(loading: boolean, empty: boolean): string {
if (loading) return "Loading repositories...";
if (empty) return "No repositories";
return "Select repository";
}
type MobileDisplayOptionsProps = {
activeWorkflowId: string | null;
workflows: WorkflowsState["items"];
onWorkflowChange: (id: string | null) => void;
repositoryValue: string;
repositories: Repository[];
repositoriesLoading: boolean;
onRepositoryChange: (value: string | "all") => void;
enablePreviewOnClick: boolean | undefined;
onTogglePreviewOnClick: ((checked: boolean) => void) | undefined;
tasksListShowDetails: boolean;
onToggleTasksListShowDetails: (checked: boolean) => void;
showTaskDetails: boolean;
showWorkflow: boolean;
tasksListOptions?: TasksListDisplayOptions;
};
function MobileDisplaySelects({
activeWorkflowId,
workflows,
onWorkflowChange,
repositoryValue,
repositories,
repositoriesLoading,
onRepositoryChange,
showWorkflow,
}: Omit<
MobileDisplayOptionsProps,
| "enablePreviewOnClick"
| "onTogglePreviewOnClick"
| "tasksListShowDetails"
| "onToggleTasksListShowDetails"
| "showTaskDetails"
| "tasksListOptions"
>) {
return (
<>
{showWorkflow && (
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>Workflow</label>
<Select
value={activeWorkflowId ?? "all"}
onValueChange={(value) => onWorkflowChange(value === "all" ? null : value)}
>
<SelectTrigger className={mobileControlClass}>
<SelectValue placeholder="All workflows" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All workflows</SelectItem>
{workflows.map((workflow: WorkflowsState["items"][number]) => (
<SelectItem key={workflow.id} value={workflow.id}>
{workflow.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>Repository</label>
<Select
value={repositoryValue}
onValueChange={(value) => onRepositoryChange(value as string | "all")}
disabled={repositories.length === 0}
>
<SelectTrigger className={mobileControlClass}>
<SelectValue
placeholder={getRepositoryPlaceholder(repositoriesLoading, repositories.length === 0)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
{repositories.map((repo: Repository) => (
<SelectItem key={repo.id} value={repo.id}>
{repo.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
);
}
function MobileDisplayOptions(props: MobileDisplayOptionsProps) {
const {
enablePreviewOnClick,
onTogglePreviewOnClick,
tasksListShowDetails,
onToggleTasksListShowDetails,
showTaskDetails,
tasksListOptions,
...selectProps
} = props;
return (
<div className="space-y-4">
<label className={mobileSectionTitleClass}>Display Options</label>
<MobileDisplaySelects {...selectProps} />
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>Preview Panel</label>
<label className="flex h-10 cursor-pointer items-center gap-3 rounded-md px-0 text-sm font-medium">
<Checkbox
checked={enablePreviewOnClick ?? false}
onCheckedChange={(checked) => {
onTogglePreviewOnClick?.(!!checked);
}}
/>
<span className="text-sm">Open preview on click</span>
</label>
</div>
{showTaskDetails && (
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>List rows</label>
<label className="flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-0 text-sm font-medium">
<Checkbox
checked={tasksListShowDetails}
onCheckedChange={(checked) => onToggleTasksListShowDetails(checked === true)}
/>
<span>Show task details</span>
</label>
<p className="pl-6 text-xs text-muted-foreground">
Add repository, pull request, session, parent, and review context to List rows.
</p>
</div>
)}
{tasksListOptions && <MobileTasksListOptions options={tasksListOptions} />}
</div>
);
}
function MobileSearchSection({
searchQuery,
onSearchChange,
isSearchLoading,
}: {
searchQuery: string;
onSearchChange?: (query: string) => void;
isSearchLoading: boolean;
}) {
if (!onSearchChange) return null;
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>Search</label>
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
isLoading={isSearchLoading}
className="w-full [&_[data-slot=input]]:h-10 [&_[data-slot=input]]:pl-9 [&_[data-slot=input]]:pr-9 [&_[data-slot=input]]:text-sm"
/>
</div>
);
}
function MobileWorkspaceSection({ onOpenChange }: { onOpenChange: (open: boolean) => void }) {
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>Workspace</label>
<AppSidebarWorkspacePicker
modal={false}
onActionComplete={() => onOpenChange(false)}
triggerClassName={cn("flex-none", mobileControlClass)}
triggerTestId="mobile-workspace-trigger"
chevronTestId="mobile-workspace-trigger-chevron"
itemTestIdPrefix="mobile-workspace-item"
contentClassName="w-80 max-w-[calc(100vw-2rem)]"
/>
</div>
);
}
function MobileViewSection({
viewValue,
onViewChange,
showPipeline,
}: {
viewValue: string;
onViewChange: (value: string) => void;
showPipeline: boolean;
}) {
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>View</label>
<ToggleGroup
type="single"
value={viewValue}
onValueChange={onViewChange}
variant="outline"
className="w-full justify-start"
>
<ToggleGroupItem
value="kanban"
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconLayoutKanban className={mobileControlIconClass} />
Kanban
</ToggleGroupItem>
{showPipeline && (
<ToggleGroupItem
value="pipeline"
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconTimeline className={mobileControlIconClass} />
Pipeline
</ToggleGroupItem>
)}
<ToggleGroupItem
value="list"
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconList className={mobileControlIconClass} />
List
</ToggleGroupItem>
</ToggleGroup>
</div>
);
}
function MobileUtilityActions({
showHealthIndicator,
onOpenHealthDialog,
onOpenImproveKandev,
onOpenChange,
}: {
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
onOpenImproveKandev: () => void;
onOpenChange: (open: boolean) => void;
}) {
const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer();
const closeSheet = () => onOpenChange(false);
const openHealth = () => {
closeSheet();
onOpenHealthDialog();
};
const openStatus = () => {
closeSheet();
requestAnimationFrame(openStatusDrawer);
};
return (
<div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border">
<div className={mobileSectionTitleClass}>Utilities</div>
{statusDrawerEnabled && (
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
onClick={openStatus}
data-testid="mobile-home-status-button"
>
<IconActivity className={mobileControlIconClass} />
Status
</Button>
)}
<Button
asChild
variant="outline"
className={cn(mobileControlClass, "cursor-pointer justify-start gap-3")}
>
<Link href="/settings" onClick={closeSheet}>
<IconSettings className={mobileControlIconClass} />
Settings
</Link>
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
onClick={onOpenImproveKandev}
data-testid="mobile-improve-kandev-button"
>
<IconStethoscope className={mobileControlIconClass} />
Improve Kandev
</Button>
{showHealthIndicator && (
<Button
type="button"
variant="outline"
className={cn(mobileControlClass, "cursor-pointer justify-start gap-3")}
onClick={openHealth}
>
<IconAlertTriangle className={cn(mobileControlIconClass, "text-warning")} />
Health issues
</Button>
)}
</div>
);
}
function ResponsiveMenuSurface({
isMobile,
open,
onOpenChange,
contentRef,
onOpenAutoFocus,
children,
}: {
isMobile: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
contentRef: RefObject<HTMLDivElement | null>;
onOpenAutoFocus: (event: Event) => void;
children: ReactNode;
}) {
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
ref={contentRef}
tabIndex={-1}
onOpenAutoFocus={onOpenAutoFocus}
className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none"
>
<div
data-testid="mobile-home-menu-card"
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20"
>
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<DrawerTitle>Menu</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom,0px)]">
{children}
</div>
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
ref={contentRef}
side="right"
tabIndex={-1}
onOpenAutoFocus={onOpenAutoFocus}
className="w-full overflow-y-auto outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:max-w-sm"
>
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
{children}
</SheetContent>
</Sheet>
);
}
function MobileMenuContent({
searchQuery,
onSearchChange,
isSearchLoading,
onOpenChange,
viewValue,
onViewChange,
showPipeline,
displayOptions,
showHealthIndicator,
onOpenHealthDialog,
onOpenImproveKandev,
}: Pick<
MobileMenuSheetProps,
| "searchQuery"
| "onSearchChange"
| "isSearchLoading"
| "onOpenChange"
| "showHealthIndicator"
| "onOpenHealthDialog"
> & {
viewValue: string;
onViewChange: (value: string) => void;
showPipeline: boolean;
displayOptions: MobileDisplayOptionsProps;
onOpenImproveKandev: () => void;
}) {
return (
<div className="flex min-h-full flex-col gap-6 p-4">
<MobileSearchSection
searchQuery={searchQuery ?? ""}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading ?? false}
/>
<MobileWorkspaceSection onOpenChange={onOpenChange} />
<MobileViewSection
viewValue={viewValue}
onViewChange={onViewChange}
showPipeline={showPipeline}
/>
<MobileDisplayOptions {...displayOptions} />
<MobileIntegrationsSection onNavigate={() => onOpenChange(false)} />
<MobileUtilityActions
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
onOpenImproveKandev={onOpenImproveKandev}
onOpenChange={onOpenChange}
/>
</div>
);
}
export function MobileMenuSheet({
open,
onOpenChange,
workspaceId,
currentPage = "kanban",
searchQuery = "",
onSearchChange,
isSearchLoading = false,
tasksListOptions,
showHealthIndicator,
onOpenHealthDialog,
}: MobileMenuSheetProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const router = useRouter();
const [improveOpen, setImproveOpen] = useState(false);
const { isMobile } = useResponsiveBreakpoint();
const {
workflows,
activeWorkflowId,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
tasksListShowDetails,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
onToggleTasksListShowDetails,
effectiveTaskListingView,
onViewModeChange,
} = useKanbanDisplaySettings();
const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all");
const viewValue = currentPage === "tasks" ? "list" : effectiveTaskListingView;
const handleViewChange = (value: string) => {
if (!value) return;
if (value === "list") {
onViewModeChange("list");
if (currentPage !== "tasks") router.push(linkToTasks(workspaceId));
onOpenChange(false);
} else if (value === "kanban") {
onViewModeChange("kanban");
if (currentPage !== "kanban") router.push("/");
onOpenChange(false);
} else if (value === "pipeline" && !isMobile) {
onViewModeChange("pipeline");
if (currentPage !== "kanban") router.push("/");
onOpenChange(false);
}
};
const displayOptions = {
activeWorkflowId,
workflows,
onWorkflowChange,
repositoryValue,
repositories,
repositoriesLoading,
onRepositoryChange,
enablePreviewOnClick,
onTogglePreviewOnClick,
tasksListShowDetails,
onToggleTasksListShowDetails,
showTaskDetails: currentPage === "tasks",
showWorkflow: !isMobile || currentPage !== "kanban",
tasksListOptions: isMobile && currentPage === "tasks" ? tasksListOptions : undefined,
};
const focusMenu = (event: Event) => {
event.preventDefault();
contentRef.current?.focus({ preventScroll: true });
};
const openImproveKandev = () => {
onOpenChange(false);
requestAnimationFrame(() => setImproveOpen(true));
};
return (
<MobileMenuRender
isMobile={isMobile}
open={open}
onOpenChange={onOpenChange}
contentRef={contentRef}
onOpenAutoFocus={focusMenu}
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading}
viewValue={viewValue}
onViewChange={handleViewChange}
displayOptions={displayOptions}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
onOpenImproveKandev={openImproveKandev}
improveOpen={improveOpen}
onImproveOpenChange={setImproveOpen}
workspaceId={workspaceId ?? null}
onTaskCreated={(task) => router.push(linkToTask(task.id))}
/>
);
}
function MobileMenuRender(
props: Pick<
MobileMenuSheetProps,
| "open"
| "onOpenChange"
| "searchQuery"
| "onSearchChange"
| "isSearchLoading"
| "showHealthIndicator"
| "onOpenHealthDialog"
> & {
isMobile: boolean;
contentRef: RefObject<HTMLDivElement | null>;
onOpenAutoFocus: (event: Event) => void;
viewValue: string;
onViewChange: (value: string) => void;
displayOptions: MobileDisplayOptionsProps;
onOpenImproveKandev: () => void;
improveOpen: boolean;
onImproveOpenChange: (open: boolean) => void;
workspaceId: string | null;
onTaskCreated: (task: Task) => void;
},
) {
const { isMobile, improveOpen, onImproveOpenChange, workspaceId, onTaskCreated } = props;
return (
<>
<ResponsiveMenuSurface {...props} isMobile={isMobile}>
<MobileMenuContent {...props} showPipeline={!isMobile} />
</ResponsiveMenuSurface>
<ImproveKandevDialog
open={improveOpen}
onOpenChange={onImproveOpenChange}
workspaceId={workspaceId}
onSuccess={onTaskCreated}
/>
</>
);
}