diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml index 8621b30cb3..abee3fdafb 100644 --- a/.github/workflows/cypress-tests.yml +++ b/.github/workflows/cypress-tests.yml @@ -37,10 +37,6 @@ jobs: - run: yarn install --frozen-lockfile - run: make --version - run: make -j e2e-build - - uses: actions/cache@v3 - with: - path: bin/pyroscope - key: ${{ runner.os }}-pyroscope - name: Cypress run uses: cypress-io/github-action@v4 with: diff --git a/cypress/integration/webapp/annotations.ts b/cypress/integration/webapp/annotations.ts new file mode 100644 index 0000000000..1c4db7b0e2 --- /dev/null +++ b/cypress/integration/webapp/annotations.ts @@ -0,0 +1,51 @@ +describe('Annotations', () => { + it('add annotation flow works as expected', () => { + const basePath = Cypress.env('basePath') || ''; + cy.intercept(`${basePath}**/labels*`).as('labels'); + cy.intercept(`${basePath}**/label-values*`, { + fixture: 'appNames.json', + }).as('labelValues'); + cy.intercept('**/render*', { + fixture: 'render.json', + }).as('render'); + + cy.visit('/'); + + cy.wait('@labels'); + cy.wait('@labelValues'); + cy.wait('@render'); + + cy.get('canvas.flot-overlay').click(); + + cy.get('li[role=menuitem]').contains('Add annotation').click(); + + const content = 'test'; + let time; + + cy.get('form#annotation-form') + .findByTestId('annotation_timestamp_input') + .invoke('val') + .then((sometext) => (time = sometext)); + + cy.get('form#annotation-form') + .findByTestId('annotation_content_input') + .type(content); + + cy.get('button[form=annotation-form]').click(); + + cy.get('div[data-testid="annotation_mark_wrapper"]').click(); + + cy.get('form#annotation-form') + .findByTestId('annotation_content_input') + .should('have.value', content); + + cy.get('form#annotation-form') + .findByTestId('annotation_timestamp_input') + .invoke('val') + .then((sometext2) => assert.isTrue(sometext2 === time)); + + cy.get('button[form=annotation-form]').contains('Close').click(); + + cy.get('form#annotation-form').should('not.exist'); + }); +}); diff --git a/webapp/images/comment.svg b/webapp/images/comment.svg new file mode 100644 index 0000000000..f69f82c8f8 --- /dev/null +++ b/webapp/images/comment.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/webapp/javascript/components/TimelineChart/Annotation.module.scss b/webapp/javascript/components/TimelineChart/Annotation.module.scss deleted file mode 100644 index 0314413561..0000000000 --- a/webapp/javascript/components/TimelineChart/Annotation.module.scss +++ /dev/null @@ -1,15 +0,0 @@ -.wrapper { - overflow: hidden; - max-width: 300px; - max-height: 125px; // same height as the canvas -} - -.body { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.header { - font-size: 10px; -} diff --git a/webapp/javascript/components/TimelineChart/Annotation.spec.tsx b/webapp/javascript/components/TimelineChart/Annotation.spec.tsx deleted file mode 100644 index c43b41de32..0000000000 --- a/webapp/javascript/components/TimelineChart/Annotation.spec.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import AnnotationTooltipBody, { THRESHOLD } from './Annotation'; - -describe('AnnotationTooltipBody', () => { - it('return null when theres no annotation', () => { - const { container } = render( - - ); - - expect(container.querySelector('div')).toBeNull(); - }); - - it('return nothing when no annotation match', () => { - const annotations = [ - { - timestamp: 0, - content: 'annotation 1', - }, - ]; - const coordsToCanvasPos = jest.fn(); - - // reference position - coordsToCanvasPos.mockReturnValueOnce({ left: 100 }); - // our annotation position, point is to be outside the threshold - coordsToCanvasPos.mockReturnValueOnce({ left: 100 + THRESHOLD }); - - const { container } = render( - - ); - - expect(container.querySelector('div')).toBeNull(); - }); - - describe('rendering annotation', () => { - it('return an annotation', () => { - const annotations = [ - { - timestamp: 1663000000, - content: 'annotation 1', - }, - ]; - const coordsToCanvasPos = jest.fn(); - - // reference position - coordsToCanvasPos.mockReturnValueOnce({ left: 100 }); - - render( - - ); - - expect(screen.queryByText(/annotation 1/i)).toBeInTheDocument(); - }); - - it('renders the closest annotation', () => { - const furthestAnnotation = { - timestamp: 1663000010, - content: 'annotation 1', - }; - const closestAnnotation = { - timestamp: 1663000009, - content: 'annotation closest', - }; - const annotations = [furthestAnnotation, closestAnnotation]; - const values = [{ closest: [1663000000] }]; - const coordsToCanvasPos = jest.fn(); - - coordsToCanvasPos.mockImplementation((a) => { - // our reference point - if (a.x === furthestAnnotation.timestamp) { - return { left: 100 }; - } - - // closest - if (a.x === closestAnnotation.timestamp) { - return { left: 99 }; - } - }); - - render( - - ); - - expect(screen.queryByText(/annotation closest/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/webapp/javascript/components/TimelineChart/Annotation.tsx b/webapp/javascript/components/TimelineChart/Annotation.tsx deleted file mode 100644 index c0ab0b8334..0000000000 --- a/webapp/javascript/components/TimelineChart/Annotation.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import { Maybe } from 'true-myth'; -import { format } from 'date-fns'; -import { Annotation } from '@webapp/models/annotation'; -import { getUTCdate, timezoneToOffset } from '@webapp/util/formatDate'; -import styles from './Annotation.module.scss'; - -// TODO(eh-am): what are these units? -export const THRESHOLD = 3; - -interface AnnotationTooltipBodyProps { - /** list of annotations */ - annotations: { timestamp: number; content: string }[]; - - /** given a timestamp, it returns the offset within the canvas */ - coordsToCanvasPos: jquery.flot.axis['p2c']; - - /* where in the canvas the mouse is */ - canvasX: number; - - timezone: 'browser' | 'utc'; -} - -export default function Annotations(props: AnnotationTooltipBodyProps) { - if (!props.annotations?.length) { - return null; - } - - return getClosestAnnotation( - props.annotations, - props.coordsToCanvasPos, - props.canvasX - ) - .map((annotation: Annotation) => ( - - )) - .unwrapOr(null); -} - -function AnnotationComponent({ - timestamp, - content, - timezone, -}: { - timestamp: number; - content: string; - timezone: AnnotationTooltipBodyProps['timezone']; -}) { - // TODO: these don't account for timezone - return ( -
-
- {format( - getUTCdate(new Date(timestamp), timezoneToOffset(timezone)), - 'yyyy-MM-dd HH:mm' - )} -
-
{content}
-
- ); -} - -function getClosestAnnotation( - annotations: { timestamp: number; content: string }[], - coordsToCanvasPos: AnnotationTooltipBodyProps['coordsToCanvasPos'], - canvasX: number -): Maybe { - if (!annotations.length) { - return Maybe.nothing(); - } - - // pointOffset requires a y position, even though we don't use it - const dummyY = -1; - - // Create a score based on how distant it is from the timestamp - // Then get the first value (the closest to the timestamp) - const f = annotations - .map((a) => ({ - ...a, - score: Math.abs( - coordsToCanvasPos({ x: a.timestamp, y: dummyY }).left - canvasX - ), - })) - .filter((a) => a.score < THRESHOLD) - .sort((a, b) => a.score - b.score); - - return Maybe.of(f[0]); -} diff --git a/webapp/javascript/components/TimelineChart/AnnotationMark/index.tsx b/webapp/javascript/components/TimelineChart/AnnotationMark/index.tsx new file mode 100644 index 0000000000..31634d8bd5 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/AnnotationMark/index.tsx @@ -0,0 +1,74 @@ +/* eslint-disable default-case, consistent-return */ +import Color from 'color'; +import React, { useState } from 'react'; +import classNames from 'classnames/bind'; +import AnnotationInfo from '@webapp/pages/continuous/contextMenu/AnnotationInfo'; +import useTimeZone from '@webapp/hooks/timeZone.hook'; + +import styles from './styles.module.scss'; + +const cx = classNames.bind(styles); + +interface IAnnotationMarkProps { + type: 'message'; + color: Color; + value: { + content: string; + timestamp: number; + }; +} + +const getIcon = (type: IAnnotationMarkProps['type']) => { + switch (type) { + case 'message': + return styles.message; + } +}; + +const AnnotationMark = ({ type, color, value }: IAnnotationMarkProps) => { + const { offset } = useTimeZone(); + const [visible, setVisible] = useState(false); + const [target, setTarget] = useState(); + const [hovered, setHovered] = useState(false); + + const onClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setTarget(e.target as Element); + setVisible(true); + }; + + const annotationInfoPopover = target ? ( + setVisible(false)} + popoverClassname={styles.form} + /> + ) : null; + + const onHoverStyle = { + backgroundColor: hovered ? color.darken(0.2).hex() : color.hex(), + zIndex: hovered ? 2 : 1, + }; + + return ( + <> +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + /> + {annotationInfoPopover} + + ); +}; + +export default AnnotationMark; diff --git a/webapp/javascript/components/TimelineChart/AnnotationMark/styles.module.scss b/webapp/javascript/components/TimelineChart/AnnotationMark/styles.module.scss new file mode 100644 index 0000000000..f15e7f6687 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/AnnotationMark/styles.module.scss @@ -0,0 +1,23 @@ +.wrapper { + position: relative; + width: 18px; + height: 18px; + left: -9px; + top: -7px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background-size: 14px; + background-position: center; + background-repeat: no-repeat; +} + +.message { + background-image: url('../../../../images/comment.svg'); +} + +.form { + width: 180px; +} diff --git a/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx b/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx new file mode 100644 index 0000000000..20cb58adb3 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import * as ReactDOM from 'react-dom'; +import Color from 'color'; +import { Provider } from 'react-redux'; +import store from '@webapp/redux/store'; +import extractRange from './extractRange'; +import AnnotationMark from './AnnotationMark'; + +type AnnotationType = { + content: string; + timestamp: number; + type: 'message'; + color: Color; +}; + +interface IFlotOptions extends jquery.flot.plotOptions { + annotations?: AnnotationType[]; + wrapperId?: string; +} + +interface IPlot extends jquery.flot.plot, jquery.flot.plotOptions {} + +(function ($) { + function init(plot: IPlot) { + plot.hooks!.draw!.push(renderAnnotationListInTimeline); + } + + $.plot.plugins.push({ + init, + options: {}, + name: 'annotations', + version: '1.0', + }); +})(jQuery); + +function renderAnnotationListInTimeline( + plot: IPlot, + ctx: CanvasRenderingContext2D +) { + const options: IFlotOptions = plot.getOptions(); + + if (options.annotations?.length) { + const plotOffset: { top: number; left: number } = plot.getPlotOffset(); + const extractedX = extractRange(plot, 'x'); + const extractedY = extractRange(plot, 'y'); + + options.annotations.forEach((annotation: AnnotationType) => { + const left: number = + Math.floor(extractedX.axis.p2c(annotation.timestamp * 1000)) + + plotOffset.left; + + renderAnnotationIcon({ + annotation, + options, + left, + }); + + drawAnnotationLine({ + ctx, + yMin: plotOffset.top, + yMax: + Math.floor(extractedY.axis.p2c(extractedY.axis.min)) + plotOffset.top, + left, + color: annotation.color, + }); + }); + } +} + +function drawAnnotationLine({ + ctx, + color, + left, + yMax, + yMin, +}: { + ctx: CanvasRenderingContext2D; + color: Color; + left: number; + yMax: number; + yMin: number; +}) { + ctx.beginPath(); + ctx.strokeStyle = color.hex(); + ctx.lineWidth = 1; + ctx.moveTo(left + 0.5, yMax); + ctx.lineTo(left + 0.5, yMin); + ctx.stroke(); +} + +function renderAnnotationIcon({ + annotation, + options, + left, +}: { + annotation: AnnotationType; + options: { wrapperId?: string }; + left: number; +}) { + const annotationMarkElementId = + `${options.wrapperId}_annotation_mark_`.concat( + String(annotation.timestamp) + ); + + const annotationMarkElement = $(`#${annotationMarkElementId}`); + + if (!annotationMarkElement.length) { + $( + `
` + ).appendTo(`#${options.wrapperId}`); + } else { + annotationMarkElement.css({ left }); + } + + ReactDOM.render( + + + , + document.getElementById(annotationMarkElementId) + ); +} diff --git a/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx b/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx index 4cbdf7fdb0..cb92b581d0 100644 --- a/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx +++ b/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx @@ -1,12 +1,11 @@ import React from 'react'; import * as ReactDOM from 'react-dom'; import { randomId } from '@webapp/util/randomId'; -import { Provider } from 'react-redux'; -import store from '@webapp/redux/store'; +import { PlotType } from './types'; // Pre calculated once // TODO(eh-am): does this work with multiple contextMenus? -const WRAPPER_ID = randomId('contextMenu'); +const WRAPPER_ID = randomId('context_menu'); export interface ContextMenuProps { click: { @@ -20,66 +19,50 @@ export interface ContextMenuProps { } (function ($: JQueryStatic) { - function init(plot: jquery.flot.plot & jquery.flot.plotOptions) { + function init(plot: jquery.flot.plot & jquery.flot.plotOptions & PlotType) { + const placeholder = plot.getPlaceholder(); + function onClick( event: unknown, pos: { x: number; pageX: number; pageY: number } ) { + const options: jquery.flot.plotOptions & { + ContextMenu?: React.FC; + } = plot.getOptions(); const container = inject($); const containerEl = container?.[0]; // unmount any previous menus ReactDOM.unmountComponentAtNode(containerEl); - // TODO(eh-am): improve typing - const ContextMenu = (plot.getOptions() as ShamefulAny).ContextMenu as - | React.FC - | undefined; + const ContextMenu = options?.ContextMenu; if (ContextMenu && containerEl) { - // TODO(eh-am): why do we need this conversion? - const timestamp = Math.round(pos.x / 1000); - - // Add a Provider (reux) so that we can communicate with the main app via actions - // idea from https://stackoverflow.com/questions/52660770/how-to-communicate-reactdom-render-with-other-reactdom-render - // TODO(eh-am): add a global Context too? ReactDOM.render( - - - , + , containerEl ); } } - const flotEl = plot.getPlaceholder(); - // Register events and shutdown // It's important to bind/unbind to the SAME element // Since a plugin may be register/unregistered multiple times due to react re-rendering + plot.hooks.bindEvents.push(function () { + placeholder.bind('plotclick', onClick); + }); - // TODO: not entirely sure when these are disabled - if (plot.hooks?.bindEvents) { - plot.hooks.bindEvents.push(function () { - flotEl.bind('plotclick', onClick); - }); - } - - if (plot.hooks?.shutdown) { - plot.hooks.shutdown.push(function () { - flotEl.unbind('plotclick', onClick); + plot.hooks.shutdown.push(function () { + placeholder.unbind('plotclick', onClick); - const container = inject($); - const containerEl = container?.[0]; + const container = inject($); - // unmount any previous menus - ReactDOM.unmountComponentAtNode(containerEl); - }); - } + ReactDOM.unmountComponentAtNode(container?.[0]); + }); } $.plot.plugins.push({ @@ -90,7 +73,7 @@ export interface ContextMenuProps { }); })(jQuery); -const inject = ($: JQueryStatic) => { +function inject($: JQueryStatic) { const alreadyInitialized = $(`#${WRAPPER_ID}`).length > 0; if (alreadyInitialized) { @@ -99,4 +82,4 @@ const inject = ($: JQueryStatic) => { const body = $('body'); return $(`
`).appendTo(body); -}; +} diff --git a/webapp/javascript/components/TimelineChart/Selection.plugin.ts b/webapp/javascript/components/TimelineChart/Selection.plugin.ts index 17ed22094c..6cb359c926 100644 --- a/webapp/javascript/components/TimelineChart/Selection.plugin.ts +++ b/webapp/javascript/components/TimelineChart/Selection.plugin.ts @@ -1,13 +1,15 @@ /* eslint-disable */ // extending logic of Flot's selection plugin (react-flot/flot/jquery.flot.selection) -import { PlotType, CtxType, EventHolderType, EventType } from './types'; +import { PlotType, EventHolderType, EventType } from './types'; import clamp from './clamp'; +import extractRange from './extractRange'; const handleWidth = 4; const handleHeight = 22; (function ($) { function init(plot: PlotType) { + const placeholder = plot.getPlaceholder(); var selection = { first: { x: -1, y: -1 }, second: { x: -1, y: -1 }, @@ -36,7 +38,7 @@ const handleHeight = 22; function getCursorPositionX(e: EventType) { const plotOffset = plot.getPlotOffset(); - const offset = plot.getPlaceholder().offset(); + const offset = placeholder.offset(); return clamp(0, plot.width(), e.pageX - offset.left - plotOffset.left); } @@ -44,9 +46,8 @@ const handleHeight = 22; // unlike function getSelection() which shows temp selection (it doesnt save any data between rerenders) // this function returns left X and right X coords of visible user selection (translates opts.grid.markings to X coords) const o = plot.getOptions(); - const axes = plot.getAxes(); const plotOffset = plot.getPlotOffset(); - const extractedX = extractRange(axes, 'x'); + const extractedX = extractRange(plot as jquery.flot.plot & PlotType, 'x'); return { left: @@ -108,7 +109,7 @@ const handleHeight = 22; setCursor('crosshair'); } - plot.getPlaceholder().trigger('plotselecting', [getSelection()]); + placeholder.trigger('plotselecting', [getSelection()]); } } @@ -137,7 +138,7 @@ const handleHeight = 22; }; } - const offset = plot.getPlaceholder().offset(); + const offset = placeholder.offset(); const plotOffset = plot.getPlotOffset(); const { left, right } = getPlotSelection(); const clickX = getCursorPositionX(e); @@ -190,8 +191,8 @@ const handleHeight = 22; if (selectionIsSane()) triggerSelectedEvent(); else { // this counts as a clear - plot.getPlaceholder().trigger('plotunselected', []); - plot.getPlaceholder().trigger('plotselecting', [null]); + placeholder.trigger('plotunselected', []); + placeholder.trigger('plotselecting', [null]); } setCursor('crosshair'); @@ -220,11 +221,11 @@ const handleHeight = 22; function triggerSelectedEvent() { var r: any = getSelection(); - plot.getPlaceholder().trigger('plotselected', [r]); + placeholder.trigger('plotselected', [r]); // backwards-compat stuff, to be removed in future if (r.xaxis && r.yaxis) - plot.getPlaceholder().trigger('selected', [ + placeholder.trigger('selected', [ { x1: r.xaxis.from, y1: r.yaxis.from, @@ -236,7 +237,7 @@ const handleHeight = 22; function setSelectionPos(pos: { x: number; y: number }, e: EventType) { var o = plot.getOptions(); - var offset = plot.getPlaceholder().offset(); + var offset = placeholder.offset(); var plotOffset = plot.getPlotOffset(); pos.x = clamp(0, plot.width(), e.pageX - offset.left - plotOffset.left); pos.y = clamp(0, plot.height(), e.pageY - offset.top - plotOffset.top); @@ -262,48 +263,10 @@ const handleHeight = 22; if (selection.show) { selection.show = false; plot.triggerRedrawOverlay(); - if (!preventEvent) plot.getPlaceholder().trigger('plotunselected', []); + if (!preventEvent) placeholder.trigger('plotunselected', []); } } - // function taken from markings support in Flot - function extractRange(ranges: { [x: string]: any }, coord: string) { - var axis, - from, - to, - key, - axes = plot.getAxes(); - - for (var k in axes) { - axis = axes[k]; - if (axis.direction == coord) { - key = coord + axis.n + 'axis'; - if (!ranges[key] && axis.n == 1) key = coord + 'axis'; // support x1axis as xaxis - if (ranges[key]) { - from = ranges[key].from; - to = ranges[key].to; - break; - } - } - } - - // backwards-compat stuff - to be removed in future - if (!ranges[key as string]) { - axis = coord == 'x' ? plot.getXAxes()[0] : plot.getYAxes()[0]; - from = ranges[coord + '1']; - to = ranges[coord + '2']; - } - - // auto-reverse as an added bonus - if (from != null && to != null && from > to) { - var tmp = from; - from = to; - to = tmp; - } - - return { from: from, to: to, axis: axis }; - } - function setSelection(ranges: any, preventEvent: any) { var axis, range, @@ -313,7 +276,7 @@ const handleHeight = 22; selection.first.x = 0; selection.second.x = plot.width(); } else { - range = extractRange(ranges, 'x'); + range = extractRange(plot as jquery.flot.plot & PlotType, 'x'); selection.first.x = range.axis.p2c(range.from); selection.second.x = range.axis.p2c(range.to); @@ -323,7 +286,7 @@ const handleHeight = 22; selection.first.y = 0; selection.second.y = plot.height(); } else { - range = extractRange(ranges, 'y'); + range = extractRange(plot as jquery.flot.plot & PlotType, 'y'); selection.first.y = range.axis.p2c(range.from); selection.second.y = range.axis.p2c(range.to); @@ -357,7 +320,10 @@ const handleHeight = 22; } }); - plot.hooks.drawOverlay.push(function (plot: PlotType, ctx: CtxType) { + plot.hooks.drawOverlay.push(function ( + plot: PlotType, + ctx: CanvasRenderingContext2D + ) { // draw selection if (selection.show && selectionIsSane()) { const plotOffset = plot.getPlotOffset(); @@ -419,16 +385,21 @@ const handleHeight = 22; } }); - plot.hooks.draw.push(function (plot: PlotType, ctx: CtxType) { + plot.hooks.draw.push(function ( + plot: PlotType, + ctx: CanvasRenderingContext2D + ) { const opts = plot.getOptions(); if ( opts?.selection?.selectionType === 'single' && opts?.selection?.selectionWithHandler ) { - const axes = plot.getAxes(); const plotOffset = plot.getPlotOffset(); - const extractedY = extractRange(axes, 'y'); + const extractedY = extractRange( + plot as jquery.flot.plot & PlotType, + 'y' + ); const { left, right } = getPlotSelection(); const yMax = diff --git a/webapp/javascript/components/TimelineChart/TimelineChart.tsx b/webapp/javascript/components/TimelineChart/TimelineChart.tsx index 153b400a74..2c75f7aa6c 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChart.tsx +++ b/webapp/javascript/components/TimelineChart/TimelineChart.tsx @@ -8,6 +8,7 @@ import './Selection.plugin'; import 'react-flot/flot/jquery.flot.crosshair.min'; import './TimelineChartPlugin'; import './Tooltip.plugin'; +import './Annotations.plugin'; import './ContextMenu.plugin'; interface TimelineChartProps { diff --git a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css index c29cd76100..012e213cb6 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css +++ b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css @@ -1,3 +1,3 @@ .wrapper { - overflow: hidden; + overflow: visible; } diff --git a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx index 2f5404893f..03e3ed8609 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx +++ b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx @@ -10,9 +10,8 @@ import type { ExploreTooltipProps } from '@webapp/components/TimelineChart/Explo import type { ITooltipWrapperProps } from './TooltipWrapper'; import TooltipWrapper from './TooltipWrapper'; import TimelineChart from './TimelineChart'; -import Annotation from './Annotation'; import styles from './TimelineChartWrapper.module.css'; -import { markingsFromAnnotations, markingsFromSelection } from './markings'; +import { markingsFromSelection, ANNOTATION_COLOR } from './markings'; import { ContextMenuProps } from './ContextMenu.plugin'; export interface TimelineGroupData { @@ -148,6 +147,7 @@ class TimelineChartWrapper extends React.Component< // a position and a nearby data item object as parameters. clickable: true, }, + annotations: [], yaxis: { show: false, min: 0, @@ -203,6 +203,7 @@ class TimelineChartWrapper extends React.Component< this.state = { flotOptions }; this.state.flotOptions.grid.markings = this.plotMarkings(); + this.state.flotOptions.annotations = this.composeAnnotationsList(); } componentDidUpdate(prevProps: TimelineChartWrapperProps) { @@ -212,10 +213,22 @@ class TimelineChartWrapper extends React.Component< ) { const newFlotOptions = this.state.flotOptions; newFlotOptions.grid.markings = this.plotMarkings(); + newFlotOptions.annotations = this.composeAnnotationsList(); this.setState({ flotOptions: newFlotOptions }); } } + composeAnnotationsList = () => { + return Array.isArray(this.props.annotations) + ? this.props.annotations?.map((a) => ({ + timestamp: a.timestamp, + content: a.content, + type: 'message', + color: ANNOTATION_COLOR, + })) + : []; + }; + plotMarkings = () => { const selectionMarkings = markingsFromSelection( this.props.selectionType, @@ -223,16 +236,13 @@ class TimelineChartWrapper extends React.Component< this.props.selection?.right ); - const annotationsMarkings = markingsFromAnnotations(this.props.annotations); - - return [...selectionMarkings, ...annotationsMarkings]; + return [...selectionMarkings]; }; setOnHoverDisplayTooltip = ( data: ITooltipWrapperProps & ExploreTooltipProps ) => { - const { timezone } = this.props; - let tooltipContent = []; + const tooltipContent = []; const TooltipBody: React.FC | undefined = this.props?.onHoverDisplayTooltip; @@ -247,43 +257,6 @@ class TimelineChartWrapper extends React.Component< ); } - // convert to the format we are expecting - const annotations = - this.props.annotations?.map((a) => ({ - ...a, - timestamp: a.timestamp * 1000, - })) || []; - - if (this.props.annotations) { - if ( - this.props.mode === 'singles' && - data.coordsToCanvasPos && - data.canvasX - ) { - const an = Annotation({ - timezone, - annotations, - canvasX: data.canvasX, - coordsToCanvasPos: data.coordsToCanvasPos, - }); - - // if available, only render annotation - // so that the tooltip is not bloated - if (an) { - // Rerender as tsx to make use of key - tooltipContent = [ - , - ]; - } - } - } - if (tooltipContent.length) { return ( to) { + var tmp = from; + from = to; + to = tmp; + } + + return { from: from, to: to, axis: axis }; +} diff --git a/webapp/javascript/components/TimelineChart/markings.spec.ts b/webapp/javascript/components/TimelineChart/markings.spec.ts index a6d40806fc..1445aa4724 100644 --- a/webapp/javascript/components/TimelineChart/markings.spec.ts +++ b/webapp/javascript/components/TimelineChart/markings.spec.ts @@ -1,31 +1,5 @@ import Color from 'color'; -import { - ANNOTATION_COLOR, - ANNOTATION_WIDTH, - markingsFromAnnotations, - markingsFromSelection, -} from './markings'; - -describe('markingsFromAnnotations', () => { - it('works', () => { - const timestamp = 1663000000; - const annotations = [ - { - timestamp, - }, - ]; - expect(markingsFromAnnotations(annotations)).toStrictEqual([ - { - lineWidth: ANNOTATION_WIDTH, - color: ANNOTATION_COLOR, - xaxis: { - from: timestamp * 1000, - to: timestamp * 1000, - }, - }, - ]); - }); -}); +import { markingsFromSelection } from './markings'; // Tests are definitely confusing, but that's due to the nature of the implementation // TODO: refactor implementatino diff --git a/webapp/javascript/components/TimelineChart/markings.ts b/webapp/javascript/components/TimelineChart/markings.ts index ff50c123f6..85703f9a57 100644 --- a/webapp/javascript/components/TimelineChart/markings.ts +++ b/webapp/javascript/components/TimelineChart/markings.ts @@ -3,7 +3,6 @@ import Color from 'color'; // Same green as button export const ANNOTATION_COLOR = Color('#2ecc40'); -export const ANNOTATION_WIDTH = '2px'; type FlotMarkings = { xaxis: { @@ -17,27 +16,6 @@ type FlotMarkings = { color: Color; }[]; -/** - * generate markings in flotjs format - */ -export function markingsFromAnnotations( - annotations?: { timestamp: number }[] -): FlotMarkings { - if (!annotations?.length) { - return []; - } - - return annotations.map((a) => ({ - xaxis: { - // TODO(eh-am): look this up - from: a.timestamp * 1000, - to: a.timestamp * 1000, - }, - lineWidth: ANNOTATION_WIDTH, - color: ANNOTATION_COLOR, - })); -} - // Unify these types interface Selection { from: string; diff --git a/webapp/javascript/components/TimelineChart/types.ts b/webapp/javascript/components/TimelineChart/types.ts index 7f7cd95722..29a60e92ea 100644 --- a/webapp/javascript/components/TimelineChart/types.ts +++ b/webapp/javascript/components/TimelineChart/types.ts @@ -23,18 +23,6 @@ export type PlotType = { getData: () => ShamefulAny[]; }; -export type CtxType = { - save: () => void; - translate: (arg0: ShamefulAny, arg1: ShamefulAny) => void; - strokeStyle: ShamefulAny; - lineWidth: number; - lineJoin: ShamefulAny; - fillStyle: ShamefulAny; - fillRect: (arg0: number, arg1: number, arg2: number, arg3: number) => void; - strokeRect: (arg0: number, arg1: number, arg2: number, arg3: number) => void; - restore: () => void; -}; - export type EventHolderType = { unbind: ( arg0: string, diff --git a/webapp/javascript/components/TimelineTitle.tsx b/webapp/javascript/components/TimelineTitle.tsx index 67bbfe8af5..cef1525270 100644 --- a/webapp/javascript/components/TimelineTitle.tsx +++ b/webapp/javascript/components/TimelineTitle.tsx @@ -1,6 +1,6 @@ import React from 'react'; import Color from 'color'; - +import clsx from 'clsx'; import styles from './TimelineTitle.module.scss'; const unitsToFlamegraphTitle = { @@ -20,14 +20,16 @@ const unitsToFlamegraphTitle = { interface TimelineTitleProps { color?: Color; titleKey?: keyof typeof unitsToFlamegraphTitle; + className?: string; } export default function TimelineTitle({ color, titleKey = '', + className, }: TimelineTitleProps) { return ( -
+
{color && ( + } annotations={annotations} selectionType="single" diff --git a/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx b/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx index c375fdcb7c..76cef5fa26 100644 --- a/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx +++ b/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx @@ -7,17 +7,13 @@ import { PopoverFooter, PopoverHeader, } from '@webapp/ui/Popover'; -import { format } from 'date-fns'; -import { getUTCdate, timezoneToOffset } from '@webapp/util/formatDate'; import Button from '@webapp/ui/Button'; import { Portal, PortalProps } from '@webapp/ui/Portal'; import { NewAnnotation } from '@webapp/services/annotations'; -import * as z from 'zod'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; import TextField from '@webapp/ui/Form/TextField'; +import { useAnnotationForm } from './useAnnotationForm'; -interface AddAnnotationProps { +export interface AddAnnotationProps { /** where to put the popover in the DOM */ container: PortalProps['container']; @@ -32,10 +28,6 @@ interface AddAnnotationProps { timezone: 'browser' | 'utc'; } -const newAnnotationFormSchema = z.object({ - content: z.string().min(1, { message: 'Required' }), -}); - function AddAnnotation(props: AddAnnotationProps) { const { container, @@ -45,13 +37,9 @@ function AddAnnotation(props: AddAnnotationProps) { timezone, } = props; const [isPopoverOpen, setPopoverOpen] = useState(false); - const { - register, - handleSubmit, - formState: { errors }, - setFocus, - } = useForm({ - resolver: zodResolver(newAnnotationFormSchema), + const { register, handleSubmit, errors, setFocus } = useAnnotationForm({ + timezone, + value: { timestamp }, }); // Focus on the only input @@ -61,6 +49,41 @@ function AddAnnotation(props: AddAnnotationProps) { } }, [setFocus, isPopoverOpen]); + const popoverContent = isPopoverOpen ? ( + <> + Add annotation + +
{ + onCreateAnnotation(d.content as string); + })} + > + + + +
+ + + + + ) : null; + return ( <> setPopoverOpen(true)}> @@ -69,43 +92,10 @@ function AddAnnotation(props: AddAnnotationProps) { - Add annotation - -
{ - onCreateAnnotation(d.content); - })} - > - - - -
- - - + {popoverContent}
diff --git a/webapp/javascript/pages/continuous/contextMenu/AnnotationInfo.tsx b/webapp/javascript/pages/continuous/contextMenu/AnnotationInfo.tsx new file mode 100644 index 0000000000..7414ed19b2 --- /dev/null +++ b/webapp/javascript/pages/continuous/contextMenu/AnnotationInfo.tsx @@ -0,0 +1,74 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React, { Dispatch, SetStateAction } from 'react'; +import { MenuItem, applyStatics } from '@webapp/ui/Menu'; +import { Popover, PopoverBody, PopoverFooter } from '@webapp/ui/Popover'; +import Button from '@webapp/ui/Button'; +import { Portal } from '@webapp/ui/Portal'; +import TextField from '@webapp/ui/Form/TextField'; +import { AddAnnotationProps } from './AddAnnotation.menuitem'; +import { useAnnotationForm } from './useAnnotationForm'; + +interface AnnotationInfo { + /** where to put the popover in the DOM */ + container: AddAnnotationProps['container']; + + /** where to position the popover */ + popoverAnchorPoint: AddAnnotationProps['popoverAnchorPoint']; + timestamp: AddAnnotationProps['timestamp']; + timezone: AddAnnotationProps['timezone']; + value: { content: string; timestamp: number }; + isOpen: boolean; + onClose: () => void; + popoverClassname?: string; +} + +const AnnotationInfo = ({ + container, + popoverAnchorPoint, + value, + timezone, + isOpen, + onClose, + popoverClassname, +}: AnnotationInfo) => { + const { register, errors } = useAnnotationForm({ value, timezone }); + + return ( + + >} + className={popoverClassname} + > + +
+ + + +
+ + + +
+
+ ); +}; + +applyStatics(MenuItem)(AnnotationInfo); + +export default AnnotationInfo; diff --git a/webapp/javascript/pages/continuous/contextMenu/useAnnotationForm.ts b/webapp/javascript/pages/continuous/contextMenu/useAnnotationForm.ts new file mode 100644 index 0000000000..d6594e7e7f --- /dev/null +++ b/webapp/javascript/pages/continuous/contextMenu/useAnnotationForm.ts @@ -0,0 +1,48 @@ +import * as z from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { format } from 'date-fns'; +import { getUTCdate, timezoneToOffset } from '@webapp/util/formatDate'; + +interface UseAnnotationFormProps { + timezone: 'browser' | 'utc'; + value: { + content?: string; + timestamp: number; + }; +} + +const newAnnotationFormSchema = z.object({ + content: z.string().min(1, { message: 'Required' }), +}); + +export const useAnnotationForm = ({ + value, + timezone, +}: UseAnnotationFormProps) => { + const { + register, + handleSubmit, + formState: { errors }, + setFocus, + } = useForm({ + resolver: zodResolver(newAnnotationFormSchema), + defaultValues: { + content: value?.content, + timestamp: format( + getUTCdate( + new Date(value?.timestamp * 1000), + timezoneToOffset(timezone) + ), + 'yyyy-MM-dd HH:mm' + ), + }, + }); + + return { + register, + handleSubmit, + errors, + setFocus, + }; +}; diff --git a/webapp/javascript/ui/LoadingOverlay.module.css b/webapp/javascript/ui/LoadingOverlay.module.css index 7afc5f0d7b..1b2da82a35 100644 --- a/webapp/javascript/ui/LoadingOverlay.module.css +++ b/webapp/javascript/ui/LoadingOverlay.module.css @@ -16,5 +16,5 @@ .unactive { background: initial; - visibility: hidden; + display: none; } diff --git a/webapp/sass/profile.scss b/webapp/sass/profile.scss index 6689e60237..4cdcadd2fb 100644 --- a/webapp/sass/profile.scss +++ b/webapp/sass/profile.scss @@ -113,7 +113,6 @@ body { #timeline-chart-right, #timeline-chart-diff, #timeline-chart-explore-page { - overflow: hidden; cursor: crosshair; } @@ -267,6 +266,10 @@ body, .main-wrapper { flex: 1 0 auto; min-width: 890px; + + .singleView-timeline-title { + margin-bottom: 15px; + } } // stretch all main-wrapper children by default // so that we don't have to define it for every single page