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'],
},
],
}