|
| 1 | +/* |
| 2 | + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one |
| 3 | + * or more contributor license agreements. Licensed under the "Elastic License |
| 4 | + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side |
| 5 | + * Public License v 1"; you may not use this file except in compliance with, at |
| 6 | + * your election, the "Elastic License 2.0", the "GNU Affero General Public |
| 7 | + * License v3.0 only", or the "Server Side Public License, v 1". |
| 8 | + */ |
| 9 | + |
| 10 | +import { |
| 11 | + useEuiTheme, |
| 12 | + findElementBySelectorOrRef, |
| 13 | + EuiButtonEmpty, |
| 14 | + EuiPopover, |
| 15 | + EuiForm, |
| 16 | + EuiFormRow, |
| 17 | + EuiPopoverFooter, |
| 18 | + EuiFieldText, |
| 19 | + EuiFlexGroup, |
| 20 | + EuiButton, |
| 21 | + EuiText, |
| 22 | + EuiIconTip, |
| 23 | +} from '@elastic/eui'; |
| 24 | +import { i18n } from '@kbn/i18n'; |
| 25 | +import type { PropsWithChildren } from 'react'; |
| 26 | +import React, { useCallback, useMemo } from 'react'; |
| 27 | +import { FormattedMessage } from '@kbn/i18n-react'; |
| 28 | +import { FieldSelect } from '@kbn/field-utils'; |
| 29 | +import { useKibana } from '@kbn/kibana-react-plugin/public'; |
| 30 | +import { useAddColumnName, errorMessages } from '../../hooks/use_add_column_name'; |
| 31 | +import type { IndexEditorTelemetryService } from '../../telemetry/telemetry_service'; |
| 32 | +import { isPlaceholderColumn } from '../../utils'; |
| 33 | +import type { KibanaContextExtra } from '../../types'; |
| 34 | + |
| 35 | +interface ColumnHeaderPopoverProps { |
| 36 | + isColumnInEditMode: boolean; |
| 37 | + setEditingColumnIndex: (columnIndex: number | null) => void; |
| 38 | + isSavedColumn: boolean; |
| 39 | + isUnsupportedESQLType: boolean; |
| 40 | + initialColumnName: string; |
| 41 | + initialColumnType: string | undefined; |
| 42 | + columnIndex: number; |
| 43 | + telemetryService: IndexEditorTelemetryService; |
| 44 | + originalColumnDisplay: React.ReactNode; |
| 45 | +} |
| 46 | + |
| 47 | +export const COLUMN_INDEX_PROP = 'data-column-index'; |
| 48 | + |
| 49 | +export const ColumnHeaderPopover = ({ |
| 50 | + isColumnInEditMode, |
| 51 | + setEditingColumnIndex, |
| 52 | + isSavedColumn, |
| 53 | + initialColumnName, |
| 54 | + initialColumnType, |
| 55 | + isUnsupportedESQLType, |
| 56 | + columnIndex, |
| 57 | + telemetryService, |
| 58 | + originalColumnDisplay, |
| 59 | +}: PropsWithChildren<ColumnHeaderPopoverProps>) => { |
| 60 | + const { euiTheme } = useEuiTheme(); |
| 61 | + |
| 62 | + const { |
| 63 | + services: { docLinks }, |
| 64 | + } = useKibana<KibanaContextExtra>(); |
| 65 | + |
| 66 | + const { columnType, setColumnType, columnName, setColumnName, saveColumn, validationError } = |
| 67 | + useAddColumnName(initialColumnName, initialColumnType); |
| 68 | + |
| 69 | + const canSubmit = useMemo( |
| 70 | + () => columnType && columnName.length > 0 && !validationError, |
| 71 | + [columnType, columnName, validationError] |
| 72 | + ); |
| 73 | + |
| 74 | + const onSubmit = useCallback( |
| 75 | + (event: React.FormEvent<HTMLFormElement>) => { |
| 76 | + event.preventDefault(); |
| 77 | + event.stopPropagation(); |
| 78 | + |
| 79 | + if (columnName && !validationError) { |
| 80 | + setEditingColumnIndex(null); |
| 81 | + saveColumn(); |
| 82 | + } else { |
| 83 | + telemetryService.trackEditInteraction({ |
| 84 | + actionType: 'add_column', |
| 85 | + failureReason: validationError || 'EMPTY_NAME', |
| 86 | + }); |
| 87 | + } |
| 88 | + }, |
| 89 | + [columnName, validationError, setEditingColumnIndex, saveColumn, telemetryService] |
| 90 | + ); |
| 91 | + |
| 92 | + const columnLabel = isPlaceholderColumn(initialColumnName) ? ( |
| 93 | + <FormattedMessage |
| 94 | + id="indexEditor.flyout.grid.columnHeader.add" |
| 95 | + defaultMessage="Add a column…" |
| 96 | + /> |
| 97 | + ) : ( |
| 98 | + // The default column header display comming from UnifiedDataTable, the type icon + column name |
| 99 | + <EuiFlexGroup alignItems="center" gutterSize="s" wrap={false} css={{ cursor: 'pointer' }}> |
| 100 | + {isUnsupportedESQLType && ( |
| 101 | + <EuiIconTip |
| 102 | + type="warning" |
| 103 | + color="warning" |
| 104 | + size="m" |
| 105 | + content={i18n.translate('indexEditor.columnHeader.unsupportedWarning', { |
| 106 | + defaultMessage: `ES|QL doesn't support the {unsupportedType} data type yet. You can still set columns of this index to this type and save them, but Discover won't display them and they will be hidden from this view if you open it again later.`, |
| 107 | + values: { unsupportedType: initialColumnType }, |
| 108 | + })} |
| 109 | + className="fieldWarningTip" |
| 110 | + anchorProps={{ |
| 111 | + css: { display: 'flex', marginLeft: euiTheme.size.xxs }, |
| 112 | + }} |
| 113 | + /> |
| 114 | + )} |
| 115 | + {originalColumnDisplay} |
| 116 | + </EuiFlexGroup> |
| 117 | + ); |
| 118 | + |
| 119 | + const errorMessage = useMemo(() => { |
| 120 | + if (!validationError) return; |
| 121 | + return errorMessages[validationError] |
| 122 | + ? errorMessages[validationError](columnName) |
| 123 | + : validationError; |
| 124 | + }, [validationError, columnName]); |
| 125 | + |
| 126 | + const returnFocus = useCallback(() => { |
| 127 | + requestAnimationFrame(() => { |
| 128 | + const headerWrapper = findElementBySelectorOrRef(`[${COLUMN_INDEX_PROP}="${columnIndex}"]`); |
| 129 | + |
| 130 | + if (headerWrapper) { |
| 131 | + headerWrapper.focus(); |
| 132 | + } |
| 133 | + }); |
| 134 | + |
| 135 | + return false; |
| 136 | + }, [columnIndex]); |
| 137 | + |
| 138 | + if (isSavedColumn) { |
| 139 | + return columnLabel; |
| 140 | + } |
| 141 | + |
| 142 | + const triggerButton = ( |
| 143 | + // This button is keyboard accesible via the column actions menu. |
| 144 | + // eslint-disable-next-line @elastic/eui/accessible-interactive-element |
| 145 | + <EuiButtonEmpty |
| 146 | + data-test-subj="indexEditorColumnNameButton" |
| 147 | + aria-label={i18n.translate('indexEditor.columnHeaderEdit.ariaLabel', { |
| 148 | + defaultMessage: 'Edit column', |
| 149 | + })} |
| 150 | + css={{ |
| 151 | + color: euiTheme.colors.textSubdued, |
| 152 | + width: '100%', |
| 153 | + height: euiTheme.size.xl, |
| 154 | + }} |
| 155 | + tabIndex={-1} |
| 156 | + flush="left" |
| 157 | + contentProps={{ |
| 158 | + css: { |
| 159 | + justifyContent: 'left', |
| 160 | + }, |
| 161 | + }} |
| 162 | + onClick={() => setEditingColumnIndex(columnIndex)} |
| 163 | + > |
| 164 | + {columnLabel} |
| 165 | + </EuiButtonEmpty> |
| 166 | + ); |
| 167 | + |
| 168 | + return ( |
| 169 | + <EuiPopover |
| 170 | + button={triggerButton} |
| 171 | + isOpen={isColumnInEditMode} |
| 172 | + closePopover={() => setEditingColumnIndex(null)} |
| 173 | + focusTrapProps={{ |
| 174 | + noIsolation: false, |
| 175 | + clickOutsideDisables: false, |
| 176 | + onClickOutside: (e) => { |
| 177 | + // This prevents closing the popover when clicking on the EuiSelect options |
| 178 | + if (e.isTrusted) { |
| 179 | + setEditingColumnIndex(null); |
| 180 | + } |
| 181 | + }, |
| 182 | + returnFocus, |
| 183 | + }} |
| 184 | + hasArrow={false} |
| 185 | + onKeyDown={(e: React.KeyboardEvent) => { |
| 186 | + // This prevents focus for going back to the grid header. |
| 187 | + if (e.key === 'Enter') { |
| 188 | + e.stopPropagation(); |
| 189 | + } |
| 190 | + }} |
| 191 | + > |
| 192 | + <EuiForm component="form" onSubmit={onSubmit} css={{ width: 300 }}> |
| 193 | + <EuiFormRow |
| 194 | + label={i18n.translate('indexEditor.columnHeaderEdit.fieldType', { |
| 195 | + defaultMessage: 'Select a field type', |
| 196 | + })} |
| 197 | + helpText={i18n.translate('indexEditor.columnHeaderEdit.fieldTypeHelpText', { |
| 198 | + defaultMessage: `You won't be able to change the type after saving the lookup index.`, |
| 199 | + })} |
| 200 | + > |
| 201 | + <FieldSelect |
| 202 | + selectedType={columnType || null} |
| 203 | + onTypeChange={setColumnType} |
| 204 | + data-test-subj="indexEditorColumnTypeSelect" |
| 205 | + docLinks={docLinks} |
| 206 | + /> |
| 207 | + </EuiFormRow> |
| 208 | + {(columnType || columnName.length > 0) && ( |
| 209 | + <EuiPopoverFooter> |
| 210 | + <EuiFormRow |
| 211 | + label={i18n.translate('indexEditor.columnHeaderEdit.columnNameLabel', { |
| 212 | + defaultMessage: 'Name', |
| 213 | + })} |
| 214 | + isInvalid={Boolean(errorMessage)} |
| 215 | + error={errorMessage} |
| 216 | + > |
| 217 | + <EuiFieldText |
| 218 | + isInvalid={Boolean(errorMessage)} |
| 219 | + data-test-subj="indexEditorColumnNameInput" |
| 220 | + value={columnName} |
| 221 | + placeholder={i18n.translate('indexEditor.columnHeaderEdit.columnNamePlaceholder', { |
| 222 | + defaultMessage: 'Enter field name', |
| 223 | + })} |
| 224 | + fullWidth |
| 225 | + controlOnly |
| 226 | + compressed |
| 227 | + onChange={(e) => { |
| 228 | + setColumnName(e.target.value); |
| 229 | + }} |
| 230 | + /> |
| 231 | + </EuiFormRow> |
| 232 | + <EuiFlexGroup |
| 233 | + justifyContent="flexEnd" |
| 234 | + gutterSize="m" |
| 235 | + css={{ marginTop: euiTheme.size.l }} |
| 236 | + > |
| 237 | + <EuiButton color="text" size="s" onClick={() => setEditingColumnIndex(null)}> |
| 238 | + <EuiText size="xs"> |
| 239 | + <FormattedMessage |
| 240 | + id="indexEditor.flyout.grid.columnHeader.cancelButton" |
| 241 | + defaultMessage="Cancel" |
| 242 | + /> |
| 243 | + </EuiText> |
| 244 | + </EuiButton> |
| 245 | + <EuiButton |
| 246 | + data-test-subj="indexEditorColumnNameAcceptButton" |
| 247 | + fill |
| 248 | + type="submit" |
| 249 | + disabled={!canSubmit} |
| 250 | + size="s" |
| 251 | + > |
| 252 | + <EuiText size="xs"> |
| 253 | + <FormattedMessage |
| 254 | + id="indexEditor.flyout.grid.columnHeader.acceptButton" |
| 255 | + defaultMessage="Accept" |
| 256 | + /> |
| 257 | + </EuiText> |
| 258 | + </EuiButton> |
| 259 | + </EuiFlexGroup> |
| 260 | + </EuiPopoverFooter> |
| 261 | + )} |
| 262 | + </EuiForm> |
| 263 | + </EuiPopover> |
| 264 | + ); |
| 265 | +}; |
0 commit comments