diff --git a/react-compiler.config.js b/react-compiler.config.js index f6bd008cc..8cd4ae53d 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -5,6 +5,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ // ✅ Enabled "src/components/Home", "src/components/Editor", + "src/components/Learn", // 0 useCallback/useMemo - ready to enable "src/components/layout", diff --git a/src/components/Learn/DocumentationPanel.tsx b/src/components/Learn/DocumentationPanel.tsx new file mode 100644 index 000000000..dbe693dfe --- /dev/null +++ b/src/components/Learn/DocumentationPanel.tsx @@ -0,0 +1,198 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { DOCUMENTATION_URL } from "@/utils/constants"; +import { tracking } from "@/utils/tracking"; + +interface QuickLink { + id: string; + label: string; + href: string; + icon: IconName; +} + +const QUICK_LINKS: QuickLink[] = [ + { + id: "getting-started", + label: "Getting started", + href: `${DOCUMENTATION_URL}getting-started/first-pipeline/`, + icon: "Rocket", + }, + { + id: "components", + label: "Components", + href: `${DOCUMENTATION_URL}core-concepts/what-are-components/`, + icon: "Package", + }, + { + id: "pipelines", + label: "Pipelines", + href: `${DOCUMENTATION_URL}core-concepts/understanding-inputs-outputs/`, + icon: "GitBranch", + }, + { + id: "secrets", + label: "Secrets & auth", + href: `${DOCUMENTATION_URL}core-concepts/secrets/`, + icon: "Lock", + }, + { + id: "runs", + label: "Running pipelines", + href: `${DOCUMENTATION_URL}core-concepts/tasks-and-executions/`, + icon: "Play", + }, + { + id: "schema", + label: "Schema reference", + href: `${DOCUMENTATION_URL}reference/schema/`, + icon: "Code", + }, +]; + +interface FaqItem { + id: string; + question: string; + answer: string; +} + +const FAQ_ITEMS: FaqItem[] = [ + { + id: "create-pipeline", + question: "How do I create my first pipeline?", + answer: + "Open the Pipelines tab and click 'New pipeline', or import one of the example pipelines from the Learning Hub to get started quickly.", + }, + { + id: "share-pipeline", + question: "Can I share a pipeline with someone else?", + answer: + "Yes — export the pipeline as YAML from the editor and share the file. The recipient can import it from the home dashboard.", + }, + { + id: "secrets", + question: "Where are secrets stored?", + answer: + "Secrets are managed under Settings → Secrets and are encrypted at rest. Components reference secrets by name at runtime.", + }, + { + id: "self-host", + question: "Can I run Tangle against my own backend?", + answer: + "Yes. Under Settings → Backend you can point Tangle at any compatible backend URL — see the docs for self-hosting instructions.", + }, +]; + +function QuickLinkTile({ link }: { link: QuickLink }) { + return ( + + +
+ + ); +} + +function FaqRow({ item }: { item: FaqItem }) { + return ( + + + + {item.question} + + + + + {item.answer} + + + + ); +} + +export function DocumentationPanel() { + return ( + + + + + + Full docs ↗ + + + +
+ {QUICK_LINKS.map((link) => ( + + ))} +
+ + + + Frequently asked + + + {FAQ_ITEMS.map((item) => ( + + ))} + + +
+ ); +} diff --git a/src/components/Learn/FeaturedExamples.tsx b/src/components/Learn/FeaturedExamples.tsx new file mode 100644 index 000000000..c601c35d0 --- /dev/null +++ b/src/components/Learn/FeaturedExamples.tsx @@ -0,0 +1,103 @@ +import { Link } from "@tanstack/react-router"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading } from "@/components/ui/typography"; +import { tracking } from "@/utils/tracking"; + +interface FeaturedExample { + id: string; + name: string; + description: string; + tags: string[]; +} + +const STUB_EXAMPLES: FeaturedExample[] = [ + { + id: "xgboost", + name: "XGBoost classification", + description: + "Train and evaluate an XGBoost model end-to-end with preprocessing and hyperparameter tuning.", + tags: ["XGBoost", "Tabular"], + }, + { + id: "pytorch", + name: "PyTorch neural network", + description: + "Build, train and evaluate a fully-connected network in PyTorch — data loading through metrics.", + tags: ["PyTorch", "Deep Learning"], + }, + { + id: "vertex-automl", + name: "Vertex AI AutoML", + description: + "Use Google Vertex AI AutoML to train tabular models without writing model code.", + tags: ["AutoML", "Vertex AI"], + }, +]; + +export function FeaturedExamples() { + return ( + + + + + + + +
+ {STUB_EXAMPLES.map((example) => ( + + + + + {example.name} + + {example.description} + + + {example.tags.map((tag) => ( + + {tag} + + ))} + + + + + + ))} +
+
+ ); +} diff --git a/src/components/Learn/FeaturedTours.tsx b/src/components/Learn/FeaturedTours.tsx new file mode 100644 index 000000000..92d4e47be --- /dev/null +++ b/src/components/Learn/FeaturedTours.tsx @@ -0,0 +1,103 @@ +import { Link } from "@tanstack/react-router"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { tracking } from "@/utils/tracking"; + +interface FeaturedTour { + id: string; + title: string; + duration: string; + tag?: "new" | "popular"; +} + +const STUB_TOURS: FeaturedTour[] = [ + { + id: "first-pipeline", + title: "Build your first pipeline", + duration: "4 min", + tag: "popular", + }, + { id: "using-secrets", title: "Using secrets safely", duration: "2 min" }, + { + id: "multinode-tasks", + title: "Run multinode tasks", + duration: "3 min", + tag: "new", + }, + { + id: "custom-components", + title: "Create a custom component", + duration: "5 min", + }, +]; + +export function FeaturedTours() { + return ( +
+ + + + + + + +
    + {STUB_TOURS.map((tour) => ( +
  • + +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/Learn/LearnComingSoon.tsx b/src/components/Learn/LearnComingSoon.tsx new file mode 100644 index 000000000..e8181ec91 --- /dev/null +++ b/src/components/Learn/LearnComingSoon.tsx @@ -0,0 +1,43 @@ +import { Link } from "@tanstack/react-router"; + +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack } from "@/components/ui/layout"; +import { Heading, Paragraph } from "@/components/ui/typography"; + +interface LearnComingSoonProps { + title: string; + description: string; + icon: IconName; +} + +export function LearnComingSoon({ + title, + description, + icon, +}: LearnComingSoonProps) { + return ( + +
+
+ + {title} + + {description} + + + + + Coming soon. + + + ← Back to Learning Hub + + +
+ ); +} diff --git a/src/components/Learn/LearnPageHeader.tsx b/src/components/Learn/LearnPageHeader.tsx new file mode 100644 index 000000000..62b819952 --- /dev/null +++ b/src/components/Learn/LearnPageHeader.tsx @@ -0,0 +1,53 @@ +import { Link } from "@tanstack/react-router"; + +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import { tracking } from "@/utils/tracking"; + +interface LearnPageHeaderProps { + title: string; + description?: string; + icon?: IconName; + backTo?: string; + backLabel?: string; +} + +export function LearnPageHeader({ + title, + description, + icon, + backTo, + backLabel = "Back to Learning Hub", +}: LearnPageHeaderProps) { + return ( + + {backTo && ( + + + ); +} diff --git a/src/components/Learn/LearnSearchBar.tsx b/src/components/Learn/LearnSearchBar.tsx new file mode 100644 index 000000000..7ccbb19ea --- /dev/null +++ b/src/components/Learn/LearnSearchBar.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; + +import { Icon } from "@/components/ui/icon"; +import { Input, InputGroup } from "@/components/ui/input"; +import { TANGLE_WEBSITE_URL } from "@/utils/constants"; +import { tracking } from "@/utils/tracking"; + +export function LearnSearchBar() { + const [value, setValue] = useState(""); + + const handleSubmit = () => { + const query = value.trim(); + if (!query) return; + const url = `${TANGLE_WEBSITE_URL}search/?q=${encodeURIComponent(query)}`; + window.open(url, "_blank", "noopener,noreferrer"); + }; + + return ( + + ); +} diff --git a/src/components/Learn/OnboardingHero.tsx b/src/components/Learn/OnboardingHero.tsx new file mode 100644 index 000000000..c9b5f12d2 --- /dev/null +++ b/src/components/Learn/OnboardingHero.tsx @@ -0,0 +1,117 @@ +import { Link } from "@tanstack/react-router"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { tracking } from "@/utils/tracking"; + +interface OnboardingStep { + id: string; + label: string; + completed: boolean; +} + +const STUB_STEPS: OnboardingStep[] = [ + { id: "configure-backend", label: "Connect a backend", completed: true }, + { id: "import-sample", label: "Import a sample pipeline", completed: true }, + { id: "run-pipeline", label: "Run your first pipeline", completed: false }, + { id: "edit-component", label: "Edit a component", completed: false }, + { + id: "create-pipeline", + label: "Build a pipeline from scratch", + completed: false, + }, +]; + +export function OnboardingHero() { + const completed = STUB_STEPS.filter((s) => s.completed).length; + const total = STUB_STEPS.length; + const isComplete = completed === total; + const nextStep = STUB_STEPS.find((s) => !s.completed); + + return ( +
+ + + + + + + {isComplete + ? "Onboarding complete — explore tours and tips below to keep going." + : "Follow a few quick steps to get from zero to your first pipeline run."} + + + {!isComplete && nextStep && ( + + )} + + + + + + {completed} of {total} steps + +
+
+
+ + +
    + {STUB_STEPS.map((step) => ( +
  • +
  • + ))} +
+ + +
+ ); +} diff --git a/src/components/Learn/TipOfTheDay.tsx b/src/components/Learn/TipOfTheDay.tsx new file mode 100644 index 000000000..2c1bc3e8f --- /dev/null +++ b/src/components/Learn/TipOfTheDay.tsx @@ -0,0 +1,62 @@ +import { Link } from "@tanstack/react-router"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import { tracking } from "@/utils/tracking"; + +const STUB_TIP = { + id: "subgraph-navigation", + category: "Editor", + title: "Use subgraphs to keep complex pipelines readable", + body: "Double-click any task to dive into its subgraph. Use the breadcrumbs at the top of the editor to navigate back up — perfect for organising large pipelines without clutter.", +}; + +export function TipOfTheDay() { + return ( +
+ + + + + + {STUB_TIP.category} + + + + + + {STUB_TIP.title} + + + {STUB_TIP.body} + + + + + + + +
+ ); +} diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx index 969056410..55d76153f 100644 --- a/src/routes/Dashboard/DashboardLayout.tsx +++ b/src/routes/Dashboard/DashboardLayout.tsx @@ -31,6 +31,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { to: "/components", label: "Components", icon: "Package" }, { to: "/favorites", label: "Favorites", icon: "Star" }, { to: "/recently-viewed", label: "Recently Viewed", icon: "Clock" }, + { to: "/learn", label: "Learning Hub", icon: "GraduationCap" }, ]; const navItemClass = (isActive: boolean) => diff --git a/src/routes/Dashboard/Learn/LearnExamplesView.tsx b/src/routes/Dashboard/Learn/LearnExamplesView.tsx new file mode 100644 index 000000000..124709163 --- /dev/null +++ b/src/routes/Dashboard/Learn/LearnExamplesView.tsx @@ -0,0 +1,21 @@ +import { LearnComingSoon } from "@/components/Learn/LearnComingSoon"; +import { LearnPageHeader } from "@/components/Learn/LearnPageHeader"; +import { BlockStack } from "@/components/ui/layout"; + +export function LearnExamplesView() { + return ( + + + + + ); +} diff --git a/src/routes/Dashboard/Learn/LearnHomeView.test.tsx b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx new file mode 100644 index 000000000..74df084cd --- /dev/null +++ b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx @@ -0,0 +1,66 @@ +import { screen } from "@testing-library/dom"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { LearnHomeView } from "./LearnHomeView"; + +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + Link: ({ + to, + children, + ...rest + }: { + to: string; + children: React.ReactNode; + [key: string]: unknown; + }) => ( + + {children} + + ), +})); + +describe("", () => { + afterEach(cleanup); + + test("renders the page header", () => { + render(); + expect( + screen.getByRole("heading", { level: 1, name: "Learning Hub" }), + ).toBeInTheDocument(); + }); + + test("renders the search bar", () => { + render(); + expect( + screen.getByRole("textbox", { name: /search the learning hub/i }), + ).toBeInTheDocument(); + }); + + test("renders the onboarding hero with progress", () => { + render(); + expect( + screen.getByRole("heading", { level: 2, name: /welcome to tangle/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("progressbar", { name: /onboarding progress/i }), + ).toBeInTheDocument(); + }); + + test("renders the tip of the day, tours, examples and documentation sections", () => { + render(); + expect( + screen.getByRole("heading", { level: 3, name: /tip of the day/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 3, name: /featured tours/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 2, name: /example pipelines/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 2, name: /documentation/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/routes/Dashboard/Learn/LearnHomeView.tsx b/src/routes/Dashboard/Learn/LearnHomeView.tsx new file mode 100644 index 000000000..2b13c4bcd --- /dev/null +++ b/src/routes/Dashboard/Learn/LearnHomeView.tsx @@ -0,0 +1,34 @@ +import { DocumentationPanel } from "@/components/Learn/DocumentationPanel"; +import { FeaturedExamples } from "@/components/Learn/FeaturedExamples"; +import { FeaturedTours } from "@/components/Learn/FeaturedTours"; +import { LearnPageHeader } from "@/components/Learn/LearnPageHeader"; +import { LearnSearchBar } from "@/components/Learn/LearnSearchBar"; +import { OnboardingHero } from "@/components/Learn/OnboardingHero"; +import { TipOfTheDay } from "@/components/Learn/TipOfTheDay"; +import { BlockStack } from "@/components/ui/layout"; + +export function LearnHomeView() { + return ( + + + + + + + + +
+ + +
+ + + + +
+ ); +} diff --git a/src/routes/Dashboard/Learn/LearnTipsView.tsx b/src/routes/Dashboard/Learn/LearnTipsView.tsx new file mode 100644 index 000000000..b57ce1cd1 --- /dev/null +++ b/src/routes/Dashboard/Learn/LearnTipsView.tsx @@ -0,0 +1,21 @@ +import { LearnComingSoon } from "@/components/Learn/LearnComingSoon"; +import { LearnPageHeader } from "@/components/Learn/LearnPageHeader"; +import { BlockStack } from "@/components/ui/layout"; + +export function LearnTipsView() { + return ( + + + + + ); +} diff --git a/src/routes/Dashboard/Learn/LearnToursView.tsx b/src/routes/Dashboard/Learn/LearnToursView.tsx new file mode 100644 index 000000000..70d6f25bd --- /dev/null +++ b/src/routes/Dashboard/Learn/LearnToursView.tsx @@ -0,0 +1,21 @@ +import { LearnComingSoon } from "@/components/Learn/LearnComingSoon"; +import { LearnPageHeader } from "@/components/Learn/LearnPageHeader"; +import { BlockStack } from "@/components/ui/layout"; + +export function LearnToursView() { + return ( + + + + + ); +} diff --git a/src/routes/router.ts b/src/routes/router.ts index 525caa5b3..7c976bda9 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -23,6 +23,10 @@ import { DashboardLayout } from "./Dashboard/DashboardLayout"; import { DashboardPipelinesView } from "./Dashboard/DashboardPipelinesView"; import { DashboardRecentlyViewedView } from "./Dashboard/DashboardRecentlyViewedView"; import { DashboardRunsView } from "./Dashboard/DashboardRunsView"; +import { LearnExamplesView } from "./Dashboard/Learn/LearnExamplesView"; +import { LearnHomeView } from "./Dashboard/Learn/LearnHomeView"; +import { LearnTipsView } from "./Dashboard/Learn/LearnTipsView"; +import { LearnToursView } from "./Dashboard/Learn/LearnToursView"; import Editor from "./Editor"; import { ImportPage } from "./Import"; import NotFoundPage from "./NotFoundPage"; @@ -47,6 +51,7 @@ declare module "@tanstack/react-router" { export const EDITOR_PATH = "/editor"; export const RUNS_BASE_PATH = "/runs"; export const QUICK_START_PATH = "/quick-start"; +const LEARN_BASE_PATH = "/learn"; const EDITOR_V2_BASE_PATH = "/editor-v2"; const RUNS_V2_BASE_PATH = "/runs-v2"; const SETTINGS_PATH = "/settings"; @@ -59,6 +64,10 @@ export const APP_ROUTES = { DASHBOARD_COMPONENTS: "/components", DASHBOARD_FAVORITES: "/favorites", DASHBOARD_RECENTLY_VIEWED: "/recently-viewed", + LEARN: LEARN_BASE_PATH, + LEARN_EXAMPLES: `${LEARN_BASE_PATH}/examples`, + LEARN_TIPS: `${LEARN_BASE_PATH}/tips`, + LEARN_TOURS: `${LEARN_BASE_PATH}/tours`, QUICK_START: QUICK_START_PATH, IMPORT: IMPORT_PATH, PIPELINE_EDITOR: `${EDITOR_PATH}/$name`, @@ -140,6 +149,30 @@ const dashboardRecentlyViewedRoute = createRoute({ component: DashboardRecentlyViewedView, }); +const learnIndexRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: LEARN_BASE_PATH, + component: LearnHomeView, +}); + +const learnExamplesRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: APP_ROUTES.LEARN_EXAMPLES, + component: LearnExamplesView, +}); + +const learnTipsRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: APP_ROUTES.LEARN_TIPS, + component: LearnTipsView, +}); + +const learnToursRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: APP_ROUTES.LEARN_TOURS, + component: LearnToursView, +}); + const quickStartRoute = createRoute({ getParentRoute: () => mainLayout, path: APP_ROUTES.QUICK_START, @@ -299,6 +332,10 @@ const dashboardRouteTree = dashboardRoute.addChildren([ dashboardComponentsRoute, dashboardFavoritesRoute, dashboardRecentlyViewedRoute, + learnIndexRoute, + learnExamplesRoute, + learnTipsRoute, + learnToursRoute, ]); const appRouteTree = mainLayout.addChildren([ diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 3be064c03..e6e78c78c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,6 +1,7 @@ /* Environment Config */ -export const ABOUT_URL = - import.meta.env.VITE_ABOUT_URL || "https://tangleml.com/"; +export const TANGLE_WEBSITE_URL = "https://tangleml.com/"; + +export const ABOUT_URL = import.meta.env.VITE_ABOUT_URL || TANGLE_WEBSITE_URL; export const GIVE_FEEDBACK_URL = import.meta.env.VITE_GIVE_FEEDBACK_URL || @@ -11,7 +12,7 @@ export const PRIVACY_POLICY_URL = "https://tangleml.com/docs/privacy_policy/"; export const DOCUMENTATION_URL = - import.meta.env.VITE_DOCUMENTATION_URL || "https://tangleml.com/docs/"; + import.meta.env.VITE_DOCUMENTATION_URL || `${TANGLE_WEBSITE_URL}docs/`; export const API_URL = import.meta.env.VITE_BACKEND_API_URL || ""; export const BASE_URL = import.meta.env.VITE_BASE_URL || "/";