feat(new-tab): sort apps by usage (#40319)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Marius Andra
2025-10-29 14:38:46 +01:00
committed by GitHub
parent 058d415c6a
commit 126f9ab822
20 changed files with 416 additions and 33 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -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()) {

View File

@@ -338,6 +338,7 @@ export const getDefaultTreeNew = (): FileSystemImport[] =>
href: urls.dataPipelinesNew('source'),
icon: <IconPlug />,
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: <IconPlug />,
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: <IconPlug />,
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: <IconPlug />,
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'],
},
]

View File

@@ -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<FileSystemViewLogEntry[]> {
const request = new ApiRequest().fileSystemLogView()
if (params) {
request.withQueryString(params)
}
return await request.get()
},
async create(data: { ref?: string; type?: string }): Promise<FileSystemEntry> {
return await new ApiRequest().fileSystemLogView().create({ data })
},

View File

@@ -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"

View File

@@ -2595,6 +2595,14 @@ export interface FileSystemImport extends Omit<FileSystemEntry, 'id'> {
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 {

View File

@@ -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({
) : (
<ListBox.Group ref={groupRef} groupId={`category-${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)
}
}}
>
<span className="text-sm">{item.icon ?? item.name[0]}</span>
<span className="flex min-w-0 items-center gap-2">
@@ -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({
</div>
) : (
typedItems.map((item, index) => {
const isCreateNew = item.category === 'create-new'
return (
// If we have filtered results set virtual focus to first item
<ButtonGroupPrimitive key={item.id} className="group w-full border-0">
@@ -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)
}
}}
>
<span className="text-sm">{item.icon ?? item.name[0]}</span>
<span className="text-sm truncate text-primary">

View File

@@ -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<newTabSceneLogicType>([
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<newTabSceneLogicType>([
}),
}),
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<newTabSceneLogicType>([
],
}),
selectors(({ actions }) => ({
sceneLogViewsByRef: [
(s) => [s.sceneLogViews],
(sceneLogViews): Record<string, string> => {
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<string, string>
)
},
],
newLogViewsByRef: [
(s) => [s.newLogViews],
(newLogViews): Record<string, string> => {
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<string, string>
)
},
],
newTabSceneDataIncludePersons: [
(s) => [s.newTabSceneDataInclude],
(include): boolean => include.includes('persons'),
@@ -740,12 +812,88 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
(sectionItemLimits: Record<string, number>) => (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<string, string>,
newLogViewsByRef: Record<string, string>
): NewTabTreeDataItem[] => {
const registerSceneKey = (map: Map<string, string>, key?: string | null, sceneKey?: string): void => {
if (!key || !sceneKey || map.has(key)) {
return
}
map.set(key, sceneKey)
}
const sceneKeyByType = new Map<string, string>()
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<newTabSceneLogicType>([
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<newTabSceneLogicType>([
.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<newTabSceneLogicType>([
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<newTabSceneLogicType>([
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<newTabSceneLogicType>([
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<newTabSceneLogicType>([
icon: <IconDatabase />,
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<newTabSceneLogicType>([
icon: <IconHogQL />,
href: '/debug/hog',
record: { type: 'hog', path: 'New Hog program' },
lastViewedAt: getLastViewedAtForHref('/debug/hog'),
},
]
])
return allItems
},
],
@@ -961,23 +1125,22 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
// 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<newTabSceneLogicType>([
],
})),
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<newTabSceneLogicType>([
}
},
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<newTabSceneLogicType>([
},
})),
afterMount(({ actions, values }) => {
actions.loadSceneLogViews()
actions.loadNewLogViews()
actions.loadRecents()
// Load initial data for data sections when "all" is selected by default

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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"

View File

@@ -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: [],

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ export const manifest: ProductManifest = {
type: 'feature_flag',
href: urls.featureFlags(),
sceneKey: 'FeatureFlags',
sceneKeys: ['FeatureFlags', 'FeatureFlag'],
},
],
}

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ export const manifest: ProductManifest = {
iconType: 'survey',
iconColor: ['var(--color-product-surveys-light)'] as FileSystemIconColor,
sceneKey: 'Surveys',
sceneKeys: ['Survey', 'Surveys'],
},
],
}

View File

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