[ES|QL] Index editor cell validations (#224728)

## Summary
Related issue https://github.com/elastic/kibana/issues/207330

Adds simple type validations when editing/adding a new cell in the index
editor.

<img width="1271" alt="image"
src="https://github.com/user-attachments/assets/6ec9fae4-f0d6-4ab2-b32e-64dc1594c36a"
/>
This commit is contained in:
Sebastian Delle Donne 2025-06-26 09:21:32 +02:00 committed by GitHub
parent 210280580b
commit 531d80aae4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 121 additions and 13 deletions

View file

@ -113,8 +113,15 @@ const DataGrid: React.FC<ESQLDataGridProps> = (props) => {
); );
const CellValueRenderer = useMemo(() => { const CellValueRenderer = useMemo(() => {
return getCellValueRenderer(rows, editingCell, savingDocs, setEditingCell, onValueChange); return getCellValueRenderer(
}, [rows, editingCell, setEditingCell, onValueChange, savingDocs]); rows,
props.columns,
editingCell,
savingDocs,
setEditingCell,
onValueChange
);
}, [rows, props.columns, editingCell, setEditingCell, onValueChange, savingDocs]);
const externalCustomRenderers: CustomCellRenderer = useMemo(() => { const externalCustomRenderers: CustomCellRenderer = useMemo(() => {
return activeColumns.reduce((acc, columnId) => { return activeColumns.reduce((acc, columnId) => {

View file

@ -69,7 +69,7 @@ export const RowColumnCreator = ({ columns }: { columns: DatatableColumn[] }) =>
onChange={updateRow(column.id)} onChange={updateRow(column.id)}
autoFocus={index === 0} autoFocus={index === 0}
css={css` css={css`
min-width: ${230}px; min-width: ${180}px;
`} `}
/> />
</EuiFlexItem> </EuiFlexItem>

View file

@ -7,13 +7,17 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import { EuiFieldText } from '@elastic/eui'; import React, { useMemo, useState } from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { getInputComponentForType } from './value_inputs_factory';
import { KibanaContextExtra } from '../types';
interface ValueInputProps { interface ValueInputProps {
value?: string; value?: string;
columnName?: string; columnName?: string;
columns?: DatatableColumn[];
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void; onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
onEnter?: (value: string) => void; onEnter?: (value: string) => void;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@ -24,33 +28,55 @@ interface ValueInputProps {
export const ValueInput = ({ export const ValueInput = ({
value = '', value = '',
columnName = '', columnName = '',
columns,
onBlur, onBlur,
onEnter, onEnter,
onChange, onChange,
autoFocus = false, autoFocus = false,
className = '', className = '',
}: ValueInputProps) => { }: ValueInputProps) => {
const {
services: { notifications },
} = useKibana<KibanaContextExtra>();
const [editValue, setEditValue] = useState(value); const [editValue, setEditValue] = useState(value);
const [error, setError] = useState<string | null>(null);
const columnType = useMemo(() => {
if (!columns || !columnName) return;
const col = columns.find((c) => c.name === columnName);
return col?.meta?.type;
}, [columns, columnName]);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
// Perform Validations if (error) {
notifications.toasts.addDanger({
title: error,
});
return;
}
onEnter?.(editValue); onEnter?.(editValue);
} }
}; };
const onBlurHandler = (event: React.FocusEvent<HTMLInputElement>) => { const onBlurHandler = (event: React.FocusEvent<HTMLInputElement>) => {
if (onBlur) { if (error) {
onBlur(event); notifications.toasts.addDanger({
title: error,
});
return;
} }
// Perform Validations ? onBlur?.(event);
}; };
const InputComponent = useMemo(() => getInputComponentForType(columnType), [columnType]);
return ( return (
<EuiFieldText <InputComponent
autoFocus={autoFocus} autoFocus={autoFocus}
compressed
placeholder={columnName} placeholder={columnName}
label={columnName}
value={editValue} value={editValue}
aria-label={i18n.translate('indexEditor.cellValueInput.aria', { aria-label={i18n.translate('indexEditor.cellValueInput.aria', {
defaultMessage: 'Value for {columnName}', defaultMessage: 'Value for {columnName}',
@ -60,6 +86,7 @@ export const ValueInput = ({
setEditValue(e.target.value); setEditValue(e.target.value);
onChange?.(e.target.value); onChange?.(e.target.value);
}} }}
onError={setError}
onBlur={onBlurHandler} onBlur={onBlurHandler}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className={className} className={className}

View file

@ -12,6 +12,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { type DataGridCellValueElementProps } from '@kbn/unified-data-table'; import { type DataGridCellValueElementProps } from '@kbn/unified-data-table';
import type { DataTableRecord } from '@kbn/discover-utils'; import type { DataTableRecord } from '@kbn/discover-utils';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import type { PendingSave } from '../index_update_service'; import type { PendingSave } from '../index_update_service';
import { ValueInput } from './value_input'; import { ValueInput } from './value_input';
@ -20,6 +21,7 @@ export type OnCellValueChange = (docId: string, update: any) => void;
export const getCellValueRenderer = export const getCellValueRenderer =
( (
rows: DataTableRecord[], rows: DataTableRecord[],
columns: DatatableColumn[],
editingCell: { row: number | null; col: string | null }, editingCell: { row: number | null; col: string | null },
savingDocs: PendingSave | undefined, savingDocs: PendingSave | undefined,
onEditStart: (update: { row: number | null; col: string | null }) => void, onEditStart: (update: { row: number | null; col: string | null }) => void,
@ -40,9 +42,8 @@ export const getCellValueRenderer =
cellValue = pendingSaveValue; cellValue = pendingSaveValue;
} else if (row.flattened) { } else if (row.flattened) {
// Otherwise, use the value from the row // Otherwise, use the value from the row
cellValue = row.flattened[columnId]; cellValue = row.flattened[columnId]?.toString();
} }
if (cellValue == null) { if (cellValue == null) {
return null; return null;
} }
@ -60,6 +61,7 @@ export const getCellValueRenderer =
onValueChange(docId!, { [columnId]: value }); onValueChange(docId!, { [columnId]: value });
}} }}
columnName={columnId} columnName={columnId}
columns={columns}
value={cellValue} value={cellValue}
autoFocus autoFocus
/> />

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback } from 'react';
import { EuiFieldText, EuiFieldNumber } from '@elastic/eui';
import { DatatableColumnType } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
export interface ValueInputProps {
onError?: (error: string | null) => void;
value: string;
label?: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
className?: string;
isInvalid?: boolean;
placeholder?: string;
}
export const StringInput: React.FC<ValueInputProps> = ({ onError, ...restOfProps }) => (
<EuiFieldText compressed {...restOfProps} />
);
export const NumberInput: React.FC<ValueInputProps> = ({ onError, ...restOfProps }) => (
<EuiFieldNumber compressed {...restOfProps} />
);
export const BooleanInput: React.FC<ValueInputProps> = ({ onError, onChange, ...restOfProps }) => {
const [error, setError] = React.useState<string | null>(null);
const onChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!['true', 'false'].includes(e.target.value.toLowerCase())) {
const booleanError = i18n.translate('indexEditor.cellValueInput.validation.boolean', {
defaultMessage: 'Value must be true or false',
});
setError(booleanError);
onError?.(booleanError);
} else {
setError(null);
onError?.(null);
}
onChange?.(e);
},
[onError, onChange]
);
return (
<EuiFieldText compressed {...restOfProps} onChange={onChangeHandler} isInvalid={!!error} />
);
};
export function getInputComponentForType(
type: DatatableColumnType | undefined
): React.FC<ValueInputProps> {
switch (type) {
case 'number':
return NumberInput;
case 'boolean':
return BooleanInput;
default:
return StringInput;
}
}