From 0bf1a97262c6fd4d64dccd806147f58ba35de18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Wed, 21 Jul 2021 20:09:00 +0200 Subject: [PATCH] Graphite: Migrate to React (part 2: migrate smaller AngularJS directives) (#36797) * Add UMLs * Add rendered diagrams * Move QueryCtrl to flux * Remove redundant param in the reducer * Use named imports for lodash and fix typing for GraphiteTagOperator * Add missing async/await * Extract providers to a separate file * Clean up async await * Rename controller functions back to main * Simplify creating actions * Re-order controller functions * Separate helpers from actions * Rename vars * Simplify helpers * Move controller methods to state reducers * Remove docs (they are added in design doc) * Move actions.ts to state folder * Add docs * Add old methods stubs for easier review * Check how state dependencies will be mapped * Rename state to store * Rename state to store * Rewrite spec tests for Graphite Query Controller * Update docs * Update docs * Add GraphiteTextEditor * Add play button * Add AddGraphiteFunction * Use Segment to simplify AddGraphiteFunction * Memoize function defs * Fix useCallback deps * Update public/app/plugins/datasource/graphite/state/helpers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/helpers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/helpers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Add more type definitions * Remove submitOnClickAwayOption This behavior is actually needed to remove parameters in functions * Load function definitions before parsing the target on initial load * Add button padding * Fix loading function definitions * Change targetChanged to updateQuery to avoid mutating state directly It's also needed for extra refresh/runQuery execution as handleTargetChanged doesn't handle changing the raw query * Fix updating query after adding a function * Simplify updating function params * Simplify setting Segment Select min width * Extract view logic to a helper and update types definitions * Clean up types * Update FuncDef types and add tests Co-authored-by: Giordano Ricci --- .../src/components/Segment/Segment.tsx | 4 +- .../src/components/Segment/SegmentAsync.tsx | 4 +- .../src/components/Segment/types.ts | 1 + public/app/core/angular_wrappers.ts | 10 +- .../graphite/FunctionEditor.test.tsx | 25 ++- .../datasource/graphite/FunctionEditor.tsx | 5 +- .../graphite/FunctionEditorControls.tsx | 26 +-- .../datasource/graphite/add_graphite_func.ts | 164 ------------------ .../components/AddGraphiteFunction.tsx | 42 +++++ .../components/GraphiteTextEditor.tsx | 30 ++++ .../graphite/components/PlayButton.tsx | 16 ++ .../graphite/components/helpers.test.tsx | 31 ++++ .../graphite/components/helpers.tsx | 22 +++ .../plugins/datasource/graphite/datasource.ts | 12 +- .../plugins/datasource/graphite/gfunc.test.ts | 7 +- .../app/plugins/datasource/graphite/gfunc.ts | 57 +++--- .../graphite/partials/query.editor.html | 6 +- .../plugins/datasource/graphite/query_ctrl.ts | 1 - .../datasource/graphite/state/helpers.ts | 6 +- .../datasource/graphite/state/store.ts | 4 + 20 files changed, 234 insertions(+), 239 deletions(-) delete mode 100644 public/app/plugins/datasource/graphite/add_graphite_func.ts create mode 100644 public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx create mode 100644 public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx create mode 100644 public/app/plugins/datasource/graphite/components/PlayButton.tsx create mode 100644 public/app/plugins/datasource/graphite/components/helpers.test.tsx create mode 100644 public/app/plugins/datasource/graphite/components/helpers.tsx diff --git a/packages/grafana-ui/src/components/Segment/Segment.tsx b/packages/grafana-ui/src/components/Segment/Segment.tsx index c76054792e0..dcb10922951 100644 --- a/packages/grafana-ui/src/components/Segment/Segment.tsx +++ b/packages/grafana-ui/src/components/Segment/Segment.tsx @@ -22,9 +22,11 @@ export function Segment({ allowCustomValue, placeholder, disabled, + inputMinWidth, ...rest }: React.PropsWithChildren>) { - const [Label, width, expanded, setExpanded] = useExpandableLabel(false); + const [Label, labelWidth, expanded, setExpanded] = useExpandableLabel(false); + const width = inputMinWidth ? Math.max(inputMinWidth, labelWidth) : labelWidth; const styles = useStyles(getSegmentStyles); if (!expanded) { diff --git a/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx b/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx index 86a48a7e15d..84cbcca4cf5 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx @@ -26,11 +26,13 @@ export function SegmentAsync({ allowCustomValue, disabled, placeholder, + inputMinWidth, noOptionMessageHandler = mapStateToNoOptionsMessage, ...rest }: React.PropsWithChildren>) { const [state, fetchOptions] = useAsyncFn(loadOptions, [loadOptions]); - const [Label, width, expanded, setExpanded] = useExpandableLabel(false); + const [Label, labelWidth, expanded, setExpanded] = useExpandableLabel(false); + const width = inputMinWidth ? Math.max(inputMinWidth, labelWidth) : labelWidth; const styles = useStyles(getSegmentStyles); if (!expanded) { diff --git a/packages/grafana-ui/src/components/Segment/types.ts b/packages/grafana-ui/src/components/Segment/types.ts index 3f1ed1a5a7a..ade12f97459 100644 --- a/packages/grafana-ui/src/components/Segment/types.ts +++ b/packages/grafana-ui/src/components/Segment/types.ts @@ -6,4 +6,5 @@ export interface SegmentProps { allowCustomValue?: boolean; placeholder?: string; disabled?: boolean; + inputMinWidth?: number; } diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 2dd8ff2ce88..d4c5adc0aa1 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -25,6 +25,9 @@ import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { SearchField, SearchResults, SearchResultsFilter } from '../features/search'; import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings'; import QueryEditor from 'app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor'; +import { GraphiteTextEditor } from '../plugins/datasource/graphite/components/GraphiteTextEditor'; +import { PlayButton } from '../plugins/datasource/graphite/components/PlayButton'; +import { AddGraphiteFunction } from '../plugins/datasource/graphite/components/AddGraphiteFunction'; const { SecretFormField } = LegacyForms; @@ -38,7 +41,6 @@ export function registerAngularDirectives() { ]); react2AngularDirective('spinner', Spinner, ['inline']); react2AngularDirective('helpModal', HelpModal, []); - react2AngularDirective('functionEditor', FunctionEditor, ['func', 'onRemove', 'onMoveLeft', 'onMoveRight']); react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); react2AngularDirective('emptyListCta', EmptyListCTA, [ 'title', @@ -201,4 +203,10 @@ export function registerAngularDirectives() { ['datasource', { watchDepth: 'reference' }], 'onChange', ]); + + // Temporal wrappers for Graphite migration + react2AngularDirective('functionEditor', FunctionEditor, ['func', 'onRemove', 'onMoveLeft', 'onMoveRight']); + react2AngularDirective('graphiteTextEditor', GraphiteTextEditor, ['rawQuery', 'dispatch']); + react2AngularDirective('playButton', PlayButton, ['dispatch']); + react2AngularDirective('addGraphiteFunction', AddGraphiteFunction, ['funcDefs', 'dispatch']); } diff --git a/public/app/plugins/datasource/graphite/FunctionEditor.test.tsx b/public/app/plugins/datasource/graphite/FunctionEditor.test.tsx index bde1a75a729..db5260fad8c 100644 --- a/public/app/plugins/datasource/graphite/FunctionEditor.test.tsx +++ b/public/app/plugins/datasource/graphite/FunctionEditor.test.tsx @@ -1,28 +1,25 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { FunctionEditor } from './FunctionEditor'; -import { FunctionDescriptor } from './FunctionEditorControls'; +import { FuncInstance } from './gfunc'; -function mockFunctionDescriptor(name: string, unknown?: boolean): FunctionDescriptor { - return { - text: '', +function mockFunctionInstance(name: string, unknown?: boolean): FuncInstance { + const def = { + category: 'category', + defaultParams: [], + fake: false, + name: name, params: [], - def: { - category: 'category', - defaultParams: [], - fake: false, - name: name, - params: [], - unknown: unknown, - }, + unknown: unknown, }; + return new FuncInstance(def); } describe('FunctionEditor', () => { it('should display a defined function with name and no icon', () => { render( {}} onMoveRight={() => {}} onRemove={() => {}} @@ -36,7 +33,7 @@ describe('FunctionEditor', () => { it('should display an unknown function with name and warning icon', () => { render( = ({ onMoveLeft, onMoveRight, func, ...props }) => { diff --git a/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx b/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx index cefb52bbd48..731cc96bb7e 100644 --- a/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx +++ b/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx @@ -1,27 +1,11 @@ import React, { Suspense } from 'react'; import { Icon, Tooltip } from '@grafana/ui'; - -export interface FunctionDescriptor { - text: string; - params: string[]; - def: { - category: string; - defaultParams: string[]; - description?: string; - fake: boolean; - name: string; - params: string[]; - /** - * True if the function was not found on the list of available function descriptions. - */ - unknown?: boolean; - }; -} +import { FuncInstance } from './gfunc'; export interface FunctionEditorControlsProps { - onMoveLeft: (func: FunctionDescriptor) => void; - onMoveRight: (func: FunctionDescriptor) => void; - onRemove: (func: FunctionDescriptor) => void; + onMoveLeft: (func: FuncInstance) => void; + onMoveRight: (func: FuncInstance) => void; + onRemove: (func: FuncInstance) => void; } const FunctionDescription = React.lazy(async () => { @@ -64,7 +48,7 @@ const FunctionHelpButton = (props: { description?: string; name: string }) => { export const FunctionEditorControls = ( props: FunctionEditorControlsProps & { - func: FunctionDescriptor; + func: FuncInstance; } ) => { const { func, onMoveLeft, onMoveRight, onRemove } = props; diff --git a/public/app/plugins/datasource/graphite/add_graphite_func.ts b/public/app/plugins/datasource/graphite/add_graphite_func.ts deleted file mode 100644 index 5a254628eb0..00000000000 --- a/public/app/plugins/datasource/graphite/add_graphite_func.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { map, find, forEach, sortBy } from 'lodash'; -import $ from 'jquery'; -// @ts-ignore -import Drop from 'tether-drop'; -import coreModule from 'app/core/core_module'; -import { FuncDef } from './gfunc'; - -/** @ngInject */ -export function graphiteAddFunc($compile: any) { - const inputTemplate = - ''; - - const buttonTemplate = - '' + - ''; - - return { - link: function ($scope: any, elem: JQuery) { - const ctrl = $scope.ctrl; - - const $input = $(inputTemplate); - const $button = $(buttonTemplate); - - $input.appendTo(elem); - $button.appendTo(elem); - - // TODO: ctrl.state is not ready yet when link() is called. This will be moved to a separate provider. - ctrl.datasource.getFuncDefs().then((funcDefs: FuncDef[]) => { - const allFunctions = map(funcDefs, 'name').sort(); - - $scope.functionMenu = createFunctionDropDownMenu(funcDefs); - - $input.attr('data-provide', 'typeahead'); - $input.typeahead({ - source: allFunctions, - minLength: 1, - items: 10, - updater: (value: any) => { - let funcDef: any = ctrl.state.datasource.getFuncDef(value); - if (!funcDef) { - // try find close match - value = value.toLowerCase(); - funcDef = find(allFunctions, (funcName) => { - return funcName.toLowerCase().indexOf(value) === 0; - }); - - if (!funcDef) { - return ''; - } - } - - $scope.$apply(() => { - ctrl.addFunction(funcDef); - }); - - $input.trigger('blur'); - return ''; - }, - }); - - $button.click(() => { - $button.hide(); - $input.show(); - $input.focus(); - }); - - $input.keyup(() => { - elem.toggleClass('open', $input.val() === ''); - }); - - $input.blur(() => { - // clicking the function dropdown menu won't - // work if you remove class at once - setTimeout(() => { - $input.val(''); - $input.hide(); - $button.show(); - elem.removeClass('open'); - }, 200); - }); - - $compile(elem.contents())($scope); - }); - - let drop: any; - const cleanUpDrop = () => { - if (drop) { - drop.destroy(); - drop = null; - } - }; - - $(elem) - .on('mouseenter', 'ul.dropdown-menu li', async () => { - cleanUpDrop(); - - let funcDef; - try { - funcDef = ctrl.state.datasource.getFuncDef($('a', this).text()); - } catch (e) { - // ignore - } - - if (funcDef && funcDef.description) { - let shortDesc = funcDef.description; - if (shortDesc.length > 500) { - shortDesc = shortDesc.substring(0, 497) + '...'; - } - - const contentElement = document.createElement('div'); - // @ts-ignore - const { default: rst2html } = await import(/* webpackChunkName: "rst2html" */ 'rst2html'); - contentElement.innerHTML = '

' + funcDef.name + '

' + rst2html(shortDesc); - - drop = new Drop({ - target: this, - content: contentElement, - classes: 'drop-popover', - openOn: 'always', - tetherOptions: { - attachment: 'bottom left', - targetAttachment: 'bottom right', - }, - }); - } - }) - .on('mouseout', 'ul.dropdown-menu li', () => { - cleanUpDrop(); - }); - - $scope.$on('$destroy', cleanUpDrop); - }, - }; -} - -coreModule.directive('graphiteAddFunc', graphiteAddFunc); - -function createFunctionDropDownMenu(funcDefs: FuncDef[]) { - const categories: any = {}; - - forEach(funcDefs, (funcDef) => { - if (!funcDef.category) { - return; - } - if (!categories[funcDef.category]) { - categories[funcDef.category] = []; - } - categories[funcDef.category].push({ - text: funcDef.name, - click: "ctrl.addFunction('" + funcDef.name + "')", - }); - }); - - return sortBy( - map(categories, (submenu, category) => { - return { - text: category, - submenu: sortBy(submenu, 'text'), - }; - }), - 'text' - ); -} diff --git a/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx b/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx new file mode 100644 index 00000000000..9676be50974 --- /dev/null +++ b/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx @@ -0,0 +1,42 @@ +import React, { useCallback, useMemo } from 'react'; +import { Button, Segment, useStyles2 } from '@grafana/ui'; +import { FuncDefs } from '../gfunc'; +import { actions } from '../state/actions'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css, cx } from '@emotion/css'; +import { mapFuncDefsToSelectables } from './helpers'; +import { Dispatch } from 'redux'; + +type Props = { + dispatch: Dispatch; + funcDefs: FuncDefs; +}; + +export function AddGraphiteFunction({ dispatch, funcDefs }: Props) { + const onChange = useCallback( + ({ value }) => { + dispatch(actions.addFunction({ name: value })); + }, + [dispatch] + ); + const styles = useStyles2(getStyles); + + const options = useMemo(() => mapFuncDefsToSelectables(funcDefs), [funcDefs]); + + return ( + } + options={options} + onChange={onChange} + inputMinWidth={150} + > + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + button: css` + margin-right: ${theme.spacing(0.5)}; + `, + }; +} diff --git a/public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx b/public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx new file mode 100644 index 00000000000..7ff0c096dfe --- /dev/null +++ b/public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx @@ -0,0 +1,30 @@ +import React, { useCallback, useState } from 'react'; +import { QueryField } from '@grafana/ui'; +import { actions } from '../state/actions'; +import { Dispatch } from 'redux'; + +type Props = { + rawQuery: string; + dispatch: Dispatch; +}; + +export function GraphiteTextEditor({ rawQuery, dispatch }: Props) { + const [currentQuery, updateCurrentQuery] = useState(rawQuery); + + const applyChanges = useCallback(() => { + dispatch(actions.updateQuery({ query: currentQuery })); + }, [dispatch, currentQuery]); + + return ( + <> + + + ); +} diff --git a/public/app/plugins/datasource/graphite/components/PlayButton.tsx b/public/app/plugins/datasource/graphite/components/PlayButton.tsx new file mode 100644 index 00000000000..c9f7035c162 --- /dev/null +++ b/public/app/plugins/datasource/graphite/components/PlayButton.tsx @@ -0,0 +1,16 @@ +import React, { useCallback } from 'react'; +import { Button } from '@grafana/ui'; +import { actions } from '../state/actions'; +import { Dispatch } from 'redux'; + +type Props = { + rawQuery: string; + dispatch: Dispatch; +}; + +export function PlayButton({ dispatch }: Props) { + const onClick = useCallback(() => { + dispatch(actions.unpause()); + }, [dispatch]); + return