feat: implement Data Recipes page feature subfolders for workflow management and saving logic
This commit is contained in:
parent
390e9ed9d2
commit
30cc509197
15 changed files with 838 additions and 146 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { createRouter } from "@tanstack/react-router";
|
||||
import { Route as rootRoute } from "./routes/__root";
|
||||
import { Route as dataRecipesNewRoute } from "./routes/data-recipes-new";
|
||||
import { Route as dataRecipesRoute } from "./routes/data-recipes";
|
||||
import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId";
|
||||
import { Route as chatRoute } from "./routes/chat";
|
||||
import { Route as exportRoute } from "./routes/export";
|
||||
import { Route as gridTestRoute } from "./routes/grid-test";
|
||||
|
|
@ -19,7 +20,8 @@ const routeTree = rootRoute.addChildren([
|
|||
studioRoute,
|
||||
chatRoute,
|
||||
exportRoute,
|
||||
dataRecipesNewRoute,
|
||||
dataRecipesRoute,
|
||||
dataRecipeRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const RecipeStudioPage = lazy(() =>
|
||||
import("@/features/recipe-studio").then((m) => ({
|
||||
default: m.RecipeStudioPage,
|
||||
})),
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/data-recipes/new",
|
||||
component: RecipeStudioPage,
|
||||
});
|
||||
23
studio/frontend/src/app/routes/data-recipes.$recipeId.tsx
Normal file
23
studio/frontend/src/app/routes/data-recipes.$recipeId.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createRoute } from "@tanstack/react-router";
|
||||
import type { ReactElement } from "react";
|
||||
import { lazy } from "react";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const EditRecipeEditorPage = lazy(() =>
|
||||
import("@/features/data-recipes").then((m) => ({
|
||||
default: m.EditRecipeEditorPage,
|
||||
})),
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/data-recipes/$recipeId",
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: DataRecipeEditorRoute,
|
||||
});
|
||||
|
||||
function DataRecipeEditorRoute(): ReactElement {
|
||||
const { recipeId } = Route.useParams();
|
||||
return <EditRecipeEditorPage recipeId={recipeId} />;
|
||||
}
|
||||
17
studio/frontend/src/app/routes/data-recipes.tsx
Normal file
17
studio/frontend/src/app/routes/data-recipes.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const DataRecipesPage = lazy(() =>
|
||||
import("@/features/data-recipes").then((m) => ({
|
||||
default: m.DataRecipesPage,
|
||||
})),
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/data-recipes",
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: DataRecipesPage,
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Analytics01Icon,
|
||||
ArrowRight01Icon,
|
||||
Book03Icon,
|
||||
CookBookIcon,
|
||||
PackageIcon,
|
||||
ZapIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
|
@ -19,6 +20,7 @@ import { useState } from "react";
|
|||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
|
||||
{ label: "Recipes", href: "/data-recipes", icon: CookBookIcon, enabled: true },
|
||||
{ label: "Evaluate", href: "/evaluate", icon: Analytics01Icon, enabled: false },
|
||||
{ label: "Export", href: "/export", icon: PackageIcon, enabled: true },
|
||||
{ label: "Chat", href: "/chat", icon: AiChat02Icon, enabled: true },
|
||||
|
|
@ -65,7 +67,8 @@ export function Navbar() {
|
|||
{/* Center: pill nav */}
|
||||
<nav className="flex items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const active =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
if (!item.enabled) {
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
104
studio/frontend/src/components/ui/empty.tsx
Normal file
104
studio/frontend/src/components/ui/empty.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"gap-4 rounded-lg border-dashed p-12 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"gap-2 flex max-w-sm flex-col items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"gap-4 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
88
studio/frontend/src/features/data-recipes/data/recipes-db.ts
Normal file
88
studio/frontend/src/features/data-recipes/data/recipes-db.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import Dexie, { type EntityTable, liveQuery } from "dexie";
|
||||
import type { RecipePayload } from "@/features/recipe-studio";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RecipeRecord, SaveRecipeInput } from "../types";
|
||||
|
||||
const db = new Dexie("unsloth-data-recipes") as Dexie & {
|
||||
recipes: EntityTable<RecipeRecord, "id">;
|
||||
};
|
||||
|
||||
db.version(1).stores({
|
||||
recipes: "id, name, updatedAt, createdAt",
|
||||
});
|
||||
|
||||
function normalizeRecipeName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
return trimmed.length > 0 ? trimmed : "Unnamed";
|
||||
}
|
||||
|
||||
function createEmptyPayload(): RecipePayload {
|
||||
return {
|
||||
recipe: {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
model_providers: [],
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
model_configs: [],
|
||||
columns: [],
|
||||
processors: [],
|
||||
},
|
||||
run: {
|
||||
rows: 5,
|
||||
preview: true,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
output_formats: ["jsonl"],
|
||||
},
|
||||
ui: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRecipes(): Promise<RecipeRecord[]> {
|
||||
return db.recipes.orderBy("updatedAt").reverse().toArray();
|
||||
}
|
||||
|
||||
export async function getRecipe(id: string): Promise<RecipeRecord | undefined> {
|
||||
return db.recipes.get(id);
|
||||
}
|
||||
|
||||
export async function saveRecipe(input: SaveRecipeInput): Promise<RecipeRecord> {
|
||||
const now = Date.now();
|
||||
const id = input.id ?? crypto.randomUUID();
|
||||
const existing = input.id ? await db.recipes.get(input.id) : undefined;
|
||||
const record: RecipeRecord = {
|
||||
id,
|
||||
name: normalizeRecipeName(input.name),
|
||||
payload: input.payload,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.recipes.put(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function deleteRecipe(id: string): Promise<void> {
|
||||
await db.recipes.delete(id);
|
||||
}
|
||||
|
||||
export async function createRecipeDraft(): Promise<RecipeRecord> {
|
||||
return saveRecipe({
|
||||
name: "Unnamed",
|
||||
payload: createEmptyPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecipes(): RecipeRecord[] {
|
||||
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = liveQuery(() => listRecipes()).subscribe({
|
||||
next: (value) => setRecipes(value),
|
||||
error: (error) => console.error("data-recipes liveQuery:", error),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, []);
|
||||
|
||||
return recipes;
|
||||
}
|
||||
2
studio/frontend/src/features/data-recipes/index.ts
Normal file
2
studio/frontend/src/features/data-recipes/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { DataRecipesPage } from "./pages/data-recipes-page";
|
||||
export { EditRecipeEditorPage } from "./pages/edit-recipe-page";
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { CookBookIcon, Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
import { createRecipeDraft, deleteRecipe, useRecipes } from "../data/recipes-db";
|
||||
|
||||
function formatRelativeTime(value: number): string {
|
||||
const now = Date.now();
|
||||
const diffMs = Math.max(0, now - value);
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const week = 7 * day;
|
||||
|
||||
if (diffMs < minute) {
|
||||
return "just now";
|
||||
}
|
||||
if (diffMs < hour) {
|
||||
const minutes = Math.floor(diffMs / minute);
|
||||
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
if (diffMs < day) {
|
||||
const hours = Math.floor(diffMs / hour);
|
||||
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
if (diffMs < week) {
|
||||
const days = Math.floor(diffMs / day);
|
||||
return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
const weeks = Math.floor(diffMs / week);
|
||||
return `${weeks} week${weeks === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
export function DataRecipesPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const recipes = useRecipes();
|
||||
const [creatingRecipe, setCreatingRecipe] = useState(false);
|
||||
|
||||
function openNewRecipe(): void {
|
||||
if (creatingRecipe) {
|
||||
return;
|
||||
}
|
||||
setCreatingRecipe(true);
|
||||
void createRecipeDraft()
|
||||
.then((recipe) => {
|
||||
void navigate({
|
||||
to: "/data-recipes/$recipeId",
|
||||
params: { recipeId: recipe.id },
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setCreatingRecipe(false);
|
||||
});
|
||||
}
|
||||
|
||||
function openRecipe(recipeId: string): void {
|
||||
void navigate({
|
||||
to: "/data-recipes/$recipeId",
|
||||
params: { recipeId },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteRecipe(recipeId: string): Promise<void> {
|
||||
await deleteRecipe(recipeId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto w-full max-w-7xl px-6 py-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Data Recipes</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create and manage local recipe workflows.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={openNewRecipe} disabled={creatingRecipe}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
|
||||
New Recipe
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recipes.length === 0 ? (
|
||||
<Empty className="mt-8 border border-dashed border-border/70">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<HugeiconsIcon icon={CookBookIcon} className="size-5" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No recipes yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Create your first recipe to start building workflows.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button type="button" onClick={openNewRecipe} disabled={creatingRecipe}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
|
||||
Create Recipe
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="mt-8 space-y-2">
|
||||
{recipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() => openRecipe(recipe.id)}
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20">
|
||||
<HugeiconsIcon icon={CookBookIcon} className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{recipe.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last updated {formatRelativeTime(recipe.updatedAt)} | Created{" "}
|
||||
{formatRelativeTime(recipe.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => void handleDeleteRecipe(recipe.id)}
|
||||
aria-label={`Delete ${recipe.name}`}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { RecipeStudioPage, type RecipePayload } from "@/features/recipe-studio";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ReactElement } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getRecipe, saveRecipe } from "../data/recipes-db";
|
||||
import type { RecipeRecord } from "../types";
|
||||
|
||||
type EditRecipePageProps = {
|
||||
recipeId: string;
|
||||
};
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "missing" }
|
||||
| { status: "ready"; record: RecipeRecord };
|
||||
|
||||
function RecipeLoadState({
|
||||
title,
|
||||
description,
|
||||
onBack,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
onBack: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto flex min-h-[70vh] w-full max-w-4xl items-center justify-center px-6 py-8">
|
||||
<div className="w-full rounded-2xl border bg-card p-8 text-center">
|
||||
<h1 className="text-lg font-semibold">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
<Button type="button" variant="outline" className="mt-5" onClick={onBack}>
|
||||
Back to Recipes
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditRecipeEditorPage({ recipeId }: EditRecipePageProps): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getRecipe(recipeId).then((record) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
if (!record) {
|
||||
setLoadState({ status: "missing" });
|
||||
return;
|
||||
}
|
||||
setLoadState({ status: "ready", record });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [recipeId]);
|
||||
|
||||
const handlePersist = useCallback(
|
||||
async (input: { id: string | null; name: string; payload: RecipePayload }) => {
|
||||
const record = await saveRecipe({
|
||||
id: input.id ?? recipeId,
|
||||
name: input.name,
|
||||
payload: input.payload,
|
||||
});
|
||||
return { id: record.id, updatedAt: record.updatedAt };
|
||||
},
|
||||
[recipeId],
|
||||
);
|
||||
|
||||
if (loadState.status === "loading") {
|
||||
return (
|
||||
<RecipeLoadState
|
||||
title="Loading recipe..."
|
||||
description="Please wait while we load your recipe."
|
||||
onBack={() => void navigate({ to: "/data-recipes" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState.status === "missing") {
|
||||
return (
|
||||
<RecipeLoadState
|
||||
title="Recipe not found"
|
||||
description="This recipe may have been deleted."
|
||||
onBack={() => void navigate({ to: "/data-recipes" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RecipeStudioPage
|
||||
key={loadState.record.id}
|
||||
recipeId={loadState.record.id}
|
||||
initialRecipeName={loadState.record.name}
|
||||
initialPayload={loadState.record.payload}
|
||||
initialSavedAt={loadState.record.updatedAt}
|
||||
onPersistRecipe={handlePersist}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
studio/frontend/src/features/data-recipes/types.ts
Normal file
15
studio/frontend/src/features/data-recipes/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { RecipePayload } from "@/features/recipe-studio";
|
||||
|
||||
export type RecipeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
payload: RecipePayload;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type SaveRecipeInput = {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
payload: RecipePayload;
|
||||
};
|
||||
|
|
@ -1,60 +1,111 @@
|
|||
import type { ReactElement } from "react";
|
||||
import { EyeIcon } from "@hugeicons/core-free-icons";
|
||||
import { type KeyboardEvent, type ReactElement, useState } from "react";
|
||||
import {
|
||||
CookBookIcon,
|
||||
FloppyDiskIcon,
|
||||
TestTubeIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type StatusTone = "success" | "error";
|
||||
|
||||
type RecipeStudioHeaderProps = {
|
||||
previewLoading: boolean;
|
||||
statusMessage: {
|
||||
tone: StatusTone;
|
||||
text: string;
|
||||
} | null;
|
||||
saveLoading: boolean;
|
||||
saveTone: StatusTone;
|
||||
savedAtLabel: string;
|
||||
workflowName: string;
|
||||
onWorkflowNameChange: (value: string) => void;
|
||||
onPreview: () => void;
|
||||
onSaveRecipe: () => void;
|
||||
};
|
||||
|
||||
const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
|
||||
success: "mt-2 text-xs text-emerald-600",
|
||||
error: "mt-2 text-xs text-rose-600",
|
||||
success: "Saved",
|
||||
error: "Unsaved changes",
|
||||
};
|
||||
|
||||
export function RecipeStudioHeader({
|
||||
previewLoading,
|
||||
statusMessage,
|
||||
saveLoading,
|
||||
saveTone,
|
||||
savedAtLabel,
|
||||
workflowName,
|
||||
onWorkflowNameChange,
|
||||
onPreview,
|
||||
onSaveRecipe,
|
||||
}: RecipeStudioHeaderProps): ReactElement {
|
||||
const [editingWorkflowName, setEditingWorkflowName] = useState(false);
|
||||
|
||||
function closeWorkflowNameEditor(): void {
|
||||
if (workflowName.trim().length === 0) {
|
||||
onWorkflowNameChange("Unnamed");
|
||||
}
|
||||
setEditingWorkflowName(false);
|
||||
}
|
||||
|
||||
function handleWorkflowNameKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
|
||||
if (event.key === "Enter") {
|
||||
closeWorkflowNameEditor();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setEditingWorkflowName(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[1fr_auto] lg:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Create Data Recipe</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Design synthetic-data pipelines with Data Designer.
|
||||
</p>
|
||||
{statusMessage && (
|
||||
<p className={STATUS_MESSAGE_CLASS[statusMessage.tone]}>
|
||||
{statusMessage.text}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-4 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-lg corner-squircle border border-border/70 bg-muted/20"
|
||||
aria-label="Recipe icon"
|
||||
>
|
||||
<HugeiconsIcon icon={CookBookIcon} className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{editingWorkflowName ? (
|
||||
<Input
|
||||
value={workflowName}
|
||||
onChange={(event) => onWorkflowNameChange(event.target.value)}
|
||||
onBlur={closeWorkflowNameEditor}
|
||||
onKeyDown={handleWorkflowNameKeyDown}
|
||||
autoFocus={true}
|
||||
className="h-7 w-[180px]"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingWorkflowName(true)}
|
||||
className="truncate text-sm font-semibold text-foreground hover:text-primary"
|
||||
>
|
||||
{workflowName}
|
||||
</button>
|
||||
)}
|
||||
<Badge variant="secondary" className="h-6 text-[10px]">
|
||||
{STATUS_MESSAGE_CLASS[saveTone]}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{savedAtLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-2 lg:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
disabled={previewLoading}
|
||||
className="gap-2 text-xs"
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={EyeIcon} className="size-3.5" />
|
||||
)}
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" onClick={onPreview} disabled={previewLoading}>
|
||||
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
|
||||
{previewLoading ? "Previewing..." : "Preview"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onSaveRecipe}
|
||||
disabled={saveLoading}
|
||||
>
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} className="size-3.5" />
|
||||
{saveLoading ? "Saving..." : "Save Recipe"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1 +1,7 @@
|
|||
export { RecipeStudioPage } from "./recipe-studio-page";
|
||||
export type {
|
||||
PersistRecipeInput,
|
||||
PersistRecipeResult,
|
||||
RecipeStudioPageProps,
|
||||
} from "./recipe-studio-page";
|
||||
export type { RecipePayload } from "./utils/payload/types";
|
||||
|
|
|
|||
|
|
@ -42,18 +42,60 @@ import { isCategoryConfig } from "./utils";
|
|||
import { deriveDisplayGraph } from "./utils/graph/derive-display-graph";
|
||||
import { importRecipePayload } from "./utils/import";
|
||||
import { buildRecipePayload } from "./utils/payload";
|
||||
import type { RecipePayload } from "./utils/payload/types";
|
||||
import { buildDefaultSchemaTransform } from "./utils/processors";
|
||||
|
||||
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
|
||||
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
|
||||
|
||||
type StatusTone = "success" | "error";
|
||||
type StatusMessage = {
|
||||
tone: StatusTone;
|
||||
text: string;
|
||||
|
||||
export type PersistRecipeInput = {
|
||||
id: string | null;
|
||||
name: string;
|
||||
payload: RecipePayload;
|
||||
};
|
||||
|
||||
export function RecipeStudioPage(): ReactElement {
|
||||
export type PersistRecipeResult = {
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type RecipeStudioPageProps = {
|
||||
recipeId: string;
|
||||
initialRecipeName: string;
|
||||
initialPayload: RecipePayload;
|
||||
initialSavedAt: number;
|
||||
onPersistRecipe: (input: PersistRecipeInput) => Promise<PersistRecipeResult>;
|
||||
};
|
||||
|
||||
function buildSignature(name: string, payload: RecipePayload): string {
|
||||
return JSON.stringify({ name, payload });
|
||||
}
|
||||
|
||||
function normalizeWorkflowName(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : "Unnamed";
|
||||
}
|
||||
|
||||
function formatSavedLabel(savedAt: number | null): string {
|
||||
if (!savedAt) {
|
||||
return "Not saved yet";
|
||||
}
|
||||
const time = new Date(savedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `Saved ${time}`;
|
||||
}
|
||||
|
||||
export function RecipeStudioPage({
|
||||
recipeId,
|
||||
initialRecipeName,
|
||||
initialPayload,
|
||||
initialSavedAt,
|
||||
onPersistRecipe,
|
||||
}: RecipeStudioPageProps): ReactElement {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
|
|
@ -79,6 +121,7 @@ export function RecipeStudioPage(): ReactElement {
|
|||
setSheetView,
|
||||
setProcessors,
|
||||
setDialogOpen,
|
||||
resetRecipe,
|
||||
loadRecipe,
|
||||
setLayoutDirection,
|
||||
applyLayout,
|
||||
|
|
@ -112,6 +155,7 @@ export function RecipeStudioPage(): ReactElement {
|
|||
setSheetView: state.setSheetView,
|
||||
setProcessors: state.setProcessors,
|
||||
setDialogOpen: state.setDialogOpen,
|
||||
resetRecipe: state.resetRecipe,
|
||||
loadRecipe: state.loadRecipe,
|
||||
setLayoutDirection: state.setLayoutDirection,
|
||||
applyLayout: state.applyLayout,
|
||||
|
|
@ -125,11 +169,14 @@ export function RecipeStudioPage(): ReactElement {
|
|||
null,
|
||||
);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [processorsOpen, setProcessorsOpen] = useState(false);
|
||||
const [interactive, setInteractive] = useState(true);
|
||||
const [statusMessage, setStatusMessage] = useState<StatusMessage | null>(null);
|
||||
const [workflowName, setWorkflowName] = useState("Unnamed");
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
const [savedSignature, setSavedSignature] = useState<string>("");
|
||||
|
||||
const baseNodeIds = useMemo(
|
||||
() => new Set(nodes.map((node) => node.id)),
|
||||
|
|
@ -249,45 +296,116 @@ export function RecipeStudioPage(): ReactElement {
|
|||
setInteractive((value) => !value);
|
||||
}, []);
|
||||
|
||||
const setSuccessStatus = useCallback((text: string) => {
|
||||
setStatusMessage({ tone: "success", text });
|
||||
}, []);
|
||||
|
||||
const setErrorStatus = useCallback((text: string) => {
|
||||
setStatusMessage({ tone: "error", text });
|
||||
}, []);
|
||||
|
||||
const buildPayload = useCallback(
|
||||
const payloadResult = useMemo(
|
||||
() => buildRecipePayload(configs, nodes, edges, processors),
|
||||
[configs, nodes, edges, processors],
|
||||
[configs, edges, nodes, processors],
|
||||
);
|
||||
const currentPayload = payloadResult.payload;
|
||||
const normalizedWorkflowName = useMemo(
|
||||
() => normalizeWorkflowName(workflowName),
|
||||
[workflowName],
|
||||
);
|
||||
const currentSignature = useMemo(
|
||||
() => buildSignature(normalizedWorkflowName, currentPayload),
|
||||
[currentPayload, normalizedWorkflowName],
|
||||
);
|
||||
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
|
||||
const saveTone: StatusTone =
|
||||
!isDirty && Boolean(lastSavedAt) ? "success" : "error";
|
||||
const savedAtLabel = formatSavedLabel(lastSavedAt);
|
||||
|
||||
useEffect(() => {
|
||||
const nextName = normalizeWorkflowName(initialRecipeName);
|
||||
resetRecipe();
|
||||
setWorkflowName(nextName);
|
||||
setLastSavedAt(initialSavedAt);
|
||||
setCopied(false);
|
||||
|
||||
const parsed = importRecipePayload(JSON.stringify(initialPayload));
|
||||
if (parsed.snapshot) {
|
||||
loadRecipe(parsed.snapshot);
|
||||
} else {
|
||||
console.error("Failed to load recipe payload.", parsed.errors);
|
||||
}
|
||||
|
||||
const state = useRecipeStudioStore.getState();
|
||||
const { payload } = buildRecipePayload(
|
||||
state.configs,
|
||||
state.nodes,
|
||||
state.edges,
|
||||
state.processors,
|
||||
);
|
||||
setSavedSignature(buildSignature(nextName, payload));
|
||||
}, [
|
||||
initialPayload,
|
||||
initialRecipeName,
|
||||
initialSavedAt,
|
||||
loadRecipe,
|
||||
recipeId,
|
||||
resetRecipe,
|
||||
]);
|
||||
|
||||
const persistRecipe = useCallback(async (): Promise<void> => {
|
||||
if (saveLoading) {
|
||||
return;
|
||||
}
|
||||
const nextName = normalizeWorkflowName(workflowName);
|
||||
if (nextName !== workflowName) {
|
||||
setWorkflowName(nextName);
|
||||
}
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const result = await onPersistRecipe({
|
||||
id: recipeId,
|
||||
name: nextName,
|
||||
payload: currentPayload,
|
||||
});
|
||||
setLastSavedAt(result.updatedAt);
|
||||
setSavedSignature(buildSignature(nextName, currentPayload));
|
||||
} catch (error) {
|
||||
console.error("Save recipe failed:", error);
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}, [
|
||||
currentPayload,
|
||||
onPersistRecipe,
|
||||
recipeId,
|
||||
saveLoading,
|
||||
workflowName,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty || saveLoading) {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void persistRecipe();
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [isDirty, persistRecipe, saveLoading]);
|
||||
|
||||
const readPayload = useCallback(
|
||||
(fallbackError: string) => {
|
||||
const { payload, errors } = buildPayload();
|
||||
if (errors.length === 0) {
|
||||
return payload;
|
||||
() => {
|
||||
if (payloadResult.errors.length === 0) {
|
||||
return payloadResult.payload;
|
||||
}
|
||||
setErrorStatus(errors[0] ?? fallbackError);
|
||||
return null;
|
||||
},
|
||||
[buildPayload, setErrorStatus],
|
||||
[payloadResult.errors.length, payloadResult.payload],
|
||||
);
|
||||
|
||||
const handlePreview = async (): Promise<void> => {
|
||||
setPreviewLoading(true);
|
||||
setStatusMessage(null);
|
||||
const payload = readPayload("Fix config errors before preview.");
|
||||
const payload = readPayload();
|
||||
if (!payload) {
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await previewRecipe(payload);
|
||||
const rows = Array.isArray(result.dataset) ? result.dataset.length : 0;
|
||||
setSuccessStatus(`Preview ready (${rows} rows).`);
|
||||
await previewRecipe(payload);
|
||||
} catch (error) {
|
||||
setErrorStatus(error instanceof Error ? error.message : "Preview failed.");
|
||||
console.error("Preview failed:", error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
|
|
@ -295,22 +413,20 @@ export function RecipeStudioPage(): ReactElement {
|
|||
|
||||
const handleCopyRecipe = async (): Promise<void> => {
|
||||
setCopied(false);
|
||||
setStatusMessage(null);
|
||||
const payload = readPayload("Fix config errors before copy.");
|
||||
const payload = readPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard) {
|
||||
setErrorStatus("Clipboard not available.");
|
||||
console.error("Clipboard not available.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
setSuccessStatus("Recipe copied to clipboard.");
|
||||
} catch (error) {
|
||||
setErrorStatus(error instanceof Error ? error.message : "Copy failed.");
|
||||
console.error("Copy failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -320,7 +436,6 @@ export function RecipeStudioPage(): ReactElement {
|
|||
return result.errors[0] ?? "Invalid payload.";
|
||||
}
|
||||
loadRecipe(result.snapshot);
|
||||
setSuccessStatus("Recipe imported.");
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -338,69 +453,78 @@ export function RecipeStudioPage(): ReactElement {
|
|||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="w-full px-6 py-8">
|
||||
<RecipeStudioHeader
|
||||
previewLoading={previewLoading}
|
||||
statusMessage={statusMessage}
|
||||
onPreview={handlePreview}
|
||||
/>
|
||||
<div
|
||||
className="relative h-[75vh] w-full rounded-2xl corner-squircle border "
|
||||
className="relative w-full overflow-hidden rounded-2xl corner-squircle border"
|
||||
ref={setSheetContainer}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={displayGraph.nodes}
|
||||
edges={displayGraph.edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{
|
||||
type: "canvas",
|
||||
data: { key: "name", path: "auto" },
|
||||
style: { strokeWidth: 1.5, stroke: "var(--border)" },
|
||||
<RecipeStudioHeader
|
||||
previewLoading={previewLoading}
|
||||
saveLoading={saveLoading}
|
||||
saveTone={saveTone}
|
||||
savedAtLabel={savedAtLabel}
|
||||
workflowName={workflowName}
|
||||
onWorkflowNameChange={setWorkflowName}
|
||||
onPreview={handlePreview}
|
||||
onSaveRecipe={() => {
|
||||
void persistRecipe();
|
||||
}}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
isValidConnection={isValidConnection}
|
||||
nodesDraggable={interactive}
|
||||
nodesConnectable={interactive}
|
||||
elementsSelectable={interactive}
|
||||
fitView={true}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<LayoutControls
|
||||
direction={layoutDirection}
|
||||
onLayout={applyLayout}
|
||||
onToggleDirection={handleToggleDirection}
|
||||
/>
|
||||
<InternalsSync nodeIds={displayNodeIds} />
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={18}
|
||||
size={1}
|
||||
color="#d4d4d8"
|
||||
/>
|
||||
<Panel position="top-right" className="m-3">
|
||||
<BlockSheet
|
||||
container={sheetContainer}
|
||||
sheetView={sheetView}
|
||||
onViewChange={setSheetView}
|
||||
onAddSampler={addSamplerNode}
|
||||
onAddLlm={addLlmNode}
|
||||
onAddModelProvider={addModelProviderNode}
|
||||
onAddModelConfig={addModelConfigNode}
|
||||
onAddExpression={addExpressionNode}
|
||||
onOpenProcessors={openProcessorsFromSheet}
|
||||
copied={copied}
|
||||
onCopy={handleCopyRecipe}
|
||||
onImport={() => setImportOpen(true)}
|
||||
/>
|
||||
<div className="h-[75vh] w-full rounded-t-none">
|
||||
<ReactFlow
|
||||
nodes={displayGraph.nodes}
|
||||
edges={displayGraph.edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{
|
||||
type: "canvas",
|
||||
data: { key: "name", path: "auto" },
|
||||
style: { strokeWidth: 1.5, stroke: "var(--border)" },
|
||||
}}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
isValidConnection={isValidConnection}
|
||||
nodesDraggable={interactive}
|
||||
nodesConnectable={interactive}
|
||||
elementsSelectable={interactive}
|
||||
fitView={true}
|
||||
className="h-full w-full rounded-t-none"
|
||||
>
|
||||
<LayoutControls
|
||||
direction={layoutDirection}
|
||||
onLayout={applyLayout}
|
||||
onToggleDirection={handleToggleDirection}
|
||||
/>
|
||||
</Panel>
|
||||
<ViewportControls
|
||||
interactive={interactive}
|
||||
onToggleInteractive={toggleInteractive}
|
||||
/>
|
||||
</ReactFlow>
|
||||
<InternalsSync nodeIds={displayNodeIds} />
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={18}
|
||||
size={1}
|
||||
color="#d4d4d8"
|
||||
/>
|
||||
<Panel position="top-right" className="m-3">
|
||||
<BlockSheet
|
||||
container={sheetContainer}
|
||||
sheetView={sheetView}
|
||||
onViewChange={setSheetView}
|
||||
onAddSampler={addSamplerNode}
|
||||
onAddLlm={addLlmNode}
|
||||
onAddModelProvider={addModelProviderNode}
|
||||
onAddModelConfig={addModelConfigNode}
|
||||
onAddExpression={addExpressionNode}
|
||||
onOpenProcessors={openProcessorsFromSheet}
|
||||
copied={copied}
|
||||
onCopy={handleCopyRecipe}
|
||||
onImport={() => setImportOpen(true)}
|
||||
/>
|
||||
</Panel>
|
||||
<ViewportControls
|
||||
interactive={interactive}
|
||||
onToggleInteractive={toggleInteractive}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<ConfigDialog
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ type RecipeStudioState = {
|
|||
setSheetView: (view: SheetView) => void;
|
||||
setProcessors: (processors: RecipeProcessorConfig[]) => void;
|
||||
setDialogOpen: (open: boolean) => void;
|
||||
resetRecipe: () => void;
|
||||
selectConfig: (id: string) => void;
|
||||
openConfig: (id: string) => void;
|
||||
setLayoutDirection: (direction: LayoutDirection) => void;
|
||||
|
|
@ -113,6 +114,21 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
setSheetView: (view) => set({ sheetView: view }),
|
||||
setProcessors: (processors) => set({ processors }),
|
||||
setDialogOpen: (open) => set({ dialogOpen: open }),
|
||||
resetRecipe: () =>
|
||||
set({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
auxNodePositions: {},
|
||||
auxNodeSizes: {},
|
||||
configs: {},
|
||||
processors: [],
|
||||
sheetView: "root",
|
||||
activeConfigId: null,
|
||||
dialogOpen: false,
|
||||
layoutDirection: "LR",
|
||||
nextId: 3,
|
||||
nextY: 280,
|
||||
}),
|
||||
selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }),
|
||||
openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }),
|
||||
setLayoutDirection: (direction) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue