Files
posthog/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts
Michael Matloka fded6fdf62 test: Regular checkup of visual regression tests (#18469)
* test: Regular checkup of visual regression tests

* Fix billing gauge animation

Animations done in JS can't be stopped automatically by the Storybook test runner. CSS animations can, easily, and they are anyway the cleaner and more performant way of achieving the same here.

* Rename misleading feature flag `recentInsights` to `relatedInsights`

* Mock homepage endpoints to avoid error toasts

* Wait for the recordings list in the notebook node story

* Fix `featureFlagLogic`

* Wait for `.NotebookNode__content`

* Try to optimize

* Screenshot failures and upload as artifacts

* Fix remaining failures

* Increase timeouts

* Fix rendering of Survey stories

* Remove `clang-format`

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (2)

* Fix alignment of series name in insights details

* Try to fix experiment story flakiness

* Include toasts in loaders

* Fix superfluous toast

* Fix un-awaited breakpoints

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (1)

* Make login snapshots slightly stabler

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (2)

* Skip incorrect Surveys story

* Update UI snapshots for `chromium` (2)

* Revert msw upgrade

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2023-11-13 15:32:10 +01:00

599 lines
22 KiB
TypeScript

import {
BuiltLogic,
actions,
connect,
kea,
key,
listeners,
path,
props,
reducers,
selectors,
sharedListeners,
} from 'kea'
import type { notebookLogicType } from './notebookLogicType'
import { loaders } from 'kea-loaders'
import { notebooksModel, openNotebook, SCRATCHPAD_NOTEBOOK } from '~/models/notebooksModel'
import { NotebookNodeType, NotebookSyncStatus, NotebookTarget, NotebookType } from '~/types'
// NOTE: Annoyingly, if we import this then kea logic type-gen generates
// two imports and fails so, we reimport it from a utils file
import { EditorRange, JSONContent, NotebookEditor } from './utils'
import api from 'lib/api'
import posthog from 'posthog-js'
import { downloadFile, slugify } from 'lib/utils'
import { lemonToast } from '@posthog/lemon-ui'
import { notebookNodeLogicType } from '../Nodes/notebookNodeLogicType'
import {
buildTimestampCommentContent,
NotebookNodeReplayTimestampAttrs,
} from 'scenes/notebooks/Nodes/NotebookNodeReplayTimestamp'
import { NOTEBOOKS_VERSION, migrate } from './migrations/migrate'
import { router, urlToAction } from 'kea-router'
const SYNC_DELAY = 1000
export type NotebookLogicMode = 'notebook' | 'canvas'
export type NotebookLogicProps = {
shortId: string
mode?: NotebookLogicMode
target?: NotebookTarget
}
async function runWhenEditorIsReady(waitForEditor: () => boolean, fn: () => any): Promise<any> {
// TRICKY: external code doesn't know how to wait for the editor to be ready
// so, we have to poll until it is, then run the function
// the use-case is that we have opened a notebook, mounted this logic,
// and then want to run commands against the editor
// but, we are racing against it being ready
// throw an error after 2 seconds
const timeout = setTimeout(() => {
throw new Error('Notebook editor not ready')
}, 2000)
while (!waitForEditor()) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
clearTimeout(timeout)
return fn()
}
export const notebookLogic = kea<notebookLogicType>([
props({} as NotebookLogicProps),
path((key) => ['scenes', 'notebooks', 'Notebook', 'notebookLogic', key]),
key(({ shortId, mode }) => `${shortId}-${mode}`),
connect(() => ({
values: [notebooksModel, ['scratchpadNotebook', 'notebookTemplates']],
actions: [notebooksModel, ['receiveNotebookUpdate']],
})),
actions({
setEditor: (editor: NotebookEditor) => ({ editor }),
editorIsReady: true,
onEditorUpdate: true,
onEditorSelectionUpdate: true,
setLocalContent: (jsonContent: JSONContent, updateEditor = false) => ({ jsonContent, updateEditor }),
clearLocalContent: true,
setPreviewContent: (jsonContent: JSONContent) => ({ jsonContent }),
clearPreviewContent: true,
loadNotebook: true,
saveNotebook: (notebook: Pick<NotebookType, 'content' | 'title'>) => ({ notebook }),
setEditingNodeId: (editingNodeId: string | null) => ({ editingNodeId }),
exportJSON: true,
showConflictWarning: true,
onUpdateEditor: true,
registerNodeLogic: (nodeId: string, nodeLogic: BuiltLogic<notebookNodeLogicType>) => ({ nodeId, nodeLogic }),
unregisterNodeLogic: (nodeId: string) => ({ nodeId }),
setEditable: (editable: boolean) => ({ editable }),
scrollToSelection: true,
pasteAfterLastNode: (content: string) => ({
content,
}),
insertAfterLastNode: (content: JSONContent) => ({
content,
}),
insertAfterLastNodeOfType: (nodeType: string, content: JSONContent, knownStartingPosition) => ({
content,
nodeType,
knownStartingPosition,
}),
insertReplayCommentByTimestamp: (options: {
timestamp: number
sessionRecordingId: string
knownStartingPosition?: number
nodeId?: string
}) => options,
setShowHistory: (showHistory: boolean) => ({ showHistory }),
setTextSelection: (selection: number | EditorRange) => ({ selection }),
setContainerSize: (containerSize: 'small' | 'medium') => ({ containerSize }),
}),
reducers(({ props }) => ({
localContent: [
null as JSONContent | null,
{ persist: props.mode !== 'canvas', prefix: NOTEBOOKS_VERSION },
{
setLocalContent: (_, { jsonContent }) => jsonContent,
clearLocalContent: () => null,
},
],
previewContent: [
null as JSONContent | null,
{
setPreviewContent: (_, { jsonContent }) => jsonContent,
clearPreviewContent: () => null,
},
],
editor: [
null as NotebookEditor | null,
{
setEditor: (_, { editor }) => editor,
},
],
ready: [
false,
{
setReady: () => true,
},
],
conflictWarningVisible: [
false,
{
showConflictWarning: () => true,
loadNotebookSuccess: () => false,
},
],
editingNodeId: [
null as string | null,
{
setEditingNodeId: (_, { editingNodeId }) => editingNodeId,
},
],
nodeLogics: [
{} as Record<string, BuiltLogic<notebookNodeLogicType>>,
{
registerNodeLogic: (state, { nodeId, nodeLogic }) => {
if (nodeId === null) {
return state
} else {
return {
...state,
[nodeId]: nodeLogic,
}
}
},
unregisterNodeLogic: (state, { nodeId }) => {
const newState = { ...state }
if (nodeId !== null) {
delete newState[nodeId]
}
return newState
},
},
],
shouldBeEditable: [
false,
{
setEditable: (_, { editable }) => editable,
},
],
showHistory: [
false,
{
setShowHistory: (_, { showHistory }) => showHistory,
},
],
containerSize: [
'small' as 'small' | 'medium',
{
setContainerSize: (_, { containerSize }) => containerSize,
},
],
})),
loaders(({ values, props, actions }) => ({
notebook: [
null as NotebookType | null,
{
loadNotebook: async () => {
let response: NotebookType | null = null
if (values.mode !== 'notebook') {
return null
}
if (props.shortId === SCRATCHPAD_NOTEBOOK.short_id) {
response = {
...values.scratchpadNotebook,
content: null,
text_content: null,
version: 0,
}
} else if (props.shortId.startsWith('template-')) {
response =
values.notebookTemplates.find((template) => template.short_id === props.shortId) || null
if (!response) {
return null
}
} else {
try {
response = await api.notebooks.get(props.shortId)
} catch (e: any) {
if (e.status === 404) {
return null
}
throw e
}
}
const notebook = migrate(response)
if (!values.notebook && notebook.content) {
// If this is the first load we need to override the content fully
values.editor?.setContent(notebook.content)
}
return notebook
},
saveNotebook: async ({ notebook }) => {
if (!values.notebook) {
return values.notebook
}
try {
const response = await api.notebooks.update(values.notebook.short_id, {
version: values.notebook.version,
content: notebook.content,
text_content: values.editor?.getText() || '',
title: notebook.title,
})
// If the object is identical then no edits were made, so we can safely clear the local changes
if (notebook.content === values.localContent) {
actions.clearLocalContent()
}
return response
} catch (error: any) {
if (error.code === 'conflict') {
actions.showConflictWarning()
return null
} else {
throw error
}
}
},
},
],
newNotebook: [
null as NotebookType | null,
{
duplicateNotebook: async () => {
if (!values.content) {
return null
}
// We use the local content if set otherwise the notebook content. That way it supports templates, scratchpad etc.
const response = await api.notebooks.create({
content: values.content,
text_content: values.editor?.getText() || '',
title: values.title,
})
posthog.capture(`notebook duplicated`, {
short_id: response.short_id,
})
const source =
values.mode === 'canvas'
? 'Canvas'
: values.notebook?.short_id === 'scratchpad'
? 'Scratchpad'
: 'Template'
lemonToast.success(`Notebook created from ${source}!`)
if (values.notebook?.short_id === 'scratchpad') {
// If duplicating the scratchpad, we assume they don't want the scratchpad content anymore
actions.clearLocalContent()
}
await openNotebook(response.short_id, props.target ?? NotebookTarget.Scene)
return response
},
},
],
})),
selectors({
shortId: [() => [(_, props) => props], (props): string => props.shortId],
mode: [() => [(_, props) => props], (props): NotebookLogicMode => props.mode ?? 'notebook'],
isTemplate: [(s) => [s.shortId], (shortId): boolean => shortId.startsWith('template-')],
isLocalOnly: [
() => [(_, props) => props],
(props): boolean => {
return props.shortId === 'scratchpad' || props.mode === 'canvas'
},
],
notebookMissing: [
(s) => [s.notebook, s.notebookLoading, s.mode],
(notebook, notebookLoading, mode): boolean => {
return (['notebook', 'template'].includes(mode) && !notebook && !notebookLoading) ?? false
},
],
content: [
(s) => [s.notebook, s.localContent, s.previewContent],
(notebook, localContent, previewContent): JSONContent => {
// We use the local content is set otherwise the notebook content
return previewContent || localContent || notebook?.content || []
},
],
title: [
(s) => [s.notebook, s.content],
(notebook, content): string => {
const contentTitle = content?.content?.[0].content?.[0].text || 'Untitled'
return contentTitle || notebook?.title || 'Untitled'
},
],
syncStatus: [
(s) => [s.notebook, s.notebookLoading, s.localContent, s.isLocalOnly, s.previewContent],
(notebook, notebookLoading, localContent, isLocalOnly, previewContent): NotebookSyncStatus => {
if (previewContent || notebook?.is_template) {
return 'synced'
}
if (isLocalOnly) {
return 'local'
}
if (!notebook || !localContent) {
return 'synced'
}
if (notebookLoading) {
return 'saving'
}
return 'unsaved'
},
],
editingNodeLogic: [
(s) => [s.editingNodeId, s.nodeLogics],
(editingNodeId, nodeLogics) =>
Object.values(nodeLogics).find((nodeLogic) => nodeLogic.values.nodeId === editingNodeId),
],
findNodeLogic: [
(s) => [s.nodeLogics],
(nodeLogics) => {
return (type: NotebookNodeType, attributes: Record<string, any>): notebookNodeLogicType | null => {
const attrEntries = Object.entries(attributes || {})
return (
Object.values(nodeLogics).find((nodeLogic) => {
return (
nodeLogic.props.nodeType === type &&
attrEntries.every(
([attr, value]: [string, any]) => nodeLogic.props.attributes?.[attr] === value
)
)
}) ?? null
)
}
},
],
findNodeLogicById: [
(s) => [s.nodeLogics],
(nodeLogics) => {
return (id: string) => {
return Object.values(nodeLogics).find((nodeLogic) => nodeLogic.values.nodeId === id) ?? null
}
},
],
nodeLogicsWithChildren: [
(s) => [s.nodeLogics, s.content],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(nodeLogics, _content) => {
// NOTE: _content is not but is needed to retrigger as it could mean the children have changed
return Object.values(nodeLogics).filter((nodeLogic) => nodeLogic.props.attributes?.children)
},
],
isShowingLeftColumn: [
(s) => [s.editingNodeId, s.showHistory, s.containerSize],
(editingNodeId, showHistory, containerSize) => {
return showHistory || (!!editingNodeId && containerSize !== 'small')
},
],
isEditable: [
(s) => [s.shouldBeEditable, s.previewContent],
(shouldBeEditable, previewContent) => shouldBeEditable && !previewContent,
],
}),
sharedListeners(({ values, actions }) => ({
onNotebookChange: () => {
// Keep the list logic up to date with any changes
if (values.notebook && values.notebook.short_id !== SCRATCHPAD_NOTEBOOK.short_id) {
actions.receiveNotebookUpdate(values.notebook)
}
},
})),
listeners(({ values, actions, sharedListeners, cache }) => ({
insertAfterLastNode: async ({ content }) => {
await runWhenEditorIsReady(
() => !!values.editor,
() => {
let insertionPosition = 0
let nextNode = values.editor?.nextNode(insertionPosition)
while (nextNode) {
insertionPosition = nextNode.position
nextNode = values.editor?.nextNode(insertionPosition)
}
values.editor?.insertContentAfterNode(insertionPosition, content)
}
)
},
pasteAfterLastNode: async ({ content }) => {
await runWhenEditorIsReady(
() => !!values.editor,
() => {
const endPosition = values.editor?.getEndPosition() || 0
values.editor?.pasteContent(endPosition, content)
}
)
},
insertAfterLastNodeOfType: async ({ content, nodeType, knownStartingPosition }) => {
await runWhenEditorIsReady(
() => !!values.editor,
() => {
let insertionPosition = knownStartingPosition
let nextNode = values.editor?.nextNode(insertionPosition)
while (nextNode && values.editor?.hasChildOfType(nextNode.node, nodeType)) {
insertionPosition = nextNode.position
nextNode = values.editor?.nextNode(insertionPosition)
}
values.editor?.insertContentAfterNode(insertionPosition, content)
}
)
},
insertReplayCommentByTimestamp: async ({ timestamp, sessionRecordingId, knownStartingPosition, nodeId }) => {
await runWhenEditorIsReady(
() => !!values.editor,
() => {
let insertionPosition =
knownStartingPosition || values.editor?.findNodePositionByAttrs({ id: sessionRecordingId })
let nextNode = values.editor?.nextNode(insertionPosition)
while (nextNode && values.editor?.hasChildOfType(nextNode.node, NotebookNodeType.ReplayTimestamp)) {
const candidateTimestampAttributes = nextNode.node.content.firstChild
?.attrs as NotebookNodeReplayTimestampAttrs
const nextNodePlaybackTime = candidateTimestampAttributes.playbackTime || -1
if (nextNodePlaybackTime <= timestamp) {
insertionPosition = nextNode.position
nextNode = values.editor?.nextNode(insertionPosition)
} else {
nextNode = null
}
}
values.editor?.insertContentAfterNode(
insertionPosition,
buildTimestampCommentContent({
playbackTime: timestamp,
sessionRecordingId,
sourceNodeId: nodeId,
})
)
}
)
},
setLocalContent: async ({ updateEditor, jsonContent }, breakpoint) => {
if (values.previewContent) {
// We don't want to modify the content if we are viewing a preview
return
}
if (updateEditor) {
values.editor?.setContent(jsonContent)
}
await breakpoint(SYNC_DELAY)
if (values.mode === 'canvas') {
// TODO: We probably want this to be configurable
cache.lastState = btoa(JSON.stringify(jsonContent))
router.actions.replace(
router.values.currentLocation.pathname,
router.values.currentLocation.searchParams,
{
...router.values.currentLocation.hashParams,
state: cache.lastState,
}
)
}
posthog.capture('notebook content changed', {
short_id: values.notebook?.short_id,
})
if (!values.isLocalOnly && values.content && !values.notebookLoading) {
actions.saveNotebook({
content: values.content,
title: values.title,
})
}
},
setPreviewContent: async () => {
values.editor?.setContent(values.content)
},
clearPreviewContent: async () => {
values.editor?.setContent(values.content)
},
setShowHistory: async ({ showHistory }) => {
if (!showHistory) {
actions.clearPreviewContent()
}
},
onEditorUpdate: () => {
if (!values.editor) {
return
}
const jsonContent = values.editor.getJSON()
actions.setLocalContent(jsonContent)
actions.onUpdateEditor()
},
setEditor: () => {
values.editor?.setContent(values.content)
},
saveNotebookSuccess: sharedListeners.onNotebookChange,
loadNotebookSuccess: sharedListeners.onNotebookChange,
exportJSON: () => {
const file = new File(
[JSON.stringify(values.editor?.getJSON(), null, 2)],
`${slugify(values.title ?? 'untitled')}.ph-notebook.json`,
{ type: 'application/json' }
)
downloadFile(file)
},
onEditorSelectionUpdate: () => {
if (values.editor) {
actions.onUpdateEditor()
}
},
scrollToSelection: () => {
if (values.editor) {
values.editor.scrollToSelection()
}
},
setEditingNodeId: () => {
values.editingNodeLogic?.actions.selectNode()
},
setTextSelection: ({ selection }) => {
queueMicrotask(() => {
values.editor?.setTextSelection(selection)
})
},
})),
urlToAction(({ values, actions, cache }) => ({
'*': (_, _search, hashParams) => {
if (values.mode === 'canvas' && hashParams?.state) {
if (cache.lastState === hashParams.state) {
return
}
actions.setLocalContent(JSON.parse(atob(hashParams.state)))
}
},
})),
])