diff --git a/assets/icons/analytics.png b/assets/icons/analytics.png new file mode 100644 index 00000000..6fbfa6a4 Binary files /dev/null and b/assets/icons/analytics.png differ diff --git a/assets/icons/cloud-search.png b/assets/icons/cloud-search.png new file mode 100644 index 00000000..ca3d4281 Binary files /dev/null and b/assets/icons/cloud-search.png differ diff --git a/assets/icons/data-migration.png b/assets/icons/data-migration.png new file mode 100644 index 00000000..8546d5da Binary files /dev/null and b/assets/icons/data-migration.png differ diff --git a/assets/icons/datalake_card.png b/assets/icons/datalake_card.png new file mode 100644 index 00000000..dffd2f55 Binary files /dev/null and b/assets/icons/datalake_card.png differ diff --git a/assets/icons/index.ts b/assets/icons/index.ts index 267f0ee3..aa5a677d 100644 --- a/assets/icons/index.ts +++ b/assets/icons/index.ts @@ -18,6 +18,10 @@ import apacheIcebergLake from './apache-iceberg-lake.png'; import deltaLake from './delta-lake.png'; import apacheHudiLake from './apache-hudi-lake.png'; import flow from './flow.svg'; +import analytics from './analytics.png'; +import datalakeCard from './datalake_card.png'; +import cloudSearch from './cloud-search.png'; +import dataMigration from './data-migration.png'; export const icons = { settings, @@ -40,4 +44,8 @@ export const icons = { deltaLake, apacheHudiLake, flow, + analytics, + datalakeCard, + cloudSearch, + dataMigration, }; diff --git a/e2e/fixtures/electron.fixture.ts b/e2e/fixtures/electron.fixture.ts index 55dac1d9..63b8884a 100644 --- a/e2e/fixtures/electron.fixture.ts +++ b/e2e/fixtures/electron.fixture.ts @@ -32,10 +32,10 @@ export type ElectronFixtures = { extraProjects: string[]; /** * Mark the Quick Start tour as already seen before the renderer loads. - * The tour auto-opens ~700ms after the project selection screen mounts - * with zero projects and its full-screen overlay swallows every click, so - * tests that don't target it would otherwise race against it. Set to false - * in specs that test the tour itself. + * The tour auto-opens ~700ms after the Home screen (dashboard) mounts with + * zero projects and its full-screen overlay swallows every click, so tests + * that don't target it would otherwise race against it. Set to false in + * specs that test the tour itself. */ skipQuickStartTour: boolean; /** The Electron application instance */ diff --git a/e2e/helpers/window.helper.ts b/e2e/helpers/window.helper.ts index 299af094..b3dd5a1f 100644 --- a/e2e/helpers/window.helper.ts +++ b/e2e/helpers/window.helper.ts @@ -37,7 +37,28 @@ export const findStableWindow = async ( }; /** - * Land on the project selection screen with setup skipped. + * Land on the Home screen (dashboard) with setup skipped. Home is the app's + * default route, so this simply waits for the workspace overview to mount. + */ +export const openHome = async ( + electronApp: ElectronApplication, +): Promise => { + const window = await findStableWindow(electronApp); + await window.waitForLoadState('domcontentloaded'); + + const appHelper = new AppHelper(electronApp, window); + await appHelper.skipSetupIfPresent(); + + await window.waitForSelector('[data-tour="tour-workspace-overview"]', { + timeout: 10000, + }); + return window; +}; + +/** + * Land on the project selection screen with setup skipped. The app opens on + * the Home screen, so when the selection screen is not already mounted we take + * the "Open Project" shortcut from Home. */ export const openProjectSelection = async ( electronApp: ElectronApplication, @@ -48,6 +69,19 @@ export const openProjectSelection = async ( const appHelper = new AppHelper(electronApp, window); await appHelper.skipSetupIfPresent(); + const selection = window.locator('[data-testid="project-selection"]'); + const home = window.locator('[data-tour="tour-workspace-overview"]'); + + await Promise.race([ + selection.waitFor({ state: 'visible', timeout: 10000 }), + home.waitFor({ state: 'visible', timeout: 10000 }), + ]); + + if (await home.isVisible().catch(() => false)) { + await appHelper.dismissQuickStartTourIfPresent(); + await window.locator('[data-tour="tour-open-project-btn"]').click(); + } + await window.waitForSelector('[data-testid="project-selection"]', { timeout: 10000, }); @@ -77,5 +111,9 @@ export const openProject = async ( await expect(window.locator('[data-testid="sidebar"]')).toBeVisible({ timeout: 30000, }); + + // The app opens on the Home screen, so enter the selected project's + // workspace to mount the project-scoped chrome (file tree, dbt actions). + await window.locator('[data-testid="nav-item-files"]').click(); return window; }; diff --git a/e2e/page-objects/components/QuickStartTour.ts b/e2e/page-objects/components/QuickStartTour.ts index 0cab05f9..920b3aa5 100644 --- a/e2e/page-objects/components/QuickStartTour.ts +++ b/e2e/page-objects/components/QuickStartTour.ts @@ -1,9 +1,9 @@ /** * Quick Start Tour Component * - * Page object for the custom onboarding tour overlay shown on the project - * selection screen for users with no projects. It has no testids, so it is - * located through its visible text and the "Skip tour" icon title. + * Page object for the custom onboarding tour overlay shown on the Home screen + * (dashboard) for users with no projects. It has no testids, so it is located + * through its visible text and the "Skip tour" icon title. */ import { Page, Locator, expect } from '@playwright/test'; diff --git a/e2e/tests/onboarding/quick-start-tour.spec.ts b/e2e/tests/onboarding/quick-start-tour.spec.ts index b98a3eb6..58bf1042 100644 --- a/e2e/tests/onboarding/quick-start-tour.spec.ts +++ b/e2e/tests/onboarding/quick-start-tour.spec.ts @@ -1,14 +1,14 @@ /** * Quick Start Tour Tests * - * The tour auto-opens on the project selection screen when there are no + * The tour auto-opens on the Home screen (dashboard) when there are no * projects and it has not been seen before. Its overlay blocks the whole * page until it is skipped or completed. The fixture suppresses it by * default; these specs opt back in. */ import { test, expect } from '../../fixtures/electron.fixture'; -import { openProjectSelection } from '../../helpers/window.helper'; +import { openHome } from '../../helpers/window.helper'; import { QuickStartTourComponent } from '../../page-objects/components/QuickStartTour'; import { ProjectSelectionPage } from '../../page-objects/screens/ProjectSelection'; @@ -18,7 +18,7 @@ test.describe('Quick Start Tour', () => { test('should open automatically for a new user with no projects', async ({ electronApp, }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); await tour.expectVisible(); @@ -32,52 +32,56 @@ test.describe('Quick Start Tour', () => { test('should block the page underneath until dismissed', async ({ electronApp, }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); const projectSelection = new ProjectSelectionPage(window); await tour.expectVisible(); // The overlay intercepts pointer events, so a normal click never lands + const openProjectBtn = window.locator('[data-tour="tour-open-project-btn"]'); await expect( - projectSelection.createProjectBtn.click({ timeout: 1500 }), + openProjectBtn.click({ timeout: 1500 }), ).rejects.toThrow(); await tour.skip(); await tour.expectHidden(); // Now the page is interactive again - await projectSelection.clickCreateProject(); - await expect( - window.locator('[data-testid="project-name-input"]'), - ).toBeVisible(); + await openProjectBtn.click(); + await expect(projectSelection.container).toBeVisible(); }); test('should step forward and back through the tour', async ({ electronApp, }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); await tour.expectStep(1); await tour.letsGo(); await tour.expectStep(2); await expect( - window.getByRole('heading', { name: 'Your Projects' }), + window.getByRole('heading', { name: 'Workspace Overview' }), ).toBeVisible(); + await expect(window.getByText('Your Home Screen')).toBeVisible(); await tour.next(); await tour.expectStep(3); + await expect(window.getByText('Studio Modules').first()).toBeVisible(); + + await tour.next(); + await tour.expectStep(4); await expect( - window.getByRole('heading', { name: 'Create a New Project' }), + window.getByText('Recent Projects', { exact: true }), ).toBeVisible(); await tour.back(); - await tour.expectStep(2); + await tour.expectStep(3); }); test('should mark the tour as seen when skipped', async ({ electronApp }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); await tour.expectVisible(); @@ -90,7 +94,7 @@ test.describe('Quick Start Tour', () => { test('should close with the skip icon at any step', async ({ electronApp, }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); await tour.letsGo(); @@ -102,7 +106,7 @@ test.describe('Quick Start Tour', () => { }); test('should finish with Done on the last step', async ({ electronApp }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); await tour.letsGo(); @@ -128,7 +132,7 @@ test.describe('Quick Start Tour', () => { test('should not open when the user already has projects', async ({ electronApp, }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); // Give the 700ms auto-open timer a chance to fire @@ -144,7 +148,7 @@ test.describe('Quick Start Tour - fixture default', () => { test('should be suppressed by the fixture for ordinary specs', async ({ electronApp, }) => { - const window = await openProjectSelection(electronApp); + const window = await openHome(electronApp); const tour = new QuickStartTourComponent(window); await window.waitForTimeout(1500); diff --git a/e2e/tests/projects/project-lifecycle.spec.ts b/e2e/tests/projects/project-lifecycle.spec.ts index 06111244..c3a34baa 100644 --- a/e2e/tests/projects/project-lifecycle.spec.ts +++ b/e2e/tests/projects/project-lifecycle.spec.ts @@ -1,7 +1,7 @@ import { Page, ElectronApplication } from '@playwright/test'; import { test, expect } from '../../fixtures/electron.fixture'; import { ProjectSelectionPage } from '../../page-objects/screens/ProjectSelection'; -import { AppHelper } from '../../helpers/app.helper'; +import { openProjectSelection } from '../../helpers/window.helper'; // Helper to find a stable window (after splash screen closes) const findStableWindow = async ( @@ -31,17 +31,7 @@ const findStableWindow = async ( test.describe('Project Lifecycle', () => { test.beforeEach(async ({ electronApp }) => { - // Wait for stable window (splash screen to close) - const stableWindow = await findStableWindow(electronApp); - await stableWindow.waitForLoadState('domcontentloaded'); - - const appHelper = new AppHelper(electronApp, stableWindow); - await appHelper.skipSetupIfPresent(); - - // Wait for project selection screen to be visible - await stableWindow.waitForSelector('[data-testid="project-selection"]', { - timeout: 10000, - }); + await openProjectSelection(electronApp); }); test('should create a new project', async ({ electronApp }) => { diff --git a/e2e/tests/projects/project-selection.spec.ts b/e2e/tests/projects/project-selection.spec.ts index fef5ab6a..91735737 100644 --- a/e2e/tests/projects/project-selection.spec.ts +++ b/e2e/tests/projects/project-selection.spec.ts @@ -12,7 +12,7 @@ import * as path from 'path'; import { Page, ElectronApplication } from '@playwright/test'; import { test, expect } from '../../fixtures/electron.fixture'; import { ProjectSelectionPage } from '../../page-objects/screens/ProjectSelection'; -import { AppHelper } from '../../helpers/app.helper'; +import { openProjectSelection } from '../../helpers/window.helper'; // Helper to find a stable window (after splash screen closes) const findStableWindow = async ( @@ -45,15 +45,7 @@ const errorToast = (page: Page, text: string) => test.describe('Project Selection', () => { test.beforeEach(async ({ electronApp }) => { - const stableWindow = await findStableWindow(electronApp); - await stableWindow.waitForLoadState('domcontentloaded'); - - const appHelper = new AppHelper(electronApp, stableWindow); - await appHelper.skipSetupIfPresent(); - - await stableWindow.waitForSelector('[data-testid="project-selection"]', { - timeout: 10000, - }); + await openProjectSelection(electronApp); }); test.describe('with no projects', () => { diff --git a/e2e/tests/shell/app-shell.spec.ts b/e2e/tests/shell/app-shell.spec.ts index c1233ce5..92f4f0c0 100644 --- a/e2e/tests/shell/app-shell.spec.ts +++ b/e2e/tests/shell/app-shell.spec.ts @@ -8,6 +8,7 @@ import { test, expect } from '../../fixtures/electron-seeded.fixture'; import { openProject } from '../../helpers/window.helper'; +import { NavigationSidebarComponent } from '../../page-objects/components/NavigationSidebar'; const PROJECT = 'test_project'; @@ -63,7 +64,11 @@ test.describe('App Shell', () => { test('should open the AI assistant and point to provider settings', async ({ electronApp, }) => { + // The assistant pane is mounted by the workspace screens (SQL/Notebooks/ + // project details), so open it from the SQL editor. const window = await openProject(electronApp, PROJECT); + const nav = new NavigationSidebarComponent(window); + await nav.goToSqlEditor(); await window.getByRole('button', { name: 'AI Assistant (beta)' }).click(); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4111de06..c50048d0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -25,6 +25,7 @@ import { DuckLake as DataLake, Notebooks, Flows, + Dashboard, } from './screens'; import { SelectProjectLayout, AppShell } from './layouts'; import { @@ -45,12 +46,14 @@ const App: React.FC = () => { }> - } /> - } /> - } /> + } /> + } /> + } /> + } /> }> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/renderer/components/QuickStartTour/index.tsx b/src/renderer/components/QuickStartTour/index.tsx index e6aa902a..2db80256 100644 --- a/src/renderer/components/QuickStartTour/index.tsx +++ b/src/renderer/components/QuickStartTour/index.tsx @@ -7,10 +7,11 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import CheckIcon from '@mui/icons-material/Check'; import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; import FolderOpenIcon from '@mui/icons-material/FolderOpen'; -import AddIcon from '@mui/icons-material/Add'; import ElectricalServicesIcon from '@mui/icons-material/ElectricalServices'; -import SearchIcon from '@mui/icons-material/Search'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import SpaceDashboardIcon from '@mui/icons-material/SpaceDashboard'; +import AppsIcon from '@mui/icons-material/Apps'; +import MenuIcon from '@mui/icons-material/Menu'; const TOUR_SEEN_KEY = 'dbt-studio-quickstart-tour-seen'; @@ -51,61 +52,61 @@ const TOUR_STEPS: TourStep[] = [ { title: 'Welcome to DBT Studio! 🎉', description: - "We'll give you a quick tour of the key features so you can hit the ground running. This only takes about 30 seconds.", + "We'll give you a quick tour of the home screen and the key features so you can hit the ground running. This only takes about 30 seconds.", icon: , placement: 'center', }, { - targetId: 'tour-projects-area', - title: 'Your Projects', + targetId: 'tour-workspace-overview', + title: 'Your Home Screen', description: - 'This is your project workspace. All your dbt projects live here. Click any project to open it and start working.', - icon: , + 'This is your workspace overview. From here you can jump into any module, open your projects, or manage your database connections.', + icon: , placement: 'bottom', padding: 12, }, { - targetId: 'tour-new-project-btn', - title: 'Create a New Project', - description: - 'Click here to create a brand new dbt project from scratch. Give it a name, choose a location, and optionally link a database connection.', - icon: , - placement: 'bottom', - padding: 8, - }, - { - targetId: 'tour-get-started-btn', - title: 'Get Started Fast', + targetId: 'tour-modules-area', + title: 'Studio Modules', description: - 'New to dbt Studio? Import a pre-built example project with sample data and dbt models — ready to explore instantly. Perfect for learning the ropes.', - icon: , + 'These cards give you one-click access to every tool — SQL Editor, Notebooks, Analytics, Data Lake, Pipeline, and more. Click any card to jump in.', + icon: , placement: 'bottom', - padding: 8, + padding: 12, }, { - targetId: 'tour-import-btn', - title: 'Import an Existing Project', + targetId: 'tour-recent-projects', + title: 'Recent Projects', description: - 'Already have a dbt project on your machine? Use Import to load it directly from your filesystem or from a compressed archive (.zip, .tar.gz).', + 'Your projects live here. Click any project to open it and pick up right where you left off.', icon: , - placement: 'bottom', - padding: 8, + placement: 'top', + padding: 12, }, { - targetId: 'tour-connections-nav', + targetId: 'tour-configured-connections', title: 'Database Connections', description: - 'Manage your database connections here — connect to BigQuery, Snowflake, DuckDB, Redshift, and more. Connections can be shared across multiple projects.', + 'Your configured connections are listed here — BigQuery, Snowflake, DuckDB, Redshift, and more. Add or manage them anytime.', icon: , + placement: 'top', + padding: 12, + }, + { + targetId: 'tour-sidebar-nav', + title: 'Navigate the App', + description: + 'Use the sidebar to move around DBT Studio at any time — Home, your project, and every module are just a click away.', + icon: , placement: 'right', padding: 8, }, { - targetId: 'tour-search-bar', + targetId: 'tour-open-project-btn', title: "You're All Set! 🚀", description: - 'Start by creating your first project or importing an existing one. Use the search bar to quickly find projects as your list grows. Happy building!', - icon: , + 'Start by creating your first project or importing an existing one — the Open Project button takes you to the project manager. Happy building!', + icon: , placement: 'bottom', padding: 8, }, diff --git a/src/renderer/components/menu/index.tsx b/src/renderer/components/menu/index.tsx index 0c25c54e..cf3514a2 100644 --- a/src/renderer/components/menu/index.tsx +++ b/src/renderer/components/menu/index.tsx @@ -97,67 +97,71 @@ export const Menu: React.FC = ({ actions }) => { return ( - {!isSidebarOpen && ( - - setIsSidebarOpen(true)} - sx={{ - opacity: 0.5, - '&:hover': { opacity: 1 }, - }} - > - - - - )} - - - All Projects - - ), - }, - ...projects.map((p) => ({ - value: String(p.id), - label: ( - - - {p?.name} - - ), - })), - ]} - onSelect={async (value) => { - if (value === 'all') { - navigate('/app/select-project'); - } else { - await selectProject({ projectId: value }); - navigate('/app'); + {!isSidebarOpen && + location.pathname !== '/app' && + !location.pathname.includes('flows') && ( + + setIsSidebarOpen(true)} + sx={{ + opacity: 0.5, + '&:hover': { opacity: 1 }, + }} + > + + + + )} + {!isOnProjectDetails && ( + + + All Projects + + ), + }, + ...projects.map((p) => ({ + value: String(p.id), + label: ( + + + {p?.name} + + ), + })), + ]} + onSelect={async (value) => { + if (value === 'all') { + navigate('/app/select-project'); + } else { + await selectProject({ projectId: value }); + navigate('/app'); + } + }} + selectedItem={project ? String(project.id) : 'all'} + anchorElement={ + + {project ? ( + <> + + {project.name} + + ) : ( + <> + + Select Project + + )} + + } - }} - selectedItem={project ? String(project.id) : 'all'} - anchorElement={ - - {project ? ( - <> - - {project.name} - - ) : ( - <> - - Select Project - - )} - - - } - /> + /> + )} {isProjectSelected && actions} diff --git a/src/renderer/components/sidebar/elements.ts b/src/renderer/components/sidebar/elements.ts index 323fcf67..5416cbc8 100644 --- a/src/renderer/components/sidebar/elements.ts +++ b/src/renderer/components/sidebar/elements.ts @@ -12,6 +12,12 @@ interface SideBarElementType { const baseElements: SideBarElementType[] = [ { path: '/app', + text: 'Home', + icon: Icons.HomeIcon, + testId: 'nav-item-home', + }, + { + path: '/app/dbt-project', text: 'DBT Studio', icon: Icons.DBTProjects, testId: 'nav-item-files', @@ -52,7 +58,7 @@ export const getMainElements = ( isProjectSelected: boolean, ): SideBarElementType[] => { return baseElements.map((element) => { - if (!isProjectSelected && element.path === '/app') { + if (!isProjectSelected && element.path === '/app/dbt-project') { return { ...element, disabled: true }; } return element; diff --git a/src/renderer/components/sidebar/icons.tsx b/src/renderer/components/sidebar/icons.tsx index ae0868d7..4d0387ce 100644 --- a/src/renderer/components/sidebar/icons.tsx +++ b/src/renderer/components/sidebar/icons.tsx @@ -7,6 +7,7 @@ import { ElectricalServices as ElectricalServicesIcon, LibraryBooks, Settings, + Home as HomeMuiIcon, } from '@mui/icons-material'; import { useTheme } from '@mui/material/styles'; import { icons } from '../../../../assets'; @@ -151,3 +152,5 @@ export const FlowsIcon: React.FC = () => { /> ); }; + +export const HomeIcon: React.FC = () => ; diff --git a/src/renderer/components/sidebar/index.tsx b/src/renderer/components/sidebar/index.tsx index 99074b35..a8f81fcd 100644 --- a/src/renderer/components/sidebar/index.tsx +++ b/src/renderer/components/sidebar/index.tsx @@ -50,6 +50,7 @@ export const Sidebar: React.FC = ({ const activeItemPath = React.useMemo(() => { const path = location.pathname; + if (path.includes('dbt-project')) return '/app/dbt-project'; if (path.includes('cloud-explorer')) return '/app/cloud-explorer'; if (path.includes('data-lake') || path.includes('datalake')) return '/app/data-lake'; @@ -71,6 +72,15 @@ export const Sidebar: React.FC = ({ const isPanelOpen = Boolean(content) && isSidebarOpen; + // Auto-collapse sidebar panel when on Home (/app) and auto-expand when navigating to any module + React.useEffect(() => { + if (location.pathname === '/app') { + setIsSidebarOpen(false); + } else { + setIsSidebarOpen(true); + } + }, [location.pathname, setIsSidebarOpen]); + const renderItem = (element: (typeof mainElements)[0], isActive: boolean) => { const isDisabled = element.disabled; const targetPath = @@ -114,14 +124,10 @@ export const Sidebar: React.FC = ({ ); - const tourAttr = - element.path === '/app/connections' ? 'tour-connections-nav' : undefined; - const wrapped = ( = ({ open={isPanelOpen} activityBarWidth={activityBarWidth} data-testid="sidebar" + data-tour="tour-sidebar-nav" > diff --git a/src/renderer/components/sidebar/project-sidebar.tsx b/src/renderer/components/sidebar/project-sidebar.tsx index 9946ec65..8cff1c77 100644 --- a/src/renderer/components/sidebar/project-sidebar.tsx +++ b/src/renderer/components/sidebar/project-sidebar.tsx @@ -266,6 +266,7 @@ interface ExplorerTabProps { onRenameFile?: (oldPath: string, newPath: string) => void; onRunPipeline?: (filePath: string) => void; onRunPipelineLocal?: (filePath: string) => void; + openCreatePipeline?: boolean; } const ExplorerTab: React.FC = ({ @@ -282,10 +283,18 @@ const ExplorerTab: React.FC = ({ onRenameFile, onRunPipeline, onRunPipelineLocal, + openCreatePipeline, }) => { const theme = useTheme(); const [createPipelineOpen, setCreatePipelineOpen] = React.useState(false); + // Open the modal when triggered externally (e.g. from the home screen pipeline card) + React.useEffect(() => { + if (openCreatePipeline) { + setCreatePipelineOpen(true); + } + }, [openCreatePipeline]); + const { refetch: refetchPipelines } = useListPipelines(project?.id); const handlePipelineCreated = React.useCallback( @@ -460,6 +469,7 @@ interface ProjectSidebarProps { // Pipeline onRunPipeline?: (filePath: string) => void; + openCreatePipeline?: boolean; // Search (find in files) onSearchResultSelect: (selection: SearchResultSelection) => void; @@ -491,6 +501,7 @@ export const ProjectSidebar: React.FC = ({ onRunPipeline, onSearchResultSelect, onRunPipelineLocal, + openCreatePipeline, }) => { return ( = ({ onRenameFile={onRenameFile} onRunPipeline={onRunPipeline} onRunPipelineLocal={onRunPipelineLocal} + openCreatePipeline={openCreatePipeline} /> )} diff --git a/src/renderer/screens/dashboard/index.tsx b/src/renderer/screens/dashboard/index.tsx new file mode 100644 index 00000000..e965a7b9 --- /dev/null +++ b/src/renderer/screens/dashboard/index.tsx @@ -0,0 +1,631 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Button, + Card, + CardActionArea, + Chip, + Divider, + Grid, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Paper, + Typography, + useTheme, +} from '@mui/material'; +import FolderSpecialIcon from '@mui/icons-material/FolderSpecial'; +import Terminal from '@mui/icons-material/Terminal'; +import AutoStoriesIcon from '@mui/icons-material/AutoStories'; +import AddIcon from '@mui/icons-material/Add'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; +import StorageOutlinedIcon from '@mui/icons-material/StorageOutlined'; + +import { icons, rosettaIcon } from '../../../../assets'; +import { ReactComponent as RouteIcon } from '../../assets/icons/lucide/route.svg'; +import { useAppContext } from '../../hooks'; +import { QuickStartTour, hasSeenTour, markTourSeen } from '../../components'; +import { + useGetProjects, + useGetConnections, + useSelectProject, +} from '../../controllers'; + +const DbtCardIcon: React.FC = () => ( + dbt +); + +const PipelineCardIcon: React.FC = () => { + const theme = useTheme(); + return ( + + ); +}; + +const AnalyticsCardIcon: React.FC = () => ( + analytics +); + +const DataLakeCardIcon: React.FC = () => ( + datalake +); + +const EtlCardIcon: React.FC = () => ( + etl +); + +const CloudExplorerCardIcon: React.FC = () => ( + cloud explorer +); + +const SqlEditorCardIcon: React.FC = () => ( + +); + +const features = [ + { + id: 'dbt-project', + title: 'DBT Project', + category: 'dbt Core', + description: + 'Manage and explore your dbt models, sources, and documentation.', + icon: DbtCardIcon, + path: '/app/select-project', + }, + { + id: 'notebook', + title: 'Notebook', + category: 'Analytics', + description: + 'Create interactive notebooks for data exploration and analysis.', + icon: AutoStoriesIcon, + path: '/app/notebooks', + }, + { + id: 'sql-editor', + title: 'SQL Editor', + category: 'SQL', + description: 'Write, execute, and profile high-performance SQL queries.', + icon: SqlEditorCardIcon, + path: '/app/sql', + }, + { + id: 'analytics', + title: 'Analytics', + category: 'BI & Viz', + description: 'Visualize your data with powerful dashboards and charts.', + icon: AnalyticsCardIcon, + // navigated programmatically with state: { tab: 2 } + path: '/app/sql', + }, + { + id: 'datalake', + title: 'Data Lake', + category: 'Storage', + description: 'Access and manage raw data across your data lake storage.', + icon: DataLakeCardIcon, + path: '/app/data-lake', + }, + { + id: 'object-explorer', + title: 'Object Explorer', + category: 'Cloud', + description: 'Browse and manage cloud storage objects effortlessly.', + icon: CloudExplorerCardIcon, + path: '/app/cloud-explorer', + }, + { + id: 'pipeline', + title: 'Pipeline', + category: 'Orchestration', + description: 'Design and monitor robust data pipelines and workflows.', + icon: PipelineCardIcon, + // opened via modal — path unused + path: '', + }, + { + id: 'etl', + title: 'ETL (Migrate)', + category: 'Migration', + description: + 'Extract, transform, and load data seamlessly between systems.', + icon: EtlCardIcon, + path: '/app/flows', + }, +]; + +const Dashboard: React.FC = () => { + const navigate = useNavigate(); + const theme = useTheme(); + const { setIsSidebarOpen, selectedProject } = useAppContext(); + const { data: projects = [] } = useGetProjects(); + const { data: connections = [] } = useGetConnections(); + const { mutateAsync: selectProject } = useSelectProject(); + + const handleFeatureClick = (path: string) => { + setIsSidebarOpen(true); + navigate(path); + }; + + const handleCardClick = (feature: (typeof features)[number]) => { + if (feature.id === 'pipeline') { + if (!selectedProject) { + navigate('/app/select-project'); + return; + } + setIsSidebarOpen(true); + navigate('/app/dbt-project', { state: { openCreatePipeline: true } }); + return; + } + if (feature.id === 'analytics') { + setIsSidebarOpen(true); + navigate('/app/sql', { state: { tab: 2 } }); + return; + } + handleFeatureClick(feature.path); + }; + + const handleOpenProject = async (projectId: string) => { + try { + await selectProject({ projectId }); + setIsSidebarOpen(true); + navigate('/app/dbt-project'); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to select project:', err); + } + }; + + React.useEffect(() => { + if (projects.length > 0 && !hasSeenTour()) { + markTourSeen(); + } + }, [projects.length]); + + return ( + + {/* Hero / Welcome Header */} + + + + + + Workspace Overview + + + Access your data engineering modules, analytics notebooks, and + pipelines. + + + + + {/* Quick Header Actions */} + + + + + + + + {/* Main Studio Modules (7 Cards) */} + + + Studio Modules + + + {features.map((feature) => { + const IconComponent = feature.icon; + return ( + + + handleCardClick(feature)} + sx={{ + height: '100%', + p: 2.5, + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'space-between', + }} + > + + + + + + + + + {feature.title} + + + {feature.description} + + + + + + ); + })} + + + + {/* Bottom Panels: Recent Projects & Active Connections */} + + {/* Recent Projects */} + + + + + + Recent Projects ({projects.length}) + + + + + {projects.length === 0 ? ( + + No recent projects found. + + ) : ( + + {projects.slice(0, 4).map((p: any) => ( + handleOpenProject(String(p.id))} + sx={{ + borderRadius: 1.5, + mb: 0.5, + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }} + > + + + + + + ))} + + )} + + + + {/* Database Connections */} + + + + + + Configured Connections ({connections.length}) + + + + + {connections.length === 0 ? ( + + No connections configured yet. + + ) : ( + + {connections.slice(0, 4).map((c: any) => ( + handleFeatureClick('/app/connections')} + sx={{ + borderRadius: 1.5, + mb: 0.5, + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }} + > + + + + + + ))} + + )} + + + + {/* Only show the quick start tour for brand-new users with no projects */} + {projects.length === 0 && } + + ); +}; + +export default Dashboard; diff --git a/src/renderer/screens/dashboard/styles.ts b/src/renderer/screens/dashboard/styles.ts new file mode 100644 index 00000000..b947f866 --- /dev/null +++ b/src/renderer/screens/dashboard/styles.ts @@ -0,0 +1,94 @@ +import { styled } from '@mui/material/styles'; +import { Box, Typography, Card, CardContent } from '@mui/material'; + +export const DashboardContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + background: theme.palette.background.default, + padding: theme.spacing(6), + boxSizing: 'border-box', + overflowY: 'auto', + alignItems: 'center', + justifyContent: 'center', +})); + +export const CardsGrid = styled(Box)(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: theme.spacing(4), + width: '100%', + maxWidth: '1200px', +})); + +export const FeatureCard = styled(Card)(({ theme }) => ({ + position: 'relative', + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.spacing(2), + overflow: 'hidden', + transition: 'all 0.25s ease-in-out', + cursor: 'pointer', + height: '100%', + display: 'flex', + flexDirection: 'column', + + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: + theme.palette.mode === 'dark' + ? '0 12px 24px rgba(255, 107, 0, 0.2)' + : '0 12px 24px rgba(255, 107, 0, 0.15)', + borderColor: '#ff6b00', + + '& .icon-container': { + transform: 'scale(1.08)', + color: '#ff6b00', + background: 'rgba(255, 107, 0, 0.15)', + }, + + '& .arrow-icon': { + transform: 'translateX(4px)', + opacity: 1, + }, + }, +})); + +export const CardContentWrapper = styled(CardContent)(({ theme }) => ({ + padding: theme.spacing(4, 3), + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + height: '100%', + position: 'relative', + zIndex: 1, + '&:last-child': { + paddingBottom: theme.spacing(4), + }, +})); + +export const IconContainer = styled(Box)(({ theme }) => ({ + width: '60px', + height: '60px', + borderRadius: '14px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: theme.spacing(2), + background: 'rgba(255, 107, 0, 0.1)', + color: '#ff6b00', + transition: 'all 0.25s ease', + + '& svg': { + fontSize: '30px', + }, +})); + +export const CardTitle = styled(Typography)(({ theme }) => ({ + fontWeight: 700, + fontSize: '1.15rem', + color: theme.palette.text.primary, +})); diff --git a/src/renderer/screens/index.ts b/src/renderer/screens/index.ts index ad13e465..ca326bc4 100644 --- a/src/renderer/screens/index.ts +++ b/src/renderer/screens/index.ts @@ -10,6 +10,7 @@ import CloudExplorer from './cloudExplorer'; import DuckLake from './dataLake'; import Notebooks from './notebooks'; import Flows from './flows'; +import Dashboard from './dashboard'; export { Settings, @@ -24,4 +25,5 @@ export { DuckLake, Notebooks, Flows, + Dashboard, }; diff --git a/src/renderer/screens/projectDetails/index.tsx b/src/renderer/screens/projectDetails/index.tsx index 75e61ca5..8c4b233a 100644 --- a/src/renderer/screens/projectDetails/index.tsx +++ b/src/renderer/screens/projectDetails/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import SplitPane, { Pane } from 'split-pane-react'; import 'split-pane-react/esm/themes/default.css'; -import { Navigate, useNavigate } from 'react-router-dom'; +import { Navigate, useNavigate, useLocation } from 'react-router-dom'; import { AccountTree, AutoAwesome, @@ -168,6 +168,20 @@ const getPipelineRelativeName = ( const ProjectDetails: React.FC = () => { const navigate = useNavigate(); + const location = useLocation(); + const openCreatePipeline = React.useMemo(() => { + const state = location.state as { openCreatePipeline?: boolean } | null; + return state?.openCreatePipeline === true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Clear the nav state so a back-navigation doesn't re-trigger the modal + React.useEffect(() => { + if (openCreatePipeline) { + navigate(location.pathname, { replace: true, state: {} }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const [verticalSizes, setVerticalSizes] = React.useState<(number | string)[]>( ['auto', 500], ); @@ -1516,6 +1530,7 @@ const ProjectDetails: React.FC = () => { }} onRunPipeline={handleRunPipelineFile} onRunPipelineLocal={handleRunPipelineFileLocally} + openCreatePipeline={openCreatePipeline} /> diff --git a/src/renderer/screens/selectProject/index.tsx b/src/renderer/screens/selectProject/index.tsx index b9717059..44c13af1 100644 --- a/src/renderer/screens/selectProject/index.tsx +++ b/src/renderer/screens/selectProject/index.tsx @@ -48,9 +48,6 @@ import { Icon, GetStartedModal, NewProject, - QuickStartTour, - markTourSeen, - hasSeenTour, } from '../../components'; import { icons } from '../../../../assets'; import connectionIcons from '../../../../assets/connectionIcons'; @@ -322,7 +319,7 @@ const SelectProject: React.FC = () => { setNewProject({ name: '', createTemplateFolders: true }); setSelectedConnection(''); setConnectionType('standard'); - navigate('/app/loading'); + navigate('/app/select-project'); } catch (error) { toast.error( `Failed to create project: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -578,12 +575,6 @@ const SelectProject: React.FC = () => { setDefaultProjectPath(settings?.projectsDirectory ?? ''); }, [settings?.projectsDirectory]); - React.useEffect(() => { - if (projects.length > 0 && !hasSeenTour()) { - markTourSeen(); - } - }, [projects.length]); - return ( @@ -617,7 +608,7 @@ const SelectProject: React.FC = () => { - + { color="primary" onClick={handleGetStarted} sx={{ height: 40 }} - data-tour="tour-get-started-btn" > { variant="contained" color="primary" data-testid="import-project-btn" - data-tour="tour-import-btn" onClick={async () => { try { const project = @@ -752,7 +741,6 @@ const SelectProject: React.FC = () => { onClick={() => setIsAddingProject(true)} sx={{ height: 40 }} data-testid="create-project-btn" - data-tour="tour-new-project-btn" > New @@ -761,7 +749,6 @@ const SelectProject: React.FC = () => { { isOpen={isGetStartedModalOpen} onClose={() => setIsGetStartedModalOpen(false)} /> - {/* Only show the quick start tour for brand-new users with no projects */} - {projects.length === 0 && } {isCloneModalOpen && ( ( const Sql = () => { const theme = useTheme(); const navigate = useNavigate(); + const location = useLocation(); const { selectedProject, projects, isChatOpen, setIsChatOpen } = useContext(AppContext); const tabManager = useSqlTabManager(); @@ -121,6 +122,17 @@ const Sql = () => { refetch: refetchDuckLakeInstances, } = useDuckLakeInstances(); const [sidebarTab, setSidebarTab] = useState(0); + + // Open the Analytics tab when navigated here with state { tab: 2 } + useEffect(() => { + const state = location.state as { tab?: number } | null; + if (state?.tab === 2) { + setSidebarTab(2); + // Clear the state so a back-navigation doesn't re-trigger + navigate(location.pathname, { replace: true, state: {} }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const [activeAnalyticsPageId, setActiveAnalyticsPageId] = useState< string | null >(null);