From 339379e9e884fc51aa1b790d46e388b90ac9f52f Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Wed, 10 Apr 2024 16:49:49 -0300 Subject: [PATCH] [Discover] [Unified Data Table] Add document comparison mode (#166577) ## Summary This PR adds a new document comparison mode to Discover and Unified Data Table: ![diff](https://github.com/elastic/kibana/assets/25592674/40edf401-9514-4388-87a2-c528dbdd6f90) Flaky test runner x50: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5590. Resolves #93567. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/__mocks__/es_hits.ts | 37 ++ packages/kbn-unified-data-table/index.ts | 15 +- .../compare_documents.test.tsx | 173 +++++++ .../compare_documents/compare_documents.tsx | 215 ++++++++ .../comparison_controls.test.tsx | 196 ++++++++ .../compare_documents/comparison_controls.tsx | 442 ++++++++++++++++ .../comparison_toolbar.test.tsx | 77 +++ .../compare_documents/comparison_toolbar.tsx | 44 ++ .../use_comparison_cell_value.test.tsx.snap | 449 +++++++++++++++++ .../use_comparison_css.test.ts.snap | 473 ++++++++++++++++++ .../hooks/calculate_diff.test.ts | 298 +++++++++++ .../compare_documents/hooks/calculate_diff.ts | 60 +++ .../hooks/use_comparison_cell_value.test.tsx | 446 +++++++++++++++++ .../hooks/use_comparison_cell_value.tsx | 251 ++++++++++ .../hooks/use_comparison_columns.test.tsx | 230 +++++++++ .../hooks/use_comparison_columns.tsx | 173 +++++++ .../hooks/use_comparison_css.test.ts | 65 +++ .../hooks/use_comparison_css.ts | 128 +++++ .../hooks/use_comparison_fields.test.ts | 151 ++++++ .../hooks/use_comparison_fields.ts | 83 +++ .../components/compare_documents/index.tsx | 12 + .../src/components/compare_documents/types.ts | 9 + .../render_custom_toolbar.test.tsx.snap | 52 +- .../render_custom_toolbar.scss | 12 +- .../render_custom_toolbar.test.tsx | 0 .../custom_toolbar}/render_custom_toolbar.tsx | 57 ++- .../src/components/data_table.scss | 4 - .../src/components/data_table.test.tsx | 122 ++++- .../src/components/data_table.tsx | 151 ++++-- .../data_table_document_selection.test.tsx | 46 +- .../data_table_document_selection.tsx | 107 +++- .../src/hooks/use_full_screen_watcher.ts | 2 +- .../src/utils/get_render_cell_value.tsx | 2 +- packages/kbn-unified-data-table/tsconfig.json | 2 + .../context/context_app_content.test.tsx | 4 +- .../layout/discover_documents.test.tsx | 4 +- .../components/layout/discover_documents.tsx | 6 +- .../discover_grid/discover_grid.tsx | 16 +- .../discover_grid_flyout.test.tsx | 2 +- .../discover_grid_flyout.tsx | 2 +- .../doc_table/components/table_row.test.tsx | 2 +- .../components/table_row_details.tsx | 2 +- .../public/embeddable/saved_search_grid.tsx | 7 +- .../embeddable/_saved_search_embeddable.ts | 2 +- .../apps/discover/group2/_data_grid.ts | 2 +- .../discover/group2/_data_grid_context.ts | 2 +- .../apps/discover/group3/_view_mode_toggle.ts | 4 +- .../discover/group4/_document_comparison.ts | 262 ++++++++++ test/functional/apps/discover/group4/index.ts | 1 + test/functional/services/data_grid.ts | 140 +++++- .../embeddable/_saved_search_embeddable.ts | 2 +- 51 files changed, 4855 insertions(+), 189 deletions(-) create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.test.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.test.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.test.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_cell_value.test.tsx.snap create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_css.test.ts.snap create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.test.ts create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.ts create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.test.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_columns.test.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_columns.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_css.test.ts create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_css.ts create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_fields.test.ts create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_fields.ts create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/index.tsx create mode 100644 packages/kbn-unified-data-table/src/components/compare_documents/types.ts rename {src/plugins/discover/public/components/discover_grid => packages/kbn-unified-data-table/src/components/custom_toolbar}/__snapshots__/render_custom_toolbar.test.tsx.snap (72%) rename {src/plugins/discover/public/components/discover_grid => packages/kbn-unified-data-table/src/components/custom_toolbar}/render_custom_toolbar.scss (79%) rename {src/plugins/discover/public/components/discover_grid => packages/kbn-unified-data-table/src/components/custom_toolbar}/render_custom_toolbar.test.tsx (100%) rename {src/plugins/discover/public/components/discover_grid => packages/kbn-unified-data-table/src/components/custom_toolbar}/render_custom_toolbar.tsx (63%) create mode 100644 test/functional/apps/discover/group4/_document_comparison.ts diff --git a/packages/kbn-discover-utils/src/__mocks__/es_hits.ts b/packages/kbn-discover-utils/src/__mocks__/es_hits.ts index 84891fec5ab107..0cde2c6a00d193 100644 --- a/packages/kbn-discover-utils/src/__mocks__/es_hits.ts +++ b/packages/kbn-discover-utils/src/__mocks__/es_hits.ts @@ -6,6 +6,10 @@ * Side Public License, v 1. */ +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { KBN_FIELD_TYPES } from '@kbn/field-types'; +import type { EsHitRecord } from '../types'; + export const esHitsMock = [ { _index: 'i', @@ -54,3 +58,36 @@ export const esHitsMockWithSort = esHitsMock.map((hit) => ({ ...hit, sort: [hit._source.date], // some `sort` param should be specified for "fetch more" to work })); + +const baseDate = new Date('2024-01-1').getTime(); +const dateInc = 100_000_000; + +const generateFieldValue = (field: DataViewField, index: number) => { + switch (field.type) { + case KBN_FIELD_TYPES.BOOLEAN: + return index % 2 === 0; + case KBN_FIELD_TYPES.DATE: + return new Date(baseDate + index * dateInc).toISOString(); + case KBN_FIELD_TYPES.NUMBER: + return Array.from(field.name).reduce((sum, char) => sum + char.charCodeAt(0) + index, 0); + case KBN_FIELD_TYPES.STRING: + return `${field.name}_${index}`; + default: + throw new Error(`Unsupported type ${field.type}`); + } +}; + +export const generateEsHits = (dataView: DataView, count: number): EsHitRecord[] => { + return Array.from({ length: count }, (_, i) => ({ + _index: 'i', + _id: i.toString(), + _score: 1, + fields: dataView.fields.reduce>( + (source, field) => ({ + ...source, + [field.name]: [generateFieldValue(field, i)], + }), + {} + ), + })); +}; diff --git a/packages/kbn-unified-data-table/index.ts b/packages/kbn-unified-data-table/index.ts index f59b98cda99d92..26095d948cb8ae 100644 --- a/packages/kbn-unified-data-table/index.ts +++ b/packages/kbn-unified-data-table/index.ts @@ -7,11 +7,7 @@ */ export { UnifiedDataTable, DataLoadingState } from './src/components/data_table'; -export type { - UnifiedDataTableProps, - UnifiedDataTableRenderCustomToolbar, - UnifiedDataTableRenderCustomToolbarProps, -} from './src/components/data_table'; +export type { UnifiedDataTableProps } from './src/components/data_table'; export { RowHeightSettings, type RowHeightSettingsProps, @@ -31,3 +27,12 @@ export { popularizeField } from './src/utils/popularize_field'; export { useColumns } from './src/hooks/use_data_grid_columns'; export { OPEN_DETAILS, SELECT_ROW } from './src/components/data_table_columns'; export { DataTableRowControl } from './src/components/data_table_row_control'; + +export type { + UnifiedDataTableRenderCustomToolbar, + UnifiedDataTableRenderCustomToolbarProps, +} from './src/components/custom_toolbar/render_custom_toolbar'; +export { + getRenderCustomToolbarWithElements, + renderCustomToolbar, +} from './src/components/custom_toolbar/render_custom_toolbar'; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.test.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.test.tsx new file mode 100644 index 00000000000000..5bf4c631cf5cfa --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.test.tsx @@ -0,0 +1,173 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import type { EuiDataGridProps } from '@elastic/eui'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { generateEsHits } from '@kbn/discover-utils/src/__mocks__'; +import { render } from '@testing-library/react'; +import { omit } from 'lodash'; +import React from 'react'; +import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield'; +import CompareDocuments, { CompareDocumentsProps } from './compare_documents'; +import { useComparisonFields } from './hooks/use_comparison_fields'; + +let mockLocalStorage: Record = {}; + +jest.mock('react-use/lib/useLocalStorage', () => + jest.fn((key: string, value: unknown) => { + mockLocalStorage[key] = JSON.stringify(value); + return [value, jest.fn()]; + }) +); + +let mockDataGridProps: EuiDataGridProps | undefined; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + EuiDataGrid: jest.fn((props) => { + mockDataGridProps = props; + return <>; + }), +})); + +jest.mock('./hooks/use_comparison_fields', () => { + const originalModule = jest.requireActual('./hooks/use_comparison_fields'); + return { + ...originalModule, + useComparisonFields: jest.fn(originalModule.useComparisonFields), + }; +}); + +const docs = generateEsHits(dataViewWithTimefieldMock, 5).map((hit) => + buildDataTableRecord(hit, dataViewWithTimefieldMock) +); + +const getDocById = (id: string) => docs.find((doc) => doc.raw._id === id); + +const renderCompareDocuments = ({ + forceShowAllFields = false, +}: { forceShowAllFields?: boolean } = {}) => { + const setSelectedDocs = jest.fn(); + const getCompareDocuments = (props?: Partial) => ( + + ); + const { rerender } = render(getCompareDocuments()); + return { + setSelectedDocs, + rerender: (props?: Partial) => rerender(getCompareDocuments(props)), + }; +}; + +describe('CompareDocuments', () => { + beforeEach(() => { + mockLocalStorage = {}; + mockDataGridProps = undefined; + }); + + it('should pass expected grid props', () => { + renderCompareDocuments(); + expect(mockDataGridProps).toBeDefined(); + expect(mockDataGridProps?.columns).toBeDefined(); + expect(mockDataGridProps?.css).toBeDefined(); + expect(omit(mockDataGridProps, 'columns', 'css')).toMatchInlineSnapshot(` + Object { + "aria-describedby": "test", + "aria-labelledby": "test", + "columnVisibility": Object { + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "fields_generated-id", + "0", + "1", + "2", + ], + }, + "data-test-subj": "unifiedDataTableCompareDocuments", + "gridStyle": Object { + "border": "horizontal", + "cellPadding": "l", + "fontSize": "s", + "header": "underline", + "rowHover": "highlight", + "stripes": undefined, + }, + "id": "test", + "inMemory": Object { + "level": "sorting", + }, + "renderCellValue": [Function], + "renderCustomToolbar": [Function], + "rowCount": 3, + "rowHeightsOptions": Object { + "defaultHeight": "auto", + }, + "schemaDetectors": Array [], + "toolbarVisibility": Object { + "showColumnSelector": false, + "showDisplaySelector": false, + "showFullScreenSelector": true, + }, + } + `); + }); + + it('should get values from local storage', () => { + renderCompareDocuments(); + expect(mockLocalStorage).toEqual({ + 'test:dataGridComparisonDiffMode': '"basic"', + 'test:dataGridComparisonShowAllFields': 'false', + 'test:dataGridComparisonShowDiff': 'true', + 'test:dataGridComparisonShowDiffDecorations': 'true', + 'test:dataGridComparisonShowMatchingValues': 'true', + }); + }); + + it('should set selected docs when columns change', () => { + const { setSelectedDocs } = renderCompareDocuments(); + const visibleColumns = ['fields_generated-id', '0', '1', '2']; + mockDataGridProps?.columnVisibility.setVisibleColumns(visibleColumns); + expect(setSelectedDocs).toHaveBeenCalledWith(visibleColumns.slice(1)); + }); + + it('should force show all fields when prop is true', () => { + renderCompareDocuments(); + expect(useComparisonFields).toHaveBeenLastCalledWith( + expect.objectContaining({ showAllFields: false }) + ); + renderCompareDocuments({ forceShowAllFields: true }); + expect(useComparisonFields).toHaveBeenLastCalledWith( + expect.objectContaining({ showAllFields: true }) + ); + }); + + it('should retain comparison docs when getDocById loses access to them', () => { + const { rerender } = renderCompareDocuments(); + const visibleColumns = ['fields_generated-id', '0', '1', '2']; + expect(mockDataGridProps?.columnVisibility.visibleColumns).toEqual(visibleColumns); + rerender({ getDocById: () => undefined }); + expect(mockDataGridProps?.columnVisibility.visibleColumns).toEqual(visibleColumns); + }); +}); diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.tsx new file mode 100644 index 00000000000000..b6f22b67603626 --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/compare_documents.tsx @@ -0,0 +1,215 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { + EuiDataGrid, + EuiDataGridColumnVisibility, + EuiDataGridInMemory, + EuiDataGridProps, + EuiDataGridRowHeightsOptions, + EuiDataGridSchemaDetector, + EuiDataGridStyle, + EuiDataGridToolBarVisibilityOptions, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { DataTableRecord } from '@kbn/discover-utils/types'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import { memoize } from 'lodash'; +import React, { useMemo, useState } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { GRID_STYLE } from '../../constants'; +import { ComparisonControls } from './comparison_controls'; +import { renderComparisonToolbar } from './comparison_toolbar'; +import { useComparisonCellValue } from './hooks/use_comparison_cell_value'; +import { useComparisonColumns } from './hooks/use_comparison_columns'; +import { useComparisonCss } from './hooks/use_comparison_css'; +import { useComparisonFields } from './hooks/use_comparison_fields'; +import type { DocumentDiffMode } from './types'; + +export interface CompareDocumentsProps { + id: string; + wrapper: HTMLElement | null; + consumer: string; + ariaDescribedBy: string; + ariaLabelledBy: string; + dataView: DataView; + isPlainRecord: boolean; + selectedFieldNames: string[]; + selectedDocs: string[]; + schemaDetectors: EuiDataGridSchemaDetector[]; + forceShowAllFields: boolean; + showFullScreenButton?: boolean; + fieldFormats: FieldFormatsStart; + getDocById: (id: string) => DataTableRecord | undefined; + setSelectedDocs: (selectedDocs: string[]) => void; + setIsCompareActive: (isCompareActive: boolean) => void; +} + +const COMPARISON_ROW_HEIGHT: EuiDataGridRowHeightsOptions = { defaultHeight: 'auto' }; +const COMPARISON_IN_MEMORY: EuiDataGridInMemory = { level: 'sorting' }; +const COMPARISON_GRID_STYLE: EuiDataGridStyle = { ...GRID_STYLE, stripes: undefined }; + +const getStorageKey = (consumer: string, key: string) => `${consumer}:dataGridComparison${key}`; + +const CompareDocuments = ({ + id, + wrapper, + consumer, + ariaDescribedBy, + ariaLabelledBy, + dataView, + isPlainRecord, + selectedFieldNames, + selectedDocs, + schemaDetectors, + forceShowAllFields, + showFullScreenButton, + fieldFormats, + getDocById, + setSelectedDocs, + setIsCompareActive, +}: CompareDocumentsProps) => { + // Memoize getDocById to ensure we don't lose access to the comparison docs if, for example, + // a time range change or auto refresh causes the previous docs to no longer be available + const [memoizedGetDocById] = useState(() => memoize(getDocById)); + const [showDiff, setShowDiff] = useLocalStorage(getStorageKey(consumer, 'ShowDiff'), true); + const [diffMode, setDiffMode] = useLocalStorage( + getStorageKey(consumer, 'DiffMode'), + 'basic' + ); + const [showDiffDecorations, setShowDiffDecorations] = useLocalStorage( + getStorageKey(consumer, 'ShowDiffDecorations'), + true + ); + const [showAllFields, setShowAllFields] = useLocalStorage( + getStorageKey(consumer, 'ShowAllFields'), + false + ); + const [showMatchingValues, setShowMatchingValues] = useLocalStorage( + getStorageKey(consumer, 'ShowMatchingValues'), + true + ); + + const fieldColumnId = useGeneratedHtmlId({ prefix: 'fields' }); + const { comparisonFields, totalFields } = useComparisonFields({ + dataView, + selectedFieldNames, + selectedDocs, + showAllFields: Boolean(forceShowAllFields || showAllFields), + showMatchingValues: Boolean(showMatchingValues), + getDocById: memoizedGetDocById, + }); + const comparisonColumns = useComparisonColumns({ + wrapper, + isPlainRecord, + fieldColumnId, + selectedDocs, + getDocById: memoizedGetDocById, + setSelectedDocs, + }); + const comparisonColumnVisibility = useMemo( + () => ({ + visibleColumns: comparisonColumns.map(({ id: columnId }) => columnId), + setVisibleColumns: (visibleColumns) => { + const [_fieldColumnId, ...newSelectedDocs] = visibleColumns; + setSelectedDocs(newSelectedDocs); + }, + }), + [comparisonColumns, setSelectedDocs] + ); + const additionalControls = useMemo( + () => ( + + ), + [ + diffMode, + forceShowAllFields, + isPlainRecord, + selectedDocs, + setDiffMode, + setIsCompareActive, + setShowAllFields, + setShowDiff, + setShowDiffDecorations, + setShowMatchingValues, + showAllFields, + showDiff, + showDiffDecorations, + showMatchingValues, + ] + ); + const comparisonToolbarVisibility = useMemo( + () => ({ + showColumnSelector: false, + showDisplaySelector: false, + showFullScreenSelector: showFullScreenButton, + }), + [showFullScreenButton] + ); + const renderCustomToolbarFn = useMemo( + () => + renderComparisonToolbar({ + additionalControls, + comparisonFields, + totalFields, + }), + [additionalControls, comparisonFields, totalFields] + ); + const renderCellValue = useComparisonCellValue({ + dataView, + comparisonFields, + fieldColumnId, + selectedDocs, + diffMode: showDiff ? diffMode : undefined, + fieldFormats, + getDocById: memoizedGetDocById, + }); + const comparisonCss = useComparisonCss({ + diffMode: showDiff ? diffMode : undefined, + showDiffDecorations, + }); + + return ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default CompareDocuments; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.test.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.test.tsx new file mode 100644 index 00000000000000..f95c547fac0acd --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.test.tsx @@ -0,0 +1,196 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { useState } from 'react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { ComparisonControls, ComparisonControlsProps } from './comparison_controls'; +import { DocumentDiffMode } from './types'; + +const renderComparisonControls = ({ + isPlainRecord = false, + forceShowAllFields = false, +}: { + isPlainRecord?: ComparisonControlsProps['isPlainRecord']; + forceShowAllFields?: ComparisonControlsProps['forceShowAllFields']; +} = {}) => { + const selectedDocs = ['0', '1', '2']; + const Wrapper = () => { + const [showDiff, setShowDiff] = useState(true); + const [diffMode, setDiffMode] = useState('basic'); + const [showDiffDecorations, setShowDiffDecorations] = useState(true); + const [showMatchingValues, setShowMatchingValues] = useState(true); + const [showAllFields, setShowAllFields] = useState(true); + const [isCompareActive, setIsCompareActive] = useState(true); + return ( + <> + {isCompareActive && Comparison active} + + + + + ); + }; + render(); + const getComparisonSettingsButton = () => + screen.getByRole('button', { name: 'Comparison settings' }); + const getShowDiffSwitch = () => screen.getByTestId('unifiedDataTableShowDiffSwitch'); + const getDiffModeEntry = (mode: DocumentDiffMode) => + screen.getByTestId(`unifiedDataTableDiffMode-${mode}`); + const getShowAllFieldsSwitch = () => + screen.queryByTestId('unifiedDataTableDiffOptionSwitch-showAllFields'); + const getShowMatchingValuesSwitch = () => + screen.getByTestId('unifiedDataTableDiffOptionSwitch-showMatchingValues'); + const getShowDiffDecorationsSwitch = () => + screen.getByTestId('unifiedDataTableDiffOptionSwitch-showDiffDecorations'); + return { + getComparisonCountDisplay: () => + screen.getByText( + `Comparing ${selectedDocs.length} ${isPlainRecord ? 'results' : 'documents'}` + ), + getComparisonSettingsButton, + clickComparisonSettingsButton: () => userEvent.click(getComparisonSettingsButton()), + getShowDiffSwitch, + clickShowDiffSwitch: () => + userEvent.click(getShowDiffSwitch(), undefined, { + skipPointerEventsCheck: true, + }), + clickDiffModeFullValueButton: () => + userEvent.click(screen.getByRole('button', { name: 'Full value' }), undefined, { + skipPointerEventsCheck: true, + }), + clickDiffModeByCharacterButton: () => + userEvent.click(screen.getByRole('button', { name: 'By character' }), undefined, { + skipPointerEventsCheck: true, + }), + clickDiffModeByWordButton: () => + userEvent.click(screen.getByRole('button', { name: 'By word' }), undefined, { + skipPointerEventsCheck: true, + }), + clickDiffModeByLineButton: () => + userEvent.click(screen.getByRole('button', { name: 'By line' }), undefined, { + skipPointerEventsCheck: true, + }), + getDiffModeEntry, + diffModeIsSelected: (mode: DocumentDiffMode) => + getDiffModeEntry(mode).getAttribute('aria-current') === 'true', + getShowAllFieldsSwitch, + clickShowAllFieldsSwitch: () => { + const fieldSwitch = getShowAllFieldsSwitch(); + if (fieldSwitch) { + userEvent.click(fieldSwitch, undefined, { + skipPointerEventsCheck: true, + }); + } + }, + getShowMatchingValuesSwitch, + clickShowMatchingValuesSwitch: () => + userEvent.click(getShowMatchingValuesSwitch(), undefined, { + skipPointerEventsCheck: true, + }), + getShowDiffDecorationsSwitch, + clickShowDiffDecorationsSwitch: () => + userEvent.click(getShowDiffDecorationsSwitch(), undefined, { + skipPointerEventsCheck: true, + }), + getExitComparisonButton: () => screen.getByRole('button', { name: 'Exit comparison mode' }), + isCompareActive: () => screen.queryByText('Comparison active') !== null, + }; +}; + +describe('ComparisonControls', () => { + it('should render', () => { + const result = renderComparisonControls(); + expect(result.getComparisonCountDisplay()).toBeInTheDocument(); + expect(result.getComparisonSettingsButton()).toBeInTheDocument(); + expect(result.getExitComparisonButton()).toBeInTheDocument(); + }); + + it('should render with isPlainRecord = true', () => { + const result = renderComparisonControls({ isPlainRecord: true }); + expect(result.getComparisonCountDisplay()).toBeInTheDocument(); + expect(result.getComparisonSettingsButton()).toBeInTheDocument(); + expect(result.getExitComparisonButton()).toBeInTheDocument(); + }); + + it('should allow toggling show diff switch', () => { + const result = renderComparisonControls(); + result.clickComparisonSettingsButton(); + expect(result.getShowDiffSwitch()).toBeChecked(); + expect(result.getDiffModeEntry('basic')).toBeEnabled(); + expect(result.getDiffModeEntry('chars')).toBeEnabled(); + expect(result.getDiffModeEntry('words')).toBeEnabled(); + expect(result.getDiffModeEntry('lines')).toBeEnabled(); + expect(result.getShowDiffDecorationsSwitch()).toBeEnabled(); + result.clickShowDiffSwitch(); + expect(result.getShowDiffSwitch()).not.toBeChecked(); + expect(result.getDiffModeEntry('basic')).toBeDisabled(); + expect(result.getDiffModeEntry('chars')).toBeDisabled(); + expect(result.getDiffModeEntry('words')).toBeDisabled(); + expect(result.getDiffModeEntry('lines')).toBeDisabled(); + expect(result.getShowDiffDecorationsSwitch()).toBeDisabled(); + result.clickShowDiffSwitch(); + expect(result.getShowDiffSwitch()).toBeChecked(); + }); + + it('should allow changing diff mode', () => { + const result = renderComparisonControls(); + result.clickComparisonSettingsButton(); + expect(result.diffModeIsSelected('basic')).toBe(true); + result.clickDiffModeByCharacterButton(); + expect(result.diffModeIsSelected('chars')).toBe(true); + result.clickDiffModeByWordButton(); + expect(result.diffModeIsSelected('words')).toBe(true); + result.clickDiffModeByLineButton(); + expect(result.diffModeIsSelected('lines')).toBe(true); + result.clickDiffModeFullValueButton(); + expect(result.diffModeIsSelected('basic')).toBe(true); + }); + + it('should allow toggling options', () => { + const result = renderComparisonControls(); + result.clickComparisonSettingsButton(); + expect(result.getShowAllFieldsSwitch()).toBeChecked(); + expect(result.getShowMatchingValuesSwitch()).toBeChecked(); + expect(result.getShowDiffDecorationsSwitch()).toBeChecked(); + result.clickShowAllFieldsSwitch(); + expect(result.getShowAllFieldsSwitch()).not.toBeChecked(); + result.clickShowMatchingValuesSwitch(); + expect(result.getShowMatchingValuesSwitch()).not.toBeChecked(); + result.clickShowDiffDecorationsSwitch(); + expect(result.getShowDiffDecorationsSwitch()).not.toBeChecked(); + }); + + it('should hide showAllFields switch when forceShowAllFields is true', () => { + const result = renderComparisonControls({ forceShowAllFields: true }); + expect(result.getShowAllFieldsSwitch()).not.toBeInTheDocument(); + }); + + it('should exit comparison mode', () => { + const result = renderComparisonControls(); + expect(result.isCompareActive()).toBe(true); + userEvent.click(result.getExitComparisonButton()); + expect(result.isCompareActive()).toBe(false); + }); +}); diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.tsx new file mode 100644 index 00000000000000..6787c9e56dd05d --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_controls.tsx @@ -0,0 +1,442 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { + EuiContextMenuItem, + EuiContextMenuItemProps, + EuiContextMenuPanel, + EuiDataGridToolbarControl, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIconTip, + EuiPopover, + EuiSwitch, + EuiSwitchProps, + EuiText, + EuiTitle, + EuiTitleSize, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { FC, ReactNode, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { DocumentDiffMode } from './types'; + +export interface ComparisonControlsProps { + isPlainRecord?: boolean; + selectedDocs: string[]; + showDiff: boolean | undefined; + diffMode: DocumentDiffMode | undefined; + showDiffDecorations: boolean | undefined; + showMatchingValues: boolean | undefined; + showAllFields: boolean | undefined; + forceShowAllFields: boolean; + setIsCompareActive: (isCompareActive: boolean) => void; + setShowDiff: (showDiff: boolean) => void; + setDiffMode: (diffMode: DocumentDiffMode) => void; + setShowDiffDecorations: (showDiffDecorations: boolean) => void; + setShowMatchingValues: (showMatchingValues: boolean) => void; + setShowAllFields: (showAllFields: boolean) => void; +} + +export const ComparisonControls = ({ + isPlainRecord, + selectedDocs, + showDiff, + diffMode, + showDiffDecorations, + showMatchingValues, + showAllFields, + forceShowAllFields, + setIsCompareActive, + setShowDiff, + setDiffMode, + setShowDiffDecorations, + setShowMatchingValues, + setShowAllFields, +}: ComparisonControlsProps) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + {isPlainRecord ? ( + + ) : ( + + )} + + + + + + + + + +
+ { + setIsCompareActive(false); + }} + data-test-subj="unifiedDataTableExitDocumentComparison" + > + + +
+
+
+ ); +}; + +const showDiffLabel = i18n.translate('unifiedDataTable.showDiff', { + defaultMessage: 'Show diff', +}); + +const ComparisonSettings = ({ + showDiff, + diffMode, + showDiffDecorations, + showMatchingValues, + showAllFields, + forceShowAllFields, + setShowDiff, + setDiffMode, + setShowDiffDecorations, + setShowMatchingValues, + setShowAllFields, +}: Pick< + ComparisonControlsProps, + | 'showDiff' + | 'diffMode' + | 'showDiffDecorations' + | 'showMatchingValues' + | 'showAllFields' + | 'forceShowAllFields' + | 'setShowDiff' + | 'setDiffMode' + | 'setShowDiffDecorations' + | 'setShowMatchingValues' + | 'setShowAllFields' +>) => { + const [isSettingsMenuOpen, setIsSettingsMenuOpen] = useState(false); + + return ( + + { + setIsSettingsMenuOpen(!isSettingsMenuOpen); + }} + data-test-subj="unifiedDataTableComparisonSettings" + > + + + + } + isOpen={isSettingsMenuOpen} + closePopover={() => { + setIsSettingsMenuOpen(false); + }} + panelPaddingSize="none" + anchorPosition="downCenter" + > + + { + setShowDiff(e.target.checked); + }} + data-test-subj="unifiedDataTableShowDiffSwitch" + /> + } + /> + + } + type="subsection" + /> + + + + + + + } + description={ + + } + type="subsection" + noPadding + /> + + + + + + + + + + + + + + + + } + /> + + {!forceShowAllFields && ( + { + setShowAllFields(e.target.checked); + }} + data-test-subj="showAllFields" + itemCss={{ paddingBottom: 0 }} + /> + )} + + { + setShowMatchingValues(e.target.checked); + }} + data-test-subj="showMatchingValues" + itemCss={{ paddingBottom: 0 }} + /> + + { + setShowDiffDecorations(e.target.checked); + }} + /> + + + ); +}; + +const SectionHeader = ({ + title, + description, + type = 'section', + noPadding, + append, +}: { + title: ReactNode; + description?: ReactNode; + type?: 'section' | 'subsection'; + noPadding?: boolean; + append?: ReactNode; +}) => { + const { euiTheme } = useEuiTheme(); + const size: EuiTitleSize = type === 'section' ? 'xxs' : 'xxxs'; + const HeadingTag = type === 'section' ? 'h3' : 'h4'; + + return ( + + + + + + {title} + + + {description && ( + + + + )} + + {append && {append}} + + + ); +}; + +const enableShowDiffTooltip = i18n.translate('unifiedDataTable.enableShowDiff', { + defaultMessage: 'You need to enable Show diff', +}); + +const DiffModeEntry: FC< + Pick & { + entryDiffMode: DocumentDiffMode; + disabled?: boolean; + } +> = ({ children, entryDiffMode, diffMode, disabled, setDiffMode }) => { + const { euiTheme } = useEuiTheme(); + + return ( + { + setDiffMode(entryDiffMode); + }} + data-test-subj={`unifiedDataTableDiffMode-${entryDiffMode}`} + css={{ paddingLeft: `calc(${euiTheme.size.m} + ${euiTheme.size.xxs})` }} + > + {children} + + ); +}; + +const DiffOptionSwitch = ({ + label, + description, + checked, + disabled, + onChange, + ['data-test-subj']: dataTestSubj, + itemCss, +}: Pick & { + description?: string; + disabled?: boolean; + ['data-test-subj']: string; + itemCss?: EuiContextMenuItemProps['css']; +}) => { + return ( + + + + + + {description && ( + + + + )} + + + ); +}; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.test.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.test.tsx new file mode 100644 index 00000000000000..4709c8daaf29ea --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { renderComparisonToolbar } from './comparison_toolbar'; + +const renderToolbar = ({ + hasRoomForGridControls = true, + totalFields = 2, +}: { + hasRoomForGridControls?: boolean; + totalFields?: number; +} = {}) => { + const comparisonFields = ['field1', 'field2']; + const ComparisonToolbar = renderComparisonToolbar({ + additionalControls:
additionalControls
, + comparisonFields, + totalFields, + }); + render( + keyboardShortcutsControl + } + fullScreenControl={
fullScreenControl
} + columnControl={
columnControl
} + columnSortingControl={
columnSortingControl
} + displayControl={
displayControl
} + /> + ); + return { + getAdditionalControls: () => screen.queryByTestId('additionalControls'), + getKeyboardShortcutsControl: () => screen.queryByTestId('keyboardShortcutsControl'), + getFullScreenControl: () => screen.queryByTestId('fullScreenControl'), + getDisplayControl: () => screen.queryByTestId('displayControl'), + getColumnControl: () => screen.queryByTestId('columnControl'), + getColumnSortingControl: () => screen.queryByTestId('columnSortingControl'), + getComparisonLimitCallout: () => + screen.queryByText( + `Comparison is limited to ${comparisonFields.length} of ${totalFields} fields.` + ), + }; +}; + +describe('renderComparisonToolbar', () => { + it('should render the toolbar', () => { + const result = renderToolbar(); + expect(result.getAdditionalControls()).toBeInTheDocument(); + expect(result.getKeyboardShortcutsControl()).toBeInTheDocument(); + expect(result.getFullScreenControl()).toBeInTheDocument(); + expect(result.getDisplayControl()).toBeInTheDocument(); + expect(result.getColumnControl()).toBeInTheDocument(); + expect(result.getColumnSortingControl()).toBeInTheDocument(); + expect(result.getComparisonLimitCallout()).not.toBeInTheDocument(); + cleanup(); + const result2 = renderToolbar({ hasRoomForGridControls: false }); + expect(result2.getAdditionalControls()).toBeInTheDocument(); + expect(result2.getKeyboardShortcutsControl()).toBeInTheDocument(); + expect(result2.getFullScreenControl()).toBeInTheDocument(); + expect(result2.getDisplayControl()).toBeInTheDocument(); + expect(result2.getColumnControl()).not.toBeInTheDocument(); + expect(result2.getColumnSortingControl()).not.toBeInTheDocument(); + expect(result.getComparisonLimitCallout()).not.toBeInTheDocument(); + }); + + it('should render comparison limited callout when totalFields is greater than comparisonFields', () => { + const result = renderToolbar({ totalFields: 3 }); + expect(result.getComparisonLimitCallout()).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.tsx new file mode 100644 index 00000000000000..da6d591d65db0d --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/comparison_toolbar.tsx @@ -0,0 +1,44 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { EuiCallOut, EuiDataGridCustomToolbarProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { ReactElement } from 'react'; +import { internalRenderCustomToolbar } from '../custom_toolbar/render_custom_toolbar'; + +export interface ComparisonToolbarProps { + additionalControls: ReactElement; + comparisonFields: string[]; + totalFields: number; +} + +export const renderComparisonToolbar = ({ + additionalControls, + comparisonFields, + totalFields, +}: ComparisonToolbarProps) => { + return (toolbarProps: EuiDataGridCustomToolbarProps) => { + return internalRenderCustomToolbar({ + leftSide: additionalControls, + toolbarProps, + gridProps: {}, + bottomSection: + totalFields > comparisonFields.length ? ( + + ) : undefined, + }); + }; +}; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_cell_value.test.tsx.snap b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_cell_value.test.tsx.snap new file mode 100644 index 00000000000000..84e37f5f515aa2 --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_cell_value.test.tsx.snap @@ -0,0 +1,449 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useComparisonCellValue should render cells with diff mode "Chars" 1`] = ` +
+ + This is a message val + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Chars" 2`] = ` +
+ + + This + + + one + + + is a + + + different + + + m + + + e + + + s + + + sa + + + g + + + e + + + val + + + ue + + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Chars" 3`] = ` +
+ + + This is a message val + + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Full value" 1`] = ` +
+ + This is a message val + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Full value" 2`] = ` +
+ + This one is a different msg value + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Full value" 3`] = ` +
+ + This is a message val + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Lines" 1`] = ` +
+ + [ + "gif", + "png" +] + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Lines" 2`] = ` +
+ +
+ [ + +
+
+ "gif", + +
+
+ "png", + +
+
+ "jpg" + +
+
+ ] +
+
+
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Lines" 3`] = ` +
+ +
+ [ + "gif", + "png" +] +
+
+
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Words" 1`] = ` +
+ + This is a message val + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Words" 2`] = ` +
+ + + This + + + one + + + is a + + + message + + + different + + + + + + val + + + msg value + + +
+`; + +exports[`useComparisonCellValue should render cells with diff mode "Words" 3`] = ` +
+ + + This is a message val + + +
+`; + +exports[`useComparisonCellValue should render cells with no diff mode 1`] = ` +
+ + This is a message val + +
+`; + +exports[`useComparisonCellValue should render cells with no diff mode 2`] = ` +
+ + This one is a different msg value + +
+`; + +exports[`useComparisonCellValue should render cells with no diff mode 3`] = ` +
+ + This is a message val + +
+`; + +exports[`useComparisonCellValue should render exmpty cell if doc is not found 1`] = ` +
+ + - + +
+`; + +exports[`useComparisonCellValue should render field cells 1`] = ` +
+
+
+ + + string + + +
+
+
+ message +
+
+
+
+`; + +exports[`useComparisonCellValue should render field cells 2`] = ` +
+
+
+ + + string + + +
+
+
+ extension +
+
+
+
+`; + +exports[`useComparisonCellValue should render field cells 3`] = ` +
+
+
+ + + number + + +
+
+
+ bytes +
+
+
+
+`; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_css.test.ts.snap b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_css.test.ts.snap new file mode 100644 index 00000000000000..123b4fe7f23c83 --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/__snapshots__/use_comparison_css.test.ts.snap @@ -0,0 +1,473 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useComparisonCss should render with basic diff mode and diff decorations 1`] = ` +Object { + "map": undefined, + "name": "1qc86qa", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + .unifiedDataTable__comparisonMatchCell { + .unifiedDataTable__cellValue { + &, + & * { + color: #007871 !important; + } + } + } + + .unifiedDataTable__comparisonDiffCell { + .unifiedDataTable__cellValue { + &, + & * { + color: #bd271e !important; + } + } + } + ; + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + + + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with basic diff mode and no diff decorations 1`] = ` +Object { + "map": undefined, + "name": "1qc86qa", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + .unifiedDataTable__comparisonMatchCell { + .unifiedDataTable__cellValue { + &, + & * { + color: #007871 !important; + } + } + } + + .unifiedDataTable__comparisonDiffCell { + .unifiedDataTable__cellValue { + &, + & * { + color: #bd271e !important; + } + } + } + ; + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + + + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with chars diff mode and diff decorations 1`] = ` +Object { + "map": undefined, + "name": "vd39me", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + .unifiedDataTable__comparisonAddedSegment { + text-decoration: underline; + } + + .unifiedDataTable__comparisonRemovedSegment { + text-decoration: line-through; + } + ; + + + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with chars diff mode and no diff decorations 1`] = ` +Object { + "map": undefined, + "name": "1ylxgdl", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + + + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with lines diff mode and diff decorations 1`] = ` +Object { + "map": undefined, + "name": "nlhk3s", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + + + .unifiedDataTable__comparisonSegment { + padding-left: calc(4px / 2); + } + + + .unifiedDataTable__comparisonAddedSegment:before { + content: '+'; + + position: absolute; + width: 8px; + height: 100%; + margin-left: calc(-8px - calc(4px / 2)); + text-align: center; + line-height: 1; + font-weight: 500; + ; + background-color: #00BFB3; + color: #F1F4FA; + } + + .unifiedDataTable__comparisonRemovedSegment:before { + content: '-'; + + position: absolute; + width: 8px; + height: 100%; + margin-left: calc(-8px - calc(4px / 2)); + text-align: center; + line-height: 1; + font-weight: 500; + ; + background-color: #ce5d56; + color: #F1F4FA; + } + ; + ; + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with lines diff mode and no diff decorations 1`] = ` +Object { + "map": undefined, + "name": "1mthx0u", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + + + .unifiedDataTable__comparisonSegment { + padding-left: calc(4px / 2); + } + + + ; + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with no diff mode and no diff decorations 1`] = ` +Object { + "map": undefined, + "name": "1ylxgdl", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + + + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with words diff mode and diff decorations 1`] = ` +Object { + "map": undefined, + "name": "vd39me", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + .unifiedDataTable__comparisonAddedSegment { + text-decoration: underline; + } + + .unifiedDataTable__comparisonRemovedSegment { + text-decoration: line-through; + } + ; + + + ", + "toString": [Function], +} +`; + +exports[`useComparisonCss should render with words diff mode and no diff decorations 1`] = ` +Object { + "map": undefined, + "name": "1ylxgdl", + "next": undefined, + "styles": " + .unifiedDataTable__cellValue { + white-space: pre-wrap; + } + + .unifiedDataTable__comparisonFieldName { + font-weight: 600; + } + + .unifiedDataTable__comparisonBaseDocCell { + background-color: rgba(211,218,230,0.2); + } + + + + .unifiedDataTable__comparisonSegment { + position: relative; + } + + .unifiedDataTable__comparisonAddedSegment { + background-color: #e6f9f7; + color: #007871; + } + + .unifiedDataTable__comparisonRemovedSegment { + background-color: #f8e9e9; + color: #bd271e; + } + + + + + ", + "toString": [Function], +} +`; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.test.ts b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.test.ts new file mode 100644 index 00000000000000..55c7437bfb6af5 --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.test.ts @@ -0,0 +1,298 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { calculateDiff, formatDiffValue } from './calculate_diff'; + +describe('calculateDiff', () => { + const baseValue = ['This is a message val']; + const comparisonValue = ['This one is a different msg value']; + const baseValueJson = ['gif', 'png']; + const comparisonValueJson = ['png', 'jpg']; + + it('should return diffChars when diffMode is chars', () => { + const result = calculateDiff({ diffMode: 'chars', baseValue, comparisonValue }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 5, + "value": "This ", + }, + Object { + "added": true, + "count": 4, + "removed": undefined, + "value": "one ", + }, + Object { + "count": 5, + "value": "is a ", + }, + Object { + "added": true, + "count": 10, + "removed": undefined, + "value": "different ", + }, + Object { + "count": 1, + "value": "m", + }, + Object { + "added": undefined, + "count": 1, + "removed": true, + "value": "e", + }, + Object { + "count": 1, + "value": "s", + }, + Object { + "added": undefined, + "count": 2, + "removed": true, + "value": "sa", + }, + Object { + "count": 1, + "value": "g", + }, + Object { + "added": undefined, + "count": 1, + "removed": true, + "value": "e", + }, + Object { + "count": 4, + "value": " val", + }, + Object { + "added": true, + "count": 2, + "removed": undefined, + "value": "ue", + }, + ] + `); + }); + + it('should return diffWords when diffMode is words', () => { + const result = calculateDiff({ diffMode: 'words', baseValue, comparisonValue }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 2, + "value": "This ", + }, + Object { + "added": true, + "count": 2, + "removed": undefined, + "value": "one ", + }, + Object { + "count": 4, + "value": "is a ", + }, + Object { + "added": undefined, + "count": 1, + "removed": true, + "value": "message", + }, + Object { + "added": true, + "count": 1, + "removed": undefined, + "value": "different", + }, + Object { + "count": 1, + "value": " ", + }, + Object { + "added": undefined, + "count": 1, + "removed": true, + "value": "val", + }, + Object { + "added": true, + "count": 3, + "removed": undefined, + "value": "msg value", + }, + ] + `); + }); + + it('should return diffLines when diffMode is lines', () => { + const result = calculateDiff({ diffMode: 'lines', baseValue, comparisonValue }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "added": undefined, + "count": 1, + "removed": true, + "value": "This is a message val", + }, + Object { + "added": true, + "count": 1, + "removed": undefined, + "value": "This one is a different msg value", + }, + ] + `); + }); + + it('should return diffJson when diffMode is lines and values are json', () => { + const result = calculateDiff({ + diffMode: 'lines', + baseValue: baseValueJson, + comparisonValue: comparisonValueJson, + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 1, + "value": "[ + ", + }, + Object { + "added": undefined, + "count": 1, + "removed": true, + "value": " \\"gif\\", + ", + }, + Object { + "count": 1, + "value": " \\"png\\", + ", + }, + Object { + "added": true, + "count": 1, + "removed": undefined, + "value": " \\"jpg\\" + ", + }, + Object { + "count": 1, + "value": "]", + }, + ] + `); + }); + + it('should force json when comparing a single value to multiple values', () => { + const diffMode = 'lines'; + const result = calculateDiff({ + diffMode, + baseValue: ['single value'], + comparisonValue: ['multiple', 'values'], + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 1, + "value": "[ + ", + }, + Object { + "added": undefined, + "count": 1, + "removed": true, + "value": " \\"single value\\" + ", + }, + Object { + "added": true, + "count": 2, + "removed": undefined, + "value": " \\"multiple\\", + \\"values\\" + ", + }, + Object { + "count": 1, + "value": "]", + }, + ] + `); + const result2 = calculateDiff({ + diffMode, + baseValue: ['multiple', 'values'], + comparisonValue: ['single value'], + }); + expect(result2).toMatchInlineSnapshot(` + Array [ + Object { + "count": 1, + "value": "[ + ", + }, + Object { + "added": undefined, + "count": 2, + "removed": true, + "value": " \\"multiple\\", + \\"values\\" + ", + }, + Object { + "added": true, + "count": 1, + "removed": undefined, + "value": " \\"single value\\" + ", + }, + Object { + "count": 1, + "value": "]", + }, + ] + `); + }); +}); + +describe('formatDiffValue', () => { + it('should return a JSON stringified value when value is an object', () => { + const result = formatDiffValue({ key: 'value' }, false); + expect(result).toEqual({ value: '{\n "key": "value"\n}', isJson: true }); + }); + + it('should return a stringified value when value is not an object', () => { + const result = formatDiffValue(42, false); + expect(result).toEqual({ value: '42', isJson: false }); + }); + + it('should return an empty string when value is null', () => { + const result = formatDiffValue(null, false); + expect(result).toEqual({ value: '', isJson: false }); + }); + + it('should return an empty string when value is undefined', () => { + const result = formatDiffValue(undefined, false); + expect(result).toEqual({ value: '', isJson: false }); + }); + + it('should extract the first entry when value is an array with a single entry', () => { + const value = ['gif']; + const result = formatDiffValue(value, false); + expect(result).toEqual({ value: 'gif', isJson: false }); + }); + + it('should return a JSON stringified value when forceJson is true', () => { + const value = ['gif']; + const result = formatDiffValue(value, true); + expect(result).toEqual({ value: '[\n "gif"\n]', isJson: true }); + }); +}); diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.ts b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.ts new file mode 100644 index 00000000000000..3c94da1e18f015 --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/calculate_diff.ts @@ -0,0 +1,60 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { diffChars, diffJson, diffLines, diffWords } from 'diff'; +import type { DocumentDiffMode } from '../types'; + +export interface CalculateDiffProps { + diffMode: Exclude; + baseValue: unknown; + comparisonValue: unknown; +} + +export const calculateDiff = ({ diffMode, baseValue, comparisonValue }: CalculateDiffProps) => { + const forceJson = + baseValue != null && + comparisonValue != null && + ((hasLengthOne(baseValue) && !hasLengthOne(comparisonValue)) || + (!hasLengthOne(baseValue) && hasLengthOne(comparisonValue))); + + const { value: formattedBaseValue, isJson: baseValueIsJson } = formatDiffValue( + baseValue, + forceJson + ); + + const { value: formattedComparisonValue, isJson: comparisonValueIsJson } = formatDiffValue( + comparisonValue, + forceJson + ); + + if (diffMode === 'chars') { + return diffChars(formattedBaseValue, formattedComparisonValue); + } + + if (diffMode === 'words') { + return diffWords(formattedBaseValue, formattedComparisonValue, { ignoreWhitespace: false }); + } + + return baseValueIsJson && comparisonValueIsJson + ? diffJson(formattedBaseValue, formattedComparisonValue, { ignoreWhitespace: false }) + : diffLines(formattedBaseValue, formattedComparisonValue, { ignoreWhitespace: false }); +}; + +export const formatDiffValue = (value: unknown, forceJson: boolean) => { + const extractedValue = !forceJson && hasLengthOne(value) ? value[0] : value; + + if (value != null && (forceJson || typeof extractedValue === 'object')) { + return { value: JSON.stringify(extractedValue, null, 2), isJson: true }; + } + + return { value: String(extractedValue ?? ''), isJson: false }; +}; + +const hasLengthOne = (value: unknown): value is unknown[] => { + return Array.isArray(value) && value.length === 1; +}; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.test.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.test.tsx new file mode 100644 index 00000000000000..7ae6b705ae9fbb --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.test.tsx @@ -0,0 +1,446 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { EuiDataGridCellValueElementProps, EuiDataGridSetCellProps } from '@elastic/eui'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { generateEsHits } from '@kbn/discover-utils/src/__mocks__'; +import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks'; +import { render, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { ReactNode, useState } from 'react'; +import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield'; +import { useComparisonCellValue, UseComparisonCellValueProps } from './use_comparison_cell_value'; +import { CELL_CLASS } from '../../../utils/get_render_cell_value'; +import { + ADDED_SEGMENT_CLASS, + BASE_CELL_CLASS, + DIFF_CELL_CLASS, + FIELD_NAME_CLASS, + MATCH_CELL_CLASS, + REMOVED_SEGMENT_CLASS, + SEGMENT_CLASS, +} from './use_comparison_css'; +import * as CalculateDiff from './calculate_diff'; + +const calculateDiff = jest.spyOn(CalculateDiff, 'calculateDiff'); + +const docs = generateEsHits(dataViewWithTimefieldMock, 3).map((hit, i) => { + switch (i) { + case 0: + case 2: + hit.fields!.message = ['This is a message val']; + hit.fields!.extension = ['gif', 'png']; + break; + case 1: + hit.fields!.message = ['This one is a different msg value']; + hit.fields!.extension = ['png', 'jpg']; + break; + } + + return buildDataTableRecord(hit, dataViewWithTimefieldMock); +}); + +const getDocById = (id: string) => docs.find((doc) => doc.raw._id === id); + +const fieldColumnId = 'fieldColumnId'; + +const renderComparisonCellValue = (props: Partial = {}) => { + const defaultProps: UseComparisonCellValueProps = { + dataView: dataViewWithTimefieldMock, + comparisonFields: ['message', 'extension', 'bytes'], + fieldColumnId, + selectedDocs: ['0', '1', '2'], + diffMode: undefined, + fieldFormats: fieldFormatsMock, + getDocById, + ...props, + }; + const hook = renderHook((currentProps) => useComparisonCellValue(currentProps), { + initialProps: defaultProps, + }); + return { + rerender: (newProps: Partial) => { + hook.rerender({ ...defaultProps, ...newProps }); + }, + renderCellValue: (cellValueProps: EuiDataGridCellValueElementProps) => { + return hook.result.current(cellValueProps); + }, + }; +}; + +const ComparisonCell = ({ + columnId, + colIndex, + rowIndex, + renderCellValue, +}: { + columnId: string; + colIndex: number; + rowIndex: number; + renderCellValue: (innerProps: EuiDataGridCellValueElementProps) => ReactNode; +}) => { + const [cellProps, setCellProps] = useState(); + return ( +
+ {renderCellValue({ + columnId, + colIndex, + rowIndex, + isExpandable: false, + isExpanded: false, + isDetails: false, + setCellProps, + })} +
+ ); +}; + +const renderComparisonCell = ({ + columnId, + colIndex, + rowIndex, + renderCellValue, +}: Parameters[0]) => { + render( + + ); + const getCell = () => screen.getByTestId(`${columnId}_${colIndex}_${rowIndex}`); + return { + getCell, + getCellValue: () => getCell().querySelector(`.${CELL_CLASS}`), + getAllSegments: () => getCell().querySelectorAll(`.${SEGMENT_CLASS}`), + getAddedSegments: () => getCell().querySelectorAll(`.${ADDED_SEGMENT_CLASS}`), + getRemovedSegments: () => getCell().querySelectorAll(`.${REMOVED_SEGMENT_CLASS}`), + }; +}; + +describe('useComparisonCellValue', () => { + it('should render field cells', () => { + const { renderCellValue } = renderComparisonCellValue(); + const messageCell = renderComparisonCell({ + columnId: fieldColumnId, + colIndex: 0, + rowIndex: 0, + renderCellValue, + }); + const messageElement = screen.getByText('message'); + expect(messageElement).toBeInTheDocument(); + expect(messageElement).toHaveClass(FIELD_NAME_CLASS); + expect(messageCell.getCell()).toMatchSnapshot(); + const extensionCell = renderComparisonCell({ + columnId: fieldColumnId, + colIndex: 0, + rowIndex: 1, + renderCellValue, + }); + const extensionElement = screen.getByText('extension'); + expect(extensionElement).toBeInTheDocument(); + expect(extensionElement).toHaveClass(FIELD_NAME_CLASS); + expect(extensionCell.getCell()).toMatchSnapshot(); + const bytesCell = renderComparisonCell({ + columnId: fieldColumnId, + colIndex: 0, + rowIndex: 2, + renderCellValue, + }); + const bytesElement = screen.getByText('bytes'); + expect(bytesElement).toBeInTheDocument(); + expect(bytesElement).toHaveClass(FIELD_NAME_CLASS); + expect(bytesCell.getCell()).toMatchSnapshot(); + }); + + it('should render exmpty cell if doc is not found', () => { + const { renderCellValue } = renderComparisonCellValue(); + const emptyCell = renderComparisonCell({ + columnId: 'unknown', + colIndex: 1, + rowIndex: 0, + renderCellValue, + }); + expect(emptyCell.getCellValue()).toBeInTheDocument(); + expect(emptyCell.getCell()).toMatchSnapshot(); + }); + + it('should render cells with no diff mode', () => { + const { renderCellValue } = renderComparisonCellValue(); + const baseCell = renderComparisonCell({ + columnId: '0', + colIndex: 1, + rowIndex: 0, + renderCellValue, + }); + expect(baseCell.getCellValue()).toBeInTheDocument(); + expect(baseCell.getCell()).toHaveClass(BASE_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(baseCell.getAllSegments()).toHaveLength(0); + expect(baseCell.getCell()).toMatchSnapshot(); + const comparisonCell1 = renderComparisonCell({ + columnId: '1', + colIndex: 2, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell1.getCellValue()).toBeInTheDocument(); + expect(comparisonCell1.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell1.getAllSegments()).toHaveLength(0); + expect(comparisonCell1.getCell()).toMatchSnapshot(); + const comparisonCell2 = renderComparisonCell({ + columnId: '2', + colIndex: 3, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell2.getCellValue()).toBeInTheDocument(); + expect(comparisonCell2.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell2.getAllSegments()).toHaveLength(0); + expect(comparisonCell2.getCell()).toMatchSnapshot(); + }); + + it('should render cells with diff mode "Full value"', () => { + const { renderCellValue } = renderComparisonCellValue({ diffMode: 'basic' }); + const baseCell = renderComparisonCell({ + columnId: '0', + colIndex: 1, + rowIndex: 0, + renderCellValue, + }); + expect(baseCell.getCellValue()).toBeInTheDocument(); + expect(baseCell.getCell()).toHaveClass(BASE_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(baseCell.getAllSegments()).toHaveLength(0); + expect(baseCell.getCell()).toMatchSnapshot(); + const comparisonCell1 = renderComparisonCell({ + columnId: '1', + colIndex: 2, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell1.getCellValue()).toBeInTheDocument(); + expect(comparisonCell1.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell1.getCell()).toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell1.getAllSegments()).toHaveLength(0); + expect(comparisonCell1.getCell()).toMatchSnapshot(); + const comparisonCell2 = renderComparisonCell({ + columnId: '2', + colIndex: 3, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell2.getCellValue()).toBeInTheDocument(); + expect(comparisonCell2.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell2.getCell()).toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell2.getAllSegments()).toHaveLength(0); + expect(comparisonCell2.getCell()).toMatchSnapshot(); + }); + + it('should render cells with diff mode "Chars"', () => { + const { renderCellValue } = renderComparisonCellValue({ diffMode: 'chars' }); + const baseCell = renderComparisonCell({ + columnId: '0', + colIndex: 1, + rowIndex: 0, + renderCellValue, + }); + expect(baseCell.getCellValue()).toBeInTheDocument(); + expect(baseCell.getCell()).toHaveClass(BASE_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(baseCell.getAllSegments()).toHaveLength(0); + expect(baseCell.getCell()).toMatchSnapshot(); + const comparisonCell1 = renderComparisonCell({ + columnId: '1', + colIndex: 2, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell1.getCellValue()).toBeInTheDocument(); + expect(comparisonCell1.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell1.getAllSegments()).toHaveLength(12); + expect(comparisonCell1.getAddedSegments()).toHaveLength(3); + expect(comparisonCell1.getRemovedSegments()).toHaveLength(3); + expect(comparisonCell1.getCell()).toMatchSnapshot(); + const comparisonCell2 = renderComparisonCell({ + columnId: '2', + colIndex: 3, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell2.getCellValue()).toBeInTheDocument(); + expect(comparisonCell2.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell2.getAllSegments()).toHaveLength(1); + expect(comparisonCell2.getAddedSegments()).toHaveLength(0); + expect(comparisonCell2.getRemovedSegments()).toHaveLength(0); + expect(comparisonCell2.getCell()).toMatchSnapshot(); + }); + + it('should render cells with diff mode "Words"', () => { + const { renderCellValue } = renderComparisonCellValue({ diffMode: 'words' }); + const baseCell = renderComparisonCell({ + columnId: '0', + colIndex: 1, + rowIndex: 0, + renderCellValue, + }); + expect(baseCell.getCellValue()).toBeInTheDocument(); + expect(baseCell.getCell()).toHaveClass(BASE_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(baseCell.getAllSegments()).toHaveLength(0); + expect(baseCell.getCell()).toMatchSnapshot(); + const comparisonCell1 = renderComparisonCell({ + columnId: '1', + colIndex: 2, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell1.getCellValue()).toBeInTheDocument(); + expect(comparisonCell1.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell1.getAllSegments()).toHaveLength(8); + expect(comparisonCell1.getAddedSegments()).toHaveLength(3); + expect(comparisonCell1.getRemovedSegments()).toHaveLength(2); + expect(comparisonCell1.getCell()).toMatchSnapshot(); + const comparisonCell2 = renderComparisonCell({ + columnId: '2', + colIndex: 3, + rowIndex: 0, + renderCellValue, + }); + expect(comparisonCell2.getCellValue()).toBeInTheDocument(); + expect(comparisonCell2.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell2.getAllSegments()).toHaveLength(1); + expect(comparisonCell2.getAddedSegments()).toHaveLength(0); + expect(comparisonCell2.getRemovedSegments()).toHaveLength(0); + expect(comparisonCell2.getCell()).toMatchSnapshot(); + }); + + it('should render cells with diff mode "Lines"', () => { + const { renderCellValue } = renderComparisonCellValue({ diffMode: 'lines' }); + const baseCell = renderComparisonCell({ + columnId: '0', + colIndex: 1, + rowIndex: 1, + renderCellValue, + }); + expect(baseCell.getCellValue()).toBeInTheDocument(); + expect(baseCell.getCell()).toHaveClass(BASE_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(baseCell.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(baseCell.getAllSegments()).toHaveLength(0); + expect(baseCell.getCell()).toMatchSnapshot(); + const comparisonCell1 = renderComparisonCell({ + columnId: '1', + colIndex: 2, + rowIndex: 1, + renderCellValue, + }); + expect(comparisonCell1.getCellValue()).toBeInTheDocument(); + expect(comparisonCell1.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell1.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell1.getAllSegments()).toHaveLength(5); + expect(comparisonCell1.getAddedSegments()).toHaveLength(1); + expect(comparisonCell1.getRemovedSegments()).toHaveLength(1); + expect(comparisonCell1.getCell()).toMatchSnapshot(); + const comparisonCell2 = renderComparisonCell({ + columnId: '2', + colIndex: 3, + rowIndex: 1, + renderCellValue, + }); + expect(comparisonCell2.getCellValue()).toBeInTheDocument(); + expect(comparisonCell2.getCell()).not.toHaveClass(BASE_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(MATCH_CELL_CLASS); + expect(comparisonCell2.getCell()).not.toHaveClass(DIFF_CELL_CLASS); + expect(comparisonCell2.getAllSegments()).toHaveLength(1); + expect(comparisonCell2.getAddedSegments()).toHaveLength(0); + expect(comparisonCell2.getRemovedSegments()).toHaveLength(0); + expect(comparisonCell2.getCell()).toMatchSnapshot(); + }); + + it('should not recalculate diffs for advanced modes when remounting the same cell', () => { + calculateDiff.mockClear(); + expect(calculateDiff).not.toHaveBeenCalled(); + const { rerender, renderCellValue } = renderComparisonCellValue({ diffMode: 'chars' }); + const cellProps1 = { + columnId: '1', + colIndex: 2, + rowIndex: 1, + renderCellValue, + }; + const cellProps2 = { + columnId: '2', + colIndex: 3, + rowIndex: 1, + renderCellValue, + }; + renderComparisonCell(cellProps1); + expect(calculateDiff).toHaveBeenCalledTimes(1); + renderComparisonCell(cellProps2); + expect(calculateDiff).toHaveBeenCalledTimes(2); + renderComparisonCell(cellProps1); + expect(calculateDiff).toHaveBeenCalledTimes(2); + renderComparisonCell(cellProps2); + expect(calculateDiff).toHaveBeenCalledTimes(2); + rerender({ diffMode: 'words', selectedDocs: ['1', '2', '0'] }); + const cellProps3 = { + ...cellProps1, + columnId: '2', + }; + const cellProps4 = { + ...cellProps2, + columnId: '0', + }; + renderComparisonCell(cellProps3); + expect(calculateDiff).toHaveBeenCalledTimes(3); + renderComparisonCell(cellProps4); + expect(calculateDiff).toHaveBeenCalledTimes(4); + renderComparisonCell(cellProps3); + expect(calculateDiff).toHaveBeenCalledTimes(4); + renderComparisonCell(cellProps4); + expect(calculateDiff).toHaveBeenCalledTimes(4); + rerender({ diffMode: 'lines', selectedDocs: ['2', '0', '1'] }); + const cellProps5 = { + ...cellProps1, + columnId: '0', + }; + const cellProps6 = { + ...cellProps2, + columnId: '1', + }; + renderComparisonCell(cellProps5); + expect(calculateDiff).toHaveBeenCalledTimes(5); + renderComparisonCell(cellProps6); + expect(calculateDiff).toHaveBeenCalledTimes(6); + renderComparisonCell(cellProps5); + expect(calculateDiff).toHaveBeenCalledTimes(6); + renderComparisonCell(cellProps6); + expect(calculateDiff).toHaveBeenCalledTimes(6); + }); +}); diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.tsx new file mode 100644 index 00000000000000..98fae03a0ace35 --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.tsx @@ -0,0 +1,251 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { EuiDataGridCellValueElementProps, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { formatFieldValue } from '@kbn/discover-utils'; +import type { DataTableRecord } from '@kbn/discover-utils/types'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import { getFieldIconProps } from '@kbn/field-utils'; +import { FieldIcon } from '@kbn/react-field'; +import classNames from 'classnames'; +import { isEqual, memoize } from 'lodash'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { CELL_CLASS } from '../../../utils/get_render_cell_value'; +import type { DocumentDiffMode } from '../types'; +import { calculateDiff, CalculateDiffProps, formatDiffValue } from './calculate_diff'; +import { + ADDED_SEGMENT_CLASS, + BASE_CELL_CLASS, + DIFF_CELL_CLASS, + FIELD_NAME_CLASS, + MATCH_CELL_CLASS, + REMOVED_SEGMENT_CLASS, + SEGMENT_CLASS, +} from './use_comparison_css'; + +export interface UseComparisonCellValueProps { + dataView: DataView; + comparisonFields: string[]; + fieldColumnId: string; + selectedDocs: string[]; + diffMode: DocumentDiffMode | undefined; + fieldFormats: FieldFormatsStart; + getDocById: (id: string) => DataTableRecord | undefined; +} + +export const useComparisonCellValue = ({ + dataView, + comparisonFields, + fieldColumnId, + selectedDocs, + diffMode, + fieldFormats, + getDocById, +}: UseComparisonCellValueProps) => { + const baseDocId = selectedDocs[0]; + const baseDoc = useMemo(() => getDocById(baseDocId)?.flattened, [baseDocId, getDocById]); + const [calculateDiffMemoized] = useState(() => createCalculateDiffMemoized()); + + return useCallback( + (props: EuiDataGridCellValueElementProps) => ( + + + + ), + [ + baseDoc, + baseDocId, + calculateDiffMemoized, + comparisonFields, + dataView, + diffMode, + fieldColumnId, + fieldFormats, + getDocById, + ] + ); +}; + +type CellValueProps = Omit & + EuiDataGridCellValueElementProps & { + baseDocId: string; + baseDoc: DataTableRecord['flattened'] | undefined; + }; + +const EMPTY_VALUE = '-'; + +const CellValue = (props: CellValueProps) => { + const { dataView, comparisonFields, fieldColumnId, rowIndex, columnId, getDocById } = props; + const fieldName = comparisonFields[rowIndex]; + const field = useMemo(() => dataView.fields.getByName(fieldName), [dataView.fields, fieldName]); + const comparisonDoc = useMemo(() => getDocById(columnId), [columnId, getDocById]); + + if (columnId === fieldColumnId) { + return ; + } + + if (!comparisonDoc) { + return {EMPTY_VALUE}; + } + + return ( + + ); +}; + +interface FieldCellValueProps { + field: DataViewField | undefined; + fieldName: string; +} + +const FieldCellValue = ({ field, fieldName }: FieldCellValueProps) => { + return ( + + {field && ( + + + + )} + + + {field?.displayName ?? fieldName} + + + + ); +}; + +type DiffCellValueProps = CellValueProps & + FieldCellValueProps & { + comparisonDoc: DataTableRecord; + }; + +const DiffCellValue = ({ + dataView, + field, + fieldName, + baseDocId, + baseDoc, + comparisonDoc, + diffMode, + columnId, + fieldFormats, + setCellProps, +}: DiffCellValueProps) => { + const baseValue = baseDoc?.[fieldName]; + const comparisonValue = comparisonDoc?.flattened[fieldName]; + const isBaseDoc = columnId === baseDocId; + const formattedBaseValue = useMemo( + () => (isBaseDoc ? formatDiffValue(baseValue, false).value : undefined), + [baseValue, isBaseDoc] + ); + + useEffect(() => { + if (isBaseDoc) { + setCellProps({ className: BASE_CELL_CLASS }); + } else if (diffMode !== 'basic') { + setCellProps({ className: undefined }); + } else if (isEqual(baseValue, comparisonValue)) { + setCellProps({ className: MATCH_CELL_CLASS }); + } else { + setCellProps({ className: DIFF_CELL_CLASS }); + } + }, [baseValue, columnId, comparisonValue, baseDocId, diffMode, setCellProps, isBaseDoc]); + + if (!diffMode || diffMode === 'basic') { + return ( + + ); + } + + if (formattedBaseValue) { + return {formattedBaseValue || EMPTY_VALUE}; + } + + return ( + + ); +}; + +const DiffCellValueAdvanced = ({ diffMode, ...props }: CalculateDiffProps) => { + const diff = useDiff({ diffMode, ...props }); + const SegmentTag = diffMode === 'lines' ? 'div' : 'span'; + + return ( + + {diff.map((change, i) => ( + + {change.value || EMPTY_VALUE} + + ))} + + ); +}; + +// EuiDataGrid remounts cells often due to virtualization, e.g. on init to calculate cell sizes +// and while scrolling, so React memoization is not effective here. Instead we memoize the diff +// results in the comparison to avoid recalculating them frequently. +const createCalculateDiffMemoized = (): typeof calculateDiff => { + const calculateDiffMemoized = memoize((diffMode: CalculateDiffProps['diffMode']) => { + return memoize((baseValue: CalculateDiffProps['baseValue']) => { + return memoize((comparisonValue: CalculateDiffProps['comparisonValue']) => { + return calculateDiff({ diffMode, baseValue, comparisonValue }); + }); + }); + }); + + return ({ diffMode, baseValue, comparisonValue }: CalculateDiffProps) => { + return calculateDiffMemoized(diffMode)(baseValue)(comparisonValue); + }; +}; + +const DiffContext = createContext(calculateDiff); +const DiffProvider = DiffContext.Provider; + +const useDiff = (props: CalculateDiffProps) => { + const calculateDiffMemoized = useContext(DiffContext); + return calculateDiffMemoized(props); +}; diff --git a/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_columns.test.tsx b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_columns.test.tsx new file mode 100644 index 00000000000000..c6df0f7fabcb6e --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_columns.test.tsx @@ -0,0 +1,230 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { + DEFAULT_COLUMN_WIDTH, + FIELD_COLUMN_NAME, + FIELD_COLUMN_WIDTH, + useComparisonColumns, +} from './use_comparison_columns'; +import { renderHook } from '@testing-library/react-hooks'; +import type { EuiDataGridColumn, EuiDataGridColumnActions } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { generateEsHits } from '@kbn/discover-utils/src/__mocks__'; +import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import type { DataTableRecord } from '@kbn/discover-utils/types'; + +type DataGridColumn = Partial> & + Pick & { + actions?: Partial>; + }; + +const getComparisonColumn = ({ + column, + includePinAction, + includeRemoveAction, +}: { + column: DataGridColumn; + includePinAction?: boolean; + includeRemoveAction?: boolean; +}): EuiDataGridColumn => { + const additional: EuiDataGridColumnActions['additional'] = []; + if (includePinAction) { + additional.push({ + iconType: 'pin', + label: 'Pin for comparison', + size: 'xs', + onClick: expect.any(Function), + }); + } + if (includeRemoveAction) { + additional.push({ + iconType: 'cross', + label: 'Remove from comparison', + size: 'xs', + onClick: expect.any(Function), + }); + } + return { + display: undefined, + initialWidth: DEFAULT_COLUMN_WIDTH, + isSortable: false, + isExpandable: false, + ...column, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: false, + showSortDesc: false, + ...column.actions, + additional, + }, + }; +}; + +const docs = generateEsHits(dataViewWithTimefieldMock, 4).map((hit) => + buildDataTableRecord(hit, dataViewWithTimefieldMock) +); + +const defaultGetDocById = (id: string) => docs.find((doc) => doc.raw._id === id); + +const fieldColumnId = 'fieldColumnId'; +const selectedDocs = ['0', '1', '2', '3']; + +const renderColumns = ({ + wrapperWidth, + isPlainRecord = false, + getDocById = defaultGetDocById, +}: { + wrapperWidth?: number; + isPlainRecord?: boolean; + getDocById?: (id: string) => DataTableRecord | undefined; +} = {}) => { + const wrapper = document.createElement('div'); + if (wrapperWidth) { + Object.defineProperty(wrapper, 'offsetWidth', { value: wrapperWidth }); + } + const setSelectedDocs = jest.fn(); + const { + result: { current: columns }, + } = renderHook(() => + useComparisonColumns({ + wrapper, + isPlainRecord, + fieldColumnId, + selectedDocs, + getDocById, + setSelectedDocs, + }) + ); + return { columns, setSelectedDocs }; +}; + +describe('useComparisonColumns', () => { + it('should return comparison columns', () => { + const { columns, setSelectedDocs } = renderColumns(); + expect(columns).toEqual([ + { + id: fieldColumnId, + displayAsText: FIELD_COLUMN_NAME, + initialWidth: FIELD_COLUMN_WIDTH, + isSortable: false, + isExpandable: false, + actions: false, + }, + getComparisonColumn({ + column: { + id: selectedDocs[0], + display: expect.anything(), + displayAsText: `Pinned document: ${selectedDocs[0]}`, + }, + includeRemoveAction: true, + }), + getComparisonColumn({ + column: { + id: selectedDocs[1], + display: selectedDocs[1], + displayAsText: `Comparison document: ${selectedDocs[1]}`, + actions: { + showMoveRight: true, + }, + }, + includePinAction: true, + includeRemoveAction: true, + }), + getComparisonColumn({ + column: { + id: selectedDocs[2], + display: selectedDocs[2], + displayAsText: `Comparison document: ${selectedDocs[2]}`, + actions: { + showMoveLeft: true, + showMoveRight: true, + }, + }, + includePinAction: true, + includeRemoveAction: true, + }), + getComparisonColumn({ + column: { + id: selectedDocs[3], + display: selectedDocs[3], + displayAsText: `Comparison document: ${selectedDocs[3]}`, + actions: { + showMoveLeft: true, + }, + }, + includePinAction: true, + includeRemoveAction: true, + }), + ]); + expect(columns[1].display).toMatchInlineSnapshot(` + + + + + + 0 + + + `); + const actions = columns[2].actions as EuiDataGridColumnActions; + const pinAction = actions.additional?.[0].onClick; + const removeAction = actions.additional?.[1].onClick; + render(