feat: move replay annotations to comments (#35539)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: David Newell <d.newell1@outlook.com>
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
@@ -16,7 +16,7 @@ import { annotationModalLogic, annotationScopeToName } from 'scenes/annotations/
|
||||
import { insightLogic } from 'scenes/insights/insightLogic'
|
||||
|
||||
import { annotationsModel } from '~/models/annotationsModel'
|
||||
import { AnnotationScope, AnnotationType, IntervalType } from '~/types'
|
||||
import { AnnotationType, IntervalType } from '~/types'
|
||||
|
||||
import {
|
||||
annotationsOverlayLogic,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
determineAnnotationsDateGroup,
|
||||
} from './annotationsOverlayLogic'
|
||||
import { useAnnotationsPositioning } from './useAnnotationsPositioning'
|
||||
import ViewRecordingButton from 'lib/components/ViewRecordingButton/ViewRecordingButton'
|
||||
import { TextContent } from 'lib/components/Cards/TextCard/TextCard'
|
||||
|
||||
/** User-facing format for annotation groups. */
|
||||
@@ -252,7 +251,7 @@ function AnnotationCard({ annotation }: { annotation: AnnotationType }): JSX.Ele
|
||||
<h5 className="grow m-0 text-secondary">
|
||||
{annotation.date_marker?.format('MMM DD, YYYY h:mm A')} ({shortTimeZone(timezone)}) –{' '}
|
||||
{annotationScopeToName[annotation.scope]}
|
||||
{annotation.scope === AnnotationScope.Recording ? ' comment' : '-level'}
|
||||
-level
|
||||
</h5>
|
||||
<LemonButton
|
||||
size="small"
|
||||
@@ -288,15 +287,6 @@ function AnnotationCard({ annotation }: { annotation: AnnotationType }): JSX.Ele
|
||||
/>{' '}
|
||||
• {humanFriendlyDetailedTime(annotation.created_at, 'MMMM DD, YYYY', 'h:mm A')}
|
||||
</div>
|
||||
{annotation.scope === AnnotationScope.Recording &&
|
||||
!!annotation.recording_id &&
|
||||
!!annotation.date_marker ? (
|
||||
<ViewRecordingButton
|
||||
sessionId={annotation.recording_id}
|
||||
timestamp={annotation.date_marker}
|
||||
inModal={true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -152,7 +152,6 @@ export const annotationsOverlayLogic = kea<annotationsOverlayLogicType>([
|
||||
dateRange
|
||||
? annotations.filter(
|
||||
(annotation) =>
|
||||
annotation.scope !== AnnotationScope.Recording &&
|
||||
(annotation.scope !== AnnotationScope.Insight ||
|
||||
annotation.dashboard_item === insightNumericId) &&
|
||||
(annotation.scope !== AnnotationScope.Dashboard ||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { urls } from 'scenes/urls'
|
||||
import { AnnotationScope, AnnotationType } from '~/types'
|
||||
|
||||
import { annotationModalLogic, annotationScopeToName } from './annotationModalLogic'
|
||||
import ViewRecordingButton from 'lib/components/ViewRecordingButton/ViewRecordingButton'
|
||||
|
||||
export function NewAnnotationButton(): JSX.Element {
|
||||
const { openModalToCreateAnnotation } = useActions(annotationModalLogic)
|
||||
@@ -95,13 +94,6 @@ export function AnnotationModal({
|
||||
value: AnnotationScope.Organization,
|
||||
label: annotationScopeToName[AnnotationScope.Organization],
|
||||
},
|
||||
{
|
||||
value: AnnotationScope.Recording,
|
||||
label: annotationScopeToName[AnnotationScope.Recording],
|
||||
disabledReason: annotationModal.recordingId
|
||||
? undefined
|
||||
: 'To select this scope, open this annotation on the target recording',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -111,7 +103,7 @@ export function AnnotationModal({
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
title={existingModalAnnotation ? 'Edit annotation' : 'New annotation'}
|
||||
description="Use annotations to comment on insights, dashboards, and recordings."
|
||||
description="Use annotations to comment on insights, dashboards"
|
||||
footer={
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -183,19 +175,6 @@ export function AnnotationModal({
|
||||
maxLength={400}
|
||||
/>
|
||||
</LemonField>
|
||||
{!!existingModalAnnotation &&
|
||||
existingModalAnnotation.scope === AnnotationScope.Recording &&
|
||||
!!existingModalAnnotation.recording_id &&
|
||||
!!existingModalAnnotation.date_marker ? (
|
||||
<div className="flex flex-row justify-end">
|
||||
<ViewRecordingButton
|
||||
sessionId={existingModalAnnotation.recording_id}
|
||||
timestamp={existingModalAnnotation.date_marker}
|
||||
inModal={true}
|
||||
type="secondary"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Form>
|
||||
</LemonModal>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ export const annotationScopeToName: Record<AnnotationScope, string> = {
|
||||
[AnnotationScope.Dashboard]: 'Dashboard',
|
||||
[AnnotationScope.Project]: 'Project',
|
||||
[AnnotationScope.Organization]: 'Organization',
|
||||
[AnnotationScope.Recording]: 'Recording',
|
||||
}
|
||||
|
||||
export const annotationScopeToLevel: Record<AnnotationScope, number> = {
|
||||
@@ -28,7 +27,6 @@ export const annotationScopeToLevel: Record<AnnotationScope, number> = {
|
||||
[AnnotationScope.Dashboard]: 1,
|
||||
[AnnotationScope.Project]: 2,
|
||||
[AnnotationScope.Organization]: 3,
|
||||
[AnnotationScope.Recording]: 4,
|
||||
}
|
||||
|
||||
export interface AnnotationModalForm {
|
||||
@@ -37,7 +35,6 @@ export interface AnnotationModalForm {
|
||||
content: AnnotationType['content']
|
||||
dashboardItemId: AnnotationType['dashboard_item'] | null
|
||||
dashboardId: AnnotationType['dashboard_id'] | null
|
||||
recordingId: AnnotationType['recording_id'] | null
|
||||
}
|
||||
|
||||
export const annotationModalLogic = kea<annotationModalLogicType>([
|
||||
|
||||
@@ -15,21 +15,21 @@ const PlayerFrameCommentOverlayContent = (): JSX.Element | null => {
|
||||
const { setIsCommenting } = useActions(sessionRecordingPlayerLogic)
|
||||
|
||||
const theBuiltOverlayLogic = playerCommentOverlayLogic({ recordingId: sessionRecordingId, ...logicProps })
|
||||
const { recordingAnnotation, isRecordingAnnotationSubmitting } = useValues(theBuiltOverlayLogic)
|
||||
const { submitRecordingAnnotation, resetRecordingAnnotation } = useActions(theBuiltOverlayLogic)
|
||||
const { recordingComment, isRecordingCommentSubmitting } = useValues(theBuiltOverlayLogic)
|
||||
const { submitRecordingComment, resetRecordingComment } = useActions(theBuiltOverlayLogic)
|
||||
|
||||
return isCommenting ? (
|
||||
<div className="absolute bottom-4 left-4 z-20 w-90">
|
||||
<div className="flex flex-col bg-primary border border-border rounded p-2 shadow-lg">
|
||||
<Form
|
||||
logic={playerCommentOverlayLogic}
|
||||
formKey="recordingAnnotation"
|
||||
id="recording-annotation-form"
|
||||
formKey="recordingComment"
|
||||
id="recording-comment-form"
|
||||
enableFormOnSubmit
|
||||
className="flex flex-col gap-y-1"
|
||||
>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<LemonField name="annotationId" className="hidden">
|
||||
<LemonField name="commentId" className="hidden">
|
||||
<input type="hidden" />
|
||||
</LemonField>
|
||||
<LemonField
|
||||
@@ -45,31 +45,31 @@ const PlayerFrameCommentOverlayContent = (): JSX.Element | null => {
|
||||
<LemonField name="content">
|
||||
<LemonTextAreaMarkdown
|
||||
placeholder="Comment on this recording?"
|
||||
data-attr="create-annotation-input"
|
||||
data-attr="create-recording-comment-input"
|
||||
maxLength={400}
|
||||
/>
|
||||
</LemonField>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2 justify-between">
|
||||
<LemonButton
|
||||
data-attr="cancel-recording-annotation"
|
||||
data-attr="cancel-recording-comment"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
resetRecordingAnnotation()
|
||||
resetRecordingComment()
|
||||
setIsCommenting(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
form="recording-annotation-form"
|
||||
form="recording-comment-form"
|
||||
type="primary"
|
||||
onClick={submitRecordingAnnotation}
|
||||
data-attr="create-recording-annotation-submit"
|
||||
onClick={submitRecordingComment}
|
||||
data-attr="create-recording-comment-submit"
|
||||
size="small"
|
||||
loading={isRecordingAnnotationSubmitting}
|
||||
loading={isRecordingCommentSubmitting}
|
||||
>
|
||||
{recordingAnnotation.annotationId ? 'Update' : 'Save'}
|
||||
{recordingComment.commentId ? 'Update' : 'Save'}
|
||||
</LemonButton>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { actions, kea, path } from 'kea'
|
||||
import { RecordingAnnotationForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
|
||||
import { RecordingCommentForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
|
||||
|
||||
import type { playerCommentModelType } from './playerCommentModelType'
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { playerCommentModelType } from './playerCommentModelType'
|
||||
export const playerCommentModel = kea<playerCommentModelType>([
|
||||
path(['scenes', 'session-recordings', 'player', 'playerCommentModel']),
|
||||
actions({
|
||||
startCommenting: (annotation: RecordingAnnotationForm | null) => ({ annotation }),
|
||||
startCommenting: (comment: RecordingCommentForm | null) => ({ comment }),
|
||||
commentEdited: (recordingId: string) => ({ recordingId }),
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -5,26 +5,25 @@ import api from 'lib/api'
|
||||
import { Dayjs, dayjs } from 'lib/dayjs'
|
||||
import { colonDelimitedDuration } from 'lib/utils'
|
||||
|
||||
import { annotationsModel } from '~/models/annotationsModel'
|
||||
import { AnnotationScope, AnnotationType } from '~/types'
|
||||
import { CommentType } from '~/types'
|
||||
|
||||
import type { playerCommentOverlayLogicType } from './playerFrameCommentOverlayLogicType'
|
||||
import { sessionRecordingPlayerLogic, SessionRecordingPlayerLogicProps } from '../sessionRecordingPlayerLogic'
|
||||
import { lemonToast } from 'lib/lemon-ui/LemonToast'
|
||||
import { isSingleEmoji } from 'scenes/session-recordings/utils'
|
||||
import { playerCommentModel } from 'scenes/session-recordings/player/commenting/playerCommentModel'
|
||||
|
||||
export interface RecordingAnnotationForm {
|
||||
export interface RecordingCommentForm {
|
||||
// formatted time in recording, e.g. 00:00:00, 00:00:01, 00:00:02, etc.
|
||||
// this is a string because we want to be able to display the time in the recording
|
||||
timeInRecording: string | null
|
||||
timeInRecording?: string | null
|
||||
// number of seconds into recording
|
||||
timestampInRecording?: number | null
|
||||
// the date that the timeInRecording represents
|
||||
dateForTimestamp?: Dayjs | null
|
||||
content: string
|
||||
recordingId: string | null
|
||||
annotationId: AnnotationType['id'] | null
|
||||
scope: AnnotationType['scope'] | null
|
||||
commentId: CommentType['id'] | null
|
||||
}
|
||||
|
||||
export interface PlayerCommentOverlayLogicProps extends SessionRecordingPlayerLogicProps {
|
||||
@@ -36,15 +35,10 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
|
||||
props({} as PlayerCommentOverlayLogicProps),
|
||||
connect((props: PlayerCommentOverlayLogicProps) => ({
|
||||
values: [sessionRecordingPlayerLogic(props), ['currentPlayerTime', 'currentTimestamp', 'sessionPlayerData']],
|
||||
actions: [
|
||||
annotationsModel,
|
||||
['appendAnnotations', 'replaceAnnotation'],
|
||||
sessionRecordingPlayerLogic(props),
|
||||
['setIsCommenting'],
|
||||
],
|
||||
actions: [sessionRecordingPlayerLogic(props), ['setIsCommenting']],
|
||||
})),
|
||||
actions({
|
||||
editAnnotation: (annotation: RecordingAnnotationForm) => ({ annotation }),
|
||||
editComment: (comment: RecordingCommentForm) => ({ comment }),
|
||||
addEmojiComment: (emoji: string) => ({ emoji }),
|
||||
setLoading: (isLoading: boolean) => ({ isLoading }),
|
||||
}),
|
||||
@@ -74,24 +68,22 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
|
||||
subscriptions(({ actions, values }) => ({
|
||||
formattedTimestamp: (formattedTimestamp) => {
|
||||
// as the timestamp from the player changes we track three representations of it
|
||||
actions.setRecordingAnnotationValue('timeInRecording', formattedTimestamp)
|
||||
actions.setRecordingAnnotationValue('timestampInRecording', values.currentPlayerTime)
|
||||
actions.setRecordingAnnotationValue('dateForTimestamp', dayjs(values.currentTimestamp))
|
||||
actions.setRecordingCommentValue('timeInRecording', formattedTimestamp)
|
||||
actions.setRecordingCommentValue('timestampInRecording', values.currentPlayerTime)
|
||||
actions.setRecordingCommentValue('dateForTimestamp', dayjs(values.currentTimestamp))
|
||||
},
|
||||
})),
|
||||
listeners(({ actions, props, values }) => ({
|
||||
editAnnotation: ({ annotation }) => {
|
||||
actions.setRecordingAnnotationValue('content', annotation.content)
|
||||
actions.setRecordingAnnotationValue('recordingId', annotation.recordingId)
|
||||
// don't change the scope if it has one
|
||||
actions.setRecordingAnnotationValue('scope', annotation.scope || AnnotationScope.Recording)
|
||||
actions.setRecordingAnnotationValue('annotationId', annotation.annotationId)
|
||||
editComment: ({ comment }) => {
|
||||
actions.setRecordingCommentValue('content', comment.content)
|
||||
actions.setRecordingCommentValue('recordingId', comment.recordingId)
|
||||
actions.setRecordingCommentValue('commentId', comment.commentId)
|
||||
// opening to edit also sets the player timestamp, which will update the timestamps in the form
|
||||
actions.setIsCommenting(true)
|
||||
},
|
||||
setIsCommenting: ({ isCommenting }) => {
|
||||
if (!isCommenting) {
|
||||
actions.resetRecordingAnnotation()
|
||||
actions.resetRecordingComment()
|
||||
}
|
||||
},
|
||||
addEmojiComment: async ({ emoji }) => {
|
||||
@@ -104,15 +96,16 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
|
||||
}, 250)
|
||||
|
||||
try {
|
||||
const apiPayload = {
|
||||
date_marker: dayjs(values.currentTimestamp).toISOString(),
|
||||
await api.comments.create({
|
||||
content: emoji,
|
||||
scope: AnnotationScope.Recording,
|
||||
recording_id: props.recordingId,
|
||||
is_emoji: true,
|
||||
}
|
||||
const createdAnnotation = await api.annotations.create(apiPayload)
|
||||
actions.appendAnnotations([createdAnnotation])
|
||||
scope: 'recording',
|
||||
item_id: props.recordingId,
|
||||
item_context: {
|
||||
is_emoji: true,
|
||||
time_in_recording: dayjs(values.currentTimestamp).toISOString(),
|
||||
},
|
||||
})
|
||||
playerCommentModel.actions.commentEdited(props.recordingId)
|
||||
} finally {
|
||||
if (loadingTimeout) {
|
||||
clearTimeout(loadingTimeout)
|
||||
@@ -122,45 +115,44 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
|
||||
},
|
||||
})),
|
||||
forms(({ props, values, actions }) => ({
|
||||
recordingAnnotation: {
|
||||
recordingComment: {
|
||||
defaults: {
|
||||
timeInRecording: values.formattedTimestamp ?? '00:00:00',
|
||||
dateForTimestamp: null,
|
||||
content: '',
|
||||
scope: AnnotationScope.Recording,
|
||||
recordingId: null,
|
||||
annotationId: null,
|
||||
} as RecordingAnnotationForm,
|
||||
commentId: null,
|
||||
} as RecordingCommentForm,
|
||||
errors: ({ content }) => ({
|
||||
content: !content?.trim()
|
||||
? 'An annotation must have text content.'
|
||||
? 'A comment must have text content.'
|
||||
: content.length > 400
|
||||
? 'Must be 400 characters or less'
|
||||
: null,
|
||||
}),
|
||||
submit: async (data) => {
|
||||
const { annotationId, content, dateForTimestamp, scope } = data
|
||||
const { commentId, content, dateForTimestamp } = data
|
||||
|
||||
if (!dateForTimestamp) {
|
||||
throw new Error('Cannot comment without a timestamp.')
|
||||
}
|
||||
|
||||
const apiPayload = {
|
||||
date_marker: dateForTimestamp.toISOString(),
|
||||
content,
|
||||
scope: scope || AnnotationScope.Recording,
|
||||
recording_id: props.recordingId,
|
||||
scope: 'recording',
|
||||
item_id: props.recordingId,
|
||||
item_context: {
|
||||
time_in_recording: dateForTimestamp.toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
if (annotationId) {
|
||||
const updatedAnnotation = await api.annotations.update(annotationId, apiPayload)
|
||||
actions.replaceAnnotation(updatedAnnotation)
|
||||
if (commentId) {
|
||||
await api.comments.update(commentId, apiPayload)
|
||||
} else {
|
||||
const createdAnnotation = await api.annotations.create(apiPayload)
|
||||
actions.appendAnnotations([createdAnnotation])
|
||||
await api.comments.create(apiPayload)
|
||||
}
|
||||
|
||||
actions.resetRecordingAnnotation()
|
||||
playerCommentModel.actions.commentEdited(props.recordingId)
|
||||
actions.resetRecordingComment()
|
||||
actions.setIsCommenting(false)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ import { autoCaptureEventToDescription } from 'lib/utils'
|
||||
import React, { memo, MutableRefObject } from 'react'
|
||||
import {
|
||||
InspectorListItem,
|
||||
InspectorListItemAnnotationComment,
|
||||
InspectorListItemComment,
|
||||
InspectorListItemEvent,
|
||||
InspectorListItemNotebookComment,
|
||||
@@ -23,19 +22,25 @@ function isEventItem(x: InspectorListItem): x is InspectorListItemEvent {
|
||||
}
|
||||
|
||||
function isNotebookComment(x: InspectorListItem): x is InspectorListItemNotebookComment {
|
||||
return x.type === 'comment' && x.source === 'notebook'
|
||||
}
|
||||
|
||||
function isAnnotationComment(x: InspectorListItem): x is InspectorListItemAnnotationComment {
|
||||
return x.type === 'comment' && x.source === 'annotation'
|
||||
if (x.type !== 'comment') {
|
||||
return false
|
||||
}
|
||||
return 'source' in x && x.source === 'notebook'
|
||||
}
|
||||
|
||||
function isComment(x: InspectorListItem): x is InspectorListItemComment {
|
||||
return isAnnotationComment(x) || isNotebookComment(x)
|
||||
if (x.type !== 'comment') {
|
||||
return false
|
||||
}
|
||||
return 'source' in x && x.source === 'comment'
|
||||
}
|
||||
|
||||
function isAnnotationEmojiComment(x: InspectorListItem): x is InspectorListItemAnnotationComment {
|
||||
return isAnnotationComment(x) && !!x.data.is_emoji && !!x.data.content && isSingleEmoji(x.data.content)
|
||||
function isAnyComment(x: InspectorListItem): x is InspectorListItemComment | InspectorListItemNotebookComment {
|
||||
return x.type === 'comment'
|
||||
}
|
||||
|
||||
function isEmojiComment(x: InspectorListItem): x is InspectorListItemComment {
|
||||
return isComment(x) && !!x.data.item_context?.is_emoji && !!x.data.content && isSingleEmoji(x.data.content)
|
||||
}
|
||||
|
||||
function PlayerSeekbarTick({
|
||||
@@ -44,7 +49,7 @@ function PlayerSeekbarTick({
|
||||
zIndex,
|
||||
onClick,
|
||||
}: {
|
||||
item: InspectorListItemComment | InspectorListItemEvent
|
||||
item: InspectorListItemComment | InspectorListItemNotebookComment | InspectorListItemEvent
|
||||
endTimeMs: number
|
||||
zIndex: number
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
@@ -95,23 +100,14 @@ function PlayerSeekbarTick({
|
||||
) : (
|
||||
<div className="flex flex-col px-4 py-2 gap-y-2">
|
||||
<TextContent text={item.data.content ?? ''} data-attr="PlayerSeekbarTicks--text-content" />
|
||||
<ProfilePicture
|
||||
user={
|
||||
item.data.creation_type === 'GIT'
|
||||
? { first_name: 'GitHub automation' }
|
||||
: item.data.created_by
|
||||
}
|
||||
showName
|
||||
size="md"
|
||||
type={item.data.creation_type === 'GIT' ? 'bot' : 'person'}
|
||||
/>{' '}
|
||||
<ProfilePicture user={item.data.created_by} showName size="md" type="person" />{' '}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isAnnotationEmojiComment(item) ? (
|
||||
{isEmojiComment(item) ? (
|
||||
<div className="PlayerSeekbarTick__emoji">{item.data.content}</div>
|
||||
) : isComment(item) ? (
|
||||
) : isAnyComment(item) ? (
|
||||
<div className="PlayerSeekbarTick__comment">
|
||||
<IconComment />
|
||||
</div>
|
||||
@@ -130,7 +126,7 @@ export const PlayerSeekbarTicks = memo(
|
||||
seekToTime,
|
||||
hoverRef,
|
||||
}: {
|
||||
seekbarItems: (InspectorListItemEvent | InspectorListItemComment)[]
|
||||
seekbarItems: (InspectorListItemEvent | InspectorListItemComment | InspectorListItemNotebookComment)[]
|
||||
endTimeMs: number
|
||||
seekToTime: (timeInMilliseconds: number) => void
|
||||
hoverRef: MutableRefObject<HTMLDivElement | null>
|
||||
|
||||
@@ -18,40 +18,35 @@ const meta: Meta<typeof PlayerInspector> = {
|
||||
decorators: [
|
||||
mswDecorator({
|
||||
get: {
|
||||
'/api/projects/:team_id/annotations': {
|
||||
'/api/projects/:team_id/comments': {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 21,
|
||||
content: 'about seven seconds in there is this comment which is too long',
|
||||
date_marker: '2024-11-15T09:19:35.620000Z',
|
||||
creation_type: 'USR',
|
||||
dashboard_item: null,
|
||||
dashboard_id: null,
|
||||
dashboard_name: null,
|
||||
insight_short_id: null,
|
||||
insight_name: null,
|
||||
insight_derived_name: null,
|
||||
id: '019838f3-1bab-0000-fce8-04be1d6b6fe3',
|
||||
created_by: {
|
||||
id: 1,
|
||||
uuid: '0196b443-26f4-0000-5d24-b982365fe43d',
|
||||
distinct_id: 'BpwPZw8BGaeISf7DlDprsui5J9DMIYjhE3fTFMJiEMF',
|
||||
first_name: 'fasdadafsfasdadafsfasdadafsfasdadafsfasdadafsfasdadafs',
|
||||
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
|
||||
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
|
||||
first_name: 'fasda',
|
||||
last_name: '',
|
||||
email: 'paul@posthog.com',
|
||||
is_email_verified: false,
|
||||
hedgehog_config: null,
|
||||
role_at_organization: 'data',
|
||||
role_at_organization: 'other',
|
||||
},
|
||||
created_at: '2025-06-11T13:15:06.976791Z',
|
||||
updated_at: '2025-06-11T13:15:06.977228Z',
|
||||
deleted: false,
|
||||
content: 'about seven seconds in there is this comment which is too long',
|
||||
version: 0,
|
||||
created_at: '2025-07-23T20:21:53.197354Z',
|
||||
item_id: '01975ab7-e00e-726f-aada-988b2f7fa053',
|
||||
item_context: {
|
||||
is_emoji: false,
|
||||
time_in_recording: '2024-11-15T09:19:35.620000Z',
|
||||
},
|
||||
scope: 'recording',
|
||||
recording_id: '01975ab7-e00e-726f-aada-988b2f7fa053',
|
||||
source_comment: null,
|
||||
},
|
||||
],
|
||||
next: null,
|
||||
previous: null,
|
||||
},
|
||||
'/api/environments/:team_id/session_recordings/:id': largeRecordingMetaJson,
|
||||
'/api/environments/:team_id/session_recordings/:id/snapshots': (req, res, ctx) => {
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { IconInfo, IconPencil } from '@posthog/icons'
|
||||
import { IconPencil } from '@posthog/icons'
|
||||
import { useActions } from 'kea'
|
||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||
import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
|
||||
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||
import { toSentenceCase } from 'lib/utils'
|
||||
import { notebookPanelLogic } from 'scenes/notebooks/NotebookPanel/notebookPanelLogic'
|
||||
import {
|
||||
InspectorListItemAnnotationComment,
|
||||
InspectorListItemComment,
|
||||
InspectorListItemNotebookComment,
|
||||
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
|
||||
import { playerCommentModel } from 'scenes/session-recordings/player/commenting/playerCommentModel'
|
||||
import { RecordingAnnotationForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
|
||||
import { RecordingCommentForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
|
||||
import { TextContent } from 'lib/components/Cards/TextCard/TextCard'
|
||||
|
||||
export interface ItemCommentProps {
|
||||
item: InspectorListItemComment
|
||||
item: InspectorListItemComment | InspectorListItemNotebookComment
|
||||
}
|
||||
|
||||
function isInspectorListItemNotebookComment(x: ItemCommentProps['item']): x is InspectorListItemNotebookComment {
|
||||
return 'comment' in x.data
|
||||
}
|
||||
|
||||
function ItemNotebookComment({ item }: { item: InspectorListItemNotebookComment }): JSX.Element {
|
||||
@@ -30,7 +32,7 @@ function ItemNotebookComment({ item }: { item: InspectorListItemNotebookComment
|
||||
)
|
||||
}
|
||||
|
||||
function ItemAnnotationComment({ item }: { item: InspectorListItemAnnotationComment }): JSX.Element {
|
||||
function ItemComment({ item }: { item: InspectorListItemComment }): JSX.Element {
|
||||
// lazy but good enough check for markdown image urls
|
||||
// we don't want to render markdown in the list row if there's an image since it won't fit
|
||||
const hasMarkdownImage = (item.data.content ?? '').includes('![')
|
||||
@@ -61,16 +63,8 @@ function ItemAnnotationComment({ item }: { item: InspectorListItemAnnotationComm
|
||||
)
|
||||
}
|
||||
|
||||
function isInspectorListItemNotebookComment(x: ItemCommentProps['item']): x is InspectorListItemNotebookComment {
|
||||
return 'comment' in x.data
|
||||
}
|
||||
|
||||
export function ItemComment({ item }: ItemCommentProps): JSX.Element {
|
||||
return isInspectorListItemNotebookComment(item) ? (
|
||||
<ItemNotebookComment item={item} />
|
||||
) : (
|
||||
<ItemAnnotationComment item={item} />
|
||||
)
|
||||
export function ItemAnyComment({ item }: ItemCommentProps): JSX.Element {
|
||||
return isInspectorListItemNotebookComment(item) ? <ItemNotebookComment item={item} /> : <ItemComment item={item} />
|
||||
}
|
||||
|
||||
function ItemCommentNotebookDetail({ item }: { item: InspectorListItemNotebookComment }): JSX.Element {
|
||||
@@ -97,39 +91,31 @@ function ItemCommentNotebookDetail({ item }: { item: InspectorListItemNotebookCo
|
||||
)
|
||||
}
|
||||
|
||||
function ItemCommentAnnotationDetail({ item }: { item: InspectorListItemAnnotationComment }): JSX.Element {
|
||||
function ItemCommentDetail({ item }: { item: InspectorListItemComment }): JSX.Element {
|
||||
const { startCommenting } = useActions(playerCommentModel)
|
||||
return (
|
||||
<div data-attr="item-annotation-comment" className="font-light w-full flex flex-col gap-y-1">
|
||||
<div className="px-2 py-1 text-xs border-t w-full flex justify-between items-center">
|
||||
<Tooltip title="Annotations can be scoped to the project or organization, or to individual insights or dashboards. Project and organization scoped annotations are shown in the recording timeline.">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IconInfo className="text-muted text-xs" />
|
||||
Scope: {toSentenceCase(item.data.scope)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="px-2 py-1 text-xs border-t w-full flex justify-end items-center">
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
// relying on the click here to set the player timestamp
|
||||
// so this shouldn't swallow the click
|
||||
const annotationEditPayload: RecordingAnnotationForm = {
|
||||
annotationId: item.data.id,
|
||||
scope: item.data.scope,
|
||||
const commentEditPayload: RecordingCommentForm = {
|
||||
commentId: item.data.id,
|
||||
content: item.data.content ?? '',
|
||||
dateForTimestamp: item.data.date_marker,
|
||||
recordingId: item.data.recording_id ?? null,
|
||||
timeInRecording: null,
|
||||
dateForTimestamp: item.timestamp,
|
||||
recordingId: item.data.item_id ?? null,
|
||||
timestampInRecording: item.timeInRecording,
|
||||
}
|
||||
startCommenting(annotationEditPayload)
|
||||
startCommenting(commentEditPayload)
|
||||
})()
|
||||
}}
|
||||
size="xsmall"
|
||||
icon={<IconPencil />}
|
||||
>
|
||||
Edit annotation
|
||||
Edit
|
||||
</LemonButton>
|
||||
</div>
|
||||
|
||||
@@ -140,20 +126,15 @@ function ItemCommentAnnotationDetail({ item }: { item: InspectorListItemAnnotati
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProfilePicture
|
||||
user={item.data.creation_type === 'GIT' ? { first_name: 'GitHub automation' } : item.data.created_by}
|
||||
showName
|
||||
size="md"
|
||||
type={item.data.creation_type === 'GIT' ? 'bot' : 'person'}
|
||||
/>
|
||||
<ProfilePicture user={item.data.created_by} showName size="md" type="person" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemCommentDetail({ item }: ItemCommentProps): JSX.Element {
|
||||
export function ItemAnyCommentDetail({ item }: ItemCommentProps): JSX.Element {
|
||||
return isInspectorListItemNotebookComment(item) ? (
|
||||
<ItemCommentNotebookDetail item={item} />
|
||||
) : (
|
||||
<ItemCommentAnnotationDetail item={item} />
|
||||
<ItemCommentDetail item={item} />
|
||||
)
|
||||
}
|
||||
@@ -2,23 +2,23 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'
|
||||
import { now } from 'lib/dayjs'
|
||||
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
|
||||
import {
|
||||
ItemComment,
|
||||
ItemCommentDetail,
|
||||
ItemAnyComment,
|
||||
ItemAnyCommentDetail,
|
||||
ItemCommentProps,
|
||||
} from 'scenes/session-recordings/player/inspector/components/ItemComment'
|
||||
} from 'scenes/session-recordings/player/inspector/components/ItemAnyComment'
|
||||
import {
|
||||
InspectorListItemAnnotationComment,
|
||||
InspectorListItemComment,
|
||||
InspectorListItemNotebookComment,
|
||||
RecordingComment,
|
||||
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
|
||||
|
||||
import { mswDecorator } from '~/mocks/browser'
|
||||
import { AnnotationScope, AnnotationType } from '~/types'
|
||||
import { CommentType } from '~/types'
|
||||
|
||||
type Story = StoryObj<typeof ItemComment>
|
||||
const meta: Meta<typeof ItemComment> = {
|
||||
type Story = StoryObj<typeof ItemAnyComment>
|
||||
const meta: Meta<typeof ItemAnyComment> = {
|
||||
title: 'Components/PlayerInspector/ItemComment',
|
||||
component: ItemComment,
|
||||
component: ItemAnyComment,
|
||||
decorators: [
|
||||
mswDecorator({
|
||||
get: {},
|
||||
@@ -49,18 +49,18 @@ function makeNotebookItem(
|
||||
}
|
||||
}
|
||||
|
||||
function makeAnnotationItem(
|
||||
itemOverrides: Partial<InspectorListItemAnnotationComment> = {},
|
||||
dataOverrides: Partial<AnnotationType> = {}
|
||||
): InspectorListItemAnnotationComment {
|
||||
function makeCommentItem(
|
||||
itemOverrides: Partial<InspectorListItemComment> = {},
|
||||
dataOverrides: Partial<CommentType> = {}
|
||||
): InspectorListItemComment {
|
||||
return {
|
||||
data: {
|
||||
id: 0,
|
||||
created_at: now(),
|
||||
date_marker: now(),
|
||||
updated_at: now().toISOString(),
|
||||
scope: AnnotationScope.Project,
|
||||
id: '0',
|
||||
version: 0,
|
||||
created_at: now().toISOString(),
|
||||
scope: 'recording',
|
||||
content: '🪓😍🪓😍🪓😍🪓😍',
|
||||
item_context: {},
|
||||
created_by: {
|
||||
id: 1,
|
||||
uuid: '0196b443-26f4-0000-5d24-b982365fe43d',
|
||||
@@ -75,13 +75,13 @@ function makeAnnotationItem(
|
||||
timeInRecording: 0,
|
||||
timestamp: now(),
|
||||
type: 'comment',
|
||||
source: 'annotation',
|
||||
source: 'comment',
|
||||
search: '',
|
||||
...itemOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
const BasicTemplate: StoryFn<typeof ItemComment> = (props: Partial<ItemCommentProps>) => {
|
||||
const BasicTemplate: StoryFn<typeof ItemAnyComment> = (props: Partial<ItemCommentProps>) => {
|
||||
props.item = props.item || makeNotebookItem()
|
||||
|
||||
const propsToUse = props as ItemCommentProps
|
||||
@@ -89,14 +89,14 @@ const BasicTemplate: StoryFn<typeof ItemComment> = (props: Partial<ItemCommentPr
|
||||
return (
|
||||
<div className="flex flex-col gap-2 min-w-96">
|
||||
<h3>Collapsed</h3>
|
||||
<ItemComment {...propsToUse} />
|
||||
<ItemAnyComment {...propsToUse} />
|
||||
<LemonDivider />
|
||||
<h3>Expanded</h3>
|
||||
<ItemCommentDetail {...propsToUse} />
|
||||
<ItemAnyCommentDetail {...propsToUse} />
|
||||
<LemonDivider />
|
||||
<h3>Expanded with overflowing comment</h3>
|
||||
<div className="w-52">
|
||||
<ItemCommentDetail
|
||||
<ItemAnyCommentDetail
|
||||
{...propsToUse}
|
||||
item={
|
||||
{
|
||||
@@ -113,7 +113,7 @@ const BasicTemplate: StoryFn<typeof ItemComment> = (props: Partial<ItemCommentPr
|
||||
<LemonDivider />
|
||||
<h3>Collapsed with overflowing comment</h3>
|
||||
<div className="w-52">
|
||||
<ItemComment
|
||||
<ItemAnyComment
|
||||
{...propsToUse}
|
||||
item={
|
||||
{
|
||||
@@ -138,5 +138,5 @@ NotebookComment.args = {
|
||||
|
||||
export const AnnotationComment: Story = BasicTemplate.bind({})
|
||||
AnnotationComment.args = {
|
||||
item: makeAnnotationItem(),
|
||||
item: makeCommentItem(),
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||
import { ceilMsToClosestSecond } from 'lib/utils'
|
||||
import { FunctionComponent, isValidElement, useEffect, useRef } from 'react'
|
||||
import { ItemTimeDisplay } from 'scenes/session-recordings/components/ItemTimeDisplay'
|
||||
import { ItemComment, ItemCommentDetail } from 'scenes/session-recordings/player/inspector/components/ItemComment'
|
||||
import {
|
||||
ItemAnyComment,
|
||||
ItemAnyCommentDetail,
|
||||
} from 'scenes/session-recordings/player/inspector/components/ItemAnyComment'
|
||||
import { ItemInactivity } from 'scenes/session-recordings/player/inspector/components/ItemInactivity'
|
||||
import { ItemSummary } from 'scenes/session-recordings/player/inspector/components/ItemSummary'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
@@ -164,7 +167,7 @@ function RowItemTitle({
|
||||
) : item.type === 'doctor' ? (
|
||||
<ItemDoctor item={item} />
|
||||
) : item.type === 'comment' ? (
|
||||
<ItemComment item={item} />
|
||||
<ItemAnyComment item={item} />
|
||||
) : item.type === 'inspector-summary' ? (
|
||||
<ItemSummary item={item} />
|
||||
) : item.type === 'inactivity' ? (
|
||||
@@ -195,7 +198,7 @@ function RowItemDetail({
|
||||
'doctor' ? (
|
||||
<ItemDoctorDetail item={item} />
|
||||
) : item.type === 'comment' ? (
|
||||
<ItemCommentDetail item={item} />
|
||||
<ItemAnyCommentDetail item={item} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InspectorListItemComment,
|
||||
InspectorListItemDoctor,
|
||||
InspectorListItemEvent,
|
||||
InspectorListItemNotebookComment,
|
||||
InspectorListOfflineStatusChange,
|
||||
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
|
||||
|
||||
@@ -93,10 +94,10 @@ describe('filtering inspector list items', () => {
|
||||
{
|
||||
type: 'comment',
|
||||
source: 'notebook',
|
||||
} as InspectorListItemComment,
|
||||
} as InspectorListItemNotebookComment,
|
||||
{
|
||||
type: 'comment',
|
||||
source: 'annotation',
|
||||
source: 'comment',
|
||||
} as InspectorListItemComment,
|
||||
],
|
||||
miniFiltersByKey: { comment: { enabled } as unknown as SharedListMiniFilter },
|
||||
|
||||
@@ -27,6 +27,60 @@ describe('playerInspectorLogic', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
'api/projects/:team/comments': {
|
||||
results: [
|
||||
{
|
||||
id: '019838f3-1bab-0000-fce8-04be1d6b6fe3',
|
||||
created_by: {
|
||||
id: 1,
|
||||
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
|
||||
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
|
||||
first_name: 'fasda',
|
||||
last_name: '',
|
||||
email: 'paul@posthog.com',
|
||||
is_email_verified: false,
|
||||
hedgehog_config: null,
|
||||
role_at_organization: 'other',
|
||||
},
|
||||
deleted: false,
|
||||
content: '🥶',
|
||||
version: 0,
|
||||
created_at: '2025-07-23T20:21:53.197354Z',
|
||||
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
|
||||
item_context: {
|
||||
is_emoji: true,
|
||||
time_in_recording: '2025-07-23T19:37:25.284Z',
|
||||
},
|
||||
scope: 'recording',
|
||||
source_comment: null,
|
||||
},
|
||||
{
|
||||
id: '019838c9-d3bb-0000-dae0-18031d78ad67',
|
||||
created_by: {
|
||||
id: 1,
|
||||
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
|
||||
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
|
||||
first_name: 'fasda',
|
||||
last_name: '',
|
||||
email: 'paul@posthog.com',
|
||||
is_email_verified: false,
|
||||
hedgehog_config: null,
|
||||
role_at_organization: 'other',
|
||||
},
|
||||
deleted: false,
|
||||
content: '😏',
|
||||
version: 0,
|
||||
created_at: '2025-07-23T19:36:47.813482Z',
|
||||
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
|
||||
item_context: {
|
||||
is_emoji: true,
|
||||
time_in_recording: '2025-07-23T19:35:47.216Z',
|
||||
},
|
||||
scope: 'recording',
|
||||
source_comment: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
initKeaTests()
|
||||
@@ -42,10 +96,29 @@ describe('playerInspectorLogic', () => {
|
||||
describe('item comments', () => {
|
||||
it('does not load comments without prompting', async () => {
|
||||
await expectLogic(logic).toMatchValues({
|
||||
sessionNotebookComments: null,
|
||||
sessionComments: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads notebook comments from data logic', async () => {
|
||||
await expectLogic(dataLogic, () => {
|
||||
dataLogic.actions.maybeLoadRecordingMeta()
|
||||
}).toDispatchActions(['loadRecordingNotebookCommentsSuccess'])
|
||||
|
||||
await expectLogic(logic).toMatchValues({
|
||||
sessionNotebookComments: [
|
||||
{
|
||||
timeInRecording: 12,
|
||||
comment: 'The comment',
|
||||
notebookShortId: '12345',
|
||||
notebookTitle: 'The notebook',
|
||||
id: 'abcdefg',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('reads comments from data logic', async () => {
|
||||
await expectLogic(dataLogic, () => {
|
||||
dataLogic.actions.maybeLoadRecordingMeta()
|
||||
@@ -54,11 +127,54 @@ describe('playerInspectorLogic', () => {
|
||||
await expectLogic(logic).toMatchValues({
|
||||
sessionComments: [
|
||||
{
|
||||
timeInRecording: 12,
|
||||
comment: 'The comment',
|
||||
notebookShortId: '12345',
|
||||
notebookTitle: 'The notebook',
|
||||
id: 'abcdefg',
|
||||
content: '🥶',
|
||||
created_at: '2025-07-23T20:21:53.197354Z',
|
||||
created_by: {
|
||||
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
|
||||
email: 'paul@posthog.com',
|
||||
first_name: 'fasda',
|
||||
hedgehog_config: null,
|
||||
id: 1,
|
||||
is_email_verified: false,
|
||||
last_name: '',
|
||||
role_at_organization: 'other',
|
||||
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
|
||||
},
|
||||
deleted: false,
|
||||
id: '019838f3-1bab-0000-fce8-04be1d6b6fe3',
|
||||
item_context: {
|
||||
is_emoji: true,
|
||||
time_in_recording: '2025-07-23T19:37:25.284Z',
|
||||
},
|
||||
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
|
||||
scope: 'recording',
|
||||
source_comment: null,
|
||||
version: 0,
|
||||
},
|
||||
{
|
||||
content: '😏',
|
||||
created_at: '2025-07-23T19:36:47.813482Z',
|
||||
created_by: {
|
||||
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
|
||||
email: 'paul@posthog.com',
|
||||
first_name: 'fasda',
|
||||
hedgehog_config: null,
|
||||
id: 1,
|
||||
is_email_verified: false,
|
||||
last_name: '',
|
||||
role_at_organization: 'other',
|
||||
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
|
||||
},
|
||||
deleted: false,
|
||||
id: '019838c9-d3bb-0000-dae0-18031d78ad67',
|
||||
item_context: {
|
||||
is_emoji: true,
|
||||
time_in_recording: '2025-07-23T19:35:47.216Z',
|
||||
},
|
||||
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
|
||||
scope: 'recording',
|
||||
source_comment: null,
|
||||
version: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ import { sessionRecordingEventUsageLogic } from 'scenes/session-recordings/sessi
|
||||
import { RecordingsQuery } from '~/queries/schema/schema-general'
|
||||
import { getCoreFilterDefinition } from '~/taxonomy/helpers'
|
||||
import {
|
||||
AnnotationType,
|
||||
CommentType,
|
||||
MatchedRecordingEvent,
|
||||
PerformanceEvent,
|
||||
RecordingConsoleLogV2,
|
||||
@@ -110,14 +110,12 @@ export type InspectorListItemNotebookComment = InspectorListItemBase & {
|
||||
data: RecordingComment
|
||||
}
|
||||
|
||||
export type InspectorListItemAnnotationComment = InspectorListItemBase & {
|
||||
export type InspectorListItemComment = InspectorListItemBase & {
|
||||
type: 'comment'
|
||||
source: 'annotation'
|
||||
data: AnnotationType
|
||||
source: 'comment'
|
||||
data: CommentType
|
||||
}
|
||||
|
||||
export type InspectorListItemComment = InspectorListItemNotebookComment | InspectorListItemAnnotationComment
|
||||
|
||||
export type InspectorListItemConsole = InspectorListItemBase & {
|
||||
type: 'console'
|
||||
data: RecordingConsoleLogV2
|
||||
@@ -155,6 +153,7 @@ export type InspectorListItem =
|
||||
| InspectorListItemDoctor
|
||||
| InspectorListBrowserVisibility
|
||||
| InspectorListItemComment
|
||||
| InspectorListItemNotebookComment
|
||||
| InspectorListItemSummary
|
||||
| InspectorListItemInactivity
|
||||
|
||||
@@ -226,15 +225,25 @@ function getPayloadFor(customEvent: customEvent, tag: string): Record<string, an
|
||||
return customEvent.data.payload as Record<string, any>
|
||||
}
|
||||
|
||||
function commentTimestamp(
|
||||
comment: RecordingComment,
|
||||
function notebookCommentTimestamp(
|
||||
timeInRecording: number,
|
||||
start: Dayjs | null
|
||||
): {
|
||||
timeInRecording: number
|
||||
timestamp: dayjs.Dayjs | undefined
|
||||
} {
|
||||
const timestamp = start?.add(comment.timeInRecording, 'ms')
|
||||
return { timestamp, timeInRecording: comment.timeInRecording }
|
||||
const timestamp = start?.add(timeInRecording, 'ms')
|
||||
return { timestamp, timeInRecording }
|
||||
}
|
||||
|
||||
function commentTimestamp(
|
||||
commentTime: Dayjs,
|
||||
start: Dayjs | null
|
||||
): {
|
||||
timeInRecording: number
|
||||
timestamp: dayjs.Dayjs | undefined
|
||||
} {
|
||||
return { timestamp: commentTime, timeInRecording: commentTime.valueOf() - (start?.valueOf() ?? 0) }
|
||||
}
|
||||
|
||||
export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
@@ -264,12 +273,12 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
'start',
|
||||
'end',
|
||||
'durationMs',
|
||||
'sessionNotebookComments',
|
||||
'sessionComments',
|
||||
'sessionCommentsLoading',
|
||||
'windowIdForTimestamp',
|
||||
'sessionPlayerMetaData',
|
||||
'segments',
|
||||
'sessionAnnotations',
|
||||
'annotationsLoading',
|
||||
],
|
||||
sessionRecordingPlayerLogic(props),
|
||||
['currentPlayerTime'],
|
||||
@@ -558,11 +567,16 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
],
|
||||
|
||||
notebookCommentItems: [
|
||||
(s) => [s.sessionComments, s.windowIdForTimestamp, s.windowNumberForID, s.start],
|
||||
(sessionComments, windowIdForTimestamp, windowNumberForID, start): InspectorListItemNotebookComment[] => {
|
||||
(s) => [s.sessionNotebookComments, s.windowIdForTimestamp, s.windowNumberForID, s.start],
|
||||
(
|
||||
sessionNotebookComments,
|
||||
windowIdForTimestamp,
|
||||
windowNumberForID,
|
||||
start
|
||||
): InspectorListItemNotebookComment[] => {
|
||||
const items: InspectorListItemNotebookComment[] = []
|
||||
for (const comment of sessionComments || []) {
|
||||
const { timestamp, timeInRecording } = commentTimestamp(comment, start)
|
||||
for (const comment of sessionNotebookComments || []) {
|
||||
const { timestamp, timeInRecording } = notebookCommentTimestamp(comment.timeInRecording, start)
|
||||
if (timestamp) {
|
||||
items.push({
|
||||
highlightColor: 'primary',
|
||||
@@ -581,20 +595,33 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
},
|
||||
],
|
||||
|
||||
annotationItems: [
|
||||
(s) => [s.sessionAnnotations, s.windowIdForTimestamp, s.windowNumberForID],
|
||||
(sessionAnnotations, windowIdForTimestamp, windowNumberForID): InspectorListItemAnnotationComment[] => {
|
||||
const items: InspectorListItemAnnotationComment[] = []
|
||||
for (const annotation of sessionAnnotations || []) {
|
||||
const windowId = windowIdForTimestamp(annotation.timestamp.valueOf())
|
||||
items.push({
|
||||
...annotation,
|
||||
type: 'comment',
|
||||
source: 'annotation',
|
||||
highlightColor: 'primary',
|
||||
windowId: windowId,
|
||||
windowNumber: windowNumberForID(windowId),
|
||||
})
|
||||
commentItems: [
|
||||
(s) => [s.sessionComments, s.windowIdForTimestamp, s.windowNumberForID, s.start],
|
||||
(sessionComments, windowIdForTimestamp, windowNumberForID, start): InspectorListItemComment[] => {
|
||||
const items: InspectorListItemComment[] = []
|
||||
for (const comment of sessionComments || []) {
|
||||
if (!comment.item_context.time_in_recording) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { timestamp, timeInRecording } = commentTimestamp(
|
||||
dayjs(comment.item_context.time_in_recording),
|
||||
start
|
||||
)
|
||||
if (timestamp) {
|
||||
const item: InspectorListItemComment = {
|
||||
timestamp,
|
||||
timeInRecording,
|
||||
type: 'comment',
|
||||
source: 'comment',
|
||||
highlightColor: 'primary',
|
||||
windowId: windowIdForTimestamp(timestamp.valueOf()),
|
||||
windowNumber: windowNumberForID(windowIdForTimestamp(timestamp.valueOf())),
|
||||
data: comment,
|
||||
search: comment.content,
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
},
|
||||
@@ -686,7 +713,7 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
s.matchingEventUUIDs,
|
||||
s.windowNumberForID,
|
||||
s.allContextItems,
|
||||
s.annotationItems,
|
||||
s.commentItems,
|
||||
s.notebookCommentItems,
|
||||
],
|
||||
(
|
||||
@@ -697,7 +724,7 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
matchingEventUUIDs,
|
||||
windowNumberForID,
|
||||
allContextItems,
|
||||
annotationItems,
|
||||
commentItems,
|
||||
notebookCommentItems
|
||||
): InspectorListItem[] => {
|
||||
// NOTE: Possible perf improvement here would be to have a selector to parse the items
|
||||
@@ -786,8 +813,8 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
items.push(event)
|
||||
}
|
||||
|
||||
for (const annotation of annotationItems || []) {
|
||||
items.push(annotation)
|
||||
for (const comment of commentItems || []) {
|
||||
items.push(comment)
|
||||
}
|
||||
|
||||
for (const notebookComment of notebookCommentItems || []) {
|
||||
@@ -928,8 +955,8 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
s.consoleLogs,
|
||||
s.allPerformanceEvents,
|
||||
s.doctorEvents,
|
||||
s.sessionAnnotations,
|
||||
s.annotationsLoading,
|
||||
s.sessionComments,
|
||||
s.sessionCommentsLoading,
|
||||
],
|
||||
(
|
||||
sessionEventsDataLoading,
|
||||
@@ -939,8 +966,8 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
logs,
|
||||
performanceEvents,
|
||||
doctorEvents,
|
||||
sessionAnnotations,
|
||||
annotationsLoading
|
||||
sessionComments,
|
||||
sessionCommentsLoading
|
||||
): Record<FilterableInspectorListItemTypes, 'loading' | 'ready' | 'empty'> => {
|
||||
const dataForEventsState = sessionEventsDataLoading ? 'loading' : events?.length ? 'ready' : 'empty'
|
||||
const dataForConsoleState =
|
||||
@@ -963,9 +990,9 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
|
||||
: 'empty'
|
||||
|
||||
// TODO include notebook comments here?
|
||||
const dataForCommentState = annotationsLoading
|
||||
const dataForCommentState = sessionCommentsLoading
|
||||
? 'loading'
|
||||
: sessionAnnotations?.length
|
||||
: sessionComments?.length
|
||||
? 'ready'
|
||||
: 'empty'
|
||||
|
||||
|
||||
@@ -8,10 +8,7 @@ import { Dayjs, dayjs } from 'lib/dayjs'
|
||||
import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic'
|
||||
import { chainToElements } from 'lib/utils/elements-chain'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
InspectorListItemAnnotationComment,
|
||||
RecordingComment,
|
||||
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
|
||||
import { RecordingComment } from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
|
||||
import {
|
||||
parseEncodedSnapshots,
|
||||
processAllSnapshots,
|
||||
@@ -23,7 +20,7 @@ import { teamLogic } from 'scenes/teamLogic'
|
||||
import { annotationsModel } from '~/models/annotationsModel'
|
||||
import { hogql, HogQLQueryString } from '~/queries/utils'
|
||||
import {
|
||||
AnnotationScope,
|
||||
CommentType,
|
||||
RecordingEventsFilters,
|
||||
RecordingEventType,
|
||||
RecordingSegment,
|
||||
@@ -43,6 +40,7 @@ import { sessionRecordingEventUsageLogic } from '../sessionRecordingEventUsageLo
|
||||
import type { sessionRecordingDataLogicType } from './sessionRecordingDataLogicType'
|
||||
import { getHrefFromSnapshot, ViewportResolution } from './snapshot-processing/patch-meta-event'
|
||||
import { createSegments, mapSnapshotsToWindowId } from './utils/segmenter'
|
||||
import { playerCommentModel } from 'scenes/session-recordings/player/commenting/playerCommentModel'
|
||||
|
||||
const IS_TEST_MODE = process.env.NODE_ENV === 'test'
|
||||
const TWENTY_FOUR_HOURS_IN_MS = 24 * 60 * 60 * 1000 // +- before and after start and end of a recording to query for session linked events.
|
||||
@@ -80,8 +78,9 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
|
||||
actions({
|
||||
setFilters: (filters: Partial<RecordingEventsFilters>) => ({ filters }),
|
||||
loadRecordingMeta: true,
|
||||
loadRecordingComments: true,
|
||||
maybeLoadRecordingMeta: true,
|
||||
loadRecordingComments: true,
|
||||
loadRecordingNotebookComments: true,
|
||||
loadSnapshots: true,
|
||||
loadSnapshotSources: (breakpointLength?: number) => ({ breakpointLength }),
|
||||
loadNextSnapshotSource: true,
|
||||
@@ -143,6 +142,19 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
|
||||
loaders(({ values, props, cache }) => ({
|
||||
sessionComments: {
|
||||
loadRecordingComments: async (_, breakpoint) => {
|
||||
const empty: CommentType[] = []
|
||||
if (!props.sessionRecordingId) {
|
||||
return empty
|
||||
}
|
||||
|
||||
const response = await api.comments.list({ item_id: props.sessionRecordingId })
|
||||
breakpoint()
|
||||
|
||||
return response.results || empty
|
||||
},
|
||||
},
|
||||
sessionNotebookComments: {
|
||||
loadRecordingNotebookComments: async (_, breakpoint) => {
|
||||
const empty: RecordingComment[] = []
|
||||
if (!props.sessionRecordingId) {
|
||||
return empty
|
||||
@@ -255,7 +267,7 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
|
||||
(a, b) => a.timestamp - b.timestamp
|
||||
)
|
||||
// we store the data in the cache because we want to avoid copying this data as much as possible
|
||||
// and kea's immutability means we were copying all of the data on every snapshot call
|
||||
// and kea's immutability means we were copying all the data on every snapshot call
|
||||
cache.snapshotsBySource = cache.snapshotsBySource || {}
|
||||
// it doesn't matter which source we use as the key, since we combine the snapshots anyway
|
||||
cache.snapshotsBySource[keyForSource(sources[0])] = { snapshots: parsedSnapshots }
|
||||
@@ -429,6 +441,11 @@ AND properties.$lib != 'web'`
|
||||
],
|
||||
})),
|
||||
listeners(({ values, actions, cache, props }) => ({
|
||||
[playerCommentModel.actionTypes.commentEdited]: ({ recordingId }) => {
|
||||
if (props.sessionRecordingId === recordingId) {
|
||||
actions.loadRecordingComments()
|
||||
}
|
||||
},
|
||||
loadSnapshots: () => {
|
||||
// This kicks off the loading chain
|
||||
if (!values.snapshotSourcesLoading) {
|
||||
@@ -442,6 +459,9 @@ AND properties.$lib != 'web'`
|
||||
if (!values.sessionCommentsLoading) {
|
||||
actions.loadRecordingComments()
|
||||
}
|
||||
if (!values.sessionNotebookCommentsLoading) {
|
||||
actions.loadRecordingNotebookComments()
|
||||
}
|
||||
},
|
||||
loadSnapshotSources: () => {
|
||||
// We only load events once we actually start loading the recording
|
||||
@@ -492,7 +512,7 @@ AND properties.$lib != 'web'`
|
||||
},
|
||||
|
||||
loadNextSnapshotSource: () => {
|
||||
// yes this is ugly duplication but we're going to deprecate v1 and I want it to be clear which is which
|
||||
// yes this is ugly duplication, but we're going to deprecate v1 and I want it to be clear which is which
|
||||
if (values.snapshotSources?.some((s) => s.source === SnapshotSourceType.blob_v2)) {
|
||||
const nextSourcesToLoad =
|
||||
values.snapshotSources?.filter((s) => {
|
||||
@@ -591,42 +611,6 @@ AND properties.$lib != 'web'`
|
||||
},
|
||||
})),
|
||||
selectors(({ cache }) => ({
|
||||
sessionAnnotations: [
|
||||
(s) => [s.annotations, s.start, s.end],
|
||||
(annotations, start, end): InspectorListItemAnnotationComment[] => {
|
||||
const allowedScopes = [AnnotationScope.Recording, AnnotationScope.Project, AnnotationScope.Organization]
|
||||
const startValue = start?.valueOf()
|
||||
const endValue = end?.valueOf()
|
||||
|
||||
const result: InspectorListItemAnnotationComment[] = []
|
||||
for (const annotation of annotations) {
|
||||
if (!allowedScopes.includes(annotation.scope)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!annotation.date_marker || !startValue || !endValue || !annotation.content) {
|
||||
continue
|
||||
}
|
||||
|
||||
const annotationTime = dayjs(annotation.date_marker).valueOf()
|
||||
if (annotationTime < startValue || annotationTime > endValue) {
|
||||
continue
|
||||
}
|
||||
|
||||
result.push({
|
||||
type: 'comment',
|
||||
source: 'annotation',
|
||||
data: annotation,
|
||||
timestamp: dayjs(annotation.date_marker),
|
||||
timeInRecording: annotation.date_marker.valueOf() - startValue,
|
||||
search: annotation.content,
|
||||
highlightColor: 'primary',
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
],
|
||||
webVitalsEvents: [
|
||||
(s) => [s.sessionEventsData],
|
||||
(sessionEventsData): RecordingEventType[] =>
|
||||
@@ -770,14 +754,30 @@ AND properties.$lib != 'web'`
|
||||
snapshotsLoaded: [(s) => [s.snapshotSources], (snapshotSources): boolean => !!snapshotSources],
|
||||
|
||||
fullyLoaded: [
|
||||
(s) => [s.snapshots, s.sessionPlayerMetaDataLoading, s.snapshotsLoading, s.sessionEventsDataLoading],
|
||||
(snapshots, sessionPlayerMetaDataLoading, snapshotsLoading, sessionEventsDataLoading): boolean => {
|
||||
(s) => [
|
||||
s.snapshots,
|
||||
s.sessionPlayerMetaDataLoading,
|
||||
s.snapshotsLoading,
|
||||
s.sessionEventsDataLoading,
|
||||
s.sessionCommentsLoading,
|
||||
s.sessionNotebookCommentsLoading,
|
||||
],
|
||||
(
|
||||
snapshots,
|
||||
sessionPlayerMetaDataLoading,
|
||||
snapshotsLoading,
|
||||
sessionEventsDataLoading,
|
||||
sessionCommentsLoading,
|
||||
sessionNotebookCommentsLoading
|
||||
): boolean => {
|
||||
// TODO: Do a proper check for all sources having been loaded
|
||||
return (
|
||||
!!snapshots?.length &&
|
||||
!sessionPlayerMetaDataLoading &&
|
||||
!snapshotsLoading &&
|
||||
!sessionEventsDataLoading
|
||||
!sessionEventsDataLoading &&
|
||||
!sessionCommentsLoading &&
|
||||
!sessionNotebookCommentsLoading
|
||||
)
|
||||
},
|
||||
],
|
||||
|
||||
@@ -817,9 +817,9 @@ export const sessionRecordingPlayerLogic = kea<sessionRecordingPlayerLogicType>(
|
||||
],
|
||||
}),
|
||||
listeners(({ props, values, actions, cache }) => ({
|
||||
[playerCommentModel.actionTypes.startCommenting]: async ({ annotation }) => {
|
||||
[playerCommentModel.actionTypes.startCommenting]: async ({ comment }) => {
|
||||
actions.setIsCommenting(true)
|
||||
if (annotation) {
|
||||
if (comment) {
|
||||
// and we need a short wait until the logic is mounted after calling setIsCommenting
|
||||
const waitForLogic = async (): Promise<BuiltLogic<playerCommentOverlayLogicType> | null> => {
|
||||
for (let attempts = 0; attempts < 5; attempts++) {
|
||||
@@ -835,9 +835,9 @@ export const sessionRecordingPlayerLogic = kea<sessionRecordingPlayerLogicType>(
|
||||
const theMountedLogic = await waitForLogic()
|
||||
|
||||
if (theMountedLogic) {
|
||||
theMountedLogic.actions.editAnnotation(annotation)
|
||||
theMountedLogic.actions.editComment(comment)
|
||||
} else {
|
||||
lemonToast.error('Could not start editing annotation 😓, please refresh the page and try again.')
|
||||
lemonToast.error('Could not start editing that comment 😓, please refresh the page and try again.')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2322,7 +2322,6 @@ export enum AnnotationScope {
|
||||
Dashboard = 'dashboard',
|
||||
Project = 'project',
|
||||
Organization = 'organization',
|
||||
Recording = 'recording',
|
||||
}
|
||||
|
||||
export interface RawAnnotationType {
|
||||
@@ -2341,9 +2340,6 @@ export interface RawAnnotationType {
|
||||
dashboard_name?: DashboardBasicType['name'] | null
|
||||
deleted?: boolean
|
||||
creation_type?: 'USR' | 'GIT'
|
||||
recording_id?: string | null
|
||||
// convenience flag that indicates the content _should_ be a single emoji
|
||||
is_emoji?: boolean
|
||||
}
|
||||
|
||||
export interface AnnotationType extends Omit<RawAnnotationType, 'created_at' | 'date_marker'> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import emoji
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
@@ -35,8 +34,6 @@ class AnnotationSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"deleted",
|
||||
"scope",
|
||||
"recording_id",
|
||||
"is_emoji",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@@ -54,15 +51,10 @@ class AnnotationSerializer(serializers.ModelSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
is_emoji = attrs.get("is_emoji", False)
|
||||
content = attrs.get("content", "")
|
||||
scope = attrs.get("scope", None)
|
||||
|
||||
if is_emoji and content:
|
||||
# Check if content is an emoji
|
||||
if not emoji.is_emoji(content):
|
||||
raise serializers.ValidationError("When is_emoji is True, content must be a single emoji")
|
||||
elif is_emoji and not content:
|
||||
raise serializers.ValidationError("When is_emoji is True, content cannot be empty")
|
||||
if scope == Annotation.Scope.RECORDING.value:
|
||||
raise serializers.ValidationError("Recording scope is deprecated")
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@@ -260,8 +260,6 @@
|
||||
"posthog_annotation"."creation_type",
|
||||
"posthog_annotation"."date_marker",
|
||||
"posthog_annotation"."deleted",
|
||||
"posthog_annotation"."recording_id",
|
||||
"posthog_annotation"."is_emoji",
|
||||
"posthog_annotation"."apply_all",
|
||||
"posthog_dashboarditem"."id",
|
||||
"posthog_dashboarditem"."name",
|
||||
@@ -614,8 +612,6 @@
|
||||
"posthog_annotation"."creation_type",
|
||||
"posthog_annotation"."date_marker",
|
||||
"posthog_annotation"."deleted",
|
||||
"posthog_annotation"."recording_id",
|
||||
"posthog_annotation"."is_emoji",
|
||||
"posthog_annotation"."apply_all",
|
||||
"posthog_dashboarditem"."id",
|
||||
"posthog_dashboarditem"."name",
|
||||
|
||||
@@ -307,77 +307,6 @@ class TestAnnotation(APIBaseTest, QueryMatchingTest):
|
||||
else:
|
||||
assert len(results) == 0
|
||||
|
||||
def test_filter_annotations_for_session_replay_scenario(self) -> None:
|
||||
"""Test a realistic session replay scenario with multiple annotations and scopes."""
|
||||
|
||||
# Session replay scenario: 1-hour recording from 10:00 to 11:00
|
||||
session_start = datetime(2023, 1, 1, 10, 0, 0, tzinfo=ZoneInfo("UTC"))
|
||||
session_end = datetime(2023, 1, 1, 11, 0, 0, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
# Create annotations at different times and scopes
|
||||
annotations_data = [
|
||||
# Inside session time range
|
||||
(session_start + timedelta(minutes=15), Annotation.Scope.PROJECT, "Project annotation at 15min"),
|
||||
(session_start + timedelta(minutes=30), Annotation.Scope.ORGANIZATION, "Org annotation at 30min"),
|
||||
(session_start + timedelta(minutes=45), Annotation.Scope.PROJECT, "Project annotation at 45min"),
|
||||
# Outside session time range (should not appear)
|
||||
(session_start - timedelta(minutes=30), Annotation.Scope.PROJECT, "Before session"),
|
||||
(session_end + timedelta(minutes=30), Annotation.Scope.ORGANIZATION, "After session"),
|
||||
]
|
||||
|
||||
for date_marker, scope, content in annotations_data:
|
||||
Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content=content,
|
||||
scope=scope,
|
||||
date_marker=date_marker,
|
||||
)
|
||||
|
||||
# Test: Get all annotations within the session time range
|
||||
response = self.client.get(
|
||||
f"/api/projects/{self.team.id}/annotations/",
|
||||
{
|
||||
"date_from": session_start.isoformat(),
|
||||
"date_to": session_end.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should get 3 annotations (the ones within the time range)
|
||||
assert len(results) == 3
|
||||
|
||||
# Verify they're ordered by date_marker (newest first based on existing ordering)
|
||||
contents = [r["content"] for r in results]
|
||||
assert "Project annotation at 15min" in contents
|
||||
assert "Org annotation at 30min" in contents
|
||||
assert "Project annotation at 45min" in contents
|
||||
assert "Before session" not in contents
|
||||
assert "After session" not in contents
|
||||
|
||||
# Test: Filter by specific scope within time range
|
||||
response_project_only = self.client.get(
|
||||
f"/api/projects/{self.team.id}/annotations/",
|
||||
{
|
||||
"date_from": session_start.isoformat(),
|
||||
"date_to": session_end.isoformat(),
|
||||
"scope": Annotation.Scope.PROJECT,
|
||||
},
|
||||
)
|
||||
|
||||
assert response_project_only.status_code == 200
|
||||
project_results = response_project_only.json()["results"]
|
||||
|
||||
# Should get 2 project-scoped annotations
|
||||
assert len(project_results) == 2
|
||||
project_contents = [r["content"] for r in project_results]
|
||||
assert "Project annotation at 15min" in project_contents
|
||||
assert "Project annotation at 45min" in project_contents
|
||||
assert "Org annotation at 30min" not in project_contents
|
||||
|
||||
def test_filter_annotations_400_for_invalid_scope(self) -> None:
|
||||
response = self.client.get(
|
||||
f"/api/projects/{self.team.id}/annotations/",
|
||||
@@ -386,6 +315,14 @@ class TestAnnotation(APIBaseTest, QueryMatchingTest):
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid scope: invalid_scope"
|
||||
|
||||
def test_create_annotations_400_for_recording_scope(self) -> None:
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/annotations/",
|
||||
{"scope": "recording"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Recording scope is deprecated"
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("invalid_date", "2024-01-01T11:00:00Z", "date_from must be a valid ISO 8601 date"),
|
||||
@@ -402,203 +339,3 @@ class TestAnnotation(APIBaseTest, QueryMatchingTest):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == f"Invalid date range: {error_message}"
|
||||
|
||||
def test_filter_annotations_by_specific_recording(self) -> None:
|
||||
annotation = Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="Test annotation",
|
||||
scope=Annotation.Scope.RECORDING,
|
||||
recording_id="123e4567-e89b-12d3-a456-426614174000",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/projects/{self.team.id}/annotations/",
|
||||
{"recording": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["results"]) == 1
|
||||
assert response.json()["results"][0]["id"] == annotation.id
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("true", True), # Filter for emoji annotations
|
||||
("false", False), # Filter for non-emoji annotations
|
||||
("1", True), # Alternative true value
|
||||
("0", False), # Alternative false value
|
||||
]
|
||||
)
|
||||
def test_filter_annotations_by_is_emoji(self, is_emoji_param: str, should_return_emoji: bool) -> None:
|
||||
"""Test filtering annotations by is_emoji parameter."""
|
||||
Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="😊",
|
||||
is_emoji=True,
|
||||
scope=Annotation.Scope.DASHBOARD,
|
||||
recording_id="123e4567-e89b-12d3-a456-426614174000",
|
||||
)
|
||||
|
||||
emoji_annotation = Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="😊",
|
||||
is_emoji=True,
|
||||
scope=Annotation.Scope.RECORDING,
|
||||
recording_id="123e4567-e89b-12d3-a456-426614174000",
|
||||
)
|
||||
|
||||
# Create non-emoji annotation
|
||||
text_annotation = Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="Text annotation",
|
||||
is_emoji=False,
|
||||
scope=Annotation.Scope.RECORDING,
|
||||
recording_id="123e4567-e89b-12d3-a456-426614174000",
|
||||
)
|
||||
|
||||
# Test filtering by is_emoji parameter
|
||||
response = self.client.get(
|
||||
f"/api/projects/{self.team.id}/annotations/",
|
||||
{"is_emoji": is_emoji_param, "scope": "recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
|
||||
if should_return_emoji:
|
||||
assert results[0]["id"] == emoji_annotation.id
|
||||
assert results[0]["content"] == "😊"
|
||||
assert results[0]["is_emoji"] is True
|
||||
else:
|
||||
assert results[0]["id"] == text_annotation.id
|
||||
assert results[0]["content"] == "Text annotation"
|
||||
assert results[0]["is_emoji"] is False
|
||||
|
||||
def test_filter_annotations_by_is_emoji_combined_with_other_filters(self) -> None:
|
||||
"""Test combining is_emoji filter with scope and recording filters."""
|
||||
# Create emoji annotation for recording - this should be returned
|
||||
emoji_recording = Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="🚀",
|
||||
is_emoji=True,
|
||||
scope=Annotation.Scope.RECORDING,
|
||||
recording_id="123e4567-e89b-12d3-a456-426614174000",
|
||||
)
|
||||
|
||||
# Create emoji annotation for project (different scope) - should not be returned
|
||||
Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="⭐",
|
||||
is_emoji=True,
|
||||
scope=Annotation.Scope.PROJECT,
|
||||
)
|
||||
|
||||
# Create non-emoji annotation for recording - should not be returned
|
||||
Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="Text note",
|
||||
is_emoji=False,
|
||||
scope=Annotation.Scope.RECORDING,
|
||||
recording_id="123e4567-e89b-12d3-a456-426614174000",
|
||||
)
|
||||
|
||||
# Test: Filter for emoji annotations on recordings only
|
||||
response = self.client.get(
|
||||
f"/api/projects/{self.team.id}/annotations/",
|
||||
{
|
||||
"is_emoji": "true",
|
||||
"scope": "recording",
|
||||
"recording": "123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == emoji_recording.id
|
||||
assert results[0]["content"] == "🚀"
|
||||
assert results[0]["is_emoji"] is True
|
||||
assert results[0]["scope"] == "recording"
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("Standard emoji", "😊", True, None),
|
||||
("Another emoji", "🚀", True, None),
|
||||
("Single letter", "a", False, "When is_emoji is True, content must be a single emoji"),
|
||||
("Single digit", "1", False, "When is_emoji is True, content must be a single emoji"),
|
||||
("Single symbol", "!", False, "When is_emoji is True, content must be a single emoji"),
|
||||
("Empty string", "", False, "When is_emoji is True, content cannot be empty"),
|
||||
("Multiple characters", "ab", False, "When is_emoji is True, content must be a single emoji"),
|
||||
("Multiple emojis", "😊😊", False, "When is_emoji is True, content must be a single emoji"),
|
||||
("No content", None, False, "When is_emoji is True, content cannot be empty"),
|
||||
]
|
||||
)
|
||||
def test_create_annotation_emoji_validation(
|
||||
self, _name: str, content: Optional[str], should_succeed: bool, error: str
|
||||
) -> None:
|
||||
"""Test emoji validation when creating annotations with is_emoji=True."""
|
||||
data = {
|
||||
"is_emoji": True,
|
||||
"scope": "project",
|
||||
"date_marker": "2020-01-01T00:00:00.000000Z",
|
||||
}
|
||||
if content is not None:
|
||||
data["content"] = content
|
||||
|
||||
response = self.client.post(f"/api/projects/{self.team.id}/annotations/", data)
|
||||
|
||||
if should_succeed:
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
instance = Annotation.objects.get(pk=response.json()["id"])
|
||||
assert instance.content == content
|
||||
assert instance.is_emoji is True
|
||||
else:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["detail"] == error
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("Valid emoji", "🚀", True, None),
|
||||
("Invalid for emoji", "multiple chars", False, "When is_emoji is True, content must be a single emoji"),
|
||||
("Empty string", "", False, "When is_emoji is True, content cannot be empty"),
|
||||
("Single symbol", "!", False, "When is_emoji is True, content must be a single emoji"),
|
||||
]
|
||||
)
|
||||
def test_update_annotation_emoji_validation(
|
||||
self, _name: str, content: str, should_succeed: bool, error: str | None
|
||||
) -> None:
|
||||
"""Test emoji validation when updating annotations with is_emoji=True."""
|
||||
annotation = Annotation.objects.create(
|
||||
organization=self.organization,
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
content="old content",
|
||||
is_emoji=False,
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/projects/{self.team.id}/annotations/{annotation.pk}/",
|
||||
{"content": content, "is_emoji": True},
|
||||
)
|
||||
|
||||
if should_succeed:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
annotation.refresh_from_db()
|
||||
assert annotation.content == content
|
||||
assert annotation.is_emoji is True
|
||||
else:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["detail"] == error
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 4.2.22 on 2025-07-23 22:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
# Bulk update every CHUNK_SIZE items
|
||||
CHUNK_SIZE = 500
|
||||
|
||||
|
||||
def migrate_recording_annotations(apps, schema_editor):
|
||||
Annotation = apps.get_model("posthog", "Annotation")
|
||||
Comment = apps.get_model("posthog", "Comment")
|
||||
|
||||
recording_annotations = Annotation.objects.filter(scope="recording", deleted=False)
|
||||
|
||||
comment_chunk = []
|
||||
annotation_chunk = []
|
||||
for annotation in recording_annotations.iterator(chunk_size=CHUNK_SIZE):
|
||||
# technically date_marker can be null, but a recording comment without one is invalid
|
||||
if annotation.date_marker:
|
||||
comment_chunk.append(
|
||||
Comment(
|
||||
content=annotation.content,
|
||||
scope="recording",
|
||||
item_id=annotation.recording_id,
|
||||
item_context={
|
||||
"is_emoji": annotation.is_emoji,
|
||||
"time_in_recording": annotation.date_marker.isoformat(),
|
||||
"migrated_from_annotation": annotation.id,
|
||||
},
|
||||
team_id=annotation.team_id,
|
||||
created_by=annotation.created_by,
|
||||
created_at=annotation.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
annotation.deleted = True
|
||||
annotation_chunk.append(annotation)
|
||||
|
||||
# Bulk update every CHUNK_SIZE items
|
||||
if len(comment_chunk) == CHUNK_SIZE or len(annotation_chunk) == CHUNK_SIZE:
|
||||
Comment.objects.bulk_create(comment_chunk)
|
||||
Annotation.objects.bulk_update(annotation_chunk, fields=["deleted"])
|
||||
comment_chunk = []
|
||||
annotation_chunk = []
|
||||
|
||||
if comment_chunk: # Handle remaining items if length is less than CHUNK_SIZE
|
||||
Comment.objects.bulk_create(comment_chunk)
|
||||
Annotation.objects.bulk_update(annotation_chunk, fields=["deleted"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("posthog", "0802_messagecategory_category_type_and_more_fix"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_recording_annotations, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
0802_messagecategory_category_type_and_more_fix
|
||||
0803_move_recording_annotation_to_comments
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django_deprecate_fields import deprecate_field
|
||||
|
||||
|
||||
class Annotation(models.Model):
|
||||
@@ -28,14 +29,13 @@ class Annotation(models.Model):
|
||||
creation_type = models.CharField(max_length=3, choices=CreationType.choices, default=CreationType.USER)
|
||||
date_marker = models.DateTimeField(null=True, blank=True)
|
||||
deleted = models.BooleanField(default=False)
|
||||
# we don't want a foreign key, since not all recordings will be in postgres
|
||||
recording_id = models.UUIDField(null=True, blank=True)
|
||||
|
||||
# convenience so that we can load just emoji annotations, without checking the content
|
||||
is_emoji = models.BooleanField(default=False, null=True, blank=True)
|
||||
|
||||
# DEPRECATED: replaced by scope
|
||||
apply_all = models.BooleanField(null=True)
|
||||
# DEPRECATED: moved to the comment model
|
||||
recording_id = deprecate_field(models.UUIDField(null=True, blank=True))
|
||||
# DEPRECATED: moved to the comment model
|
||||
is_emoji = deprecate_field(models.BooleanField(default=False, null=True, blank=True))
|
||||
|
||||
@property
|
||||
def insight_short_id(self) -> Optional[str]:
|
||||
|
||||