diff --git a/examples/expressions_example/.eslintrc.js b/examples/expressions_example/.eslintrc.js new file mode 100644 index 000000000000..d40ef4ec9091 --- /dev/null +++ b/examples/expressions_example/.eslintrc.js @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +module.exports = { + root: true, + extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'], + rules: { + '@osd/eslint/require-license-header': 'off', + }, +}; diff --git a/examples/expressions_example/.i18nrc.json b/examples/expressions_example/.i18nrc.json new file mode 100644 index 000000000000..a4777b0c81b7 --- /dev/null +++ b/examples/expressions_example/.i18nrc.json @@ -0,0 +1,7 @@ +{ + "prefix": "expressionsExample", + "paths": { + "expressionsExample": "." + }, + "translations": ["translations/ja-JP.json"] +} diff --git a/examples/expressions_example/README.md b/examples/expressions_example/README.md new file mode 100755 index 000000000000..009a67cd6504 --- /dev/null +++ b/examples/expressions_example/README.md @@ -0,0 +1,11 @@ +# expressions_example + +An OpenSearch Dashboards example plugin to demonstrate the expressions plugin + +--- + +## Development + +See the [OpenSearch Dashboards contributing +guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/master/CONTRIBUTING.md) for instructions +setting up your development environment. diff --git a/examples/expressions_example/common/expression_functions/action/index.ts b/examples/expressions_example/common/expression_functions/action/index.ts new file mode 100644 index 000000000000..17e5f37c3d31 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/action/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './quick_form_fn'; +export * from './quick_form_renderer'; diff --git a/examples/expressions_example/common/expression_functions/action/quick_form_fn.ts b/examples/expressions_example/common/expression_functions/action/quick_form_fn.ts new file mode 100644 index 000000000000..1eb4c59d0029 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/action/quick_form_fn.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + ExpressionFunctionDefinition, + Render, +} from '../../../../../src/plugins/expressions/public'; +import { QuickFormRenderValue } from './quick_form_renderer'; + +type Arguments = QuickFormRenderValue; + +export const quickFormFn = (): ExpressionFunctionDefinition< + 'quick-form', + unknown, + Arguments, + Render +> => ({ + name: 'quick-form', + type: 'render', + help: i18n.translate('expressionsExample.function.avatar.help', { + defaultMessage: 'Render a simple form that sends the value back as an event on click', + }), + args: { + label: { + types: ['string'], + help: i18n.translate('expressionsExample.function.form.args.label.help', { + defaultMessage: 'Form label', + }), + default: i18n.translate('expressionsExample.function.form.args.label.default', { + defaultMessage: 'Input', + }), + }, + buttonLabel: { + types: ['string'], + help: i18n.translate('expressionsExample.function.form.args.buttonLabel.help', { + defaultMessage: 'Button label', + }), + default: i18n.translate('expressionsExample.function.form.args.buttonLabel.default', { + defaultMessage: 'Submit', + }), + }, + }, + fn: (input, args) => { + return { + type: 'render', + as: 'quick-form-renderer', + value: { ...args }, + }; + }, +}); diff --git a/examples/expressions_example/common/expression_functions/action/quick_form_renderer.tsx b/examples/expressions_example/common/expression_functions/action/quick_form_renderer.tsx new file mode 100644 index 000000000000..5c987595e1f2 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/action/quick_form_renderer.tsx @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useState } from 'react'; +import { EuiForm, EuiFormRow, EuiButton, EuiFieldText } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { ExpressionRenderDefinition } from '../../../../../src/plugins/expressions/public'; + +export interface QuickFormRenderValue { + label: string; + buttonLabel: string; +} + +export const quickFormRenderer: ExpressionRenderDefinition = { + name: 'quick-form-renderer', + displayName: i18n.translate('expressionsExample.form.render.help', { + defaultMessage: 'Render a simple input form', + }), + reuseDomNode: true, + render: (domNode, config, handlers) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + render( + + handlers.event({ + data: value, + }) + } + />, + domNode, + handlers.done + ); + }, +}; + +interface QuickFormProps extends QuickFormRenderValue { + onSubmit: Function; +} + +const QuickForm = ({ onSubmit, buttonLabel, label }: QuickFormProps) => { + const [value, setValue] = useState(''); + const handleClick = useCallback(() => { + onSubmit(value); + }, [onSubmit, value]); + + return ( + + + setValue(e.target.value)} /> + + {buttonLabel} + + ); +}; diff --git a/examples/expressions_example/common/expression_functions/basic/index.ts b/examples/expressions_example/common/expression_functions/basic/index.ts new file mode 100644 index 000000000000..551f8e4f2d59 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/basic/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './sleep'; +export * from './square'; diff --git a/examples/expressions_example/common/expression_functions/basic/sleep.ts b/examples/expressions_example/common/expression_functions/basic/sleep.ts new file mode 100644 index 000000000000..5722369920b5 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/basic/sleep.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../src/plugins/expressions/public'; + +interface Arguments { + time: number; +} + +export const sleep = (): ExpressionFunctionDefinition<'sleep', any, Arguments, any> => ({ + name: 'sleep', + help: i18n.translate('expressionsExample.function.sleep.help', { + defaultMessage: 'Generates range object', + }), + args: { + time: { + types: ['number'], + help: i18n.translate('expressionsExample.function.sleep.time.help', { + defaultMessage: 'Time for settimeout', + }), + required: false, + }, + }, + fn: async (input, args, context) => { + await new Promise((r) => setTimeout(r, args.time)); + return input; + }, +}); diff --git a/examples/expressions_example/common/expression_functions/basic/square.ts b/examples/expressions_example/common/expression_functions/basic/square.ts new file mode 100644 index 000000000000..899afed37e05 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/basic/square.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../src/plugins/expressions/public'; + +export const square = (): ExpressionFunctionDefinition<'square', number, {}, any> => ({ + name: 'square', + help: i18n.translate('expressionsExample.function.square.help', { + defaultMessage: 'Squares the input', + }), + args: {}, + fn: async (input, args, context) => { + return input * input; + }, +}); diff --git a/examples/expressions_example/common/expression_functions/index.ts b/examples/expressions_example/common/expression_functions/index.ts new file mode 100644 index 000000000000..2bf7e68d0038 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './basic'; +export * from './render'; +export * from './action'; diff --git a/examples/expressions_example/common/expression_functions/render/avatar_fn.ts b/examples/expressions_example/common/expression_functions/render/avatar_fn.ts new file mode 100644 index 000000000000..7fe377b3f1d3 --- /dev/null +++ b/examples/expressions_example/common/expression_functions/render/avatar_fn.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + ExpressionFunctionDefinition, + Render, +} from '../../../../../src/plugins/expressions/public'; +import { AvatarRenderValue } from './avatar_renderer'; + +type Arguments = AvatarRenderValue; + +export const avatarFn = (): ExpressionFunctionDefinition< + 'avatar', + unknown, + Arguments, + Render +> => ({ + name: 'avatar', + type: 'render', + help: i18n.translate('expressionsExample.function.avatar.help', { + defaultMessage: 'Avatar expression function', + }), + args: { + name: { + types: ['string'], + help: i18n.translate('expressionsExample.function.avatar.args.name.help', { + defaultMessage: 'Enter Name', + }), + required: true, + }, + size: { + types: ['string'], + help: i18n.translate('expressionsExample.function.avatar.args.size.help', { + defaultMessage: 'Size of the avatar', + }), + default: 'l', + }, + }, + fn: (input, args) => { + return { + type: 'render', + as: 'avatar', + value: { + name: args.name, + size: args.size, + }, + }; + }, +}); diff --git a/examples/expressions_example/common/expression_functions/render/avatar_renderer.tsx b/examples/expressions_example/common/expression_functions/render/avatar_renderer.tsx new file mode 100644 index 000000000000..8b2fb3e8efaa --- /dev/null +++ b/examples/expressions_example/common/expression_functions/render/avatar_renderer.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiAvatar, EuiAvatarProps } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { ExpressionRenderDefinition } from '../../../../../src/plugins/expressions/public'; + +export interface AvatarRenderValue { + name: string; + size: EuiAvatarProps['size']; +} + +export const avatar: ExpressionRenderDefinition = { + name: 'avatar', + displayName: i18n.translate('expressionsExample.render.help', { + defaultMessage: 'Render an avatar', + }), + reuseDomNode: true, + render: (domNode, { name, size }, handlers) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + render(, domNode, handlers.done); + }, +}; diff --git a/examples/expressions_example/common/expression_functions/render/index.ts b/examples/expressions_example/common/expression_functions/render/index.ts new file mode 100644 index 000000000000..437b2ac9512b --- /dev/null +++ b/examples/expressions_example/common/expression_functions/render/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './avatar_fn'; +export * from './avatar_renderer'; diff --git a/examples/expressions_example/common/index.ts b/examples/expressions_example/common/index.ts new file mode 100644 index 000000000000..3208d933b77c --- /dev/null +++ b/examples/expressions_example/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'expressionsExample'; +export const PLUGIN_NAME = 'expressions_example'; diff --git a/examples/expressions_example/opensearch_dashboards.json b/examples/expressions_example/opensearch_dashboards.json new file mode 100644 index 000000000000..8a1eb85f6176 --- /dev/null +++ b/examples/expressions_example/opensearch_dashboards.json @@ -0,0 +1,14 @@ +{ + "id": "expressionsExample", + "version": "1.0.0", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": false, + "ui": true, + "requiredPlugins": [ + "navigation", + "expressions", + "developerExamples", + "opensearchDashboardsReact" + ], + "optionalPlugins": [] +} \ No newline at end of file diff --git a/examples/expressions_example/public/application.tsx b/examples/expressions_example/public/application.tsx new file mode 100644 index 000000000000..15953cf73ce4 --- /dev/null +++ b/examples/expressions_example/public/application.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, CoreStart } from '../../../src/core/public'; +import { ExpressionsExampleStartDependencies } from './types'; +import { ExpressionsExampleApp } from './components/app'; +import { OpenSearchDashboardsContextProvider } from '../../../src/plugins/opensearch_dashboards_react/public'; + +export const renderApp = ( + { notifications, http }: CoreStart, + { navigation, expressions }: ExpressionsExampleStartDependencies, + { appBasePath, element }: AppMountParameters +) => { + const services = { expressions, notifications }; + ReactDOM.render( + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/expressions_example/public/components/actions_tab.tsx b/examples/expressions_example/public/components/actions_tab.tsx new file mode 100644 index 000000000000..0d1d9a699c36 --- /dev/null +++ b/examples/expressions_example/public/components/actions_tab.tsx @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCallOut, EuiCodeBlock, EuiFormLabel, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useCallback } from 'react'; +import { ReactExpressionRenderer } from '../../../../src/plugins/expressions/public'; +import { useOpenSearchDashboards } from '../../../../src/plugins/opensearch_dashboards_react/public'; +import { ExpressionsExampleServices } from '../types'; + +export function ActionsTab() { + const { + services: { notifications }, + } = useOpenSearchDashboards(); + const handleEvent = useCallback( + ({ data }) => { + notifications.toasts.addSuccess(data); + }, + [notifications.toasts] + ); + + const expressionString = `quick-form label="Toast message" buttonLabel="Toast"`; + + return ( + <> + + + + + + + + + {expressionString} + + + + ); +} diff --git a/examples/expressions_example/public/components/app.tsx b/examples/expressions_example/public/components/app.tsx new file mode 100644 index 000000000000..99db0d40765f --- /dev/null +++ b/examples/expressions_example/public/components/app.tsx @@ -0,0 +1,138 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo } from 'react'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { BrowserRouter as Router } from 'react-router-dom'; + +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageHeader, + EuiTitle, + EuiText, + EuiTabbedContent, +} from '@elastic/eui'; + +import { CoreStart } from '../../../../src/core/public'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; + +import { BasicTab } from './basic_tab'; +import { RenderTab } from './render_tab'; +import { ActionsTab } from './actions_tab'; +import { PlaygroundTab } from './playground_tab'; +import { ExplorerTab } from './explorer_tab'; + +interface ExpressionsExampleAppDeps { + basename: string; + notifications: CoreStart['notifications']; + http: CoreStart['http']; + navigation: NavigationPublicPluginStart; +} + +export const ExpressionsExampleApp = ({ basename }: ExpressionsExampleAppDeps) => { + const tabs = useMemo( + () => [ + { + id: 'demo-1', + name: ( + + ), + content: , + }, + { + id: 'demo-2', + name: ( + + ), + content: , + }, + { + id: 'demo-3', + name: ( + + ), + content: , + }, + { + id: 'playground', + name: ( + + ), + content: , + }, + { + id: 'explorer', + name: ( + + ), + content: , + }, + ], + [] + ); + // Render the application DOM. + return ( + + + <> + + + + +

+ +

+
+
+ + + +

+ +

+
+
+ + + +
+
+
+ +
+
+ ); +}; diff --git a/examples/expressions_example/public/components/basic_tab.tsx b/examples/expressions_example/public/components/basic_tab.tsx new file mode 100644 index 000000000000..06005b7ca8d6 --- /dev/null +++ b/examples/expressions_example/public/components/basic_tab.tsx @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiCodeBlock, + EuiFieldNumber, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiStat, + EuiFormLabel, + EuiCallOut, + EuiProgress, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useEffect, useState } from 'react'; +import { useOpenSearchDashboards } from '../../../../src/plugins/opensearch_dashboards_react/public'; +import { ExpressionsExampleServices } from '../types'; + +export function BasicTab() { + const { + services: { expressions }, + } = useOpenSearchDashboards(); + const [input, setInput] = useState(2); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState( + i18n.translate('expressionsExample.tab.demo1.loading', { + defaultMessage: 'Still sleeping', + }) + ); + const expression = `sleep time=2000 | square`; + + useEffect(() => { + let isMounted = true; + + try { + setLoading(true); + const execution = expressions.execute(expression, input); + execution.getData().then((data: any) => { + if (!isMounted) return; + + const value = + data?.type === 'error' + ? `Error: ${data?.error?.message ?? 'Something went wrong'}` + : data; + + setLoading(false); + setResult(String(value)); + }); + } catch (error) { + setLoading(false); + setResult(String(error)); + } + + return () => { + isMounted = false; + }; + }, [expressions, input, expression]); + + return ( + <> + + + + + + + + setInput(Number(e.target.value))} /> + + + + + + + + {loading && } + {expression} + + + + + ); +} diff --git a/examples/expressions_example/public/components/explorer_section.tsx b/examples/expressions_example/public/components/explorer_section.tsx new file mode 100644 index 000000000000..b6b04efc61d4 --- /dev/null +++ b/examples/expressions_example/public/components/explorer_section.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiCallOut, + EuiDescriptionList, + EuiPanel, + EuiSpacer, + EuiTitle, + EuiBasicTable, + EuiText, + EuiBadge, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useMemo } from 'react'; +import { ExpressionFunction } from '../../../../src/plugins/expressions'; + +interface Props { + fn: ExpressionFunction; +} + +export function ExplorerSection({ fn }: Props) { + const argumentItems = useMemo( + () => + Object.values(fn.args).map((arg) => ({ + name: arg.name, + default: arg.default, + types: String(arg.types), + required: arg.required, + help: arg.help, + })), + [fn] + ); + + return ( + + {/* arguments */} + +

+ +

+
+ +
+ ); +} diff --git a/examples/expressions_example/public/components/explorer_tab.tsx b/examples/expressions_example/public/components/explorer_tab.tsx new file mode 100644 index 000000000000..14030751e5b3 --- /dev/null +++ b/examples/expressions_example/public/components/explorer_tab.tsx @@ -0,0 +1,163 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiCallOut, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiBasicTable, + EuiButtonIcon, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useMemo, useState } from 'react'; +import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { useOpenSearchDashboards } from '../../../../src/plugins/opensearch_dashboards_react/public'; +import { ExpressionsExampleServices } from '../types'; +import { ExplorerSection } from './explorer_section'; + +interface ExpressionFunctionItem { + name: string; + type: string; + help: string; +} + +export function ExplorerTab() { + const { + services: { expressions }, + } = useOpenSearchDashboards(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('all'); + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState({}); + + const functions = expressions.getFunctions(); + + const types = useMemo(() => { + const allTypes = new Set(Object.values(functions).map((fn) => fn.type)); + + // Catch all filter and remove + allTypes.delete(undefined); + allTypes.add('all'); + + return [...allTypes].map((type) => ({ text: type })); + }, [functions]); + + const items = useMemo( + () => + Object.values(functions) + .filter((fn) => fn.name.includes(search)) + .filter((fn) => (filter === 'all' ? true : fn.type === filter)) + .map((fn) => ({ + name: fn.name, + type: fn.type, + help: fn.help, + })), + [filter, functions, search] + ); + + const toggleDetails = (item: ExpressionFunctionItem) => { + const { name: id } = item; + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[id]) { + delete itemIdToExpandedRowMapValues[id]; + } else { + itemIdToExpandedRowMapValues[id] = ; + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + return ( + <> + + + + + + + + + + + setSearch(e.target.value)} + /> + + + + + setFilter(e.target.value)} + /> + + + + + + + ( + toggleDetails(item)} + aria-label={itemIdToExpandedRowMap[item.name] ? 'Collapse' : 'Expand'} + iconType={itemIdToExpandedRowMap[item.name] ? 'arrowUp' : 'arrowDown'} + /> + ), + }, + ]} + items={items} + /> + + {/* {sections} */} + + ); +} diff --git a/examples/expressions_example/public/components/playground_section.tsx b/examples/expressions_example/public/components/playground_section.tsx new file mode 100644 index 000000000000..ce957eeb0845 --- /dev/null +++ b/examples/expressions_example/public/components/playground_section.tsx @@ -0,0 +1,140 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiCodeBlock, + EuiFormLabel, + EuiSpacer, + EuiCodeEditor, + EuiPanel, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiTitle, + EuiProgress, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useEffect, useState } from 'react'; +import { ReactExpressionRenderer } from '../../../../src/plugins/expressions/public'; +import { useOpenSearchDashboards } from '../../../../src/plugins/opensearch_dashboards_react/public'; +import { ExpressionsExampleServices } from '../types'; + +interface Props { + title: string; + defaultInput?: string; + defaultExpression: string; + renderType?: boolean; +} + +export function PlaygroundSection({ + title, + defaultExpression, + defaultInput = '10', + renderType = false, +}: Props) { + const { + services: { expressions }, + } = useOpenSearchDashboards(); + const [loading, setLoading] = useState(false); + const [input, setInput] = useState(defaultInput); + const [expression, setExpression] = useState(defaultExpression); + const [result, setResult] = useState(''); + + useEffect(() => { + let isMounted = true; + if (renderType) return; + + try { + setLoading(true); + const execution = expressions.execute(expression, input); + execution.getData().then((data: any) => { + if (!isMounted) return; + + const value = + data?.type === 'error' + ? `Error: ${data?.error?.message ?? 'Something went wrong'}` + : data; + + const parsedValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : value; + + setLoading(false); + setResult(parsedValue); + }); + } catch (error) { + setLoading(false); + setResult(String(error)); + } + + return () => { + isMounted = false; + }; + }, [expressions, input, expression, renderType]); + + return ( + <> + + +

{title}

+
+ + {/* Rendered the input field only for non renderable expressions */} + {!renderType && ( + <> + + + setInput(e.target.value)} /> + + + + + )} + + + + setExpression(value)} + /> + + + + + {renderType ? ( + <> + + + + + ) : ( + + {loading && } + {result} + + )} +
+ + ); +} diff --git a/examples/expressions_example/public/components/playground_tab.tsx b/examples/expressions_example/public/components/playground_tab.tsx new file mode 100644 index 000000000000..9acdbfa89796 --- /dev/null +++ b/examples/expressions_example/public/components/playground_tab.tsx @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React from 'react'; +import { PlaygroundSection } from './playground_section'; + +export function PlaygroundTab() { + return ( + <> + + + + + + + + + + + + + ); +} diff --git a/examples/expressions_example/public/components/render_tab.tsx b/examples/expressions_example/public/components/render_tab.tsx new file mode 100644 index 000000000000..30342da63081 --- /dev/null +++ b/examples/expressions_example/public/components/render_tab.tsx @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiCodeBlock, + EuiFieldText, + EuiForm, + EuiFormLabel, + EuiFormRow, + EuiSpacer, + EuiSelect, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useState } from 'react'; +import { ReactExpressionRenderer } from '../../../../src/plugins/expressions/public'; + +export function RenderTab() { + const [value, setValue] = useState('OpenSearch Dashboards'); + const [size, setSize] = useState('xl'); + const expressionString = `avatar name="${value}" size="${size}"`; + + return ( + <> + + + + + + + + setValue(String(e.target.value))} /> + + + setSize(String(e.target.value))} + /> + + + + + + + {expressionString} + + + + + + + ); +} diff --git a/examples/expressions_example/public/index.scss b/examples/expressions_example/public/index.scss new file mode 100644 index 000000000000..e33c1b1f588e --- /dev/null +++ b/examples/expressions_example/public/index.scss @@ -0,0 +1,13 @@ +.expressions-demo { + .explorer_list .euiDescriptionList__title { + max-width: 400px; + } + + .explorer_section_type > code { + margin-left: $euiSizeXS; + } + + .playgroundRenderer { + height: 500px; // Visualizations require the container to have a valid width and height to render + } +} diff --git a/examples/expressions_example/public/index.ts b/examples/expressions_example/public/index.ts new file mode 100644 index 000000000000..b930fc76eb65 --- /dev/null +++ b/examples/expressions_example/public/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './index.scss'; + +import { ExpressionsExamplePlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. +export function plugin() { + return new ExpressionsExamplePlugin(); +} +export { ExpressionsExamplePluginSetup, ExpressionsExamplePluginStart } from './types'; diff --git a/examples/expressions_example/public/plugin.ts b/examples/expressions_example/public/plugin.ts new file mode 100644 index 000000000000..47e02ae1d006 --- /dev/null +++ b/examples/expressions_example/public/plugin.ts @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + AppMountParameters, + AppNavLinkStatus, + CoreSetup, + CoreStart, + Plugin, +} from '../../../src/core/public'; +import { + ExpressionsExamplePluginSetup, + ExpressionsExamplePluginStart, + ExpressionsExampleSetupDependencies, + ExpressionsExampleStartDependencies, +} from './types'; +import { PLUGIN_NAME } from '../common'; + +import { + sleep, + square, + avatar, + avatarFn, + quickFormFn, + quickFormRenderer, +} from '../common/expression_functions'; + +export class ExpressionsExamplePlugin + implements Plugin { + public setup( + core: CoreSetup, + { expressions, developerExamples }: ExpressionsExampleSetupDependencies + ): ExpressionsExamplePluginSetup { + // Register an application into the side navigation menu + core.application.register({ + id: 'expressions-example', + title: PLUGIN_NAME, + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services as specified in opensearch_dashboards.json + const [coreStart, depsStart] = await core.getStartServices(); + // Render the application + return renderApp(coreStart, depsStart as ExpressionsExampleStartDependencies, params); + }, + }); + + const expressionFunctions = [sleep, square, avatarFn, quickFormFn]; + const expressionRenderers = [avatar, quickFormRenderer]; + + expressionFunctions.forEach((createExpressionFunction) => { + expressions.registerFunction(createExpressionFunction); + }); + + expressionRenderers.forEach((createExpressionRenderer) => { + expressions.registerRenderer(createExpressionRenderer); + }); + + developerExamples.register({ + appId: 'expressions-example', + title: i18n.translate('expressionsExample.developerExamples.title', { + defaultMessage: 'Expressions', + }), + description: i18n.translate('expressionsExample.developerExamples.description', { + defaultMessage: + 'Examples showing you how the expressions plugin is used to chain functions and render content', + }), + links: [ + { + label: 'README', + href: + 'https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/expressions/README.md', + iconType: 'logoGithub', + target: '_blank', + size: 's', + }, + ], + }); + + return {}; + } + + public start(core: CoreStart): ExpressionsExamplePluginStart { + return {}; + } + + public stop() {} +} diff --git a/examples/expressions_example/public/types.ts b/examples/expressions_example/public/types.ts new file mode 100644 index 000000000000..ea32b4783283 --- /dev/null +++ b/examples/expressions_example/public/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart, NotificationsStart } from '../../../src/core/public'; +import { ExpressionsSetup, ExpressionsStart } from '../../../src/plugins/expressions/public'; +import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; +import { DeveloperExamplesSetup } from '../../developer_examples/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ExpressionsExamplePluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ExpressionsExamplePluginStart {} + +export interface ExpressionsExampleSetupDependencies { + expressions: ExpressionsSetup; + developerExamples: DeveloperExamplesSetup; +} + +export interface ExpressionsExampleStartDependencies { + navigation: NavigationPublicPluginStart; + expressions: ExpressionsStart; +} + +export interface ExpressionsExampleServices extends CoreStart { + expressions: ExpressionsStart; + notifications: NotificationsStart; +} diff --git a/src/plugins/expressions/README.md b/src/plugins/expressions/README.md index aca90ea25953..053344c8c28b 100644 --- a/src/plugins/expressions/README.md +++ b/src/plugins/expressions/README.md @@ -5,31 +5,75 @@ string for you, as well as a series of registries for advanced users who might want to incorporate their own functions, types, and renderers into the service for use in their own application. -Expression pipeline is a chain of functions that _pipe_ its output to the -input of the next function. Functions can be configured using arguments provided -by the user. The final output of the expression pipeline can be rendered using -one of the _renderers_ registered in `expressions` plugin. +`Expressions` is a simple custom language designed to write a chain of functions that _pipe_ its output to the +input of the next function. When two or more such functions are chained together, it is an expressions pipeline. Since it is a custom language, any expression can be represented as a string. Functions can be configured using arguments provided. The final output of the expression pipeline can either be rendered using +one of the _renderers_ registered in `expressions` plugin or made to output the result of the final function in the chain. -Expressions power visualizations in Dashboard and Lens, as well as, every -_element_ in Canvas is backed by an expression. +> It is not necessary to chain functions and a single function can be used in isolation. -Below is an example of one Canvas element that fetches data using `opensearchsql` function, -pipes it further to `math` and `metric` functions, and final `render` function -renders the result. +Expressions power visualizations in Dashboard. +Below is an example of an expression that renders a metric visualization that aggregates the average value for the field `AvgTicketPrice` in the index. It does so by first fetching the opensearch dashboards global context, pipes it into the `opensearchaggs` function that fetches the aggregate data and pipes its result to the `metricVis` function that renders a metric visualization for the data. + +``` +opensearchDashboards +| opensearchaggs + index='d3d7af60-4c81-11e8-b3d7-01146121b73d' + aggConfigs='[{"id":"1","type":"avg","params":{"field":"AvgTicketPrice","customLabel":"Avg. Ticket Price"}}]' +| metricVis + metric={visdimension accessor=0 format='number'} +``` + +![image](./expressions_example.png) + +## Anatomy of an expression + +Consider the example below where the expression performs the following. It takes an input, sleeps for 2000ms and then returns the square of the input as its final output + +``` +sleep time=2000 | square +``` + +**Note:** The above example expression functions are only available with the `--run-examples` flag + +The whole string is an expression. `sleep` and `square` are expression functions registered with the expression plugin. `time=2000` is the argument passed to the `sleep` funciton with the value `2000`. `|` is used to denote pipe between the two functions. Every expression can take an input. In the example above, the input provided will be passed on by the sleep function to the square function. + +## Using Expressions + +### Execute Expressions + +One of the two ways an expressions can be used is to execute an expression to return a value. This can be done using the `expressions.execute` or `expressions.run` command. The primary difference being that the `execute` method returns an `ExecutionContract` that tracks the progress of the execution and can be used to interact with the expression. + +```js +const expression = `sleep time=2000 | square`; +const execution = expressions.execute(expression, input); ``` -filters -| opensearchsql - query="SELECT COUNT(timestamp) as total_errors - FROM opensearch_dashboards_sample_data_logs - WHERE tags LIKE '%warning%' OR tags LIKE '%error%'" -| math "total_errors" -| metric "TOTAL ISSUES" - metricFont={font family="'Open Sans', Helvetica, Arial, sans-serif" size=48 align="left" color="#FFFFFF" weight="normal" underline=false italic=false} - labelFont={font family="'Open Sans', Helvetica, Arial, sans-serif" size=30 align="left" color="#FFFFFF" weight="lighter" underline=false italic=false} -| render + +**Note:** The above example expression functions are only available with the `--run-examples` flag + +### Rendering Expressions + +The other way an expression can be used is to render an output using one of the _renderers_ registered in `expressions` plugin. This can be done using a few ways, the easiest of which is to use the `ReactExpressionRenderer` component. + +```jsx +const expressionString = `avatar name="OpenSearch Dashboards" size="xl"`; + +``` + +**Note:** The above example expression functions are only available with the `--run-examples` flag + +## Custom expressions + +Users can extend the service to incorporate their own functions, types, and renderers. Examples of these can be found in `./examples/expressions_example/common/expression_functions` and can be registered using the `registerFunction`, `registertype` and `registerRenderer` api's from the expression setup contract. + +## Playground + +Working with expressions can sometimes be a little tricky. To make this easier we have an example plugin with some examples, a playground to run your own expression functions and explorer to view all the registered expression functions and their propoerties. It can be started up using the `--run-examples` flag and found under the `Developer examples` option in the main menu. + +```sh +yarn start --run-examples ``` -![image](https://user-images.githubusercontent.com/9773803/74162514-3250a880-4c21-11ea-9e68-86f66862a183.png) -[See Canvas documentation about expressions](https://opensearch.org/docs/latest/dashboards/index/). + + diff --git a/src/plugins/expressions/expressions_example.png b/src/plugins/expressions/expressions_example.png new file mode 100644 index 000000000000..9850f25aba87 Binary files /dev/null and b/src/plugins/expressions/expressions_example.png differ diff --git a/src/plugins/expressions/public/_expression_renderer.scss b/src/plugins/expressions/public/_expression_renderer.scss index 4f030384ed88..c372f3b3bf2a 100644 --- a/src/plugins/expressions/public/_expression_renderer.scss +++ b/src/plugins/expressions/public/_expression_renderer.scss @@ -8,8 +8,13 @@ } .expExpressionRenderer__expression { + display: flex; + flex-direction: column; width: 100%; height: 100%; + overflow: auto; + position: relative; + flex: 1 1 100%; } .expExpressionRenderer-isEmpty, diff --git a/src/plugins/expressions/public/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer.tsx index 6d6760756c51..838fa703a750 100644 --- a/src/plugins/expressions/public/react_expression_renderer.tsx +++ b/src/plugins/expressions/public/react_expression_renderer.tsx @@ -184,10 +184,9 @@ export const ReactExpressionRenderer = ({ } }, [state.error]); - const classes = classNames('expExpressionRenderer', { + const classes = classNames('expExpressionRenderer', className, { 'expExpressionRenderer-isEmpty': state.isEmpty, 'expExpressionRenderer-hasError': !!state.error, - className, }); const expressionStyles: React.CSSProperties = {}; diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index c009707f6d42..f743c37e911b 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -106,7 +106,7 @@ export class ExpressionRenderHandler { }; } - render = async (data: any, uiState: any = {}) => { + render = async (data: any, uiState?: any) => { if (!data || typeof data !== 'object') { return this.handleRenderError(new Error('invalid data provided to the expression renderer')); }