From 6f5799537e91f5350f77906300221207907f3813 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Mon, 3 Mar 2025 15:11:43 +0000 Subject: [PATCH 01/10] changes --- .../components/ProjectTree/ProjectTree.tsx | 189 ++++++++-- .../ProjectTree/projectTreeLogic.tsx | 97 ++--- .../lemon-ui/LemonTree/LemonTree.stories.tsx | 78 +++- .../src/lib/lemon-ui/LemonTree/LemonTree.tsx | 354 ++++++++++++------ frontend/src/lib/ui/Colors/Colors.stories.tsx | 2 +- .../ui/ContextMenu/ContextMenu.stories.tsx | 79 ++++ .../src/lib/ui/ContextMenu/ContextMenu.tsx | 195 ++++++++++ frontend/src/styles/base.scss | 13 + frontend/tailwind.config.js | 13 +- 9 files changed, 824 insertions(+), 196 deletions(-) create mode 100644 frontend/src/lib/ui/ContextMenu/ContextMenu.stories.tsx create mode 100644 frontend/src/lib/ui/ContextMenu/ContextMenu.tsx diff --git a/frontend/src/layout/navigation-3000/components/ProjectTree/ProjectTree.tsx b/frontend/src/layout/navigation-3000/components/ProjectTree/ProjectTree.tsx index 6b4ef5e291c5c..76099453156f7 100644 --- a/frontend/src/layout/navigation-3000/components/ProjectTree/ProjectTree.tsx +++ b/frontend/src/layout/navigation-3000/components/ProjectTree/ProjectTree.tsx @@ -1,16 +1,17 @@ +import { IconPlusSmall } from '@posthog/icons' import { LemonBanner, LemonButton } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' import { Resizer } from 'lib/components/Resizer/Resizer' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonTree } from 'lib/lemon-ui/LemonTree/LemonTree' +import { ContextMenuGroup, ContextMenuItem } from 'lib/ui/ContextMenu/ContextMenu' import { useRef } from 'react' import { themeLogic } from '~/layout/navigation-3000/themeLogic' import { FileSystemEntry } from '~/queries/schema/schema-general' import { navigation3000Logic } from '../../navigationLogic' -import { KeyboardShortcut } from '../KeyboardShortcut' import { NavbarBottom } from '../NavbarBottom' import { projectTreeLogic } from './projectTreeLogic' import { joinPath, splitPath } from './utils' @@ -19,30 +20,36 @@ export function ProjectTree({ contentRef }: { contentRef: React.RefObject(null) // Items that should not be draggable or droppable, or have a side action // TODO: sync with projectTreeLogic - const specialItemsIds: string[] = [ - 'project', - 'project/Explore', - 'project/Create new', - '__separator__', - '__apply_pending_actions__', - ] + const notDraggableIds: string[] = ['project', 'project/Explore', 'project/Create new', 'project/Unfiled'] + const notDroppableIds: string[] = ['project', 'project/Explore', 'project/Create new'] return ( <> @@ -52,30 +59,91 @@ export function ProjectTree({ contentRef }: { contentRef: React.RefObject +
+

Files

