diff --git a/CHANGELOG.md b/CHANGELOG.md index 76332c01a2..70dce158b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to ### Added +- Add collapsible left sidebar with keyboard shortcut (Cmd/Ctrl+M) and + command-palette style project picker (Cmd/Ctrl+Shift+P) + [#4197](https://github.com/OpenFn/lightning/pull/4197) + ### Changed ### Fixed diff --git a/assets/css/app.css b/assets/css/app.css index 70edd3e056..bcb886d671 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -201,6 +201,15 @@ hover:bg-white; } +/* Modal/Dialog Backdrop + * Single source of truth for all modal overlays. + * Used by: Elixir modals, React modals, command palette, alert dialogs. + * Change this one place to update ALL backdrops in the app. + */ +@utility modal-backdrop { + @apply fixed inset-0 bg-gray-900/60 backdrop-blur-xs transition-opacity; +} + @layer utilities { /* This file is for your main application CSS */ @@ -300,6 +309,414 @@ } } } + + /* Prevent icons from shrinking in flex containers */ + .menu-item svg { + flex-shrink: 0; + } + + /* Hide scrollbar in sidebar to prevent flash during transition */ + & ::-webkit-scrollbar { + display: none; + } + + & { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + } + } + + /* Sidebar header (logo container + toggle) - can be inside or outside #side-menu */ + .app-logo-container, + .sidebar-toggle-btn { + background-color: var(--primary-bg-dark); + color: var(--primary-text); + + &.secondary-variant { + --primary-bg: var(--color-blue-700); + --primary-text: white; + --primary-bg-dark: var(--color-blue-900); + } + + &.sudo-variant { + --primary-bg: var(--color-slate-700); + --primary-text: white; + --primary-bg-dark: var(--color-slate-900); + } + } + + /* Material Design easing for sidebar transitions */ + #sidebar-panel, + #sidebar-panel + div > div:first-child { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + + /* Menu item text - base styles with transition */ + #sidebar-panel .menu-item-text { + transition: max-width 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + white-space: nowrap; + } + + /* Logo text transition */ + #sidebar-panel .sidebar-logo-text { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + + /* Sidebar footer - default state (expanded) */ + #sidebar-panel .sidebar-footer { + .sidebar-logo-text { + max-width: 50px; + margin-right: 4px; + opacity: 1; + } + + /* Branding: show expanded, hide collapsed */ + .sidebar-branding-expanded { + display: block; + } + .sidebar-branding-collapsed { + display: none; + } + } + + /* =========================================== + * SIDEBAR STATE MANAGEMENT VIA DATA-COLLAPSED + * =========================================== + * States: + * - data-collapsed="false" = Expanded (default) + * - data-collapsed="true" = Collapsed + * - data-collapsed="true":hover = Hover-expanded + * - data-collapsed="true".menu-open = Menu-open (expanded) + * =========================================== */ + + /* Expanded state (default) - data-collapsed="false" */ + #sidebar-panel[data-collapsed="false"] { + width: 192px; + + .menu-item { + padding-left: 0.75rem; + padding-right: 0.75rem; + gap: 0.75rem; + } + + .menu-item-text { + max-width: 150px; + opacity: 1; + } + + .user-menu-trigger { + text-align: left; + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + .user-menu-content { + gap: 0.5rem; + min-width: 0; + } + + .user-menu-text, + .user-menu-chevron { + display: flex; + } + + .sidebar-branding-expanded { + display: block; + } + + .sidebar-branding-collapsed { + display: none; + } + + .sidebar-collapse-icon { + display: block; + } + + .sidebar-expand-icon { + display: none; + } + + .sidebar-logo-text { + max-width: 50px; + margin-right: 4px; + opacity: 1; + } + + .project-picker-text, + .project-picker-chevron { + display: block; + } + + #project-picker-wrapper, + #user-menu-wrapper { + display: block; + } + + #project-picker-trigger, + #user-menu-trigger { + width: 100%; + } + } + + /* Keep sidebar expanded when menu is open */ + #sidebar-panel[data-collapsed="true"].menu-open { + width: 192px; + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.3), 4px 0 8px rgba(0, 0, 0, 0.2); + + &::after { + opacity: 1; + visibility: visible; + } + + .menu-item { + justify-content: flex-start; + padding-left: 0.75rem; + padding-right: 0.75rem; + gap: 0.75rem; + } + + .menu-item-text { + max-width: 150px; + opacity: 1; + } + + .user-menu-trigger { + text-align: left; + padding-left: 0.75rem; + padding-right: 0.75rem; + width: 100%; + } + + .user-menu-content { + justify-content: flex-start; + gap: 0.5rem; + min-width: 0; + } + + .user-menu-text { + display: flex; + } + + .user-menu-chevron, + .project-picker-text, + .project-picker-chevron { + display: block; + } + + .sidebar-branding-expanded { + display: block; + } + + .sidebar-branding-collapsed { + display: none; + } + + .sidebar-collapse-icon { + display: block; + } + + .sidebar-expand-icon { + display: none; + } + + .sidebar-logo-text { + max-width: 50px; + margin-right: 4px; + opacity: 1; + } + + #project-picker-wrapper, + #user-menu-wrapper { + display: block; + } + + #project-picker-trigger, + #user-menu-trigger { + width: 100%; + } + } + + /* Collapsed state - data-collapsed="true" */ + #sidebar-panel[data-collapsed="true"] { + width: 64px; + transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Hide text elements when collapsed */ + .menu-item-text { + max-width: 0; + opacity: 0; + } + + /* Center icons when collapsed */ + .menu-item { + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + gap: 0; + } + + .project-picker-text, + .project-picker-chevron, + .user-menu-text, + .user-menu-chevron { + display: none; + } + + /* User menu button centered when collapsed */ + .user-menu-trigger { + display: flex; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .user-menu-content { + justify-content: center; + gap: 0; + } + + /* Branding swap for collapsed state */ + .sidebar-branding-expanded { + display: none; + } + + .sidebar-branding-collapsed { + display: block; + } + + /* Toggle button icons */ + .sidebar-collapse-icon { + display: none; + } + + .sidebar-expand-icon { + display: block; + } + + /* Center icon when collapsed to match menu items */ + .sidebar-toggle-btn > div { + margin: 0; + padding: 0; + justify-content: center; + } + + /* Animate logo text to collapse */ + .sidebar-logo-text { + max-width: 0; + margin-right: 0; + opacity: 0; + } + + /* Wrappers stay block, elements handle centering internally */ + #project-picker-wrapper, + #user-menu-wrapper { + display: block; + } + + /* Overlay when collapsed */ + &::after { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1), visibility 200ms cubic-bezier(0.4, 0, 0.2, 1); + z-index: -1; + } + + /* Hover expansion - show everything, expand width (triggered by JS hook after 1s delay) */ + &.sidebar-hover-expanded { + width: 192px; + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.3), 4px 0 8px rgba(0, 0, 0, 0.2); + + &::after { + opacity: 1; + visibility: visible; + } + + .menu-item { + justify-content: flex-start; + padding-left: 0.75rem; + padding-right: 0.75rem; + gap: 0.75rem; + } + + .menu-item-text { + max-width: 150px; + opacity: 1; + } + + .user-menu-trigger { + justify-content: flex-start; + text-align: left; + padding-left: 0.75rem; + padding-right: 0.75rem; + width: 100%; + } + + .user-menu-content { + justify-content: flex-start; + gap: 0.5rem; + min-width: 0; + } + + .user-menu-text { + display: flex; + } + + .user-menu-chevron, + .project-picker-text, + .project-picker-chevron { + display: block; + } + + /* Show expanded branding, hide collapsed branding */ + .sidebar-branding-expanded { + display: block; + } + + .sidebar-branding-collapsed { + display: none; + } + + /* Keep expand icon visible on hover - clicking expands permanently */ + .sidebar-collapse-icon { + display: none; + } + + .sidebar-expand-icon { + display: block; + } + + /* Left-align toggle icon on hover (override collapsed centering) */ + .sidebar-toggle-btn > div { + margin-left: 0.75rem; + margin-right: 0.75rem; + padding-left: 0.75rem; + justify-content: flex-start; + } + + .sidebar-logo-text { + max-width: 50px; + margin-right: 4px; + opacity: 1; + } + + #project-picker-wrapper, + #user-menu-wrapper { + display: block; + } + + #project-picker-trigger, + #user-menu-trigger { + width: 100%; + } + } } /* Alerts and form errors used by phx.new */ diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index e4bb1ae343..e707a40a5f 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -6,7 +6,11 @@ import { SocketProvider } from '../react/contexts/SocketProvider'; import type { WithActionProps } from '../react/lib/with-props'; import { AIAssistantPanelWrapper } from './components/AIAssistantPanelWrapper'; -import { BreadcrumbLink, BreadcrumbText } from './components/Breadcrumbs'; +import { + BreadcrumbLink, + BreadcrumbProjectPicker, + BreadcrumbText, +} from './components/Breadcrumbs'; import type { MonacoHandle } from './components/CollaborativeMonaco'; import { Header } from './components/Header'; import { LoadingBoundary } from './components/LoadingBoundary'; @@ -88,14 +92,21 @@ function BreadcrumbContent({ const handleVersionSelect = useVersionSelect(); + const handleProjectPickerClick = (e: React.MouseEvent) => { + e.preventDefault(); + // Dispatch the event that the global ProjectPicker listens for + document.body.dispatchEvent(new CustomEvent('open-project-picker')); + }; + const breadcrumbElements = useMemo(() => { return [ - - Projects - , - + // Project name as picker trigger + {projectName} - , + , Workflows , diff --git a/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx b/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx index a99a24d8e6..9a18635fc9 100644 --- a/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx +++ b/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx @@ -195,10 +195,8 @@ export function AdaptorSelectionModal({
diff --git a/assets/js/collaborative-editor/components/AlertDialog.tsx b/assets/js/collaborative-editor/components/AlertDialog.tsx index 743635e9f3..f837c7a68d 100644 --- a/assets/js/collaborative-editor/components/AlertDialog.tsx +++ b/assets/js/collaborative-editor/components/AlertDialog.tsx @@ -52,9 +52,8 @@ export function AlertDialog({
diff --git a/assets/js/collaborative-editor/components/Breadcrumbs.tsx b/assets/js/collaborative-editor/components/Breadcrumbs.tsx index 7e80dad358..a293c4cff8 100644 --- a/assets/js/collaborative-editor/components/Breadcrumbs.tsx +++ b/assets/js/collaborative-editor/components/Breadcrumbs.tsx @@ -1,32 +1,21 @@ -import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; import { useMemo } from 'react'; import { cn } from '../../utils/cn'; export function Breadcrumbs({ children }: { children: React.ReactNode[] }) { - // Split: Last item is always the title (always visible) - // Of the remaining breadcrumbs, show only the last one, hide the rest - const { hiddenItems, visibleBreadcrumb, title } = useMemo(() => { + // Split: Last item is always the title (workflow name) + // All other breadcrumbs are visible: Projects > Project Name > ... + const { visibleBreadcrumbs, title } = useMemo(() => { if (children.length === 0) { - return { hiddenItems: [], visibleBreadcrumb: null, title: null }; + return { visibleBreadcrumbs: [], title: null }; } - // Last child is the title + // Last child is the title (workflow name) const titleItem = children[children.length - 1]; const breadcrumbs = children.slice(0, -1); - if (breadcrumbs.length > 1) { - // Hide all but the last breadcrumb - return { - hiddenItems: breadcrumbs.slice(0, -1), - visibleBreadcrumb: breadcrumbs[breadcrumbs.length - 1], - title: titleItem, - }; - } - return { - hiddenItems: [], - visibleBreadcrumb: breadcrumbs[0] ?? null, + visibleBreadcrumbs: breadcrumbs, title: titleItem, }; }, [children]); @@ -34,33 +23,30 @@ export function Breadcrumbs({ children }: { children: React.ReactNode[] }) { const items = useMemo(() => { const result: React.ReactNode[] = []; - // Add ellipsis dropdown if there are hidden items - if (hiddenItems.length > 0) { - result.push(); - } - - // Add visible breadcrumb (if exists) - if (visibleBreadcrumb) { - // Only show separator if there are hidden items (ellipsis dropdown before this) - if (hiddenItems.length > 0) { + // Add visible breadcrumbs + visibleBreadcrumbs.forEach((breadcrumb, index) => { + // Add separator - skip after first item (project picker pill) + if (index > 1) { result.push( ); } result.push( -
  • - {visibleBreadcrumb} +
  • + {breadcrumb}
  • ); - } + }); - // Add title (with separator only if there's something before it) + // Add title (with separator only if there's more than just project picker) if (title) { - // Show separator if there are hidden items OR a visible breadcrumb - if (hiddenItems.length > 0 || visibleBreadcrumb !== null) { + if (visibleBreadcrumbs.length > 1) { result.push( @@ -85,33 +71,6 @@ export function Breadcrumbs({ children }: { children: React.ReactNode[] }) { ); } -function BreadcrumbDropdown({ items }: { items: React.ReactNode[] }) { - return ( -
  • -
    - - - - - -
    - {items.map((item, index) => ( - -
    - {item} -
    -
    - ))} -
    -
    -
    -
    -
  • - ); -} export function BreadcrumbLink({ href, icon, @@ -167,3 +126,22 @@ export function BreadcrumbText({
    ); } + +export function BreadcrumbProjectPicker({ + children, + onClick, +}: { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; +}) { + return ( + + ); +} diff --git a/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx b/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx index e1b2e5ccb9..a2539d0e82 100644 --- a/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx +++ b/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx @@ -480,10 +480,8 @@ export function ConfigureAdaptorModal({
    diff --git a/assets/js/collaborative-editor/components/GitHubSyncModal.tsx b/assets/js/collaborative-editor/components/GitHubSyncModal.tsx index fb44fadef5..b65a5361b8 100644 --- a/assets/js/collaborative-editor/components/GitHubSyncModal.tsx +++ b/assets/js/collaborative-editor/components/GitHubSyncModal.tsx @@ -85,9 +85,8 @@ export function GitHubSyncModal() { >
    diff --git a/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx b/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx index a28d5b85df..96f3433771 100644 --- a/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx +++ b/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx @@ -829,7 +829,7 @@ export function FullScreenIDE({ {/* IDE Heading Bar */}
    -
    +
    {/* Job Selector - show when workflow loaded, even if no job selected */} {workflow && ( diff --git a/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx b/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx index 28aaef281f..12d36a8574 100644 --- a/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx +++ b/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx @@ -73,8 +73,7 @@ export function WebhookAuthMethodModal({ diff --git a/assets/js/hooks/KeyHandlers.ts b/assets/js/hooks/KeyHandlers.ts index bbfcad7e6f..2e70b61975 100644 --- a/assets/js/hooks/KeyHandlers.ts +++ b/assets/js/hooks/KeyHandlers.ts @@ -367,3 +367,74 @@ export const CloseNodePanelViaEscape = createKeyCombinationHook( closeAction, PRIORITY.NORMAL ); + +/** + * Determines if the key combination for "Ctrl+M" (or "Cmd+M" on macOS) is pressed. + * + * @param e - The keyboard event to evaluate. + * @returns `true` if "Ctrl+M" or "Cmd+M" is pressed, otherwise `false`. + */ +const isCtrlOrMetaM = (e: KeyboardEvent) => + (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'm'; + +/** + * Action to toggle the sidebar collapsed state. + * Clicks the sidebar toggle button to trigger the toggle_sidebar event. + */ +const toggleSidebarAction = (_e: KeyboardEvent, _el: HTMLElement) => { + // Find and click the sidebar toggle button + const toggleButton = document.querySelector( + '[phx-click="toggle_sidebar"]' + ) as HTMLButtonElement; + if (toggleButton) { + toggleButton.click(); + } +}; + +/** + * Hook to toggle the sidebar when "Ctrl+M" (or "Cmd+M" on macOS) is pressed. + * + * This hook listens globally and toggles the sidebar collapsed/expanded state. + * + * Priority: `PRIORITY.HIGH`, ensuring it takes precedence over other handlers. + */ +export const ToggleSidebarViaCtrlM = createKeyCombinationHook( + isCtrlOrMetaM, + toggleSidebarAction, + PRIORITY.HIGH +); + +/** + * Determines if the key combination for "Ctrl+P" (or "Cmd+P" on macOS) is pressed. + * + * @param e - The keyboard event to evaluate. + * @returns `true` if "Ctrl+P" or "Cmd+P" is pressed, otherwise `false`. + */ +const isCtrlOrMetaP = (e: KeyboardEvent) => + (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'p'; + +/** + * Action to open the project picker modal. + * Clicks the project picker trigger button (in breadcrumbs) to open the modal. + */ +const openProjectPickerAction = (_e: KeyboardEvent, _el: HTMLElement) => { + const trigger = document.getElementById( + 'breadcrumb-project-picker-trigger' + ) as HTMLButtonElement; + if (trigger) { + trigger.click(); + } +}; + +/** + * Hook to open the project picker when "Ctrl+P" (or "Cmd+P" on macOS) is pressed. + * + * This hook listens globally and opens the command palette-style project picker. + * + * Priority: `PRIORITY.HIGH`, ensuring it takes precedence over browser print dialog. + */ +export const OpenProjectPickerViaCtrlP = createKeyCombinationHook( + isCtrlOrMetaP, + openProjectPickerAction, + PRIORITY.HIGH +); diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index 5e6e67b08d..2ac75ea69a 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -21,6 +21,8 @@ import { AltRunViaCtrlShiftEnter, CloseInspectorPanelViaEscape, CloseNodePanelViaEscape, + ToggleSidebarViaCtrlM, + OpenProjectPickerViaCtrlP, } from './KeyHandlers'; import LogLineHighlight from './LogLineHighlight'; import type { PhoenixHook } from './PhoenixHook'; @@ -47,6 +49,8 @@ export { AltRunViaCtrlShiftEnter, CloseInspectorPanelViaEscape, CloseNodePanelViaEscape, + ToggleSidebarViaCtrlM, + OpenProjectPickerViaCtrlP, FileDropzone, CredentialSelector, }; @@ -82,7 +86,7 @@ export const Combobox = { this.input = this.el.querySelector('input')!; this.dropdown = this.el.querySelector('ul')!; this.options = Array.from(this.el.querySelectorAll('li')); - this.toggleButton = this.el.querySelector('button')!; + this.toggleButton = this.el.querySelector('button'); this.highlightedIndex = -1; this.navigatingWithKeys = false; this.navigatingWithMouse = false; @@ -93,7 +97,7 @@ export const Combobox = { this.debounce(e => this.handleInput(e), 300) ); this.input.addEventListener('keydown', e => this.handleKeydown(e)); - this.toggleButton.addEventListener('click', () => this.toggleDropdown()); + this.toggleButton?.addEventListener('click', () => this.toggleDropdown()); this.options.forEach((option, index) => { option.addEventListener('click', () => @@ -185,7 +189,7 @@ export const Combobox = { this.options.forEach(option => { const text = (option.textContent ?? '').toLowerCase(); if (text.includes(lowercaseSearchTerm)) { - option.style.display = 'block'; + option.style.display = ''; hasVisibleOptions = true; } else { option.style.display = 'none'; @@ -1019,3 +1023,50 @@ export const LocalTimeConverter = { convertDateTime: () => void; convertToDisplayTime: (isoTimestamp: string, display: string) => void; }>; + +/** + * Delays sidebar expansion on hover by 1 second. + * This allows power users to click icons directly without triggering expansion. + * Collapse is immediate when mouse leaves. + */ +export const SidebarHoverDelay = { + mounted() { + this.hoverTimer = null; + + this.handleMouseEnter = () => { + // Only apply delay when sidebar is collapsed + if (this.el.dataset['collapsed'] !== 'true') return; + + // Start timer to add expanded class after 1s + this.hoverTimer = window.setTimeout(() => { + this.el.classList.add('sidebar-hover-expanded'); + }, 500); + }; + + this.handleMouseLeave = () => { + // Cancel pending expansion + if (this.hoverTimer) { + clearTimeout(this.hoverTimer); + this.hoverTimer = null; + } + + // Immediately remove expanded class + this.el.classList.remove('sidebar-hover-expanded'); + }; + + this.el.addEventListener('mouseenter', this.handleMouseEnter); + this.el.addEventListener('mouseleave', this.handleMouseLeave); + }, + + destroyed() { + if (this.hoverTimer) { + clearTimeout(this.hoverTimer); + } + this.el.removeEventListener('mouseenter', this.handleMouseEnter); + this.el.removeEventListener('mouseleave', this.handleMouseLeave); + }, +} as PhoenixHook<{ + hoverTimer: number | null; + handleMouseEnter: () => void; + handleMouseLeave: () => void; +}>; diff --git a/assets/js/project-picker/ProjectPicker.tsx b/assets/js/project-picker/ProjectPicker.tsx new file mode 100644 index 0000000000..24b9bd9f4f --- /dev/null +++ b/assets/js/project-picker/ProjectPicker.tsx @@ -0,0 +1,314 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { cn } from '../utils/cn'; + +export interface Project { + id: string; + name: string; +} + +interface ProjectPickerProps { + 'data-projects': string; // JSON-encoded array of {id, name} + 'data-current-project-id'?: string; +} + +/** + * Global Project Picker - Command palette style + * + * Mounted via ReactComponent hook in LiveView layouts. + * Opens with Cmd/Ctrl+P keyboard shortcut. + */ +export function ProjectPicker(props: ProjectPickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Detect macOS for keyboard shortcut display + const isMac = useMemo( + () => + typeof navigator !== 'undefined' && + /Mac|iPod|iPhone|iPad/.test(navigator.platform), + [] + ); + + const projects = useMemo(() => { + const projectsJson = props['data-projects']; + if (!projectsJson) return []; + try { + return JSON.parse(projectsJson) as Project[]; + } catch { + return []; + } + }, [props['data-projects']]); + + const currentProjectId = props['data-current-project-id']; + + const filteredProjects = useMemo(() => { + if (!searchTerm) return projects; + const lower = searchTerm.toLowerCase(); + return projects.filter(p => p.name.toLowerCase().includes(lower)); + }, [projects, searchTerm]); + + const openPicker = useCallback(() => { + setIsOpen(true); + setSearchTerm(''); + // Start with first project selected (index 1), not "View all" (index 0) + setHighlightedIndex(projects.length > 0 ? 1 : 0); + setTimeout(() => inputRef.current?.focus(), 50); + }, [projects.length]); + + const closePicker = useCallback(() => { + setIsOpen(false); + }, []); + + // Keyboard shortcut: Cmd/Ctrl+P to open + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'p') { + e.preventDefault(); + if (isOpen) { + closePicker(); + } else { + openPicker(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, openPicker, closePicker]); + + // Global Escape key handler (capture phase to prevent propagation) + useEffect(() => { + if (!isOpen) return; + + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + closePicker(); + } + }; + + document.addEventListener('keydown', handleGlobalKeyDown, true); + return () => + document.removeEventListener('keydown', handleGlobalKeyDown, true); + }, [isOpen, closePicker]); + + // Keep highlighted index in bounds (0 = "View all", 1+ = projects) + useEffect(() => { + const maxIndex = filteredProjects.length; // 0 is "View all", so max is length not length-1 + if (highlightedIndex > maxIndex) { + setHighlightedIndex(Math.max(0, maxIndex)); + } + }, [filteredProjects.length, highlightedIndex]); + + // Scroll highlighted item into view + useEffect(() => { + if (!isOpen) return; + const list = listRef.current; + if (!list) return; + // Query by data-index to avoid separator element throwing off indexing + const highlighted = list.querySelector( + `[data-index="${highlightedIndex}"]` + ) as HTMLElement; + if (highlighted) { + highlighted.scrollIntoView({ block: 'nearest' }); + } + }, [isOpen, highlightedIndex]); + + // Listen for custom event to open picker (from breadcrumb click) + // Phoenix JS.dispatch sends to body + useEffect(() => { + const handleOpen = () => openPicker(); + document.body.addEventListener('open-project-picker', handleOpen); + return () => + document.body.removeEventListener('open-project-picker', handleOpen); + }, [openPicker]); + + // Total items = "View all projects" + filtered projects + const totalItems = filteredProjects.length + 1; + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex(i => (i < totalItems - 1 ? i + 1 : 0)); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex(i => (i > 0 ? i - 1 : totalItems - 1)); + break; + case 'Enter': { + e.preventDefault(); + if (highlightedIndex === 0) { + navigateToProjectsList(); + } else { + const project = filteredProjects[highlightedIndex - 1]; + if (project) { + navigateToProject(project.id); + } + } + break; + } + } + }, + [filteredProjects, highlightedIndex, totalItems] + ); + + const navigateToProjectsList = () => { + window.location.href = '/projects'; + }; + + const navigateToProject = (projectId: string) => { + window.location.href = `/projects/${projectId}/w`; + }; + + if (!isOpen) return null; + + return ( +
    + {/* Backdrop */} +
    + + {/* Modal content - click outside closes */} +
    +
    e.stopPropagation()} + > + {/* Search input */} +
    + + setSearchTerm(e.target.value)} + onKeyDown={handleInputKeyDown} + className="w-full border-0 py-4 pl-3 pr-4 text-gray-900 placeholder:text-gray-400 focus:ring-0 text-base" + role="combobox" + aria-controls="project-picker-options" + aria-expanded="true" + autoComplete="off" + /> + + {isMac ? '⌘' : 'Ctrl'} + P + +
    + + {/* Options list */} +
      + {/* View all projects option */} +
    • setHighlightedIndex(0)} + > + + View all projects + +
    • + + {/* Separator */} + {filteredProjects.length > 0 && ( +
    • + )} + + {/* Project list */} + {filteredProjects.map((project, index) => { + const isSelected = project.id === currentProjectId; + const itemIndex = index + 1; // +1 because 0 is "View all" + const isHighlighted = itemIndex === highlightedIndex; + + return ( +
    • navigateToProject(project.id)} + onMouseEnter={() => setHighlightedIndex(itemIndex)} + > + + + {project.name} + + {isSelected && ( + + )} +
    • + ); + })} + {filteredProjects.length === 0 && ( +
    • + No projects found +
    • + )} +
    +
    +
    +
    + ); +} diff --git a/assets/js/project-picker/index.ts b/assets/js/project-picker/index.ts new file mode 100644 index 0000000000..74226a982e --- /dev/null +++ b/assets/js/project-picker/index.ts @@ -0,0 +1 @@ +export { ProjectPicker } from './ProjectPicker'; diff --git a/config/config.exs b/config/config.exs index 967c7c0dda..1cae6f73eb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -111,6 +111,7 @@ config :esbuild, js/manual-run-panel/ManualRunPanel.tsx js/panel/panels/WorkflowRunPanel.tsx js/collaborative-editor/CollaborativeEditor.tsx + js/project-picker/ProjectPicker.tsx editor.worker=monaco-editor/esm/vs/editor/editor.worker.js json.worker=monaco-editor/esm/vs/language/json/json.worker.js css.worker=monaco-editor/esm/vs/language/css/css.worker.js diff --git a/lib/lightning_web/components/layout_components.ex b/lib/lightning_web/components/layout_components.ex index 603389763b..eaa75979b3 100644 --- a/lib/lightning_web/components/layout_components.ex +++ b/lib/lightning_web/components/layout_components.ex @@ -2,9 +2,37 @@ defmodule LightningWeb.LayoutComponents do @moduledoc false use LightningWeb, :html - import PetalComponents.Avatar - alias LightningWeb.Components.Menu + alias Phoenix.LiveView.JS + + @doc """ + Renders a user avatar with initials. + + ## Examples + + <.user_avatar first_name="John" last_name="Doe" /> + <.user_avatar first_name="John" /> + """ + attr :first_name, :string, required: true + attr :last_name, :string, default: nil + attr :class, :string, default: nil + + def user_avatar(assigns) do + initials = + String.at(assigns.first_name, 0) <> + if assigns.last_name, do: String.at(assigns.last_name, 0), else: "" + + assigns = assign(assigns, :initials, String.upcase(initials)) + + ~H""" +
    + {@initials} +
    + """ + end attr :current_user, Lightning.Accounts.User, required: true @@ -21,15 +49,17 @@ defmodule LightningWeb.LayoutComponents do ~H"""
    assign(:custom_menu_items, Application.get_env(:lightning, :menu_items)) ~H""" <%= if @custom_menu_items do %> @@ -138,12 +164,6 @@ defmodule LightningWeb.LayoutComponents do {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} )} <% else %> - ~p"/projects/#{project.id}/w" end} - /> <%= if assigns[:project] do %> 1 do - # Show only the last breadcrumb, hide all earlier ones - {Enum.take(all_crumbs, length(all_crumbs) - 1), - Enum.take(all_crumbs, -1)} - else - {[], all_crumbs} - end + breadcrumbs = collect_breadcrumbs(assigns) # description has the same title class except for height and font assigns = assign(assigns, title_class: "max-w-7xl mx-auto sm:px-6 lg:px-8", title_height: "py-6 flex items-center " <> title_height, - hidden_crumbs: hidden_crumbs, - visible_crumbs: visible_crumbs + breadcrumbs: breadcrumbs ) ~H""" @@ -267,33 +260,27 @@ defmodule LightningWeb.LayoutComponents do >
    <%= if assigns[:current_user] do %> - + <% else %> +

    + {if assigns[:title], do: render_slot(@title)} +

    + <% end %> <% else %>

    {if assigns[:title], do: render_slot(@title)} @@ -337,53 +324,39 @@ defmodule LightningWeb.LayoutComponents do """ end - attr :items, :list, required: true + attr :label, :string, required: true + attr :index, :integer, required: true - def breadcrumb_dropdown(assigns) do - dropdown_id = "breadcrumb-dropdown-#{:erlang.phash2(assigns.items)}" - assigns = assign(assigns, :dropdown_id, dropdown_id) + defp breadcrumb_item(%{index: 0} = assigns) do + ~H""" + <.breadcrumb_project_picker label={@label} /> + """ + end + defp breadcrumb_item(assigns) do ~H""" -
  • + <.breadcrumb show_separator={@index > 1}> + <:label>{@label} + + """ + end + + attr :label, :string, required: true + attr :show_separator, :boolean, default: true + + def breadcrumb_project_picker(assigns) do + ~H""" +
  • - + <.icon name="hero-folder" class="h-4 w-4 text-gray-500" /> + {@label} +
  • """ @@ -397,20 +370,11 @@ defmodule LightningWeb.LayoutComponents do ~H"""
  • - <%= if @show_separator do %> - - <% end %> + <.icon + :if={@show_separator} + name="hero-chevron-right" + class="h-5 w-5 shrink-0 text-gray-400" + /> <%= if @path do %> <.link patch={@path} @@ -497,4 +461,63 @@ defmodule LightningWeb.LayoutComponents do """ end + + def sidebar_footer(assigns) do + ~H""" + + """ + end end diff --git a/lib/lightning_web/components/layouts/live.html.heex b/lib/lightning_web/components/layouts/live.html.heex index ddb5ef5e84..b8953b59a3 100644 --- a/lib/lightning_web/components/layouts/live.html.heex +++ b/lib/lightning_web/components/layouts/live.html.heex @@ -1,55 +1,82 @@
    + +
    - +
    @@ -80,4 +107,25 @@ />
    + <%!-- Global project picker (React component) --%> + <%= if assigns[:current_user] && !assigns[:is_first_setup] do %> +
    + %{id: p.id, name: p.name} + end) + ) + } + data-current-project-id={ + if assigns[:project], do: assigns[:project].id, else: nil + } + > +
    + <% end %> diff --git a/lib/lightning_web/components/layouts/settings.html.heex b/lib/lightning_web/components/layouts/settings.html.heex index 215958fba8..93c629d184 100644 --- a/lib/lightning_web/components/layouts/settings.html.heex +++ b/lib/lightning_web/components/layouts/settings.html.heex @@ -1,69 +1,112 @@
    + +
    - + +
    <.live_info_block flash={@flash} /> <.live_error_block flash={@flash} /> {@inner_content}
  • + <%!-- Global project picker (React component) --%> + <%= if assigns[:current_user] do %> +
    + %{id: p.id, name: p.name} + end) + ) + } + data-current-project-id={ + if assigns[:project], do: assigns[:project].id, else: nil + } + /> + <% end %> diff --git a/lib/lightning_web/init_assigns.ex b/lib/lightning_web/init_assigns.ex index da2ebe9dcb..bd0b3c78a9 100644 --- a/lib/lightning_web/init_assigns.ex +++ b/lib/lightning_web/init_assigns.ex @@ -3,17 +3,31 @@ defmodule LightningWeb.InitAssigns do Ensures common `assigns` are applied to all LiveViews attaching this hook. """ import Phoenix.Component + import Phoenix.LiveView alias Lightning.Accounts def on_mount(:default, _params, session, socket) do - current_user = Accounts.get_user_by_session_token(session["user_token"]) + current_user = + case session["user_token"] do + nil -> nil + token -> Accounts.get_user_by_session_token(token) + end + confirmation_required? = Accounts.confirmation_required?(current_user) + sidebar_collapsed = + if current_user do + Accounts.get_preference(current_user, "sidebar_collapsed") || false + else + false + end + {:cont, socket |> assign_new(:current_user, fn -> current_user end) + |> assign(:sidebar_collapsed, sidebar_collapsed) |> assign_new(:account_confirmation_required?, fn -> confirmation_required? end) @@ -26,6 +40,24 @@ defmodule LightningWeb.InitAssigns do } end end) - |> assign_new(:gdpr_banner, fn -> Lightning.Config.gdpr_banner() end)} + |> assign_new(:gdpr_banner, fn -> Lightning.Config.gdpr_banner() end) + |> attach_hook(:sidebar_toggle, :handle_event, &handle_sidebar_toggle/3)} + end + + defp handle_sidebar_toggle("toggle_sidebar", _params, socket) do + new_state = !socket.assigns.sidebar_collapsed + user = socket.assigns.current_user + + {:ok, updated_user} = + Accounts.update_user_preference(user, "sidebar_collapsed", new_state) + + {:halt, + socket + |> assign(:current_user, updated_user) + |> assign(:sidebar_collapsed, new_state)} + end + + defp handle_sidebar_toggle(_event, _params, socket) do + {:cont, socket} end end diff --git a/lib/lightning_web/live/ai_assistant/component.ex b/lib/lightning_web/live/ai_assistant/component.ex index 3bf06ebc9c..0c10781b86 100644 --- a/lib/lightning_web/live/ai_assistant/component.ex +++ b/lib/lightning_web/live/ai_assistant/component.ex @@ -705,7 +705,7 @@ defmodule LightningWeb.AiAssistant.Component do disabled={@disabled || form_content_empty?(@form[:content].value)} form={@form_id} class={[ - "p-1.5 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200 flex items-center justify-center h-7 w-7", + "p-1.5 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200 flex items-center justify-center w-10 h-10", if(@disabled || form_content_empty?(@form[:content].value), do: "text-gray-400 bg-gray-300 cursor-not-allowed focus:ring-gray-300", diff --git a/lib/lightning_web/live/components/common.ex b/lib/lightning_web/live/components/common.ex index 9f4c5ccf09..6a2aee0e92 100644 --- a/lib/lightning_web/live/components/common.ex +++ b/lib/lightning_web/live/components/common.ex @@ -12,7 +12,7 @@ defmodule LightningWeb.Components.Common do def openfn_logo(assigns) do ~H""" + + <%!-- Box frame --%> + + <%!-- Fn text inside the box --%> + + + + """ + end + defp select_icon(type) do case type do "success" -> "hero-check-circle-solid" @@ -267,7 +301,7 @@ defmodule LightningWeb.Components.Common do assigns = assign(assigns, display: display, message: message) ~H""" -
    + - """ - end - attr :sort_direction, :string, values: ["asc", "desc"], default: "asc" @@ -697,11 +670,4 @@ defmodule LightningWeb.Components.Common do
    """ end - - defp root_name(nil), do: "" - - defp root_name(%Lightning.Projects.Project{} = selected_item), - do: Lightning.Projects.root_of(selected_item).name - - defp root_name(%{name: name}), do: name end diff --git a/lib/lightning_web/live/components/menu.ex b/lib/lightning_web/live/components/menu.ex index 5f8f5235ca..27bfb77fc4 100644 --- a/lib/lightning_web/live/components/menu.ex +++ b/lib/lightning_web/live/components/menu.ex @@ -4,14 +4,20 @@ defmodule LightningWeb.Components.Menu do """ use LightningWeb, :component + import LightningWeb.Components.Icons + + attr :project_id, :string, required: true + attr :current_user, :map, required: true + attr :active_menu_item, :atom, required: true + def project_items(assigns) do ~H""" <.menu_item to={~p"/projects/#{@project_id}/w"} active={@active_menu_item == :overview} > - - Workflows + + Workflows <%= if Lightning.Accounts.experimental_features_enabled?(@current_user) do %> @@ -19,8 +25,8 @@ defmodule LightningWeb.Components.Menu do to={~p"/projects/#{@project_id}/sandboxes"} active={@active_menu_item == :sandboxes} > - - Sandboxes + + Sandboxes <% end %> @@ -28,68 +34,70 @@ defmodule LightningWeb.Components.Menu do to={~p"/projects/#{@project_id}/history"} active={@active_menu_item == :runs} > - - History + + History <.menu_item to={"/projects/#{@project_id}/settings"} active={@active_menu_item == :settings} > - - Settings + + Settings - - """ end + attr :active_menu_item, :atom, required: true + def profile_items(assigns) do ~H""" <.menu_item to={~p"/projects"} active={@active_menu_item == :projects}> - Projects + <.icon name="hero-folder" class="h-5 w-5 shrink-0" /> + Projects <.menu_item to={~p"/profile"} active={@active_menu_item == :profile}> - User Profile + <.icon name="hero-user-circle" class="h-5 w-5 shrink-0" /> + User Profile <.menu_item to={~p"/credentials"} active={@active_menu_item == :credentials}> - Credentials + <.icon name="hero-key" class="h-5 w-5 shrink-0" /> + Credentials <.menu_item to={~p"/profile/tokens"} active={@active_menu_item == :tokens}> - API Tokens + <.icon name="hero-command-line" class="h-5 w-5 shrink-0" /> + API Tokens """ end + attr :to, :string, default: nil + attr :href, :string, default: nil + attr :active, :boolean, default: false + attr :target, :string, default: "_blank" + attr :text, :string, default: nil + slot :inner_block + def menu_item(assigns) do base_classes = - ~w[menu-item px-3 py-2 rounded-md text-sm font-medium rounded-md block] - - active_classes = ~w[menu-item-active] ++ base_classes - - inactive_classes = ~w[menu-item-inactive] ++ base_classes + ~w[menu-item h-10 rounded-lg text-sm font-medium flex items-center + transition-colors duration-150 + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30] assigns = assigns |> assign( class: if assigns[:active] do - active_classes + ~w[menu-item-active] ++ base_classes else - inactive_classes + ~w[menu-item-inactive] ++ base_classes end ) |> assign_new(:target, fn -> "_blank" end) ~H""" -
    +
    <%= if assigns[:href] do %> <.link href={@href} target={@target} class={@class}> <%= if assigns[:inner_block] do %> @@ -99,7 +107,7 @@ defmodule LightningWeb.Components.Menu do <% end %> <% else %> - <.link navigate={@to} class={@class}> + <.link navigate={@to} class={@class} aria-current={@active && "page"}> <%= if assigns[:inner_block] do %> {render_slot(@inner_block)} <% else %> diff --git a/lib/lightning_web/live/components/modal.ex b/lib/lightning_web/live/components/modal.ex index 1012303ef1..1555b9cacf 100644 --- a/lib/lightning_web/live/components/modal.ex +++ b/lib/lightning_web/live/components/modal.ex @@ -40,7 +40,7 @@ defmodule LightningWeb.Components.Modal do >