From af48d3db1eb2d8681843f5997e50fea5e5ea3096 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Mon, 9 Sep 2024 08:11:55 -0600 Subject: [PATCH] Canvas: Add actions support (#90677) Co-authored-by: Leon Sorokin --- .betterer.results | 22 ++ .github/CODEOWNERS | 1 + .../feature-toggles/index.md | 1 + .../grafana-data/src/field/fieldOverrides.ts | 2 +- packages/grafana-data/src/index.ts | 9 + packages/grafana-data/src/types/action.ts | 70 +++++++ packages/grafana-data/src/types/dataLink.ts | 2 +- .../src/types/featureToggles.gen.ts | 1 + .../src/components/Actions/ActionButton.tsx | 18 ++ .../VizTooltip/VizTooltipFooter.tsx | 47 +++-- pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 14 ++ public/app/features/actions/ActionEditor.tsx | 185 +++++++++++++++++ .../actions/ActionEditorModalContent.tsx | 52 +++++ .../features/actions/ActionsInlineEditor.tsx | 192 ++++++++++++++++++ .../app/features/actions/ActionsListItem.tsx | 111 ++++++++++ public/app/features/actions/ParamsEditor.tsx | 130 ++++++++++++ public/app/features/actions/utils.ts | 148 ++++++++++++++ public/app/features/canvas/element.ts | 3 +- .../app/features/canvas/runtime/element.tsx | 52 ++++- public/app/features/canvas/runtime/scene.tsx | 7 +- .../suggestionsInput/SuggestionsInput.tsx | 53 +++-- .../panel/canvas/components/CanvasTooltip.tsx | 45 +++- .../canvas/editor/element/ActionsEditor.tsx | 20 ++ .../canvas/editor/element/elementEditor.tsx | 29 ++- .../plugins/panel/canvas/editor/options.ts | 17 +- 28 files changed, 1194 insertions(+), 50 deletions(-) create mode 100644 packages/grafana-data/src/types/action.ts create mode 100644 packages/grafana-ui/src/components/Actions/ActionButton.tsx create mode 100644 public/app/features/actions/ActionEditor.tsx create mode 100644 public/app/features/actions/ActionEditorModalContent.tsx create mode 100644 public/app/features/actions/ActionsInlineEditor.tsx create mode 100644 public/app/features/actions/ActionsListItem.tsx create mode 100644 public/app/features/actions/ParamsEditor.tsx create mode 100644 public/app/features/actions/utils.ts create mode 100644 public/app/plugins/panel/canvas/editor/element/ActionsEditor.tsx diff --git a/.betterer.results b/.betterer.results index 81d2c506962..792fa9c78f0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -196,6 +196,11 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "packages/grafana-data/src/types/action.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "packages/grafana-data/src/types/annotations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1372,6 +1377,17 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "public/app/features/actions/ActionEditorModalContent.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/actions/ActionsInlineEditor.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/actions/ParamsEditor.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/admin/AdminEditOrgPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], @@ -5598,6 +5614,12 @@ exports[`better eslint`] = { "public/app/features/transformers/standardTransformers.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"] + ], "public/app/features/users/TokenRevokedModal.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c3acb119e5d..50225b9103b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -413,6 +413,7 @@ playwright.config.ts @grafana/plugins-platform-frontend # Temp owners until Enterprise team takes over /public/app/features/migrate-to-cloud @grafana/grafana-frontend-platform +/public/app/features/actions/ @grafana/dataviz-squad /public/app/features/auth-config/ @grafana/identity-squad /public/app/features/annotations/ @grafana/dashboards-squad /public/app/features/api-keys/ @grafana/identity-squad diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index d50f0bb7f26..ed738340c38 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -119,6 +119,7 @@ Experimental features might be changed or removed without prior notice. | `lokiExperimentalStreaming` | Support new streaming approach for loki (prototype, needs special loki build) | | `storage` | Configurable storage for dashboards, datasources, and resources | | `canvasPanelNesting` | Allow elements nesting | +| `vizActions` | Allow actions in visualizations | | `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables | | `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown | | `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema | diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 549599d81b4..a1cf083cbea 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -607,7 +607,7 @@ export function useFieldOverrides( /** * Clones the existing dataContext or creates a new one */ -function getFieldDataContextClone(frame: DataFrame, field: Field, fieldScopedVars: ScopedVars) { +export function getFieldDataContextClone(frame: DataFrame, field: Field, fieldScopedVars: ScopedVars) { if (fieldScopedVars?.__dataContext) { return { value: { diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index f7b3fa21399..890123bcd90 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -145,6 +145,7 @@ export { validateFieldConfig, applyRawFieldOverrides, useFieldOverrides, + getFieldDataContextClone, } from './field/fieldOverrides'; export { getFieldDisplayValuesProxy } from './field/getFieldDisplayValuesProxy'; export { @@ -800,6 +801,14 @@ export { VariableSuggestionsScope, OneClickMode, } from './types/dataLink'; +export { + type Action, + type ActionModel, + HttpRequestMethod, + defaultActionConfig, + contentTypeOptions, + httpMethodOptions, +} from './types/action'; export { DataFrameType } from './types/dataFrameTypes'; export { FieldType, diff --git a/packages/grafana-data/src/types/action.ts b/packages/grafana-data/src/types/action.ts new file mode 100644 index 00000000000..cfd4d2fff33 --- /dev/null +++ b/packages/grafana-data/src/types/action.ts @@ -0,0 +1,70 @@ +import { ScopedVars } from './ScopedVars'; +import { DataFrame, Field, ValueLinkConfig } from './dataFrame'; +import { InterpolateFunction } from './panel'; +import { SelectableValue } from './select'; + +export interface Action { + type: T; + title: string; + options: TOptions; +} + +/** + * Processed Action Model. The values are ready to use + */ +export interface ActionModel { + title: string; + onClick: (event: any, origin?: any) => void; +} + +interface FetchOptions { + method: HttpRequestMethod; + url: string; + body?: string; + queryParams?: Array<[string, string]>; + headers?: Array<[string, string]>; +} + +export enum ActionType { + Fetch = 'fetch', +} + +export enum HttpRequestMethod { + POST = 'POST', + PUT = 'PUT', + GET = 'GET', +} + +export const httpMethodOptions: SelectableValue[] = [ + { label: HttpRequestMethod.POST, value: HttpRequestMethod.POST }, + { label: HttpRequestMethod.PUT, value: HttpRequestMethod.PUT }, + { label: HttpRequestMethod.GET, value: HttpRequestMethod.GET }, +]; + +export const contentTypeOptions: SelectableValue[] = [ + { label: 'application/json', value: 'application/json' }, + { label: 'text/plain', value: 'text/plain' }, + { label: 'application/xml', value: 'application/xml' }, + { label: 'application/x-www-form-urlencoded', value: 'application/x-www-form-urlencoded' }, +]; + +export const defaultActionConfig: Action = { + type: ActionType.Fetch, + title: '', + options: { + url: '', + method: HttpRequestMethod.POST, + body: '{}', + queryParams: [], + headers: [['Content-Type', 'application/json']], + }, +}; + +export type ActionsArgs = { + frame: DataFrame; + field: Field; + fieldScopedVars: ScopedVars; + replaceVariables: InterpolateFunction; + actions: Action[]; + config: ValueLinkConfig; +}; diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 47805993095..47cba535029 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -51,7 +51,6 @@ export interface DataLink { internal?: InternalDataLink; origin?: DataLinkConfigOrigin; - sortIndex?: number; } /** @@ -131,6 +130,7 @@ export enum VariableSuggestionsScope { } export enum OneClickMode { + Action = 'action', Link = 'link', Off = 'off', } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 27eeb7769cc..311fad8ef32 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -38,6 +38,7 @@ export interface FeatureToggles { autoMigrateXYChartPanel?: boolean; disableAngular?: boolean; canvasPanelNesting?: boolean; + vizActions?: boolean; disableSecretsCompatibility?: boolean; logRequestsInstrumentedAsUnknown?: boolean; topnav?: boolean; diff --git a/packages/grafana-ui/src/components/Actions/ActionButton.tsx b/packages/grafana-ui/src/components/Actions/ActionButton.tsx new file mode 100644 index 00000000000..49b3c82c5c9 --- /dev/null +++ b/packages/grafana-ui/src/components/Actions/ActionButton.tsx @@ -0,0 +1,18 @@ +import { ActionModel, Field } from '@grafana/data'; + +import { Button, ButtonProps } from '../Button'; + +type ActionButtonProps = ButtonProps & { + action: ActionModel; +}; + +/** + * @internal + */ +export function ActionButton({ action, ...buttonProps }: ActionButtonProps) { + return ( + + ); +} diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index e455f8591aa..ca4ef351874 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -1,37 +1,50 @@ import { css } from '@emotion/css'; -import { Field, GrafanaTheme2, LinkModel } from '@grafana/data'; +import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data'; import { Button, ButtonProps, DataLinkButton, Stack } from '..'; import { useStyles2 } from '../../themes'; +import { ActionButton } from '../Actions/ActionButton'; interface VizTooltipFooterProps { dataLinks: Array>; + actions?: Array>; annotate?: () => void; } export const ADD_ANNOTATION_ID = 'add-annotation-button'; -export const VizTooltipFooter = ({ dataLinks, annotate }: VizTooltipFooterProps) => { - const styles = useStyles2(getStyles); - - const renderDataLinks = () => { - const buttonProps: ButtonProps = { - variant: 'secondary', - }; - - return ( - - {dataLinks.map((link, i) => ( - - ))} - - ); +const renderDataLinks = (dataLinks: LinkModel[]) => { + const buttonProps: ButtonProps = { + variant: 'secondary', }; + return ( + + {dataLinks.map((link, i) => ( + + ))} + + ); +}; + +const renderActions = (actions: ActionModel[]) => { + return ( + + {actions.map((action, i) => ( + + ))} + + ); +}; + +export const VizTooltipFooter = ({ dataLinks, actions, annotate }: VizTooltipFooterProps) => { + const styles = useStyles2(getStyles); + return (
- {dataLinks.length > 0 &&
{renderDataLinks()}
} + {dataLinks.length > 0 &&
{renderDataLinks(dataLinks)}
} + {actions && actions.length > 0 &&
{renderActions(actions)}
} {annotate != null && (
+ + + + ); +}; diff --git a/public/app/features/actions/ActionsInlineEditor.tsx b/public/app/features/actions/ActionsInlineEditor.tsx new file mode 100644 index 00000000000..04a59f0714e --- /dev/null +++ b/public/app/features/actions/ActionsInlineEditor.tsx @@ -0,0 +1,192 @@ +import { css } from '@emotion/css'; +import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'; +import { cloneDeep } from 'lodash'; +import { ReactNode, useEffect, useState } from 'react'; + +import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data'; +import { Button } from '@grafana/ui/src/components/Button'; +import { Modal } from '@grafana/ui/src/components/Modal/Modal'; +import { useStyles2 } from '@grafana/ui/src/themes'; + +import { ActionEditorModalContent } from './ActionEditorModalContent'; +import { ActionListItem } from './ActionsListItem'; + +interface ActionsInlineEditorProps { + actions?: Action[]; + onChange: (actions: Action[]) => void; + data: DataFrame[]; + getSuggestions: () => VariableSuggestion[]; + showOneClick?: boolean; +} + +export const ActionsInlineEditor = ({ + actions, + onChange, + data, + getSuggestions, + showOneClick = false, +}: ActionsInlineEditorProps) => { + const [editIndex, setEditIndex] = useState(null); + const [isNew, setIsNew] = useState(false); + + const [actionsSafe, setActionsSafe] = useState([]); + + useEffect(() => { + setActionsSafe(actions ?? []); + }, [actions]); + + const styles = useStyles2(getActionsInlineEditorStyle); + const isEditing = editIndex !== null; + + const onActionChange = (index: number, action: Action) => { + if (isNew) { + if (action.title.trim() === '') { + setIsNew(false); + setEditIndex(null); + return; + } else { + setEditIndex(null); + setIsNew(false); + } + } + const update = cloneDeep(actionsSafe); + update[index] = action; + onChange(update); + + setEditIndex(null); + }; + + const onActionAdd = () => { + let update = cloneDeep(actionsSafe); + setEditIndex(update.length); + setIsNew(true); + }; + + const onActionCancel = (index: number) => { + if (isNew) { + setIsNew(false); + } + setEditIndex(null); + }; + + const onActionRemove = (index: number) => { + const update = cloneDeep(actionsSafe); + update.splice(index, 1); + onChange(update); + }; + + const onDragEnd = (result: DropResult) => { + if (!actions || !result.destination) { + return; + } + + const update = cloneDeep(actionsSafe); + const action = update[result.source.index]; + + update.splice(result.source.index, 1); + update.splice(result.destination.index, 0, action); + + setActionsSafe(update); + onChange(update); + }; + + const renderFirstAction = (actionsJSX: ReactNode, key: string) => { + if (showOneClick) { + return ( +
+ One-click action {actionsJSX} +
+ ); + } + return actionsJSX; + }; + + return ( + <> + + + {(provided) => ( +
+ {actionsSafe.map((action, idx) => { + const key = `${action.title}/${idx}`; + + const actionsJSX = ( +
+ setEditIndex(idx)} + onRemove={() => onActionRemove(idx)} + data={data} + itemKey={key} + /> +
+ ); + + if (idx === 0) { + return renderFirstAction(actionsJSX, key); + } + + return actionsJSX; + })} + {provided.placeholder} +
+ )} +
+
+ + {isEditing && editIndex !== null && ( + { + onActionCancel(editIndex); + }} + > + + + )} + + + + ); +}; + +const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({ + wrapper: css({ + marginBottom: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + }), + oneClickOverlay: css({ + height: 'auto', + border: `2px dashed ${theme.colors.text.link}`, + fontSize: 10, + color: theme.colors.text.primary, + marginBottom: theme.spacing(1), + }), + oneClickSpan: css({ + padding: 10, + // Negates the padding on the span from moving the underlying link + marginBottom: -10, + display: 'inline-block', + }), + itemWrapper: css({ + padding: '4px 8px 8px 8px', + }), + button: css({ + marginLeft: theme.spacing(1), + }), +}); diff --git a/public/app/features/actions/ActionsListItem.tsx b/public/app/features/actions/ActionsListItem.tsx new file mode 100644 index 00000000000..5d69abfeeb7 --- /dev/null +++ b/public/app/features/actions/ActionsListItem.tsx @@ -0,0 +1,111 @@ +import { css, cx } from '@emotion/css'; +import { Draggable } from '@hello-pangea/dnd'; + +import { Action, DataFrame, GrafanaTheme2 } from '@grafana/data'; +import { Icon } from '@grafana/ui/src/components/Icon/Icon'; +import { IconButton } from '@grafana/ui/src/components/IconButton/IconButton'; +import { useStyles2 } from '@grafana/ui/src/themes'; + +export interface ActionsListItemProps { + index: number; + action: Action; + data: DataFrame[]; + onChange: (index: number, action: Action) => void; + onEdit: () => void; + onRemove: () => void; + isEditing?: boolean; + itemKey: string; +} + +export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: ActionsListItemProps) => { + const styles = useStyles2(getActionListItemStyles); + const { title = '' } = action; + + const hasTitle = title.trim() !== ''; + + return ( + + {(provided) => ( + <> +
+
+
+ {hasTitle ? title : 'Action title not provided'} +
+
+
+ + +
+ +
+
+
+ + )} +
+ ); +}; + +const getActionListItemStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + display: 'flex', + flexGrow: 1, + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + padding: '5px 0 5px 10px', + borderRadius: theme.shape.radius.default, + background: theme.colors.background.secondary, + gap: 8, + }), + linkDetails: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }), + errored: css({ + color: theme.colors.error.text, + fontStyle: 'italic', + }), + notConfigured: css({ + fontStyle: 'italic', + }), + title: css({ + color: theme.colors.text.primary, + fontSize: theme.typography.size.sm, + fontWeight: theme.typography.fontWeightMedium, + }), + url: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.size.sm, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: `calc(100% - 100px)`, + }), + dragIcon: css({ + cursor: 'grab', + color: theme.colors.text.secondary, + margin: theme.spacing(0, 0.5), + }), + icon: css({ + color: theme.colors.text.secondary, + }), + dragRow: css({ + position: 'relative', + }), + icons: css({ + display: 'flex', + padding: 6, + alignItems: 'center', + gap: 8, + }), + }; +}; diff --git a/public/app/features/actions/ParamsEditor.tsx b/public/app/features/actions/ParamsEditor.tsx new file mode 100644 index 00000000000..46287cadf89 --- /dev/null +++ b/public/app/features/actions/ParamsEditor.tsx @@ -0,0 +1,130 @@ +import { css } from '@emotion/css'; +import { useEffect, useState } from 'react'; + +import { contentTypeOptions, GrafanaTheme2, VariableSuggestion } from '@grafana/data'; +import { IconButton } from '@grafana/ui/src/components/IconButton/IconButton'; +import { Input } from '@grafana/ui/src/components/Input/Input'; +import { Stack } from '@grafana/ui/src/components/Layout/Stack/Stack'; +import { Select } from '@grafana/ui/src/components/Select/Select'; +import { useStyles2 } from '@grafana/ui/src/themes'; + +import { SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput'; + +interface Props { + onChange: (v: Array<[string, string]>) => void; + value: Array<[string, string]>; + suggestions: VariableSuggestion[]; + contentTypeHeader?: boolean; +} + +export const ParamsEditor = ({ value, onChange, suggestions, contentTypeHeader = false }: Props) => { + const styles = useStyles2(getStyles); + + const headersContentType = value.find(([key, value]) => key === 'Content-Type'); + + const [paramName, setParamName] = useState(''); + const [paramValue, setParamValue] = useState(''); + const [contentTypeParamValue, setContentTypeParamValue] = useState(''); + + useEffect(() => { + if (contentTypeParamValue !== '') { + setContentTypeParamValue(contentTypeParamValue); + } else if (headersContentType) { + setContentTypeParamValue(headersContentType[1]); + } + }, [contentTypeParamValue, headersContentType]); + + // forces re-init of first SuggestionsInput(s), since they are stateful and don't respond to 'value' prop changes to be able to clear them :( + const [entryKey, setEntryKey] = useState(Math.random().toString()); + + const changeParamValue = (paramValue: string) => { + setParamValue(paramValue); + }; + + const changeParamName = (paramName: string) => { + setParamName(paramName); + }; + + const removeParam = (key: string) => () => { + const updatedParams = value.filter((param) => param[0] !== key); + onChange(updatedParams); + }; + + const addParam = (contentType?: [string, string]) => { + let newParams: Array<[string, string]>; + + if (value) { + newParams = value.filter((e) => e[0] !== (contentType ? contentType[0] : paramName)); + } else { + newParams = []; + } + + newParams.push(contentType ?? [paramName, paramValue]); + newParams.sort((a, b) => a[0].localeCompare(b[0])); + onChange(newParams); + + setParamName(''); + setParamValue(''); + setEntryKey(Math.random().toString()); + }; + + const changeContentTypeParamValue = (value: string) => { + setContentTypeParamValue(value); + addParam(['Content-Type', value]); + }; + + const isAddParamsDisabled = paramName === '' || paramValue === ''; + + return ( +
+ + + + addParam()} disabled={isAddParamsDisabled} /> + + + + {Array.from(value.filter((param) => param[0] !== 'Content-Type') || []).map((entry) => ( + + + + + + ))} + + + {contentTypeHeader && ( +
+ + + + {type === HTMLElementType.InputElement ? ( + } autoFocus={autoFocus} /> + ) : ( +