+
+ {pendingActionsCount > 0 ? ( + + {pendingActionsCount} {pendingActionsCount > 1 ? 'changes' : 'change'} + + ) : null} + { + cancelPendingActions() + toggleDragAndDrop(!dragAndDropEnabled) + }} + type="secondary" + size="small" + tooltip={ + dragAndDropEnabled + ? 'Click to cancel editing and changes' + : 'Click to editinga and drag and drop' + } + > + {dragAndDropEnabled ? `Cancel` : 'Edit'} + + { + applyPendingActions() + toggleDragAndDrop(!dragAndDropEnabled) + } + : undefined + } + > + Save + + { + const folder = prompt('Create a new folder:') + if (folder) { + addFolder(folder) + } + }} + icon={} + /> +
+
+ +
+ { - if (node?.record?.type === 'project' || node?.record?.type === 'folder') { - updateLastViewedPath(node.record?.path) + if (node?.record?.path) { + updateLastViewedId(node?.id || '') } }} onFolderClick={(folder, isExpanded) => { if (folder) { - updateSelectedFolder(folder.record?.path || '') - toggleFolder(folder.record?.path || '', isExpanded) + toggleFolderOpen(folder?.id || '', isExpanded) } }} onSetExpandedItemIds={updateExpandedFolders} - enableDragAndDrop={true} + enableDragAndDrop={dragAndDropEnabled} onDragEnd={(dragEvent) => { const oldPath = dragEvent.active.id as string const folder = dragEvent.over?.id + if (oldPath === folder) { + return false + } + if (folder === '') { const oldSplit = splitPath(oldPath) const oldFile = oldSplit.pop() @@ -100,14 +168,15 @@ export function ProjectTree({ contentRef }: { contentRef: React.RefObject { const path = item.record?.path || '' - // disable dropping for special items - if (specialItemsIds.includes(item.id || '')) { + // disable dropping for these IDS + if (notDroppableIds.includes(item.id || '')) { return false } @@ -121,8 +190,76 @@ export function ProjectTree({ contentRef }: { contentRef: React.RefObject { + if (notDraggableIds.includes(item.id || '')) { + return undefined + } + return ( + + {item.record?.type === 'folder' || item.record?.type === 'project' ? ( + { + e.stopPropagation() + const folder = prompt( + item.record?.path + ? `Create a folder under "${item.record?.path}":` + : 'Create a new folder:', + '' + ) + if (folder) { + addFolder( + item.record?.path ? `${item.record?.path}/${folder}` : folder + ) + } + }} + > + New Folder + + ) : null} + {item.record?.path ? ( + { + const oldPath = item.record?.path + const splits = splitPath(oldPath) + if (splits.length > 0) { + const folder = prompt('New name?', splits[splits.length - 1]) + if (folder) { + moveItem(oldPath, joinPath([...splits.slice(0, -1), folder])) + } + } + }} + > + Rename + + ) : null} + {item.record?.path ? ( + { + e.stopPropagation() + if (item.record?.path) { + void navigator.clipboard.writeText(item.record?.path) + } + }} + > + Copy Path + + ) : null} + {item.record?.created_at ? ( + { + e.stopPropagation() + deleteItem(item.record as unknown as FileSystemEntry) + }} + > + Delete + + ) : null} + {/* Add more menu items as needed */} + + ) + }} itemSideAction={(item) => { - if (specialItemsIds.includes(item.id || '')) { + if (notDraggableIds.includes(item.id || '')) { return undefined } return { @@ -145,7 +282,7 @@ export function ProjectTree({ contentRef }: { contentRef: React.RefObject -
  • - Hold down to enable drag and drop. -
  • +
  • Right click on tree item for more options.
  • diff --git a/frontend/src/layout/navigation-3000/components/ProjectTree/projectTreeLogic.tsx b/frontend/src/layout/navigation-3000/components/ProjectTree/projectTreeLogic.tsx index f2c72ed7449c0..4d459a78936bb 100644 --- a/frontend/src/layout/navigation-3000/components/ProjectTree/projectTreeLogic.tsx +++ b/frontend/src/layout/navigation-3000/components/ProjectTree/projectTreeLogic.tsx @@ -1,5 +1,4 @@ -import { IconBook, IconUpload } from '@posthog/icons' -import { Spinner } from '@posthog/lemon-ui' +import { IconBook } from '@posthog/icons' import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { router } from 'kea-router' @@ -7,7 +6,7 @@ import api from 'lib/api' import { GroupsAccessStatus } from 'lib/introductions/groupsAccessLogic' import { TreeDataItem } from 'lib/lemon-ui/LemonTree/LemonTree' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { capitalizeFirstLetter } from 'lib/utils' +import { capitalizeFirstLetter, shouldIgnoreInput } from 'lib/utils' import { urls } from 'scenes/urls' import { groupsModel } from '~/models/groupsModel' @@ -37,15 +36,15 @@ export const projectTreeLogic = kea([ queueAction: (action: ProjectTreeAction) => ({ action }), removeQueuedAction: (action: ProjectTreeAction) => ({ action }), applyPendingActions: true, + cancelPendingActions: true, createSavedItem: (savedItem: FileSystemEntry) => ({ savedItem }), updateSavedItem: (savedItem: FileSystemEntry) => ({ savedItem }), deleteSavedItem: (savedItem: FileSystemEntry) => ({ savedItem }), - updateExpandedFolders: (folders: string[]) => ({ folders }), - updateActiveFolder: (folder: string | null) => ({ folder }), - updateLastViewedPath: (path: string) => ({ path }), - toggleFolder: (folder: string, isExpanded: boolean) => ({ folder, isExpanded }), - updateSelectedFolder: (folder: string) => ({ folder }), + updateExpandedFolders: (folderIds: string[]) => ({ folderIds }), + updateLastViewedId: (id: string) => ({ id }), + toggleFolderOpen: (folderId: string, isExpanded: boolean) => ({ folderId, isExpanded }), updateHelpNoticeVisibility: (visible: boolean) => ({ visible }), + toggleDragAndDrop: (enabled: boolean) => ({ enabled }), }), loaders(({ actions, values }) => ({ savedItems: [ @@ -90,6 +89,12 @@ export const projectTreeLogic = kea([ } return true }, + cancelPendingActions: async () => { + for (const action of values.pendingActions) { + actions.removeQueuedAction(action) + } + return true + }, }, ], })), @@ -107,6 +112,7 @@ export const projectTreeLogic = kea([ { queueAction: (state, { action }) => [...state, action], removeQueuedAction: (state, { action }) => state.filter((a) => a !== action), + cancelPendingActions: () => [], }, ], savedItems: [ @@ -122,21 +128,14 @@ export const projectTreeLogic = kea([ [] as string[], { persist: true }, { - updateExpandedFolders: (_, { folders }) => folders, + updateExpandedFolders: (_, { folderIds }) => folderIds, }, ], - activeFolder: [ - null as string | null, - { persist: true }, - { - updateActiveFolder: (_, { folder }) => folder, - }, - ], - lastViewedPath: [ + lastViewedId: [ '', { persist: true }, { - updateLastViewedPath: (_, { path }) => path, + updateLastViewedId: (_, { id }) => id, }, ], helpNoticeVisible: [ @@ -146,6 +145,12 @@ export const projectTreeLogic = kea([ updateHelpNoticeVisibility: (_, { visible }) => visible, }, ], + dragAndDropEnabled: [ + false, + { + toggleDragAndDrop: (_, { enabled }) => enabled, + }, + ], }), selectors({ unfiledLoading: [(s) => [s.unfiledLoadingCount], (unfiledLoadingCount) => unfiledLoadingCount > 0], @@ -270,27 +275,13 @@ export const projectTreeLogic = kea([ convertFileSystemEntryToTreeDataItem(getDefaultTree(groupNodes)), ], projectRow: [ - (s) => [s.pendingActionsCount, s.pendingLoaderLoading], - (pendingActionsCount, pendingLoaderLoading): TreeDataItem[] => [ - ...(pendingActionsCount > 0 - ? [ - { - id: '__apply_pending_actions__', - name: `--- Apply${ - pendingLoaderLoading ? 'ing' : '' - } ${pendingActionsCount} unsaved change${pendingActionsCount > 1 ? 's' : ''} ---`, - icon: pendingLoaderLoading ? : , - onClick: !pendingLoaderLoading - ? () => projectTreeLogic.actions.applyPendingActions() - : undefined, - }, - ] - : [ - { - id: '__separator__', - name: '', - }, - ]), + () => [], + (): TreeDataItem[] => [ + { + id: 'top-seperator', + name: '', + type: 'seperator', + }, { id: 'project', name: 'Default Project', @@ -334,24 +325,38 @@ export const projectTreeLogic = kea([ newPath: folder, }) }, - toggleFolder: ({ folder, isExpanded }) => { + toggleFolderOpen: ({ folderId, isExpanded }) => { if (isExpanded) { - actions.updateExpandedFolders(values.expandedFolders.filter((f) => f !== folder)) + actions.updateExpandedFolders(values.expandedFolders.filter((f) => f !== folderId)) } else { - actions.updateExpandedFolders([...values.expandedFolders, folder]) + actions.updateExpandedFolders([...values.expandedFolders, folderId]) } }, - updateSelectedFolder: ({ folder }) => { - actions.updateActiveFolder(folder) - actions.updateLastViewedPath(folder) + cancelPendingActions: () => { + // Clear all pending actions without applying them + for (const action of values.pendingActions) { + actions.removeQueuedAction(action) + } }, })), - afterMount(({ actions }) => { + afterMount(({ actions, cache }) => { actions.loadSavedItems() actions.loadUnfiledItems('feature_flag') actions.loadUnfiledItems('experiment') actions.loadUnfiledItems('insight') actions.loadUnfiledItems('dashboard') actions.loadUnfiledItems('notebook') + + // register keyboard shortcuts + cache.onKeyDown = (event: KeyboardEvent) => { + if (shouldIgnoreInput(event)) { + return + } + if (event.key === 'Escape') { + event.preventDefault() + actions.toggleDragAndDrop(false) + } + } + window.addEventListener('keydown', cache.onKeyDown) }), ]) diff --git a/frontend/src/lib/lemon-ui/LemonTree/LemonTree.stories.tsx b/frontend/src/lib/lemon-ui/LemonTree/LemonTree.stories.tsx index 425865012d5fa..29fc100127550 100644 --- a/frontend/src/lib/lemon-ui/LemonTree/LemonTree.stories.tsx +++ b/frontend/src/lib/lemon-ui/LemonTree/LemonTree.stories.tsx @@ -24,11 +24,15 @@ const meta: Meta = { }, showFolderActiveState: false, expandAllFolders: false, - // defaultSelectedFolderOrNodeId: 'pis_n3o4p', + defaultSelectedFolderOrNodeId: 'pis_n3o4p', data: [ { - id: 'gt_7d8f9', + id: 'xxxxxxxxxxxxxx', name: 'Growth team', + record: { + type: 'folder', + path: 'Growth team', + }, children: [ { id: 'gsm_a1b2c', @@ -37,31 +41,55 @@ const meta: Meta = { // eslint-disable-next-line no-console console.log('clicked growth support metrics', open) }, + record: { + type: 'file', + path: 'Growth team/Growth Support Metrics', + }, }, { id: 'ssc_3d4e5', name: 'Self-serve credits', icon: , disabledReason: "you're not cool enough", + record: { + type: 'file', + path: 'Growth team/Self-serve credits', + }, }, { id: 'ot_f6g7h', name: 'Onboarding things', + record: { + type: 'folder', + path: 'Growth team/Onboarding things', + }, children: [ { id: 'cf_8i9j0', name: 'Conversion funnel', icon: , + record: { + type: 'file', + path: 'Growth team/Onboarding things/Conversion funnel', + }, }, { id: 'mpu_k1l2m', name: 'Multi-product usage', icon: , + record: { + type: 'file', + path: 'Growth team/Onboarding things/Multi-product usage', + }, }, { id: 'pis_n3o4p', name: 'Post-install survey', icon: , + record: { + type: 'file', + path: 'Growth team/Onboarding things/Post-install survey', + }, }, ], }, @@ -69,37 +97,65 @@ const meta: Meta = { id: 'ob2_q5r6s', name: 'Onboarding 2.0', disabledReason: "you're not cool enough", + record: { + type: 'folder', + path: 'Growth team/Onboarding 2.0', + }, children: [ { id: 'hsc_t7u8v', name: 'Hypothesis & success criteria', icon: , + record: { + type: 'file', + path: 'Growth team/Onboarding 2.0/Hypothesis & success criteria', + }, }, { id: 'ob2a_w9x0y', name: 'Onboarding 2.0', icon: , + record: { + type: 'file', + path: 'Growth team/Onboarding 2.0/Onboarding 2.0', + }, }, { id: 'ob2b_z1a2b', name: 'Onboarding 2.0', icon: , + record: { + type: 'file', + path: 'Growth team/Onboarding 2.0/Onboarding 2.0', + }, }, { id: 'ob2c_c3d4e', name: 'Onboarding 2.0', icon: , + record: { + type: 'file', + path: 'Growth team/Onboarding 2.0/Onboarding 2.0', + }, }, ], }, { id: 'bt_f5g6h', name: 'Billing test', + record: { + type: 'folder', + path: 'Growth team/Billing test', + }, children: [ { id: 'os_i7j8k', name: 'other stuff', icon: , + record: { + type: 'file', + path: 'Growth team/Billing test/other stuff', + }, }, ], }, @@ -108,18 +164,34 @@ const meta: Meta = { { id: 'et_l9m0n', name: 'Exec team', + record: { + type: 'file', + path: 'Exec team', + }, }, { id: 'wv_o1p2q', name: 'Website & vibes', + record: { + type: 'file', + path: 'Website & vibes', + }, }, { id: 'pa_r3s4t', name: 'Product analytics', + record: { + type: 'file', + path: 'Product analytics', + }, }, { id: 'uf_u5v6w', - name: 'Unfilled', + name: 'Unfiled', + record: { + type: 'file', + path: 'Unfiled', + }, }, ], }, diff --git a/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx b/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx index 42452508c7de4..2b1813cffb464 100644 --- a/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx +++ b/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx @@ -5,6 +5,7 @@ import { ScrollableShadows } from 'lib/components/ScrollableShadows/ScrollableSh import { cn } from 'lib/utils/css-classes' import { forwardRef, HTMLAttributes, useCallback, useEffect, useRef, useState } from 'react' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '../../ui/ContextMenu/ContextMenu' import { LemonButton, SideAction } from '../LemonButton' import { Spinner } from '../Spinner/Spinner' import { getIcon, TreeNodeDraggable, TreeNodeDroppable } from './LemonTreeUtils' @@ -24,6 +25,8 @@ export type TreeDataItem = { children?: TreeDataItem[] /** Disabled: The reason the item is disabled. */ disabledReason?: string + + type?: 'node' | 'seperator' /** * Handle a click on the item. * @param open - boolean to indicate if it's a folder and it's open state @@ -50,6 +53,8 @@ type LemonTreeBaseProps = Omit, 'onDragEnd'> & { isItemDroppable?: (item: TreeDataItem) => boolean /** The side action to render for the item. */ itemSideAction?: (item: TreeDataItem) => SideAction | undefined + /** The context menu to render for the item. */ + itemContextMenu?: (item: TreeDataItem) => React.ReactNode /** Whether the item is loading */ isItemLoading?: (item: TreeDataItem) => boolean /** Whether the item is unapplied */ @@ -84,6 +89,8 @@ export type LemonTreeNodeProps = LemonTreeBaseProps & { handleClick: (item: TreeDataItem | undefined, isKeyboardAction?: boolean) => void /** The depth of the item. */ depth?: number + /** Whether the context menu is open */ + onContextMenuOpen?: (open: boolean) => void } const LemonTreeNode = forwardRef( @@ -104,45 +111,51 @@ const LemonTreeNode = forwardRef( depth = 0, itemSideAction, enableDragAndDrop = false, + onContextMenuOpen, + itemContextMenu, ...props }, ref ): JSX.Element => { const DEPTH_OFFSET = 4 + 8 * depth // 4 is .25rem to match lemon button padding x axis - // Handle meta key to enable dragging - const [isModifierKeyPressed, setIsModifierKeyPressed] = useState(false) + const [isContextMenuOpenForItem, setIsContextMenuOpenForItem] = useState(undefined) if (!(data instanceof Array)) { data = [data] } - // TODO: move this keydown listener to the parent component - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent): void => { - if (e.metaKey || e.ctrlKey) { - setIsModifierKeyPressed(true) - } - } - - const handleKeyUp = (e: KeyboardEvent): void => { - if (!e.metaKey && !e.ctrlKey) { - setIsModifierKeyPressed(false) - } - } - - window.addEventListener('keydown', handleKeyDown) - window.addEventListener('keyup', handleKeyUp) + function handleContextMenuOpen(open: boolean, itemId: string): void { + // Set local state + setIsContextMenuOpenForItem(open ? itemId : undefined) - return () => { - window.removeEventListener('keydown', handleKeyDown) - window.removeEventListener('keyup', handleKeyUp) - } - }, []) + // Tell parent that the context menu is open + onContextMenuOpen?.(open) + } return (