mirror of
https://github.com/willchen96/mike.git
synced 2026-06-18 21:15:13 +02:00
Merge pull request #180 from willchen96/ui-fixes-modals-headers
Modal, header, mobile display and workflow UI updates
This commit is contained in:
commit
01dfcfe0d4
34 changed files with 1635 additions and 1076 deletions
|
|
@ -11,6 +11,7 @@ import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys";
|
|||
export type UserModelSettings = {
|
||||
title_model: string;
|
||||
tabular_model: string;
|
||||
legal_research_us: boolean;
|
||||
api_keys: UserApiKeys;
|
||||
};
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export async function getUserModelSettings(
|
|||
const client = db ?? createServerSupabase();
|
||||
const { data } = await client
|
||||
.from("user_profiles")
|
||||
.select("title_model, tabular_model")
|
||||
.select("title_model, tabular_model, legal_research_us")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
const api_keys = await getStoredUserApiKeys(userId, client);
|
||||
|
|
@ -40,6 +41,9 @@ export async function getUserModelSettings(
|
|||
return {
|
||||
title_model: resolveModel(data?.title_model, resolveTitleModel(api_keys)),
|
||||
tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL),
|
||||
legal_research_us:
|
||||
(data as { legal_research_us?: boolean | null } | null)
|
||||
?.legal_research_us !== false,
|
||||
api_keys,
|
||||
};
|
||||
}
|
||||
|
|
@ -51,31 +55,3 @@ export async function getUserApiKeys(
|
|||
const client = db ?? createServerSupabase();
|
||||
return getStoredUserApiKeys(userId, client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has US legal research (CourtListener) tools enabled in
|
||||
* chat. Controlled by the Features > Legal Research > Jurisdiction > US
|
||||
* toggle in account settings. Defaults to enabled — both when the user has
|
||||
* no profile row yet and when the column is missing (migration not applied),
|
||||
* so existing behaviour is preserved on partially-migrated deployments.
|
||||
*/
|
||||
export async function getLegalResearchUsEnabled(
|
||||
userId: string,
|
||||
db?: ReturnType<typeof createServerSupabase>,
|
||||
): Promise<boolean> {
|
||||
const client = db ?? createServerSupabase();
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.from("user_profiles")
|
||||
.select("legal_research_us")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (error || !data) return true;
|
||||
return (
|
||||
(data as { legal_research_us?: boolean | null })
|
||||
.legal_research_us !== false
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ import {
|
|||
} from "../lib/chatTools";
|
||||
import { completeText } from "../lib/llm";
|
||||
import {
|
||||
getLegalResearchUsEnabled,
|
||||
getUserApiKeys,
|
||||
getUserModelSettings,
|
||||
} from "../lib/userSettings";
|
||||
import { checkProjectAccess } from "../lib/access";
|
||||
|
|
@ -556,7 +554,10 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
db,
|
||||
docIndex,
|
||||
);
|
||||
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
|
||||
const {
|
||||
api_keys: apiKeys,
|
||||
legal_research_us: legalResearchUs,
|
||||
} = await getUserModelSettings(userId, db);
|
||||
const apiMessages = buildMessages(
|
||||
enrichedMessages,
|
||||
docAvailability,
|
||||
|
|
@ -586,8 +587,6 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
if (!streamFinished) streamAbort.abort();
|
||||
});
|
||||
|
||||
const apiKeys = await getUserApiKeys(userId, db);
|
||||
|
||||
try {
|
||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ import {
|
|||
type ChatMessage,
|
||||
} from "../lib/chatTools";
|
||||
import {
|
||||
getLegalResearchUsEnabled,
|
||||
getUserApiKeys,
|
||||
getUserModelSettings,
|
||||
} from "../lib/userSettings";
|
||||
import { checkProjectAccess } from "../lib/access";
|
||||
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
|
||||
|
|
@ -144,7 +143,10 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
|
||||
const {
|
||||
api_keys: apiKeys,
|
||||
legal_research_us: legalResearchUs,
|
||||
} = await getUserModelSettings(userId, db);
|
||||
const apiMessages = buildMessages(
|
||||
messagesForLLM,
|
||||
docAvailability,
|
||||
|
|
@ -168,8 +170,6 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
if (!streamFinished) streamAbort.abort();
|
||||
});
|
||||
|
||||
const apiKeys = await getUserApiKeys(userId, db);
|
||||
|
||||
try {
|
||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||
|
||||
|
|
|
|||
|
|
@ -70,20 +70,6 @@ async function attachDocumentOwnerLabels(
|
|||
.filter((id, index, arr) => arr.indexOf(id) === index);
|
||||
if (ownerIds.length === 0) return;
|
||||
|
||||
const emailByUserId = new Map<string, string>();
|
||||
const userResults = await Promise.allSettled(
|
||||
ownerIds.map(async (id) => {
|
||||
const { data, error } = await db.auth.admin.getUserById(id);
|
||||
if (error) throw error;
|
||||
return { id, email: data.user?.email ?? null };
|
||||
}),
|
||||
);
|
||||
for (const result of userResults) {
|
||||
if (result.status === "fulfilled" && result.value.email) {
|
||||
emailByUserId.set(result.value.id, result.value.email);
|
||||
}
|
||||
}
|
||||
|
||||
const displayNameByUserId = new Map<string, string>();
|
||||
const { data: profiles, error: profilesError } = await db
|
||||
.from("user_profiles")
|
||||
|
|
@ -108,7 +94,7 @@ async function attachDocumentOwnerLabels(
|
|||
owner_display_name?: string | null;
|
||||
})[]) {
|
||||
if (!doc.user_id) continue;
|
||||
doc.owner_email = emailByUserId.get(doc.user_id) ?? null;
|
||||
doc.owner_email = null;
|
||||
doc.owner_display_name = displayNameByUserId.get(doc.user_id) ?? null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
|
||||
import { SidebarContext } from "@/app/contexts/SidebarContext";
|
||||
import { PageChromeContext } from "@/app/contexts/PageChromeContext";
|
||||
import { AppSidebar } from "@/app/components/shared/AppSidebar";
|
||||
|
||||
export default function MikeLayout({
|
||||
|
|
@ -15,6 +16,8 @@ export default function MikeLayout({
|
|||
}) {
|
||||
const { isAuthenticated, authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [mobileActionsContainer, setMobileActionsContainer] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
const [isSidebarOpenDesktop, setIsSidebarOpenDesktop] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
|
|
@ -58,6 +61,13 @@ export default function MikeLayout({
|
|||
}
|
||||
};
|
||||
|
||||
const handleMobileActionsContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setMobileActionsContainer(node);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
|
|
@ -76,46 +86,52 @@ export default function MikeLayout({
|
|||
|
||||
return (
|
||||
<ChatHistoryProvider>
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
setSidebarOpen: (open) => {
|
||||
const isSmall =
|
||||
typeof window !== "undefined" &&
|
||||
window.innerWidth < 768;
|
||||
if (isSmall) {
|
||||
if (!open) setIsSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setIsSidebarOpen(open);
|
||||
setIsSidebarOpenDesktop(open);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="h-dvh flex flex-col bg-gray-50/80">
|
||||
<div className="flex-1 flex min-w-0 overflow-visible">
|
||||
<AppSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onToggle={handleSidebarToggle}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
|
||||
{/* Mobile header */}
|
||||
<div className="flex md:hidden items-center gap-3 px-4 pt-3 pb-1 shrink-0">
|
||||
<button
|
||||
onClick={handleSidebarToggle}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
|
||||
title="Open sidebar"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<PageChromeContext.Provider value={{ mobileActionsContainer }}>
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
setSidebarOpen: (open) => {
|
||||
const isSmall =
|
||||
typeof window !== "undefined" &&
|
||||
window.innerWidth < 768;
|
||||
if (isSmall) {
|
||||
if (!open) setIsSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setIsSidebarOpen(open);
|
||||
setIsSidebarOpenDesktop(open);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="h-dvh flex flex-col bg-gray-50/80">
|
||||
<div className="flex-1 flex min-w-0 overflow-visible">
|
||||
<AppSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onToggle={handleSidebarToggle}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
|
||||
{/* Mobile header */}
|
||||
<div className="relative z-20 flex md:hidden items-center gap-3 overflow-visible px-4 pt-3 pb-2 shrink-0">
|
||||
<button
|
||||
onClick={handleSidebarToggle}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
|
||||
title="Open sidebar"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
ref={handleMobileActionsContainerRef}
|
||||
className="ml-auto flex min-w-0 flex-1 items-center justify-end"
|
||||
/>
|
||||
</div>
|
||||
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
</PageChromeContext.Provider>
|
||||
</ChatHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -782,18 +782,15 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
project
|
||||
? {
|
||||
label: project.name,
|
||||
suffix: project.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null,
|
||||
onClick: () => router.push(`/projects/${projectId}`),
|
||||
onClick: () =>
|
||||
router.push(`/projects/${projectId}?tab=assistant`),
|
||||
title: "Back to project",
|
||||
}
|
||||
: {
|
||||
loading: true,
|
||||
skeletonClassName: "w-32",
|
||||
onClick: () => router.push(`/projects/${projectId}`),
|
||||
onClick: () =>
|
||||
router.push(`/projects/${projectId}?tab=assistant`),
|
||||
title: "Back to project",
|
||||
},
|
||||
chatLoaded
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export default function TabularReviewsPage() {
|
|||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
|
|
|
|||
13
frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx
Normal file
13
frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { WorkflowDetailPage } from "@/app/components/workflows/WorkflowDetailPage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function AssistantWorkflowPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <WorkflowDetailPage id={id} workflowType="assistant" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { WorkflowDetailPage } from "@/app/components/workflows/WorkflowDetailPage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function TabularReviewWorkflowPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <WorkflowDetailPage id={id} workflowType="tabular" />;
|
||||
}
|
||||
|
|
@ -1,18 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronLeft, Search, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
||||
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { WorkflowPickerModal } from "../workflows/WorkflowPickerModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (workflow: Workflow) => void;
|
||||
onSelect: (workflow: Workflow) => Promise<void> | void;
|
||||
projectName?: string;
|
||||
projectCmNumber?: string | null;
|
||||
initialWorkflowId?: string;
|
||||
|
|
@ -26,70 +20,6 @@ export function AssistantWorkflowModal({
|
|||
projectCmNumber,
|
||||
initialWorkflowId,
|
||||
}: Props) {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
const builtins = BUILT_IN_WORKFLOWS.filter(
|
||||
(w) => w.type === "assistant",
|
||||
);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setWorkflows(builtins);
|
||||
setLoading(true);
|
||||
if (initialWorkflowId) {
|
||||
const match = builtins.find((w) => w.id === initialWorkflowId);
|
||||
if (match) setSelected(match);
|
||||
}
|
||||
});
|
||||
listWorkflows("assistant")
|
||||
.then((custom) => {
|
||||
if (cancelled) return;
|
||||
const all = [...builtins, ...custom];
|
||||
setWorkflows(all);
|
||||
if (initialWorkflowId) {
|
||||
const match = all.find((w) => w.id === initialWorkflowId);
|
||||
if (match) setSelected(match);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (initialWorkflowId) {
|
||||
const match = builtins.find((w) => w.id === initialWorkflowId);
|
||||
if (match) setSelected(match);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [open, initialWorkflowId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const filteredWorkflows = search
|
||||
? workflows.filter((w) => w.title.toLowerCase().includes(search.toLowerCase()))
|
||||
: workflows;
|
||||
|
||||
function handleClose() {
|
||||
setSelected(null);
|
||||
setSearch("");
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleUse() {
|
||||
if (!selected) return;
|
||||
onSelect(selected);
|
||||
handleClose();
|
||||
}
|
||||
|
||||
const breadcrumbs = projectName
|
||||
? [
|
||||
"Projects",
|
||||
|
|
@ -100,162 +30,14 @@ export function AssistantWorkflowModal({
|
|||
: ["Assistant", "Add workflow"];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<WorkflowPickerModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size={selected ? "xl" : "lg"}
|
||||
onClose={onClose}
|
||||
onSelect={onSelect}
|
||||
workflowType="assistant"
|
||||
breadcrumbs={breadcrumbs}
|
||||
primaryAction={{
|
||||
label: "Use",
|
||||
type: "button",
|
||||
onClick: handleUse,
|
||||
disabled: !selected,
|
||||
}}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
|
||||
{/* Left panel — workflow list */}
|
||||
<div
|
||||
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="px-2 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search workflows…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-1 px-2 pb-2 pt-1">
|
||||
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
|
||||
>
|
||||
<div
|
||||
className="h-3 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredWorkflows.length === 0 ? (
|
||||
<p className="py-8 text-sm text-center text-gray-400">
|
||||
{search ? "No matches found" : "No assistant workflows found"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-y-auto">
|
||||
{filteredWorkflows.map((wf) => (
|
||||
<button
|
||||
key={wf.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev?.id === wf.id ? null : wf,
|
||||
)
|
||||
}
|
||||
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${
|
||||
selected?.id === wf.id
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate text-gray-800">
|
||||
{wf.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{wf.is_system ? "Built-in" : "Custom"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel — prompt preview */}
|
||||
{selected && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between py-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Workflow Prompt
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 text-sm border border-gray-200 rounded-md text-gray-600 leading-relaxed font-serif bg-gray-50">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-base font-semibold text-gray-900 mt-4 mb-1 first:mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-sm font-semibold text-gray-900 mt-3 mb-1 first:mt-0">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xs font-semibold text-gray-900 mt-2 mb-0.5 first:mt-0">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-4 mb-2 space-y-0.5">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-4 mb-2 space-y-0.5">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li>{children}</li>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-gray-800">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic">
|
||||
{children}
|
||||
</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{selected.prompt_md ??
|
||||
"_No prompt defined._"}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
primaryLabel="Use"
|
||||
initialWorkflowId={initialWorkflowId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function ProjectAssistantTab({
|
|||
setRenamingChatId: Dispatch<SetStateAction<string | null>>;
|
||||
setRenameChatValue: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
216
frontend/src/app/components/projects/ProjectDetailsModal.tsx
Normal file
216
frontend/src/app/components/projects/ProjectDetailsModal.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Users } from "lucide-react";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||
|
||||
interface ProjectDetailsModalProps {
|
||||
open: boolean;
|
||||
project: Project | null;
|
||||
canEdit: boolean;
|
||||
currentUserDisplayName?: string | null;
|
||||
currentUserEmail?: string | null;
|
||||
fetchPeople: (projectId: string) => Promise<ProjectPeople>;
|
||||
onClose: () => void;
|
||||
onSave: (values: { name: string; cmNumber: string }) => Promise<void>;
|
||||
onShareProject: () => void;
|
||||
}
|
||||
|
||||
export function ProjectDetailsModal({
|
||||
open,
|
||||
project,
|
||||
canEdit,
|
||||
currentUserDisplayName,
|
||||
currentUserEmail,
|
||||
fetchPeople,
|
||||
onClose,
|
||||
onSave,
|
||||
onShareProject,
|
||||
}: ProjectDetailsModalProps) {
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [cmDraft, setCmDraft] = useState("");
|
||||
const [people, setPeople] = useState<ProjectPeople | null>(null);
|
||||
const [peopleLoading, setPeopleLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !project) return;
|
||||
setNameDraft(project.name);
|
||||
setCmDraft(project.cm_number ?? "");
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}, [open, project]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !project) return;
|
||||
const isPrivateOwnedProject =
|
||||
project.is_owner !== false &&
|
||||
(!Array.isArray(project.shared_with) ||
|
||||
project.shared_with.length === 0);
|
||||
if (isPrivateOwnedProject) {
|
||||
setPeople(null);
|
||||
setPeopleLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPeopleLoading(true);
|
||||
fetchPeople(project.id)
|
||||
.then((data) => {
|
||||
if (!cancelled) setPeople(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPeople(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPeopleLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, project, fetchPeople]);
|
||||
|
||||
const trimmedName = nameDraft.trim();
|
||||
const trimmedCm = cmDraft.trim();
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!project) return false;
|
||||
return (
|
||||
trimmedName !== project.name ||
|
||||
trimmedCm !== (project.cm_number ?? "")
|
||||
);
|
||||
}, [project, trimmedCm, trimmedName]);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const accessLabel =
|
||||
Array.isArray(project.shared_with) && project.shared_with.length > 0
|
||||
? "Shared"
|
||||
: "Private";
|
||||
const isPrivateOwnedProject =
|
||||
project.is_owner !== false && accessLabel === "Private";
|
||||
const ownerLabel =
|
||||
people?.owner.display_name?.trim() ||
|
||||
people?.owner.email?.trim() ||
|
||||
(isPrivateOwnedProject ? currentUserDisplayName?.trim() : "") ||
|
||||
(isPrivateOwnedProject ? currentUserEmail?.trim() : "") ||
|
||||
"Unknown";
|
||||
|
||||
async function handleSave() {
|
||||
if (!canEdit || saving || !hasChanges || !trimmedName) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({ name: trimmedName, cmNumber: trimmedCm });
|
||||
setSaved(true);
|
||||
} catch {
|
||||
setError("Could not update project details.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Projects", project.name, "Details"]}
|
||||
secondaryAction={{
|
||||
label: "Share Project",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
onClick: onShareProject,
|
||||
}}
|
||||
footerStatus={
|
||||
error ? (
|
||||
<span className="text-sm text-red-600">{error}</span>
|
||||
) : saved ? (
|
||||
<span className="text-sm text-gray-400">Updated</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={
|
||||
canEdit
|
||||
? {
|
||||
label: saving ? "Updating..." : "Update",
|
||||
onClick: () => void handleSave(),
|
||||
disabled: saving || !hasChanges || !trimmedName,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cancelAction={canEdit ? undefined : false}
|
||||
>
|
||||
<div className="flex flex-col gap-5 py-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="project-details-name"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
id="project-details-name"
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
setNameDraft(e.target.value);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="project-details-cm"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
CM
|
||||
</label>
|
||||
<input
|
||||
id="project-details-cm"
|
||||
value={cmDraft}
|
||||
onChange={(e) => {
|
||||
setCmDraft(e.target.value);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
placeholder="No CM"
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100 text-sm">
|
||||
<DetailRow label="Ownership" value={accessLabel} />
|
||||
<DetailRow
|
||||
label="Owner"
|
||||
value={
|
||||
peopleLoading && !isPrivateOwnedProject ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading
|
||||
</span>
|
||||
) : (
|
||||
ownerLabel
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ import {
|
|||
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
|
|
@ -83,6 +84,7 @@ import {
|
|||
type ProjectTab,
|
||||
} from "./ProjectPageParts";
|
||||
import { DocumentSidePanel } from "./DocumentSidePanel";
|
||||
import { ProjectDetailsModal } from "./ProjectDetailsModal";
|
||||
import { ProjectAssistantTab } from "./ProjectAssistantTab";
|
||||
import { ProjectReviewsTab } from "./ProjectReviewsTab";
|
||||
|
||||
|
|
@ -274,9 +276,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
: initialTab;
|
||||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
||||
const [projectDetailsOpen, setProjectDetailsOpen] = useState(false);
|
||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||
const { user } = useAuth();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const { profile } = useUserProfile();
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
|
||||
const [viewingDocVersion, setViewingDocVersion] = useState<{
|
||||
id: string;
|
||||
|
|
@ -1133,33 +1137,30 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleTitleCommit(newName: string) {
|
||||
if (!newName || newName === project?.name) return;
|
||||
// Server-side this would 404 silently for non-owners; surface a
|
||||
// clear permission warning instead.
|
||||
async function handleProjectDetailsSave(values: {
|
||||
name: string;
|
||||
cmNumber: string;
|
||||
}) {
|
||||
if (project && project.is_owner === false) {
|
||||
setOwnerOnlyAction("rename this project");
|
||||
setOwnerOnlyAction("edit project details");
|
||||
return;
|
||||
}
|
||||
setProject((prev) => (prev ? { ...prev, name: newName } : prev));
|
||||
await updateProject(projectId, { name: newName });
|
||||
}
|
||||
|
||||
async function handleCmNumberCommit(newCmNumber: string) {
|
||||
if (project && project.is_owner === false) {
|
||||
setOwnerOnlyAction("rename this project's CM number");
|
||||
return;
|
||||
}
|
||||
const trimmed = newCmNumber.trim();
|
||||
if (trimmed === (project?.cm_number ?? "")) return;
|
||||
setProject((prev) =>
|
||||
prev ? { ...prev, cm_number: trimmed || null } : prev,
|
||||
);
|
||||
const name = values.name.trim();
|
||||
const cmNumber = values.cmNumber.trim();
|
||||
if (!name) return;
|
||||
const updated = await updateProject(projectId, {
|
||||
cm_number: trimmed,
|
||||
name,
|
||||
cm_number: cmNumber,
|
||||
});
|
||||
setProject((prev) =>
|
||||
prev ? { ...prev, cm_number: updated.cm_number } : prev,
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
name: updated.name,
|
||||
cm_number: updated.cm_number,
|
||||
updated_at: updated.updated_at,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2519,9 +2520,8 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
docsCount={docs.length}
|
||||
isOwner={project?.is_owner !== false}
|
||||
onBackToProjects={() => router.push("/projects")}
|
||||
onRenameProject={handleTitleCommit}
|
||||
onRenameCmNumber={handleCmNumberCommit}
|
||||
onOwnerOnly={setOwnerOnlyAction}
|
||||
onOpenDetails={() => setProjectDetailsOpen(true)}
|
||||
onDeleteProject={requestProjectDelete}
|
||||
onSearchChange={setSearch}
|
||||
onOpenPeople={() => setPeopleModalOpen(true)}
|
||||
|
|
@ -3426,6 +3426,21 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
onClose={() => setOwnerOnlyAction(null)}
|
||||
/>
|
||||
|
||||
<ProjectDetailsModal
|
||||
open={projectDetailsOpen}
|
||||
project={project}
|
||||
canEdit={project?.is_owner !== false}
|
||||
currentUserDisplayName={profile?.displayName ?? null}
|
||||
currentUserEmail={user?.email ?? null}
|
||||
fetchPeople={getProjectPeople}
|
||||
onClose={() => setProjectDetailsOpen(false)}
|
||||
onSave={handleProjectDetailsSave}
|
||||
onShareProject={() => {
|
||||
setProjectDetailsOpen(false);
|
||||
setPeopleModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmPopup
|
||||
open={deleteProjectConfirmOpen}
|
||||
title="Delete project?"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { type CSSProperties, type KeyboardEvent, useState } from "react";
|
||||
import { type CSSProperties, useRef, useState } from "react";
|
||||
import {
|
||||
CornerDownRight,
|
||||
File,
|
||||
FileText,
|
||||
Hash,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
|
|
@ -108,8 +108,10 @@ export function DocVersionHistory({
|
|||
null,
|
||||
);
|
||||
const [editingValue, setEditingValue] = useState("");
|
||||
const committingVersionId = useRef<string | null>(null);
|
||||
|
||||
const commit = async (versionId: string) => {
|
||||
if (committingVersionId.current === versionId) return;
|
||||
const trimmed = editingValue.trim();
|
||||
const previousFilename = versions
|
||||
.find((version) => version.id === versionId)
|
||||
|
|
@ -123,6 +125,7 @@ export function DocVersionHistory({
|
|||
return;
|
||||
}
|
||||
|
||||
committingVersionId.current = versionId;
|
||||
setEditingVersionId(null);
|
||||
const next = trimmed.length > 0 ? trimmed : null;
|
||||
await onRenameVersion?.(versionId, next);
|
||||
|
|
@ -260,6 +263,7 @@ export function DocVersionHistory({
|
|||
e.preventDefault();
|
||||
void commit(v.id);
|
||||
} else if (e.key === "Escape") {
|
||||
committingVersionId.current = null;
|
||||
setEditingVersionId(null);
|
||||
}
|
||||
}}
|
||||
|
|
@ -322,6 +326,7 @@ export function DocVersionHistory({
|
|||
onRename={
|
||||
onRenameVersion
|
||||
? () => {
|
||||
committingVersionId.current = null;
|
||||
setEditingVersionId(v.id);
|
||||
setEditingValue(
|
||||
v.filename ?? "",
|
||||
|
|
@ -355,9 +360,8 @@ export function ProjectPageHeader({
|
|||
docsCount,
|
||||
isOwner,
|
||||
onBackToProjects,
|
||||
onRenameProject,
|
||||
onRenameCmNumber,
|
||||
onOwnerOnly,
|
||||
onOpenDetails,
|
||||
onDeleteProject,
|
||||
onSearchChange,
|
||||
onOpenPeople,
|
||||
|
|
@ -371,69 +375,26 @@ export function ProjectPageHeader({
|
|||
docsCount: number;
|
||||
isOwner: boolean;
|
||||
onBackToProjects: () => void;
|
||||
onRenameProject: (name: string) => void;
|
||||
onRenameCmNumber: (cmNumber: string) => void;
|
||||
onOwnerOnly: (action: string) => void;
|
||||
onOpenDetails: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
onOpenPeople: () => void;
|
||||
onNewChat: () => void;
|
||||
onNewReview: () => void;
|
||||
}) {
|
||||
const [editingField, setEditingField] = useState<"name" | "cm" | null>(
|
||||
null,
|
||||
);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const startEdit = (field: "name" | "cm") => {
|
||||
const requestRename = () => {
|
||||
if (!project) return;
|
||||
if (!isOwner) {
|
||||
onOwnerOnly(
|
||||
field === "name"
|
||||
? "rename this project"
|
||||
: "rename this project's CM number",
|
||||
);
|
||||
onOwnerOnly("rename this project");
|
||||
return;
|
||||
}
|
||||
setDraft(field === "name" ? project.name : project.cm_number ?? "");
|
||||
setEditingField(field);
|
||||
onOpenDetails();
|
||||
};
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editingField) return;
|
||||
const value = draft.trim();
|
||||
if (editingField === "name") onRenameProject(value);
|
||||
else onRenameCmNumber(value);
|
||||
setEditingField(null);
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
const editInputClassName =
|
||||
"min-w-0 cursor-text border-0 border-b border-gray-200 bg-transparent font-serif text-2xl font-medium outline-none transition-colors focus:border-gray-300";
|
||||
|
||||
const titleLabel = !project ? undefined : editingField === "name" ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
size={Math.max(draft.length + 1, 3)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={commitEdit}
|
||||
className={`${editInputClassName} text-gray-900`}
|
||||
aria-label="Rename project"
|
||||
/>
|
||||
) : (
|
||||
const titleLabel = !project ? undefined : (
|
||||
<span
|
||||
onClick={() => startEdit("name")}
|
||||
onClick={requestRename}
|
||||
className="inline-block cursor-text"
|
||||
title="Rename"
|
||||
>
|
||||
|
|
@ -441,31 +402,6 @@ export function ProjectPageHeader({
|
|||
</span>
|
||||
);
|
||||
|
||||
const cmSuffix = !project ? null : editingField === "cm" ? (
|
||||
<span className="ml-1 inline-flex items-center text-gray-400">
|
||||
(#
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
size={Math.max(draft.length + 1, 3)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={commitEdit}
|
||||
className={`${editInputClassName} text-gray-400`}
|
||||
aria-label="Rename CM number"
|
||||
/>
|
||||
)
|
||||
</span>
|
||||
) : project.cm_number ? (
|
||||
<span
|
||||
onClick={() => startEdit("cm")}
|
||||
className="ml-1 inline-block cursor-text text-gray-400"
|
||||
title="Rename CM"
|
||||
>
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
|
|
@ -478,7 +414,6 @@ export function ProjectPageHeader({
|
|||
...(project
|
||||
? {
|
||||
label: titleLabel,
|
||||
suffix: cmSuffix,
|
||||
cursor: "text",
|
||||
}
|
||||
: {
|
||||
|
|
@ -511,12 +446,12 @@ export function ProjectPageHeader({
|
|||
{
|
||||
label: "Rename",
|
||||
icon: Pencil,
|
||||
onSelect: () => startEdit("name"),
|
||||
onSelect: requestRename,
|
||||
},
|
||||
{
|
||||
label: "Rename CM",
|
||||
icon: Hash,
|
||||
onSelect: () => startEdit("cm"),
|
||||
label: "Project Details",
|
||||
icon: Info,
|
||||
onSelect: onOpenDetails,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
|
|
@ -535,6 +470,7 @@ export function ProjectPageHeader({
|
|||
{
|
||||
onClick: onNewChat,
|
||||
disabled: creatingChat,
|
||||
compact: true,
|
||||
icon: creatingChat ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
|
@ -549,6 +485,7 @@ export function ProjectPageHeader({
|
|||
{
|
||||
onClick: onNewReview,
|
||||
disabled: docsCount === 0 || creatingReview,
|
||||
compact: true,
|
||||
icon: creatingReview ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function ProjectReviewsTab({
|
|||
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
|
||||
setRenameReviewValue: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function ProjectsOverview() {
|
|||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, authLoading } = useAuth();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import {
|
|||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { usePageChrome } from "@/app/contexts/PageChromeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PageHeaderBreadcrumb {
|
||||
label?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
onClick?: () => void;
|
||||
cursor?: "text";
|
||||
loading?: boolean;
|
||||
|
|
@ -31,6 +32,7 @@ type PageHeaderButtonAction = {
|
|||
title?: string;
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
compact?: boolean;
|
||||
tooltip?: ReactNode;
|
||||
};
|
||||
|
||||
|
|
@ -108,6 +110,7 @@ export function PageHeader({
|
|||
breadcrumbs,
|
||||
loading = false,
|
||||
}: PageHeaderProps) {
|
||||
const { mobileActionsContainer } = usePageChrome();
|
||||
const headerContent = breadcrumbs?.length ? (
|
||||
<PageHeaderBreadcrumbs items={breadcrumbs} />
|
||||
) : (
|
||||
|
|
@ -124,6 +127,7 @@ export function PageHeader({
|
|||
? [{ actions: actionItems, gap: actionGap }]
|
||||
: [])
|
||||
);
|
||||
const hasActions = groupedActionItems.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -131,39 +135,70 @@ export function PageHeader({
|
|||
"flex justify-between",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
"px-4 md:px-10",
|
||||
"pb-4 pt-5.5",
|
||||
"min-h-[76px] pb-4 pt-5.5",
|
||||
shrink && "shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{headerContent}
|
||||
{groupedActionItems.length > 0 && (
|
||||
<div className="ml-4 flex shrink-0 items-center gap-3">
|
||||
{groupedActionItems.map((group, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
actionGapClassName[group.gap],
|
||||
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
{group.actions.map((action, index) => (
|
||||
<Fragment key={index}>
|
||||
<PageHeaderActionRenderer
|
||||
action={action}
|
||||
disabled={actionsDisabled}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{hasActions && (
|
||||
<div className="ml-4 hidden shrink-0 items-center gap-3 md:flex">
|
||||
<PageHeaderActionGroups
|
||||
groupedActionItems={groupedActionItems}
|
||||
actionsDisabled={actionsDisabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasActions &&
|
||||
mobileActionsContainer &&
|
||||
createPortal(
|
||||
<div className="flex min-w-0 items-center justify-end gap-3 overflow-visible py-2 -my-2">
|
||||
<PageHeaderActionGroups
|
||||
groupedActionItems={groupedActionItems}
|
||||
actionsDisabled={actionsDisabled}
|
||||
/>
|
||||
</div>,
|
||||
mobileActionsContainer,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeaderActionGroups({
|
||||
groupedActionItems,
|
||||
actionsDisabled,
|
||||
}: {
|
||||
groupedActionItems: {
|
||||
actions: PageHeaderAction[];
|
||||
gap: PageHeaderActionGap;
|
||||
}[];
|
||||
actionsDisabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{groupedActionItems.map((group, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
actionGapClassName[group.gap],
|
||||
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
{group.actions.map((action, index) => (
|
||||
<Fragment key={index}>
|
||||
<PageHeaderActionRenderer
|
||||
action={action}
|
||||
disabled={actionsDisabled}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeActionGroup(
|
||||
group: PageHeaderActionGroup,
|
||||
fallbackGap: PageHeaderActionGap,
|
||||
|
|
@ -264,6 +299,7 @@ function PageHeaderButtonActionControl({
|
|||
aria-label={action.title}
|
||||
variant={action.variant}
|
||||
iconOnly={iconOnly}
|
||||
compact={action.compact}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
|
|
@ -394,11 +430,13 @@ type PageHeaderActionButtonProps = Omit<
|
|||
> & {
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
type PageHeaderActionControlClassNameOptions = {
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
|
@ -406,12 +444,13 @@ type PageHeaderActionControlClassNameOptions = {
|
|||
function pageHeaderActionControlClassName({
|
||||
variant = "default",
|
||||
iconOnly = false,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: PageHeaderActionControlClassNameOptions = {}) {
|
||||
return cn(
|
||||
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
|
||||
iconOnly ? "w-7" : "gap-1.5 px-3",
|
||||
iconOnly ? "w-7" : compact ? "gap-1.5 px-2" : "gap-1.5 px-3",
|
||||
disabled ? "cursor-default" : "cursor-pointer",
|
||||
"hover:bg-gray-100 active:bg-gray-100",
|
||||
variant === "danger"
|
||||
|
|
@ -425,6 +464,7 @@ function PageHeaderActionButton({
|
|||
children,
|
||||
variant = "default",
|
||||
iconOnly = false,
|
||||
compact = false,
|
||||
disabled,
|
||||
...props
|
||||
}: PageHeaderActionButtonProps) {
|
||||
|
|
@ -434,6 +474,7 @@ function PageHeaderActionButton({
|
|||
className={pageHeaderActionControlClassName({
|
||||
variant,
|
||||
iconOnly,
|
||||
compact,
|
||||
disabled,
|
||||
})}
|
||||
{...props}
|
||||
|
|
@ -444,7 +485,6 @@ function PageHeaderActionButton({
|
|||
}
|
||||
|
||||
function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
||||
const current = items[items.length - 1];
|
||||
const parent = [...items]
|
||||
.slice(0, -1)
|
||||
.reverse()
|
||||
|
|
@ -462,21 +502,15 @@ function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
|||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="hidden min-w-0 items-center gap-1.5 sm:flex">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{items.map((item, index) => (
|
||||
<BreadcrumbItem
|
||||
key={index}
|
||||
item={item}
|
||||
current={index === items.length - 1}
|
||||
showSuffix
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 sm:hidden">
|
||||
{current ? (
|
||||
<BreadcrumbItem item={current} current showSuffix={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -484,11 +518,9 @@ function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
|||
function BreadcrumbItem({
|
||||
item,
|
||||
current,
|
||||
showSuffix,
|
||||
}: {
|
||||
item: PageHeaderBreadcrumb;
|
||||
current: boolean;
|
||||
showSuffix: boolean;
|
||||
}) {
|
||||
const content = item.loading ? (
|
||||
<div
|
||||
|
|
@ -507,7 +539,6 @@ function BreadcrumbItem({
|
|||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{showSuffix && item.suffix}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -520,9 +551,13 @@ function BreadcrumbItem({
|
|||
? "text-gray-500 hover:text-gray-700"
|
||||
: "text-gray-500",
|
||||
);
|
||||
const wrapperClassName = cn(
|
||||
"min-w-0 items-center gap-1.5",
|
||||
current ? "flex" : "hidden sm:flex",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={wrapperClassName}>
|
||||
{current ? (
|
||||
<span className={className}>{content}</span>
|
||||
) : item.onClick ? (
|
||||
|
|
@ -533,6 +568,6 @@ function BreadcrumbItem({
|
|||
<span className={className}>{content}</span>
|
||||
)}
|
||||
{!current && <span className="shrink-0 text-gray-300">›</span>}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,9 +207,10 @@ export function PeopleModal({
|
|||
} with access.`
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
{/* Add-member row */}
|
||||
{onSharedWithChange && (
|
||||
<div className="pt-1 pb-2">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
|
|
@ -269,90 +270,92 @@ export function PeopleModal({
|
|||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Section heading */}
|
||||
<div className="pt-3 pb-1 flex items-center gap-2">
|
||||
<h3 className="text-xs font-medium text-gray-500">
|
||||
People with Access
|
||||
</h3>
|
||||
{peopleLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Member list */}
|
||||
{roster.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
No one has access yet.
|
||||
<section className="min-h-0 flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h3 className="text-xs font-medium text-gray-500">
|
||||
People with Access
|
||||
</h3>
|
||||
{peopleLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
|
||||
{roster.map((entry) => {
|
||||
const isYou =
|
||||
!!currentUserEmail &&
|
||||
entry.email.toLowerCase() ===
|
||||
currentUserEmail.toLowerCase();
|
||||
const isRemoving =
|
||||
busy === "remove" &&
|
||||
removingEmail === entry.email;
|
||||
const primary =
|
||||
entry.display_name?.trim() || entry.email;
|
||||
const showSecondary =
|
||||
!!entry.display_name?.trim() &&
|
||||
primary !== entry.email;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.role}-${entry.email}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-800">
|
||||
{primary}
|
||||
{isYou && (
|
||||
<span className="ml-1.5 text-xs text-gray-400">
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
{entry.role === "owner" && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-400">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{showSecondary && (
|
||||
<p className="truncate text-xs text-gray-400">
|
||||
{entry.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{entry.role === "member" &&
|
||||
onSharedWithChange && (
|
||||
<button
|
||||
onClick={() =>
|
||||
void handleRemove(
|
||||
entry.email,
|
||||
)
|
||||
}
|
||||
disabled={busy !== null}
|
||||
title="Remove access"
|
||||
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
>
|
||||
{isRemoving && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
|
||||
{/* Member list */}
|
||||
{roster.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
No one has access yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
|
||||
{roster.map((entry) => {
|
||||
const isYou =
|
||||
!!currentUserEmail &&
|
||||
entry.email.toLowerCase() ===
|
||||
currentUserEmail.toLowerCase();
|
||||
const isRemoving =
|
||||
busy === "remove" &&
|
||||
removingEmail === entry.email;
|
||||
const primary =
|
||||
entry.display_name?.trim() || entry.email;
|
||||
const showSecondary =
|
||||
!!entry.display_name?.trim() &&
|
||||
primary !== entry.email;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.role}-${entry.email}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-800">
|
||||
{primary}
|
||||
{isYou && (
|
||||
<span className="ml-1.5 text-xs text-gray-400">
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{entry.role === "owner" && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-400">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{showSecondary && (
|
||||
<p className="truncate text-xs text-gray-400">
|
||||
{entry.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{entry.role === "member" &&
|
||||
onSharedWithChange && (
|
||||
<button
|
||||
onClick={() =>
|
||||
void handleRemove(
|
||||
entry.email,
|
||||
)
|
||||
}
|
||||
disabled={busy !== null}
|
||||
title="Remove access"
|
||||
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
>
|
||||
{isRemoving && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
|||
const [draft, setDraft] = useState("");
|
||||
const caretPos = useRef<number | null>(null);
|
||||
const escaped = useRef(false);
|
||||
const committed = useRef(false);
|
||||
|
||||
function startEditing(e: React.MouseEvent) {
|
||||
const doc = document as CaretDocument;
|
||||
|
|
@ -32,15 +33,18 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
|||
? range.startOffset
|
||||
: null;
|
||||
escaped.current = false;
|
||||
committed.current = false;
|
||||
setDraft(value);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
if (committed.current) return;
|
||||
if (escaped.current) {
|
||||
escaped.current = false;
|
||||
return;
|
||||
}
|
||||
committed.current = true;
|
||||
setEditing(false);
|
||||
onCommit(draft.trim());
|
||||
}
|
||||
|
|
@ -58,9 +62,13 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
|||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
escaped.current = true;
|
||||
committed.current = true;
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -141,9 +141,12 @@ export function RowActionMenuItems({
|
|||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
disabled={deleting}
|
||||
aria-disabled={deleteDisabled}
|
||||
onClick={() => {
|
||||
if (deleteDisabled || deleting) return;
|
||||
onClose();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={deleting || deleteDisabled}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
|
||||
deleteDisabled
|
||||
? "cursor-not-allowed opacity-40 hover:bg-transparent"
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Loader2, Search } from "lucide-react";
|
||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import type { Workflow } from "@/app/components/shared/types";
|
||||
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
applying?: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (workflow: Workflow) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function ApplyWorkflowPresetModal({
|
||||
open,
|
||||
applying = false,
|
||||
onClose,
|
||||
onApply,
|
||||
}: Props) {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
|
||||
(workflow) => workflow.type === "tabular",
|
||||
);
|
||||
setLoading(true);
|
||||
setSearch("");
|
||||
setSelectedWorkflowId(null);
|
||||
listWorkflows("tabular")
|
||||
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
|
||||
.catch(() => setWorkflows(builtinTabular))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const filteredWorkflows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return workflows;
|
||||
return workflows.filter((workflow) =>
|
||||
[workflow.title, workflow.practice ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [search, workflows]);
|
||||
|
||||
const selectedWorkflow =
|
||||
workflows.find((workflow) => workflow.id === selectedWorkflowId) ?? null;
|
||||
const canApply =
|
||||
!!selectedWorkflow &&
|
||||
!applying &&
|
||||
!loading &&
|
||||
!!selectedWorkflow.columns_config?.length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Apply preset workflow"
|
||||
size="md"
|
||||
primaryAction={{
|
||||
label: applying ? "Applying..." : "Apply",
|
||||
onClick: () => {
|
||||
if (selectedWorkflow) void onApply(selectedWorkflow);
|
||||
},
|
||||
disabled: !canApply,
|
||||
icon: applying ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : undefined,
|
||||
}}
|
||||
cancelAction={{ label: "Cancel", onClick: onClose }}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
Choose a tabular review workflow. Applying it will replace
|
||||
the current review columns with the workflow preset.
|
||||
</p>
|
||||
<div className="flex h-9 items-center gap-2 rounded-xl bg-gray-100 px-3">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search workflows..."
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-1.5">
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-1">
|
||||
{[1, 2, 3, 4].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-14 animate-pulse rounded-xl bg-white"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredWorkflows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-gray-400">
|
||||
No workflows found
|
||||
</div>
|
||||
) : (
|
||||
filteredWorkflows.map((workflow) => {
|
||||
const selected = workflow.id === selectedWorkflowId;
|
||||
const columnCount =
|
||||
workflow.columns_config?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedWorkflowId(workflow.id)
|
||||
}
|
||||
disabled={columnCount === 0}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-white text-gray-950 shadow-[0_1px_4px_rgba(15,23,42,0.06)]"
|
||||
: "text-gray-700 hover:bg-white/75",
|
||||
columnCount === 0 &&
|
||||
"cursor-not-allowed opacity-45",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{workflow.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-gray-400">
|
||||
{workflow.practice ?? "Tabular"} ·{" "}
|
||||
{columnCount}{" "}
|
||||
{columnCount === 1
|
||||
? "column"
|
||||
: "columns"}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
},
|
||||
ref,
|
||||
) {
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
const totalContentWidth =
|
||||
|
|
|
|||
36
frontend/src/app/components/tabular/TRWorkflowModal.tsx
Normal file
36
frontend/src/app/components/tabular/TRWorkflowModal.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { WorkflowPickerModal } from "../workflows/WorkflowPickerModal";
|
||||
|
||||
interface TRWorkflowModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (workflow: Workflow) => Promise<void> | void;
|
||||
breadcrumbs: ReactNode[];
|
||||
applying?: boolean;
|
||||
}
|
||||
|
||||
export function TRWorkflowModal({
|
||||
open,
|
||||
onClose,
|
||||
onApply,
|
||||
breadcrumbs,
|
||||
applying = false,
|
||||
}: TRWorkflowModalProps) {
|
||||
return (
|
||||
<WorkflowPickerModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onSelect={onApply}
|
||||
workflowType="tabular"
|
||||
breadcrumbs={breadcrumbs}
|
||||
primaryLabel="Apply"
|
||||
selectingLabel="Applying..."
|
||||
selecting={applying}
|
||||
closeOnSelect={false}
|
||||
disabledWorkflow={(workflow) => !workflow.columns_config?.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ import type {
|
|||
Workflow,
|
||||
} from "../shared/types";
|
||||
import { AddColumnModal } from "./AddColumnModal";
|
||||
import { ApplyWorkflowPresetModal } from "./ApplyWorkflowPresetModal";
|
||||
import { TRWorkflowModal } from "./TRWorkflowModal";
|
||||
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
|
||||
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
|
||||
import { PeopleModal } from "../shared/PeopleModal";
|
||||
|
|
@ -79,9 +79,8 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
const [addColOpen, setAddColOpen] = useState(false);
|
||||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
||||
const [workflowPresetModalOpen, setWorkflowPresetModalOpen] =
|
||||
useState(false);
|
||||
const [applyingWorkflowPreset, setApplyingWorkflowPreset] = useState(false);
|
||||
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
||||
const [applyingWorkflow, setApplyingWorkflow] = useState(false);
|
||||
const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] =
|
||||
useState(false);
|
||||
const [deleteReviewStatus, setDeleteReviewStatus] = useState<
|
||||
|
|
@ -591,15 +590,15 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
function requestWorkflowPreset() {
|
||||
function requestWorkflow() {
|
||||
if (review?.is_owner === false) {
|
||||
setOwnerOnlyAction("apply a preset workflow");
|
||||
setOwnerOnlyAction("apply a workflow");
|
||||
return;
|
||||
}
|
||||
setWorkflowPresetModalOpen(true);
|
||||
setWorkflowModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleApplyWorkflowPreset(workflow: Workflow) {
|
||||
async function handleApplyWorkflow(workflow: Workflow) {
|
||||
if (!workflow.columns_config?.length) return;
|
||||
const nextColumns = workflow.columns_config.map((column, index) => ({
|
||||
...column,
|
||||
|
|
@ -607,7 +606,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
}));
|
||||
const previousColumns = columns;
|
||||
const previousCells = cells;
|
||||
setApplyingWorkflowPreset(true);
|
||||
setApplyingWorkflow(true);
|
||||
setColumns(nextColumns);
|
||||
setCells([]);
|
||||
try {
|
||||
|
|
@ -622,13 +621,13 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
console.error("Failed to clear old tabular cells", err);
|
||||
}
|
||||
}
|
||||
setWorkflowPresetModalOpen(false);
|
||||
setWorkflowModalOpen(false);
|
||||
} catch (err) {
|
||||
setColumns(previousColumns);
|
||||
setCells(previousCells);
|
||||
console.error("Failed to apply workflow preset", err);
|
||||
console.error("Failed to apply workflow", err);
|
||||
} finally {
|
||||
setApplyingWorkflowPreset(false);
|
||||
setApplyingWorkflow(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -657,18 +656,17 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
loading: true,
|
||||
skeletonClassName: "w-32",
|
||||
onClick: () =>
|
||||
router.push(`/projects/${projectId}`),
|
||||
router.push(
|
||||
`/projects/${projectId}?tab=reviews`,
|
||||
),
|
||||
title: "Back to project",
|
||||
}
|
||||
: {
|
||||
label: project?.name ?? "",
|
||||
suffix: project?.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null,
|
||||
onClick: () =>
|
||||
router.push(`/projects/${projectId}`),
|
||||
router.push(
|
||||
`/projects/${projectId}?tab=reviews`,
|
||||
),
|
||||
title: "Back to project",
|
||||
},
|
||||
]
|
||||
|
|
@ -716,10 +714,9 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
onSelect: requestReviewRename,
|
||||
},
|
||||
{
|
||||
label: "Apply preset workflow",
|
||||
label: "Apply workflow",
|
||||
icon: WandSparkles,
|
||||
onSelect:
|
||||
requestWorkflowPreset,
|
||||
onSelect: requestWorkflow,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
|
|
@ -1057,14 +1054,28 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
}
|
||||
/>
|
||||
|
||||
<ApplyWorkflowPresetModal
|
||||
open={workflowPresetModalOpen}
|
||||
applying={applyingWorkflowPreset}
|
||||
<TRWorkflowModal
|
||||
open={workflowModalOpen}
|
||||
onClose={() => {
|
||||
if (applyingWorkflowPreset) return;
|
||||
setWorkflowPresetModalOpen(false);
|
||||
if (applyingWorkflow) return;
|
||||
setWorkflowModalOpen(false);
|
||||
}}
|
||||
onApply={handleApplyWorkflowPreset}
|
||||
onApply={handleApplyWorkflow}
|
||||
breadcrumbs={[
|
||||
...(project
|
||||
? [
|
||||
"Projects",
|
||||
project.name +
|
||||
(project.cm_number
|
||||
? ` (#${project.cm_number})`
|
||||
: ""),
|
||||
]
|
||||
: []),
|
||||
"Tabular Reviews",
|
||||
review?.title || "Untitled Review",
|
||||
"Add workflow",
|
||||
]}
|
||||
applying={applyingWorkflow}
|
||||
/>
|
||||
|
||||
<ConfirmPopup
|
||||
|
|
|
|||
|
|
@ -1,28 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Folder,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Table2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type {
|
||||
Document,
|
||||
Workflow,
|
||||
} from "../shared/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Folder, Search, X } from "lucide-react";
|
||||
import type { Document, Workflow } from "../shared/types";
|
||||
import { createTabularReview } from "@/app/lib/mikeApi";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatIcon, formatLabel } from "../tabular/columnFormat";
|
||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import type { Project } from "../shared/types";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { WorkflowPickerContent } from "./WorkflowPickerContent";
|
||||
import { workflowDetailPath } from "./workflowRoutes";
|
||||
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
|
|
@ -122,165 +111,6 @@ function SimpleProjectPicker({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared markdown renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
function MarkdownBody({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-base font-semibold text-gray-900 mt-4 mb-1 first:mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-sm font-semibold text-gray-900 mt-3 mb-1 first:mt-0">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xs font-semibold text-gray-900 mt-2 mb-0.5 first:mt-0">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-4 mb-2 space-y-0.5">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-4 mb-2 space-y-0.5">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-gray-800">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Right panel for assistant workflows (select screen)
|
||||
// ---------------------------------------------------------------------------
|
||||
function AssistantPanel({ workflow }: { workflow: Workflow }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="py-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Workflow Prompt
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 text-sm border border-gray-200 rounded-md text-gray-600 leading-relaxed font-serif bg-gray-50">
|
||||
<MarkdownBody
|
||||
content={workflow.prompt_md ?? "_No prompt defined._"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Right panel for tabular workflows — accordion column list (select screen)
|
||||
// ---------------------------------------------------------------------------
|
||||
function TabularPanel({ workflow }: { workflow: Workflow }) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const columns = (workflow.columns_config ?? []).sort(
|
||||
(a, b) => a.index - b.index,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="py-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">Columns</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-md bg-gray-50">
|
||||
{columns.length === 0 ? (
|
||||
<p className="px-4 py-6 text-xs text-center text-gray-400">
|
||||
No columns defined
|
||||
</p>
|
||||
) : (
|
||||
columns.map((col) => {
|
||||
const isExpanded = expandedIndex === col.index;
|
||||
const FormatIcon = formatIcon(col.format ?? "text");
|
||||
return (
|
||||
<div
|
||||
key={col.index}
|
||||
className="border-b border-gray-200"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedIndex(
|
||||
isExpanded ? null : col.index,
|
||||
)
|
||||
}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-xs text-left hover:bg-white transition-colors"
|
||||
>
|
||||
<FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-800">
|
||||
{col.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-400">
|
||||
{formatLabel(col.format ?? "text")}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 shrink-0 text-gray-300 transition-transform duration-150 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 py-3 bg-white border-t border-gray-200 text-sm text-gray-600 leading-relaxed font-serif space-y-3">
|
||||
{col.tags && col.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 mb-1.5 font-sans">
|
||||
Tags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{col.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 font-sans"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 mb-1 font-sans">
|
||||
Prompt
|
||||
</p>
|
||||
<MarkdownBody
|
||||
content={
|
||||
col.prompt ||
|
||||
"_No prompt defined._"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DisplayWorkflowModal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -288,7 +118,6 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
const [screen, setScreen] = useState<"select" | "configure">("select");
|
||||
const [selected, setSelected] = useState<Workflow | null>(workflow);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Configure screen state
|
||||
const [inProject, setInProject] = useState(false);
|
||||
|
|
@ -320,12 +149,6 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
}
|
||||
}, [workflow?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected && selectedRowRef.current) {
|
||||
selectedRowRef.current.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selected?.id]);
|
||||
|
||||
// Reset configure state on back
|
||||
useEffect(() => {
|
||||
if (screen === "select") {
|
||||
|
|
@ -467,7 +290,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
];
|
||||
|
||||
const selectPageAction = () => {
|
||||
router.push(`/workflows/${wf.id}`);
|
||||
router.push(workflowDetailPath(wf));
|
||||
handleClose();
|
||||
};
|
||||
|
||||
|
|
@ -525,59 +348,19 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
|
||||
{/* ── SELECT SCREEN ── */}
|
||||
{screen === "select" && (
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
|
||||
{/* Left: workflow list */}
|
||||
<div className="w-80 shrink-0 flex flex-col overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="px-2 py-3 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={listSearch}
|
||||
onChange={(e) => setListSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
/>
|
||||
{listSearch && (
|
||||
<button onClick={() => setListSearch("")} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* List */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{workflows
|
||||
.filter((wfItem) => !listSearch || wfItem.title.toLowerCase().includes(listSearch.toLowerCase()))
|
||||
.map((wfItem) => {
|
||||
const isSelected = selected?.id === wfItem.id;
|
||||
const Icon = wfItem.type === "tabular" ? Table2 : MessageSquare;
|
||||
return (
|
||||
<button
|
||||
key={wfItem.id}
|
||||
ref={isSelected ? selectedRowRef : null}
|
||||
type="button"
|
||||
onClick={() => setSelected(wfItem)}
|
||||
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${isSelected ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50"}`}
|
||||
>
|
||||
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
|
||||
{wfItem.title}
|
||||
</span>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: workflow detail */}
|
||||
{wf.type === "assistant" ? (
|
||||
<AssistantPanel key={wf.id} workflow={wf} />
|
||||
) : (
|
||||
<TabularPanel key={wf.id} workflow={wf} />
|
||||
)}
|
||||
</div>
|
||||
<WorkflowPickerContent
|
||||
workflows={workflows}
|
||||
selected={wf}
|
||||
onSelect={(next) => {
|
||||
if (next) setSelected(next);
|
||||
}}
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
workflowType="all"
|
||||
previewMode="auto"
|
||||
showTypeIcon
|
||||
allowClearPreview={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── ASSISTANT CONFIGURE SCREEN ── */}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export function ShareWorkflowModal({
|
|||
disabled: saving || pendingEmails.length === 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<section className="space-y-3">
|
||||
<EmailPillInput
|
||||
emails={pendingEmails}
|
||||
onChange={setPendingEmails}
|
||||
|
|
@ -101,9 +103,10 @@ export function ShareWorkflowModal({
|
|||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Permission toggle */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -112,10 +115,10 @@ export function ShareWorkflowModal({
|
|||
>
|
||||
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Existing access */}
|
||||
<div>
|
||||
<section className="min-h-0 flex-1">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">People with access</p>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -146,7 +149,8 @@ export function ShareWorkflowModal({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { use, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ChevronDown, Plus, Users, X } from "lucide-react";
|
||||
import { getWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { deleteWorkflow, getWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
|
||||
import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModal";
|
||||
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
|
||||
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
|
||||
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
|
||||
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
|
||||
import {
|
||||
BUILT_IN_IDS,
|
||||
BUILT_IN_WORKFLOWS,
|
||||
} from "@/app/components/workflows/builtinWorkflows";
|
||||
import { BUILT_IN_WORKFLOWS } from "@/app/components/workflows/builtinWorkflows";
|
||||
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
|
||||
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { WorkflowDetailsModal } from "@/app/components/workflows/WorkflowDetailsModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
|
||||
const WorkflowPromptEditor = dynamic(
|
||||
() =>
|
||||
|
|
@ -27,26 +37,32 @@ const WorkflowPromptEditor = dynamic(
|
|||
);
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
id: string;
|
||||
workflowType: Workflow["type"];
|
||||
}
|
||||
|
||||
type SaveStatus = "idle" | "saving" | "saved";
|
||||
type DeleteStatus = "idle" | "loading" | "complete";
|
||||
|
||||
const NAME_COL_W = "w-[332px] shrink-0";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function WorkflowDetailPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
export function WorkflowDetailPage({ id, workflowType }: Props) {
|
||||
const router = useRouter();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const { user } = useAuth();
|
||||
const { profile } = useUserProfile();
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
const builtinWorkflow =
|
||||
BUILT_IN_WORKFLOWS.find((w) => w.id === id && w.type === workflowType) ??
|
||||
null;
|
||||
const isBuiltin = builtinWorkflow !== null;
|
||||
|
||||
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const isBuiltin = BUILT_IN_IDS.has(id);
|
||||
const readOnly =
|
||||
isBuiltin ||
|
||||
(workflow?.is_system ?? false) ||
|
||||
|
|
@ -71,6 +87,9 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
|
||||
// Share popover
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus>("idle");
|
||||
|
||||
// Column actions dropdown
|
||||
const [colActionsOpen, setColActionsOpen] = useState(false);
|
||||
|
|
@ -91,7 +110,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (isBuiltin) {
|
||||
const wf = BUILT_IN_WORKFLOWS.find((w) => w.id === id) ?? null;
|
||||
const wf = builtinWorkflow;
|
||||
if (!wf) {
|
||||
setNotFound(true);
|
||||
} else {
|
||||
|
|
@ -105,6 +124,10 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
|
||||
getWorkflow(id)
|
||||
.then((wf) => {
|
||||
if (wf.type !== workflowType) {
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
setWorkflow(wf);
|
||||
setPromptMd(wf.prompt_md ?? "");
|
||||
setColumns(
|
||||
|
|
@ -115,7 +138,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
})
|
||||
.catch(() => setNotFound(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, isBuiltin]);
|
||||
}, [id, isBuiltin, builtinWorkflow, workflowType]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debounced auto-save for prompt
|
||||
|
|
@ -138,10 +161,27 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
[id, readOnly],
|
||||
);
|
||||
|
||||
async function handleTitleCommit(newTitle: string) {
|
||||
if (!newTitle || newTitle === workflow?.title) return;
|
||||
const updated = await updateWorkflow(id, { title: newTitle });
|
||||
setWorkflow(updated);
|
||||
async function handleWorkflowDetailsSave(values: { title: string }) {
|
||||
if (!workflow || readOnly || !values.title) return;
|
||||
if (values.title === workflow.title) return;
|
||||
const updated = await updateWorkflow(id, { title: values.title });
|
||||
setWorkflow({
|
||||
...updated,
|
||||
shared_by_name:
|
||||
updated.shared_by_name ?? workflow.shared_by_name ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteWorkflow() {
|
||||
if (!workflow || readOnly || workflow.is_owner === false) return;
|
||||
setDeleteStatus("loading");
|
||||
try {
|
||||
await deleteWorkflow(id);
|
||||
setDeleteStatus("complete");
|
||||
setTimeout(() => router.push("/workflows"), 600);
|
||||
} catch {
|
||||
setDeleteStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
function handlePromptChange(val: string | undefined) {
|
||||
|
|
@ -190,53 +230,24 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
// ---------------------------------------------------------------------------
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
shrink
|
||||
breadcrumbs={[
|
||||
{ label: "Workflows" },
|
||||
{
|
||||
label: "Workflows",
|
||||
onClick: () => router.push("/workflows"),
|
||||
title: "Back to Workflows",
|
||||
},
|
||||
{ loading: true, skeletonClassName: "w-40" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex items-center px-8 h-10 border-b border-gray-200 shrink-0">
|
||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Table header skeleton */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 shrink-0">
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-2.5 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="h-2.5 w-14 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="h-2.5 w-12 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Row skeletons */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center h-10 pr-8 border-b border-gray-50">
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 rounded bg-gray-100 animate-pulse" style={{ width: `${40 + (i * 13) % 35}%` }} />
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="h-3 rounded bg-gray-100 animate-pulse" style={{ width: `${50 + (i * 17) % 35}%` }} />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{workflowType === "tabular" ? (
|
||||
<TabularWorkflowEditorSkeleton />
|
||||
) : (
|
||||
<AssistantWorkflowEditorSkeleton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -263,31 +274,29 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
title: "Back to Workflows",
|
||||
},
|
||||
{
|
||||
label: readOnly ? (
|
||||
label: (
|
||||
<span className="text-gray-900 truncate max-w-xs">
|
||||
{workflow.title}
|
||||
</span>
|
||||
) : (
|
||||
<RenameableTitle
|
||||
value={workflow.title}
|
||||
onCommit={handleTitleCommit}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
type: "custom",
|
||||
render: (
|
||||
<span className="text-xs text-gray-400">
|
||||
{saveStatus === "saving"
|
||||
? "Saving…"
|
||||
: saveStatus === "saved"
|
||||
? "Saved"
|
||||
: ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
saveStatus !== "idle"
|
||||
? {
|
||||
type: "custom",
|
||||
render: (
|
||||
<span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500">
|
||||
{saveStatus === "saved" ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : null}
|
||||
{saveStatus === "saving"
|
||||
? "Saving…"
|
||||
: "Saved"}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
canShare
|
||||
? {
|
||||
onClick: () => setShareOpen(true),
|
||||
|
|
@ -296,8 +305,57 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
icon: <Users className="h-4 w-4" />,
|
||||
}
|
||||
: null,
|
||||
!readOnly
|
||||
? {
|
||||
type: "custom",
|
||||
render: (
|
||||
<HeaderActionsMenu
|
||||
title="Workflow actions"
|
||||
items={[
|
||||
{
|
||||
label: "Rename",
|
||||
icon: Pencil,
|
||||
onSelect: () =>
|
||||
setDetailsOpen(true),
|
||||
},
|
||||
{
|
||||
label: "Workflow Details",
|
||||
icon: Info,
|
||||
onSelect: () =>
|
||||
setDetailsOpen(true),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "danger",
|
||||
disabled:
|
||||
workflow.is_owner === false,
|
||||
onSelect: () => {
|
||||
setDeleteStatus("idle");
|
||||
setDeleteOpen(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
]}
|
||||
/>
|
||||
<WorkflowDetailsModal
|
||||
open={detailsOpen}
|
||||
workflow={workflow}
|
||||
canEdit={!readOnly}
|
||||
canShare={canShare}
|
||||
currentUserDisplayName={profile?.displayName}
|
||||
currentUserEmail={user?.email}
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
onSave={handleWorkflowDetailsSave}
|
||||
onShareWorkflow={() => {
|
||||
setDetailsOpen(false);
|
||||
setShareOpen(true);
|
||||
}}
|
||||
/>
|
||||
{shareOpen && (
|
||||
<ShareWorkflowModal
|
||||
workflowId={id}
|
||||
|
|
@ -305,19 +363,25 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
onClose={() => setShareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Read-only badge for built-in workflows */}
|
||||
{readOnly && (
|
||||
<div className="flex items-center h-10 px-8 border-b border-gray-200">
|
||||
<span className="text-xs text-gray-400">Read-only</span>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmPopup
|
||||
open={deleteOpen}
|
||||
title="Delete workflow?"
|
||||
message="This workflow will be permanently deleted."
|
||||
confirmLabel="Delete"
|
||||
confirmStatus={deleteStatus}
|
||||
onConfirm={() => void handleDeleteWorkflow()}
|
||||
onCancel={() => {
|
||||
if (deleteStatus === "loading") return;
|
||||
setDeleteOpen(false);
|
||||
setDeleteStatus("idle");
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{workflow.type === "assistant" ? (
|
||||
/* ── Assistant: WYSIWYG editor ── */
|
||||
<div className="flex-1 min-h-0 p-6">
|
||||
<div className="flex-1 min-h-0 px-4 pb-2 pt-0 md:px-10 md:pb-3">
|
||||
<WorkflowPromptEditor
|
||||
value={promptMd}
|
||||
onChange={readOnly ? undefined : handlePromptChange}
|
||||
|
|
@ -329,7 +393,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Toolbar */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center justify-between px-8 h-10 border-b border-gray-200 shrink-0">
|
||||
<div className="flex items-center justify-between px-4 md:px-10 h-10 border-b border-gray-200 shrink-0">
|
||||
<button
|
||||
onClick={() => setAddColumnOpen(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
|
|
@ -368,11 +432,18 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{readOnly && (
|
||||
<div className="flex h-10 shrink-0 items-center bg-gray-50 px-4 md:px-10">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Read-only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="min-w-max flex min-h-full flex-col">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none">
|
||||
<div className={`flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none ${readOnly ? "border-t" : ""}`}>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
{columns.length > 0 && (
|
||||
<input
|
||||
|
|
@ -418,7 +489,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
<div
|
||||
key={col.index}
|
||||
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100`}>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
|
|
@ -505,3 +576,84 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantWorkflowEditorSkeleton() {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 px-4 pb-2 pt-0 md:px-10 md:pb-3">
|
||||
<div className="h-full rounded-md border border-gray-200 bg-gray-50 px-5 py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-5/6 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-4/5 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="h-3 w-28 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-11/12 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-10/12 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-4/6 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-5/6 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabularWorkflowEditorSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-10 shrink-0 items-center border-b border-gray-200 px-4 md:px-10">
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 shrink-0 items-center border-b border-gray-200 pr-3 md:pr-10">
|
||||
<div
|
||||
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-2.5 w-20 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="h-2.5 w-14 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="h-2.5 w-12 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-10 items-center border-b border-gray-50 pr-3 md:pr-10"
|
||||
>
|
||||
<div
|
||||
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 shrink-0 animate-pulse rounded bg-gray-100" />
|
||||
<div
|
||||
className="h-3 animate-pulse rounded bg-gray-100"
|
||||
style={{ width: `${40 + (i * 13) % 35}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex-1 pr-4">
|
||||
<div
|
||||
className="h-3 animate-pulse rounded bg-gray-100"
|
||||
style={{ width: `${50 + (i * 17) % 35}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
frontend/src/app/components/workflows/WorkflowDetailsModal.tsx
Normal file
190
frontend/src/app/components/workflows/WorkflowDetailsModal.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import type { Workflow } from "@/app/components/shared/types";
|
||||
import { listWorkflowShares } from "@/app/lib/mikeApi";
|
||||
|
||||
interface WorkflowDetailsModalProps {
|
||||
open: boolean;
|
||||
workflow: Workflow | null;
|
||||
canEdit: boolean;
|
||||
canShare: boolean;
|
||||
currentUserDisplayName?: string | null;
|
||||
currentUserEmail?: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (values: { title: string }) => Promise<void>;
|
||||
onShareWorkflow: () => void;
|
||||
}
|
||||
|
||||
export function WorkflowDetailsModal({
|
||||
open,
|
||||
workflow,
|
||||
canEdit,
|
||||
canShare,
|
||||
currentUserDisplayName,
|
||||
currentUserEmail,
|
||||
onClose,
|
||||
onSave,
|
||||
onShareWorkflow,
|
||||
}: WorkflowDetailsModalProps) {
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [shareCount, setShareCount] = useState<number | null>(null);
|
||||
const [sharesLoading, setSharesLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workflow) return;
|
||||
setTitleDraft(workflow.title);
|
||||
setShareCount(null);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}, [open, workflow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workflow || !canShare) {
|
||||
setSharesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSharesLoading(true);
|
||||
listWorkflowShares(workflow.id)
|
||||
.then((shares) => {
|
||||
if (!cancelled) setShareCount(shares.length);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setShareCount(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSharesLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canShare, open, workflow]);
|
||||
|
||||
const trimmedTitle = titleDraft.trim();
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!workflow) return false;
|
||||
return trimmedTitle !== workflow.title;
|
||||
}, [trimmedTitle, workflow]);
|
||||
|
||||
if (!workflow) return null;
|
||||
|
||||
const typeLabel = workflow.type === "tabular" ? "Tabular" : "Assistant";
|
||||
const ownershipLabel = workflow.is_system
|
||||
? "Built-in"
|
||||
: workflow.is_owner === false
|
||||
? "Shared with you"
|
||||
: shareCount && shareCount > 0
|
||||
? "Shared"
|
||||
: "Private";
|
||||
const ownerLabel =
|
||||
workflow.is_owner === false
|
||||
? workflow.shared_by_name?.trim() || "Unknown"
|
||||
: currentUserDisplayName?.trim() ||
|
||||
currentUserEmail?.trim() ||
|
||||
"You";
|
||||
|
||||
async function handleSave() {
|
||||
if (!canEdit || saving || !hasChanges || !trimmedTitle) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({ title: trimmedTitle });
|
||||
setSaved(true);
|
||||
} catch {
|
||||
setError("Could not update workflow details.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Workflows", workflow.title, "Details"]}
|
||||
secondaryAction={
|
||||
canShare
|
||||
? {
|
||||
label: "Share Workflow",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
onClick: onShareWorkflow,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footerStatus={
|
||||
error ? (
|
||||
<span className="text-sm text-red-600">{error}</span>
|
||||
) : saved ? (
|
||||
<span className="text-sm text-gray-400">Updated</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={
|
||||
canEdit
|
||||
? {
|
||||
label: saving ? "Updating..." : "Update",
|
||||
onClick: () => void handleSave(),
|
||||
disabled: saving || !hasChanges || !trimmedTitle,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cancelAction={canEdit ? undefined : false}
|
||||
>
|
||||
<div className="flex flex-col gap-5 py-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="workflow-details-title"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Workflow Name
|
||||
</label>
|
||||
<input
|
||||
id="workflow-details-title"
|
||||
value={titleDraft}
|
||||
onChange={(e) => {
|
||||
setTitleDraft(e.target.value);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100 text-sm">
|
||||
<DetailRow label="Type" value={typeLabel} />
|
||||
<DetailRow
|
||||
label="Ownership"
|
||||
value={
|
||||
sharesLoading ? (
|
||||
<span className="inline-block h-4 w-14 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
ownershipLabel
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Owner" value={ownerLabel} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import { RowActions } from "../shared/RowActions";
|
|||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { workflowDetailPath } from "./workflowRoutes";
|
||||
|
||||
type Tab = "all" | "builtin" | "custom" | "hidden";
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ const TABS: { id: Tab; label: string }[] = [
|
|||
export function WorkflowList() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
const [custom, setCustom] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||
|
|
@ -606,7 +607,7 @@ export function WorkflowList() {
|
|||
onCreated={(wf) => {
|
||||
setCustom((prev) => [wf, ...prev]);
|
||||
setNewModalOpen(false);
|
||||
router.push(`/workflows/${wf.id}`);
|
||||
router.push(workflowDetailPath(wf));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
362
frontend/src/app/components/workflows/WorkflowPickerContent.tsx
Normal file
362
frontend/src/app/components/workflows/WorkflowPickerContent.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Table2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ColumnConfig, Workflow } from "../shared/types";
|
||||
import { formatIcon, formatLabel } from "../tabular/columnFormat";
|
||||
import { TAG_COLORS } from "../tabular/pillUtils";
|
||||
|
||||
type WorkflowPreviewMode = "auto" | "prompt" | "columns";
|
||||
|
||||
interface WorkflowPickerContentProps {
|
||||
workflows: Workflow[];
|
||||
selected: Workflow | null;
|
||||
onSelect: (workflow: Workflow | null) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
loading?: boolean;
|
||||
workflowType?: Workflow["type"] | "all";
|
||||
emptyMessage?: string;
|
||||
previewMode?: WorkflowPreviewMode;
|
||||
disabledWorkflow?: (workflow: Workflow) => boolean;
|
||||
showTypeIcon?: boolean;
|
||||
allowClearPreview?: boolean;
|
||||
}
|
||||
|
||||
export function WorkflowPickerContent({
|
||||
workflows,
|
||||
selected,
|
||||
onSelect,
|
||||
search,
|
||||
onSearchChange,
|
||||
loading = false,
|
||||
workflowType = "all",
|
||||
emptyMessage,
|
||||
previewMode = "auto",
|
||||
disabledWorkflow,
|
||||
showTypeIcon = false,
|
||||
allowClearPreview = true,
|
||||
}: WorkflowPickerContentProps) {
|
||||
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRowRef.current) {
|
||||
selectedRowRef.current.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selected?.id]);
|
||||
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
const filteredWorkflows = normalizedSearch
|
||||
? workflows.filter((workflow) =>
|
||||
[
|
||||
workflow.title,
|
||||
workflow.practice ?? "",
|
||||
workflow.is_system ? "Built-in" : "Custom",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch),
|
||||
)
|
||||
: workflows;
|
||||
const resolvedEmptyMessage =
|
||||
emptyMessage ??
|
||||
(search
|
||||
? "No matches found"
|
||||
: workflowType === "all"
|
||||
? "No workflows found"
|
||||
: `No ${workflowType} workflows found`);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-row gap-3 overflow-hidden">
|
||||
<div
|
||||
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
|
||||
>
|
||||
<div className="shrink-0 px-2 pb-2 pt-3">
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search workflows..."
|
||||
value={search}
|
||||
onChange={(event) =>
|
||||
onSearchChange(event.target.value)
|
||||
}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-1">
|
||||
{[60, 45, 75, 50, 65, 40, 55].map((width, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
|
||||
>
|
||||
<div
|
||||
className="h-3 animate-pulse rounded bg-gray-100"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredWorkflows.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-gray-400">
|
||||
{resolvedEmptyMessage}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 overflow-y-auto">
|
||||
{filteredWorkflows.map((workflow) => {
|
||||
const disabled = disabledWorkflow?.(workflow) ?? false;
|
||||
const isSelected = selected?.id === workflow.id;
|
||||
const TypeIcon =
|
||||
workflow.type === "tabular"
|
||||
? Table2
|
||||
: MessageSquare;
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
ref={isSelected ? selectedRowRef : null}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onSelect(isSelected ? null : workflow)
|
||||
}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "hover:bg-gray-50"
|
||||
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
isSelected
|
||||
? "font-medium text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{workflow.title}
|
||||
</span>
|
||||
{showTypeIcon ? (
|
||||
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{workflow.is_system
|
||||
? "Built-in"
|
||||
: "Custom"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<WorkflowPreview
|
||||
workflow={selected}
|
||||
mode={previewMode}
|
||||
onClear={() => onSelect(null)}
|
||||
allowClear={allowClearPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowPreview({
|
||||
workflow,
|
||||
mode,
|
||||
onClear,
|
||||
allowClear,
|
||||
}: {
|
||||
workflow: Workflow;
|
||||
mode: WorkflowPreviewMode;
|
||||
onClear: () => void;
|
||||
allowClear: boolean;
|
||||
}) {
|
||||
const resolvedMode =
|
||||
mode === "auto"
|
||||
? workflow.type === "tabular"
|
||||
? "columns"
|
||||
: "prompt"
|
||||
: mode;
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between pb-2 pt-3">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
Workflow Details
|
||||
</p>
|
||||
{allowClear ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{resolvedMode === "columns" ? (
|
||||
<WorkflowColumnPreview columns={workflow.columns_config ?? []} />
|
||||
) : (
|
||||
<WorkflowPromptPreview
|
||||
content={workflow.prompt_md ?? "_No prompt defined._"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowPromptPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||
<WorkflowPromptMarkdown content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowPromptMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-1 mt-4 text-base font-semibold text-gray-900 first:mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-1 mt-3 text-sm font-semibold text-gray-900 first:mt-0">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-0.5 mt-2 text-xs font-semibold text-gray-900 first:mt-0">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-2 list-disc space-y-0.5 pl-4">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-2 list-decimal space-y-0.5 pl-4">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-gray-800">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50">
|
||||
{sortedColumns.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-xs text-gray-400">
|
||||
No columns defined
|
||||
</p>
|
||||
) : (
|
||||
sortedColumns.map((column) => {
|
||||
const isExpanded = expandedIndex === column.index;
|
||||
const FormatIcon = formatIcon(column.format ?? "text");
|
||||
return (
|
||||
<div
|
||||
key={column.index}
|
||||
className="border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedIndex(
|
||||
isExpanded ? null : column.index,
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-xs transition-colors hover:bg-white"
|
||||
>
|
||||
<FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-800">
|
||||
{column.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-400">
|
||||
{formatLabel(column.format ?? "text")}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 shrink-0 text-gray-300 transition-transform duration-150 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="space-y-3 border-t border-gray-200 bg-white px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||
{column.tags && column.tags.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1.5 font-sans text-[11px] font-medium text-gray-600">
|
||||
Tags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{column.tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`inline-block rounded-full px-1.5 py-0.5 font-sans text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<p className="mb-1 font-sans text-[11px] font-medium text-gray-600">
|
||||
Prompt
|
||||
</p>
|
||||
<WorkflowPromptMarkdown
|
||||
content={
|
||||
column.prompt ||
|
||||
"_No prompt defined._"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/app/components/workflows/WorkflowPickerModal.tsx
Normal file
137
frontend/src/app/components/workflows/WorkflowPickerModal.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { BUILT_IN_WORKFLOWS } from "./builtinWorkflows";
|
||||
import { WorkflowPickerContent } from "./WorkflowPickerContent";
|
||||
|
||||
interface WorkflowPickerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (workflow: Workflow) => Promise<void> | void;
|
||||
workflowType: Workflow["type"];
|
||||
breadcrumbs: ReactNode[];
|
||||
primaryLabel?: string;
|
||||
selectingLabel?: string;
|
||||
selecting?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
initialWorkflowId?: string;
|
||||
disabledWorkflow?: (workflow: Workflow) => boolean;
|
||||
}
|
||||
|
||||
export function WorkflowPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
workflowType,
|
||||
breadcrumbs,
|
||||
primaryLabel = "Use",
|
||||
selectingLabel,
|
||||
selecting = false,
|
||||
closeOnSelect = true,
|
||||
initialWorkflowId,
|
||||
disabledWorkflow,
|
||||
}: WorkflowPickerModalProps) {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
const builtins = BUILT_IN_WORKFLOWS.filter(
|
||||
(workflow) => workflow.type === workflowType,
|
||||
);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setWorkflows(builtins);
|
||||
setLoading(true);
|
||||
setSelected(
|
||||
initialWorkflowId
|
||||
? builtins.find((workflow) => workflow.id === initialWorkflowId) ??
|
||||
null
|
||||
: null,
|
||||
);
|
||||
setSearch("");
|
||||
});
|
||||
|
||||
listWorkflows(workflowType)
|
||||
.then((custom) => {
|
||||
if (cancelled) return;
|
||||
const all = [...builtins, ...custom];
|
||||
setWorkflows(all);
|
||||
if (initialWorkflowId) {
|
||||
setSelected(
|
||||
all.find((workflow) => workflow.id === initialWorkflowId) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (initialWorkflowId) {
|
||||
setSelected(
|
||||
builtins.find(
|
||||
(workflow) => workflow.id === initialWorkflowId,
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [initialWorkflowId, open, workflowType]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selectionDisabled =
|
||||
!selected || selecting || (selected && disabledWorkflow?.(selected));
|
||||
const resolvedPrimaryLabel =
|
||||
selecting && selectingLabel ? selectingLabel : primaryLabel;
|
||||
|
||||
function handleClose() {
|
||||
setSelected(null);
|
||||
setSearch("");
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleSelect() {
|
||||
if (!selected || selectionDisabled) return;
|
||||
await onSelect(selected);
|
||||
if (closeOnSelect) handleClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size={selected ? "xl" : "lg"}
|
||||
breadcrumbs={breadcrumbs}
|
||||
primaryAction={{
|
||||
label: resolvedPrimaryLabel,
|
||||
onClick: () => void handleSelect(),
|
||||
disabled: selectionDisabled,
|
||||
}}
|
||||
>
|
||||
<WorkflowPickerContent
|
||||
workflows={workflows}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
workflowType={workflowType}
|
||||
previewMode={workflowType === "tabular" ? "columns" : "prompt"}
|
||||
disabledWorkflow={disabledWorkflow}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -99,7 +99,13 @@ export function WorkflowPromptEditor({
|
|||
}, [value, editor]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-gray-200 rounded-md overflow-hidden bg-white">
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden bg-white ${
|
||||
readOnly
|
||||
? "rounded-md border border-gray-200"
|
||||
: "rounded-md border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{!readOnly && editor && (
|
||||
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-gray-100 bg-gray-50 shrink-0">
|
||||
<ToolbarBtn
|
||||
|
|
@ -181,7 +187,18 @@ export function WorkflowPromptEditor({
|
|||
</ToolbarBtn>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{readOnly && (
|
||||
<div className="flex h-9 shrink-0 items-center bg-gray-50 px-5">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Read-only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${
|
||||
readOnly ? "border-t border-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
7
frontend/src/app/components/workflows/workflowRoutes.ts
Normal file
7
frontend/src/app/components/workflows/workflowRoutes.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { Workflow } from "../shared/types";
|
||||
|
||||
export function workflowDetailPath(workflow: Pick<Workflow, "id" | "type">) {
|
||||
return workflow.type === "assistant"
|
||||
? `/workflows/assistant/${workflow.id}`
|
||||
: `/workflows/tabular-review/${workflow.id}`;
|
||||
}
|
||||
15
frontend/src/app/contexts/PageChromeContext.tsx
Normal file
15
frontend/src/app/contexts/PageChromeContext.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface PageChromeContextValue {
|
||||
mobileActionsContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const PageChromeContext = createContext<PageChromeContextValue>({
|
||||
mobileActionsContainer: null,
|
||||
});
|
||||
|
||||
export function usePageChrome() {
|
||||
return useContext(PageChromeContext);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue