mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
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:
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 |
@@ -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()) {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const manifest: ProductManifest = {
|
||||
type: 'feature_flag',
|
||||
href: urls.featureFlags(),
|
||||
sceneKey: 'FeatureFlags',
|
||||
sceneKeys: ['FeatureFlags', 'FeatureFlag'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const manifest: ProductManifest = {
|
||||
iconType: 'survey',
|
||||
iconColor: ['var(--color-product-surveys-light)'] as FileSystemIconColor,
|
||||
sceneKey: 'Surveys',
|
||||
sceneKeys: ['Survey', 'Surveys'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user