diff --git a/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--dark.png b/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--dark.png index a32ac746d3..c355f98e9d 100644 Binary files a/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--dark.png and b/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--light.png b/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--light.png index 8b6a5e4313..0cddee14e6 100644 Binary files a/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--light.png and b/frontend/__snapshots__/scenes-app-insights-trendsline--trends-line--light.png differ diff --git a/frontend/build-products.mjs b/frontend/build-products.mjs index e8d7c8ce11..62be9284b9 100644 --- a/frontend/build-products.mjs +++ b/frontend/build-products.mjs @@ -39,6 +39,8 @@ export function buildProductManifests() { const treeItemsProducts = {} const visitManifests = (sourceFile) => { + const manifestSceneKeys = [] // collect the scene keys used in this manifest file + const manifestTreeItems = [] ts.forEachChild(sourceFile, function walk(node) { if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.initializer)) { const { text: name } = node.name @@ -52,6 +54,10 @@ export function buildProductManifests() { node.initializer.properties.forEach((p) => list.push(cloneNode(p))) } else if (name === 'scenes') { node.initializer.properties.forEach((prop) => { + const sceneName = prop.name?.text ?? prop.name?.escapedText + if (sceneName && !manifestSceneKeys.includes(sceneName)) { + manifestSceneKeys.push(sceneName) + } const imp = keepOnlyImport(prop, sourceFile.fileName) if (imp) { scenes.push(imp) @@ -69,6 +75,8 @@ export function buildProductManifests() { ts.isArrayLiteralExpression(node.initializer) && ['treeItemsNew', 'treeItemsProducts', 'treeItemsMetadata', 'treeItemsGames'].includes(node.name.text) ) { + // only annotate apps and data for now + const shouldAnnotate = node.name.text === 'treeItemsProducts' || node.name.text === 'treeItemsMetadata' const dict = node.name.text === 'treeItemsNew' ? treeItemsNew @@ -84,13 +92,35 @@ export function buildProductManifests() { const pathProp = el.properties.find((p) => p.name?.text === 'path') const thePath = pathProp?.initializer?.text if (thePath) { - dict[thePath] = cloneNode(el) + const cloned = cloneNode(el) + if (shouldAnnotate) { + manifestTreeItems.push(cloned) + } + dict[thePath] = cloned } }) } else { ts.forEachChild(node, walk) } }) + + // go through all tree items + manifestTreeItems.forEach((item) => { + // skip if the tree item already contains "sceneKeys" + if (item.properties.some((p) => p.name?.text === 'sceneKeys')) { + return + } + // add collected "sceneKeys" to the tree item + item.properties = ts.factory.createNodeArray([ + ...item.properties, + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('sceneKeys'), + ts.factory.createArrayLiteralExpression( + manifestSceneKeys.map((key) => ts.factory.createStringLiteral(key)) + ) + ), + ]) + }) } for (const sf of program.getSourceFiles()) { diff --git a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx index 439d9b5027..deca0c78d3 100644 --- a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx +++ b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx @@ -338,6 +338,7 @@ export const getDefaultTreeNew = (): FileSystemImport[] => href: urls.dataPipelinesNew('source'), icon: , iconColor: ['var(--color-product-data-pipeline-light)'] as FileSystemIconColor, + sceneKeys: ['HogFunction'], }, { path: `Data/Destination`, @@ -345,6 +346,7 @@ export const getDefaultTreeNew = (): FileSystemImport[] => href: urls.dataPipelinesNew('destination'), icon: , iconColor: ['var(--color-product-data-pipeline-light)'] as FileSystemIconColor, + sceneKeys: ['HogFunction'], }, { path: `Data/Transformation`, @@ -352,6 +354,7 @@ export const getDefaultTreeNew = (): FileSystemImport[] => href: urls.dataPipelinesNew('transformation'), icon: , iconColor: ['var(--color-product-data-pipeline-light)'] as FileSystemIconColor, + sceneKeys: ['HogFunction'], }, { path: `Data/Site app`, @@ -359,6 +362,7 @@ export const getDefaultTreeNew = (): FileSystemImport[] => href: urls.dataPipelinesNew('site_app'), icon: , iconColor: ['var(--color-product-data-pipeline-light)'] as FileSystemIconColor, + sceneKeys: ['HogFunction'], }, ].sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: 'accent' })) @@ -369,14 +373,16 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ category: 'Schema', iconType: 'event_definition', href: urls.eventDefinitions(), - sceneKey: 'EventDefinition', + sceneKey: 'EventDefinitions', + sceneKeys: ['EventDefinition', 'EventDefinitions'], }, { path: 'Property definitions', category: 'Schema', iconType: 'property_definition', href: urls.propertyDefinitions(), - sceneKey: 'PropertyDefinition', + sceneKey: 'PropertyDefinitions', + sceneKeys: ['PropertyDefinition', 'PropertyDefinitions'], }, { path: 'Property groups', @@ -391,6 +397,7 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ iconType: 'annotation', href: urls.annotations(), sceneKey: 'Annotations', + sceneKeys: ['Annotations'], }, { path: 'Comments', @@ -398,6 +405,7 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ iconType: 'comment', href: urls.comments(), sceneKey: 'Comments', + sceneKeys: ['Comments'], }, { path: 'Ingestion warnings', @@ -406,6 +414,7 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ href: urls.ingestionWarnings(), flag: FEATURE_FLAGS.INGESTION_WARNINGS_ENABLED, sceneKey: 'IngestionWarnings', + sceneKeys: ['IngestionWarnings'], }, { path: `Sources`, @@ -414,6 +423,7 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ iconType: 'data_pipeline_metadata', href: urls.dataPipelines('sources'), sceneKey: 'DataPipelines', + sceneKeys: ['DataPipelines'], } as FileSystemImport, { path: `Transformations`, @@ -422,6 +432,7 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ iconType: 'data_pipeline_metadata', href: urls.dataPipelines('transformations'), sceneKey: 'DataPipelines', + sceneKeys: ['DataPipelines'], } as FileSystemImport, { path: `Destinations`, @@ -430,6 +441,7 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ iconType: 'data_pipeline_metadata', href: urls.dataPipelines('destinations'), sceneKey: 'DataPipelines', + sceneKeys: ['DataPipelines'], } as FileSystemImport, { path: 'Managed viewsets', @@ -451,6 +463,7 @@ export const getDefaultTreeProducts = (): FileSystemImport[] => iconColor: ['var(--color-product-dashboards-light)'] as FileSystemIconColor, href: urls.dashboards(), sceneKey: 'Dashboards', + sceneKeys: ['Dashboard', 'Dashboards'], }, { path: 'Notebooks', @@ -459,6 +472,7 @@ export const getDefaultTreeProducts = (): FileSystemImport[] => iconType: 'notebook' as FileSystemIconType, href: urls.notebooks(), sceneKey: 'Notebooks', + sceneKeys: ['Notebook', 'Notebooks'], }, { path: `Data pipelines`, @@ -468,6 +482,7 @@ export const getDefaultTreeProducts = (): FileSystemImport[] => iconColor: ['var(--color-product-data-pipeline-light)'] as FileSystemIconColor, href: urls.dataPipelines(), sceneKey: 'DataPipelines', + sceneKeys: ['DataPipelines'], } as FileSystemImport, { path: `SQL editor`, @@ -477,6 +492,7 @@ export const getDefaultTreeProducts = (): FileSystemImport[] => iconColor: ['var(--color-product-data-warehouse-light)'] as FileSystemIconColor, href: urls.sqlEditor(), sceneKey: 'SQLEditor', + sceneKeys: ['SQLEditor'], } as FileSystemImport, { path: 'Heatmaps', @@ -489,6 +505,7 @@ export const getDefaultTreeProducts = (): FileSystemImport[] => href: urls.heatmaps(), tags: ['beta'], sceneKey: 'Heatmaps', + sceneKeys: ['Heatmaps'], } as FileSystemImport, ].sort((a, b) => { if (a.visualOrder === -1) { @@ -510,6 +527,8 @@ export const getDefaultTreePersons = (): FileSystemImport[] => [ iconType: 'persons', href: urls.persons(), visualOrder: 10, + sceneKey: 'Persons', + sceneKeys: ['Person', 'Persons'], }, { path: 'Cohorts', @@ -517,5 +536,7 @@ export const getDefaultTreePersons = (): FileSystemImport[] => [ type: 'cohort', href: urls.cohorts(), visualOrder: 20, + sceneKey: 'Cohorts', + sceneKeys: ['Cohort', 'Cohorts'], }, ] diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index eb881958e0..3ee2eecd63 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -30,6 +30,7 @@ import { ExternalDataSourceType, FileSystemCount, FileSystemEntry, + FileSystemViewLogEntry, HogCompileResponse, HogQLQuery, HogQLQueryResponse, @@ -1819,6 +1820,13 @@ const api = { }, fileSystemLogView: { + async list(params?: { type?: string; limit?: number }): Promise { + const request = new ApiRequest().fileSystemLogView() + if (params) { + request.withQueryString(params) + } + return await request.get() + }, async create(data: { ref?: string; type?: string }): Promise { return await new ApiRequest().fileSystemLogView().create({ data }) }, diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 59e3e3385b..92038e14da 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -13615,6 +13615,13 @@ "description": "Match this with the a base scene key or a specific one", "type": "string" }, + "sceneKeys": { + "description": "List of all scenes exported by the app", + "items": { + "type": "string" + }, + "type": "array" + }, "shortcut": { "description": "Whether this is a shortcut or the actual item", "type": "boolean" @@ -13639,6 +13646,22 @@ "required": ["path"], "type": "object" }, + "FileSystemViewLogEntry": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "type": { + "type": "string" + }, + "viewed_at": { + "type": "string" + } + }, + "required": ["type", "ref", "viewed_at"], + "type": "object" + }, "FilterLogicalOperator": { "enum": ["AND", "OR"], "type": "string" diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts index 73f1e51447..dd80b29f47 100644 --- a/frontend/src/queries/schema/schema-general.ts +++ b/frontend/src/queries/schema/schema-general.ts @@ -2595,6 +2595,14 @@ export interface FileSystemImport extends Omit { iconColor?: FileSystemIconColor /** Match this with the a base scene key or a specific one */ sceneKey?: string + /** List of all scenes exported by the app */ + sceneKeys?: string[] +} + +export interface FileSystemViewLogEntry { + type: string + ref: string + viewed_at: string } export interface PersistedFolder { diff --git a/frontend/src/scenes/new-tab/components/Results.tsx b/frontend/src/scenes/new-tab/components/Results.tsx index 5393f64e63..bb826d61a3 100644 --- a/frontend/src/scenes/new-tab/components/Results.tsx +++ b/frontend/src/scenes/new-tab/components/Results.tsx @@ -1,4 +1,5 @@ import { useActions, useValues } from 'kea' +import { router } from 'kea-router' import { ReactNode, useEffect, useRef } from 'react' import { IconArrowRight, IconEllipsis, IconExternal, IconInfo, IconSparkles } from '@posthog/icons' @@ -130,7 +131,7 @@ function Category({ getSectionItemLimit, newTabSceneDataInclude, } = useValues(newTabSceneLogic({ tabId })) - const { showMoreInSection } = useActions(newTabSceneLogic({ tabId })) + const { showMoreInSection, logCreateNewItem } = useActions(newTabSceneLogic({ tabId })) const { groupTypes } = useValues(groupsModel) return ( @@ -181,6 +182,7 @@ function Category({ ) : ( {typedItems.map((item, index) => { + const isCreateNew = item.category === 'create-new' const focusFirst = (newTabSceneData && isFirstCategoryWithResults && index === 0) || (filteredItemsGrid.length > 0 && isFirstCategory && index === 0) @@ -229,6 +231,15 @@ function Category({ size: 'sm', hasSideActionRight: true, }} + onClick={(e) => { + e.preventDefault() + if (item.href) { + if (isCreateNew) { + logCreateNewItem(item.href) + } + router.actions.push(item.href) + } + }} > {item.icon ?? item.name[0]} @@ -437,7 +448,7 @@ export function Results({ allCategories, firstCategoryWithResults, } = useValues(newTabSceneLogic({ tabId })) - const { setSearch } = useActions(newTabSceneLogic({ tabId })) + const { setSearch, logCreateNewItem } = useActions(newTabSceneLogic({ tabId })) const { openSidePanel } = useActions(sidePanelStateLogic) const newTabSceneData = useFeatureFlag('DATA_IN_NEW_TAB_SCENE') const items = groupedFilteredItems[selectedCategory] || [] @@ -477,6 +488,7 @@ export function Results({ ) : ( typedItems.map((item, index) => { + const isCreateNew = item.category === 'create-new' return ( // If we have filtered results set virtual focus to first item @@ -497,6 +509,15 @@ export function Results({ hasSideActionRight: true, truncate: true, }} + onClick={(e) => { + e.preventDefault() + if (item.href) { + if (isCreateNew) { + logCreateNewItem(item.href) + } + router.actions.push(item.href) + } + }} > {item.icon ?? item.name[0]} diff --git a/frontend/src/scenes/new-tab/newTabSceneLogic.tsx b/frontend/src/scenes/new-tab/newTabSceneLogic.tsx index 5c59b571e4..26784222d4 100644 --- a/frontend/src/scenes/new-tab/newTabSceneLogic.tsx +++ b/frontend/src/scenes/new-tab/newTabSceneLogic.tsx @@ -9,6 +9,7 @@ import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { tabAwareActionToUrl } from 'lib/logic/scenes/tabAwareActionToUrl' import { tabAwareUrlToAction } from 'lib/logic/scenes/tabAwareUrlToAction' +import { capitalizeFirstLetter } from 'lib/utils' import { getCurrentTeamId } from 'lib/utils/getAppContext' import { groupDisplayId } from 'scenes/persons/GroupActorDisplay' import { urls } from 'scenes/urls' @@ -25,7 +26,12 @@ import { SearchResults } from '~/layout/panel-layout/ProjectTree/projectTreeLogi import { splitPath } from '~/layout/panel-layout/ProjectTree/utils' import { TreeDataItem } from '~/lib/lemon-ui/LemonTree/LemonTree' import { groupsModel } from '~/models/groupsModel' -import { FileSystemEntry, FileSystemIconType, FileSystemImport } from '~/queries/schema/schema-general' +import { + FileSystemEntry, + FileSystemIconType, + FileSystemImport, + FileSystemViewLogEntry, +} from '~/queries/schema/schema-general' import { EventDefinition, Group, GroupTypeIndex, PersonType, PropertyDefinition } from '~/types' import { SearchInputCommand } from './components/SearchInput' @@ -100,6 +106,25 @@ function getIconForFileSystemItem(fs: FileSystemImport): JSX.Element { return iconForType('iconType' in fs ? fs.iconType : (fs.type as FileSystemIconType), fs.iconColor) } +const sortByLastViewedAt = (items: NewTabTreeDataItem[]): NewTabTreeDataItem[] => + items + .map((item, originalIndex) => ({ item, originalIndex })) + .toSorted((a, b) => { + const parseTime = (value: string | null | undefined): number => { + if (!value) { + return 0 + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : 0 + } + const diff = parseTime(b.item.lastViewedAt) - parseTime(a.item.lastViewedAt) + if (diff !== 0) { + return diff + } + return a.originalIndex - b.originalIndex + }) + .map(({ item }) => item) + function matchesRecentsSearch(entry: FileSystemEntry, searchChunks: string[]): boolean { if (searchChunks.length === 0) { return true @@ -137,6 +162,7 @@ export const newTabSceneLogic = kea([ showMoreInSection: (section: string) => ({ section }), resetSectionLimits: true, askAI: (searchTerm: string) => ({ searchTerm }), + logCreateNewItem: (href: string | null | undefined) => ({ href }), loadInitialGroups: true, setFirstNoResultsSearchPrefix: (dataset: NewTabSearchDataset, prefix: string | null) => ({ dataset, @@ -144,6 +170,22 @@ export const newTabSceneLogic = kea([ }), }), loaders(({ values, actions }) => ({ + sceneLogViews: [ + [] as FileSystemViewLogEntry[], + { + loadSceneLogViews: async () => { + return await api.fileSystemLogView.list({ type: 'scene' }) + }, + }, + ], + newLogViews: [ + [] as FileSystemViewLogEntry[], + { + loadNewLogViews: async () => { + return await api.fileSystemLogView.list({ type: 'create-new' }) + }, + }, + ], recents: [ (() => { if ('sessionStorage' in window) { @@ -484,6 +526,36 @@ export const newTabSceneLogic = kea([ ], }), selectors(({ actions }) => ({ + sceneLogViewsByRef: [ + (s) => [s.sceneLogViews], + (sceneLogViews): Record => { + return sceneLogViews.reduce( + (acc, { ref, viewed_at }) => { + const current = acc[ref] + if (!current || Date.parse(viewed_at) > Date.parse(current)) { + acc[ref] = viewed_at + } + return acc + }, + {} as Record + ) + }, + ], + newLogViewsByRef: [ + (s) => [s.newLogViews], + (newLogViews): Record => { + return newLogViews.reduce( + (acc, { ref, viewed_at }) => { + const current = acc[ref] + if (!current || Date.parse(viewed_at) > Date.parse(current)) { + acc[ref] = viewed_at + } + return acc + }, + {} as Record + ) + }, + ], newTabSceneDataIncludePersons: [ (s) => [s.newTabSceneDataInclude], (include): boolean => include.includes('persons'), @@ -740,12 +812,88 @@ export const newTabSceneLogic = kea([ (sectionItemLimits: Record) => (section: string) => sectionItemLimits[section] || 5, ], itemsGrid: [ - (s) => [s.featureFlags, s.projectTreeSearchItems, s.aiSearchItems], + (s) => [ + s.featureFlags, + s.projectTreeSearchItems, + s.aiSearchItems, + s.sceneLogViewsByRef, + s.newLogViewsByRef, + ], ( featureFlags: any, projectTreeSearchItems: NewTabTreeDataItem[], - aiSearchItems: NewTabTreeDataItem[] + aiSearchItems: NewTabTreeDataItem[], + sceneLogViewsByRef: Record, + newLogViewsByRef: Record ): NewTabTreeDataItem[] => { + const registerSceneKey = (map: Map, key?: string | null, sceneKey?: string): void => { + if (!key || !sceneKey || map.has(key)) { + return + } + map.set(key, sceneKey) + } + + const sceneKeyByType = new Map() + + const getSceneKeyForFs = (fs: FileSystemImport): string | null => { + if (fs.sceneKey) { + return fs.sceneKey + } + if (fs.type) { + const direct = sceneKeyByType.get(fs.type) + if (direct) { + return direct + } + const baseType = fs.type.split('/')?.[0] + if (baseType) { + const base = sceneKeyByType.get(baseType) + if (base) { + return base + } + } + } + if ('iconType' in fs && fs.iconType) { + const fromIcon = sceneKeyByType.get(fs.iconType as string) + if (fromIcon) { + return fromIcon + } + } + return null + } + + const getLastViewedAt = (sceneKey?: string | null): string | null => + sceneKey ? (sceneLogViewsByRef[sceneKey] ?? null) : null + + const getLastViewedAtForHref = (href?: string | null): string | null => + href ? (newLogViewsByRef[href] ?? null) : null + + const defaultProducts = getDefaultTreeProducts() + const defaultData = getDefaultTreeData() + + defaultProducts.forEach((fs) => { + if (fs.sceneKey) { + registerSceneKey(sceneKeyByType, fs.type, fs.sceneKey) + if (fs.type?.includes('/')) { + registerSceneKey(sceneKeyByType, fs.type.split('/')[0], fs.sceneKey) + } + if ('iconType' in fs) { + registerSceneKey(sceneKeyByType, fs.iconType as string | undefined, fs.sceneKey) + } + } + }) + + defaultData.forEach((fs) => { + if (fs.sceneKey) { + registerSceneKey(sceneKeyByType, fs.type, fs.sceneKey) + if (fs.type?.includes('/')) { + registerSceneKey(sceneKeyByType, fs.type.split('/')[0], fs.sceneKey) + } + if ('iconType' in fs) { + registerSceneKey(sceneKeyByType, fs.iconType as string | undefined, fs.sceneKey) + } + } + }) + const newInsightItems = getDefaultTreeNew() .filter(({ path }) => path.startsWith('Insight/')) .map((fs, index) => ({ @@ -756,6 +904,7 @@ export const newTabSceneLogic = kea([ flag: fs.flag, icon: getIconForFileSystemItem(fs), record: fs, + lastViewedAt: getLastViewedAtForHref(fs.href), })) .filter(({ flag }) => !flag || featureFlags[flag as keyof typeof featureFlags]) @@ -763,12 +912,13 @@ export const newTabSceneLogic = kea([ .filter(({ path }) => path.startsWith('Data/')) .map((fs, index) => ({ id: `new-data-${index}`, - name: 'Data ' + fs.path.substring(5).toLowerCase(), - category: 'data-management' as NEW_TAB_CATEGORY_ITEMS, + name: 'New ' + capitalizeFirstLetter(fs.path.substring(5).toLowerCase()), + category: 'create-new' as NEW_TAB_CATEGORY_ITEMS, href: fs.href, flag: fs.flag, icon: getIconForFileSystemItem(fs), record: fs, + lastViewedAt: getLastViewedAtForHref(fs.href), })) .filter(({ flag }) => !flag || featureFlags[flag as keyof typeof featureFlags]) @@ -782,10 +932,11 @@ export const newTabSceneLogic = kea([ flag: fs.flag, icon: getIconForFileSystemItem(fs), record: fs, + lastViewedAt: getLastViewedAtForHref(fs.href), })) .filter(({ flag }) => !flag || featureFlags[flag as keyof typeof featureFlags]) - const products = [...getDefaultTreeProducts(), ...getDefaultTreePersons()] + const products = [...defaultProducts, ...getDefaultTreePersons()] .map((fs, index) => ({ id: `product-${index}`, name: fs.path, @@ -794,11 +945,14 @@ export const newTabSceneLogic = kea([ flag: fs.flag, icon: getIconForFileSystemItem(fs), record: fs, + lastViewedAt: getLastViewedAt(getSceneKeyForFs(fs)), })) .filter(({ flag }) => !flag || featureFlags[flag as keyof typeof featureFlags]) .toSorted((a, b) => a.name.localeCompare(b.name)) - const data = getDefaultTreeData() + const sortedProducts = sortByLastViewedAt(products) + + const data = defaultData .map((fs, index) => ({ id: `data-${index}`, name: fs.path, @@ -807,12 +961,20 @@ export const newTabSceneLogic = kea([ flag: fs.flag, icon: getIconForFileSystemItem(fs), record: fs, + // TODO: re-enable when all data-management items support it + // lastViewedAt: getLastViewedAt(getSceneKeyForFs(fs)), })) .filter(({ flag }) => !flag || featureFlags[flag as keyof typeof featureFlags]) + const sortedData = sortByLastViewedAt(data) + + const sortedNewInsightItems = sortByLastViewedAt(newInsightItems) + const sortedNewDataItems = sortByLastViewedAt(newDataItems) + const sortedNewOtherItems = sortByLastViewedAt(newOtherItems) + const newTabSceneData = featureFlags[FEATURE_FLAGS.DATA_IN_NEW_TAB_SCENE] - const allItems: NewTabTreeDataItem[] = [ + const allItems: NewTabTreeDataItem[] = sortByLastViewedAt([ ...(newTabSceneData ? aiSearchItems : []), ...projectTreeSearchItems, { @@ -822,12 +984,13 @@ export const newTabSceneLogic = kea([ icon: , href: '/sql', record: { type: 'query', path: 'New SQL query' }, + lastViewedAt: getLastViewedAtForHref('/sql'), }, - ...newInsightItems, - ...newOtherItems, - ...products, - ...data, - ...newDataItems, + ...sortedNewInsightItems, + ...sortedNewOtherItems, + ...sortedProducts, + ...sortedData, + ...sortedNewDataItems, { id: 'new-hog-program', name: 'New Hog program', @@ -835,8 +998,9 @@ export const newTabSceneLogic = kea([ icon: , href: '/debug/hog', record: { type: 'hog', path: 'New Hog program' }, + lastViewedAt: getLastViewedAtForHref('/debug/hog'), }, - ] + ]) return allItems }, ], @@ -961,23 +1125,22 @@ export const newTabSceneLogic = kea([ // Add each category only if it's selected or if "all" is selected if (showAll || newTabSceneDataInclude.includes('create-new')) { const limit = getSectionItemLimit('create-new') - grouped['create-new'] = filterBySearch( - itemsGrid.filter((item) => item.category === 'create-new') + grouped['create-new'] = sortByLastViewedAt( + filterBySearch(itemsGrid.filter((item) => item.category === 'create-new')) ).slice(0, limit) } if (showAll || newTabSceneDataInclude.includes('apps')) { const limit = getSectionItemLimit('apps') - grouped['apps'] = filterBySearch(itemsGrid.filter((item) => item.category === 'apps')).slice( - 0, - limit + grouped['apps'] = sortByLastViewedAt( + filterBySearch(itemsGrid.filter((item) => item.category === 'apps')).slice(0, limit) ) } if (showAll || newTabSceneDataInclude.includes('data-management')) { const limit = getSectionItemLimit('data-management') - grouped['data-management'] = filterBySearch( - itemsGrid.filter((item) => item.category === 'data-management') + grouped['data-management'] = sortByLastViewedAt( + filterBySearch(itemsGrid.filter((item) => item.category === 'data-management')) ).slice(0, limit) } @@ -1185,6 +1348,19 @@ export const newTabSceneLogic = kea([ ], })), listeners(({ actions, values }) => ({ + logCreateNewItem: async ({ href }) => { + if (!href) { + return + } + + try { + await api.fileSystemLogView.create({ type: 'create-new', ref: href }) + } catch (error) { + console.error('Failed to log create new item usage:', error) + } + + actions.loadNewLogViews() + }, triggerSearchForIncludedItems: () => { const newTabSceneData = values.featureFlags[FEATURE_FLAGS.DATA_IN_NEW_TAB_SCENE] @@ -1223,11 +1399,15 @@ export const newTabSceneLogic = kea([ } }, onSubmit: () => { - if (values.selectedItem) { - if (values.selectedItem.category === 'askAI' && values.selectedItem.record?.searchTerm) { - actions.askAI(values.selectedItem.record.searchTerm) - } else if (values.selectedItem.href) { - router.actions.push(values.selectedItem.href) + const selected = values.selectedItem + if (selected) { + if (selected.category === 'askAI' && selected.record?.searchTerm) { + actions.askAI(selected.record.searchTerm) + } else if (selected.href) { + if (selected.category === 'create-new') { + actions.logCreateNewItem(selected.href) + } + router.actions.push(selected.href) } } }, @@ -1583,6 +1763,8 @@ export const newTabSceneLogic = kea([ }, })), afterMount(({ actions, values }) => { + actions.loadSceneLogViews() + actions.loadNewLogViews() actions.loadRecents() // Load initial data for data sections when "all" is selected by default diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 8d4b029238..1ac0ab027b 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -17,7 +17,7 @@ from posthog.api.utils import action from posthog.models.activity_logging.model_activity import is_impersonated_session from posthog.models.file_system.file_system import FileSystem, join_path, split_path from posthog.models.file_system.file_system_representation import FileSystemRepresentation -from posthog.models.file_system.file_system_view_log import annotate_file_system_with_view_logs +from posthog.models.file_system.file_system_view_log import FileSystemViewLog, annotate_file_system_with_view_logs from posthog.models.file_system.unfiled_file_saver import save_unfiled_files from posthog.models.team import Team from posthog.models.user import User @@ -103,6 +103,11 @@ class FileSystemViewLogSerializer(serializers.Serializer): viewed_at = serializers.DateTimeField(required=False) +class FileSystemViewLogListQuerySerializer(serializers.Serializer): + type = serializers.CharField(required=False, allow_blank=True) + limit = serializers.IntegerField(required=False, min_value=1) + + class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): scope_object = "file_system" queryset = FileSystem.objects.select_related("created_by") @@ -498,8 +503,11 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): return Response({"count": qs.count()}, status=status.HTTP_200_OK) - @action(methods=["POST"], detail=False, url_path="log_view") + @action(methods=["GET", "POST"], detail=False, url_path="log_view") def log_view(self, request: Request, *args: Any, **kwargs: Any) -> Response: + if request.method == "GET": + return self._list_log_views(request) + if is_impersonated_session(request): return Response( {"detail": "Impersonated sessions cannot log file system views."}, @@ -528,6 +536,28 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): return Response(status=status.HTTP_204_NO_CONTENT) + def _list_log_views(self, request: Request) -> Response: + if not request.user.is_authenticated: + return Response(status=status.HTTP_401_UNAUTHORIZED) + + serializer = FileSystemViewLogListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + + queryset = FileSystemViewLog.objects.filter(team=self.team, user=request.user) + log_type = validated.get("type") + if log_type: + queryset = queryset.filter(type=log_type) + + queryset = queryset.order_by("-viewed_at") + + limit = validated.get("limit") + if limit is not None: + queryset = queryset[:limit] + + return Response(FileSystemViewLogSerializer(queryset, many=True).data) + @action(methods=["POST"], detail=False) def count_by_path(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Get count of all files in a folder.""" diff --git a/posthog/api/test/test_file_system.py b/posthog/api/test/test_file_system.py index 8335fe1d54..d1ebd6273b 100644 --- a/posthog/api/test/test_file_system.py +++ b/posthog/api/test/test_file_system.py @@ -225,3 +225,36 @@ class TestFileSystemLogViewEndpoint(APIBaseTest): assert response.status_code == status.HTTP_403_FORBIDDEN mock_logger.assert_not_called() assert FileSystemViewLog.objects.count() == 0 + + def test_log_view_endpoint_lists_entries(self) -> None: + FileSystemViewLog.objects.all().delete() + + earlier = now() - timedelta(hours=1) + later = now() + + FileSystemViewLog.objects.create( + team=self.team, + user=self.user, + type="scene", + ref="First", + viewed_at=earlier, + ) + FileSystemViewLog.objects.create( + team=self.team, + user=self.user, + type="scene", + ref="Second", + viewed_at=later, + ) + data: dict = {"type": "scene", "limit": 10} + response = self.client.get( + f"/api/environments/{self.team.id}/file_system/log_view/", + data=data, + ) + + assert response.status_code == status.HTTP_200_OK + + payload = response.json() + assert [entry["ref"] for entry in payload] == ["Second", "First"] + assert all(entry["type"] == "scene" for entry in payload) + assert all("viewed_at" in entry for entry in payload) diff --git a/posthog/schema.py b/posthog/schema.py index 3f54663c8b..9fd7029376 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -1502,6 +1502,7 @@ class FileSystemImport(BaseModel): protocol: Optional[str] = Field(default=None, description='Protocol of the item, defaults to "project://"') ref: Optional[str] = Field(default=None, description="Object's ID or other unique reference") sceneKey: Optional[str] = Field(default=None, description="Match this with the a base scene key or a specific one") + sceneKeys: Optional[list[str]] = Field(default=None, description="List of all scenes exported by the app") shortcut: Optional[bool] = Field(default=None, description="Whether this is a shortcut or the actual item") tags: Optional[list[Tag]] = Field(default=None, description="Tag for the product 'beta' / 'alpha'") type: Optional[str] = Field( @@ -1510,6 +1511,15 @@ class FileSystemImport(BaseModel): visualOrder: Optional[float] = Field(default=None, description="Order of object in tree") +class FileSystemViewLogEntry(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + ref: str + type: str + viewed_at: str + + class FilterLogicalOperator(StrEnum): AND_ = "AND" OR_ = "OR" diff --git a/products/cohorts/manifest.tsx b/products/cohorts/manifest.tsx index bbb689e645..30bbec58b6 100644 --- a/products/cohorts/manifest.tsx +++ b/products/cohorts/manifest.tsx @@ -27,6 +27,7 @@ export const manifest: ProductManifest = { href: urls.cohort('new'), iconType: 'cohort' as FileSystemIconType, iconColor: ['var(--color-product-cohorts-light)'] as FileSystemIconColor, + sceneKeys: ['Cohorts', 'Cohort'], }, ], treeItemsProducts: [], diff --git a/products/dashboards/manifest.tsx b/products/dashboards/manifest.tsx index 122de58668..7b6c196a4a 100644 --- a/products/dashboards/manifest.tsx +++ b/products/dashboards/manifest.tsx @@ -37,6 +37,8 @@ export const manifest: ProductManifest = { href: urls.dashboards() + '#newDashboard=modal', iconType: 'dashboard' as FileSystemIconType, iconColor: ['var(--color-product-dashboards-light)'] as FileSystemIconColor, + sceneKey: 'Dashboard', + sceneKeys: ['Dashboards', 'Dashboard'], }, ], } diff --git a/products/experiments/manifest.tsx b/products/experiments/manifest.tsx index c3b0908a33..e83cd04156 100644 --- a/products/experiments/manifest.tsx +++ b/products/experiments/manifest.tsx @@ -40,6 +40,7 @@ export const manifest: ProductManifest = { href: urls.experiment('new'), iconType: 'experiment', iconColor: ['var(--color-product-experiments-light)'] as FileSystemIconColor, + sceneKeys: ['Experiments', 'Experiment'], }, ], treeItemsProducts: [ @@ -51,6 +52,7 @@ export const manifest: ProductManifest = { iconType: 'experiment', iconColor: ['var(--color-product-experiments-light)'] as FileSystemIconColor, sceneKey: 'Experiments', + sceneKeys: ['Experiments', 'Experiment'], }, ], } diff --git a/products/feature_flags/manifest.tsx b/products/feature_flags/manifest.tsx index 42527bf285..5bde3f9006 100644 --- a/products/feature_flags/manifest.tsx +++ b/products/feature_flags/manifest.tsx @@ -34,6 +34,7 @@ export const manifest: ProductManifest = { type: 'feature_flag', href: urls.featureFlags(), sceneKey: 'FeatureFlags', + sceneKeys: ['FeatureFlags', 'FeatureFlag'], }, ], } diff --git a/products/product_analytics/manifest.tsx b/products/product_analytics/manifest.tsx index 5467aa6248..3d86c2eb1f 100644 --- a/products/product_analytics/manifest.tsx +++ b/products/product_analytics/manifest.tsx @@ -108,6 +108,7 @@ export const manifest: ProductManifest = { iconType: 'insight/trends', iconColor: ['var(--color-insight-trends-light)'] as FileSystemIconColor, visualOrder: INSIGHT_VISUAL_ORDER.trends, + sceneKeys: ['Insight'], }, { path: `Insight/Funnel`, @@ -116,6 +117,7 @@ export const manifest: ProductManifest = { iconType: 'insight/funnels', iconColor: ['var(--color-insight-funnel-light)'] as FileSystemIconColor, visualOrder: INSIGHT_VISUAL_ORDER.funnel, + sceneKeys: ['Insight'], }, { path: `Insight/Retention`, @@ -124,6 +126,7 @@ export const manifest: ProductManifest = { iconType: 'insight/retention', iconColor: ['var(--color-insight-retention-light)'] as FileSystemIconColor, visualOrder: INSIGHT_VISUAL_ORDER.retention, + sceneKeys: ['Insight'], }, { path: `Insight/User paths`, @@ -132,6 +135,7 @@ export const manifest: ProductManifest = { iconType: 'insight/paths', iconColor: ['var(--color-insight-user-paths-light)', 'var(--color-user-paths-dark)'] as FileSystemIconColor, visualOrder: INSIGHT_VISUAL_ORDER.paths, + sceneKeys: ['Insight'], }, { path: `Insight/Stickiness`, @@ -140,6 +144,7 @@ export const manifest: ProductManifest = { iconType: 'insight/stickiness', iconColor: ['var(--color-insight-stickiness-light)'] as FileSystemIconColor, visualOrder: INSIGHT_VISUAL_ORDER.stickiness, + sceneKeys: ['Insight'], }, { path: `Insight/Lifecycle`, @@ -148,6 +153,7 @@ export const manifest: ProductManifest = { iconType: 'insight/lifecycle', iconColor: ['var(--color-insight-lifecycle-light)'] as FileSystemIconColor, visualOrder: INSIGHT_VISUAL_ORDER.lifecycle, + sceneKeys: ['Insight'], }, ], treeItemsProducts: [ @@ -159,6 +165,7 @@ export const manifest: ProductManifest = { iconType: 'product_analytics', iconColor: ['var(--color-product-product-analytics-light)'] as FileSystemIconColor, sceneKey: 'SavedInsights', + sceneKeys: ['SavedInsights', 'Insight'], }, ], } diff --git a/products/replay/manifest.tsx b/products/replay/manifest.tsx index dd4ab2cea4..840b2043d5 100644 --- a/products/replay/manifest.tsx +++ b/products/replay/manifest.tsx @@ -42,6 +42,7 @@ export const manifest: ProductManifest = { iconType: 'session_replay', iconColor: ['var(--color-product-session-replay-light)', 'var(--color-product-session-replay-dark)'], sceneKey: 'Replay', + sceneKeys: ['Replay', 'ReplaySingle', 'ReplaySettings', 'ReplayPlaylist', 'ReplayFilePlayback'], }, ], } diff --git a/products/surveys/manifest.tsx b/products/surveys/manifest.tsx index f8e7b7d60b..b315946a0a 100644 --- a/products/surveys/manifest.tsx +++ b/products/surveys/manifest.tsx @@ -38,6 +38,7 @@ export const manifest: ProductManifest = { iconType: 'survey', iconColor: ['var(--color-product-surveys-light)'] as FileSystemIconColor, sceneKey: 'Surveys', + sceneKeys: ['Survey', 'Surveys'], }, ], } diff --git a/products/web_analytics/manifest.tsx b/products/web_analytics/manifest.tsx index d1b87a3475..a48d3ac48c 100644 --- a/products/web_analytics/manifest.tsx +++ b/products/web_analytics/manifest.tsx @@ -20,6 +20,7 @@ export const manifest: ProductManifest = { iconColor: ['var(--color-product-web-analytics-light)'] as FileSystemIconColor, href: urls.webAnalytics(), sceneKey: 'WebAnalytics', + sceneKeys: ['WebAnalytics'], }, ], treeItemsMetadata: [ @@ -30,6 +31,7 @@ export const manifest: ProductManifest = { href: urls.marketingAnalytics(), flag: FEATURE_FLAGS.WEB_ANALYTICS_MARKETING, sceneKey: 'WebAnalyticsMarketing', + sceneKeys: ['WebAnalyticsMarketing'], }, ], }